/** * 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 = '
'; if (presentCharacters.length === 0) { // Show placeholder const characters = getCharacters(); const currentCharId = dependencies.getCurrentCharId(); let defaultPortrait = getFallbackAvatar(); let defaultName = 'Character'; if (currentCharId !== undefined && characters[currentCharId]) { defaultPortrait = findCharacterAvatar(characters[currentCharId].name, dependencies); defaultName = characters[currentCharId].name || 'Character'; } html += `
${defaultName}
⚖️
😊 ${defaultName}
Traits
`; } else { // Render character cards for (const char of presentCharacters) { const characterPortrait = findCharacterAvatar(char.name, dependencies); const relationshipEmoji = relationshipEmojis[char.relationship] || '⚖️'; html += `
${char.name}
${relationshipEmoji}
${char.emoji} ${char.name}
${char.traits}
`; } } html += '
'; container.innerHTML = html; attachCharacterHandlers(container, dependencies); }, getConfig() { return { showThoughtsInChat: { type: 'boolean', label: 'Show thought bubbles in chat', default: false }, cardLayout: { type: 'select', label: 'Card Layout', default: 'grid', options: [ { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }, { value: 'compact', label: 'Compact' } ] } }; } }); } /** * Attach character field edit handlers */ function attachCharacterHandlers(container, dependencies) { const editableFields = container.querySelectorAll('.rpg-editable'); editableFields.forEach(field => { const characterName = field.dataset.character; const fieldName = field.dataset.field; let originalValue = field.textContent.trim(); field.addEventListener('focus', () => { originalValue = field.textContent.trim(); const range = document.createRange(); range.selectNodeContents(field); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }); field.addEventListener('blur', () => { const value = field.textContent.trim(); if (value && value !== originalValue) { updateCharacterThoughtsField(dependencies, characterName, fieldName, value); } }); field.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); field.blur(); } if (e.key === 'Escape') { e.preventDefault(); field.textContent = originalValue; field.blur(); } }); // Prevent paste with formatting field.addEventListener('paste', (e) => { e.preventDefault(); const text = (e.clipboardData || window.clipboardData).getData('text/plain'); document.execCommand('insertText', false, text); }); }); }