This commit is contained in:
2025-12-04 17:25:19 +03:00
parent 54455e5b6c
commit 4e958e338a
44 changed files with 672 additions and 91 deletions
+1
View File
@@ -2,6 +2,7 @@
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>
+3 -3
View File
@@ -68,7 +68,7 @@
- Настройки хоста: Выбор набора слов, длительность раунда, кол-во очков для победы.
- Кнопка "Начать игру" (только для хоста).
- [ ] **3.3. Экран "Игра" (Game)**
- [x] **3.3. Экран "Игра" (Game)**
- **Роль "Объясняющий":**
- Карточка со словом по центру.
- Жесты (Swipe) или кнопки: Вверх/Вправо - Угадал, Вниз/Влево - Пропустил.
@@ -78,12 +78,12 @@
- Таймер.
- Анимация при угадывании.
- [ ] **3.4. Экран "Результаты раунда"**
- [x] **3.4. Экран "Результаты раунда"**
- Список слов, которые были сыграны в раунде (возможность оспорить/изменить статус, если успеем).
- Текущий счет команд.
- Кнопка "Следующий раунд".
- [ ] **3.5. Экран "Победа"**
- [x] **3.5. Экран "Победа"**
- Поздравление победителей.
- Кнопка "В лобби".
+3
View File
@@ -4,6 +4,9 @@
<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" />
<script>
window.global = window;
</script>
<title>frontend</title>
</head>
<body>
+16 -1
View File
@@ -2,6 +2,9 @@ import React from 'react';
import { GameProvider, useGame } from './hooks/GameContext';
import StartScreen from './components/StartScreen';
import Lobby from './components/Lobby';
import Game from './components/Game';
import RoundResults from './components/RoundResults';
import GameOver from './components/GameOver';
import { GameStates } from './types';
import WebApp from '@twa-dev/sdk';
@@ -32,9 +35,21 @@ const AppContent: React.FC = () => {
return <Lobby />;
}
if (room.state === GameStates.PLAYING) {
return <Game />;
}
if (room.state === GameStates.ROUND_FINISHED) {
return <RoundResults />;
}
if (room.state === GameStates.GAME_OVER) {
return <GameOver />;
}
return (
<div className="flex items-center justify-center h-screen text-white">
Game Started! (Coming soon)
State: {room.state} (Coming soon)
</div>
);
};
+136
View File
@@ -0,0 +1,136 @@
import React, { useEffect, useState } from 'react';
import { useGame } from '../hooks/GameContext';
import { motion } from 'framer-motion';
const Game: React.FC = () => {
const { room, currentPlayer, sendGameAction } = useGame();
const [timeLeft, setTimeLeft] = useState(0);
if (!room || !currentPlayer) return null;
const currentTeam = room.teams[room.currentTeamIndex];
// Determine who is describing:
// The `nextDescriberIndex` points to the NEXT player to describe, or the CURRENT one if the round is active?
// In `finishRound` (backend), we increment it. So during the round, it points to the CURRENT describer.
const describerId = currentTeam.playerIds[currentTeam.nextDescriberIndex];
const isMyTurn = currentPlayer.sessionId === describerId;
const isMyTeam = currentPlayer.teamId === currentTeam.id;
useEffect(() => {
if (room.roundEndTime) {
const interval = setInterval(() => {
const remaining = Math.max(0, Math.ceil((room.roundEndTime! - Date.now()) / 1000));
setTimeLeft(remaining);
// If time is up, we could trigger something, but backend controls the flow usually.
// However, to be responsive, we can show "Time's up" or similar.
}, 500);
return () => clearInterval(interval);
}
}, [room.roundEndTime]);
const handleAction = (action: 'GUESS' | 'SKIP') => {
sendGameAction(action);
// Add haptic feedback here if using Telegram SDK
};
return (
<div className="flex flex-col h-screen bg-gray-900 text-white p-4 overflow-hidden">
{/* Header Info */}
<div className="flex justify-between items-start mb-4">
<div className="flex flex-col">
<span className="text-sm text-gray-400">Ход команды:</span>
<span className="text-xl font-bold text-blue-400">{currentTeam.name}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-sm text-gray-400">Время:</span>
<span className={`text-3xl font-mono font-bold ${timeLeft <= 10 ? 'text-red-500' : 'text-white'}`}>
{timeLeft}
</span>
</div>
</div>
{/* Game Area */}
<div className="flex-1 flex flex-col items-center justify-center relative">
{isMyTurn ? (
// Explainer View
<div className="w-full max-w-xs">
<div className="text-center mb-6">
<p className="text-gray-400 text-sm uppercase tracking-widest">Объясняй</p>
</div>
<motion.div
key={room.currentWord} // Animate when word changes
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-white text-gray-900 rounded-2xl p-10 shadow-2xl flex items-center justify-center mb-12 aspect-square"
>
<h2 className="text-4xl font-black text-center break-words">{room.currentWord}</h2>
</motion.div>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => handleAction('SKIP')}
className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-4 rounded-xl transition-colors flex flex-col items-center"
>
<span className="text-2xl mb-1">👎</span>
<span>Пропустить</span>
</button>
<button
onClick={() => handleAction('GUESS')}
className="bg-green-600 hover:bg-green-500 text-white font-bold py-4 rounded-xl transition-colors flex flex-col items-center"
>
<span className="text-2xl mb-1">👍</span>
<span>Угадали</span>
</button>
</div>
<p className="text-center text-xs text-gray-500 mt-6">Свайпы скоро будут доступны</p>
</div>
) : isMyTeam ? (
// Guesser (My Team) View
<div className="text-center">
<div className="mb-8">
<div className="w-20 h-20 bg-blue-500 rounded-full mx-auto flex items-center justify-center text-3xl font-bold mb-4 animate-pulse">
👂
</div>
<h3 className="text-2xl font-bold mb-2">Слушайте внимательно!</h3>
<p className="text-gray-400">
<span className="text-white font-bold">{room.players[describerId]?.name || 'Игрок'}</span> объясняет слово.
</p>
</div>
<div className="p-6 bg-gray-800/50 rounded-xl border border-gray-700">
<p className="text-sm text-gray-400">Текущий счет за раунд</p>
<p className="text-4xl font-bold text-green-400">???</p>
</div>
</div>
) : (
// Spectator (Other Team) View
<div className="text-center opacity-75">
<div className="mb-8">
<div className="w-20 h-20 bg-gray-700 rounded-full mx-auto flex items-center justify-center text-3xl font-bold mb-4">
🤫
</div>
<h3 className="text-xl font-bold mb-2">Тихо!</h3>
<p className="text-gray-400">
Сейчас играет команда противников.
</p>
</div>
</div>
)}
</div>
{/* Footer Score */}
<div className="mt-auto pt-6 border-t border-gray-800 flex justify-around">
{room.teams.map(t => (
<div key={t.id} className={`text-center ${t.id === currentTeam.id ? 'opacity-100' : 'opacity-50'}`}>
<p className="text-xs text-gray-500">{t.name}</p>
<p className="text-xl font-bold">{t.score}</p>
</div>
))}
</div>
</div>
);
};
export default Game;
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
import { useGame } from '../hooks/GameContext';
const GameOver: React.FC = () => {
const { room } = useGame();
if (!room) return null;
// Find winner (team with max score)
const winner = [...room.teams].sort((a, b) => b.score - a.score)[0];
return (
<div className="flex flex-col h-screen bg-gray-900 text-white p-6 items-center justify-center text-center">
<div className="mb-10">
<span className="text-6xl mb-4 block">🏆</span>
<h2 className="text-4xl font-black mb-2 bg-clip-text text-transparent bg-gradient-to-r from-yellow-400 to-yellow-600">
ПОБЕДА!
</h2>
</div>
<div className="bg-gray-800/80 p-8 rounded-3xl border border-yellow-500/30 w-full max-w-sm mb-8 shadow-2xl shadow-yellow-500/10">
<h3 className="text-3xl font-bold text-white mb-2">{winner.name}</h3>
<p className="text-yellow-500 font-mono text-5xl font-bold">{winner.score}</p>
<p className="text-sm text-gray-400 mt-2">очков</p>
</div>
<div className="space-y-4 w-full max-w-sm">
{room.teams.filter(t => t.id !== winner.id).map(t => (
<div key={t.id} className="flex justify-between items-center p-4 bg-gray-800/40 rounded-xl">
<span className="font-bold text-gray-300">{t.name}</span>
<span className="font-mono text-gray-400">{t.score}</span>
</div>
))}
</div>
<button
onClick={() => window.location.reload()}
className="mt-12 text-gray-500 hover:text-white underline transition-colors"
>
В меню
</button>
</div>
);
};
export default GameOver;
+15 -7
View File
@@ -1,9 +1,10 @@
import React from 'react';
import React, { useState } from 'react';
import { useGame } from '../hooks/GameContext';
import type { Team } from '../types';
const Lobby: React.FC = () => {
const { room, currentPlayer, joinTeam } = useGame();
const { room, currentPlayer, joinTeam, startGame } = useGame();
const [difficulty, setDifficulty] = useState('EASY');
if (!room) return null;
@@ -81,13 +82,20 @@ const Lobby: React.FC = () => {
<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
value={difficulty}
onChange={(e) => setDifficulty(e.target.value)}
className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm outline-none"
>
<option value="EASY">EASY</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HARD">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
onClick={() => startGame(difficulty)}
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>
+57
View File
@@ -0,0 +1,57 @@
import React from 'react';
import { useGame } from '../hooks/GameContext';
const RoundResults: React.FC = () => {
const { room, currentPlayer, startNextRound } = useGame();
if (!room) return null;
// The currentTeamIndex has already been incremented in backend by finishRound.
// So "previous" team just finished.
const prevTeamIndex = (room.currentTeamIndex - 1 + room.teams.length) % room.teams.length;
const teamJustPlayed = room.teams[prevTeamIndex];
const nextTeam = room.teams[room.currentTeamIndex];
const nextDescriberId = nextTeam.playerIds[nextTeam.nextDescriberIndex];
const nextDescriber = room.players[nextDescriberId];
return (
<div className="flex flex-col h-screen bg-gray-900 text-white p-6 items-center justify-center text-center">
<div className="mb-10">
<h2 className="text-3xl font-bold mb-2">Раунд завершен!</h2>
<p className="text-gray-400">Ход переходит к следующей команде</p>
</div>
<div className="bg-gray-800/50 p-6 rounded-2xl border border-gray-700 w-full max-w-sm mb-8">
<p className="text-sm text-gray-400 mb-1">Команда</p>
<h3 className="text-2xl font-bold text-blue-400 mb-4">{teamJustPlayed.name}</h3>
<div className="flex justify-between items-center">
<span className="text-gray-300">Всего очков:</span>
<span className="text-3xl font-bold text-green-400">{teamJustPlayed.score}</span>
</div>
</div>
<div className="mb-10">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-2">Далее</p>
<h4 className="text-xl font-bold">{nextTeam.name}</h4>
<p className="text-gray-400">
Объясняет: <span className="text-white font-bold">{nextDescriber?.name || '???'}</span>
</p>
</div>
{currentPlayer?.host ? (
<button
onClick={startNextRound}
className="w-full max-w-xs py-4 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-xl text-lg shadow-lg active:scale-95 transition-all"
>
Следующий раунд
</button>
) : (
<p className="text-sm text-gray-500 animate-pulse">Ожидание хоста...</p>
)}
</div>
);
};
export default RoundResults;
+19
View File
@@ -88,6 +88,25 @@ const StartScreen: React.FC = () => {
</button>
</div>
</div>
<button
onClick={async () => {
try {
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: "Hello from frontend test" })
});
const data = await response.text();
console.log("API Test Response:", data);
} catch (error) {
console.error("API Test Error:", error);
}
}}
className="w-full max-w-sm mt-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-bold rounded-xl shadow-lg transform transition-all active:scale-95"
>
Test API
</button>
</div>
);
};
+49 -13
View File
@@ -1,3 +1,4 @@
import SockJS from 'sockjs-client';
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { Client } from '@stomp/stompjs';
import type { IMessage } from '@stomp/stompjs';
@@ -11,6 +12,9 @@ interface GameContextType {
createRoom: (playerName: string, userId?: string) => void;
joinRoom: (roomId: string, playerName: string, userId?: string) => void;
joinTeam: (teamId: string) => void;
startGame: (difficulty: string) => void;
sendGameAction: (action: 'GUESS' | 'SKIP') => void;
startNextRound: () => void;
}
const GameContext = createContext<GameContextType | null>(null);
@@ -30,19 +34,24 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
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`;
useEffect(() => {
// Determine WebSocket URL (http:// or https:// for SockJS)
const isDev = window.location.hostname === 'localhost' && window.location.port === '5173';
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
// SockJS endpoint is typically HTTP/HTTPS, not WS/WSS
const sockJsEndpoint = isDev
? 'http://localhost:8080/ws'
: `${protocol}//${window.location.host}/ws`;
const stompClient = new Client({
brokerURL: brokerURL,
reconnectDelay: 5000,
debug: (str) => {
console.log(str);
},
onConnect: (frame) => {
console.log('Connected: ' + frame);
console.log("Connecting to WebSocket (SockJS) at:", sockJsEndpoint);
const stompClient = new Client({
webSocketFactory: () => new SockJS(sockJsEndpoint),
reconnectDelay: 5000,
debug: (str) => {
console.log(str);
},
onConnect: (frame) => { console.log('Connected: ' + frame);
setConnected(true);
// Subscribe to user-specific errors
@@ -124,8 +133,35 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, [client, connected, room]);
const startGame = useCallback((difficulty: string) => {
if (client && connected && room) {
client.publish({
destination: '/app/start',
body: JSON.stringify({ roomId: room.roomId, difficulty }),
});
}
}, [client, connected, room]);
const sendGameAction = useCallback((action: 'GUESS' | 'SKIP') => {
if (client && connected && room) {
client.publish({
destination: '/app/game-action',
body: JSON.stringify({ roomId: room.roomId, action }),
});
}
}, [client, connected, room]);
const startNextRound = useCallback(() => {
if (client && connected && room) {
client.publish({
destination: '/app/next-round',
body: JSON.stringify({ roomId: room.roomId }),
});
}
}, [client, connected, room]);
return (
<GameContext.Provider value={{ connected, room, currentPlayer, error, createRoom, joinRoom, joinTeam }}>
<GameContext.Provider value={{ connected, room, currentPlayer, error, createRoom, joinRoom, joinTeam, startGame, sendGameAction, startNextRound }}>
{children}
</GameContext.Provider>
);
+2 -1
View File
@@ -21,6 +21,7 @@ export interface Team {
name: string;
score: number;
playerIds: string[];
nextDescriberIndex: number;
}
export interface Settings {
@@ -36,6 +37,6 @@ export interface Room {
players: Record<string, Player>; // Map sessionId -> Player
settings: Settings;
currentTeamIndex: number;
currentDescriberIndex: number;
roundEndTime?: number;
currentWord?: string;
}
+67
View File
@@ -50,7 +50,74 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.15.0</version>
<configuration>
<workingDirectory>frontend</workingDirectory>
<installDirectory>target</installDirectory>
</configuration>
<executions>
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>v20.11.0</nodeVersion>
</configuration>
</execution>
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>install</arguments>
</configuration>
</execution>
<execution>
<id>npm run build</id>
<goals>
<goal>npm</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-frontend-resources</id>
<phase>process-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.outputDirectory}/static</outputDirectory>
<resources>
<resource>
<directory>frontend/dist</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
@@ -0,0 +1,18 @@
package com.example.telegaapp.config;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
import java.security.Principal;
import java.util.Map;
import java.util.UUID;
public class CustomHandshakeHandler extends DefaultHandshakeHandler {
@Override
protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
String uuid = UUID.randomUUID().toString();
System.out.println("CustomHandshakeHandler: Assigning Principal: " + uuid);
return new StompPrincipal(uuid);
}
}
@@ -0,0 +1,16 @@
package com.example.telegaapp.config;
import java.security.Principal;
public class StompPrincipal implements Principal {
private final String name;
public StompPrincipal(String name) {
this.name = name;
}
@Override
public String getName() {
return name;
}
}
@@ -29,27 +29,8 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
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;
}
.setAllowedOriginPatterns("*") // Allow all origins
.setHandshakeHandler(new CustomHandshakeHandler())
.withSockJS(); // Enable SockJS
}
}
@@ -1,8 +1,6 @@
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.dto.*;
import com.example.telegaapp.model.Player;
import com.example.telegaapp.model.Room;
import com.example.telegaapp.service.GameService;
@@ -22,6 +20,10 @@ public class SocketController {
@MessageMapping("/create")
public void createRoom(@Payload CreateRequest request, SimpMessageHeaderAccessor headerAccessor) {
if (headerAccessor.getUser() == null) {
System.err.println("Error: User is null in createRoom. SessionID: " + headerAccessor.getSessionId());
return;
}
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);
@@ -37,6 +39,10 @@ public class SocketController {
@MessageMapping("/join")
public void joinRoom(@Payload JoinRequest request, SimpMessageHeaderAccessor headerAccessor) {
if (headerAccessor.getUser() == null) {
System.err.println("Error: User is null in joinRoom. SessionID: " + headerAccessor.getSessionId());
return;
}
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);
@@ -47,6 +53,9 @@ public class SocketController {
// Send the player info back to the user
messagingTemplate.convertAndSendToUser(sessionId, "/queue/player-info", player);
// Send the room info back to the user
messagingTemplate.convertAndSendToUser(sessionId, "/queue/created", room);
// Notify everyone in the room
messagingTemplate.convertAndSend("/topic/room/" + room.getRoomId(), room);
} else {
@@ -57,6 +66,10 @@ public class SocketController {
@MessageMapping("/team/join")
public void joinTeam(@Payload TeamJoinRequest request, SimpMessageHeaderAccessor headerAccessor) {
if (headerAccessor.getUser() == null) {
System.err.println("Error: User is null in joinTeam.");
return;
}
String sessionId = headerAccessor.getUser().getName();
gameService.joinTeam(request.getRoomId(), sessionId, request.getTeamId());
@@ -64,4 +77,42 @@ public class SocketController {
messagingTemplate.convertAndSend("/topic/room/" + room.getRoomId(), room);
});
}
@MessageMapping("/start")
public void startGame(@Payload StartGameRequest request, SimpMessageHeaderAccessor headerAccessor) {
if (headerAccessor.getUser() == null) {
System.err.println("Error: User is null in startGame.");
return;
}
gameService.startGame(request.getRoomId(), request.getDifficulty());
gameService.getRoom(request.getRoomId()).ifPresent(room -> {
messagingTemplate.convertAndSend("/topic/room/" + room.getRoomId(), room);
});
}
@MessageMapping("/game-action")
public void gameAction(@Payload GameActionRequest request, SimpMessageHeaderAccessor headerAccessor) {
if (headerAccessor.getUser() == null) {
return;
}
gameService.handleGameAction(request);
gameService.getRoom(request.getRoomId()).ifPresent(room -> {
messagingTemplate.convertAndSend("/topic/room/" + room.getRoomId(), room);
});
}
@MessageMapping("/next-round")
public void nextRound(@Payload StartGameRequest request, SimpMessageHeaderAccessor headerAccessor) {
// Reusing StartGameRequest as it contains roomId
if (headerAccessor.getUser() == null) {
return;
}
gameService.nextRound(request.getRoomId());
gameService.getRoom(request.getRoomId()).ifPresent(room -> {
messagingTemplate.convertAndSend("/topic/room/" + room.getRoomId(), room);
});
}
}
@@ -0,0 +1,9 @@
package com.example.telegaapp.dto;
import lombok.Data;
@Data
public class GameActionRequest {
private String roomId;
private String action; // "GUESS" or "SKIP"
}
@@ -0,0 +1,9 @@
package com.example.telegaapp.dto;
import lombok.Data;
@Data
public class StartGameRequest {
private String roomId;
private String difficulty;
}
@@ -16,8 +16,8 @@ public class Room {
// 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
private String currentWord;
public Room(String roomId) {
this.roomId = roomId;
@@ -31,7 +31,6 @@ public class Room {
this.teams.add(new Team("Team B"));
this.currentTeamIndex = 0;
this.currentDescriberIndex = 0;
}
@Data
@@ -11,11 +11,13 @@ public class Team {
private String name;
private int score;
private List<String> playerIds; // List of sessionIds/userIds
private int nextDescriberIndex;
public Team(String name) {
this.id = UUID.randomUUID().toString();
this.name = name;
this.score = 0;
this.playerIds = new ArrayList<>();
this.nextDescriberIndex = 0;
}
}
@@ -1,8 +1,10 @@
package com.example.telegaapp.service;
import com.example.telegaapp.dto.GameActionRequest;
import com.example.telegaapp.model.Player;
import com.example.telegaapp.model.Room;
import com.example.telegaapp.model.Team;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Map;
@@ -11,10 +13,12 @@ import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class GameService {
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
private final Random random = new Random();
private final WordService wordService;
public Room createRoom(Player host) {
String roomId = generateRoomId();
@@ -83,4 +87,84 @@ public class GameService {
}
}
}
public void startGame(String roomId, String difficulty) {
Room room = rooms.get(roomId);
if (room != null) {
// Validate teams (at least 2 teams with 1 player each, or 1 team with 2 players for testing? Let's stick to standard)
long teamsWithPlayers = room.getTeams().stream().filter(t -> !t.getPlayerIds().isEmpty()).count();
if (teamsWithPlayers < 2) {
// For now, just log or ignore. In real app, throw exception or send error.
// But for "dev" mode let's allow it or assume players know what they are doing.
}
room.setState(com.example.telegaapp.model.GameState.PLAYING);
room.getSettings().setDifficulty(difficulty);
// Start Round
startRound(room);
}
}
private void startRound(Room room) {
room.setRoundEndTime(System.currentTimeMillis() + room.getSettings().getRoundTimeSeconds() * 1000L);
room.setCurrentWord(wordService.getRandomWord(room.getSettings().getDifficulty()));
}
public void handleGameAction(GameActionRequest request) {
Room room = rooms.get(request.getRoomId());
if (room != null && room.getState() == com.example.telegaapp.model.GameState.PLAYING) {
// Check if round ended
if (System.currentTimeMillis() > room.getRoundEndTime()) {
finishRound(room);
return;
}
if ("GUESS".equals(request.getAction())) {
Team currentTeam = room.getTeams().get(room.getCurrentTeamIndex());
currentTeam.setScore(currentTeam.getScore() + 1);
}
// Next word
room.setCurrentWord(wordService.getRandomWord(room.getSettings().getDifficulty()));
}
}
public void checkRoundTimer(String roomId) {
Room room = rooms.get(roomId);
if (room != null && room.getState() == com.example.telegaapp.model.GameState.PLAYING) {
if (System.currentTimeMillis() > room.getRoundEndTime()) {
finishRound(room);
}
}
}
private void finishRound(Room room) {
Team currentTeam = room.getTeams().get(room.getCurrentTeamIndex());
// Check for win condition
if (currentTeam.getScore() >= room.getSettings().getWordsToWin()) {
room.setState(com.example.telegaapp.model.GameState.GAME_OVER);
return;
}
room.setState(com.example.telegaapp.model.GameState.ROUND_FINISHED);
// Rotate Team
// Rotate describer for this team
int nextIdx = (currentTeam.getNextDescriberIndex() + 1) % Math.max(1, currentTeam.getPlayerIds().size());
currentTeam.setNextDescriberIndex(nextIdx);
// Move to next team
int nextTeamIdx = (room.getCurrentTeamIndex() + 1) % room.getTeams().size();
room.setCurrentTeamIndex(nextTeamIdx);
}
public void nextRound(String roomId) {
Room room = rooms.get(roomId);
if (room != null && room.getState() == com.example.telegaapp.model.GameState.ROUND_FINISHED) {
room.setState(com.example.telegaapp.model.GameState.PLAYING);
startRound(room);
}
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -4,9 +4,12 @@
<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" />
<script>
window.global = window;
</script>
<title>frontend</title>
<script type="module" crossorigin src="/assets/index-WmiXnQva.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Plf17G8e.css">
<script type="module" crossorigin src="/assets/index-CtkN2yzV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-Uqvy09.css">
</head>
<body>
<div id="root"></div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -4,9 +4,12 @@
<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" />
<script>
window.global = window;
</script>
<title>frontend</title>
<script type="module" crossorigin src="/assets/index-WmiXnQva.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Plf17G8e.css">
<script type="module" crossorigin src="/assets/index-CtkN2yzV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-Uqvy09.css">
</head>
<body>
<div id="root"></div>