@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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action Filters Module
|
|
3
|
+
*
|
|
4
|
+
* Handles action filtering, categorization, and rebuilding.
|
|
5
|
+
* Loaded as a plain script (no ES6 modules) to export to window.
|
|
6
|
+
*
|
|
7
|
+
* Functions exported to globalThis:
|
|
8
|
+
* - categorizeAction(action)
|
|
9
|
+
* - initializeActionFilters()
|
|
10
|
+
* - rebuildActions()
|
|
11
|
+
*
|
|
12
|
+
* State variables exported to globalThis:
|
|
13
|
+
* - actionFilters
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
(function() {
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
// ===== STATE =====
|
|
20
|
+
|
|
21
|
+
// Filter state for actions
|
|
22
|
+
const actionFilters = {
|
|
23
|
+
actionType: 'all',
|
|
24
|
+
category: 'all',
|
|
25
|
+
search: ''
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ===== FUNCTIONS =====
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Helper function to categorize an action
|
|
32
|
+
* @param {Object} action - Action object
|
|
33
|
+
* @returns {string} Category: 'healing', 'damage', or 'utility'
|
|
34
|
+
*/
|
|
35
|
+
function categorizeAction(action) {
|
|
36
|
+
const name = (action.name || '').toLowerCase();
|
|
37
|
+
const damageType = (action.damageType || '').toLowerCase();
|
|
38
|
+
const actionType = (action.actionType || '').toLowerCase();
|
|
39
|
+
|
|
40
|
+
// Check for healing based on damage type or name
|
|
41
|
+
if (damageType.includes('heal') || name.includes('heal') || name.includes('cure')) {
|
|
42
|
+
return 'healing';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check for damage based on:
|
|
46
|
+
// 1. Actual damage formula with dice
|
|
47
|
+
if (action.damage && action.damage.includes('d')) {
|
|
48
|
+
return 'damage';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Has attack roll (weapons, attacks)
|
|
52
|
+
if (action.attackRoll && action.attackRoll !== '(none)') {
|
|
53
|
+
return 'damage';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 3. Action type is explicitly 'attack'
|
|
57
|
+
if (actionType === 'attack') {
|
|
58
|
+
return 'damage';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 4. Name contains attack/weapon keywords
|
|
62
|
+
if (name.includes('attack') || name.includes('strike') || name.includes('bow') ||
|
|
63
|
+
name.includes('sword') || name.includes('axe') || name.includes('weapon')) {
|
|
64
|
+
return 'damage';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Everything else is utility
|
|
68
|
+
return 'utility';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Initialize action filter event listeners
|
|
73
|
+
*/
|
|
74
|
+
function initializeActionFilters() {
|
|
75
|
+
// Actions search filter
|
|
76
|
+
const actionsSearch = document.getElementById('actions-search');
|
|
77
|
+
if (actionsSearch) {
|
|
78
|
+
actionsSearch.addEventListener('input', (e) => {
|
|
79
|
+
actionFilters.search = e.target.value.toLowerCase();
|
|
80
|
+
rebuildActions();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Action type filters (action, bonus action, reaction, etc.)
|
|
85
|
+
document.querySelectorAll('[data-type="action-type"]').forEach(btn => {
|
|
86
|
+
btn.addEventListener('click', () => {
|
|
87
|
+
actionFilters.actionType = btn.dataset.filter;
|
|
88
|
+
document.querySelectorAll('[data-type="action-type"]').forEach(b => b.classList.remove('active'));
|
|
89
|
+
btn.classList.add('active');
|
|
90
|
+
rebuildActions();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Action category filters (damage, healing, utility)
|
|
95
|
+
document.querySelectorAll('[data-type="action-category"]').forEach(btn => {
|
|
96
|
+
btn.addEventListener('click', () => {
|
|
97
|
+
actionFilters.category = btn.dataset.filter;
|
|
98
|
+
document.querySelectorAll('[data-type="action-category"]').forEach(b => b.classList.remove('active'));
|
|
99
|
+
btn.classList.add('active');
|
|
100
|
+
rebuildActions();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Rebuild actions with current filters
|
|
107
|
+
*/
|
|
108
|
+
function rebuildActions() {
|
|
109
|
+
if (!characterData || !characterData.actions) return;
|
|
110
|
+
|
|
111
|
+
// Filter actions based on current filter state
|
|
112
|
+
let filteredActions = characterData.actions.filter(action => {
|
|
113
|
+
// Filter by action type (action, bonus, reaction, free)
|
|
114
|
+
if (actionFilters.actionType !== 'all') {
|
|
115
|
+
const actionType = (action.actionType || '').toLowerCase();
|
|
116
|
+
if (actionType !== actionFilters.actionType) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Filter by category (damage, healing, utility)
|
|
122
|
+
if (actionFilters.category !== 'all') {
|
|
123
|
+
const category = categorizeAction(action);
|
|
124
|
+
if (category !== actionFilters.category) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Filter by search term
|
|
130
|
+
if (actionFilters.search) {
|
|
131
|
+
const searchLower = actionFilters.search.toLowerCase();
|
|
132
|
+
const nameMatch = (action.name || '').toLowerCase().includes(searchLower);
|
|
133
|
+
const descMatch = (action.description || '').toLowerCase().includes(searchLower);
|
|
134
|
+
const summaryMatch = (action.summary || '').toLowerCase().includes(searchLower);
|
|
135
|
+
if (!nameMatch && !descMatch && !summaryMatch) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
debug.log(`🔍 Filtered actions: ${filteredActions.length}/${characterData.actions.length} (type=${actionFilters.actionType}, category=${actionFilters.category}, search="${actionFilters.search}")`);
|
|
144
|
+
|
|
145
|
+
const container = document.getElementById('actions-container');
|
|
146
|
+
buildActionsDisplay(container, filteredActions);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ===== EXPORTS =====
|
|
150
|
+
|
|
151
|
+
window.categorizeAction = categorizeAction;
|
|
152
|
+
window.initializeActionFilters = initializeActionFilters;
|
|
153
|
+
window.rebuildActions = rebuildActions;
|
|
154
|
+
|
|
155
|
+
// Export state variable with getter and setter
|
|
156
|
+
Object.defineProperty(globalThis, 'actionFilters', {
|
|
157
|
+
get: () => actionFilters,
|
|
158
|
+
set: (value) => {
|
|
159
|
+
if (value && typeof value === 'object') {
|
|
160
|
+
Object.assign(actionFilters, value);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
console.log('✅ Action Filters module loaded');
|
|
166
|
+
|
|
167
|
+
})();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action Options Module
|
|
3
|
+
*
|
|
4
|
+
* Handles generation of action options (attack, damage, healing buttons).
|
|
5
|
+
* Loaded as a plain script (no ES6 modules) to export to globalThis.
|
|
6
|
+
*
|
|
7
|
+
* Functions exported to globalThis:
|
|
8
|
+
* - getActionOptions(action)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
(function() {
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get available action options (attack/damage rolls) with edge case modifications
|
|
16
|
+
* @param {Object} action - Action object
|
|
17
|
+
* @returns {Object} Object with { options: [], skipNormalButtons: boolean }
|
|
18
|
+
*/
|
|
19
|
+
function getActionOptions(action) {
|
|
20
|
+
const options = [];
|
|
21
|
+
|
|
22
|
+
// Check for attack
|
|
23
|
+
if (action.attackRoll) {
|
|
24
|
+
// Convert to full formula if it's just a number (legacy data)
|
|
25
|
+
let formula = action.attackRoll;
|
|
26
|
+
if (typeof formula === 'number' || (typeof formula === 'string' && !formula.includes('d20'))) {
|
|
27
|
+
const bonus = parseInt(formula);
|
|
28
|
+
formula = bonus >= 0 ? `1d20+${bonus}` : `1d20${bonus}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
options.push({
|
|
32
|
+
type: 'attack',
|
|
33
|
+
label: '🎯 Attack Roll',
|
|
34
|
+
formula: formula,
|
|
35
|
+
icon: '🎯',
|
|
36
|
+
color: '#e74c3c'
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check for damage/healing rolls
|
|
41
|
+
debug.log(`🎲 Action "${action.name}" damage check:`, {
|
|
42
|
+
damage: action.damage,
|
|
43
|
+
damageType: typeof action.damage,
|
|
44
|
+
damageValue: action.damage,
|
|
45
|
+
attackRoll: action.attackRoll
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Handle damage as string or extract from object
|
|
49
|
+
let damageFormula = action.damage;
|
|
50
|
+
if (typeof action.damage === 'object' && action.damage !== null) {
|
|
51
|
+
damageFormula = action.damage.value || action.damage.calculation || action.damage.formula || '';
|
|
52
|
+
debug.log(`🔍 Extracted damage from object: "${damageFormula}"`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isValidDiceFormula = damageFormula && typeof damageFormula === 'string' && (/\d*d\d+/.test(damageFormula) || /\d*d\d+/.test(damageFormula.replace(/\s*\+\s*/g, '+')));
|
|
56
|
+
debug.log(`✅ Damage validation result: isValid=${isValidDiceFormula}, formula="${damageFormula}"`);
|
|
57
|
+
if (isValidDiceFormula) {
|
|
58
|
+
const isHealing = action.damageType && action.damageType.toLowerCase().includes('heal');
|
|
59
|
+
const isTempHP = action.damageType && (
|
|
60
|
+
action.damageType.toLowerCase() === 'temphp' ||
|
|
61
|
+
action.damageType.toLowerCase() === 'temporary' ||
|
|
62
|
+
action.damageType.toLowerCase().includes('temp')
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Use different text for healing vs damage vs features
|
|
66
|
+
let btnText;
|
|
67
|
+
if (isHealing) {
|
|
68
|
+
btnText = '💚 Heal';
|
|
69
|
+
} else if (action.actionType === 'feature' || !action.attackRoll) {
|
|
70
|
+
btnText = '🎲 Roll';
|
|
71
|
+
} else {
|
|
72
|
+
btnText = '💥 Damage Roll';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
options.push({
|
|
76
|
+
type: isHealing ? 'healing' : (isTempHP ? 'temphp' : 'damage'),
|
|
77
|
+
label: btnText,
|
|
78
|
+
formula: damageFormula,
|
|
79
|
+
icon: isTempHP ? '🛡️' : (isHealing ? '💚' : '💥'),
|
|
80
|
+
color: isTempHP ? '#3498db' : (isHealing ? '#27ae60' : '#e67e22')
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Apply edge case modifications
|
|
85
|
+
let edgeCaseResult;
|
|
86
|
+
|
|
87
|
+
// Check class feature edge cases first
|
|
88
|
+
if (isClassFeatureEdgeCase(action.name)) {
|
|
89
|
+
edgeCaseResult = applyClassFeatureEdgeCaseModifications(action, options);
|
|
90
|
+
debug.log(`🔍 Edge case applied for "${action.name}": skipNormalButtons = ${edgeCaseResult.skipNormalButtons}`);
|
|
91
|
+
}
|
|
92
|
+
// Check racial feature edge cases
|
|
93
|
+
else if (isRacialFeatureEdgeCase(action.name)) {
|
|
94
|
+
edgeCaseResult = applyRacialFeatureEdgeCaseModifications(action, options);
|
|
95
|
+
debug.log(`🔍 Edge case applied for "${action.name}": skipNormalButtons = ${edgeCaseResult.skipNormalButtons}`);
|
|
96
|
+
}
|
|
97
|
+
// Check combat maneuver edge cases
|
|
98
|
+
else if (isCombatManeuverEdgeCase(action.name)) {
|
|
99
|
+
edgeCaseResult = applyCombatManeuverEdgeCaseModifications(action, options);
|
|
100
|
+
debug.log(`🔍 Edge case applied for "${action.name}": skipNormalButtons = ${edgeCaseResult.skipNormalButtons}`);
|
|
101
|
+
}
|
|
102
|
+
// Default - no edge cases
|
|
103
|
+
else {
|
|
104
|
+
edgeCaseResult = { options, skipNormalButtons: false };
|
|
105
|
+
debug.log(`🔍 No edge case for "${action.name}": skipNormalButtons = false`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return edgeCaseResult;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ===== EXPORTS =====
|
|
112
|
+
|
|
113
|
+
globalThis.getActionOptions = getActionOptions;
|
|
114
|
+
|
|
115
|
+
console.log('✅ Action Options module loaded');
|
|
116
|
+
|
|
117
|
+
})();
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card Creator Utilities
|
|
3
|
+
* Functions for creating UI card elements
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a simple card element
|
|
8
|
+
* @param {string} title - Card title
|
|
9
|
+
* @param {string} main - Main content (e.g., bonus value)
|
|
10
|
+
* @param {string} sub - Sub content (optional)
|
|
11
|
+
* @param {Function} onClick - Click handler
|
|
12
|
+
* @returns {HTMLElement} Card element
|
|
13
|
+
*/
|
|
14
|
+
function createCard(title, main, sub, onClick) {
|
|
15
|
+
const card = document.createElement('div');
|
|
16
|
+
card.className = 'card';
|
|
17
|
+
card.innerHTML = `
|
|
18
|
+
<strong>${title}</strong><br>
|
|
19
|
+
<span class="bonus">${main}</span><br>
|
|
20
|
+
${sub ? `<span class="bonus">${sub}</span>` : ''}
|
|
21
|
+
`;
|
|
22
|
+
card.addEventListener('click', onClick);
|
|
23
|
+
return card;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a spell card element
|
|
28
|
+
* @param {object} spell - Spell data object
|
|
29
|
+
* @param {number} index - Spell index
|
|
30
|
+
* @returns {HTMLElement} Spell card element
|
|
31
|
+
*/
|
|
32
|
+
function createSpellCard(spell, index) {
|
|
33
|
+
const card = document.createElement('div');
|
|
34
|
+
card.className = 'spell-card';
|
|
35
|
+
|
|
36
|
+
const header = document.createElement('div');
|
|
37
|
+
header.className = 'spell-header';
|
|
38
|
+
|
|
39
|
+
// Build tags string
|
|
40
|
+
let tags = '';
|
|
41
|
+
if (spell.concentration) {
|
|
42
|
+
tags += '<span class="concentration-tag">🧠 Concentration</span>';
|
|
43
|
+
}
|
|
44
|
+
if (spell.ritual) {
|
|
45
|
+
tags += '<span class="ritual-tag">📖 Ritual</span>';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
header.innerHTML = `
|
|
49
|
+
<div>
|
|
50
|
+
<span style="font-weight: bold;">${spell.name}</span>
|
|
51
|
+
${spell.level ? `<span style="margin-left: 10px; color: var(--text-secondary);">Level ${spell.level}</span>` : ''}
|
|
52
|
+
${tags}
|
|
53
|
+
</div>
|
|
54
|
+
<div style="display: flex; gap: 8px;">
|
|
55
|
+
<button class="cast-btn" data-spell-index="${index}">✨ Cast</button>
|
|
56
|
+
<button class="toggle-btn">▼ Details</button>
|
|
57
|
+
</div>
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const desc = document.createElement('div');
|
|
61
|
+
desc.className = 'spell-description';
|
|
62
|
+
desc.id = `spell-desc-${index}`;
|
|
63
|
+
desc.innerHTML = `
|
|
64
|
+
${spell.castingTime ? `<div><strong>Casting Time:</strong> ${spell.castingTime}</div>` : ''}
|
|
65
|
+
${spell.range ? `<div><strong>Range:</strong> ${spell.range}</div>` : ''}
|
|
66
|
+
${spell.components ? `<div><strong>Components:</strong> ${spell.components}</div>` : ''}
|
|
67
|
+
${spell.duration ? `<div><strong>Duration:</strong> ${spell.duration}</div>` : ''}
|
|
68
|
+
${spell.school ? `<div><strong>School:</strong> ${spell.school}</div>` : ''}
|
|
69
|
+
${spell.source ? `<div><strong>Source:</strong> ${spell.source}</div>` : ''}
|
|
70
|
+
${spell.description ? `<div style="margin-top: 10px;">${spell.description}</div>` : ''}
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
card.appendChild(header);
|
|
74
|
+
card.appendChild(desc);
|
|
75
|
+
|
|
76
|
+
// Toggle description visibility
|
|
77
|
+
const toggleBtn = header.querySelector('.toggle-btn');
|
|
78
|
+
toggleBtn.addEventListener('click', (e) => {
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
desc.classList.toggle('expanded');
|
|
81
|
+
toggleBtn.textContent = desc.classList.contains('expanded') ? '▲ Hide' : '▼ Details';
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return card;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create an action card element
|
|
89
|
+
* @param {object} action - Action data object
|
|
90
|
+
* @param {number} index - Action index
|
|
91
|
+
* @returns {HTMLElement} Action card element
|
|
92
|
+
*/
|
|
93
|
+
function createActionCard(action, index) {
|
|
94
|
+
const card = document.createElement('div');
|
|
95
|
+
card.className = 'action-card';
|
|
96
|
+
|
|
97
|
+
const header = document.createElement('div');
|
|
98
|
+
header.innerHTML = `
|
|
99
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
100
|
+
<div>
|
|
101
|
+
<strong>${action.name || 'Action'}</strong>
|
|
102
|
+
${action.uses ? `<span class="uses-badge">${action.uses} uses</span>` : ''}
|
|
103
|
+
</div>
|
|
104
|
+
<div class="action-buttons" style="display: flex; gap: 8px;">
|
|
105
|
+
${action.attackRoll ? `<button class="attack-btn" data-action-index="${index}">⚔️ Attack</button>` : ''}
|
|
106
|
+
${action.damage ? `<button class="damage-btn" data-action-index="${index}">💥 Damage</button>` : ''}
|
|
107
|
+
${action.uses ? `<button class="use-btn" data-action-index="${index}">✨ Use</button>` : ''}
|
|
108
|
+
<button class="toggle-btn">▼</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
const desc = document.createElement('div');
|
|
114
|
+
desc.className = 'action-description';
|
|
115
|
+
desc.id = `action-desc-${index}`;
|
|
116
|
+
if (action.description) {
|
|
117
|
+
desc.innerHTML = `<div>${action.description}</div>`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
card.appendChild(header);
|
|
121
|
+
card.appendChild(desc);
|
|
122
|
+
|
|
123
|
+
// Toggle description visibility
|
|
124
|
+
const toggleBtn = header.querySelector('.toggle-btn');
|
|
125
|
+
toggleBtn.addEventListener('click', (e) => {
|
|
126
|
+
e.stopPropagation();
|
|
127
|
+
desc.classList.toggle('expanded');
|
|
128
|
+
toggleBtn.textContent = desc.classList.contains('expanded') ? '▲' : '▼';
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return card;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Export for use in other modules
|
|
135
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
136
|
+
module.exports = { createCard, createSpellCard, createActionCard };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Make available globally for popup-sheet.js
|
|
140
|
+
if (typeof window !== 'undefined') {
|
|
141
|
+
window.CardCreator = { createCard, createSpellCard, createActionCard };
|
|
142
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Character Portrait Module
|
|
3
|
+
*
|
|
4
|
+
* Handles character portrait display with circular cropping
|
|
5
|
+
* Exports to globalThis for use across all extensions
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
(function() {
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Crop an image to a circle with optional colored border
|
|
13
|
+
* @param {string} imageUrl - URL of the image to crop
|
|
14
|
+
* @param {number} size - Size of the output image (default: 200)
|
|
15
|
+
* @param {string} borderColor - Color of the border (default: null for no border)
|
|
16
|
+
* @param {number} borderWidth - Width of the border in pixels (default: 4)
|
|
17
|
+
* @returns {Promise<string>} - Data URL of the cropped circular image
|
|
18
|
+
*/
|
|
19
|
+
function cropToCircle(imageUrl, size = 200, borderColor = null, borderWidth = 4) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const img = new Image();
|
|
22
|
+
img.crossOrigin = 'anonymous'; // Handle CORS
|
|
23
|
+
|
|
24
|
+
img.onload = () => {
|
|
25
|
+
try {
|
|
26
|
+
// Create canvas
|
|
27
|
+
const canvas = document.createElement('canvas');
|
|
28
|
+
canvas.width = size;
|
|
29
|
+
canvas.height = size;
|
|
30
|
+
const ctx = canvas.getContext('2d');
|
|
31
|
+
|
|
32
|
+
// Calculate the radius for the image circle (accounting for border)
|
|
33
|
+
const imageRadius = borderColor ? (size / 2) - borderWidth : (size / 2);
|
|
34
|
+
|
|
35
|
+
// Draw circular clip path for the image
|
|
36
|
+
ctx.save();
|
|
37
|
+
ctx.beginPath();
|
|
38
|
+
ctx.arc(size / 2, size / 2, imageRadius, 0, Math.PI * 2);
|
|
39
|
+
ctx.closePath();
|
|
40
|
+
ctx.clip();
|
|
41
|
+
|
|
42
|
+
// Calculate scaling to cover the circle (crop to fit)
|
|
43
|
+
const scale = Math.max((imageRadius * 2) / img.width, (imageRadius * 2) / img.height);
|
|
44
|
+
const scaledWidth = img.width * scale;
|
|
45
|
+
const scaledHeight = img.height * scale;
|
|
46
|
+
|
|
47
|
+
// Center the image
|
|
48
|
+
const x = (size - scaledWidth) / 2;
|
|
49
|
+
const y = (size - scaledHeight) / 2;
|
|
50
|
+
|
|
51
|
+
// Draw image
|
|
52
|
+
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
|
53
|
+
ctx.restore();
|
|
54
|
+
|
|
55
|
+
// Draw border if color is provided
|
|
56
|
+
if (borderColor) {
|
|
57
|
+
ctx.beginPath();
|
|
58
|
+
ctx.arc(size / 2, size / 2, imageRadius, 0, Math.PI * 2);
|
|
59
|
+
ctx.strokeStyle = borderColor;
|
|
60
|
+
ctx.lineWidth = borderWidth;
|
|
61
|
+
ctx.stroke();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Convert to data URL
|
|
65
|
+
resolve(canvas.toDataURL('image/png'));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
reject(error);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
img.onerror = () => {
|
|
72
|
+
reject(new Error('Failed to load image'));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
img.src = imageUrl;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fallback debug object if not available
|
|
80
|
+
const debug = window.debug || {
|
|
81
|
+
log: console.log.bind(console),
|
|
82
|
+
warn: console.warn.bind(console),
|
|
83
|
+
error: console.error.bind(console)
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Display character portrait in the specified element
|
|
88
|
+
* @param {string} elementId - ID of the img element to display portrait in
|
|
89
|
+
* @param {object} characterData - Character data object
|
|
90
|
+
* @param {number} size - Size of the portrait (default: 120)
|
|
91
|
+
*/
|
|
92
|
+
async function displayCharacterPortrait(elementId, characterData, size = 120) {
|
|
93
|
+
const portraitElement = document.getElementById(elementId);
|
|
94
|
+
|
|
95
|
+
if (!portraitElement) {
|
|
96
|
+
debug.warn(`⚠️ Portrait element not found: ${elementId}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!characterData) {
|
|
101
|
+
debug.warn('⚠️ No character data provided for portrait');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Try multiple possible portrait URL fields
|
|
106
|
+
const portraitUrl = characterData.picture
|
|
107
|
+
|| characterData.avatarPicture
|
|
108
|
+
|| characterData.avatar
|
|
109
|
+
|| characterData.image
|
|
110
|
+
|| (characterData.rawDiceCloudData && characterData.rawDiceCloudData.creature && characterData.rawDiceCloudData.creature.picture)
|
|
111
|
+
|| (characterData.raw && characterData.raw.creature && characterData.raw.creature.picture)
|
|
112
|
+
|| (characterData.raw && characterData.raw.picture)
|
|
113
|
+
|| (characterData.creature && characterData.creature.picture)
|
|
114
|
+
|| null;
|
|
115
|
+
|
|
116
|
+
if (!portraitUrl) {
|
|
117
|
+
debug.log('ℹ️ No portrait URL found in character data');
|
|
118
|
+
portraitElement.style.display = 'none';
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
debug.log('🖼️ Displaying portrait from URL:', portraitUrl);
|
|
123
|
+
|
|
124
|
+
// Get the character's notification color for the border
|
|
125
|
+
const borderColor = characterData.notificationColor || '#3498db';
|
|
126
|
+
const borderWidth = 4;
|
|
127
|
+
|
|
128
|
+
// Clear any existing content
|
|
129
|
+
portraitElement.innerHTML = '';
|
|
130
|
+
|
|
131
|
+
// Create img element
|
|
132
|
+
const img = document.createElement('img');
|
|
133
|
+
img.style.width = '100%';
|
|
134
|
+
img.style.height = '100%';
|
|
135
|
+
img.style.objectFit = 'cover';
|
|
136
|
+
img.style.borderRadius = '50%';
|
|
137
|
+
img.alt = `${characterData.name || 'Character'} portrait`;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
// Try to crop to circle if cropToCircle is available
|
|
141
|
+
if (typeof cropToCircle === 'function') {
|
|
142
|
+
const croppedUrl = await cropToCircle(portraitUrl, size, borderColor, borderWidth);
|
|
143
|
+
img.src = croppedUrl;
|
|
144
|
+
portraitElement.appendChild(img);
|
|
145
|
+
portraitElement.style.display = 'block';
|
|
146
|
+
debug.log('✅ Portrait displayed (cropped with border)');
|
|
147
|
+
} else {
|
|
148
|
+
// Fallback: display directly without cropping
|
|
149
|
+
img.src = portraitUrl;
|
|
150
|
+
portraitElement.appendChild(img);
|
|
151
|
+
portraitElement.style.display = 'block';
|
|
152
|
+
debug.log('✅ Portrait displayed (uncropped)');
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
debug.warn('⚠️ Failed to crop portrait:', error);
|
|
156
|
+
// Fallback to original image
|
|
157
|
+
img.src = portraitUrl;
|
|
158
|
+
portraitElement.appendChild(img);
|
|
159
|
+
portraitElement.style.display = 'block';
|
|
160
|
+
debug.log('✅ Portrait displayed (uncropped fallback)');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Export to global scope
|
|
165
|
+
globalThis.cropToCircle = cropToCircle;
|
|
166
|
+
globalThis.displayCharacterPortrait = displayCharacterPortrait;
|
|
167
|
+
|
|
168
|
+
debug.log('✅ Character Portrait module loaded');
|
|
169
|
+
})();
|