diff --git a/admin.html b/admin.html index a3920bb..6d1b78b 100644 --- a/admin.html +++ b/admin.html @@ -594,6 +594,42 @@ color: #888; } + .skill-icon-btn { + width: 32px; + height: 32px; + padding: 0; + border: 2px dashed #555; + border-radius: 6px; + background: rgba(255,255,255,0.05); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + } + + .skill-icon-btn:hover { + border-color: #4CAF50; + background: rgba(76, 175, 80, 0.1); + } + + .skill-icon-btn.has-icon { + border-style: solid; + border-color: #4CAF50; + } + + .skill-icon-btn img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .skill-icon-btn .icon-placeholder { + font-size: 14px; + color: #666; + } + .skill-name-section { flex: 1; display: flex; @@ -1066,7 +1102,7 @@ Tag ID - Icon + Artwork Prefixes Visibility Spawn Radius @@ -1096,9 +1132,26 @@ Matches geocache tags array values -
- - +
+
+ + + Maps to cacheIcon100-{number}.png +
+ + +
+
+
+ + + + +
@@ -1508,6 +1561,22 @@
+ +
+

Skill Icon

+
+
+ No icon +
+
+ + + +

Recommended: 64x64 PNG. Max 500KB. Will be auto-renamed to skill ID.

+
+
+
+
@@ -1631,6 +1700,7 @@ let currentMonsterSkills = []; // Skills for the monster being edited let allClasses = []; let currentClassSkills = []; // Skills for the class being edited + let pendingSkillIcon = null; // Pending icon file for upload // API Helper async function api(endpoint, options = {}) { @@ -1784,14 +1854,22 @@ `` ).join(''); + const monsterId = document.getElementById('monsterId').value; container.innerHTML = currentMonsterSkills.map(ms => { const skill = allSkills.find(s => s.id === ms.skill_id); const baseName = skill ? skill.name : ms.skill_id; const displayName = ms.custom_name || baseName; const hasCustomName = !!ms.custom_name; const currentAnim = ms.animation || ''; + const hasIcon = !!ms.custom_icon; + const iconSrc = hasIcon ? `/mapgameimgs/skills/${ms.custom_icon}` : ''; return `
+
{ + const anim = animations[id]; + return ``; + }).join(''); + } + + // Update artwork preview in OSM tag modal + function updateArtworkPreview() { + const artworkNum = parseInt(document.getElementById('osmTagArtwork').value) || 1; + const padNum = String(artworkNum).padStart(2, '0'); + const basePath = '/mapgameimgs/cacheicons/cacheIcon100-'; + + const mainImg = document.getElementById('artworkPreview'); + const shadowImg = document.getElementById('artworkPreviewShadow'); + + if (mainImg) { + mainImg.src = `${basePath}${padNum}.png`; + mainImg.onerror = function() { this.style.display = 'none'; }; + mainImg.onload = function() { this.style.display = 'block'; }; + } + + if (shadowImg) { + shadowImg.src = `${basePath}${padNum}_shadow.png`; + shadowImg.onerror = function() { this.style.display = 'none'; }; + shadowImg.onload = function() { this.style.display = 'block'; }; + } + } + // Test animation preview function testMonsterAnimation() { const animId = document.getElementById('testAnimationSelect').value; @@ -2454,6 +2569,13 @@ // Toggle visibility of form sections based on skill type handleSkillTypeChange(); + // Load existing icon preview + if (skill.icon) { + updateSkillIconPreview(`/mapgameimgs/skills/${skill.icon}`); + } else { + resetSkillIconPreview(); + } + document.getElementById('skillModal').classList.add('active'); } @@ -2469,6 +2591,7 @@ // Reset to default (damage) type and toggle visibility document.getElementById('skillType').value = 'damage'; handleSkillTypeChange(); + resetSkillIconPreview(); document.getElementById('skillModal').classList.add('active'); }); @@ -2485,6 +2608,7 @@ // Set type to utility and toggle visibility document.getElementById('skillType').value = 'utility'; handleSkillTypeChange(); + resetSkillIconPreview(); // Set default utility values document.getElementById('utilityEffectType').value = 'mp_regen_multiplier'; document.getElementById('utilityEffectValue').value = '2.0'; @@ -2496,6 +2620,144 @@ function closeSkillModal() { document.getElementById('skillModal').classList.remove('active'); document.getElementById('skillId').disabled = false; + pendingSkillIcon = null; + } + + // Skill icon helper functions + function updateSkillIconPreview(src) { + const preview = document.getElementById('skillIconPreview'); + const removeBtn = document.getElementById('removeSkillIconBtn'); + if (src) { + preview.innerHTML = ``; + removeBtn.style.display = 'inline-block'; + } else { + preview.innerHTML = 'No icon'; + removeBtn.style.display = 'none'; + } + } + + function resetSkillIconPreview() { + pendingSkillIcon = null; + updateSkillIconPreview(null); + } + + async function uploadSkillIcon(skillId) { + if (!pendingSkillIcon) return; + const formData = new FormData(); + formData.append('icon', pendingSkillIcon); + try { + const response = await fetch(`/api/admin/skills/${skillId}/icon`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${accessToken}` }, + body: formData + }); + if (!response.ok) throw new Error('Upload failed'); + pendingSkillIcon = null; + } catch (err) { + console.error('Icon upload error:', err); + showToast('Failed to upload icon', 'error'); + } + } + + async function removeSkillIcon() { + const skillId = document.getElementById('skillEditId').value; + if (skillId) { + try { + await api(`/api/admin/skills/${skillId}/icon`, { method: 'DELETE' }); + // Update local data + const skill = allSkills.find(s => s.id === skillId); + if (skill) skill.icon = null; + } catch (err) { + showToast('Failed to remove icon', 'error'); + return; + } + } + pendingSkillIcon = null; + updateSkillIconPreview(null); + } + + // File input change listener + document.getElementById('skillIconFile').addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + if (file.size > 500 * 1024) { + showToast('Icon file must be under 500KB', 'error'); + return; + } + const reader = new FileReader(); + reader.onload = (e) => updateSkillIconPreview(e.target.result); + reader.readAsDataURL(file); + pendingSkillIcon = file; + }); + + // Monster skill icon upload + async function uploadMonsterSkillIcon(monsterTypeId, skillId) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/png,image/jpeg,image/gif,image/webp'; + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + if (file.size > 500 * 1024) { + showToast('Icon must be under 500KB', 'error'); + return; + } + const formData = new FormData(); + formData.append('icon', file); + try { + const response = await fetch(`/api/admin/monster-skills/${monsterTypeId}/${skillId}/icon`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${accessToken}` }, + body: formData + }); + if (!response.ok) throw new Error('Upload failed'); + const result = await response.json(); + // Update local data + const ms = currentMonsterSkills.find(s => s.skill_id === skillId); + if (ms) ms.custom_icon = result.icon; + renderMonsterSkills(); + showToast('Icon uploaded'); + } catch (err) { + console.error('Monster skill icon upload error:', err); + showToast('Failed to upload icon', 'error'); + } + }; + input.click(); + } + + // Class skill icon upload + async function uploadClassSkillIcon(classId, skillId) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/png,image/jpeg,image/gif,image/webp'; + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + if (file.size > 500 * 1024) { + showToast('Icon must be under 500KB', 'error'); + return; + } + const formData = new FormData(); + formData.append('icon', file); + try { + const response = await fetch(`/api/admin/class-skills/${classId}/${skillId}/icon`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${accessToken}` }, + body: formData + }); + if (!response.ok) throw new Error('Upload failed'); + const result = await response.json(); + // Update local data + const cs = currentClassSkills.find(s => s.skill_id === skillId); + if (cs) cs.custom_icon = result.icon; + renderClassSkills(); + showToast('Icon uploaded'); + } catch (err) { + console.error('Class skill icon upload error:', err); + showToast('Failed to upload icon', 'error'); + } + }; + input.click(); } document.getElementById('skillForm').addEventListener('submit', async (e) => { @@ -2546,11 +2808,13 @@ }; try { + let skillId; if (editId) { await api(`/api/admin/skills/${editId}`, { method: 'PUT', body: JSON.stringify(data) }); + skillId = editId; // Update local array immediately (optimistic update) const idx = allSkills.findIndex(s => s.id === editId); if (idx !== -1) { @@ -2558,14 +2822,21 @@ } showToast('Skill updated'); } else { - await api('/api/admin/skills', { + const result = await api('/api/admin/skills', { method: 'POST', body: JSON.stringify(data) }); + skillId = result.id || data.id; // Add to local array immediately - allSkills.push({ ...data }); + allSkills.push({ ...data, id: skillId }); showToast('Skill created'); } + + // Upload icon if pending + if (pendingSkillIcon && skillId) { + await uploadSkillIcon(skillId); + } + renderSkillTable(); closeSkillModal(); loadSkillsAdmin(); // Background refresh for consistency @@ -2790,6 +3061,8 @@ return; } + const classId = document.getElementById('classEditId').value; + // Group by unlock level const byLevel = {}; currentClassSkills.forEach(cs => { @@ -2806,8 +3079,15 @@ skills.forEach(cs => { const displayName = cs.custom_name || cs.base_name || cs.skill_id; const choiceLabel = cs.choice_group ? `Choice ${cs.choice_group}` : 'Auto'; + const hasIcon = !!cs.custom_icon; + const iconSrc = hasIcon ? `/mapgameimgs/skills/${cs.custom_icon}` : ''; html += `
+
2 ? '...' : ''); + const artworkNum = String(tag.artwork || 1).padStart(2, '0'); return ` ${escapeHtml(tag.id)} - ${escapeHtml(tag.icon)} + ${artworkNum}${tag.animation ? ` (${tag.animation})` : ''} ${prefixCount > 0 ? `${prefixCount} prefix${prefixCount > 1 ? 'es' : ''}` : 'None'} ${prefixPreview ? `
${escapeHtml(prefixPreview)}` : ''} @@ -3243,12 +3524,18 @@ form.reset(); + // Populate animation dropdowns from MONSTER_ANIMATIONS if available + populateAnimationDropdown('osmTagAnimation'); + populateAnimationDropdown('osmTagAnimationShadow'); + if (tag) { title.textContent = 'Edit OSM Tag'; document.getElementById('osmTagIdField').value = tag.id; idInput.value = tag.id; idInput.readOnly = true; - document.getElementById('osmTagIcon').value = tag.icon || 'map-marker'; + document.getElementById('osmTagArtwork').value = tag.artwork || 1; + document.getElementById('osmTagAnimation').value = tag.animation || ''; + document.getElementById('osmTagAnimationShadow').value = tag.animation_shadow || ''; document.getElementById('osmTagVisibility').value = tag.visibility_distance || 400; document.getElementById('osmTagSpawnRadius').value = tag.spawn_radius || 400; const prefixes = typeof tag.prefixes === 'string' ? JSON.parse(tag.prefixes || '[]') : (tag.prefixes || []); @@ -3257,12 +3544,17 @@ title.textContent = 'Add OSM Tag'; document.getElementById('osmTagIdField').value = ''; idInput.readOnly = false; - document.getElementById('osmTagIcon').value = 'map-marker'; + document.getElementById('osmTagArtwork').value = 1; + document.getElementById('osmTagAnimation').value = ''; + document.getElementById('osmTagAnimationShadow').value = ''; document.getElementById('osmTagVisibility').value = 400; document.getElementById('osmTagSpawnRadius').value = 400; document.getElementById('osmTagPrefixes').value = ''; } + // Update artwork preview + updateArtworkPreview(); + modal.classList.add('active'); } @@ -3304,7 +3596,9 @@ const data = { id: newId, - icon: document.getElementById('osmTagIcon').value.trim() || 'map-marker', + artwork: parseInt(document.getElementById('osmTagArtwork').value) || 1, + animation: document.getElementById('osmTagAnimation').value || null, + animation_shadow: document.getElementById('osmTagAnimationShadow').value || null, visibility_distance: parseInt(document.getElementById('osmTagVisibility').value) || 400, spawn_radius: parseInt(document.getElementById('osmTagSpawnRadius').value) || 400, prefixes: prefixes diff --git a/database.js b/database.js index 8fdf3ea..bdf260c 100644 --- a/database.js +++ b/database.js @@ -353,6 +353,17 @@ class HikeMapDB { this.db.exec(`ALTER TABLE osm_tags ADD COLUMN animation_shadow TEXT DEFAULT NULL`); } catch (e) { /* Column already exists */ } + // Skill icon migrations + try { + this.db.exec(`ALTER TABLE skills ADD COLUMN icon TEXT`); + } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE class_skills ADD COLUMN custom_icon TEXT`); + } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE monster_skills ADD COLUMN custom_icon TEXT`); + } catch (e) { /* Column already exists */ } + // OSM Tag settings - global prefix configuration this.db.exec(` CREATE TABLE IF NOT EXISTS osm_tag_settings ( @@ -1214,16 +1225,41 @@ class HikeMapDB { return stmt.run(id); } + // ===================== + // SKILL ICON METHODS + // ===================== + + updateSkillIcon(skillId, iconFilename) { + const stmt = this.db.prepare(`UPDATE skills SET icon = ? WHERE id = ?`); + return stmt.run(iconFilename, skillId); + } + + updateClassSkillIcon(classId, skillId, iconFilename) { + const stmt = this.db.prepare(` + UPDATE class_skills SET custom_icon = ? + WHERE class_id = ? AND skill_id = ? + `); + return stmt.run(iconFilename, classId, skillId); + } + + updateMonsterSkillIcon(monsterTypeId, skillId, iconFilename) { + const stmt = this.db.prepare(` + UPDATE monster_skills SET custom_icon = ? + WHERE monster_type_id = ? AND skill_id = ? + `); + return stmt.run(iconFilename, monsterTypeId, skillId); + } + // ===================== // CLASS SKILL NAMES METHODS // ===================== - // Get custom names from class_skills table (primary source - what admin panel edits) + // Get custom names and icons from class_skills table (primary source - what admin panel edits) getAllClassSkillNamesFromClassSkills() { const stmt = this.db.prepare(` - SELECT cs.id, cs.skill_id, cs.class_id, cs.custom_name, cs.custom_description + SELECT cs.id, cs.skill_id, cs.class_id, cs.custom_name, cs.custom_description, cs.custom_icon FROM class_skills cs - WHERE cs.custom_name IS NOT NULL AND cs.custom_name != '' + WHERE (cs.custom_name IS NOT NULL AND cs.custom_name != '') OR cs.custom_icon IS NOT NULL `); return stmt.all(); } diff --git a/index.html b/index.html index 7104e0a..8012213 100644 --- a/index.html +++ b/index.html @@ -4950,6 +4950,49 @@ }; } + // Get skill icon with fallback chain: class/monster override → base skill → emoji + function getSkillIcon(skillId, contextType = null, contextId = null) { + const baseSkill = SKILLS_DB[skillId]; + const hardcodedSkill = SKILLS[skillId]; + + // Check class override + if (contextType === 'class' && contextId) { + const classSkill = CLASS_SKILL_NAMES.find( + n => n.skillId === skillId && n.classId === contextId + ); + if (classSkill?.customIcon) { + return { type: 'image', src: `/mapgameimgs/skills/${classSkill.customIcon}` }; + } + } + + // Check monster override + if (contextType === 'monster' && contextId) { + const monsterSkills = MONSTER_SKILLS[contextId] || []; + const monsterSkill = monsterSkills.find(s => s.skillId === skillId); + if (monsterSkill?.customIcon) { + return { type: 'image', src: `/mapgameimgs/skills/${monsterSkill.customIcon}` }; + } + } + + // Base skill icon from database + if (baseSkill?.icon) { + return { type: 'image', src: `/mapgameimgs/skills/${baseSkill.icon}` }; + } + + // Emoji fallback + return { type: 'emoji', value: hardcodedSkill?.icon || '⚔️' }; + } + + // Render skill icon as HTML (with error fallback to emoji) + function renderSkillIcon(skillId, contextType = null, contextId = null, size = 20) { + const icon = getSkillIcon(skillId, contextType, contextId); + if (icon.type === 'image') { + const fallbackEmoji = SKILLS[skillId]?.icon || '⚔️'; + return ``; + } + return icon.value; + } + // Calculate hit chance: skill accuracy + (attacker accuracy - 90) - defender dodge function calculateHitChance(attackerAccuracy, defenderDodge, skillAccuracy) { const hitChance = skillAccuracy + (attackerAccuracy - 90) - defenderDodge; @@ -12216,9 +12259,10 @@ statsText = `${mpCost} MP`; } + const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 24); return `
- ${skill.icon} + ${iconHtml}
${displayName}
${displayDesc}
@@ -12308,9 +12352,10 @@ } } + const secondWindIconHtml = renderSkillIcon('second_wind', 'class', playerStats?.class, 24); container.innerHTML = `
- 💨 + ${secondWindIconHtml}
Second Wind
Double MP regen while walking for 1 hour
@@ -12433,12 +12478,12 @@ const displayName = skillInfo?.displayName || skill.name; const displayDesc = skillInfo?.displayDescription || skill.description; - const icon = hardcodedSkill?.icon || '⚔️'; + const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 32); const mpCost = skill.mpCost || skill.mp_cost || 0; return `
- ${icon} + ${iconHtml}
${displayName}
${displayDesc}
@@ -13201,7 +13246,7 @@ if (!baseSkill) return; const displayName = skillInfo?.displayName || baseSkill.name; - const icon = SKILLS[skillId]?.icon || baseSkill.icon || '⚔️'; + const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 24); const mpCost = baseSkill.mpCost || 0; const isActive = activeSkills.includes(skillId); @@ -13210,7 +13255,7 @@
Level ${tier}
- ${icon} + ${iconHtml}
${displayName} ${mpCost > 0 ? mpCost + ' MP' : 'Free'} @@ -13233,7 +13278,7 @@ if (!baseSkill) return; const displayName = skillInfo?.displayName || baseSkill.name; - const icon = SKILLS[skillId]?.icon || baseSkill.icon || '⚔️'; + const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 24); const mpCost = baseSkill.mpCost || 0; const isActive = activeSkills.includes(skillId); const canSwap = isAtHome && !isActive; @@ -13242,7 +13287,7 @@