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