first commit
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
useParams,
|
||||
Link,
|
||||
useNavigate,
|
||||
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 navigate = useNavigate();
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickAction = (action: string) => {
|
||||
setInput(action);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user