commit a1a3e74ea7d6517b083e45e494724e924b1872b6 Author: CPTN Cosmo Date: Tue Feb 10 01:17:05 2026 +0100 Implement visual and DiceSoNice effects for critical hits in Daggerheart. diff --git a/module.json b/module.json new file mode 100644 index 0000000..11567d9 --- /dev/null +++ b/module.json @@ -0,0 +1,37 @@ +{ + "id": "dh-immersive-crits", + "title": "Immersive Crits for Daggerheart", + "version": "1.0.0", + "compatibility": { + "minimum": "13", + "verified": "13" + }, + "authors": [ + { + "name": "CPTN Cosmo", + "email": "cptncosmo@gmail.com", + "url": "https://github.com/cptn-cosmo", + "discord": "cptn_cosmo", + "flags": {} + } + ], + "relationships": { + "systems": [ + { + "id": "daggerheart", + "type": "system", + "compatibility": {} + } + ] + }, + "esmodules": [ + "scripts/module.js" + ], + "styles": [ + "styles/immersive-crits.css" + ], + "url": "https://github.com/cptn-cosmo/dh-immersive-crits", + "manifest": "https://github.com/cptn-cosmo/dh-immersive-crits/releases/latest/download/module.json", + "download": "https://github.com/cptn-cosmo/dh-immersive-crits/releases/download/1.0.0/dh-immersive-crits.zip", + "description": "Immersive crits for Daggerheart." +} \ No newline at end of file diff --git a/scripts/module.js b/scripts/module.js new file mode 100644 index 0000000..d42fe21 --- /dev/null +++ b/scripts/module.js @@ -0,0 +1,199 @@ +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 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 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 (isCriticalHit(roll)) { + const effect = game.settings.get('dh-immersive-crits', 'dsnCritEffect'); + if (effect && effect !== 'none') { + roll.dice.forEach(die => { + if (!die.options) die.options = {}; + die.options.specialEffect = effect === 'default' ? 'Stars' : capitalize(effect); + }); + } + } +}); + +function capitalize(s) { + if (typeof s !== 'string') return ''; + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/styles/immersive-crits.css b/styles/immersive-crits.css new file mode 100644 index 0000000..25b71b6 --- /dev/null +++ b/styles/immersive-crits.css @@ -0,0 +1,227 @@ +/* Critical Hit Animations */ + +.dh-critical-hit { + position: relative; + overflow: hidden; + /* Ensure effects stay within the card */ + z-index: 0; +} + +/* Ensure content stays above effects */ +.dh-critical-hit>* { + position: relative; + z-index: 1; +} + +/* Embers Animation - Refactored for softer look */ +/* Embers Animation - JS Particles */ +.dh-crit-embers { + position: relative; + /* Container styles if needed */ +} + +.dh-particle-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 0; + /* Behind text but visible */ + overflow: visible; + /* Let them drift out a bit if needed, or hidden to clip */ + overflow: hidden; +} + +.dh-ember-particle { + position: absolute; + bottom: -10px; + /* Start just below */ + left: var(--dh-left, 50%); + width: var(--dh-size, 4px); + height: var(--dh-size, 4px); + background: radial-gradient(circle, #ffcc00, #ff4500); + border-radius: 50%; + opacity: 0; + mix-blend-mode: screen; + box-shadow: 0 0 4px #ff4500; + + /* Animation */ + animation: ember-float var(--dh-anim-duration, 3s) linear infinite; + animation-delay: var(--dh-anim-delay, 0s); +} + +@keyframes ember-float { + 0% { + transform: translate(0, 0) scale(1); + opacity: 0; + } + + 10% { + opacity: var(--dh-opacity-peak, 1); + } + + 80% { + opacity: var(--dh-opacity-peak, 1); + } + + 100% { + /* Drift right and up */ + transform: translate(var(--dh-drift, 50px), -150px) scale(0.5); + opacity: 0; + } +} + + +/* Pulse Animation - Replaces Glow */ +.dh-crit-pulse { + /* --dh-crit-pulse-color is set via JS */ + box-shadow: 0 0 15px var(--dh-crit-pulse-color, #ff0000), inset 0 0 15px var(--dh-crit-pulse-color, #ff0000); + animation: pulse-effect 2s infinite ease-in-out; +} + +@keyframes pulse-effect { + + 0%, + 100% { + box-shadow: 0 0 15px var(--dh-crit-pulse-color, #ff0000), inset 0 0 15px var(--dh-crit-pulse-color, #ff0000); + } + + 50% { + box-shadow: 0 0 25px var(--dh-crit-pulse-color, #ff0000), inset 0 0 25px var(--dh-crit-pulse-color, #ff0000); + } +} + +/* Arcane Animation */ +.dh-crit-arcane::before { + content: ""; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: conic-gradient(from 0deg, transparent 0%, rgba(138, 43, 226, 0.2) 20%, transparent 40%); + animation: arcane-spin 6s infinite linear; + pointer-events: none; + z-index: -1; + mix-blend-mode: color-dodge; +} + +.dh-crit-arcane { + box-shadow: 0 0 10px rgba(138, 43, 226, 0.4); +} + +@keyframes arcane-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* Holy Animation */ +.dh-crit-holy::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(var(--dh-holy-scale, 1)); + width: 0; + height: 0; + box-shadow: 0 0 40px 20px rgba(255, 255, 200, 0.6); + animation: holy-burst 4s infinite ease-in-out; + /* Slower */ + pointer-events: none; + z-index: -1; + border-radius: 50%; +} + +.dh-crit-holy { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 215, 0, 0.1) 100%); + border: 1px solid rgba(255, 215, 0, 0.5); +} + +@keyframes holy-burst { + + 0%, + 100% { + box-shadow: 0 0 30px 10px rgba(255, 255, 200, 0.4); + } + + 50% { + box-shadow: 0 0 50px 30px rgba(255, 215, 0, 0.6); + } +} + +/* Necrotic Animation */ +.dh-crit-necrotic::before { + content: ""; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + /* Seamless Gradient: Transparent -> Color -> Transparent */ + background: linear-gradient(0deg, transparent 0%, rgba(0, 255, 0, 0.2) 50%, transparent 100%); + background-size: 100% 200%; + animation: necrotic-fumes 6s infinite linear; + pointer-events: none; + z-index: -1; + filter: blur(3px) hue-rotate(90deg) contrast(1.2); + mix-blend-mode: hard-light; +} + +.dh-crit-necrotic { + box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.8); + border: 1px solid rgba(46, 139, 87, 0.5); + /* Softer border */ +} + +@keyframes necrotic-fumes { + 0% { + background-position: 0 0; + } + + 100% { + background-position: 0 -200%; + } +} + +/* Nature Animation */ +.dh-crit-nature::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: + radial-gradient(circle at 20% 30%, rgba(34, 139, 34, 0.3) 0%, transparent 20%), + radial-gradient(circle at 80% 70%, rgba(107, 142, 35, 0.3) 0%, transparent 20%); + animation: nature-breathe 4s infinite ease-in-out; + pointer-events: none; + z-index: -1; + filter: blur(5px); +} + +.dh-crit-nature { + border-left: 2px solid #228b22; + border-right: 2px solid #228b22; +} + +@keyframes nature-breathe { + + 0%, + 100% { + opacity: 0.4; + transform: scale(1); + } + + 50% { + opacity: 0.8; + transform: scale(1.05); + } +} \ No newline at end of file