├── 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 |
8 | 9 |
10 |

Пожалуйста, поверните ваше устройство

11 |

Для лучшего опыта играйте в горизонтальном режиме.

12 |
13 | ); 14 | }; 15 | 16 | export default OrientationLock; 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | GHBanner 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 | Player 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 |
19 |
HP:
20 |
21 |
25 |
26 |
27 | {Math.max(0, playerHealth)}/{maxHealth} 28 |
29 |
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 | Enemy 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 | 38 | 42 | 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 |
    18 |
  • 19 | 28 |
  • 29 |
  • 30 | 39 |
  • 40 |
  • 41 | 50 |
  • 51 |
  • 52 | 61 |
  • 62 |
  • 63 | 72 |
  • 73 |
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; --------------------------------------------------------------------------------