493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|