diff --git a/src/pages/GamePage.css b/src/pages/GamePage.css index 3a46ba4..37354d5 100644 --- a/src/pages/GamePage.css +++ b/src/pages/GamePage.css @@ -237,10 +237,12 @@ .game-content { flex: 1; min-height: 0; + min-width: 0; display: flex; flex-direction: column; overflow: hidden; position: relative; + width: 100%; } .scroll-to-bottom-btn { @@ -272,6 +274,7 @@ .messages-container { flex: 1; overflow-y: auto; + overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; touch-action: pan-y; @@ -282,11 +285,15 @@ display: flex; flex-direction: column; gap: 0.75rem; + width: 100%; + box-sizing: border-box; } .message { max-width: 88%; animation: fadeIn 0.25s ease; + word-wrap: break-word; + overflow-wrap: break-word; } @keyframes fadeIn { @@ -714,6 +721,121 @@ margin-left: 2px; } +/* Game Layout with Character Panel */ +.game-layout { + flex: 1; + display: flex; + min-height: 0; + min-width: 0; + overflow: hidden; + position: relative; + width: 100%; +} + +.character-panel { + width: 350px; + min-width: 350px; + height: 100%; + position: relative; + overflow: hidden; + flex-shrink: 0; + background: #0a0a0a; + border-right: 1px solid rgba(255, 255, 255, 0.08); +} + +.character-panel img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: top center; +} + +.character-panel-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 1rem; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.9)); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.character-panel-name { + font-size: 1.25rem; + font-weight: 600; + color: #fff; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8); +} + +.character-panel-role { + font-size: 0.85rem; + color: #aaa; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); +} + +/* Desktop layout */ +@media (min-width: 769px) { + .game-layout.has-character .game-content { + flex: 1; + } +} + +/* Mobile: character as background */ +@media (max-width: 768px) { + .character-panel { + display: none; + } + + .game-layout { + flex-direction: column; + } + + .game-layout .game-content { + width: 100%; + max-width: 100%; + } + + .game-layout.has-character { + position: relative; + } + + .game-layout.has-character::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: var(--character-bg); + background-size: cover; + background-position: top center; + opacity: 0.15; + pointer-events: none; + z-index: 0; + } + + .game-layout.has-character .game-content { + position: relative; + z-index: 1; + } + + .game-layout.has-character .messages-container { + background: transparent; + } + + .game-layout.has-character .message { + background: transparent; + } + + .game-layout.has-character .input-container { + background: rgba(10, 10, 10, 0.9); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } +} + @keyframes blink { 0%, 50% { diff --git a/src/pages/GamePage.tsx b/src/pages/GamePage.tsx index 7cbdfd3..17aa419 100644 --- a/src/pages/GamePage.tsx +++ b/src/pages/GamePage.tsx @@ -26,6 +26,7 @@ import type { ChatMessage, PlayerCharacter, MessageVersion, + Character, } from "../types"; import "./GamePage.css"; @@ -65,6 +66,9 @@ export default function GamePage() { const [showScrollButton, setShowScrollButton] = useState(false); const [editingMessageId, setEditingMessageId] = useState(null); const [editContent, setEditContent] = useState(""); + const [activeCharacter, setActiveCharacter] = useState( + null, + ); const abortControllerRef = useRef(null); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); @@ -83,6 +87,71 @@ export default function GamePage() { sessionRef.current = session; }, [session]); + // Determine active character from message content + const detectActiveCharacter = useCallback( + (content: string, characters: Character[]): Character | null => { + if (!characters || characters.length === 0) return null; + + const contentLower = content.toLowerCase(); + + // Look for character name at the beginning of message or in dialogue + for (const char of characters) { + if (!char.name) continue; + + // Split name into parts and check each (e.g., "Принцеса Лапис" -> ["принцеса", "лапис"]) + const nameParts = char.name.toLowerCase().split(/\s+/); + + for (const namePart of nameParts) { + if (namePart.length < 3) continue; // Skip short words like "и", "в", etc. + + // Create regex to match whole word only + const wordBoundary = new RegExp( + `(^|[\\s.,!?;:"'«»—\\-])${namePart}([\\s.,!?;:"'«»—\\-]|$)`, + "i", + ); + + // Check if name appears as a whole word in first 200 characters + const firstPart = contentLower.substring(0, 200); + if (wordBoundary.test(firstPart)) { + return char; + } + } + } + return null; + }, + [], + ); + + // Update active character based on last assistant message + useEffect(() => { + if (!story?.characters || !session?.messages) return; + + // Find last assistant message + const lastAssistantMsg = [...session.messages] + .reverse() + .find((m) => m.role === "assistant"); + + if (lastAssistantMsg) { + const detected = detectActiveCharacter( + lastAssistantMsg.content, + story.characters, + ); + if (detected && detected.avatarUrl) { + setActiveCharacter(detected); + } + } + }, [session?.messages, story?.characters, detectActiveCharacter]); + + // Also update during streaming + useEffect(() => { + if (!story?.characters || !streamingContent) return; + + const detected = detectActiveCharacter(streamingContent, story.characters); + if (detected && detected.avatarUrl) { + setActiveCharacter(detected); + } + }, [streamingContent, story?.characters, detectActiveCharacter]); + // Warn before leaving if there are unsaved changes useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -892,127 +961,155 @@ export default function GamePage() { -
-
- {session?.messages.map((message) => ( -
- {editingMessageId === message.id ? ( -
-