Hooks.once('init', () => { // Register Settings game.settings.register('dh-immersive-crits', 'critAnimation', { name: 'Critical Hit Animation', hint: 'Select the visual effect for critical hit chat cards.', scope: 'world', config: true, type: String, choices: { "none": "None", "embers": "Embers", "pulse": "Pulse", "arcane": "Arcane", "holy": "Holy", "necrotic": "Necrotic", "nature": "Nature" }, default: 'embers' }); game.settings.register('dh-immersive-crits', 'pulseColor', { name: 'Pulse Color', hint: 'Select the color for the Pulse animation.', scope: 'world', config: true, type: String, default: '#ff0000' }); game.settings.register('dh-immersive-crits', 'dsnCritEffect', { name: 'DiceSoNice Critical Effect', hint: 'Select the DiceSoNice special effect to play on critical hits.', scope: 'world', config: true, type: String, choices: { "none": "None", "WhiteGlow": "White Glow", "Darkness": "Darkness", "GlassImpact": "Glass Impact", "Confetti": "Confetti", "DoubleSpirals": "Double Spirals", "Blaze": "Blaze", "MagicVortex": "Magic Vortex" }, default: 'Blaze' }); }); // Hook to handle conditional settings in the UI Hooks.on('renderSettingsConfig', (app, html, data) => { // Search for our specific inputs with safer selectors (ending with key) const $html = $(html); const animSelect = $html.find('select[name$="critAnimation"]'); const colorInput = $html.find('input[name$="pulseColor"]'); // Ensure we found the color input to transform it if (colorInput.length) { // Enforce Color Picker Type using prop for property change colorInput.prop('type', 'color'); // Ensure standard height for the picker colorInput.css({ 'height': '28px', 'width': '50px', 'padding': '0', 'border': 'none', 'cursor': 'pointer', 'background': 'none' }); // If the value is somehow empty, set a default if (!colorInput.val()) colorInput.val('#ff0000'); } // Handle Conditional Visibility if (animSelect.length && colorInput.length) { // Try to find the container. .form-group is standard, but sometimes it's direct parent const colorGroup = colorInput.closest('.form-group'); const toggleColor = () => { const val = animSelect.val(); // Show only if 'pulse' is selected if (val === 'pulse') { colorGroup.show(); } else { colorGroup.hide(); } }; // Initial check toggleColor(); // Bind change event animSelect.on('change', toggleColor); } }); // Helper to determine if a roll is a critical hit based on module rules function isCriticalHit(roll) { if (!roll) return false; // Check for Duality Roll (Class or Structure) // DSN might pass a BaseRoll, so we check properties or terms const isDuality = roll.constructor.name === "DualityRoll" || (roll.dHope && roll.dFear) || (roll.terms && roll.terms.filter(t => t.faces === 12).length === 2); if (isDuality) { // Exclude reaction rolls const isReaction = roll.options?.actionType === "reaction"; if (isReaction) return false; let hopeTotal, fearTotal; if (roll.dHope?.total !== undefined && roll.dFear?.total !== undefined) { hopeTotal = roll.dHope.total; fearTotal = roll.dFear.total; } else { // Fallback: extract from terms const d12s = roll.terms.filter(t => t.faces === 12); if (d12s.length >= 2) { // Assuming standard order or just checking equality // In Duality, Hope and Fear are the two d12s. Equality is what matters. hopeTotal = d12s[0].total; fearTotal = d12s[1].total; } } if (hopeTotal !== undefined && fearTotal !== undefined) { return hopeTotal === fearTotal; } } // Check for standard d20 Roll (GM rolls mainly) if (roll.constructor.name === "D20Roll") { if (roll.d20?.total === 20) return true; } return false; } // Hook into renderChatMessageHTML (replacement for renderChatMessage) Hooks.on('renderChatMessageHTML', (message, html, data) => { // Only proceed if we have a roll if (!message.rolls || message.rolls.length === 0) return; const roll = message.rolls[0]; if (isCriticalHit(roll)) { const anim = game.settings.get('dh-immersive-crits', 'critAnimation'); if (anim && anim !== 'none') { const $html = $(html); const content = $html.find('.message-content'); content.addClass(`dh-crit-${anim}`); // Apply custom color for Pulse if (anim === 'pulse') { const pulseColor = game.settings.get('dh-immersive-crits', 'pulseColor'); content.css('--dh-crit-pulse-color', pulseColor); } // Apply random scale for Holy if (anim === 'holy') { const scale = 0.8 + Math.random() * 0.5; // 0.8 - 1.3 scale content.css('--dh-holy-scale', scale); } // Inject particles for Embers to allow individual randomization if (anim === 'embers') { const particleContainer = $('
'); // Create 12 particles for (let i = 0; i < 12; i++) { const particle = $(''); // Randomize properties via CSS variables const duration = 2 + Math.random() * 3; // 2-5s const delay = Math.random() * 2; // 0-2s delay const left = Math.random() * 100; // 0-100% width const size = 3 + Math.random() * 4; // 3-7px const drift = 20 + Math.random() * 60; // 20-80px right drift (more variance) const opacity = 0.85 + Math.random() * 0.15; // 0.85 - 1.0 peak opacity particle.css({ '--dh-anim-duration': `${duration}s`, '--dh-anim-delay': `${delay}s`, '--dh-left': `${left}%`, '--dh-size': `${size}px`, '--dh-drift': `${drift}px`, '--dh-opacity-peak': opacity }); particleContainer.append(particle); } content.append(particleContainer); } // Also add a general class for easier targeting if needed $html.addClass('dh-critical-hit'); } } }); // Hook into DiceSoNice to trigger effects Hooks.on('diceSoNiceRollStart', (messageId, context) => { const roll = context.roll; if (!roll) return; if (isCriticalHit(roll)) { const effect = game.settings.get('dh-immersive-crits', 'dsnCritEffect'); if (effect && effect !== 'none') { // Function to apply effect to a die/term const applyEffect = (term) => { if (!term.options) term.options = {}; // 'effect' key from settings is now the direct Class Name (PascalCase) term.options.sfx = { specialEffect: effect }; }; // Check if roll.dice exists and has elements if (roll.dice && roll.dice.length > 0) { roll.dice.forEach(die => applyEffect(die)); } else { // Determine if we need to iterate terms for Duality Rolls if (roll.terms) { roll.terms.forEach(term => { if (term.faces) { // It's a die applyEffect(term); } }); } } } } }); function capitalize(s) { if (typeof s !== 'string') return ''; return s.charAt(0).toUpperCase() + s.slice(1); }