@carmaclouds/core 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cache/CacheManager.d.ts.map +1 -0
- package/dist/cache/CacheManager.js +131 -0
- package/dist/cache/CacheManager.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/ir/index.d.ts +11 -0
- package/dist/ir/index.d.ts.map +1 -0
- package/dist/ir/index.js +9 -0
- package/dist/ir/index.js.map +1 -0
- package/dist/ir/normalize.d.ts +10 -0
- package/dist/ir/normalize.d.ts.map +1 -0
- package/dist/ir/normalize.js +207 -0
- package/dist/ir/normalize.js.map +1 -0
- package/dist/ir/persistence.d.ts +26 -0
- package/dist/ir/persistence.d.ts.map +1 -0
- package/dist/ir/persistence.js +21 -0
- package/dist/ir/persistence.js.map +1 -0
- package/dist/ir/sync.d.ts +12 -0
- package/dist/ir/sync.d.ts.map +1 -0
- package/dist/ir/sync.js +36 -0
- package/dist/ir/sync.js.map +1 -0
- package/dist/ir/types.d.ts +143 -0
- package/dist/ir/types.d.ts.map +1 -0
- package/dist/ir/types.js +13 -0
- package/dist/ir/types.js.map +1 -0
- package/dist/ir/views/dnd5e.d.ts +40 -0
- package/dist/ir/views/dnd5e.d.ts.map +1 -0
- package/dist/ir/views/dnd5e.js +50 -0
- package/dist/ir/views/dnd5e.js.map +1 -0
- package/dist/render/character.d.ts +19 -0
- package/dist/render/character.d.ts.map +1 -0
- package/dist/render/character.js +156 -0
- package/dist/render/character.js.map +1 -0
- package/dist/render/h.d.ts +27 -0
- package/dist/render/h.d.ts.map +1 -0
- package/dist/render/h.js +64 -0
- package/dist/render/h.js.map +1 -0
- package/dist/render/index.d.ts +11 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +8 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/mount.d.ts +31 -0
- package/dist/render/mount.d.ts.map +1 -0
- package/dist/render/mount.js +63 -0
- package/dist/render/mount.js.map +1 -0
- package/dist/supabase/fields.d.ts.map +1 -0
- package/dist/supabase/fields.js +120 -0
- package/dist/supabase/fields.js.map +1 -0
- package/dist/types/character.d.ts.map +1 -0
- package/dist/types/character.js +5 -0
- package/dist/types/character.js.map +1 -0
- package/package.json +73 -0
- package/src/browser.js +51 -0
- package/src/cache/CacheManager.ts +174 -0
- package/src/common/browser-polyfill.js +319 -0
- package/src/common/debug.js +123 -0
- package/src/common/html-utils.js +134 -0
- package/src/common/theme-manager.js +265 -0
- package/src/index.ts +25 -0
- package/src/ir/__fixtures__/dnd5e-character.json +75962 -0
- package/src/ir/__fixtures__/non-dnd-character.json +14218 -0
- package/src/ir/index.ts +10 -0
- package/src/ir/normalize.ts +245 -0
- package/src/ir/persistence.ts +37 -0
- package/src/ir/sync.ts +49 -0
- package/src/ir/types.ts +161 -0
- package/src/ir/views/dnd5e.ts +94 -0
- package/src/lib/indexeddb-cache.js +320 -0
- package/src/modules/action-announcements.js +102 -0
- package/src/modules/action-display.js +1557 -0
- package/src/modules/action-executor.js +860 -0
- package/src/modules/action-filters.js +167 -0
- package/src/modules/action-options.js +117 -0
- package/src/modules/card-creator.js +142 -0
- package/src/modules/character-portrait.js +169 -0
- package/src/modules/character-trait-popups.js +959 -0
- package/src/modules/character-traits.js +814 -0
- package/src/modules/class-feature-edge-cases.js +1320 -0
- package/src/modules/color-utils.js +69 -0
- package/src/modules/combat-maneuver-edge-cases.js +660 -0
- package/src/modules/companions-manager.js +178 -0
- package/src/modules/concentration-tracker.js +178 -0
- package/src/modules/data-manager.js +514 -0
- package/src/modules/dice-roller.js +719 -0
- package/src/modules/effects-manager.js +743 -0
- package/src/modules/feature-modals.js +1264 -0
- package/src/modules/formula-resolver.js +444 -0
- package/src/modules/gm-mode.js +184 -0
- package/src/modules/health-modals.js +399 -0
- package/src/modules/hp-management.js +752 -0
- package/src/modules/inventory-manager.js +242 -0
- package/src/modules/macro-system.js +825 -0
- package/src/modules/notification-system.js +92 -0
- package/src/modules/racial-feature-edge-cases.js +746 -0
- package/src/modules/resource-manager.js +775 -0
- package/src/modules/sheet-builder.js +654 -0
- package/src/modules/spell-action-modals.js +583 -0
- package/src/modules/spell-cards.js +602 -0
- package/src/modules/spell-casting.js +723 -0
- package/src/modules/spell-display.js +314 -0
- package/src/modules/spell-edge-cases.js +509 -0
- package/src/modules/spell-macros.js +201 -0
- package/src/modules/spell-modals.js +1221 -0
- package/src/modules/spell-slots.js +224 -0
- package/src/modules/status-bar-bridge.js +101 -0
- package/src/modules/ui-utilities.js +284 -0
- package/src/modules/warlock-invocations.js +219 -0
- package/src/modules/window-management.js +211 -0
- package/src/render/character.ts +234 -0
- package/src/render/h.ts +74 -0
- package/src/render/index.ts +10 -0
- package/src/render/mount.ts +94 -0
- package/src/supabase/client.js +1383 -0
- package/src/supabase/config.js +60 -0
- package/src/supabase/fields.ts +129 -0
- package/src/types/character.ts +85 -0
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spell Modals Module
|
|
3
|
+
*
|
|
4
|
+
* Handles modal dialogs for spell casting interactions.
|
|
5
|
+
* - Main spell casting modal with options
|
|
6
|
+
* - Upcast selection modal
|
|
7
|
+
* - Resource choice modal
|
|
8
|
+
* - Option click handlers
|
|
9
|
+
*
|
|
10
|
+
* Loaded as a plain script (no ES6 modules) to export to globalThis.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
(function() {
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Show spell casting modal with options
|
|
18
|
+
* @param {object} spell - Spell object
|
|
19
|
+
* @param {number} spellIndex - Spell index
|
|
20
|
+
* @param {Array} options - Array of spell options
|
|
21
|
+
* @param {boolean} descriptionAnnounced - Whether spell description was already announced
|
|
22
|
+
*/
|
|
23
|
+
function showSpellModal(spell, spellIndex, options, descriptionAnnounced = false) {
|
|
24
|
+
// Get theme-aware colors
|
|
25
|
+
const colors = getPopupThemeColors();
|
|
26
|
+
|
|
27
|
+
// Check for custom macros
|
|
28
|
+
const customMacros = getCustomMacros(spell.name);
|
|
29
|
+
const hasCustomMacros = customMacros && customMacros.buttons && customMacros.buttons.length > 0;
|
|
30
|
+
|
|
31
|
+
// Create modal overlay
|
|
32
|
+
const overlay = document.createElement('div');
|
|
33
|
+
overlay.className = 'spell-modal-overlay';
|
|
34
|
+
overlay.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10000;';
|
|
35
|
+
|
|
36
|
+
// Create modal content
|
|
37
|
+
const modal = document.createElement('div');
|
|
38
|
+
modal.className = 'spell-modal';
|
|
39
|
+
modal.style.cssText = `background: ${colors.background}; padding: 24px; border-radius: 8px; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 4px 20px rgba(0,0,0,0.3);`;
|
|
40
|
+
|
|
41
|
+
// Modal header
|
|
42
|
+
const header = document.createElement('div');
|
|
43
|
+
header.style.cssText = `margin-bottom: 16px; padding-bottom: 12px; border-bottom: 2px solid ${colors.border};`;
|
|
44
|
+
|
|
45
|
+
// Format spell level text
|
|
46
|
+
let levelText = '';
|
|
47
|
+
if (spell.level === 0) {
|
|
48
|
+
levelText = `<div style="color: ${colors.infoText}; font-size: 14px;">Cantrip</div>`;
|
|
49
|
+
} else if (spell.level) {
|
|
50
|
+
levelText = `<div style="color: ${colors.infoText}; font-size: 14px;">Level ${spell.level} Spell</div>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
header.innerHTML = `
|
|
54
|
+
<h2 style="margin: 0 0 8px 0; color: ${colors.heading};">Cast ${spell.name}</h2>
|
|
55
|
+
${levelText}
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
modal.appendChild(header);
|
|
59
|
+
|
|
60
|
+
// Slot selection (for leveled spells)
|
|
61
|
+
let slotSelect = null;
|
|
62
|
+
if (spell.level && spell.level > 0) {
|
|
63
|
+
const slotSection = document.createElement('div');
|
|
64
|
+
slotSection.style.cssText = `margin-bottom: 16px; padding: 12px; background: ${colors.infoBox}; border-radius: 6px;`;
|
|
65
|
+
|
|
66
|
+
const slotLabel = document.createElement('label');
|
|
67
|
+
slotLabel.style.cssText = `display: block; margin-bottom: 8px; font-weight: bold; color: ${colors.text};`;
|
|
68
|
+
slotLabel.textContent = 'Cast at level:';
|
|
69
|
+
|
|
70
|
+
slotSelect = document.createElement('select');
|
|
71
|
+
slotSelect.style.cssText = `width: 100%; padding: 8px; border: 2px solid ${colors.border}; border-radius: 4px; font-size: 14px; background: ${colors.background}; color: ${colors.text};`;
|
|
72
|
+
|
|
73
|
+
// Check for Pact Magic slots (Warlock) - these are SEPARATE from regular spell slots
|
|
74
|
+
// Check both spellSlots and otherVariables since data may come from either source
|
|
75
|
+
// DiceCloud uses various variable names: pactSlot, pactMagicSlots, pactSlotLevelVisible, etc.
|
|
76
|
+
const pactMagicSlotLevel = characterData.spellSlots?.pactMagicSlotLevel ||
|
|
77
|
+
characterData.otherVariables?.pactMagicSlotLevel ||
|
|
78
|
+
characterData.otherVariables?.pactSlotLevelVisible ||
|
|
79
|
+
characterData.otherVariables?.pactSlotLevel ||
|
|
80
|
+
characterData.otherVariables?.slotLevel;
|
|
81
|
+
const pactMagicSlots = characterData.spellSlots?.pactMagicSlots ??
|
|
82
|
+
characterData.otherVariables?.pactMagicSlots ??
|
|
83
|
+
characterData.otherVariables?.pactSlot ?? 0;
|
|
84
|
+
const pactMagicSlotsMax = characterData.spellSlots?.pactMagicSlotsMax ??
|
|
85
|
+
characterData.otherVariables?.pactMagicSlotsMax ??
|
|
86
|
+
characterData.otherVariables?.pactSlotMax ?? 0;
|
|
87
|
+
const hasPactMagic = pactMagicSlotsMax > 0;
|
|
88
|
+
// Default slot level to 1 if we have slots but couldn't detect level
|
|
89
|
+
const effectivePactLevel = pactMagicSlotLevel || (hasPactMagic ? 5 : 0); // Default to max (5) if level unknown
|
|
90
|
+
|
|
91
|
+
debug.log(`🔮 Pact Magic check: level=${pactMagicSlotLevel} (effective=${effectivePactLevel}), slots=${pactMagicSlots}/${pactMagicSlotsMax}, hasPact=${hasPactMagic}`);
|
|
92
|
+
|
|
93
|
+
// Add options for available spell slots (spell level and higher)
|
|
94
|
+
let hasAnySlots = false;
|
|
95
|
+
let hasRegularSlots = false;
|
|
96
|
+
let firstValidOption = null;
|
|
97
|
+
|
|
98
|
+
// First, add Pact Magic slots if available and spell level is compatible
|
|
99
|
+
// Pact Magic slots can cast any spell from level 1 up to the pact slot level
|
|
100
|
+
if (hasPactMagic && spell.level <= effectivePactLevel) {
|
|
101
|
+
hasAnySlots = true;
|
|
102
|
+
const option = document.createElement('option');
|
|
103
|
+
option.value = `pact:${effectivePactLevel}`; // Special format to identify pact slots
|
|
104
|
+
option.textContent = `Level ${effectivePactLevel} - Pact Magic (${pactMagicSlots}/${pactMagicSlotsMax})`;
|
|
105
|
+
option.disabled = pactMagicSlots === 0;
|
|
106
|
+
slotSelect.appendChild(option);
|
|
107
|
+
if (!option.disabled && !firstValidOption) {
|
|
108
|
+
firstValidOption = option;
|
|
109
|
+
}
|
|
110
|
+
debug.log(`🔮 Added Pact Magic slot option: Level ${effectivePactLevel} (${pactMagicSlots}/${pactMagicSlotsMax})`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Then add regular spell slots (excluding the pact magic level to avoid duplicates)
|
|
114
|
+
for (let level = spell.level; level <= 9; level++) {
|
|
115
|
+
const slotsProp = `level${level}SpellSlots`;
|
|
116
|
+
const maxSlotsProp = `level${level}SpellSlotsMax`;
|
|
117
|
+
let available = characterData.spellSlots?.[slotsProp] || characterData[slotsProp] || 0;
|
|
118
|
+
let max = characterData.spellSlots?.[maxSlotsProp] || characterData[maxSlotsProp] || 0;
|
|
119
|
+
|
|
120
|
+
// If this level has Pact Magic, subtract pact slots from the total (they're counted separately)
|
|
121
|
+
if (hasPactMagic && level === effectivePactLevel) {
|
|
122
|
+
available = Math.max(0, available - pactMagicSlots);
|
|
123
|
+
max = Math.max(0, max - pactMagicSlotsMax);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (max > 0) {
|
|
127
|
+
hasAnySlots = true;
|
|
128
|
+
hasRegularSlots = true;
|
|
129
|
+
const option = document.createElement('option');
|
|
130
|
+
option.value = level; // Regular level number for normal slots
|
|
131
|
+
option.textContent = `Level ${level} (${available}/${max} slots)`;
|
|
132
|
+
option.disabled = available === 0;
|
|
133
|
+
slotSelect.appendChild(option);
|
|
134
|
+
if (!option.disabled && !firstValidOption) {
|
|
135
|
+
firstValidOption = option;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Select the first valid (non-disabled) option
|
|
141
|
+
if (firstValidOption) {
|
|
142
|
+
firstValidOption.selected = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// If no slots available at all, show a message
|
|
146
|
+
if (!hasAnySlots) {
|
|
147
|
+
const noSlotsOption = document.createElement('option');
|
|
148
|
+
noSlotsOption.value = spell.level;
|
|
149
|
+
noSlotsOption.textContent = 'No spell slots available';
|
|
150
|
+
noSlotsOption.disabled = true;
|
|
151
|
+
noSlotsOption.selected = true;
|
|
152
|
+
slotSelect.appendChild(noSlotsOption);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// If ONLY Pact Magic slots exist (no regular spell slots), don't show the dropdown
|
|
156
|
+
// Instead, automatically use the Pact Magic slot level
|
|
157
|
+
if (hasPactMagic && !hasRegularSlots && spell.level <= effectivePactLevel) {
|
|
158
|
+
// Store the auto-selected Pact Magic level on the modal for button handlers to use
|
|
159
|
+
modal.dataset.autoSlotLevel = `pact:${effectivePactLevel}`;
|
|
160
|
+
debug.log(`🔮 Auto-selecting Pact Magic level ${effectivePactLevel} (no regular slots available)`);
|
|
161
|
+
// Don't append the slot selection UI - it's not needed
|
|
162
|
+
} else {
|
|
163
|
+
// Show the dropdown since there are multiple slot options
|
|
164
|
+
slotSection.appendChild(slotLabel);
|
|
165
|
+
slotSection.appendChild(slotSelect);
|
|
166
|
+
modal.appendChild(slotSection);
|
|
167
|
+
|
|
168
|
+
// Store reference to update button labels later
|
|
169
|
+
// (will be set after buttons are created)
|
|
170
|
+
slotSelect.updateButtonLabels = null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Concentration spell recast option OR special spells that allow reuse without slots
|
|
175
|
+
// (if already concentrating on this spell, or for spells like Spiritual Weapon, Meld into Stone)
|
|
176
|
+
// NOTE: Cantrips (level 0) never use slots, so don't show this checkbox for them
|
|
177
|
+
let skipSlotCheckbox = null;
|
|
178
|
+
const isCantrip = spell.level === 0;
|
|
179
|
+
const isConcentrationRecast = spell.concentration && concentratingSpell === spell.name;
|
|
180
|
+
|
|
181
|
+
// Spells that allow repeated use without consuming slots (non-concentration)
|
|
182
|
+
// Exclude cantrips since they never use slots anyway
|
|
183
|
+
const isReuseableSpellType = !isCantrip && isReuseableSpell(spell.name, characterData);
|
|
184
|
+
|
|
185
|
+
// Check if this spell was already cast (stored in localStorage or session)
|
|
186
|
+
const castSpellsKey = `castSpells_${characterData.name}`;
|
|
187
|
+
const castSpells = JSON.parse(localStorage.getItem(castSpellsKey) || '[]');
|
|
188
|
+
const wasAlreadyCast = castSpells.includes(spell.name);
|
|
189
|
+
|
|
190
|
+
// Show checkbox for concentration recasts OR for all reuseable spells (even on first cast)
|
|
191
|
+
// But NOT for cantrips since they never consume slots
|
|
192
|
+
if (!isCantrip && (isConcentrationRecast || isReuseableSpellType)) {
|
|
193
|
+
const recastSection = document.createElement('div');
|
|
194
|
+
recastSection.style.cssText = 'margin-bottom: 16px; padding: 12px; background: #fff3cd; border-radius: 6px; border: 2px solid #f39c12;';
|
|
195
|
+
|
|
196
|
+
const checkboxContainer = document.createElement('label');
|
|
197
|
+
checkboxContainer.style.cssText = 'display: flex; align-items: center; gap: 8px; cursor: pointer;';
|
|
198
|
+
|
|
199
|
+
skipSlotCheckbox = document.createElement('input');
|
|
200
|
+
skipSlotCheckbox.type = 'checkbox';
|
|
201
|
+
// Default checked if concentration recast OR if reuseable spell was already cast
|
|
202
|
+
skipSlotCheckbox.checked = isConcentrationRecast || wasAlreadyCast;
|
|
203
|
+
skipSlotCheckbox.style.cssText = 'width: 20px; height: 20px;';
|
|
204
|
+
|
|
205
|
+
const checkboxLabel = document.createElement('span');
|
|
206
|
+
checkboxLabel.style.cssText = 'font-weight: bold; color: #856404;';
|
|
207
|
+
if (isConcentrationRecast) {
|
|
208
|
+
checkboxLabel.textContent = '🧠 Already concentrating - don\'t consume spell slot';
|
|
209
|
+
} else if (wasAlreadyCast) {
|
|
210
|
+
checkboxLabel.textContent = '⚔️ Spell already active - don\'t consume spell slot';
|
|
211
|
+
} else {
|
|
212
|
+
checkboxLabel.textContent = '⚔️ Reuse spell effect without consuming slot (first cast required)';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
checkboxContainer.appendChild(skipSlotCheckbox);
|
|
216
|
+
checkboxContainer.appendChild(checkboxLabel);
|
|
217
|
+
recastSection.appendChild(checkboxContainer);
|
|
218
|
+
|
|
219
|
+
const helpText = document.createElement('div');
|
|
220
|
+
helpText.style.cssText = 'font-size: 0.85em; color: #856404; margin-top: 6px; margin-left: 28px;';
|
|
221
|
+
if (isConcentrationRecast) {
|
|
222
|
+
helpText.textContent = 'You can use this spell\'s effect again while concentrating on it without recasting.';
|
|
223
|
+
} else {
|
|
224
|
+
helpText.textContent = 'You can use this spell\'s effect again while it\'s active without recasting.';
|
|
225
|
+
}
|
|
226
|
+
recastSection.appendChild(helpText);
|
|
227
|
+
|
|
228
|
+
modal.appendChild(recastSection);
|
|
229
|
+
|
|
230
|
+
// If skip slot is checked, disable slot selection
|
|
231
|
+
skipSlotCheckbox.addEventListener('change', () => {
|
|
232
|
+
if (slotSelect) {
|
|
233
|
+
slotSelect.disabled = skipSlotCheckbox.checked;
|
|
234
|
+
slotSelect.style.opacity = skipSlotCheckbox.checked ? '0.5' : '1';
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Initialize disabled state
|
|
239
|
+
if (slotSelect && skipSlotCheckbox.checked) {
|
|
240
|
+
slotSelect.disabled = true;
|
|
241
|
+
slotSelect.style.opacity = '0.5';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Metamagic options (if character has metamagic features)
|
|
246
|
+
// Only the 8 official Sorcerer metamagic options from PHB
|
|
247
|
+
const metamagicCheckboxes = [];
|
|
248
|
+
const validMetamagicNames = [
|
|
249
|
+
'Careful Spell',
|
|
250
|
+
'Distant Spell',
|
|
251
|
+
'Empowered Spell',
|
|
252
|
+
'Extended Spell',
|
|
253
|
+
'Heightened Spell',
|
|
254
|
+
'Quickened Spell',
|
|
255
|
+
'Subtle Spell',
|
|
256
|
+
'Twinned Spell'
|
|
257
|
+
];
|
|
258
|
+
const metamagicFeatures = characterData.features ? characterData.features.filter(f =>
|
|
259
|
+
f.name && validMetamagicNames.includes(f.name)
|
|
260
|
+
) : [];
|
|
261
|
+
|
|
262
|
+
if (metamagicFeatures.length > 0) {
|
|
263
|
+
const metamagicSection = document.createElement('div');
|
|
264
|
+
metamagicSection.style.cssText = `margin-bottom: 16px; padding: 12px; background: ${colors.infoBox}; border-radius: 6px; border: 1px solid ${colors.border};`;
|
|
265
|
+
|
|
266
|
+
const metamagicTitle = document.createElement('div');
|
|
267
|
+
metamagicTitle.style.cssText = `font-weight: bold; margin-bottom: 8px; color: ${colors.text};`;
|
|
268
|
+
metamagicTitle.textContent = 'Metamagic:';
|
|
269
|
+
metamagicSection.appendChild(metamagicTitle);
|
|
270
|
+
|
|
271
|
+
metamagicFeatures.forEach(feature => {
|
|
272
|
+
const checkboxContainer = document.createElement('label');
|
|
273
|
+
checkboxContainer.style.cssText = 'display: flex; align-items: center; gap: 8px; margin-bottom: 4px; cursor: pointer;';
|
|
274
|
+
|
|
275
|
+
const checkbox = document.createElement('input');
|
|
276
|
+
checkbox.type = 'checkbox';
|
|
277
|
+
checkbox.value = feature.name;
|
|
278
|
+
checkbox.style.cssText = 'width: 18px; height: 18px;';
|
|
279
|
+
|
|
280
|
+
const label = document.createElement('span');
|
|
281
|
+
label.textContent = feature.name;
|
|
282
|
+
label.style.cssText = `font-size: 14px; color: ${colors.infoText};`;
|
|
283
|
+
|
|
284
|
+
checkboxContainer.appendChild(checkbox);
|
|
285
|
+
checkboxContainer.appendChild(label);
|
|
286
|
+
metamagicSection.appendChild(checkboxContainer);
|
|
287
|
+
|
|
288
|
+
metamagicCheckboxes.push(checkbox);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
modal.appendChild(metamagicSection);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Track whether spell has been cast (for attack spells)
|
|
295
|
+
let spellCast = false;
|
|
296
|
+
let usedSlot = null;
|
|
297
|
+
|
|
298
|
+
// Check if spell has both attack and damage options
|
|
299
|
+
const hasAttack = options.some(opt => opt.type === 'attack');
|
|
300
|
+
const hasDamage = options.some(opt => opt.type === 'damage' || opt.type === 'healing');
|
|
301
|
+
|
|
302
|
+
// Options container (spell action buttons)
|
|
303
|
+
const optionsContainer = document.createElement('div');
|
|
304
|
+
optionsContainer.style.cssText = 'display: flex; flex-direction: column; gap: 12px;';
|
|
305
|
+
|
|
306
|
+
// Helper function to get resolved label for an option based on slot level
|
|
307
|
+
function getResolvedLabel(option, selectedSlotLevel) {
|
|
308
|
+
if (option.type === 'attack') {
|
|
309
|
+
return option.label; // Attack doesn't change with slot level
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Get the formula for this option
|
|
313
|
+
let formula = option.type === 'lifesteal' ? option.damageFormula : option.formula;
|
|
314
|
+
debug.log(`🏷️ getResolvedLabel called with formula: "${formula}", slotLevel: ${selectedSlotLevel}`);
|
|
315
|
+
|
|
316
|
+
// Replace slotLevel with actual slot level (check for null/undefined, but allow 0)
|
|
317
|
+
// Use case-insensitive regex to handle slotLevel, slotlevel, SlotLevel, etc.
|
|
318
|
+
if (selectedSlotLevel != null && formula && /slotlevel/i.test(formula)) {
|
|
319
|
+
const originalFormula = formula;
|
|
320
|
+
formula = formula.replace(/slotlevel/gi, String(selectedSlotLevel));
|
|
321
|
+
debug.log(` ✅ Replaced slotLevel: "${originalFormula}" -> "${formula}"`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Replace ~target.level with character level
|
|
325
|
+
if (formula && formula.includes('~target.level') && characterData.level) {
|
|
326
|
+
formula = formula.replace(/~target\.level/g, characterData.level);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Resolve variables and evaluate math
|
|
330
|
+
formula = resolveVariablesInFormula(formula);
|
|
331
|
+
formula = evaluateMathInFormula(formula);
|
|
332
|
+
debug.log(` 📊 Final resolved formula: "${formula}"`);
|
|
333
|
+
|
|
334
|
+
// Build label based on option type
|
|
335
|
+
if (option.type === 'lifesteal') {
|
|
336
|
+
let damageTypeLabel = '';
|
|
337
|
+
if (option.damageType && option.damageType !== 'untyped') {
|
|
338
|
+
damageTypeLabel = option.damageType.charAt(0).toUpperCase() + option.damageType.slice(1);
|
|
339
|
+
}
|
|
340
|
+
return `${formula} ${damageTypeLabel} + Heal (${option.healingRatio})`;
|
|
341
|
+
} else if (option.type === 'damage' || option.type === 'healing' || option.type === 'temphp') {
|
|
342
|
+
let damageTypeLabel = '';
|
|
343
|
+
if (option.damageType && option.damageType !== 'untyped') {
|
|
344
|
+
damageTypeLabel = option.damageType.charAt(0).toUpperCase() + option.damageType.slice(1);
|
|
345
|
+
}
|
|
346
|
+
return damageTypeLabel ? `${formula} ${damageTypeLabel}` : formula;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return option.label;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Add buttons for each option
|
|
353
|
+
const optionButtons = []; // Store buttons so we can update them when slot changes
|
|
354
|
+
|
|
355
|
+
// Add custom macro buttons if configured
|
|
356
|
+
if (hasCustomMacros) {
|
|
357
|
+
customMacros.buttons.forEach((customBtn, index) => {
|
|
358
|
+
const btn = document.createElement('button');
|
|
359
|
+
btn.className = 'spell-custom-macro-btn';
|
|
360
|
+
btn.style.cssText = `
|
|
361
|
+
padding: 12px 16px;
|
|
362
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
363
|
+
color: white;
|
|
364
|
+
border: 2px solid rgba(255,255,255,0.3);
|
|
365
|
+
border-radius: 6px;
|
|
366
|
+
cursor: pointer;
|
|
367
|
+
font-weight: bold;
|
|
368
|
+
font-size: 16px;
|
|
369
|
+
text-align: left;
|
|
370
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
371
|
+
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
|
372
|
+
`;
|
|
373
|
+
btn.innerHTML = customBtn.label;
|
|
374
|
+
|
|
375
|
+
btn.addEventListener('mouseenter', () => {
|
|
376
|
+
btn.style.opacity = '0.9';
|
|
377
|
+
btn.style.transform = 'translateY(-2px)';
|
|
378
|
+
});
|
|
379
|
+
btn.addEventListener('mouseleave', () => {
|
|
380
|
+
btn.style.opacity = '1';
|
|
381
|
+
btn.style.transform = 'translateY(0)';
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
btn.addEventListener('click', () => {
|
|
385
|
+
// Send custom macro to chat
|
|
386
|
+
const colorBanner = getColoredBanner(characterData);
|
|
387
|
+
const message = customBtn.macro;
|
|
388
|
+
|
|
389
|
+
const messageData = {
|
|
390
|
+
action: 'announceSpell',
|
|
391
|
+
message: message,
|
|
392
|
+
color: characterData.notificationColor
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
sendToRoll20(messageData);
|
|
396
|
+
debug.log('✅ Custom macro sent to Roll20');
|
|
397
|
+
|
|
398
|
+
showNotification(`✨ ${spell.name} - Custom Macro Sent!`, 'success');
|
|
399
|
+
document.body.removeChild(overlay);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
optionsContainer.appendChild(btn);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// If skipNormalButtons is true, don't add normal spell option buttons
|
|
406
|
+
if (customMacros.skipNormalButtons) {
|
|
407
|
+
debug.log(`⚙️ Skipping normal spell buttons for "${spell.name}" (custom macros only)`);
|
|
408
|
+
// Skip the normal options.forEach below
|
|
409
|
+
options = [];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
options.forEach(option => {
|
|
414
|
+
const btn = document.createElement('button');
|
|
415
|
+
btn.className = `spell-option-btn-${option.type}`;
|
|
416
|
+
|
|
417
|
+
// Special styling for lifesteal buttons to make them more visually distinct
|
|
418
|
+
const isLifesteal = option.type === 'lifesteal';
|
|
419
|
+
const boxShadow = isLifesteal ? 'box-shadow: 0 4px 8px rgba(0,0,0,0.3), inset 0 -2px 4px rgba(0,0,0,0.2);' : '';
|
|
420
|
+
const border = isLifesteal ? 'border: 2px solid rgba(255,255,255,0.3);' : 'border: none;';
|
|
421
|
+
|
|
422
|
+
btn.style.cssText = `
|
|
423
|
+
padding: 12px 16px;
|
|
424
|
+
background: ${option.color};
|
|
425
|
+
color: white;
|
|
426
|
+
${border}
|
|
427
|
+
border-radius: 6px;
|
|
428
|
+
cursor: pointer;
|
|
429
|
+
font-weight: bold;
|
|
430
|
+
font-size: 16px;
|
|
431
|
+
text-align: left;
|
|
432
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
433
|
+
${boxShadow}
|
|
434
|
+
`;
|
|
435
|
+
|
|
436
|
+
// Set initial label (with default slot level)
|
|
437
|
+
const initialSlotLevel = spell.level || null;
|
|
438
|
+
const resolvedLabel = getResolvedLabel(option, initialSlotLevel);
|
|
439
|
+
const edgeCaseNote = option.edgeCaseNote ? `<div style="font-size: 0.8em; color: #666; margin-top: 2px;">${option.edgeCaseNote}</div>` : '';
|
|
440
|
+
btn.innerHTML = `${option.icon} ${resolvedLabel}${edgeCaseNote}`;
|
|
441
|
+
btn.dataset.optionIndex = optionButtons.length; // Store index for later updates
|
|
442
|
+
|
|
443
|
+
btn.addEventListener('mouseenter', () => {
|
|
444
|
+
btn.style.opacity = '0.9';
|
|
445
|
+
if (isLifesteal) btn.style.transform = 'translateY(-2px)';
|
|
446
|
+
});
|
|
447
|
+
btn.addEventListener('mouseleave', () => {
|
|
448
|
+
btn.style.opacity = '1';
|
|
449
|
+
if (isLifesteal) btn.style.transform = 'translateY(0)';
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
optionButtons.push({ button: btn, option: option });
|
|
453
|
+
|
|
454
|
+
btn.addEventListener('click', () => {
|
|
455
|
+
// Get selected slot level - keep in "pact:X" format for castSpell to detect Pact Magic
|
|
456
|
+
let selectedSlotLevel = spell.level || null;
|
|
457
|
+
|
|
458
|
+
// Check if slot level was auto-selected (Pact Magic only, no dropdown shown)
|
|
459
|
+
if (modal.dataset.autoSlotLevel) {
|
|
460
|
+
selectedSlotLevel = modal.dataset.autoSlotLevel; // Keep as "pact:X" string
|
|
461
|
+
} else if (slotSelect) {
|
|
462
|
+
selectedSlotLevel = slotSelect.value; // Keep as "pact:X" string or regular level number
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Get selected metamagic options
|
|
466
|
+
const selectedMetamagic = metamagicCheckboxes
|
|
467
|
+
.filter(cb => cb.checked)
|
|
468
|
+
.map(cb => cb.value);
|
|
469
|
+
|
|
470
|
+
// Check if we should skip slot consumption (concentration recast)
|
|
471
|
+
const skipSlot = skipSlotCheckbox ? skipSlotCheckbox.checked : false;
|
|
472
|
+
|
|
473
|
+
if (option.type === 'cast') {
|
|
474
|
+
// Cast spell only (for spells with conditional damage like Meld into Stone)
|
|
475
|
+
// Announce description only if not already announced AND not using concentration recast
|
|
476
|
+
if (!descriptionAnnounced && !skipSlot) {
|
|
477
|
+
announceSpellDescription(spell, selectedSlotLevel);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const afterCast = (spell, slot) => {
|
|
481
|
+
usedSlot = slot;
|
|
482
|
+
showNotification(`✨ ${spell.name} cast successfully!`, 'success');
|
|
483
|
+
};
|
|
484
|
+
// Description announced (if needed), don't announce again in castSpell
|
|
485
|
+
castSpell(spell, spellIndex, afterCast, selectedSlotLevel, selectedMetamagic, skipSlot, true);
|
|
486
|
+
spellCast = true;
|
|
487
|
+
|
|
488
|
+
// Disable cast button after casting
|
|
489
|
+
btn.disabled = true;
|
|
490
|
+
btn.style.opacity = '0.5';
|
|
491
|
+
btn.style.cursor = 'not-allowed';
|
|
492
|
+
|
|
493
|
+
// Don't close modal - allow rolling damage if needed
|
|
494
|
+
|
|
495
|
+
} else if (option.type === 'attack') {
|
|
496
|
+
// Cast spell + roll attack, but keep modal open
|
|
497
|
+
// Announce description only if not already announced AND not using concentration recast
|
|
498
|
+
if (!descriptionAnnounced && !skipSlot) {
|
|
499
|
+
announceSpellDescription(spell, selectedSlotLevel);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const afterCast = (spell, slot) => {
|
|
503
|
+
usedSlot = slot;
|
|
504
|
+
const attackBonus = getSpellAttackBonus();
|
|
505
|
+
const attackFormula = attackBonus >= 0 ? `1d20+${attackBonus}` : `1d20${attackBonus}`;
|
|
506
|
+
roll(`${spell.name} - Spell Attack`, attackFormula);
|
|
507
|
+
};
|
|
508
|
+
// Description announced (if needed), don't announce again in castSpell
|
|
509
|
+
castSpell(spell, spellIndex, afterCast, selectedSlotLevel, selectedMetamagic, skipSlot, true);
|
|
510
|
+
spellCast = true;
|
|
511
|
+
|
|
512
|
+
// Disable slot selection and metamagic after casting
|
|
513
|
+
if (slotSelect) slotSelect.disabled = true;
|
|
514
|
+
metamagicCheckboxes.forEach(cb => cb.disabled = true);
|
|
515
|
+
|
|
516
|
+
// Disable attack button after casting
|
|
517
|
+
btn.disabled = true;
|
|
518
|
+
btn.style.opacity = '0.5';
|
|
519
|
+
btn.style.cursor = 'not-allowed';
|
|
520
|
+
|
|
521
|
+
} else if (option.type === 'damage' || option.type === 'healing' || option.type === 'temphp') {
|
|
522
|
+
// If spell not cast yet (no attack roll), cast it first
|
|
523
|
+
if (!spellCast) {
|
|
524
|
+
// Announce description only if not already announced AND not using concentration recast
|
|
525
|
+
if (!descriptionAnnounced && !skipSlot) {
|
|
526
|
+
announceSpellDescription(spell, selectedSlotLevel);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const afterCast = (spell, slot) => {
|
|
530
|
+
usedSlot = slot;
|
|
531
|
+
let formula = option.formula;
|
|
532
|
+
let actualSlotLevel = selectedSlotLevel != null ? selectedSlotLevel : (slot && slot.level);
|
|
533
|
+
// Extract numeric level from "pact:X" format if needed
|
|
534
|
+
if (typeof actualSlotLevel === 'string' && actualSlotLevel.startsWith('pact:')) {
|
|
535
|
+
actualSlotLevel = parseInt(actualSlotLevel.split(':')[1]);
|
|
536
|
+
}
|
|
537
|
+
if (actualSlotLevel != null) {
|
|
538
|
+
formula = formula.replace(/slotlevel/gi, actualSlotLevel);
|
|
539
|
+
}
|
|
540
|
+
// Replace ~target.level with character level (for cantrips)
|
|
541
|
+
if (formula.includes('~target.level') && characterData.level) {
|
|
542
|
+
formula = formula.replace(/~target\.level/g, characterData.level);
|
|
543
|
+
}
|
|
544
|
+
formula = resolveVariablesInFormula(formula);
|
|
545
|
+
formula = evaluateMathInFormula(formula);
|
|
546
|
+
|
|
547
|
+
const label = option.type === 'healing' ?
|
|
548
|
+
`${spell.name} - Healing` :
|
|
549
|
+
(option.type === 'temphp' ?
|
|
550
|
+
`${spell.name} - Temp HP` :
|
|
551
|
+
`${spell.name} - Damage (${option.damageType || ''})`);
|
|
552
|
+
roll(label, formula);
|
|
553
|
+
};
|
|
554
|
+
// Description announced (if needed), don't announce again in castSpell
|
|
555
|
+
castSpell(spell, spellIndex, afterCast, selectedSlotLevel, selectedMetamagic, skipSlot, true);
|
|
556
|
+
} else {
|
|
557
|
+
// Spell already cast (via attack), just roll damage
|
|
558
|
+
let formula = option.formula;
|
|
559
|
+
let actualSlotLevel = selectedSlotLevel != null ? selectedSlotLevel : (usedSlot && usedSlot.level);
|
|
560
|
+
// Extract numeric level from "pact:X" format if needed
|
|
561
|
+
if (typeof actualSlotLevel === 'string' && actualSlotLevel.startsWith('pact:')) {
|
|
562
|
+
actualSlotLevel = parseInt(actualSlotLevel.split(':')[1]);
|
|
563
|
+
}
|
|
564
|
+
if (actualSlotLevel != null) {
|
|
565
|
+
formula = formula.replace(/slotlevel/gi, actualSlotLevel);
|
|
566
|
+
}
|
|
567
|
+
// Replace ~target.level with character level (for cantrips)
|
|
568
|
+
if (formula.includes('~target.level') && characterData.level) {
|
|
569
|
+
formula = formula.replace(/~target\.level/g, characterData.level);
|
|
570
|
+
}
|
|
571
|
+
formula = resolveVariablesInFormula(formula);
|
|
572
|
+
formula = evaluateMathInFormula(formula);
|
|
573
|
+
|
|
574
|
+
const label = option.type === 'healing' ?
|
|
575
|
+
`${spell.name} - Healing` :
|
|
576
|
+
(option.type === 'temphp' ?
|
|
577
|
+
`${spell.name} - Temp HP` :
|
|
578
|
+
`${spell.name} - Damage (${option.damageType || ''})`);
|
|
579
|
+
roll(label, formula);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Close modal after rolling damage
|
|
583
|
+
document.body.removeChild(overlay);
|
|
584
|
+
|
|
585
|
+
} else if (option.type === 'lifesteal') {
|
|
586
|
+
// Lifesteal: Cast spell, roll damage, calculate and apply healing
|
|
587
|
+
// Announce description only if not already announced AND not using concentration recast
|
|
588
|
+
if (!descriptionAnnounced && !skipSlot) {
|
|
589
|
+
announceSpellDescription(spell, selectedSlotLevel);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const afterCast = (spell, slot) => {
|
|
593
|
+
let damageFormula = option.damageFormula;
|
|
594
|
+
const actualSlotLevel = selectedSlotLevel != null ? selectedSlotLevel : (slot && slot.level);
|
|
595
|
+
if (actualSlotLevel != null) {
|
|
596
|
+
damageFormula = damageFormula.replace(/slotlevel/gi, actualSlotLevel);
|
|
597
|
+
}
|
|
598
|
+
if (damageFormula.includes('~target.level') && characterData.level) {
|
|
599
|
+
damageFormula = damageFormula.replace(/~target\.level/g, characterData.level);
|
|
600
|
+
}
|
|
601
|
+
damageFormula = resolveVariablesInFormula(damageFormula);
|
|
602
|
+
damageFormula = evaluateMathInFormula(damageFormula);
|
|
603
|
+
|
|
604
|
+
// Roll damage
|
|
605
|
+
roll(`${spell.name} - Lifesteal Damage (${option.damageType})`, damageFormula);
|
|
606
|
+
|
|
607
|
+
// After a short delay, prompt for damage dealt to calculate healing
|
|
608
|
+
setTimeout(() => {
|
|
609
|
+
const healingText = option.healingRatio === 'half' ? 'half' : 'the full amount';
|
|
610
|
+
const damageDealt = prompt(`💉 Lifesteal: Enter the damage dealt\n\nYou regain HP equal to ${healingText} of the damage.`);
|
|
611
|
+
|
|
612
|
+
if (damageDealt && !isNaN(damageDealt)) {
|
|
613
|
+
const damage = parseInt(damageDealt);
|
|
614
|
+
const healing = option.healingRatio === 'half' ? Math.floor(damage / 2) : damage;
|
|
615
|
+
|
|
616
|
+
// Apply healing
|
|
617
|
+
const oldHP = characterData.hitPoints.current;
|
|
618
|
+
const maxHP = characterData.hitPoints.max;
|
|
619
|
+
characterData.hitPoints.current = Math.min(oldHP + healing, maxHP);
|
|
620
|
+
const actualHealing = characterData.hitPoints.current - oldHP;
|
|
621
|
+
|
|
622
|
+
// Reset death saves if healing from 0 HP
|
|
623
|
+
if (oldHP === 0 && actualHealing > 0) {
|
|
624
|
+
characterData.deathSaves = { successes: 0, failures: 0 };
|
|
625
|
+
debug.log('♻️ Death saves reset due to healing');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
saveCharacterData();
|
|
629
|
+
buildSheet(characterData);
|
|
630
|
+
|
|
631
|
+
// Announce healing
|
|
632
|
+
const colorBanner = getColoredBanner(characterData);
|
|
633
|
+
const message = `&{template:default} {{name=${colorBanner}${characterData.name} - Lifesteal}} {{💉 Damage Dealt=${damage}}} {{💚 HP Regained=${actualHealing}}} {{Current HP=${characterData.hitPoints.current}/${maxHP}}}`;
|
|
634
|
+
|
|
635
|
+
const messageData = {
|
|
636
|
+
action: 'announceSpell',
|
|
637
|
+
message: message,
|
|
638
|
+
color: characterData.notificationColor
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
sendToRoll20(messageData);
|
|
642
|
+
|
|
643
|
+
showNotification(`💉 Lifesteal! Dealt ${damage} damage, regained ${actualHealing} HP`, 'success');
|
|
644
|
+
}
|
|
645
|
+
}, 500);
|
|
646
|
+
};
|
|
647
|
+
// Description announced (if needed), don't announce again in castSpell
|
|
648
|
+
castSpell(spell, spellIndex, afterCast, selectedSlotLevel, selectedMetamagic, skipSlot, true);
|
|
649
|
+
|
|
650
|
+
// Close modal after rolling
|
|
651
|
+
document.body.removeChild(overlay);
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
optionsContainer.appendChild(btn);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// Set up slot selection change handler to update button labels
|
|
659
|
+
if (slotSelect) {
|
|
660
|
+
const updateButtonLabels = () => {
|
|
661
|
+
// Handle pact magic slot format "pact:X" - extract the level number
|
|
662
|
+
const slotValue = slotSelect.value;
|
|
663
|
+
const selectedSlotLevel = slotValue.startsWith('pact:')
|
|
664
|
+
? parseInt(slotValue.split(':')[1])
|
|
665
|
+
: parseInt(slotValue);
|
|
666
|
+
optionButtons.forEach(({ button, option }) => {
|
|
667
|
+
const resolvedLabel = getResolvedLabel(option, selectedSlotLevel);
|
|
668
|
+
const edgeCaseNote = option.edgeCaseNote ? `<div style="font-size: 0.8em; color: #666; margin-top: 2px;">${option.edgeCaseNote}</div>` : '';
|
|
669
|
+
button.innerHTML = `${option.icon} ${resolvedLabel}${edgeCaseNote}`;
|
|
670
|
+
});
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
// Add change event listener
|
|
674
|
+
slotSelect.addEventListener('change', updateButtonLabels);
|
|
675
|
+
|
|
676
|
+
// Call initially to set correct labels for default selection
|
|
677
|
+
updateButtonLabels();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Add "Done" button if spell has attack (to close modal after attacking without rolling damage)
|
|
681
|
+
if (hasAttack && hasDamage) {
|
|
682
|
+
const doneBtn = document.createElement('button');
|
|
683
|
+
doneBtn.style.cssText = 'padding: 10px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;';
|
|
684
|
+
doneBtn.textContent = 'Done';
|
|
685
|
+
doneBtn.addEventListener('click', () => {
|
|
686
|
+
document.body.removeChild(overlay);
|
|
687
|
+
});
|
|
688
|
+
optionsContainer.appendChild(doneBtn);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
modal.appendChild(optionsContainer);
|
|
692
|
+
|
|
693
|
+
// Cancel button
|
|
694
|
+
const cancelBtn = document.createElement('button');
|
|
695
|
+
cancelBtn.style.cssText = 'margin-top: 16px; padding: 10px; background: #95a5a6; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; width: 100%;';
|
|
696
|
+
cancelBtn.textContent = 'Cancel';
|
|
697
|
+
cancelBtn.addEventListener('click', () => {
|
|
698
|
+
document.body.removeChild(overlay);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
modal.appendChild(cancelBtn);
|
|
702
|
+
overlay.appendChild(modal);
|
|
703
|
+
|
|
704
|
+
// Close on overlay click
|
|
705
|
+
overlay.addEventListener('click', (e) => {
|
|
706
|
+
if (e.target === overlay) {
|
|
707
|
+
document.body.removeChild(overlay);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// Add to DOM
|
|
712
|
+
document.body.appendChild(overlay);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Handle spell option click (simplified handler)
|
|
717
|
+
* @param {object} spell - Spell object
|
|
718
|
+
* @param {number} spellIndex - Spell index
|
|
719
|
+
* @param {object} option - Spell option object
|
|
720
|
+
*/
|
|
721
|
+
function handleSpellOption(spell, spellIndex, option) {
|
|
722
|
+
if (option.type === 'attack') {
|
|
723
|
+
// Cast spell + roll attack
|
|
724
|
+
const afterCast = (spell, slot) => {
|
|
725
|
+
const attackBonus = getSpellAttackBonus();
|
|
726
|
+
const attackFormula = attackBonus >= 0 ? `1d20+${attackBonus}` : `1d20${attackBonus}`;
|
|
727
|
+
roll(`${spell.name} - Spell Attack`, attackFormula);
|
|
728
|
+
};
|
|
729
|
+
castSpell(spell, spellIndex, afterCast);
|
|
730
|
+
} else if (option.type === 'damage' || option.type === 'healing') {
|
|
731
|
+
// Handle OR choices if present
|
|
732
|
+
let damageType = option.damageType;
|
|
733
|
+
if (option.orChoices && option.orChoices.length > 1) {
|
|
734
|
+
const choiceText = option.orChoices.map((c, i) => `${i + 1}. ${c.damageType}`).join('\n');
|
|
735
|
+
const choice = prompt(`Choose damage type for ${spell.name}:\n${choiceText}\n\nEnter number (1-${option.orChoices.length}):`);
|
|
736
|
+
|
|
737
|
+
if (choice === null) return; // User cancelled
|
|
738
|
+
|
|
739
|
+
const choiceIndex = parseInt(choice) - 1;
|
|
740
|
+
if (choiceIndex >= 0 && choiceIndex < option.orChoices.length) {
|
|
741
|
+
damageType = option.orChoices[choiceIndex].damageType;
|
|
742
|
+
} else {
|
|
743
|
+
alert(`Invalid choice. Please try again.`);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Cast spell + roll damage/healing
|
|
749
|
+
const afterCast = (spell, slot) => {
|
|
750
|
+
let formula = option.formula;
|
|
751
|
+
// Replace slotLevel with actual slot level (case-insensitive)
|
|
752
|
+
if (slot && slot.level) {
|
|
753
|
+
formula = formula.replace(/slotlevel/gi, slot.level);
|
|
754
|
+
}
|
|
755
|
+
// Resolve other DiceCloud variables
|
|
756
|
+
formula = resolveVariablesInFormula(formula);
|
|
757
|
+
// Evaluate simple math expressions
|
|
758
|
+
formula = evaluateMathInFormula(formula);
|
|
759
|
+
|
|
760
|
+
const label = option.type === 'healing' ?
|
|
761
|
+
`${spell.name} - Healing` :
|
|
762
|
+
(damageType ? `${spell.name} - Damage (${damageType})` : `${spell.name} - Damage`);
|
|
763
|
+
roll(label, formula);
|
|
764
|
+
};
|
|
765
|
+
castSpell(spell, spellIndex, afterCast);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Show resource choice modal (spell slot vs class resource)
|
|
771
|
+
* @param {object} spell - Spell object
|
|
772
|
+
* @param {number} spellLevel - Spell level
|
|
773
|
+
* @param {number} spellSlots - Current spell slots
|
|
774
|
+
* @param {number} maxSlots - Maximum spell slots
|
|
775
|
+
* @param {Array} classResources - Array of class resources
|
|
776
|
+
*/
|
|
777
|
+
function showResourceChoice(spell, spellLevel, spellSlots, maxSlots, classResources) {
|
|
778
|
+
// Create modal overlay
|
|
779
|
+
const modal = document.createElement('div');
|
|
780
|
+
modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10000;';
|
|
781
|
+
|
|
782
|
+
// Create modal content
|
|
783
|
+
const modalContent = document.createElement('div');
|
|
784
|
+
modalContent.style.cssText = 'background: var(--bg-secondary); color: var(--text-primary); padding: 30px; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); max-width: 400px; width: 90%;';
|
|
785
|
+
|
|
786
|
+
let buttonsHTML = `
|
|
787
|
+
<h3 style="margin: 0 0 20px 0; color: var(--text-primary); text-align: center;">Cast ${spell.name}</h3>
|
|
788
|
+
<p style="text-align: center; color: var(--text-secondary); margin-bottom: 25px;">Choose a resource:</p>
|
|
789
|
+
<div style="display: flex; flex-direction: column; gap: 12px;">
|
|
790
|
+
`;
|
|
791
|
+
|
|
792
|
+
// Add spell slot option if available
|
|
793
|
+
if (spellSlots > 0) {
|
|
794
|
+
buttonsHTML += `
|
|
795
|
+
<button class="resource-choice-btn" data-type="spell-slot" data-level="${spellLevel}" style="padding: 15px; font-size: 1em; font-weight: bold; background: #9b59b6; color: white; border: 2px solid #9b59b6; border-radius: 8px; cursor: pointer; transition: all 0.2s; text-align: left;">
|
|
796
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
797
|
+
<span>Level ${spellLevel} Spell Slot</span>
|
|
798
|
+
<span style="background: rgba(255,255,255,0.3); padding: 4px 8px; border-radius: 4px; font-size: 0.9em;">${spellSlots}/${maxSlots}</span>
|
|
799
|
+
</div>
|
|
800
|
+
</button>
|
|
801
|
+
`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Add class resource options
|
|
805
|
+
classResources.forEach((resource, idx) => {
|
|
806
|
+
const colors = {
|
|
807
|
+
'Ki': { bg: '#f39c12', border: '#f39c12' },
|
|
808
|
+
'Sorcery Points': { bg: '#e74c3c', border: '#e74c3c' },
|
|
809
|
+
'Pact Magic': { bg: '#16a085', border: '#16a085' },
|
|
810
|
+
'Channel Divinity': { bg: '#3498db', border: '#3498db' }
|
|
811
|
+
};
|
|
812
|
+
const color = colors[resource.name] || { bg: '#95a5a6', border: '#95a5a6' };
|
|
813
|
+
|
|
814
|
+
buttonsHTML += `
|
|
815
|
+
<button class="resource-choice-btn" data-type="class-resource" data-index="${idx}" style="padding: 15px; font-size: 1em; font-weight: bold; background: ${color.bg}; color: white; border: 2px solid ${color.border}; border-radius: 8px; cursor: pointer; transition: all 0.2s; text-align: left;">
|
|
816
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
817
|
+
<span>${resource.name}</span>
|
|
818
|
+
<span style="background: rgba(255,255,255,0.3); padding: 4px 8px; border-radius: 4px; font-size: 0.9em;">${resource.current}/${resource.max}</span>
|
|
819
|
+
</div>
|
|
820
|
+
</button>
|
|
821
|
+
`;
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
buttonsHTML += `
|
|
825
|
+
</div>
|
|
826
|
+
<button id="resource-cancel" style="width: 100%; margin-top: 20px; padding: 12px; font-size: 1em; background: #95a5a6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;">
|
|
827
|
+
Cancel
|
|
828
|
+
</button>
|
|
829
|
+
`;
|
|
830
|
+
|
|
831
|
+
modalContent.innerHTML = buttonsHTML;
|
|
832
|
+
modal.appendChild(modalContent);
|
|
833
|
+
document.body.appendChild(modal);
|
|
834
|
+
|
|
835
|
+
// Add hover effects
|
|
836
|
+
const resourceBtns = modalContent.querySelectorAll('.resource-choice-btn');
|
|
837
|
+
resourceBtns.forEach(btn => {
|
|
838
|
+
btn.addEventListener('mouseenter', () => {
|
|
839
|
+
btn.style.transform = 'translateY(-2px)';
|
|
840
|
+
btn.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)';
|
|
841
|
+
});
|
|
842
|
+
btn.addEventListener('mouseleave', () => {
|
|
843
|
+
btn.style.transform = 'translateY(0)';
|
|
844
|
+
btn.style.boxShadow = 'none';
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
btn.addEventListener('click', () => {
|
|
848
|
+
const type = btn.dataset.type;
|
|
849
|
+
|
|
850
|
+
if (type === 'spell-slot') {
|
|
851
|
+
const level = parseInt(btn.dataset.level);
|
|
852
|
+
modal.remove();
|
|
853
|
+
// Check if they want to upcast
|
|
854
|
+
showUpcastChoice(spell, level);
|
|
855
|
+
} else if (type === 'class-resource') {
|
|
856
|
+
const resourceIdx = parseInt(btn.dataset.index);
|
|
857
|
+
const resource = classResources[resourceIdx];
|
|
858
|
+
modal.remove();
|
|
859
|
+
if (useClassResource(resource, spell)) {
|
|
860
|
+
announceSpellCast(spell, resource.name);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// Cancel button
|
|
867
|
+
document.getElementById('resource-cancel').addEventListener('click', () => {
|
|
868
|
+
modal.remove();
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// Click outside to close
|
|
872
|
+
modal.addEventListener('click', (e) => {
|
|
873
|
+
if (e.target === modal) {
|
|
874
|
+
modal.remove();
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Show upcast selection modal
|
|
881
|
+
* @param {object} spell - Spell object
|
|
882
|
+
* @param {number} originalLevel - Original spell level
|
|
883
|
+
* @param {Function} afterCast - Callback after spell is cast
|
|
884
|
+
*/
|
|
885
|
+
function showUpcastChoice(spell, originalLevel, afterCast = null) {
|
|
886
|
+
// Get all available spell slots at this level or higher
|
|
887
|
+
const availableSlots = [];
|
|
888
|
+
|
|
889
|
+
// Helper to extract numeric value from DiceCloud objects
|
|
890
|
+
const extractNum = (val) => {
|
|
891
|
+
if (val === null || val === undefined) return 0;
|
|
892
|
+
if (typeof val === 'number') return val;
|
|
893
|
+
if (typeof val === 'object') {
|
|
894
|
+
return val.value ?? val.total ?? val.currentValue ?? 0;
|
|
895
|
+
}
|
|
896
|
+
return parseInt(val) || 0;
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
// Check for Pact Magic slots (Warlock) - these are SEPARATE from regular spell slots
|
|
900
|
+
const rawPactLevel = characterData.spellSlots?.pactMagicSlotLevel ||
|
|
901
|
+
characterData.otherVariables?.pactMagicSlotLevel ||
|
|
902
|
+
characterData.otherVariables?.pactSlotLevelVisible ||
|
|
903
|
+
characterData.otherVariables?.pactSlotLevel;
|
|
904
|
+
const rawPactSlots = characterData.spellSlots?.pactMagicSlots ??
|
|
905
|
+
characterData.otherVariables?.pactMagicSlots ??
|
|
906
|
+
characterData.otherVariables?.pactSlot;
|
|
907
|
+
const rawPactSlotsMax = characterData.spellSlots?.pactMagicSlotsMax ??
|
|
908
|
+
characterData.otherVariables?.pactMagicSlotsMax;
|
|
909
|
+
|
|
910
|
+
// Extract numeric values (DiceCloud stores these as objects like {value: 2})
|
|
911
|
+
const pactMagicSlots = extractNum(rawPactSlots);
|
|
912
|
+
const pactMagicSlotsMax = extractNum(rawPactSlotsMax);
|
|
913
|
+
const effectivePactLevel = extractNum(rawPactLevel) || (pactMagicSlotsMax > 0 ? 5 : 0);
|
|
914
|
+
|
|
915
|
+
debug.log('🔮 Pact Magic detection:', { rawPactLevel, rawPactSlots, rawPactSlotsMax, pactMagicSlots, pactMagicSlotsMax, effectivePactLevel });
|
|
916
|
+
|
|
917
|
+
// Add Pact Magic slots first if available and spell level is compatible
|
|
918
|
+
// Show even if depleted (current = 0) - user can still cast with GM permission
|
|
919
|
+
if (pactMagicSlotsMax > 0 && originalLevel <= effectivePactLevel) {
|
|
920
|
+
availableSlots.push({
|
|
921
|
+
level: effectivePactLevel,
|
|
922
|
+
current: pactMagicSlots,
|
|
923
|
+
max: pactMagicSlotsMax,
|
|
924
|
+
slotVar: 'pactMagicSlots',
|
|
925
|
+
slotMaxVar: 'pactMagicSlotsMax',
|
|
926
|
+
isPactMagic: true,
|
|
927
|
+
label: `Level ${effectivePactLevel} - Pact Magic`
|
|
928
|
+
});
|
|
929
|
+
debug.log(`🔮 Added Pact Magic to upcast options: Level ${effectivePactLevel} (${pactMagicSlots}/${pactMagicSlotsMax})`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Then check regular spell slots - show all levels with max > 0 (even if depleted)
|
|
933
|
+
for (let level = originalLevel; level <= 9; level++) {
|
|
934
|
+
const slotVar = `level${level}SpellSlots`;
|
|
935
|
+
const slotMaxVar = `level${level}SpellSlotsMax`;
|
|
936
|
+
let current = characterData.spellSlots?.[slotVar] || 0;
|
|
937
|
+
let max = characterData.spellSlots?.[slotMaxVar] || 0;
|
|
938
|
+
|
|
939
|
+
// Skip if this level's slots are actually Pact Magic slots (avoid duplicates)
|
|
940
|
+
if (pactMagicSlotsMax > 0 && level === effectivePactLevel) {
|
|
941
|
+
// Pact Magic is already added separately above
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Show slot level if character has access to it (max > 0), even if depleted
|
|
946
|
+
if (max > 0) {
|
|
947
|
+
availableSlots.push({ level, current, max, slotVar, slotMaxVar });
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Check for metamagic options
|
|
952
|
+
const metamagicOptions = getAvailableMetamagic();
|
|
953
|
+
const sorceryPoints = getSorceryPointsResource();
|
|
954
|
+
debug.log('🔮 Metamagic detection:', {
|
|
955
|
+
metamagicOptions,
|
|
956
|
+
sorceryPoints,
|
|
957
|
+
hasMetamagic: metamagicOptions.length > 0 && sorceryPoints && sorceryPoints.current > 0
|
|
958
|
+
});
|
|
959
|
+
const hasMetamagic = metamagicOptions.length > 0 && sorceryPoints && sorceryPoints.current > 0;
|
|
960
|
+
|
|
961
|
+
debug.log('🔮 Available slots for casting:', availableSlots);
|
|
962
|
+
|
|
963
|
+
// Handle case where no spell slots are available - allow casting anyway with warning
|
|
964
|
+
if (availableSlots.length === 0) {
|
|
965
|
+
const noSlotsModal = document.createElement('div');
|
|
966
|
+
noSlotsModal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10000;';
|
|
967
|
+
|
|
968
|
+
const noSlotsContent = document.createElement('div');
|
|
969
|
+
noSlotsContent.style.cssText = 'background: var(--bg-secondary); color: var(--text-primary); padding: 30px; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); max-width: 400px; width: 90%; text-align: center;';
|
|
970
|
+
noSlotsContent.innerHTML = `
|
|
971
|
+
<h3 style="margin: 0 0 20px 0; color: #e67e22;">No Spell Slots Available</h3>
|
|
972
|
+
<p style="color: var(--text-secondary); margin-bottom: 20px;">You don't have any spell slots of level ${originalLevel} or higher to cast ${spell.name}.</p>
|
|
973
|
+
<p style="color: #95a5a6; font-size: 0.9em; margin-bottom: 20px;">You can still cast if your GM allows it - no slot will be decremented.</p>
|
|
974
|
+
<div style="display: flex; gap: 10px; justify-content: center;">
|
|
975
|
+
<button id="no-slots-cancel" style="padding: 12px 25px; background: #95a5a6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1em;">Cancel</button>
|
|
976
|
+
<button id="no-slots-cast" style="padding: 12px 25px; background: #e67e22; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1em;">Cast Anyway</button>
|
|
977
|
+
</div>
|
|
978
|
+
`;
|
|
979
|
+
|
|
980
|
+
noSlotsModal.appendChild(noSlotsContent);
|
|
981
|
+
document.body.appendChild(noSlotsModal);
|
|
982
|
+
|
|
983
|
+
document.getElementById('no-slots-cancel').onclick = () => noSlotsModal.remove();
|
|
984
|
+
document.getElementById('no-slots-cast').onclick = () => {
|
|
985
|
+
noSlotsModal.remove();
|
|
986
|
+
// Cast without decrementing a slot - pass a fake slot with noSlotUsed flag
|
|
987
|
+
castWithSlot(spell, {
|
|
988
|
+
level: originalLevel,
|
|
989
|
+
current: 0,
|
|
990
|
+
max: 0,
|
|
991
|
+
slotVar: null,
|
|
992
|
+
noSlotUsed: true
|
|
993
|
+
}, [], afterCast);
|
|
994
|
+
};
|
|
995
|
+
noSlotsModal.onclick = (e) => { if (e.target === noSlotsModal) noSlotsModal.remove(); };
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Show upcast modal
|
|
1000
|
+
const modal = document.createElement('div');
|
|
1001
|
+
modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10000;';
|
|
1002
|
+
|
|
1003
|
+
const modalContent = document.createElement('div');
|
|
1004
|
+
modalContent.style.cssText = 'background: var(--bg-secondary); color: var(--text-primary); padding: 30px; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); max-width: 400px; width: 90%;';
|
|
1005
|
+
|
|
1006
|
+
let dropdownHTML = `
|
|
1007
|
+
<h3 style="margin: 0 0 20px 0; color: var(--text-primary); text-align: center;">Cast ${spell.name}</h3>
|
|
1008
|
+
<p style="text-align: center; color: var(--text-secondary); margin-bottom: 20px;">Level ${originalLevel} spell</p>
|
|
1009
|
+
|
|
1010
|
+
<div style="margin-bottom: 25px;">
|
|
1011
|
+
<label style="display: block; margin-bottom: 10px; font-weight: bold; color: var(--text-primary);">Spell Slot Level:</label>
|
|
1012
|
+
<select id="upcast-slot-select" style="width: 100%; padding: 12px; font-size: 1.1em; border: 2px solid var(--border-color); border-radius: 6px; box-sizing: border-box; background: var(--bg-tertiary); color: var(--text-primary);">
|
|
1013
|
+
`;
|
|
1014
|
+
|
|
1015
|
+
availableSlots.forEach((slot, index) => {
|
|
1016
|
+
let label;
|
|
1017
|
+
const depleted = slot.current <= 0;
|
|
1018
|
+
const depletedMarker = depleted ? ' [EMPTY]' : '';
|
|
1019
|
+
|
|
1020
|
+
if (slot.isPactMagic) {
|
|
1021
|
+
label = `${slot.label} - ${slot.current}/${slot.max} remaining${depletedMarker}`;
|
|
1022
|
+
} else if (slot.level === originalLevel) {
|
|
1023
|
+
label = `Level ${slot.level} (Normal) - ${slot.current}/${slot.max} remaining${depletedMarker}`;
|
|
1024
|
+
} else {
|
|
1025
|
+
label = `Level ${slot.level} (Upcast) - ${slot.current}/${slot.max} remaining${depletedMarker}`;
|
|
1026
|
+
}
|
|
1027
|
+
// Store index so we can identify Pact Magic vs regular slots
|
|
1028
|
+
dropdownHTML += `<option value="${index}" data-level="${slot.level}" data-pact="${slot.isPactMagic || false}" data-current="${slot.current}">${label}</option>`;
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
dropdownHTML += `
|
|
1032
|
+
</select>
|
|
1033
|
+
</div>
|
|
1034
|
+
`;
|
|
1035
|
+
|
|
1036
|
+
// Add metamagic options if available
|
|
1037
|
+
if (hasMetamagic) {
|
|
1038
|
+
dropdownHTML += `
|
|
1039
|
+
<div style="margin-bottom: 20px; padding: 12px; background: #f8f9fa; border-radius: 8px; border: 2px solid #9b59b6;">
|
|
1040
|
+
<div style="display: flex; justify-content: space-between; align-items: center; cursor: pointer; margin-bottom: 8px;" onclick="document.getElementById('metamagic-container').style.display = document.getElementById('metamagic-container').style.display === 'none' ? 'flex' : 'none'; this.querySelector('.toggle-arrow').textContent = document.getElementById('metamagic-container').style.display === 'none' ? '▶' : '▼';">
|
|
1041
|
+
<label style="font-weight: bold; color: #9b59b6; cursor: pointer;">✨ Metamagic (Sorcery Points: ${sorceryPoints.current}/${sorceryPoints.max})</label>
|
|
1042
|
+
<span class="toggle-arrow" style="color: #9b59b6; font-size: 0.8em;">▼</span>
|
|
1043
|
+
</div>
|
|
1044
|
+
<div id="metamagic-container" style="display: flex; flex-direction: column; gap: 6px;">
|
|
1045
|
+
`;
|
|
1046
|
+
|
|
1047
|
+
metamagicOptions.forEach((meta, index) => {
|
|
1048
|
+
const cost = meta.cost === 'variable' ? calculateMetamagicCost(meta.name, originalLevel) : meta.cost;
|
|
1049
|
+
const canAfford = sorceryPoints.current >= cost;
|
|
1050
|
+
const disabledStyle = !canAfford ? 'opacity: 0.5; cursor: not-allowed;' : '';
|
|
1051
|
+
|
|
1052
|
+
dropdownHTML += `
|
|
1053
|
+
<label style="display: flex; align-items: center; padding: 8px; background: white; border-radius: 4px; cursor: pointer; ${disabledStyle}" title="${meta.description || ''}">
|
|
1054
|
+
<input type="checkbox" class="metamagic-option" data-name="${meta.name}" data-cost="${cost}" ${!canAfford ? 'disabled' : ''} style="margin-right: 8px; width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
|
|
1055
|
+
<span style="flex: 1; color: var(--text-primary); font-size: 0.95em;">${meta.name}</span>
|
|
1056
|
+
<span style="color: #9b59b6; font-weight: bold; font-size: 0.9em;">${cost} SP</span>
|
|
1057
|
+
</label>
|
|
1058
|
+
`;
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
dropdownHTML += `
|
|
1062
|
+
</div>
|
|
1063
|
+
<div id="metamagic-cost" style="margin-top: 8px; text-align: right; font-weight: bold; color: var(--text-primary); font-size: 0.9em;">Total Cost: 0 SP</div>
|
|
1064
|
+
</div>
|
|
1065
|
+
`;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
dropdownHTML += `
|
|
1069
|
+
<div style="display: flex; gap: 10px;">
|
|
1070
|
+
<button id="upcast-cancel" style="flex: 1; padding: 12px; font-size: 1em; background: #95a5a6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;">
|
|
1071
|
+
Cancel
|
|
1072
|
+
</button>
|
|
1073
|
+
<button id="upcast-confirm" style="flex: 1; padding: 12px; font-size: 1em; background: #9b59b6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;">
|
|
1074
|
+
Cast Spell
|
|
1075
|
+
</button>
|
|
1076
|
+
</div>
|
|
1077
|
+
`;
|
|
1078
|
+
|
|
1079
|
+
modalContent.innerHTML = dropdownHTML;
|
|
1080
|
+
modal.appendChild(modalContent);
|
|
1081
|
+
document.body.appendChild(modal);
|
|
1082
|
+
|
|
1083
|
+
const selectElement = document.getElementById('upcast-slot-select');
|
|
1084
|
+
const confirmBtn = document.getElementById('upcast-confirm');
|
|
1085
|
+
const cancelBtn = document.getElementById('upcast-cancel');
|
|
1086
|
+
|
|
1087
|
+
// Track metamagic selections
|
|
1088
|
+
let selectedMetamagic = [];
|
|
1089
|
+
|
|
1090
|
+
if (hasMetamagic) {
|
|
1091
|
+
const metamagicCheckboxes = document.querySelectorAll('.metamagic-option');
|
|
1092
|
+
const costDisplay = document.getElementById('metamagic-cost');
|
|
1093
|
+
|
|
1094
|
+
// Update selected spell level when it changes (affects Twinned Spell cost)
|
|
1095
|
+
selectElement.addEventListener('change', () => {
|
|
1096
|
+
const selectedIndex = parseInt(selectElement.value);
|
|
1097
|
+
const selectedLevel = availableSlots[selectedIndex]?.level || originalLevel;
|
|
1098
|
+
|
|
1099
|
+
// Recalculate costs for variable-cost metamagic
|
|
1100
|
+
metamagicCheckboxes.forEach(checkbox => {
|
|
1101
|
+
const metaName = checkbox.dataset.name;
|
|
1102
|
+
const metaOption = metamagicOptions.find(m => m.name === metaName);
|
|
1103
|
+
if (metaOption && metaOption.cost === 'variable') {
|
|
1104
|
+
const newCost = calculateMetamagicCost(metaName, selectedLevel);
|
|
1105
|
+
checkbox.dataset.cost = newCost;
|
|
1106
|
+
|
|
1107
|
+
// Update display
|
|
1108
|
+
const label = checkbox.closest('label');
|
|
1109
|
+
const costSpan = label.querySelector('span:last-child');
|
|
1110
|
+
costSpan.textContent = `${newCost} SP`;
|
|
1111
|
+
|
|
1112
|
+
// Check if still affordable
|
|
1113
|
+
if (sorceryPoints.current < newCost && checkbox.checked) {
|
|
1114
|
+
checkbox.checked = false;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// Update total cost
|
|
1120
|
+
updateMetamagicCost();
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
function updateMetamagicCost() {
|
|
1124
|
+
let totalCost = 0;
|
|
1125
|
+
selectedMetamagic = [];
|
|
1126
|
+
|
|
1127
|
+
metamagicCheckboxes.forEach(checkbox => {
|
|
1128
|
+
if (checkbox.checked) {
|
|
1129
|
+
const cost = parseInt(checkbox.dataset.cost);
|
|
1130
|
+
totalCost += cost;
|
|
1131
|
+
selectedMetamagic.push({
|
|
1132
|
+
name: checkbox.dataset.name,
|
|
1133
|
+
cost: cost
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
costDisplay.textContent = `Total Cost: ${totalCost} SP`;
|
|
1139
|
+
|
|
1140
|
+
// Disable confirm if not enough sorcery points
|
|
1141
|
+
if (totalCost > sorceryPoints.current) {
|
|
1142
|
+
confirmBtn.disabled = true;
|
|
1143
|
+
confirmBtn.style.opacity = '0.5';
|
|
1144
|
+
confirmBtn.style.cursor = 'not-allowed';
|
|
1145
|
+
} else {
|
|
1146
|
+
confirmBtn.disabled = false;
|
|
1147
|
+
confirmBtn.style.opacity = '1';
|
|
1148
|
+
confirmBtn.style.cursor = 'pointer';
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
metamagicCheckboxes.forEach(checkbox => {
|
|
1153
|
+
checkbox.addEventListener('change', updateMetamagicCost);
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
confirmBtn.addEventListener('click', () => {
|
|
1158
|
+
const selectedIndex = parseInt(selectElement.value);
|
|
1159
|
+
const selectedSlot = availableSlots[selectedIndex];
|
|
1160
|
+
debug.log(`🔮 Selected slot from upcast modal:`, selectedSlot);
|
|
1161
|
+
|
|
1162
|
+
// Check if slot is depleted
|
|
1163
|
+
if (selectedSlot.current <= 0) {
|
|
1164
|
+
// Show warning modal
|
|
1165
|
+
modal.remove();
|
|
1166
|
+
|
|
1167
|
+
const warnModal = document.createElement('div');
|
|
1168
|
+
warnModal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10001;';
|
|
1169
|
+
|
|
1170
|
+
const warnContent = document.createElement('div');
|
|
1171
|
+
warnContent.style.cssText = 'background: var(--bg-secondary); color: var(--text-primary); padding: 30px; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); max-width: 400px; width: 90%; text-align: center;';
|
|
1172
|
+
warnContent.innerHTML = `
|
|
1173
|
+
<h3 style="margin: 0 0 20px 0; color: #e67e22;">No Slots Remaining</h3>
|
|
1174
|
+
<p style="color: var(--text-secondary); margin-bottom: 20px;">You have no ${selectedSlot.isPactMagic ? 'Pact Magic' : `Level ${selectedSlot.level}`} spell slots remaining.</p>
|
|
1175
|
+
<p style="color: #95a5a6; font-size: 0.9em; margin-bottom: 20px;">You can still cast if your GM allows it - no slot will be decremented.</p>
|
|
1176
|
+
<div style="display: flex; gap: 10px; justify-content: center;">
|
|
1177
|
+
<button id="warn-cancel" style="padding: 12px 25px; background: #95a5a6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1em;">Cancel</button>
|
|
1178
|
+
<button id="warn-cast" style="padding: 12px 25px; background: #e67e22; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1em;">Cast Anyway</button>
|
|
1179
|
+
</div>
|
|
1180
|
+
`;
|
|
1181
|
+
|
|
1182
|
+
warnModal.appendChild(warnContent);
|
|
1183
|
+
document.body.appendChild(warnModal);
|
|
1184
|
+
|
|
1185
|
+
document.getElementById('warn-cancel').onclick = () => warnModal.remove();
|
|
1186
|
+
document.getElementById('warn-cast').onclick = () => {
|
|
1187
|
+
warnModal.remove();
|
|
1188
|
+
// Cast with noSlotUsed flag
|
|
1189
|
+
castWithSlot(spell, { ...selectedSlot, noSlotUsed: true }, selectedMetamagic, afterCast);
|
|
1190
|
+
};
|
|
1191
|
+
warnModal.onclick = (e) => { if (e.target === warnModal) warnModal.remove(); };
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
modal.remove();
|
|
1196
|
+
castWithSlot(spell, selectedSlot, selectedMetamagic, afterCast);
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
cancelBtn.addEventListener('click', () => {
|
|
1200
|
+
modal.remove();
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// Click outside to close
|
|
1204
|
+
modal.addEventListener('click', (e) => {
|
|
1205
|
+
if (e.target === modal) {
|
|
1206
|
+
modal.remove();
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Export functions to globalThis
|
|
1212
|
+
Object.assign(globalThis, {
|
|
1213
|
+
showSpellModal,
|
|
1214
|
+
handleSpellOption,
|
|
1215
|
+
showResourceChoice,
|
|
1216
|
+
showUpcastChoice
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
console.log('✅ Spell Modals module loaded');
|
|
1220
|
+
|
|
1221
|
+
})();
|