Init
This commit is contained in:
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
Generated
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<annotationProcessing>
|
||||||
|
<profile name="Maven default annotation processors profile" enabled="true">
|
||||||
|
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||||
|
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||||
|
<outputRelativeToContentRoot value="true" />
|
||||||
|
<module name="telegaapp" />
|
||||||
|
</profile>
|
||||||
|
</annotationProcessing>
|
||||||
|
</component>
|
||||||
|
<component name="JavacSettings">
|
||||||
|
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
|
||||||
|
<module name="telegaapp" options="-parameters" />
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+20
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RemoteRepositoriesConfiguration">
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="central" />
|
||||||
|
<option name="name" value="Central Repository" />
|
||||||
|
<option name="url" value="https://repo.maven.apache.org/maven2" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="central" />
|
||||||
|
<option name="name" value="Maven Central repository" />
|
||||||
|
<option name="url" value="https://repo1.maven.org/maven2" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="jboss.community" />
|
||||||
|
<option name="name" value="JBoss Community repository" />
|
||||||
|
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
||||||
|
</remote-repository>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="MavenProjectsManager">
|
||||||
|
<option name="originalFiles">
|
||||||
|
<list>
|
||||||
|
<option value="$PROJECT_DIR$/pom.xml" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="23" project-jdk-type="JavaSDK" />
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Project: TelegaApp (Alias Game WebApp)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This project is a Spring Boot backend application designed to serve as the host for a Telegram WebApp game, specifically "Alias". The application integrates with the Telegram Bot API to manage the bot interface and will serve the game logic and web resources.
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
* **Language:** Java 17
|
||||||
|
* **Framework:** Spring Boot 3.2.5
|
||||||
|
* **Build Tool:** Maven
|
||||||
|
* **Telegram Library:** `telegrambots-spring-boot-starter` (6.9.7.1)
|
||||||
|
* **Frontend (Planned):** React, TypeScript, Vite (to be integrated into `src/main/resources/static`)
|
||||||
|
|
||||||
|
## Architecture & Structure
|
||||||
|
The project follows a standard Maven and Spring Boot directory structure:
|
||||||
|
|
||||||
|
* `src/main/java/com/example/telegaapp`: Source code root.
|
||||||
|
* `TelegaAppApplication.java`: Entry point.
|
||||||
|
* `bot/TelegramBot.java`: Handles Telegram updates (Long Polling) and WebApp launching.
|
||||||
|
* `controller/`: REST and potentially WebSocket controllers.
|
||||||
|
* `src/main/resources`: Configuration and static assets.
|
||||||
|
* `application.properties`: Configuration for server port and Telegram credentials.
|
||||||
|
* `static/`: Location for the built frontend application.
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
* JDK 17+
|
||||||
|
* Maven
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
* **Run Application:**
|
||||||
|
```bash
|
||||||
|
mvn spring-boot:run
|
||||||
|
```
|
||||||
|
* **Build JAR:**
|
||||||
|
```bash
|
||||||
|
mvn clean package
|
||||||
|
```
|
||||||
|
* **Run Tests:**
|
||||||
|
```bash
|
||||||
|
mvn test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
Configuration is managed in `src/main/resources/application.properties`.
|
||||||
|
Key properties:
|
||||||
|
* `server.port`: Defaults to `8080`.
|
||||||
|
* `telegram.bot.username`: The username of the bot.
|
||||||
|
* `telegram.bot.token`: The authentication token from BotFather.
|
||||||
|
* `telegram.webapp.url`: The HTTPS URL where the WebApp is hosted (required for the WebApp button).
|
||||||
|
|
||||||
|
## Architecture & Design Principles
|
||||||
|
|
||||||
|
### Extensibility & Scalability
|
||||||
|
* **Interface-Driven Design:** Core components (like Word providers, Game rules) must be defined by interfaces. This allows swapping implementations (e.g., switching from JSON files to a Database) without modifying the business logic.
|
||||||
|
* **Loose Coupling:**
|
||||||
|
* The **WebSocket Controller** should act *only* as a router/adapter. It converts incoming messages to Service calls and Service events to outgoing messages. It should contain *zero* game logic.
|
||||||
|
* The **Game Service** should not know about WebSocket implementation details.
|
||||||
|
* **State Management:** While currently In-Memory, the design must support migrating the `GameState` to an external store (Redis) in the future. Avoid relying on object identity (`==`) for game entities; use IDs.
|
||||||
|
|
||||||
|
### Coding Standards & Best Practices
|
||||||
|
|
||||||
|
* **Comments & Documentation:**
|
||||||
|
* **Mandatory Javadoc:** All public interfaces, classes, and complex methods must have Javadoc explaining *what* they do and *how* to use them.
|
||||||
|
* **"Why" over "What":** Inline comments are required for complex algorithmic logic (e.g., score calculation, state transitions). Focus on explaining the *intent* and the *reasoning* behind the code, not just translating syntax into English.
|
||||||
|
* **DTOs (Data Transfer Objects):** Strict separation between Domain models (internal logic) and DTOs (API contracts). Never return a mutable entity directly to the client.
|
||||||
|
* **Error Handling:** Use global exception handlers (`@ControllerAdvice` / `@MessageExceptionHandler`). Failures should be graceful and informative to the user.
|
||||||
|
* **Testing:** Logic should be testable in isolation. Unit tests for the Game Engine are critical.
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
* Basic Spring Boot application setup.
|
||||||
|
* Telegram Bot polling implemented (`TelegramBot.java`).
|
||||||
|
* `/start` command sends a button to open the Web App.
|
||||||
|
* **Pending:** WebSocket setup, Game logic service, Frontend implementation.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# План реализации игры "Alias" для Telegram WebApp
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
* **Backend:** Java 17+, Spring Boot, Spring WebSocket (STOMP).
|
||||||
|
* **Frontend:** React, TypeScript, Vite, Tailwind CSS.
|
||||||
|
* **Хранение данных:** In-Memory (Java Collections).
|
||||||
|
* **Интеграция:** Telegram WebApp SDK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 1: Настройка Backend (Spring Boot)
|
||||||
|
|
||||||
|
- [x] **1.1. Обновление зависимостей (`pom.xml`)**
|
||||||
|
- Добавить `spring-boot-starter-websocket`.
|
||||||
|
- Добавить `lombok` (для сокращения бойлерплейта).
|
||||||
|
|
||||||
|
- [x] **1.2. Модели данных (Domain)**
|
||||||
|
- `Player` (sessionId, nickname, teamId).
|
||||||
|
- `Team` (name, score, players).
|
||||||
|
- `Room` (roomId, teams, gameState, currentSettings).
|
||||||
|
- `GameState` (LOBBY, PLAYING, PAUSED, FINISHED).
|
||||||
|
|
||||||
|
- [x] **1.3. Сервисный слой (`GameService`)**
|
||||||
|
- Логика создания комнаты (генерация 4-значного кода).
|
||||||
|
- Логика присоединения игрока.
|
||||||
|
- Управление состоянием игры (старт раунда, переключение хода).
|
||||||
|
- Обработка действий (слово угадано/пропущено).
|
||||||
|
|
||||||
|
- [x] **1.4. WebSocket Конфигурация**
|
||||||
|
- Настройка `WebSocketMessageBroker`.
|
||||||
|
- Эндпоинты: `/app/create`, `/app/join`, `/app/game-action`.
|
||||||
|
- Топики для подписки: `/topic/room/{roomId}`.
|
||||||
|
|
||||||
|
- [x] **1.5. Словари слов**
|
||||||
|
- Создать JSON файлы с наборами слов (Easy, Medium, Hard).
|
||||||
|
- Реализовать загрузчик слов при старте приложения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 2: Настройка Frontend (React + Vite)
|
||||||
|
|
||||||
|
- [x] **2.1. Инициализация проекта**
|
||||||
|
- Создать папку `frontend` внутри корня.
|
||||||
|
- Установить React + TypeScript через Vite.
|
||||||
|
- Настроить Proxy для разработки (чтобы запросы шли на Spring Boot порт 8080).
|
||||||
|
|
||||||
|
- [x] **2.2. UI Библиотеки и Стили**
|
||||||
|
- Установить Tailwind CSS.
|
||||||
|
- Установить `framer-motion` для красивых анимаций карточек.
|
||||||
|
- Установить `@twa-dev/sdk` для типизации Telegram WebApp.
|
||||||
|
- Настроить тему, использующую `Telegram.WebApp.themeParams`.
|
||||||
|
|
||||||
|
- [x] **2.3. Клиент WebSocket**
|
||||||
|
- Настроить `sockjs-client` и `@stomp/stompjs`.
|
||||||
|
- Создать хук или контекст для управления соединением (`useWebSocket`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 3: Реализация интерфейса и логики (Frontend)
|
||||||
|
|
||||||
|
- [x] **3.1. Экран "Приветствие" (Home)**
|
||||||
|
- Кнопка "Создать игру".
|
||||||
|
- Поле ввода кода комнаты + кнопка "Войти".
|
||||||
|
|
||||||
|
- [x] **3.2. Экран "Лобби" (Lobby)**
|
||||||
|
- Список подключенных игроков.
|
||||||
|
- UI для перетаскивания/выбора команды (Team A / Team B).
|
||||||
|
- Настройки хоста: Выбор набора слов, длительность раунда, кол-во очков для победы.
|
||||||
|
- Кнопка "Начать игру" (только для хоста).
|
||||||
|
|
||||||
|
- [ ] **3.3. Экран "Игра" (Game)**
|
||||||
|
- **Роль "Объясняющий":**
|
||||||
|
- Карточка со словом по центру.
|
||||||
|
- Жесты (Swipe) или кнопки: Вверх/Вправо - Угадал, Вниз/Влево - Пропустил.
|
||||||
|
- Таймер обратного отсчета.
|
||||||
|
- **Роль "Отгадывающий":**
|
||||||
|
- Статус "Слушаем {PlayerName}".
|
||||||
|
- Таймер.
|
||||||
|
- Анимация при угадывании.
|
||||||
|
|
||||||
|
- [ ] **3.4. Экран "Результаты раунда"**
|
||||||
|
- Список слов, которые были сыграны в раунде (возможность оспорить/изменить статус, если успеем).
|
||||||
|
- Текущий счет команд.
|
||||||
|
- Кнопка "Следующий раунд".
|
||||||
|
|
||||||
|
- [ ] **3.5. Экран "Победа"**
|
||||||
|
- Поздравление победителей.
|
||||||
|
- Кнопка "В лобби".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 4: Тестирование и Сборка
|
||||||
|
|
||||||
|
- [x] **4.1. Сборка**
|
||||||
|
- Настроить Maven плагин (`frontend-maven-plugin`) для сборки React приложения и копирования его в `src/main/resources/static`.
|
||||||
|
- Проверка сборки единого JAR файла.
|
||||||
|
|
||||||
|
- [ ] **4.2. Тестирование**
|
||||||
|
- Проверка работы через ngrok (для HTTPS) внутри Telegram.
|
||||||
|
- Проверка работы WebSocket при сворачивании приложения (reconnection logic).
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+4140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@stomp/stompjs": "^7.2.1",
|
||||||
|
"@twa-dev/sdk": "^8.0.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.23.25",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"sockjs-client": "^1.6.1",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/sockjs-client": "^1.5.4",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { GameProvider, useGame } from './hooks/GameContext';
|
||||||
|
import StartScreen from './components/StartScreen';
|
||||||
|
import Lobby from './components/Lobby';
|
||||||
|
import { GameStates } from './types';
|
||||||
|
import WebApp from '@twa-dev/sdk';
|
||||||
|
|
||||||
|
// Initialize Telegram Web App
|
||||||
|
WebApp.ready();
|
||||||
|
WebApp.expand(); // Expand to full height
|
||||||
|
|
||||||
|
const AppContent: React.FC = () => {
|
||||||
|
const { room, error } = useGame();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen text-red-500 p-4 text-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold mb-2">Ошибка</h2>
|
||||||
|
<p>{error}</p>
|
||||||
|
<button onClick={() => window.location.reload()} className="mt-4 text-blue-500 underline">Перезагрузить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return <StartScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.state === GameStates.LOBBY) {
|
||||||
|
return <Lobby />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen text-white">
|
||||||
|
Game Started! (Coming soon)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<GameProvider>
|
||||||
|
<AppContent />
|
||||||
|
</GameProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,103 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useGame } from '../hooks/GameContext';
|
||||||
|
import type { Team } from '../types';
|
||||||
|
|
||||||
|
const Lobby: React.FC = () => {
|
||||||
|
const { room, currentPlayer, joinTeam } = useGame();
|
||||||
|
|
||||||
|
if (!room) return null;
|
||||||
|
|
||||||
|
const renderTeam = (team: Team) => {
|
||||||
|
const teamPlayers = room.teams.find(t => t.id === team.id)?.playerIds.map(pid => room.players[pid]) || [];
|
||||||
|
const isMyTeam = currentPlayer?.teamId === team.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={team.id} className="flex-1 min-w-[45%] bg-gray-800/50 rounded-xl p-4 border border-gray-700 flex flex-col">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="font-bold text-lg text-white">{team.name}</h3>
|
||||||
|
{/* Join Button if not in this team */}
|
||||||
|
{!isMyTeam && (
|
||||||
|
<button
|
||||||
|
onClick={() => joinTeam(team.id)}
|
||||||
|
className="text-xs px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{teamPlayers.map(p => (
|
||||||
|
<div key={p.sessionId} className="flex items-center space-x-2 bg-gray-900/60 p-2 rounded-lg">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center text-xs font-bold">
|
||||||
|
{p.name.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm truncate">{p.name}</span>
|
||||||
|
{p.host && <span className="text-xs text-yellow-500">👑</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{teamPlayers.length === 0 && (
|
||||||
|
<div className="text-center text-gray-500 text-sm py-4">Пусто</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unassignedPlayers = Object.values(room.players).filter(p => !p.teamId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen p-4 bg-gray-900 text-white">
|
||||||
|
<header className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">Комната #{room.roomId}</h2>
|
||||||
|
<p className="text-xs text-gray-400">Ожидание игроков...</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 px-3 py-1 rounded-full text-xs font-mono">
|
||||||
|
{Object.keys(room.players).length} Online
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Unassigned Players */}
|
||||||
|
{unassignedPlayers.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-xs text-gray-400 uppercase mb-2 ml-1">Без команды</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{unassignedPlayers.map(p => (
|
||||||
|
<div key={p.sessionId} className="bg-gray-800 px-3 py-1.5 rounded-full text-sm border border-gray-700">
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Teams */}
|
||||||
|
<div className="flex gap-4 mb-auto">
|
||||||
|
{room.teams.map(renderTeam)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings & Start (Only Host) */}
|
||||||
|
{currentPlayer?.host ? (
|
||||||
|
<div className="mt-4 p-4 bg-gray-800 rounded-t-2xl -mx-4 space-y-4 shadow-2xl border-t border-gray-700">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-400">Сложность</span>
|
||||||
|
<select className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm outline-none">
|
||||||
|
<option>EASY</option>
|
||||||
|
<option>MEDIUM</option>
|
||||||
|
<option>HARD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button className="w-full py-4 bg-green-600 hover:bg-green-500 text-white font-bold rounded-xl text-lg shadow-lg active:scale-95 transition-all">
|
||||||
|
Начать игру
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 text-center text-gray-500 text-sm pb-4">
|
||||||
|
Ждем, пока хост начнет игру...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Lobby;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useGame } from '../hooks/GameContext';
|
||||||
|
import WebApp from '@twa-dev/sdk';
|
||||||
|
|
||||||
|
const StartScreen: React.FC = () => {
|
||||||
|
const { createRoom, joinRoom } = useGame();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [roomId, setRoomId] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'create' | 'join'>('create');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Auto-fill name from Telegram if available
|
||||||
|
if (WebApp.initDataUnsafe?.user?.first_name) {
|
||||||
|
setName(WebApp.initDataUnsafe.user.first_name);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
console.log("Create button clicked. Name:", name);
|
||||||
|
if (!name) {
|
||||||
|
console.warn("Name is empty, aborting creation.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createRoom(name, WebApp.initDataUnsafe?.user?.id?.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = () => {
|
||||||
|
if (!name || !roomId) return;
|
||||||
|
joinRoom(roomId, name, WebApp.initDataUnsafe?.user?.id?.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen p-4 space-y-8">
|
||||||
|
<h1 className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-600">
|
||||||
|
Alias
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="w-full max-w-sm bg-gray-800/50 backdrop-blur-md rounded-2xl p-6 shadow-xl border border-gray-700">
|
||||||
|
<div className="flex space-x-2 mb-6 bg-gray-900/50 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('create')}
|
||||||
|
className={`flex-1 py-2 rounded-md text-sm font-medium transition-all ${
|
||||||
|
activeTab === 'create' ? 'bg-blue-600 text-white shadow-lg' : 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('join')}
|
||||||
|
className={`flex-1 py-2 rounded-md text-sm font-medium transition-all ${
|
||||||
|
activeTab === 'join' ? 'bg-blue-600 text-white shadow-lg' : 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1 ml-1">Твое имя</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Введите имя..."
|
||||||
|
className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-white placeholder-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'join' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1 ml-1">Номер комнаты</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={roomId}
|
||||||
|
onChange={(e) => setRoomId(e.target.value)}
|
||||||
|
placeholder="1234"
|
||||||
|
className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-white placeholder-gray-600 font-mono tracking-widest text-center text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={activeTab === 'create' ? handleCreate : handleJoin}
|
||||||
|
className="w-full py-3 mt-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 text-white font-bold rounded-xl shadow-lg transform transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
{activeTab === 'create' ? 'Создать комнату' : 'Присоединиться'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StartScreen;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Client } from '@stomp/stompjs';
|
||||||
|
import type { IMessage } from '@stomp/stompjs';
|
||||||
|
import type { Room, Player } from '../types';
|
||||||
|
|
||||||
|
interface GameContextType {
|
||||||
|
connected: boolean;
|
||||||
|
room: Room | null;
|
||||||
|
currentPlayer: Player | null;
|
||||||
|
error: string | null;
|
||||||
|
createRoom: (playerName: string, userId?: string) => void;
|
||||||
|
joinRoom: (roomId: string, playerName: string, userId?: string) => void;
|
||||||
|
joinTeam: (teamId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameContext = createContext<GameContextType | null>(null);
|
||||||
|
|
||||||
|
export const useGame = () => {
|
||||||
|
const context = useContext(GameContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useGame must be used within a GameProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [client, setClient] = useState<Client | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [room, setRoom] = useState<Room | null>(null);
|
||||||
|
const [currentPlayer, setCurrentPlayer] = useState<Player | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Determine WebSocket URL (ws:// or wss://)
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const brokerURL = `${protocol}//${window.location.host}/ws`;
|
||||||
|
|
||||||
|
const stompClient = new Client({
|
||||||
|
brokerURL: brokerURL,
|
||||||
|
reconnectDelay: 5000,
|
||||||
|
debug: (str) => {
|
||||||
|
console.log(str);
|
||||||
|
},
|
||||||
|
onConnect: (frame) => {
|
||||||
|
console.log('Connected: ' + frame);
|
||||||
|
setConnected(true);
|
||||||
|
|
||||||
|
// Subscribe to user-specific errors
|
||||||
|
stompClient.subscribe('/user/queue/errors', (message: IMessage) => {
|
||||||
|
setError(message.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to player info (to know who we are)
|
||||||
|
stompClient.subscribe('/user/queue/player-info', (message: IMessage) => {
|
||||||
|
const player = JSON.parse(message.body) as Player;
|
||||||
|
setCurrentPlayer(player);
|
||||||
|
console.log("Current player set:", player);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to room creation response
|
||||||
|
stompClient.subscribe('/user/queue/created', (message: IMessage) => {
|
||||||
|
const newRoom = JSON.parse(message.body) as Room;
|
||||||
|
setRoom(newRoom);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStompError: (frame) => {
|
||||||
|
console.error('Broker reported error: ' + frame.headers['message']);
|
||||||
|
console.error('Additional details: ' + frame.body);
|
||||||
|
setError(frame.headers['message']);
|
||||||
|
},
|
||||||
|
onWebSocketClose: () => {
|
||||||
|
setConnected(false);
|
||||||
|
console.log('WebSocket connection closed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stompClient.activate();
|
||||||
|
setClient(stompClient);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stompClient.deactivate();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to room updates when room changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (connected && client && room) {
|
||||||
|
const sub = client.subscribe(`/topic/room/${room.roomId}`, (message: IMessage) => {
|
||||||
|
const updatedRoom = JSON.parse(message.body) as Room;
|
||||||
|
setRoom(updatedRoom);
|
||||||
|
});
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}
|
||||||
|
}, [connected, client, room?.roomId]);
|
||||||
|
|
||||||
|
const createRoom = useCallback((playerName: string, userId?: string) => {
|
||||||
|
console.log("Attempting to create room...", { playerName, userId, connected, hasClient: !!client });
|
||||||
|
if (client && connected) {
|
||||||
|
client.publish({
|
||||||
|
destination: '/app/create',
|
||||||
|
body: JSON.stringify({ playerName, userId }),
|
||||||
|
});
|
||||||
|
console.log("Create room request sent.");
|
||||||
|
} else {
|
||||||
|
console.error("Cannot create room: Not connected to server.");
|
||||||
|
}
|
||||||
|
}, [client, connected]);
|
||||||
|
|
||||||
|
const joinRoom = useCallback((roomId: string, playerName: string, userId?: string) => {
|
||||||
|
if (client && connected) {
|
||||||
|
client.publish({
|
||||||
|
destination: '/app/join',
|
||||||
|
body: JSON.stringify({ roomId, playerName, userId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [client, connected]);
|
||||||
|
|
||||||
|
const joinTeam = useCallback((teamId: string) => {
|
||||||
|
if (client && connected && room) {
|
||||||
|
client.publish({
|
||||||
|
destination: '/app/team/join',
|
||||||
|
body: JSON.stringify({ roomId: room.roomId, teamId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [client, connected, room]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameContext.Provider value={{ connected, room, currentPlayer, error, createRoom, joinRoom, joinTeam }}>
|
||||||
|
{children}
|
||||||
|
</GameContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
/* Default to telegram theme params, fallback to dark mode */
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export type GameState = 'LOBBY' | 'PLAYING' | 'PAUSED' | 'ROUND_FINISHED' | 'GAME_OVER';
|
||||||
|
|
||||||
|
export const GameStates = {
|
||||||
|
LOBBY: 'LOBBY' as GameState,
|
||||||
|
PLAYING: 'PLAYING' as GameState,
|
||||||
|
PAUSED: 'PAUSED' as GameState,
|
||||||
|
ROUND_FINISHED: 'ROUND_FINISHED' as GameState,
|
||||||
|
GAME_OVER: 'GAME_OVER' as GameState
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
sessionId: string;
|
||||||
|
userId?: string;
|
||||||
|
name: string;
|
||||||
|
teamId?: string;
|
||||||
|
host: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Team {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
playerIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
roundTimeSeconds: number;
|
||||||
|
wordsToWin: number;
|
||||||
|
difficulty: 'EASY' | 'MEDIUM' | 'HARD' | 'KIDS';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
roomId: string;
|
||||||
|
state: GameState;
|
||||||
|
teams: Team[];
|
||||||
|
players: Record<string, Player>; // Map sessionId -> Player
|
||||||
|
settings: Settings;
|
||||||
|
currentTeamIndex: number;
|
||||||
|
currentDescriberIndex: number;
|
||||||
|
roundEndTime?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/ws': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
|
</parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>telegaapp</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>telegaapp</name>
|
||||||
|
<description>Telegram Web App with Spring Boot</description>
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<telegrambots.version>6.9.7.1</telegrambots.version>
|
||||||
|
<maven.compiler.source>23</maven.compiler.source>
|
||||||
|
<maven.compiler.target>23</maven.compiler.target>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.telegram</groupId> <artifactId>telegrambots-spring-boot-starter</artifactId>
|
||||||
|
<version>${telegrambots.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.telegaapp;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class TelegaAppApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(TelegaAppApplication.class, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.example.telegaapp.bot;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
|
||||||
|
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
|
||||||
|
import org.telegram.telegrambots.meta.api.objects.Update;
|
||||||
|
import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup;
|
||||||
|
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardButton;
|
||||||
|
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardRow;
|
||||||
|
import org.telegram.telegrambots.meta.api.objects.webapp.WebAppInfo;
|
||||||
|
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class TelegramBot extends TelegramLongPollingBot {
|
||||||
|
|
||||||
|
@Value("${telegram.bot.username}")
|
||||||
|
private String botUsername;
|
||||||
|
|
||||||
|
@Value("${telegram.bot.token}")
|
||||||
|
private String botToken;
|
||||||
|
|
||||||
|
@Value("${telegram.webapp.url}")
|
||||||
|
private String webAppUrl;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onUpdateReceived(Update update) {
|
||||||
|
if (update.hasMessage() && update.getMessage().hasText()) {
|
||||||
|
String messageText = update.getMessage().getText();
|
||||||
|
long chatId = update.getMessage().getChatId();
|
||||||
|
|
||||||
|
if (messageText.equals("/start")) {
|
||||||
|
sendWebAppButton(chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendWebAppButton(long chatId) {
|
||||||
|
SendMessage sendMessage = new SendMessage();
|
||||||
|
sendMessage.setChatId(chatId);
|
||||||
|
sendMessage.setText("Нажми на кнопку, чтобы открыть приложение!");
|
||||||
|
|
||||||
|
ReplyKeyboardMarkup keyboardMarkup = new ReplyKeyboardMarkup();
|
||||||
|
keyboardMarkup.setResizeKeyboard(true);
|
||||||
|
List<KeyboardRow> keyboard = new ArrayList<>();
|
||||||
|
KeyboardRow row = new KeyboardRow();
|
||||||
|
|
||||||
|
// Создаем кнопку с WebAppInfo
|
||||||
|
KeyboardButton button = new KeyboardButton("Открыть Web App");
|
||||||
|
WebAppInfo webAppInfo = new WebAppInfo(webAppUrl);
|
||||||
|
button.setWebApp(webAppInfo);
|
||||||
|
|
||||||
|
row.add(button);
|
||||||
|
keyboard.add(row);
|
||||||
|
keyboardMarkup.setKeyboard(keyboard);
|
||||||
|
sendMessage.setReplyMarkup(keyboardMarkup);
|
||||||
|
|
||||||
|
try {
|
||||||
|
execute(sendMessage);
|
||||||
|
} catch (TelegramApiException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getBotUsername() {
|
||||||
|
return botUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getBotToken() {
|
||||||
|
return botToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.example.telegaapp.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||||
|
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||||
|
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
|
||||||
|
import org.springframework.http.server.ServerHttpRequest;
|
||||||
|
import org.springframework.web.socket.WebSocketHandler;
|
||||||
|
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocketMessageBroker
|
||||||
|
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||||
|
// Prefix for messages FROM server TO client
|
||||||
|
config.enableSimpleBroker("/topic", "/queue");
|
||||||
|
// Prefix for messages FROM client TO server
|
||||||
|
config.setApplicationDestinationPrefixes("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||||
|
// Endpoint for connection
|
||||||
|
registry.addEndpoint("/ws")
|
||||||
|
.setHandshakeHandler(new DefaultHandshakeHandler() {
|
||||||
|
@Override
|
||||||
|
protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
|
||||||
|
String uuid = UUID.randomUUID().toString();
|
||||||
|
System.out.println("New WS connection. Assigning Principal: " + uuid);
|
||||||
|
return new StompPrincipal(uuid);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.setAllowedOriginPatterns("*");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class StompPrincipal implements Principal {
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
public StompPrincipal(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.example.telegaapp.controller;
|
||||||
|
|
||||||
|
import com.example.telegaapp.dto.CreateRequest;
|
||||||
|
import com.example.telegaapp.dto.JoinRequest;
|
||||||
|
import com.example.telegaapp.dto.TeamJoinRequest;
|
||||||
|
import com.example.telegaapp.model.Player;
|
||||||
|
import com.example.telegaapp.model.Room;
|
||||||
|
import com.example.telegaapp.service.GameService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||||
|
import org.springframework.messaging.handler.annotation.Payload;
|
||||||
|
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SocketController {
|
||||||
|
|
||||||
|
private final GameService gameService;
|
||||||
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
|
|
||||||
|
@MessageMapping("/create")
|
||||||
|
public void createRoom(@Payload CreateRequest request, SimpMessageHeaderAccessor headerAccessor) {
|
||||||
|
String sessionId = headerAccessor.getUser().getName();
|
||||||
|
System.out.println("Received create request from session: " + sessionId + ", name: " + request.getPlayerName());
|
||||||
|
Player host = new Player(sessionId, request.getUserId(), request.getPlayerName(), null, true);
|
||||||
|
|
||||||
|
Room room = gameService.createRoom(host);
|
||||||
|
|
||||||
|
// Send the player info back to the user so they know their session ID
|
||||||
|
messagingTemplate.convertAndSendToUser(sessionId, "/queue/player-info", host);
|
||||||
|
|
||||||
|
// Notify the creator specifically about the room
|
||||||
|
messagingTemplate.convertAndSendToUser(sessionId, "/queue/created", room);
|
||||||
|
}
|
||||||
|
|
||||||
|
@MessageMapping("/join")
|
||||||
|
public void joinRoom(@Payload JoinRequest request, SimpMessageHeaderAccessor headerAccessor) {
|
||||||
|
String sessionId = headerAccessor.getUser().getName();
|
||||||
|
System.out.println("Received join request from session: " + sessionId + ", room: " + request.getRoomId());
|
||||||
|
Player player = new Player(sessionId, request.getUserId(), request.getPlayerName(), null, false);
|
||||||
|
|
||||||
|
Room room = gameService.joinRoom(request.getRoomId(), player);
|
||||||
|
|
||||||
|
if (room != null) {
|
||||||
|
// Send the player info back to the user
|
||||||
|
messagingTemplate.convertAndSendToUser(sessionId, "/queue/player-info", player);
|
||||||
|
|
||||||
|
// Notify everyone in the room
|
||||||
|
messagingTemplate.convertAndSend("/topic/room/" + room.getRoomId(), room);
|
||||||
|
} else {
|
||||||
|
// Send error to user
|
||||||
|
messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", "Room not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MessageMapping("/team/join")
|
||||||
|
public void joinTeam(@Payload TeamJoinRequest request, SimpMessageHeaderAccessor headerAccessor) {
|
||||||
|
String sessionId = headerAccessor.getUser().getName();
|
||||||
|
gameService.joinTeam(request.getRoomId(), sessionId, request.getTeamId());
|
||||||
|
|
||||||
|
gameService.getRoom(request.getRoomId()).ifPresent(room -> {
|
||||||
|
messagingTemplate.convertAndSend("/topic/room/" + room.getRoomId(), room);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.telegaapp.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class WebAppController {
|
||||||
|
|
||||||
|
// Forward all non-api, non-static resource requests to index.html
|
||||||
|
// This regex excludes paths containing a dot (files) or starting with /api or /ws
|
||||||
|
@GetMapping(value = "/{path:[^\\.]*}")
|
||||||
|
public String redirect() {
|
||||||
|
return "forward:/index.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/submit")
|
||||||
|
@ResponseBody
|
||||||
|
public String receiveData(@RequestBody Map<String, Object> data) {
|
||||||
|
System.out.println("Received data from Web App: " + data);
|
||||||
|
return "Data received successfully!";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.telegaapp.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class CreateRequest {
|
||||||
|
private String playerName;
|
||||||
|
private String userId; // Optional telegram user id
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.telegaapp.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class JoinRequest {
|
||||||
|
private String roomId;
|
||||||
|
private String playerName;
|
||||||
|
private String userId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.telegaapp.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class TeamJoinRequest {
|
||||||
|
private String roomId;
|
||||||
|
private String teamId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.telegaapp.model;
|
||||||
|
|
||||||
|
public enum GameState {
|
||||||
|
LOBBY, // Players are joining, choosing teams
|
||||||
|
PLAYING, // Round is in progress
|
||||||
|
PAUSED, // Game paused (optional)
|
||||||
|
ROUND_FINISHED, // Round ended, showing summary
|
||||||
|
GAME_OVER // Game finished, showing winner
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.example.telegaapp.model;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class Player {
|
||||||
|
private String sessionId; // WebSocket session ID
|
||||||
|
private String userId; // Telegram User ID (if available)
|
||||||
|
private String name; // Telegram First Name
|
||||||
|
private String teamId; // ID of the team the player belongs to (nullable in lobby)
|
||||||
|
private boolean isHost; // Is this player the room creator?
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.example.telegaapp.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class Room {
|
||||||
|
private String roomId;
|
||||||
|
private GameState state;
|
||||||
|
private List<Team> teams;
|
||||||
|
private Map<String, Player> players; // sessionId -> Player
|
||||||
|
private Settings settings;
|
||||||
|
|
||||||
|
// Game state specific
|
||||||
|
private int currentTeamIndex;
|
||||||
|
private int currentDescriberIndex; // Index in the player list of the current team
|
||||||
|
private long roundEndTime; // Timestamp when round ends
|
||||||
|
|
||||||
|
public Room(String roomId) {
|
||||||
|
this.roomId = roomId;
|
||||||
|
this.state = GameState.LOBBY;
|
||||||
|
this.teams = new ArrayList<>();
|
||||||
|
this.players = new ConcurrentHashMap<>();
|
||||||
|
this.settings = new Settings();
|
||||||
|
|
||||||
|
// Initialize default teams
|
||||||
|
this.teams.add(new Team("Team A"));
|
||||||
|
this.teams.add(new Team("Team B"));
|
||||||
|
|
||||||
|
this.currentTeamIndex = 0;
|
||||||
|
this.currentDescriberIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Settings {
|
||||||
|
private int roundTimeSeconds = 60;
|
||||||
|
private int wordsToWin = 30;
|
||||||
|
private String difficulty = "EASY";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.telegaapp.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class Team {
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private int score;
|
||||||
|
private List<String> playerIds; // List of sessionIds/userIds
|
||||||
|
|
||||||
|
public Team(String name) {
|
||||||
|
this.id = UUID.randomUUID().toString();
|
||||||
|
this.name = name;
|
||||||
|
this.score = 0;
|
||||||
|
this.playerIds = new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.example.telegaapp.service;
|
||||||
|
|
||||||
|
import com.example.telegaapp.model.Player;
|
||||||
|
import com.example.telegaapp.model.Room;
|
||||||
|
import com.example.telegaapp.model.Team;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class GameService {
|
||||||
|
|
||||||
|
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
|
||||||
|
private final Random random = new Random();
|
||||||
|
|
||||||
|
public Room createRoom(Player host) {
|
||||||
|
String roomId = generateRoomId();
|
||||||
|
Room room = new Room(roomId);
|
||||||
|
host.setHost(true);
|
||||||
|
room.getPlayers().put(host.getSessionId(), host);
|
||||||
|
rooms.put(roomId, room);
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Room joinRoom(String roomId, Player player) {
|
||||||
|
Room room = rooms.get(roomId);
|
||||||
|
if (room != null) {
|
||||||
|
room.getPlayers().put(player.getSessionId(), player);
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Room> getRoom(String roomId) {
|
||||||
|
return Optional.ofNullable(rooms.get(roomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removePlayer(String sessionId) {
|
||||||
|
// Iterate over rooms to find and remove player
|
||||||
|
// Optimization: Maintain a map of sessionId -> roomId
|
||||||
|
for (Room room : rooms.values()) {
|
||||||
|
if (room.getPlayers().containsKey(sessionId)) {
|
||||||
|
room.getPlayers().remove(sessionId);
|
||||||
|
// Remove from teams as well
|
||||||
|
for (Team team : room.getTeams()) {
|
||||||
|
team.getPlayerIds().remove(sessionId);
|
||||||
|
}
|
||||||
|
if (room.getPlayers().isEmpty()) {
|
||||||
|
rooms.remove(room.getRoomId());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateRoomId() {
|
||||||
|
// Generate a 4-digit code
|
||||||
|
int code = 1000 + random.nextInt(9000);
|
||||||
|
return String.valueOf(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void joinTeam(String roomId, String sessionId, String teamId) {
|
||||||
|
Room room = rooms.get(roomId);
|
||||||
|
if (room != null) {
|
||||||
|
Player player = room.getPlayers().get(sessionId);
|
||||||
|
if (player != null) {
|
||||||
|
// Remove from old team if any
|
||||||
|
if (player.getTeamId() != null) {
|
||||||
|
room.getTeams().stream()
|
||||||
|
.filter(t -> t.getId().equals(player.getTeamId()))
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(t -> t.getPlayerIds().remove(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to new team
|
||||||
|
player.setTeamId(teamId);
|
||||||
|
room.getTeams().stream()
|
||||||
|
.filter(t -> t.getId().equals(teamId))
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(t -> t.getPlayerIds().add(sessionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.example.telegaapp.service;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class WordService {
|
||||||
|
|
||||||
|
private final Map<String, List<String>> wordDictionaries = new HashMap<>();
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
// In a real app, load from JSON files
|
||||||
|
wordDictionaries.put("EASY", Arrays.asList("Яблоко", "Дом", "Машина", "Собака", "Кошка", "Солнце", "Книга", "Телефон"));
|
||||||
|
wordDictionaries.put("HARD", Arrays.asList("Синхрофазотрон", "Экзистенциализм", "Перпендикуляр", "Гипотенуза"));
|
||||||
|
wordDictionaries.put("KIDS", Arrays.asList("Мяч", "Кукла", "Мороженое", "Велосипед"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRandomWord(String difficulty) {
|
||||||
|
List<String> words = wordDictionaries.getOrDefault(difficulty, wordDictionaries.get("EASY"));
|
||||||
|
return words.get(new Random().nextInt(words.size()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
server.port=8080
|
||||||
|
|
||||||
|
telegram.bot.username=AliasSandersBot
|
||||||
|
telegram.bot.token=8352627995:AAEhidu-Qhp3Wx2ir7cVKe2GKuJmVVfcyuQ
|
||||||
|
telegram.webapp.url=https://<your-ngrok-url>/
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-WmiXnQva.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Plf17G8e.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
server.port=8080
|
||||||
|
|
||||||
|
telegram.bot.username=AliasSandersBot
|
||||||
|
telegram.bot.token=8352627995:AAEhidu-Qhp3Wx2ir7cVKe2GKuJmVVfcyuQ
|
||||||
|
telegram.webapp.url=https://<your-ngrok-url>/
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-WmiXnQva.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Plf17G8e.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
Reference in New Issue
Block a user