Implement visual and DiceSoNice effects for critical hits in Daggerheart.
This commit is contained in:
commit
a1a3e74ea7
3 changed files with 463 additions and 0 deletions
37
module.json
Normal file
37
module.json
Normal 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
199
scripts/module.js
Normal 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
227
styles/immersive-crits.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue