├── components
├── icons
│ ├── EnemyIcon.tsx
│ ├── PlayerIcon.tsx
│ └── PhoneIcon.tsx
├── OrientationLock.tsx
├── Player.tsx
├── StatusBar.tsx
├── LogPanel.tsx
├── Enemy.tsx
├── MainMenu.tsx
├── DebugMenu.tsx
├── GameBoard.tsx
└── Game.tsx
├── assets
├── ui.ts
├── actors.ts
└── environment.ts
├── metadata.json
├── .gitignore
├── index.tsx
├── package.json
├── vite.config.ts
├── README.md
├── tsconfig.json
├── constants.ts
├── index.html
├── types.ts
├── hooks
├── useGameInput.ts
└── useDungeonGenerator.ts
├── utils
├── los.ts
├── fov.ts
└── pathfinding.ts
└── App.tsx
/components/icons/EnemyIcon.tsx:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/icons/PlayerIcon.tsx:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/ui.ts:
--------------------------------------------------------------------------------
1 | // URL for the main menu background image
2 | export const MAIN_MENU_BACKGROUND_URL = 'https://i.ibb.co/fzMFYT0x/art.png';
3 |
--------------------------------------------------------------------------------
/metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Copy of Arlade",
3 | "description": "A simple roguelike game built with React and TypeScript. Explore procedurally generated dungeons, fight monsters, and try to survive. Features a main menu, a dungeon generation system, and a turn-based combat and movement system.",
4 | "requestFramePermissions": []
5 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import App from './App';
4 |
5 | const rootElement = document.getElementById('root');
6 | if (!rootElement) {
7 | throw new Error("Could not find root element to mount to");
8 | }
9 |
10 | const root = ReactDOM.createRoot(rootElement);
11 | root.render(
12 |
13 |
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "copy-of-arlade",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "react": "^19.1.1",
13 | "react-dom": "^19.1.1"
14 | },
15 | "devDependencies": {
16 | "@types/node": "^22.14.0",
17 | "@vitejs/plugin-react": "^5.0.0",
18 | "typescript": "~5.8.2",
19 | "vite": "^6.2.0"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { defineConfig, loadEnv } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 |
5 | export default defineConfig(({ mode }) => {
6 | const env = loadEnv(mode, '.', '');
7 | return {
8 | plugins: [react()],
9 | define: {
10 | 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
11 | 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
12 | },
13 | resolve: {
14 | alias: {
15 | '@': path.resolve(__dirname, '.'),
16 | }
17 | }
18 | };
19 | });
20 |
--------------------------------------------------------------------------------
/components/OrientationLock.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PhoneIcon from './icons/PhoneIcon';
3 |
4 | const OrientationLock: React.FC = () => {
5 | return (
6 |
7 |
10 |
Пожалуйста, поверните ваше устройство
11 |
Для лучшего опыта играйте в горизонтальном режиме.
12 |
13 | );
14 | };
15 |
16 | export default OrientationLock;
17 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | # Run and deploy your AI Studio app
6 |
7 | This contains everything you need to run your app locally.
8 |
9 | View your app in AI Studio: https://ai.studio/apps/drive/131tOsuLuYEVXelE18vDh3bA64ydM-o9M
10 |
11 | ## Run Locally
12 |
13 | **Prerequisites:** Node.js
14 |
15 |
16 | 1. Install dependencies:
17 | `npm install`
18 | 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
19 | 3. Run the app:
20 | `npm run dev`
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "experimentalDecorators": true,
5 | "useDefineForClassFields": false,
6 | "module": "ESNext",
7 | "lib": [
8 | "ES2022",
9 | "DOM",
10 | "DOM.Iterable"
11 | ],
12 | "skipLibCheck": true,
13 | "types": [
14 | "node"
15 | ],
16 | "moduleResolution": "bundler",
17 | "isolatedModules": true,
18 | "moduleDetection": "force",
19 | "allowJs": true,
20 | "jsx": "react-jsx",
21 | "paths": {
22 | "@/*": [
23 | "./*"
24 | ]
25 | },
26 | "allowImportingTsExtensions": true,
27 | "noEmit": true
28 | }
29 | }
--------------------------------------------------------------------------------
/constants.ts:
--------------------------------------------------------------------------------
1 | export const PC_TILE_SIZE = 32; // in pixels
2 | export const MOBILE_TILE_SIZE = 16; // in pixels
3 | export const MAP_WIDTH = 64;
4 | export const MAP_HEIGHT = 64;
5 |
6 | // Dungeon generation parameters
7 | export const MIN_ROOMS = 10;
8 | export const MAX_ROOMS = 15;
9 | export const MIN_ROOM_SIZE = 3;
10 | export const MAX_ROOM_SIZE = 15;
11 |
12 | // Gameplay parameters
13 | export const PLAYER_INITIAL_HEALTH = 100;
14 | export const ENEMY_INITIAL_HEALTH = 20;
15 | export const MAX_ENEMIES = 15;
16 | export const PLAYER_ATTACK_POWER = 10;
17 | export const PLAYER_VISION_RADIUS = 6;
18 | export const ENEMY_VISION_RADIUS = 5;
19 | export const ENEMY_PATROL_RADIUS = 5;
--------------------------------------------------------------------------------
/components/Player.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Position } from '../types';
3 | import { PLAYER_SPRITE_BASE64 } from '../assets/actors';
4 |
5 | interface PlayerProps {
6 | pos: Position;
7 | tileSize: number;
8 | }
9 |
10 | const PlayerComponent: React.FC = ({ pos, tileSize }) => {
11 | return (
12 |
21 |

27 |
28 | );
29 | };
30 |
31 | export default PlayerComponent;
--------------------------------------------------------------------------------
/assets/actors.ts:
--------------------------------------------------------------------------------
1 | export const PLAYER_SPRITE_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA2klEQVR4AYyS0RGDMAxDQ3frR0fpRB2lHx2O+umi4IDh6EU4kS3huDza+W+NVEYcj6sykOj7+zSjy+D7dguVgYRbSTucc640oOD1fBOaI90EceiiNEDUBZdvD8NWGSwkMCEauzOdCDbQIYodMVkQGZHzWukOQNhArUKCSNho8MFNe4zhhgGEEQk6kCD2Y5DOE+EDiw0kCIKV95wN+D2mITppwa3oDm4VV0XZwINz1N3TfQefjbKBhtb/BdeMayUeI+enGYhMb0QsLh76JiqTqYMTcei1ZKJdevwBAAD//3EcCnIAAAAGSURBVAMAM5BcGzWEX2UAAAAASUVORK5CYIIA';
2 | export const ENEMY_SPRITE_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAuElEQVR4AaSQ2xGDMAwEnTRFbykgvVEVsBruRiM/+IDhkGydVjbf9vKZAY6LW3Vt9e8IQGP7bZt1t8X+nTtUQJho/u97mIisYzH4VIAtaiICcaEkFfChnhtSHjXqWRVALYw0IjZWAsC9q/wDucIToKtPmhjSeTkBR5ZicnaVawBBtgDwYpFoABGbIUtAmU4jAoLIWweYNHlidKVPBtgEBCUfqessJAFqkSNK8g6jADIrZjN7rBXJrRMAAP//LaCaEQAAAAZJREFUAwD1azkgdrIESAAAAABJRU5ErkJgggAA';
3 |
--------------------------------------------------------------------------------
/components/StatusBar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface StatusBarProps {
4 | playerHealth: number;
5 | maxHealth: number;
6 | }
7 |
8 | const StatusBar: React.FC = ({ playerHealth, maxHealth }) => {
9 | const healthPercentage = Math.max(0, (playerHealth / maxHealth) * 100);
10 |
11 | const getHealthColor = () => {
12 | if (healthPercentage > 60) return 'bg-green-500';
13 | if (healthPercentage > 30) return 'bg-yellow-500';
14 | return 'bg-red-600';
15 | };
16 |
17 | return (
18 |
30 | );
31 | };
32 |
33 | export default StatusBar;
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | React Roguelike Dungeon Crawler
7 |
8 |
9 |
10 |
11 |
19 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/components/LogPanel.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useEffect } from 'react';
2 |
3 | interface LogPanelProps {
4 | messages: string[];
5 | }
6 |
7 | const LogPanel: React.FC = ({ messages }) => {
8 | const logEndRef = useRef(null);
9 |
10 | useEffect(() => {
11 | logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
12 | }, [messages]);
13 |
14 | return (
15 |
16 |
17 | Журнал
18 |
19 |
20 | {messages.map((msg, index) => {
21 | if (msg.startsWith('$$SEP$$')) {
22 | return (
23 |
24 | {msg.substring(7)}
25 |
26 | );
27 | }
28 | return (
29 |
30 | > {msg}
31 |
32 | );
33 | })}
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | export default LogPanel;
--------------------------------------------------------------------------------
/components/Enemy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Enemy, DebugOptions, EnemyState } from '../types';
3 | import { ENEMY_SPRITE_BASE64 } from '../assets/actors';
4 |
5 | interface EnemyProps {
6 | enemy: Enemy;
7 | debugOptions: DebugOptions;
8 | tileSize: number;
9 | }
10 |
11 | const stateMap: { [key in EnemyState]: string } = {
12 | [EnemyState.PATROLLING]: 'P',
13 | [EnemyState.HUNTING]: 'H',
14 | [EnemyState.SEARCHING]: 'S',
15 | };
16 |
17 | const EnemyComponent: React.FC = ({ enemy, debugOptions, tileSize }) => {
18 | return (
19 |
28 |

34 | {debugOptions.showEnemyStates && (
35 |
37 | {stateMap[enemy.state]}
38 |
39 | )}
40 |
41 | );
42 | };
43 |
44 | export default EnemyComponent;
--------------------------------------------------------------------------------
/components/icons/PhoneIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const PhoneIcon: React.FC = () => {
4 | return (
5 | <>
6 |
30 |
43 | >
44 | );
45 | };
46 |
47 | export default PhoneIcon;
48 |
--------------------------------------------------------------------------------
/types.ts:
--------------------------------------------------------------------------------
1 | export enum TileType {
2 | FLOOR,
3 | WALL,
4 | }
5 |
6 | export enum Visibility {
7 | HIDDEN,
8 | EXPLORED,
9 | VISIBLE,
10 | }
11 |
12 | export enum EnemyState {
13 | PATROLLING,
14 | HUNTING,
15 | SEARCHING,
16 | }
17 |
18 | export interface Position {
19 | x: number;
20 | y: number;
21 | }
22 |
23 | export interface Enemy {
24 | id: number;
25 | pos: Position;
26 | health: number;
27 | state: EnemyState;
28 | lastKnownPlayerPos: Position | null;
29 | path: Position[] | null;
30 | patrolCenter: Position;
31 | patrolTarget: Position | null;
32 | }
33 |
34 | export interface Player {
35 | pos: Position;
36 | health: number;
37 | }
38 |
39 | export interface Room {
40 | x: number;
41 | y: number;
42 | width: number;
43 | height: number;
44 | }
45 |
46 | export interface DungeonData {
47 | map: TileType[][];
48 | playerStart: Position;
49 | enemiesStart: Enemy[];
50 | }
51 |
52 | export interface DungeonGenerationProgress {
53 | progress: number;
54 | message: string;
55 | result?: DungeonData;
56 | }
57 |
58 | export interface Viewport {
59 | x: number;
60 | y: number;
61 | width: number;
62 | height: number;
63 | }
64 |
65 | export interface DebugOptions {
66 | godMode: boolean;
67 | revealMap: boolean;
68 | showEnemyVision: boolean;
69 | showEnemyPaths: boolean;
70 | showEnemyStates: boolean;
71 | }
--------------------------------------------------------------------------------
/hooks/useGameInput.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback, useRef } from 'react';
2 |
3 | export type GameAction =
4 | 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' |
5 | 'UP_LEFT' | 'UP_RIGHT' | 'DOWN_LEFT' | 'DOWN_RIGHT' |
6 | 'WAIT';
7 |
8 | const keyMap: { [key: string]: GameAction } = {
9 | 'KeyW': 'UP', 'ArrowUp': 'UP',
10 | 'KeyS': 'DOWN', 'ArrowDown': 'DOWN',
11 | 'KeyA': 'LEFT', 'ArrowLeft': 'LEFT',
12 | 'KeyD': 'RIGHT', 'ArrowRight': 'RIGHT',
13 | 'KeyQ': 'UP_LEFT',
14 | 'KeyE': 'UP_RIGHT',
15 | 'KeyZ': 'DOWN_LEFT',
16 | 'KeyX': 'DOWN_RIGHT',
17 | };
18 |
19 | export const useGameInput = () => {
20 | const [action, setAction] = useState(null);
21 | const pressedKeysRef = useRef>({});
22 |
23 | const handleKeyDown = useCallback((e: KeyboardEvent) => {
24 | if (pressedKeysRef.current[e.code]) return; // Key already held down
25 |
26 | let gameAction: GameAction | null = null;
27 |
28 | if (e.shiftKey && e.code === 'KeyW') {
29 | gameAction = 'WAIT';
30 | } else if (keyMap[e.code]) {
31 | gameAction = keyMap[e.code];
32 | }
33 |
34 | if (gameAction) {
35 | e.preventDefault();
36 | pressedKeysRef.current[e.code] = true;
37 | setAction(gameAction);
38 | }
39 | }, []);
40 |
41 | const handleKeyUp = useCallback((e: KeyboardEvent) => {
42 | if (pressedKeysRef.current[e.code]) {
43 | e.preventDefault();
44 | pressedKeysRef.current[e.code] = false;
45 | }
46 | }, []);
47 |
48 |
49 | useEffect(() => {
50 | window.addEventListener('keydown', handleKeyDown);
51 | window.addEventListener('keyup', handleKeyUp);
52 | return () => {
53 | window.removeEventListener('keydown', handleKeyDown);
54 | window.removeEventListener('keyup', handleKeyUp);
55 | };
56 | }, [handleKeyDown, handleKeyUp]);
57 |
58 | // This effect resets the action immediately after it has been set.
59 | // This ensures the parent component only sees the action for a single render,
60 | // preventing movement loops.
61 | useEffect(() => {
62 | if (action) {
63 | setAction(null);
64 | }
65 | }, [action]);
66 |
67 | return action;
68 | };
--------------------------------------------------------------------------------
/utils/los.ts:
--------------------------------------------------------------------------------
1 | import { Position, TileType } from '../types';
2 |
3 | /**
4 | * Checks for a clear line of sight between two points using a grid traversal algorithm.
5 | * Based on "A Fast Voxel Traversal Algorithm for Ray Tracing" by Amanatides and Woo.
6 | * This method is more accurate for grid-based worlds than Bresenham's, as it
7 | * correctly identifies all cells a ray passes through, preventing LOS through corners.
8 | * @param start The starting position.
9 | * @param end The ending position.
10 | * @param map The game map containing tile data.
11 | * @returns {boolean} True if there is a clear line of sight, false otherwise.
12 | */
13 | export function hasLineOfSight(start: Position, end: Position, map: TileType[][]): boolean {
14 | // Start from the center of the tiles for more accurate raycasting
15 | let x0 = start.x + 0.5;
16 | let y0 = start.y + 0.5;
17 | const x1 = end.x + 0.5;
18 | const y1 = end.y + 0.5;
19 |
20 | let dx = x1 - x0;
21 | let dy = y1 - y0;
22 |
23 | if (dx === 0 && dy === 0) {
24 | return true;
25 | }
26 |
27 | // Current grid cell coordinates
28 | let currentX = start.x;
29 | let currentY = start.y;
30 |
31 | // Direction of movement (1, -1, or 0)
32 | const stepX = Math.sign(dx);
33 | const stepY = Math.sign(dy);
34 |
35 | // How far we have to move in X and Y to cross a grid cell boundary
36 | // This is the distance along the ray to the next vertical/horizontal grid line
37 | const tDeltaX = dx === 0 ? Infinity : Math.abs(1 / dx);
38 | const tDeltaY = dy === 0 ? Infinity : Math.abs(1 / dy);
39 |
40 | // How far along the ray we are to the *next* grid line crossing
41 | // Initial calculation
42 | let tMaxX: number;
43 | if (stepX > 0) {
44 | tMaxX = (1.0 - (x0 - Math.floor(x0))) * tDeltaX;
45 | } else {
46 | tMaxX = (x0 - Math.floor(x0)) * tDeltaX;
47 | }
48 |
49 | let tMaxY: number;
50 | if (stepY > 0) {
51 | tMaxY = (1.0 - (y0 - Math.floor(y0))) * tDeltaY;
52 | } else {
53 | tMaxY = (y0 - Math.floor(y0)) * tDeltaY;
54 | }
55 |
56 | // Traverse the grid along the ray's path
57 | while (currentX !== end.x || currentY !== end.y) {
58 | // Step to the next cell in the direction of the smallest tMax
59 | if (tMaxX < tMaxY) {
60 | tMaxX += tDeltaX;
61 | currentX += stepX;
62 | } else {
63 | tMaxY += tDeltaY;
64 | currentY += stepY;
65 | }
66 |
67 | // Check the new cell for a wall, but not the very last one (the target)
68 | if (currentX !== end.x || currentY !== end.y) {
69 | if (map[currentY]?.[currentX] === TileType.WALL) {
70 | return false;
71 | }
72 | }
73 | }
74 |
75 | return true;
76 | }
77 |
--------------------------------------------------------------------------------
/components/MainMenu.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { MAIN_MENU_BACKGROUND_URL } from '../assets/ui';
3 |
4 | interface MainMenuProps {
5 | onStartGame: () => void;
6 | }
7 |
8 | const MainMenu: React.FC = ({ onStartGame }) => {
9 | const menuStyle: React.CSSProperties = {
10 | backgroundImage: `url(${MAIN_MENU_BACKGROUND_URL})`,
11 | backgroundSize: 'cover',
12 | backgroundPosition: 'center',
13 | backgroundRepeat: 'no-repeat',
14 | imageRendering: 'pixelated',
15 | };
16 |
17 | // Define dynamic styles using clamp for fluid scaling based on viewport size
18 | const titleStyle: React.CSSProperties = {
19 | fontSize: 'clamp(1rem, 4vw, 3rem)',
20 | textShadow: '3px 3px 0px #000',
21 | };
22 |
23 | const subtitleStyle: React.CSSProperties = {
24 | fontSize: 'clamp(0.75rem, 3vw, 1.5rem)',
25 | textShadow: '2px 2px 0px #000',
26 | };
27 |
28 | const buttonStyle: React.CSSProperties = {
29 | fontSize: 'clamp(0.8rem, 2vw, 1.1rem)',
30 | padding: 'clamp(0.5rem, 1.5vh, 0.8rem) clamp(1rem, 3vw, 1.75rem)',
31 | };
32 |
33 | return (
34 |
38 |
39 |
40 | {/* Adjusted padding for smaller screens */}
41 |
42 | {/* Top-left aligned title */}
43 |
44 |
48 | Arlade
49 |
50 |
54 | A roguelike adventure
55 |
56 |
57 |
58 | {/* Centered buttons with dynamic sizing */}
59 |
60 |
67 |
74 |
75 |
76 |
77 | );
78 | };
79 |
80 | export default MainMenu;
81 |
--------------------------------------------------------------------------------
/utils/fov.ts:
--------------------------------------------------------------------------------
1 | import { Position } from '../types';
2 | import { MAP_WIDTH, MAP_HEIGHT } from '../constants';
3 |
4 | // Recursive shadowcasting FOV algorithm
5 | // Based on http://www.adammil.net/blog/v125_roguelike_vision_algorithms.html#shadowcasting
6 |
7 | export function computeFov(origin: Position, radius: number, isBlocking: (pos: Position) => boolean): Set {
8 | const visible = new Set();
9 | visible.add(`${origin.x},${origin.y}`);
10 |
11 | for (let i = 0; i < 8; i++) {
12 | scanOctant(1, octantTransforms[i], 1.0, 0.0);
13 | }
14 |
15 | function scanOctant(depth: number, transform: OctantTransform, startSlope: number, endSlope: number) {
16 | if (startSlope < endSlope) {
17 | return;
18 | }
19 |
20 | if (depth > radius) {
21 | return;
22 | }
23 |
24 | let prevWasWall: boolean | null = null;
25 |
26 | const minJ = Math.round(depth * endSlope);
27 | const maxJ = Math.round(depth * startSlope);
28 |
29 | for (let j = maxJ; j >= minJ; j--) {
30 | const relativePos = { x: j, y: depth };
31 | const pos = transform.transform(origin, relativePos);
32 |
33 | if (pos.x < 0 || pos.x >= MAP_WIDTH || pos.y < 0 || pos.y >= MAP_HEIGHT) {
34 | continue;
35 | }
36 |
37 | const inRadius = Math.sqrt(j * j + depth * depth) <= radius;
38 | if (inRadius) {
39 | visible.add(`${pos.x},${pos.y}`);
40 | }
41 |
42 | const currentIsWall = isBlocking(pos);
43 |
44 | // If we're moving from floor to wall, we've found the end of a contiguous floor section.
45 | // We need to recursively scan the next row for this section.
46 | if (prevWasWall === false && currentIsWall === true) {
47 | const nextEndSlope = (j + 0.5) / (depth - 0.5);
48 | scanOctant(depth + 1, transform, startSlope, nextEndSlope);
49 | }
50 |
51 | // If we're moving from wall to floor, we've found the start of a new floor section.
52 | // We update the start slope for the rest of this row's scan.
53 | if (prevWasWall === true && currentIsWall === false) {
54 | startSlope = (j + 0.5) / (depth + 0.5);
55 | }
56 |
57 | prevWasWall = currentIsWall;
58 | }
59 |
60 | // If the last tile in the row was floor, we need to scan the next row for the last segment.
61 | if (prevWasWall === false) {
62 | scanOctant(depth + 1, transform, startSlope, endSlope);
63 | }
64 | }
65 |
66 | return visible;
67 | }
68 |
69 | interface OctantTransform {
70 | transform: (origin: Position, point: Position) => Position;
71 | }
72 |
73 | const octantTransforms: OctantTransform[] = [
74 | // N
75 | { transform: (origin, point) => ({ x: origin.x + point.x, y: origin.y - point.y }) },
76 | // NE
77 | { transform: (origin, point) => ({ x: origin.x + point.y, y: origin.y - point.x }) },
78 | // E
79 | { transform: (origin, point) => ({ x: origin.x + point.y, y: origin.y + point.x }) },
80 | // SE
81 | { transform: (origin, point) => ({ x: origin.x + point.x, y: origin.y + point.y }) },
82 | // S
83 | { transform: (origin, point) => ({ x: origin.x - point.x, y: origin.y + point.y }) },
84 | // SW
85 | { transform: (origin, point) => ({ x: origin.x - point.y, y: origin.y + point.x }) },
86 | // W
87 | { transform: (origin, point) => ({ x: origin.x - point.y, y: origin.y - point.x }) },
88 | // NW
89 | { transform: (origin, point) => ({ x: origin.x - point.x, y: origin.y - point.y }) },
90 | ];
--------------------------------------------------------------------------------
/components/DebugMenu.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DebugOptions } from '../types';
3 |
4 | interface DebugMenuProps {
5 | options: DebugOptions;
6 | onOptionChange: (option: K, value: DebugOptions[K]) => void;
7 | onClose: () => void;
8 | }
9 |
10 | const DebugMenu: React.FC = ({ options, onOptionChange, onClose }) => {
11 | return (
12 |
13 |
14 |
Отладка
15 |
16 |
17 |
74 |
75 | );
76 | };
77 |
78 | export default DebugMenu;
--------------------------------------------------------------------------------
/utils/pathfinding.ts:
--------------------------------------------------------------------------------
1 | import { Position, TileType } from '../types';
2 | import { MAP_WIDTH, MAP_HEIGHT } from '../constants';
3 |
4 | interface PathNode {
5 | pos: Position;
6 | g: number; // Cost from start
7 | h: number; // Heuristic cost to end
8 | f: number; // g + h
9 | parent: PathNode | null;
10 | }
11 |
12 | function diagonalDistance(posA: Position, posB: Position): number {
13 | return Math.max(Math.abs(posA.x - posB.x), Math.abs(posA.y - posB.y));
14 | }
15 |
16 | function isWalkable(pos: Position, map: TileType[][]): boolean {
17 | if (pos.x < 0 || pos.x >= MAP_WIDTH || pos.y < 0 || pos.y >= MAP_HEIGHT) {
18 | return false;
19 | }
20 | return map[pos.y][pos.x] === TileType.FLOOR;
21 | }
22 |
23 | export function findPath(start: Position, end: Position, map: TileType[][]): Position[] | null {
24 | const startNode: PathNode = { pos: start, g: 0, h: diagonalDistance(start, end), f: diagonalDistance(start, end), parent: null };
25 | const endNode: PathNode = { pos: end, g: 0, h: 0, f: 0, parent: null };
26 |
27 | if (!isWalkable(end, map)) return null;
28 |
29 | const openList: PathNode[] = [startNode];
30 | const closedList: Set = new Set();
31 |
32 | while (openList.length > 0) {
33 | // Find the node with the lowest F score in the open list
34 | let currentNode = openList[0];
35 | let currentIndex = 0;
36 | for (let i = 1; i < openList.length; i++) {
37 | if (openList[i].f < currentNode.f) {
38 | currentNode = openList[i];
39 | currentIndex = i;
40 | }
41 | }
42 |
43 | // Move current node from open to closed list
44 | openList.splice(currentIndex, 1);
45 | closedList.add(`${currentNode.pos.x},${currentNode.pos.y}`);
46 |
47 | // Found the path
48 | if (currentNode.pos.x === endNode.pos.x && currentNode.pos.y === endNode.pos.y) {
49 | const path: Position[] = [];
50 | let current: PathNode | null = currentNode;
51 | while (current && current.parent) {
52 | path.push(current.pos);
53 | current = current.parent;
54 | }
55 | return path.reverse();
56 | }
57 |
58 | // Get neighbors (cardinal and diagonal)
59 | const neighbors: Position[] = [];
60 | const { x, y } = currentNode.pos;
61 |
62 | for (let dy = -1; dy <= 1; dy++) {
63 | for (let dx = -1; dx <= 1; dx++) {
64 | if (dx === 0 && dy === 0) continue;
65 |
66 | const neighborPos = { x: x + dx, y: y + dy };
67 |
68 | if (isWalkable(neighborPos, map)) {
69 | // Check for corner cutting for diagonal moves
70 | if (dx !== 0 && dy !== 0) { // It's a diagonal move
71 | if (!isWalkable({ x: x + dx, y: y }, map) || !isWalkable({ x: x, y: y + dy }, map)) {
72 | continue; // Blocked corner
73 | }
74 | }
75 | neighbors.push(neighborPos);
76 | }
77 | }
78 | }
79 |
80 | for (const neighborPos of neighbors) {
81 | const neighborKey = `${neighborPos.x},${neighborPos.y}`;
82 | if (closedList.has(neighborKey)) {
83 | continue;
84 | }
85 |
86 | const gScore = currentNode.g + 1; // All moves cost 1
87 | const hScore = diagonalDistance(neighborPos, end);
88 | const fScore = gScore + hScore;
89 |
90 | let neighborNode = openList.find(node => node.pos.x === neighborPos.x && node.pos.y === neighborPos.y);
91 |
92 | if (!neighborNode) {
93 | neighborNode = { pos: neighborPos, g: gScore, h: hScore, f: fScore, parent: currentNode };
94 | openList.push(neighborNode);
95 | } else if (gScore < neighborNode.g) {
96 | neighborNode.parent = currentNode;
97 | neighborNode.g = gScore;
98 | neighborNode.f = fScore;
99 | }
100 | }
101 | }
102 |
103 | // No path found
104 | return null;
105 | }
--------------------------------------------------------------------------------
/assets/environment.ts:
--------------------------------------------------------------------------------
1 | export const FLOOR_TILE_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAVElEQVR4AeySMQoAIAhFPx0kaOn+52kJukipYENLfFoahH6U+L6gJgDzRWogPFBypWSQXGagsLyp44wZUOSRHAZA9OCbHvTRjgW9f53ZY9QAIy+xAAAA///hF5gGAAAABklEQVQDABs3OhFfR7ZuAAAAAElFTkSuQmCC';
2 |
3 | // 16-tile wall tileset for autotiling.
4 | // The index is a bitmask calculated from the four cardinal neighbors:
5 | // 1: North, 2: South, 4: East, 8: West
6 | export const WALL_TILES_BASE64: string[] = [
7 | // 0: No neighbors (pillar)
8 | '[data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAn0lEQVR4AaSPUQqEQAxDy55vwUMIHkrwEIL3U18gMAyt4PgRGpPpo/4i4vwiALHMS6kb/tgJwKN1WyNT1ZEjAVjk4428IwC/8GaZt94RwAFhr6ojRwJwTiUePXUCTP8pMrHc522GFwAzKgH2Y49MQPu8zfAC+EwCe88sazsBeDQqAXwmEHvPLGs7AXySJ0v2nlUmAOWoBPBJnsDsPavsAgAA//9LIGDOAAAABklEQVQDAFonyhF0dcv7AAAAAElFTkSuQmCC]',
9 | // 1: N
10 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAnElEQVR4AaSOwQmAQAwEg/UJFiFYlGARB/anzkJgH3f3iI8h6+YcskTE8wcEcezHkE8+3UnAoyoSnNcZIxDPdhJs6xYJP2TO2evY0UtAqCJBu1skiDLn7HXs6CXIc5gOD/yb7B1ZAkIVCfIcpoPUv8nekSXgNKBgOrOOnQSEKhJwGiBhOrOOnQR+MpkF0xl1ErCsIoGfTEbGdEbdCwAA//+ccUb6AAAABklEQVQDAGuHwREGbtCUAAAAAElFTkSuQmCC',
11 | // 2: S
12 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAYElEQVR4AeyS0QoAEAxFb35V+SjlW7Fbasm0eJOH+2A7TpkFAPUmIkCKyUyXb3sUCHQaCnLJsCLiXY+C8QSBPdE8BZ5LFvMFwCszGJtm/fVc1zxnoDdrhldnzVOwgry1BgAA///Ws09AAAAABklEQVQDAO2kR80W62D4AAAAAElFTkSuQmCC',
13 | // 3: N+S
14 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAUElEQVR4AeySQQoAIAgEl74a9Kigt5bjqaOIR6EBiXUR1yHprrkc6gi/HgPryb82kGp2sM8WRLNAC+h9AstVwEcEtIDWDSiytEHVHWQToO8BAAD//xCfIB4AAAAGSURBVAMATPogza+i4EIAAAAASUVORK5CYII=',
15 | // 4: E
16 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAmElEQVR4AaSO0QmAQAxDi/MJDiE4lOAQgvupLxA4xHpQP0LbtH1kiIjzjwDEMi+pbni6408AjqoSYN3WyAQ42+ELMI1TWDy4d/3yBOCgKgH2Yw8LkHvXL08AR31WHnueABxWJYCjPivQnieAY/Lg3vXNY2dfAIaqBHBMIO5d3zx29gUgUiuW7UyfeQKwrEoAIrUC1s70mXcBAAD//y8c10AAAAAGSURBVAMAvDrNEdzec/sAAAAASUVORK5CYII=',
17 | // 5: N+E
18 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAmUlEQVR4AaSO0QnAIAwFQ+crdIiCQwkdQuh+be9BQEQU048j5iWc2czs+QMCS2fq8om7ue8zl4BHFAnyla0H0l7uGXMJjv2wGgajnpnvSEATRYJyF6tBNuqZ+Y4EnASE1JpZJgFLUSTgJEBCrZllEqyc7LuIeUtAE0WClZN9lw95S8ApQEh12p68zSQgjCIBpwASqtP25G32AgAA//99ZtMtAAAABklEQVQDACKnxxGOZSZFAAAAAElFTkSuQmCC',
19 | // 6: S+E
20 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAYElEQVR4AeySSwrAIAwFH71qwUMJPWvbeatsAkZ34mIg5DNIzCXpXQGB2t1SfnlaY84CmmaxoD9dGYizGnkLeArQXMWC6lDsPwJplx1wURD/dzT2DrhCGB2KfRbERDX+AAAA//95XDlZAAAABklEQVQDAFqXQm/1zHuTAAAAAElFTkSuQmCC',
21 | // 7: N+S+E
22 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAR0lEQVR4AexSQQoAMAiSfXXQo4K9dcugB0w6Bkl1UKRcAK5tS3D+BQWCo9cIAD038OMglF+kg8gBCFlAIRYnHdSi9BFoCNIDAAD//3C2yOQAAAAGSURBVAMAZk0Yb497ObIAAAAASUVORK5CYII=',
23 | // 8: W
24 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAl0lEQVR4AayPXQqAMAyDy84neAjBQwkeQvB+6lfI6MN+YO4hpEu6kCYze/6AANu3vYovvOl5AEuj8IDjPKwGglteWpfVIvgQ38wlTbo3YGEU6boviyAovplLmvR8AkvUiuhp+PNOII1aET0NP59AdQQ4oqTJx5t3AtVJhCNKmnw8b6BKYgzN4prmAZij8ABVEhOmWVzTXgAAAP//dinZNQAAAAZJREFUAwCfA80RReOtNAAAAABJRU5ErkJggg==',
25 | // 9: N+W
26 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAmElEQVR4AayPUQrAIAxDi+cbeIiBhxJ2CGH32/aEQhAUmX6EpGmtTTCzZwUhncl6+BZ3e7yhzwXwb4R8ZeuBrb0ePv0QjwgbrMAc1fSYWY9Q7sIigxWYo5oeMzWCnwMrGNAarR56TwQ/B1bwg9Zo9dA1AoLzFDMeM3sisInzFDMeMzWCn47h2rn1tEbvieCns9G1c+tpjX4BAAD//x26oO0AAAAGSURBVAMAZjnKEdgwApgAAAAASUVORK5CYII=',
27 | // 10: S+W
28 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAV0lEQVR4AeySMQoAIAwDQ78q+CjBt6o3dOwSJ8HhKBgSgm1IWjdEb10VJ7jU8KDTgGkTYw5VkFppvKNbDagPdgDGxGqQZuYPkB79A64Q7DVyhWAHYEw2AAAA///NAG0RAAAABklEQVQDAHDoQm9l7hukAAAAAElFTkSuQmCC',
29 | // 11: N+S+W
30 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAASElEQVR4AeySwQkAMAgDQ1ctdCihs1rv4QCVQj+Ch6+EoBmS/JY1lwM6DGLXpw2kPzewbQJ+V0oQHRCUDRAmpQQpZrfBgyIdAAAA//9cX88MAAAABklEQVQDANSPGG+qK/2NAAAAAElFTkSuQmCC',
31 | // 12: E+W
32 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAl0lEQVR4AayPUQrAIAxDi+cb7BADDyXsEMLut+0VCp1UP5wfoTXRmCQRuf8AA8lH7uI172q8UwMuzUINylmkB4x7Gnzat108eODP7CNOE3BhFqleVTww8mf2EacVuEDUFhHfcmsq4ErUFhHfclrBoiPabjPi0IxfU8Gi42q7zYhDM/5TgWgAkekRcehrKxAN8BvTI+LQHwAAAP//tD6fGAAAAAZJREFUAwAIXNkRLDrK8QAAAABJRU5ErkJggg==',
33 | // 13: N+E+W
34 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAiElEQVR4AayPSwqAMAxEQ84neAihhyp4iIL3U5/Qkk36sV0MSSa/GRWRewYajiAe3sNujx36KCD+hsYzigeuej14+rpvu1hA2pq8xs1bSFcSC77ZmrzGFQsMIdeih1tngW/ItejhigWks0C0aHHrLCCdb0SLFvdZYGhENrN5Z40Fro3IZjbvPAAAAP//CGEVZQAAAAZJREFUAwD8ltMRQ+mwHQAAAABJRU5ErkJggg==',
35 | // 14: S+E+W
36 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAQUlEQVR4AezQMQ4AIAhD0carmnAoEs+qtjsMuBmGP0He0AFgv0QANi3s4ulNAJ+qCfDliCKc3QTwqVoDQG/wxQYHAAD//5vwMtkAAAAGSURBVAMApuQ3EYo+1Q0AAAAASUVORK5CYII=',
37 | // 15: N+S+E+W (full)
38 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAALElEQVR4AeyTsQ0AAAjCGv//WeUFHVhIYIShgQL6YxVs/q4UQBiEgR7k38EAAAD//16LopYAAAAGSURBVAMA7eIQERhjxOYAAAAASUVORK5CYII='
39 | ];
40 |
--------------------------------------------------------------------------------
/components/GameBoard.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import { TileType, Visibility, Viewport, DebugOptions, Enemy } from '../types';
3 | import { MAP_WIDTH, MAP_HEIGHT } from '../constants';
4 | import { WALL_TILES_BASE64, FLOOR_TILE_BASE64 } from '../assets/environment';
5 |
6 | interface GameBoardProps {
7 | map: TileType[][];
8 | visibilityMap: Visibility[][];
9 | viewport: Viewport;
10 | debugOptions: DebugOptions;
11 | enemyVisionTiles: Set;
12 | enemies: Enemy[];
13 | tileSize: number;
14 | }
15 |
16 | // Memoize image objects outside the component to ensure they are created only once.
17 | const floorTileImage = new Image();
18 | floorTileImage.src = FLOOR_TILE_BASE64;
19 | const wallTileImages = WALL_TILES_BASE64.map(src => {
20 | const img = new Image();
21 | img.src = src;
22 | return img;
23 | });
24 |
25 | const GameBoard: React.FC = ({ map, visibilityMap, viewport, debugOptions, enemies, enemyVisionTiles, tileSize }) => {
26 | const canvasRef = useRef(null);
27 |
28 | // We need to redraw when images are loaded, use a state for that.
29 | const [imagesLoaded, setImagesLoaded] = React.useState(false);
30 |
31 | useEffect(() => {
32 | const images = [floorTileImage, ...wallTileImages];
33 | let loadedCount = 0;
34 | const totalImages = images.length;
35 |
36 | const handleLoad = () => {
37 | loadedCount++;
38 | if (loadedCount === totalImages) {
39 | setImagesLoaded(true);
40 | }
41 | };
42 |
43 | images.forEach(img => {
44 | if (img.complete) {
45 | handleLoad();
46 | } else {
47 | img.addEventListener('load', handleLoad, { once: true });
48 | }
49 | });
50 |
51 | return () => {
52 | images.forEach(img => img.removeEventListener('load', handleLoad));
53 | };
54 | }, []);
55 |
56 | useEffect(() => {
57 | const canvas = canvasRef.current;
58 | if (!canvas || !imagesLoaded) {
59 | return;
60 | }
61 |
62 | const ctx = canvas.getContext('2d');
63 | if (!ctx) return;
64 |
65 | // Disable anti-aliasing for crisp pixel art
66 | ctx.imageSmoothingEnabled = false;
67 |
68 | // Fill the background, in case any tiles are not drawn
69 | ctx.fillStyle = 'black';
70 | ctx.fillRect(0, 0, canvas.width, canvas.height);
71 |
72 | // Calculate the visible tile range based on the viewport
73 | const startX = Math.max(0, Math.floor(viewport.x / tileSize));
74 | const startY = Math.max(0, Math.floor(viewport.y / tileSize));
75 | const endX = Math.min(MAP_WIDTH, Math.ceil((viewport.x + viewport.width) / tileSize));
76 | const endY = Math.min(MAP_HEIGHT, Math.ceil((viewport.y + viewport.height) / tileSize));
77 |
78 | const isWall = (x: number, y: number): boolean => {
79 | if (y < 0 || y >= MAP_HEIGHT || x < 0 || x >= MAP_WIDTH) {
80 | return true; // Treat borders as walls for seamless tiling
81 | }
82 | return map[y][x] === TileType.WALL;
83 | };
84 |
85 | // Draw the visible tiles
86 | for (let y = startY; y < endY; y++) {
87 | for (let x = startX; x < endX; x++) {
88 | let visibility = visibilityMap[y]?.[x];
89 | if (debugOptions.revealMap) visibility = Visibility.VISIBLE;
90 | if (visibility === undefined || visibility === Visibility.HIDDEN) continue;
91 |
92 | if (map[y][x] === TileType.WALL) {
93 | let index = 0;
94 | // The bitmask is based on which neighbors are ALSO walls.
95 | if (isWall(x, y - 1)) index |= 1; // North
96 | if (isWall(x, y + 1)) index |= 2; // South
97 | if (isWall(x + 1, y)) index |= 4; // East
98 | if (isWall(x - 1, y)) index |= 8; // West
99 |
100 | const tileImage = wallTileImages[index];
101 | if (tileImage?.complete) {
102 | ctx.drawImage(tileImage, x * tileSize, y * tileSize, tileSize, tileSize);
103 | }
104 | } else {
105 | ctx.drawImage(floorTileImage, x * tileSize, y * tileSize, tileSize, tileSize);
106 | }
107 |
108 | // Draw visibility overlay
109 | if (visibility === Visibility.EXPLORED) {
110 | ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
111 | ctx.fillRect(x * tileSize, y * tileSize, tileSize, tileSize);
112 | }
113 |
114 | // Draw debug overlays
115 | if (debugOptions.showEnemyVision && enemyVisionTiles.has(`${x},${y}`)) {
116 | ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
117 | ctx.fillRect(x * tileSize, y * tileSize, tileSize, tileSize);
118 | }
119 | }
120 | }
121 |
122 | // Draw debug paths on top of the tiles
123 | if (debugOptions.showEnemyPaths) {
124 | ctx.fillStyle = 'rgba(59, 130, 246, 0.4)'; // blue-500 with opacity
125 | for (const enemy of enemies) {
126 | if (enemy.path) {
127 | for (const pos of enemy.path) {
128 | if (pos.x >= startX && pos.x <= endX && pos.y >= startY && pos.y <= endY) {
129 | ctx.beginPath();
130 | ctx.arc(pos.x * tileSize + tileSize / 2, pos.y * tileSize + tileSize / 2, tileSize / 4, 0, 2 * Math.PI);
131 | ctx.fill();
132 | }
133 | }
134 | }
135 | }
136 | }
137 | }, [map, visibilityMap, viewport, debugOptions, enemies, enemyVisionTiles, imagesLoaded, tileSize]);
138 |
139 | return (
140 |
147 | );
148 | };
149 |
150 | export default React.memo(GameBoard);
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback, useEffect } from 'react';
2 | import MainMenu from './components/MainMenu';
3 | import Game from './components/Game';
4 | import OrientationLock from './components/OrientationLock';
5 | import { generateDungeon } from './hooks/useDungeonGenerator';
6 | import { DungeonData } from './types';
7 |
8 | type GameState = 'menu' | 'loading' | 'playing' | 'gameOver';
9 |
10 | const LoadingScreen: React.FC<{ progress: number; message: string }> = ({ progress, message }) => {
11 | return (
12 |
13 |
14 |
Генерация подземелья...
15 |
16 |
20 | {Math.round(progress)}%
21 |
22 |
23 |
{message}
24 |
25 |
26 | );
27 | };
28 |
29 |
30 | const App: React.FC = () => {
31 | const [gameState, setGameState] = useState('menu');
32 | const [gameId, setGameId] = useState(1);
33 | const [dungeonData, setDungeonData] = useState(null);
34 | const [loadingProgress, setLoadingProgress] = useState({ progress: 0, message: '' });
35 |
36 | const [isTouchDevice] = useState('ontouchstart' in window || navigator.maxTouchPoints > 0);
37 | const [isPortrait, setIsPortrait] = useState(isTouchDevice ? window.matchMedia("(orientation: portrait)").matches : false);
38 |
39 | useEffect(() => {
40 | if (!isTouchDevice) return;
41 |
42 | const mediaQuery = window.matchMedia("(orientation: portrait)");
43 | const handleChange = (e: MediaQueryListEvent) => setIsPortrait(e.matches);
44 |
45 | mediaQuery.addEventListener('change', handleChange);
46 |
47 | // Initial check
48 | setIsPortrait(mediaQuery.matches);
49 |
50 | return () => {
51 | mediaQuery.removeEventListener('change', handleChange);
52 | };
53 | }, [isTouchDevice]);
54 |
55 | const enterFullScreen = useCallback(() => {
56 | const element = document.documentElement as HTMLElement & {
57 | mozRequestFullScreen?: () => Promise;
58 | webkitRequestFullscreen?: () => Promise;
59 | msRequestFullscreen?: () => Promise;
60 | };
61 |
62 | const doc = document as Document & {
63 | mozFullScreenElement?: Element;
64 | webkitFullscreenElement?: Element;
65 | msFullscreenElement?: Element;
66 | };
67 |
68 | if (
69 | !doc.fullscreenElement &&
70 | !doc.webkitFullscreenElement &&
71 | !doc.mozFullScreenElement &&
72 | !doc.msFullscreenElement
73 | ) {
74 | if (element.requestFullscreen) {
75 | element.requestFullscreen().catch(err => {
76 | console.warn(`Fullscreen request failed: ${err.message}`);
77 | });
78 | } else if (element.mozRequestFullScreen) { // Firefox
79 | element.mozRequestFullScreen();
80 | } else if (element.webkitRequestFullscreen) { // Chrome, Safari & Opera
81 | element.webkitRequestFullscreen();
82 | } else if (element.msRequestFullscreen) { // IE/Edge
83 | element.msRequestFullscreen();
84 | }
85 | }
86 | }, []);
87 |
88 | const startGame = useCallback(async () => {
89 | enterFullScreen();
90 | setGameState('loading');
91 | setLoadingProgress({ progress: 0, message: 'Начало генерации...' });
92 |
93 | const generator = generateDungeon();
94 | for await (const update of generator) {
95 | setLoadingProgress({ progress: update.progress, message: update.message });
96 | if (update.result) {
97 | setDungeonData(update.result);
98 | setGameState('playing');
99 | }
100 | }
101 | }, [enterFullScreen]);
102 |
103 | const endGame = useCallback(() => {
104 | setGameState('gameOver');
105 | }, []);
106 |
107 | const restartGame = useCallback(() => {
108 | setGameId(prevId => prevId + 1);
109 | startGame();
110 | }, [startGame]);
111 |
112 | const backToMenu = useCallback(() => {
113 | setGameId(prevId => prevId + 1);
114 | setDungeonData(null);
115 | setGameState('menu');
116 | }, []);
117 |
118 | const renderContent = () => {
119 | switch (gameState) {
120 | case 'loading':
121 | return ;
122 | case 'playing':
123 | if (!dungeonData) return null; // Should not happen in normal flow
124 | return ;
125 | case 'gameOver':
126 | return (
127 |
128 |
Игра окончена
129 |
Вы были повержены.
130 |
131 |
137 |
143 |
144 |
145 | );
146 | case 'menu':
147 | default:
148 | return ;
149 | }
150 | };
151 |
152 | return (
153 |
154 | {isTouchDevice && isPortrait ? (
155 |
156 | ) : (
157 |
158 | {renderContent()}
159 |
160 | )}
161 |
162 | );
163 | };
164 |
165 | export default App;
--------------------------------------------------------------------------------
/hooks/useDungeonGenerator.ts:
--------------------------------------------------------------------------------
1 | import { TileType, Position, Enemy, Room, DungeonGenerationProgress, EnemyState, DungeonData } from '../types';
2 | import { MAP_WIDTH, MAP_HEIGHT, MAX_ENEMIES, ENEMY_INITIAL_HEALTH, MIN_ROOMS, MAX_ROOMS, MIN_ROOM_SIZE, MAX_ROOM_SIZE, PLAYER_VISION_RADIUS } from '../constants';
3 |
4 | // --- Helper Functions ---
5 | const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
6 | const tick = () => new Promise(resolve => setTimeout(resolve, 0));
7 |
8 | // --- Main Generator Class ---
9 | class DungeonGenerator {
10 | private map: TileType[][];
11 | private rooms: Room[] = [];
12 | private readonly WALL = TileType.WALL;
13 | private readonly FLOOR = TileType.FLOOR;
14 |
15 | constructor() {
16 | this.map = Array(MAP_HEIGHT).fill(null).map(() => Array(MAP_WIDTH).fill(this.WALL));
17 | }
18 |
19 | private _placeRooms(): void {
20 | for (let i = 0; i < 200 && this.rooms.length < randomInt(MIN_ROOMS, MAX_ROOMS); i++) {
21 | const width = randomInt(MIN_ROOM_SIZE, MAX_ROOM_SIZE);
22 | const height = randomInt(MIN_ROOM_SIZE, MAX_ROOM_SIZE);
23 | const x = randomInt(1, MAP_WIDTH - width - 2);
24 | const y = randomInt(1, MAP_HEIGHT - height - 2);
25 | const newRoom: Room = { x, y, width, height };
26 |
27 | const overlaps = this.rooms.some(room =>
28 | (newRoom.x < room.x + room.width + 1 &&
29 | newRoom.x + newRoom.width + 1 > room.x &&
30 | newRoom.y < room.y + room.height + 1 &&
31 | newRoom.y + newRoom.height + 1 > room.y)
32 | );
33 |
34 | if (!overlaps) {
35 | this.rooms.push(newRoom);
36 | }
37 | }
38 | }
39 |
40 | private _carveRoom(room: Room): void {
41 | for (let y = room.y; y < room.y + room.height; y++) {
42 | for (let x = room.x; x < room.x + room.width; x++) {
43 | this.map[y][x] = this.FLOOR;
44 | }
45 | }
46 | }
47 |
48 | private _divideRoomRecursively(room: Room): void {
49 | if (room.width < 8 || room.height < 8) return; // Adjusted condition
50 |
51 | const splitHorizontally = room.height >= room.width && room.height >= 5;
52 | const splitVertically = room.width > room.height && room.width >= 5;
53 |
54 | if (splitHorizontally) {
55 | const splitY = randomInt(room.y + 2, room.y + room.height - 3);
56 | for (let x = room.x; x < room.x + room.width; x++) this.map[splitY][x] = this.WALL;
57 |
58 | const doorX = randomInt(room.x, room.x + room.width - 1);
59 | this.map[splitY][doorX] = this.FLOOR;
60 |
61 | const roomA: Room = { x: room.x, y: room.y, width: room.width, height: splitY - room.y };
62 | const roomB: Room = { x: room.x, y: splitY + 1, width: room.width, height: room.y + room.height - (splitY + 1) };
63 | this._divideRoomRecursively(roomA);
64 | this._divideRoomRecursively(roomB);
65 |
66 | } else if (splitVertically) {
67 | const splitX = randomInt(room.x + 2, room.x + room.width - 3);
68 | for (let y = room.y; y < room.y + room.height; y++) this.map[y][splitX] = this.WALL;
69 |
70 | const doorY = randomInt(room.y, room.y + room.height - 1);
71 | this.map[doorY][splitX] = this.FLOOR;
72 |
73 | const roomA: Room = { x: room.x, y: room.y, width: splitX - room.x, height: room.height };
74 | const roomB: Room = { x: splitX + 1, y: room.y, width: room.x + room.width - (splitX + 1), height: room.height };
75 | this._divideRoomRecursively(roomA);
76 | this._divideRoomRecursively(roomB);
77 | }
78 | }
79 |
80 | private _runCellularAutomata(): void {
81 | // Initial noise
82 | for (let y = 1; y < MAP_HEIGHT - 1; y++) {
83 | for (let x = 1; x < MAP_WIDTH - 1; x++) {
84 | if (this.map[y][x] === this.WALL) {
85 | this.map[y][x] = Math.random() < 0.45 ? this.FLOOR : this.WALL;
86 | }
87 | }
88 | }
89 |
90 | // Simulation steps
91 | for(let i = 0; i < 4; i++) {
92 | const newMap = this.map.map(row => [...row]);
93 | for (let y = 1; y < MAP_HEIGHT - 1; y++) {
94 | for (let x = 1; x < MAP_WIDTH - 1; x++) {
95 | let neighbors = 0;
96 | for (let ny = y - 1; ny <= y + 1; ny++) {
97 | for (let nx = x - 1; nx <= x + 1; nx++) {
98 | if (nx < 0 || nx >= MAP_WIDTH || ny < 0 || ny >= MAP_HEIGHT) {
99 | neighbors++; // Count out-of-bounds as walls
100 | } else if (this.map[ny][nx] === this.WALL) {
101 | neighbors++;
102 | }
103 | }
104 | }
105 | if (this.map[y][x] === this.WALL) {
106 | if (neighbors < 4) newMap[y][x] = this.FLOOR;
107 | } else {
108 | if (neighbors > 5) newMap[y][x] = this.WALL;
109 | }
110 | }
111 | }
112 | this.map = newMap;
113 | }
114 | }
115 |
116 | private _findRegions(tileType: TileType): Position[][] {
117 | const regions: Position[][] = [];
118 | const visited: boolean[][] = Array(MAP_HEIGHT).fill(null).map(() => Array(MAP_WIDTH).fill(false));
119 |
120 | for (let y = 0; y < MAP_HEIGHT; y++) {
121 | for (let x = 0; x < MAP_WIDTH; x++) {
122 | if (this.map[y][x] === tileType && !visited[y][x]) {
123 | const newRegion: Position[] = [];
124 | const queue: Position[] = [{ x, y }];
125 | visited[y][x] = true;
126 |
127 | while (queue.length > 0) {
128 | const tile = queue.shift()!;
129 | newRegion.push(tile);
130 |
131 | const deltas = [{dx:0, dy:1}, {dx:0, dy:-1}, {dx:1, dy:0}, {dx:-1, dy:0}];
132 | for (const {dx, dy} of deltas) {
133 | const checkX = tile.x + dx;
134 | const checkY = tile.y + dy;
135 | if (checkX >= 0 && checkX < MAP_WIDTH && checkY >= 0 && checkY < MAP_HEIGHT &&
136 | !visited[checkY][checkX] && this.map[checkY][checkX] === tileType) {
137 | visited[checkY][checkX] = true;
138 | queue.push({ x: checkX, y: checkY });
139 | }
140 | }
141 | }
142 | regions.push(newRegion);
143 | }
144 | }
145 | }
146 | return regions;
147 | }
148 |
149 | private _connectRegions(): void {
150 | const regions = this._findRegions(this.FLOOR);
151 | if (regions.length <= 1) return;
152 |
153 | // Sort by size and remove small artifact regions
154 | const sortedRegions = regions.filter(r => r.length > 10).sort((a, b) => b.length - a.length);
155 | if (sortedRegions.length <= 1) return;
156 |
157 | const mainRegion = sortedRegions.shift()!;
158 |
159 | for (const region of sortedRegions) {
160 | let bestDist = Infinity;
161 | let bestPointA: Position = {x:0, y:0};
162 | let bestPointB: Position = {x:0, y:0};
163 |
164 | // Find closest points between the main region and the current one
165 | for (let j = 0; j < 100; j++) { // Check 100 random pairs
166 | const tileA = mainRegion[randomInt(0, mainRegion.length - 1)];
167 | const tileB = region[randomInt(0, region.length - 1)];
168 | const dist = Math.pow(tileA.x - tileB.x, 2) + Math.pow(tileA.y - tileB.y, 2);
169 | if (dist < bestDist) {
170 | bestDist = dist;
171 | bestPointA = tileA;
172 | bestPointB = tileB;
173 | }
174 | }
175 | this._carveTunnel(bestPointA, bestPointB);
176 | mainRegion.push(...region); // Merge the connected region into the main one
177 | }
178 | }
179 |
180 | private _carveTunnel(start: Position, end: Position): void {
181 | let x = start.x;
182 | let y = start.y;
183 |
184 | while (x !== end.x || y !== end.y) {
185 | if (x !== end.x && (y === end.y || Math.random() < 0.5)) {
186 | x += Math.sign(end.x - x);
187 | } else if (y !== end.y) {
188 | y += Math.sign(end.y - y);
189 | }
190 | if (this.map[y]?.[x] !== undefined) this.map[y][x] = this.FLOOR;
191 | }
192 | }
193 |
194 |
195 | private _removeDiagonalPassages(): void {
196 | // Run this multiple times to catch all artifacts, including those created by a previous pass.
197 | for (let i = 0; i < 5; i++) {
198 | for (let y = 1; y < MAP_HEIGHT - 1; y++) {
199 | for (let x = 1; x < MAP_WIDTH - 1; x++) {
200 | // Pattern: Top-left to bottom-right floor diagonal
201 | // . W
202 | // W .
203 | if (this.map[y][x] === this.FLOOR && this.map[y+1][x+1] === this.FLOOR &&
204 | this.map[y][x+1] === this.WALL && this.map[y+1][x] === this.WALL) {
205 | // Randomly carve one of the walls to create a clear 1-tile wide path
206 | if (Math.random() < 0.5) {
207 | this.map[y][x+1] = this.FLOOR;
208 | } else {
209 | this.map[y+1][x] = this.FLOOR;
210 | }
211 | }
212 | // Pattern: Bottom-left to top-right floor diagonal
213 | // W .
214 | // . W
215 | else if (this.map[y+1][x] === this.FLOOR && this.map[y][x+1] === this.FLOOR &&
216 | this.map[y][x] === this.WALL && this.map[y+1][x+1] === this.WALL) {
217 | // Randomly carve one of the walls to create a clear 1-tile wide path
218 | if (Math.random() < 0.5) {
219 | this.map[y][x] = this.FLOOR;
220 | } else {
221 | this.map[y+1][x+1] = this.FLOOR;
222 | }
223 | }
224 | }
225 | }
226 | }
227 | }
228 |
229 | private _placeEntities(): { playerStart: Position, enemiesStart: Enemy[] } {
230 | const floorRegions = this._findRegions(this.FLOOR);
231 | if (floorRegions.length === 0) {
232 | this.map[1][1] = this.FLOOR;
233 | return { playerStart: { x: 1, y: 1 }, enemiesStart: [] };
234 | }
235 | // Place entities only on the largest contiguous floor area
236 | const mainFloor = floorRegions.sort((a,b) => b.length - a.length)[0];
237 |
238 | if (mainFloor.length === 0) {
239 | this.map[1][1] = this.FLOOR;
240 | return { playerStart: { x: 1, y: 1 }, enemiesStart: [] };
241 | }
242 |
243 | const playerIndex = randomInt(0, mainFloor.length - 1);
244 | const playerStart = mainFloor.splice(playerIndex, 1)[0];
245 |
246 | const enemiesStart: Enemy[] = [];
247 | let enemyIdCounter = 0;
248 |
249 | const validEnemyTiles = mainFloor.filter(pos => {
250 | const dist = Math.sqrt(Math.pow(pos.x - playerStart.x, 2) + Math.pow(pos.y - playerStart.y, 2));
251 | return dist > PLAYER_VISION_RADIUS + 2;
252 | });
253 |
254 | const numEnemies = Math.min(MAX_ENEMIES, validEnemyTiles.length);
255 | for (let i = 0; i < numEnemies; i++) {
256 | const enemyIndex = randomInt(0, validEnemyTiles.length - 1);
257 | const enemyPos = validEnemyTiles.splice(enemyIndex, 1)[0];
258 | enemiesStart.push({
259 | id: enemyIdCounter++,
260 | pos: enemyPos,
261 | health: ENEMY_INITIAL_HEALTH,
262 | state: EnemyState.PATROLLING,
263 | lastKnownPlayerPos: null, path: null,
264 | patrolCenter: { ...enemyPos }, patrolTarget: null,
265 | });
266 | }
267 | return { playerStart, enemiesStart };
268 | }
269 |
270 | public async *generate(): AsyncGenerator {
271 | yield { progress: 5, message: 'Планирование комнат...' };
272 | await tick();
273 | this._placeRooms();
274 |
275 | yield { progress: 15, message: 'Строительство комплексов...' };
276 | await tick();
277 | for (const room of this.rooms) {
278 | this._carveRoom(room);
279 | if (room.width >= 8 || room.height >= 8) {
280 | this._divideRoomRecursively(room);
281 | }
282 | }
283 |
284 | yield { progress: 30, message: 'Создание пещер...' };
285 | await tick();
286 | this._runCellularAutomata();
287 |
288 | yield { progress: 70, message: 'Соединение регионов...' };
289 | await tick();
290 | this._connectRegions();
291 |
292 | yield { progress: 90, message: 'Зачистка артефактов...' };
293 | await tick();
294 | this._removeDiagonalPassages();
295 |
296 | yield { progress: 95, message: 'Размещение обитателей...' };
297 | await tick();
298 | const { playerStart, enemiesStart } = this._placeEntities();
299 |
300 | const result: DungeonData = { map: this.map, playerStart, enemiesStart };
301 | yield { progress: 100, message: 'Готово!', result };
302 | return result;
303 | }
304 | }
305 |
306 | export async function* generateDungeon(): AsyncGenerator {
307 | const generator = new DungeonGenerator();
308 | // FIX: Replaced `yield*` with a `for await...of` loop to resolve a TypeScript generator delegation error.
309 | for await (const progress of generator.generate()) {
310 | yield progress;
311 | }
312 | }
--------------------------------------------------------------------------------
/components/Game.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useCallback, useRef } from 'react';
2 | import { useGameInput, GameAction } from '../hooks/useGameInput';
3 | import GameBoard from './GameBoard';
4 | import PlayerComponent from './Player';
5 | import EnemyComponent from './Enemy';
6 | import StatusBar from './StatusBar';
7 | import LogPanel from './LogPanel';
8 | import DebugMenu from './DebugMenu';
9 | import { Player, Enemy, Position, TileType, Visibility, DungeonData, Viewport, EnemyState, DebugOptions } from '../types';
10 | import { MAP_WIDTH, MAP_HEIGHT, PC_TILE_SIZE, MOBILE_TILE_SIZE, PLAYER_INITIAL_HEALTH, PLAYER_ATTACK_POWER, PLAYER_VISION_RADIUS, ENEMY_VISION_RADIUS, ENEMY_PATROL_RADIUS } from '../constants';
11 | import { computeFov } from '../utils/fov';
12 | import { findPath } from '../utils/pathfinding';
13 | import { hasLineOfSight } from '../utils/los';
14 |
15 | interface GameProps {
16 | onGameOver: () => void;
17 | dungeonData: DungeonData;
18 | }
19 |
20 | const MAX_LOG_MESSAGES = 100;
21 |
22 | const moveDeltas: { [key in GameAction]?: { dx: number, dy: number } } = {
23 | 'UP': { dx: 0, dy: -1 },
24 | 'DOWN': { dx: 0, dy: 1 },
25 | 'LEFT': { dx: -1, dy: 0 },
26 | 'RIGHT': { dx: 1, dy: 0 },
27 | 'UP_LEFT': { dx: -1, dy: -1 },
28 | 'UP_RIGHT': { dx: 1, dy: -1 },
29 | 'DOWN_LEFT': { dx: -1, dy: 1 },
30 | 'DOWN_RIGHT': { dx: 1, dy: 1 },
31 | };
32 |
33 | const Game: React.FC = ({ onGameOver, dungeonData }) => {
34 | const { map, playerStart, enemiesStart } = dungeonData;
35 | const [player, setPlayer] = useState({ pos: playerStart, health: PLAYER_INITIAL_HEALTH });
36 | const [enemies, setEnemies] = useState(enemiesStart);
37 |
38 | const [isTouchDevice] = useState('ontouchstart' in window || navigator.maxTouchPoints > 0);
39 | const initialLogMessage = isTouchDevice
40 | ? 'Добро пожаловать! Используйте свайпы для передвижения.'
41 | : 'Добро пожаловать! Используйте стрелки/WASD для движения и QEZX для диагоналей.';
42 | const [log, setLog] = useState([initialLogMessage]);
43 |
44 | const [visibilityMap, setVisibilityMap] = useState(() =>
45 | Array(MAP_HEIGHT).fill(null).map(() => Array(MAP_WIDTH).fill(Visibility.HIDDEN))
46 | );
47 |
48 | const [showDebugMenu, setShowDebugMenu] = useState(false);
49 | const [debugOptions, setDebugOptions] = useState({
50 | godMode: false,
51 | revealMap: false,
52 | showEnemyVision: false,
53 | showEnemyPaths: false,
54 | showEnemyStates: false,
55 | });
56 | const [enemyVisionTiles, setEnemyVisionTiles] = useState>(new Set());
57 | const [turnCount, setTurnCount] = useState(1);
58 | const [touchStart, setTouchStart] = useState<{ x: number, y: number } | null>(null);
59 | const [tileSize, setTileSize] = useState(window.innerWidth < 768 ? MOBILE_TILE_SIZE : PC_TILE_SIZE);
60 |
61 | const gameAction = useGameInput();
62 | const gameAreaRef = useRef(null);
63 | const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
64 |
65 | const handleDebugOptionChange = useCallback((option: K, value: DebugOptions[K]) => {
66 | setDebugOptions(prev => ({ ...prev, [option]: value }));
67 | }, []);
68 |
69 | useEffect(() => {
70 | const handleKeyDown = (e: KeyboardEvent) => {
71 | if (e.key === 'Delete') {
72 | e.preventDefault();
73 | setShowDebugMenu(prev => !prev);
74 | }
75 | };
76 | window.addEventListener('keydown', handleKeyDown);
77 | return () => window.removeEventListener('keydown', handleKeyDown);
78 | }, []);
79 |
80 | const addLogMessage = useCallback((message: string) => {
81 | setLog(prevLog => {
82 | const newLog = [...prevLog, message];
83 | if (newLog.length > MAX_LOG_MESSAGES) {
84 | return newLog.slice(newLog.length - MAX_LOG_MESSAGES);
85 | }
86 | return newLog;
87 | });
88 | }, []);
89 |
90 | const isWall = useCallback((pos: Position) => {
91 | if (pos.y < 0 || pos.y >= MAP_HEIGHT || pos.x < 0 || pos.x >= MAP_WIDTH) {
92 | return true;
93 | }
94 | return map[pos.y][pos.x] === TileType.WALL;
95 | }, [map]);
96 |
97 | useEffect(() => {
98 | if (debugOptions.revealMap) {
99 | setVisibilityMap(Array(MAP_HEIGHT).fill(null).map(() => Array(MAP_WIDTH).fill(Visibility.VISIBLE)));
100 | return;
101 | }
102 |
103 | const isBlocking = (pos: Position) => isWall(pos);
104 | const visibleTiles = computeFov(player.pos, PLAYER_VISION_RADIUS, isBlocking);
105 |
106 | setVisibilityMap(prevMap => {
107 | const newMap = prevMap.map(row => [...row]);
108 | // Mark previously visible tiles as explored
109 | for (let y = 0; y < MAP_HEIGHT; y++) {
110 | for (let x = 0; x < MAP_WIDTH; x++) {
111 | if (newMap[y][x] === Visibility.VISIBLE) {
112 | newMap[y][x] = Visibility.EXPLORED;
113 | }
114 | }
115 | }
116 | // Mark new tiles as visible
117 | visibleTiles.forEach(key => {
118 | const [x, y] = key.split(',').map(Number);
119 | if (x >= 0 && x < MAP_WIDTH && y >= 0 && y < MAP_HEIGHT) {
120 | newMap[y][x] = Visibility.VISIBLE;
121 | }
122 | });
123 | return newMap;
124 | });
125 | }, [player.pos, isWall, debugOptions.revealMap]);
126 |
127 | const findNewPatrolTarget = useCallback((center: Position, radius: number, currentMap: TileType[][]): Position | null => {
128 | for (let i = 0; i < 10; i++) { // Try 10 times to find a valid spot
129 | const angle = Math.random() * 2 * Math.PI;
130 | const r = Math.sqrt(Math.random()) * radius;
131 | const x = Math.round(center.x + r * Math.cos(angle));
132 | const y = Math.round(center.y + r * Math.sin(angle));
133 |
134 | if (x >= 0 && x < MAP_WIDTH && y >= 0 && y < MAP_HEIGHT && currentMap[y][x] === TileType.FLOOR) {
135 | return { x, y };
136 | }
137 | }
138 | return null;
139 | }, []);
140 |
141 | const processTurn = useCallback((newPlayerPos: Position, isWaitAction: boolean = false) => {
142 | if (!isWaitAction && isWall(newPlayerPos)) {
143 | return;
144 | }
145 |
146 | const turnLogMessages: string[] = [];
147 |
148 | let nextPlayerState: Player = JSON.parse(JSON.stringify(player));
149 | let nextEnemiesState: Enemy[] = JSON.parse(JSON.stringify(enemies));
150 |
151 | const playerActionTargetPos = isWaitAction ? player.pos : newPlayerPos;
152 | const targetEnemyIndex = nextEnemiesState.findIndex(e => e.pos.x === playerActionTargetPos.x && e.pos.y === playerActionTargetPos.y);
153 |
154 | if (targetEnemyIndex !== -1) {
155 | const targetEnemy = nextEnemiesState[targetEnemyIndex];
156 | targetEnemy.health -= PLAYER_ATTACK_POWER;
157 | turnLogMessages.push(`Вы нанесли врагу ${PLAYER_ATTACK_POWER} урона!`);
158 |
159 | if (targetEnemy.health <= 0) {
160 | turnLogMessages.push('Вы победили врага!');
161 | nextEnemiesState.splice(targetEnemyIndex, 1);
162 | }
163 | } else if (!isWaitAction) {
164 | nextPlayerState.pos = playerActionTargetPos;
165 | } else {
166 | turnLogMessages.push('Вы пропускаете ход.');
167 | }
168 |
169 | const playerPosForEnemies = targetEnemyIndex !== -1 ? player.pos : playerActionTargetPos;
170 |
171 | const allNewEnemyVision = new Set();
172 | const enemiesAfterProcessing: Enemy[] = [];
173 | const isBlocking = (pos: Position) => isWall(pos);
174 |
175 | nextEnemiesState.forEach((currentEnemy, index) => {
176 | let newEnemy: Enemy = JSON.parse(JSON.stringify(currentEnemy));
177 |
178 | const enemyFov = computeFov(newEnemy.pos, ENEMY_VISION_RADIUS, isBlocking);
179 | if (debugOptions.showEnemyVision) {
180 | enemyFov.forEach(tileKey => allNewEnemyVision.add(tileKey));
181 | }
182 | const hasLosToPlayer = hasLineOfSight(newEnemy.pos, playerPosForEnemies, map);
183 |
184 | const isAdjacentForSight = Math.max(Math.abs(newEnemy.pos.x - playerPosForEnemies.x), Math.abs(newEnemy.pos.y - playerPosForEnemies.y)) === 1;
185 | const canSeePlayer = isAdjacentForSight || (enemyFov.has(`${playerPosForEnemies.x},${playerPosForEnemies.y}`) && hasLosToPlayer);
186 |
187 | if (canSeePlayer) {
188 | newEnemy.state = EnemyState.HUNTING;
189 | newEnemy.lastKnownPlayerPos = { ...playerPosForEnemies };
190 | newEnemy.path = findPath(newEnemy.pos, newEnemy.lastKnownPlayerPos, map);
191 | newEnemy.patrolTarget = null;
192 | } else {
193 | switch(newEnemy.state) {
194 | case EnemyState.HUNTING:
195 | newEnemy.state = EnemyState.SEARCHING;
196 | if (newEnemy.lastKnownPlayerPos) {
197 | newEnemy.path = findPath(newEnemy.pos, newEnemy.lastKnownPlayerPos, map);
198 | }
199 | break;
200 | case EnemyState.SEARCHING:
201 | const atDestination = newEnemy.lastKnownPlayerPos && newEnemy.pos.x === newEnemy.lastKnownPlayerPos.x && newEnemy.pos.y === newEnemy.lastKnownPlayerPos.y;
202 | if (atDestination || !newEnemy.path || newEnemy.path.length === 0) {
203 | newEnemy.state = EnemyState.PATROLLING;
204 | newEnemy.patrolCenter = { ...newEnemy.pos };
205 | newEnemy.lastKnownPlayerPos = null;
206 | newEnemy.path = null;
207 | newEnemy.patrolTarget = null;
208 | }
209 | break;
210 | case EnemyState.PATROLLING:
211 | const atPatrolTarget = newEnemy.patrolTarget && newEnemy.pos.x === newEnemy.patrolTarget.x && newEnemy.pos.y === newEnemy.patrolTarget.y;
212 | if (atPatrolTarget || !newEnemy.path || newEnemy.path.length === 0) {
213 | const newTarget = findNewPatrolTarget(newEnemy.patrolCenter, ENEMY_PATROL_RADIUS, map);
214 | if (newTarget) {
215 | newEnemy.patrolTarget = newTarget;
216 | newEnemy.path = findPath(newEnemy.pos, newTarget, map);
217 | }
218 | }
219 | break;
220 | }
221 | }
222 |
223 | const dx = playerPosForEnemies.x - newEnemy.pos.x;
224 | const dy = playerPosForEnemies.y - newEnemy.pos.y;
225 | const isAdjacentToPlayer = Math.max(Math.abs(dx), Math.abs(dy)) === 1;
226 |
227 | if (newEnemy.state === EnemyState.HUNTING && isAdjacentToPlayer) {
228 | if (debugOptions.godMode) {
229 | turnLogMessages.push('Враг пытается вас атаковать, но вы неуязвимы!');
230 | } else {
231 | nextPlayerState.health -= 10;
232 | turnLogMessages.push('Враг нанес вам 10 урона!');
233 | }
234 | } else if (newEnemy.path && newEnemy.path.length > 0) {
235 | const nextStep = newEnemy.path[0];
236 | const isOccupiedByPlayer = nextStep.x === playerPosForEnemies.x && nextStep.y === playerPosForEnemies.y;
237 | const isOccupiedByMovedEnemy = enemiesAfterProcessing.some(e => e.pos.x === nextStep.x && e.pos.y === nextStep.y);
238 | const isOccupiedByUnmovedEnemy = nextEnemiesState.slice(index + 1).some(e => e.pos.x === nextStep.x && e.pos.y === nextStep.y);
239 |
240 | if (!isWall(nextStep) && !isOccupiedByPlayer && !isOccupiedByMovedEnemy && !isOccupiedByUnmovedEnemy) {
241 | newEnemy.pos = nextStep;
242 | newEnemy.path.shift();
243 | } else {
244 | newEnemy.path = null;
245 | }
246 | }
247 |
248 | enemiesAfterProcessing.push(newEnemy);
249 | });
250 |
251 | const actionTaken = !isWaitAction && (player.pos.x !== newPlayerPos.x || player.pos.y !== newPlayerPos.y);
252 |
253 | if (turnLogMessages.length > 0) {
254 | addLogMessage(`$$SEP$$--- Ход ${turnCount} ---`);
255 | turnLogMessages.forEach(msg => addLogMessage(msg));
256 | setTurnCount(t => t + 1);
257 | } else if (actionTaken) {
258 | setTurnCount(t => t + 1);
259 | }
260 |
261 |
262 | setPlayer(nextPlayerState);
263 | setEnemies(enemiesAfterProcessing);
264 | if(debugOptions.showEnemyVision) {
265 | setEnemyVisionTiles(allNewEnemyVision);
266 | } else {
267 | setEnemyVisionTiles(new Set());
268 | }
269 |
270 | }, [player, enemies, isWall, addLogMessage, map, debugOptions, findNewPatrolTarget, turnCount]);
271 |
272 |
273 | useEffect(() => {
274 | if (gameAction) {
275 | if (gameAction === 'WAIT') {
276 | processTurn(player.pos, true);
277 | } else {
278 | const delta = moveDeltas[gameAction];
279 | if (delta) {
280 | const newPos = {
281 | x: player.pos.x + delta.dx,
282 | y: player.pos.y + delta.dy
283 | };
284 | processTurn(newPos, false);
285 | }
286 | }
287 | }
288 | }, [gameAction, processTurn, player.pos]);
289 |
290 | useEffect(() => {
291 | if (player.health <= 0) {
292 | onGameOver();
293 | }
294 | }, [player.health, onGameOver]);
295 |
296 | useEffect(() => {
297 | const handleResize = () => {
298 | if (gameAreaRef.current) {
299 | setViewportSize({
300 | width: gameAreaRef.current.clientWidth,
301 | height: gameAreaRef.current.clientHeight,
302 | });
303 | }
304 | setTileSize(window.innerWidth < 768 ? MOBILE_TILE_SIZE : PC_TILE_SIZE);
305 | };
306 | handleResize();
307 | window.addEventListener('resize', handleResize);
308 | return () => window.removeEventListener('resize', handleResize);
309 | }, []);
310 |
311 | const handleTouchStart = useCallback((e: React.TouchEvent) => {
312 | e.preventDefault(); // Prevents screen scrolling on touch
313 | const touch = e.touches[0];
314 | setTouchStart({ x: touch.clientX, y: touch.clientY });
315 | }, []);
316 |
317 | const handleTouchEnd = useCallback((e: React.TouchEvent) => {
318 | if (!touchStart) return;
319 |
320 | const touchEnd = e.changedTouches[0];
321 | const deltaX = touchEnd.clientX - touchStart.x;
322 | const deltaY = touchEnd.clientY - touchStart.y;
323 | const swipeDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
324 |
325 | const MIN_SWIPE_DISTANCE = 40;
326 |
327 | if (swipeDistance < MIN_SWIPE_DISTANCE) {
328 | setTouchStart(null);
329 | return; // It's a tap, not a swipe
330 | }
331 |
332 | let dx = 0;
333 | let dy = 0;
334 |
335 | const angle = Math.atan2(deltaY, deltaX);
336 | const octant = Math.round(4 * angle / Math.PI + 8) % 8;
337 |
338 | switch (octant) {
339 | case 0: dx = 1; dy = 0; break; // Right
340 | case 1: dx = 1; dy = 1; break; // Down-Right
341 | case 2: dx = 0; dy = 1; break; // Down
342 | case 3: dx = -1; dy = 1; break; // Down-Left
343 | case 4: dx = -1; dy = 0; break; // Left
344 | case 5: dx = -1; dy = -1; break;// Up-Left
345 | case 6: dx = 0; dy = -1; break; // Up
346 | case 7: dx = 1; dy = -1; break; // Up-Right
347 | }
348 |
349 | if (dx !== 0 || dy !== 0) {
350 | const newPos = {
351 | x: player.pos.x + dx,
352 | y: player.pos.y + dy
353 | };
354 | processTurn(newPos, false);
355 | }
356 |
357 | setTouchStart(null);
358 | }, [touchStart, player.pos, processTurn]);
359 |
360 | const viewportX = viewportSize.width > 0 ? Math.max(0, Math.min(player.pos.x * tileSize - viewportSize.width / 2, MAP_WIDTH * tileSize - viewportSize.width)) : 0;
361 | const viewportY = viewportSize.height > 0 ? Math.max(0, Math.min(player.pos.y * tileSize - viewportSize.height / 2, MAP_HEIGHT * tileSize - viewportSize.height)) : 0;
362 |
363 | const viewport: Viewport = {
364 | x: viewportX,
365 | y: viewportY,
366 | width: viewportSize.width,
367 | height: viewportSize.height,
368 | };
369 |
370 | const playerScreenX = player.pos.x * tileSize - viewport.x;
371 | // Move buttons to the left if the player is in the rightmost 25% of the viewport.
372 | const areButtonsOnLeft = viewport.width > 0 && playerScreenX > viewport.width * 0.75;
373 |
374 | return (
375 |
376 | {showDebugMenu &&
setShowDebugMenu(false)} />}
377 |
378 |
379 |
386 | {isTouchDevice && (
387 |
388 |
395 |
402 |
403 | )}
404 |
405 |
406 |
407 |
408 | {enemies.filter(enemy => {
409 | if (debugOptions.revealMap) {
410 | return true;
411 | }
412 |
413 | const isAdjacent = Math.max(Math.abs(player.pos.x - enemy.pos.x), Math.abs(player.pos.y - enemy.pos.y)) === 1;
414 | if (isAdjacent) {
415 | return true;
416 | }
417 |
418 | const tileVisibility = visibilityMap[enemy.pos.y]?.[enemy.pos.x];
419 | if (tileVisibility !== Visibility.VISIBLE) {
420 | return false;
421 | }
422 | return hasLineOfSight(player.pos, enemy.pos, map);
423 | }).map(enemy => (
424 |
425 | ))}
426 |
427 |
428 |
431 |
432 |
433 | );
434 | };
435 |
436 | export default Game;
--------------------------------------------------------------------------------