dh-immersive-crits/scripts/module.js

235 lines
8.3 KiB
JavaScript

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 (Class or Structure)
// DSN might pass a BaseRoll, so we check properties or terms
const isDuality = roll.constructor.name === "DualityRoll" ||
(roll.dHope && roll.dFear) ||
(roll.terms && roll.terms.filter(t => t.faces === 12).length === 2);
if (isDuality) {
// Exclude reaction rolls
const isReaction = roll.options?.actionType === "reaction";
if (isReaction) return false;
let hopeTotal, fearTotal;
if (roll.dHope?.total !== undefined && roll.dFear?.total !== undefined) {
hopeTotal = roll.dHope.total;
fearTotal = roll.dFear.total;
} else {
// Fallback: extract from terms
const d12s = roll.terms.filter(t => t.faces === 12);
if (d12s.length >= 2) {
// Assuming standard order or just checking equality
// In Duality, Hope and Fear are the two d12s. Equality is what matters.
hopeTotal = d12s[0].total;
fearTotal = d12s[1].total;
}
}
if (hopeTotal !== undefined && fearTotal !== undefined) {
return hopeTotal === fearTotal;
}
}
// Check for standard d20 Roll (GM rolls mainly)
if (roll.constructor.name === "D20Roll") {
if (roll.d20?.total === 20) return true;
}
return false;
}
// Hook into renderChatMessageHTML (replacement for renderChatMessage)
Hooks.on('renderChatMessageHTML', (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 = $('<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
// Hook into DiceSoNice to trigger effects
Hooks.on('diceSoNiceRollStart', (messageId, context) => {
const roll = context.roll;
if (!roll) return;
if (isCriticalHit(roll)) {
const effect = game.settings.get('dh-immersive-crits', 'dsnCritEffect');
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);
});
} else {
// Determine if we need to iterate terms for Duality Rolls
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);
}
});
}
}
}
}
});
function capitalize(s) {
if (typeof s !== 'string') return '';
return s.charAt(0).toUpperCase() + s.slice(1);
}