Files
ReSekai/src/pages/GamePage.tsx
T
2026-02-11 00:26:34 +01:00

493 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from "react";
import {
useParams,
Link,
useSearchParams,
} from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { useAuth } from "../contexts/AuthContext";
import {
getStory,
getSession,
saveSession as apiSaveSession,
getPlayerCharacter,
} from "../services/api";
import {
generateStoryResponse,
buildSystemPrompt,
sendMessage,
generateStorySummary,
extractKeyEvents,
} from "../services/deepseek";
import type {
Story,
GameSession,
ChatMessage,
PlayerCharacter,
} from "../types";
import "./GamePage.css";
function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
function estimateTokens(messages: ChatMessage[]): number {
if (!messages || messages.length === 0) return 0;
const totalChars = messages.reduce((sum, msg) => sum + msg.content.length, 0);
return Math.round(totalChars / 3);
}
function formatTokens(tokens: number): string {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
return tokens.toString();
}
// Пытаемся определить локацию из последних сообщений
function detectLocation(messages: ChatMessage[]): string {
if (!messages || messages.length === 0) return "Неизвестно";
// Берём последние 3 сообщения ассистента
const recentAssistant = messages
.filter((m) => m.role === "assistant")
.slice(-3)
.map((m) => m.content)
.join(" ");
// Паттерны для определения локации
const locationPatterns = [
/(?:находи(?:тесь|шься)|оказыва(?:етесь|ешься)|стои(?:те|шь))\s+(?:в|на|у)\s+([^.,!?]+)/i,
/(?:вошл[аи]?|входи(?:те|шь)|попада(?:ете|ешь))\s+(?:в|на)\s+([^.,!?]+)/i,
/(?:прибыл[аи]?|приход(?:ите|ишь)|добрал(?:ись|ась|ся))\s+(?:в|на|до)\s+([^.,!?]+)/i,
/(?:комнат[аеуы]|зал[аеуы]?|пещер[аеуы]|лес[ау|замок|двор(?:ец)?|тавер[нуыа]|город[ауе]?|деревн[яюией]|тронн[ыйая]|подземель[яеи])\s*([^.,!?]*)/i,
];
for (const pattern of locationPatterns) {
const match = recentAssistant.match(pattern);
if (match && match[1]) {
// Чистим и обрезаем результат
let location = match[1].trim();
if (location.length > 25) location = location.substring(0, 25) + "...";
return location;
}
}
// Простой поиск ключевых слов
const simpleLocations: [RegExp, string][] = [
[/тронн(?:ый|ого|ом)\s*зал/i, "Тронный зал"],
[/тавер[нуыа]/i, "Таверна"],
[/замок|замк[ауе]/i, "Замок"],
[/лес[ау]?/i, "Лес"],
[/пещер[аеуы]/i, "Пещера"],
[/город[ауе]?/i, "Город"],
[/деревн[яюией]/i, "Деревня"],
[/подземель[яеи]/i, "Подземелье"],
[/двор(?:ец|ц[ауе])/i, "Дворец"],
[/рын(?:ок|к[ауе])/i, "Рынок"],
[/храм[ауе]?/i, "Храм"],
[/библиотек/i, "Библиотека"],
[/казарм/i, "Казармы"],
];
for (const [pattern, name] of simpleLocations) {
if (pattern.test(recentAssistant)) {
return name;
}
}
return "Неизвестно";
}
export default function GamePage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const { isAuthenticated } = useAuth();
const [story, setStory] = useState<Story | null>(null);
const [session, setSession] = useState<GameSession | null>(null);
const [playerCharacter, setPlayerCharacter] =
useState<PlayerCharacter | null>(null);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const loadGame = async () => {
if (!id || !isAuthenticated) {
setIsInitialLoading(false);
return;
}
const foundStory = await getStory(id);
if (foundStory) {
const normalizedStory = {
...foundStory,
id: (foundStory as any)._id || foundStory.id,
};
setStory(normalizedStory);
let existingSession = await getSession(id);
console.log(
"[GamePage] Loaded session:",
existingSession?.messages?.length || 0,
"messages",
);
const characterId = searchParams.get("character");
// Загружаем персонажа
let character: PlayerCharacter | null = null;
if (characterId) {
character = await getPlayerCharacter(characterId);
setPlayerCharacter(character);
} else if (existingSession?.playerId) {
character = await getPlayerCharacter(existingSession.playerId);
setPlayerCharacter(character);
}
if (!existingSession) {
console.log("[GamePage] No existing session, creating new");
existingSession = {
storyId: id,
playerId: characterId || undefined,
messages: [],
currentState: {
location: "Неизвестно",
health: 100,
inventory: [],
questProgress: {},
},
};
} else if (characterId && existingSession.playerId !== characterId) {
// Новый персонаж — новая сессия
console.log("[GamePage] New character selected, resetting session");
existingSession = {
storyId: id,
playerId: characterId,
messages: [],
currentState: {
location: "Неизвестно",
health: 100,
inventory: [],
questProgress: {},
},
};
} else {
console.log(
"[GamePage] Using existing session with",
existingSession.messages.length,
"messages",
);
}
setSession(existingSession);
// Начинаем историю если это новая сессия
if (existingSession.messages.length === 0 && character) {
startStory(normalizedStory, existingSession, character);
}
}
setIsInitialLoading(false);
};
loadGame();
}, [id, isAuthenticated, searchParams]);
// Обновляем историю при возврате на страницу (после редактирования)
useEffect(() => {
const handleFocus = async () => {
if (!id || !isAuthenticated || isInitialLoading) return;
const updatedStory = await getStory(id);
if (updatedStory) {
const normalizedStory = {
...updatedStory,
id: (updatedStory as any)._id || updatedStory.id,
};
setStory(normalizedStory);
console.log("[GamePage] История обновлена после возврата на страницу");
}
};
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [id, isAuthenticated, isInitialLoading]);
useEffect(() => {
scrollToBottom();
}, [session?.messages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const startStory = async (
storyData: Story,
sessionData: GameSession,
character: PlayerCharacter,
) => {
// Если есть заготовленное первое сообщение, используем его
if (storyData.firstMessage && storyData.firstMessage.trim()) {
// Заменяем {user} на имя персонажа
const firstMessageContent = storyData.firstMessage.replace(
/\{user\}/gi,
character.name,
);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: firstMessageContent,
timestamp: new Date(),
};
const updatedSession: GameSession = {
...sessionData,
messages: [assistantMessage],
};
await apiSaveSession(storyData.id, updatedSession);
setSession(updatedSession);
return;
}
// Иначе генерируем через ИИ
setIsLoading(true);
setError(null);
try {
const systemPrompt = buildSystemPrompt(storyData, character);
const response = await sendMessage([
{ role: "system", content: systemPrompt },
{ role: "user", content: "Начни историю." },
]);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const updatedSession: GameSession = {
...sessionData,
messages: [assistantMessage],
};
await apiSaveSession(storyData.id, updatedSession);
setSession(updatedSession);
} catch (err) {
setError(err instanceof Error ? err.message : "Произошла ошибка");
} finally {
setIsLoading(false);
}
};
const handleSend = async () => {
if (!input.trim() || !story || !session || isLoading) return;
const userMessage: ChatMessage = {
id: generateId(),
role: "user",
content: input.trim(),
timestamp: new Date(),
};
const updatedMessages = [...session.messages, userMessage];
const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession);
setInput("");
setIsLoading(true);
setError(null);
try {
// Передаём session для оптимизированного контекста
const response = await generateStoryResponse(
story,
session.messages,
input.trim(),
playerCharacter || undefined,
session,
);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const allMessages = [...updatedMessages, assistantMessage];
// Обновляем ключевые события
const newKeyEvents = await extractKeyEvents(
response,
session.keyEvents || [],
);
// Генерируем сводку каждые 20 сообщений
let newSummary = session.storySummary;
if (allMessages.length % 20 === 0 && allMessages.length > 0) {
console.log("[GamePage] Generating story summary...");
newSummary = await generateStorySummary(
story,
allMessages,
session.storySummary,
);
}
const finalSession: GameSession = {
...session,
messages: allMessages,
keyEvents: newKeyEvents,
storySummary: newSummary,
};
const saved = await apiSaveSession(story.id, finalSession);
console.log(
"[GamePage] Session saved:",
saved,
"Messages:",
allMessages.length,
);
setSession(finalSession);
} catch (err) {
setError(err instanceof Error ? err.message : "Произошла ошибка");
// Откатываем сообщение пользователя при ошибке
setSession(session);
setInput(userMessage.content);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (isInitialLoading) {
return (
<div className="game-page">
<div className="game-loading">
<p>Загрузка игры...</p>
</div>
</div>
);
}
if (!story) {
return (
<div className="game-page">
<div className="game-error">
<h2>История не найдена</h2>
<Link to="/" className="back-link">
Вернуться к списку
</Link>
</div>
</div>
);
}
return (
<div className="game-page">
<header className="game-header">
<Link to={`/story/${story.id}`} className="back-btn">
</Link>
<div className="header-info">
<h1>{story.title}</h1>
<span className="header-protagonist">
👤 {playerCharacter?.name || "Герой"}
</span>
</div>
<div className="header-stats">
<span className="stat-badge tokens">
🎟 {formatTokens(estimateTokens(session?.messages || []))}
</span>
<span className="stat-badge location">
📍 {detectLocation(session?.messages || [])}
</span>
</div>
</header>
<div className="game-content">
<div className="messages-container">
{session?.messages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
<div className="message-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
<span className="message-time">
{new Date(message.timestamp).toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
))}
{isLoading && (
<div className="message assistant loading">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
)}
{error && (
<div className="error-message">
<span> {error}</span>
<button onClick={() => setError(null)}></button>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* RPG кнопки скрыты — раскомментировать при необходимости
<div className="quick-actions">
<button onClick={() => handleQuickAction("Осмотреться вокруг")}>
👀 Осмотреться
</button>
<button onClick={() => handleQuickAction("Проверить инвентарь")}>
🎒 Инвентарь
</button>
<button onClick={() => handleQuickAction("Поговорить с кем-нибудь")}>
💬 Говорить
</button>
<button onClick={() => handleQuickAction("Идти вперёд")}>
🚶 Идти
</button>
</div>
*/}
<div className="input-container">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Что ты хочешь сделать?..."
disabled={isLoading}
rows={2}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="send-btn"
>
{isLoading ? "⏳" : "➤"}
</button>
</div>
</div>
</div>
);
}