/** * Present Characters Widget * * Displays character cards for all characters present in the scene. * Shows: * - Character avatars (matched via fuzzy name matching) * - Character emoji and name * - Traits (status, demeanor) * - Relationship badges (Enemy/Neutral/Friend/Lover) * * All fields are editable and sync back to character thoughts data. */ /** * Fuzzy name matching for character avatars * Handles exact matches, parenthetical additions, and titles */ function namesMatch(cardName, aiName) { if (!cardName || !aiName) return false; // Exact match if (cardName.toLowerCase() === aiName.toLowerCase()) return true; // Strip parentheses and match const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim(); const cardCore = stripParens(cardName).toLowerCase(); const aiCore = stripParens(aiName).toLowerCase(); if (cardCore === aiCore) return true; // Check if card name appears as complete word in AI name const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`); return wordBoundary.test(aiCore); } /** * Parse character thoughts data * Format: [Emoji]: [Name, Traits] | [Relationship] | [Thoughts] * Or: [Emoji]: [Name, Traits] | [Demeanor] | [Relationship] | [Thoughts] */ function parseCharacterThoughts(thoughtsText) { if (!thoughtsText) return []; const lines = thoughtsText.split('\n'); const presentCharacters = []; let currentChar = null; for (const line of lines) { const trimmed = line.trim(); // Skip headers, dividers, and empty lines if (!trimmed || trimmed.includes('Present Characters') || trimmed.includes('---') || trimmed.startsWith('```')) { continue; } // New character entry (starts with -) if (trimmed.startsWith('-')) { // Save previous character if (currentChar && currentChar.name && currentChar.name.toLowerCase() !== 'unavailable') { presentCharacters.push(currentChar); } // Start new character const name = trimmed.replace(/^-\s*/, '').trim(); currentChar = { name, emoji: '😊', // Default emoji traits: '', relationship: 'Neutral', thoughts: '' }; } // Details line: "Details: 🧐 | Trait1, Trait2 | More traits" else if (trimmed.startsWith('Details:') && currentChar) { const detailsText = trimmed.replace('Details:', '').trim(); const parts = detailsText.split('|').map(p => p.trim()); // First part is emoji if (parts[0]) { currentChar.emoji = parts[0]; } // Remaining parts are traits if (parts.length > 1) { currentChar.traits = parts.slice(1).join(', '); } } // Relationship line: "Relationship: Ally (details)" else if (trimmed.startsWith('Relationship:') && currentChar) { currentChar.relationship = trimmed.replace('Relationship:', '').trim(); } // Thoughts line: "Thoughts: ..." else if (trimmed.startsWith('Thoughts:') && currentChar) { currentChar.thoughts = trimmed.replace('Thoughts:', '').trim() .replace(/^["']|["']$/g, ''); // Remove surrounding quotes } // Stats line: "Stats: ..." (optional, currently ignored but could be stored) else if (trimmed.startsWith('Stats:') && currentChar) { // Optional: could parse and store stats if needed // For now, we'll skip it as the widget doesn't display character stats } // Legacy single-line format fallback: "🧐: Name, Traits | Relationship | Thoughts" else if (trimmed.includes('|') && !currentChar) { const parts = trimmed.split('|').map(p => p.trim()); if (parts.length >= 3) { const firstPart = parts[0].trim(); const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); if (emojiMatch) { const emoji = emojiMatch[1].trim(); const info = emojiMatch[2].trim(); const infoParts = info.split(',').map(p => p.trim()); const name = infoParts[0] || ''; const traits = infoParts.slice(1).join(', '); const relationship = parts[1].trim(); const thoughts = parts[2].trim(); if (name && name.toLowerCase() !== 'unavailable') { presentCharacters.push({ emoji, name, traits, relationship, thoughts }); } } } } } // Save last character if (currentChar && currentChar.name && currentChar.name.toLowerCase() !== 'unavailable') { presentCharacters.push(currentChar); } return presentCharacters; } /** * Find character avatar */ function findCharacterAvatar(charName, dependencies) { const { getCharacters, getGroupMembers, getCurrentCharId, getFallbackAvatar, getAvatarUrl } = dependencies; let avatarUrl = getFallbackAvatar(); // Try group members first if in group chat const groupMembers = getGroupMembers(); if (groupMembers && groupMembers.length > 0) { const matchingMember = groupMembers.find(member => member && member.name && namesMatch(member.name, charName) ); if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { const url = getAvatarUrl('avatar', matchingMember.avatar); if (url) avatarUrl = url; } } // Try all characters if (avatarUrl === getFallbackAvatar()) { const characters = getCharacters(); if (characters && characters.length > 0) { const matchingChar = characters.find(c => c && c.name && namesMatch(c.name, charName) ); if (matchingChar && matchingChar.avatar && matchingChar.avatar !== 'none') { const url = getAvatarUrl('avatar', matchingChar.avatar); if (url) avatarUrl = url; } } } // Try current character in 1-on-1 chat if (avatarUrl === getFallbackAvatar()) { const currentCharId = getCurrentCharId(); const characters = getCharacters(); if (currentCharId !== undefined && characters[currentCharId]) { const currentChar = characters[currentCharId]; if (currentChar.name && namesMatch(currentChar.name, charName)) { const url = getAvatarUrl('avatar', currentChar.avatar); if (url) avatarUrl = url; } } } return avatarUrl; } /** * Update character field in shared data */ function updateCharacterThoughtsField(dependencies, characterName, field, value) { const { getCharacterThoughts, setCharacterThoughts, onDataChange } = dependencies; let thoughtsText = getCharacterThoughts() || ''; const lines = thoughtsText.split('\n'); let updated = false; const updatedLines = lines.map(line => { // Find the line for this character if (line.includes(characterName)) { const parts = line.split('|').map(p => p.trim()); if (parts.length >= 3) { const firstPart = parts[0].trim(); const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); if (emojiMatch) { let emoji = emojiMatch[1].trim(); const info = emojiMatch[2].trim(); const infoParts = info.split(',').map(p => p.trim()); let name = infoParts[0]; let traits = infoParts.slice(1).join(', '); let relationship, thoughts; if (parts.length === 3) { relationship = parts[1].trim(); thoughts = parts[2].trim(); } else { // 4-part format relationship = parts[2].trim(); thoughts = parts[3].trim(); } // Update the specific field if (field === 'emoji') emoji = value; else if (field === 'name') name = value; else if (field === 'traits') traits = value; else if (field === 'relationship') { // Convert emoji to text const relationshipMap = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' }; relationship = relationshipMap[value] || value; } // Reconstruct line const nameAndTraits = traits ? `${name}, ${traits}` : name; updated = true; if (parts.length === 3) { return `${emoji}: ${nameAndTraits} | ${relationship} | ${thoughts}`; } else { return `${emoji}: ${nameAndTraits} | ${parts[1].trim()} | ${relationship} | ${thoughts}`; } } } } return line; }); if (updated) { const newThoughtsText = updatedLines.join('\n'); setCharacterThoughts(newThoughtsText); if (onDataChange) { onDataChange('characterThoughts', field, value, characterName); } } } /** * Register Present Characters Widget */ export function registerPresentCharactersWidget(registry, dependencies) { const relationshipEmojis = { 'Enemy': '⚔️', 'Neutral': '⚖️', 'Friend': '⭐', 'Lover': '❤️' }; registry.register('presentCharacters', { name: 'Present Characters', icon: '👥', description: 'Character cards with avatars, traits, and relationships', category: 'scene', minSize: { w: 2, h: 2 }, // Column-aware sizing: narrow and tall on mobile, wide and short on desktop defaultSize: (columns) => { if (columns <= 2) { return { w: 2, h: 4 }; // Mobile: 2 cols wide (full), 4 rows tall } return { w: 3, h: 2 }; // Desktop: 3 cols wide (full), 2 rows tall (fits 1080p) }, // Column-aware max size: can expand vertically if needed maxAutoSize: (columns) => { if (columns <= 2) { return { w: 2, h: 5 }; } return { w: 3, h: 3 }; // Desktop: can expand to 3 rows if needed }, requiresSchema: false, render(container, config = {}) { const { getCharacterThoughts, getCharacters, getFallbackAvatar } = dependencies; const thoughtsText = getCharacterThoughts(); const presentCharacters = parseCharacterThoughts(thoughtsText); let html = '