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", "default": "Default", "fire": "Fire", "ice": "Ice", "force": "Force", "lightning": "Lightning", "magic": "Magic", "stars": "Stars", "thunder": "Thunder" }, default: 'default' }); }); // 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 if (roll.constructor.name === "DualityRoll") { // Exclude reaction rolls const isReaction = roll.options?.actionType === "reaction"; if (isReaction) return false; // Check Hope == Fear if (!roll.dHope?.total || !roll.dFear?.total) return false; return roll.dHope.total === roll.dFear.total; } // Check for standard d20 Roll (GM rolls mainly) if (roll.constructor.name === "D20Roll") { if (roll.d20?.total === 20) return true; } return false; } // Hook into renderChatMessage to add CSS classes Hooks.on('renderChatMessage', (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 // Hook into DiceSoNice to trigger effects Hooks.on('diceSoNiceRollStart', (messageId, context) => { console.log('DSN Hook Fired', messageId, context); const roll = context.roll; if (!roll) return; if (isCriticalHit(roll)) { console.log('DSN: Critical Hit Detected'); const effect = game.settings.get('dh-immersive-crits', 'dsnCritEffect'); console.log('DSN Effect:', effect); if (effect && effect !== 'none') { // Check if roll.dice exists and has elements if (roll.dice && roll.dice.length > 0) { roll.dice.forEach(die => { if (!die.options) die.options = {}; die.options.specialEffect = effect === 'default' ? 'Stars' : capitalize(effect); console.log('Applied effect to die:', die); }); } else { // Determine if we need to iterate terms for Duality Rolls console.log('No roll.dice found. Checking terms:', roll.terms); if (roll.terms) { roll.terms.forEach(term => { if (term.faces) { // It's a die if (!term.options) term.options = {}; term.options.specialEffect = effect === 'default' ? 'Stars' : capitalize(effect); console.log('Applied effect to term:', term); } }); } } } } else { console.log('DSN: Not a Critical Hit', roll.constructor.name, roll); } }); function capitalize(s) { if (typeof s !== 'string') return ''; return s.charAt(0).toUpperCase() + s.slice(1); }