Implement visual and DiceSoNice effects for critical hits in Daggerheart.

This commit is contained in:
CPTN Cosmo 2026-02-10 01:17:05 +01:00
commit a1a3e74ea7
No known key found for this signature in database
3 changed files with 463 additions and 0 deletions

37
module.json Normal file
View file

@ -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."
}

199
scripts/module.js Normal file
View file

@ -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 = $('<div class="dh-particle-container"></div>');
// Create 12 particles
for (let i = 0; i < 12; i++) {
const particle = $('<div class="dh-ember-particle"></div>');
// 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);
}

227
styles/immersive-crits.css Normal file
View file

@ -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);
}
}