Browse Source

Add custom skill icon system with admin upload UI

Implements three-tier icon fallback: class/monster override → base skill → emoji.
Adds multer-based file uploads in admin panel for base skills, class skills,
and monster skills. Icons stored in /mapgameimgs/skills/ directory.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
master
HikeMap User 4 weeks ago
parent
commit
74602b482d
  1. 312
      admin.html
  2. 42
      database.js
  3. 65
      index.html
  4. 1
      package.json
  5. 113
      server.js

312
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 @@
<thead>
<tr>
<th>Tag ID</th>
<th>Icon</th>
<th>Artwork</th>
<th>Prefixes</th>
<th>Visibility</th>
<th>Spawn Radius</th>
@ -1096,9 +1132,26 @@
<input type="text" id="osmTagIdInput" required placeholder="e.g., grocery, restaurant">
<small style="color: #888; font-size: 11px;">Matches geocache tags array values</small>
</div>
<div class="form-row">
<div class="form-group">
<label>Artwork Number</label>
<input type="number" id="osmTagArtwork" value="1" min="1" placeholder="1" onchange="updateArtworkPreview()">
<small style="color: #888; font-size: 11px;">Maps to cacheIcon100-{number}.png</small>
<div id="artworkPreviewContainer" style="margin-top: 8px; position: relative; width: 80px; height: 80px;">
<img id="artworkPreviewShadow" src="" style="position: absolute; width: 64px; height: 64px; top: 8px; left: 8px; opacity: 0.5; display: none;">
<img id="artworkPreview" src="" style="position: absolute; width: 64px; height: 64px; top: 4px; left: 4px;">
</div>
</div>
<div class="form-group">
<label>Icon (MDI name)</label>
<input type="text" id="osmTagIcon" value="map-marker" placeholder="e.g., cart, silverware-fork-knife">
<label>Main Animation</label>
<select id="osmTagAnimation">
<option value="">None</option>
</select>
<label style="margin-top: 10px;">Shadow Animation</label>
<select id="osmTagAnimationShadow">
<option value="">None</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
@ -1508,6 +1561,22 @@
</div>
</div>
<!-- Skill Icon Upload -->
<div class="skill-icon-section" style="margin-top: 15px; padding: 15px; border: 1px solid #333; border-radius: 8px;">
<h4 style="margin: 0 0 10px 0; color: #4CAF50;">Skill Icon</h4>
<div style="display: flex; align-items: center; gap: 20px;">
<div id="skillIconPreview" style="width: 64px; height: 64px; border: 2px dashed #555; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: #1a1a1a; overflow: hidden;">
<span style="color: #666; font-size: 11px;">No icon</span>
</div>
<div>
<input type="file" id="skillIconFile" accept="image/png,image/jpeg,image/gif,image/webp" style="display: none;">
<button type="button" class="btn btn-secondary" style="margin-bottom: 5px;" onclick="document.getElementById('skillIconFile').click()">Upload Icon</button>
<button type="button" class="btn btn-danger" id="removeSkillIconBtn" style="display: none; margin-left: 5px;" onclick="removeSkillIcon()">Remove</button>
<p style="font-size: 11px; color: #666; margin: 5px 0 0 0;">Recommended: 64x64 PNG. Max 500KB. Will be auto-renamed to skill ID.</p>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeSkillModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Skill</button>
@ -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 @@
`<option value="${id}">${anim.name}</option>`
).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 `
<div class="monster-skill-item" data-id="${ms.id}">
<button type="button" class="skill-icon-btn ${hasIcon ? 'has-icon' : ''}"
onclick="uploadMonsterSkillIcon('${monsterId}', '${ms.skill_id}')"
title="Click to upload custom icon">
${hasIcon ? `<img src="${iconSrc}" onerror="this.parentElement.innerHTML='<span class=\\'icon-placeholder\\'>🖼</span>'">` : '<span class="icon-placeholder">🖼</span>'}
</button>
<div class="skill-name-section">
<input type="text" class="skill-custom-name" value="${escapeHtml(ms.custom_name || '')}"
placeholder="${escapeHtml(baseName)}"
@ -2132,6 +2210,43 @@
});
}
// Populate a single animation dropdown (for OSM tags, etc.)
function populateAnimationDropdown(dropdownId) {
const dropdown = document.getElementById(dropdownId);
if (!dropdown) return;
const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
const animIds = Object.keys(animations);
// Keep None option, add animation options
dropdown.innerHTML = '<option value="">None</option>' + animIds.map(id => {
const anim = animations[id];
return `<option value="${id}">${anim.name}</option>`;
}).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 = `<img src="${src}" style="max-width: 100%; max-height: 100%; object-fit: contain;">`;
removeBtn.style.display = 'inline-block';
} else {
preview.innerHTML = '<span style="color: #666; font-size: 11px;">No icon</span>';
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 ? `<span style="color: #FF9800; font-size: 10px;">Choice ${cs.choice_group}</span>` : '<span style="color: #4CAF50; font-size: 10px;">Auto</span>';
const hasIcon = !!cs.custom_icon;
const iconSrc = hasIcon ? `/mapgameimgs/skills/${cs.custom_icon}` : '';
html += `
<div class="monster-skill-item" data-id="${cs.id}">
<button type="button" class="skill-icon-btn ${hasIcon ? 'has-icon' : ''}"
onclick="uploadClassSkillIcon('${classId}', '${cs.skill_id}')"
title="Click to upload custom icon">
${hasIcon ? `<img src="${iconSrc}" onerror="this.parentElement.innerHTML='<span class=\\'icon-placeholder\\'>🖼</span>'">` : '<span class="icon-placeholder">🖼</span>'}
</button>
<div class="skill-name-section">
<input type="text" class="skill-custom-name" value="${escapeHtml(cs.custom_name || '')}"
placeholder="${escapeHtml(cs.base_name || cs.skill_id)}"
@ -3211,10 +3491,11 @@
const prefixes = typeof tag.prefixes === 'string' ? JSON.parse(tag.prefixes || '[]') : (tag.prefixes || []);
const prefixCount = prefixes.length;
const prefixPreview = prefixes.slice(0, 2).join(', ') + (prefixes.length > 2 ? '...' : '');
const artworkNum = String(tag.artwork || 1).padStart(2, '0');
return `
<tr>
<td><strong>${escapeHtml(tag.id)}</strong></td>
<td>${escapeHtml(tag.icon)}</td>
<td>${artworkNum}${tag.animation ? ` <small style="color:#888">(${tag.animation})</small>` : ''}</td>
<td title="${escapeHtml(prefixes.join(', '))}">
${prefixCount > 0 ? `<span style="color:#4CAF50">${prefixCount} prefix${prefixCount > 1 ? 'es' : ''}</span>` : '<span style="color:#888">None</span>'}
${prefixPreview ? `<br><small style="color:#666">${escapeHtml(prefixPreview)}</small>` : ''}
@ -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

42
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();
}

65
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 `<img src="${icon.src}" class="skill-icon" style="width:${size}px;height:${size}px;vertical-align:middle;margin-right:4px;" onerror="this.outerHTML='${fallbackEmoji}'">`;
}
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 `
<div class="char-sheet-skill">
<span class="skill-icon">${skill.icon}</span>
<span class="skill-icon">${iconHtml}</span>
<div class="skill-info">
<div class="skill-name">${displayName}</div>
<div class="skill-desc">${displayDesc}</div>
@ -12308,9 +12352,10 @@
}
}
const secondWindIconHtml = renderSkillIcon('second_wind', 'class', playerStats?.class, 24);
container.innerHTML = `
<div class="daily-skill">
<span class="skill-icon">💨</span>
<span class="skill-icon">${secondWindIconHtml}</span>
<div class="skill-info">
<div class="skill-name">Second Wind</div>
<div class="skill-desc">Double MP regen while walking for 1 hour</div>
@ -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 `
<div class="skill-choice-option" onclick="selectSkill('${skillId}')">
<span class="skill-choice-icon">${icon}</span>
<span class="skill-choice-icon">${iconHtml}</span>
<div class="skill-choice-details">
<div class="skill-choice-name">${displayName}</div>
<div class="skill-choice-desc">${displayDesc}</div>
@ -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 @@
<div class="homebase-skill-tier-label">Level ${tier}</div>
<div class="homebase-skill-options">
<div class="homebase-skill-btn active" style="cursor: default;">
<span class="skill-icon">${icon}</span>
<span class="skill-icon">${iconHtml}</span>
<div class="skill-info">
<span class="skill-name">${displayName}</span>
<span class="skill-mp">${mpCost > 0 ? mpCost + ' MP' : 'Free'}</span>
@ -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 @@
<button class="homebase-skill-btn ${isActive ? 'active' : ''} ${!isAtHome && !isActive ? 'disabled-away' : ''}"
onclick="swapSkillFromHomebase(${tier}, '${skillId}')"
${!canSwap && !isActive ? 'disabled' : ''}>
<span class="skill-icon">${icon}</span>
<span class="skill-icon">${iconHtml}</span>
<div class="skill-info">
<span class="skill-name">${displayName}</span>
<span class="skill-mp">${mpCost > 0 ? mpCost + ' MP' : 'Free'}</span>
@ -14533,14 +14578,14 @@
if (baseSkill.type === 'utility') return;
const displayName = skillInfo?.displayName || hardcodedSkill?.name || dbSkill?.name || skillId;
const icon = hardcodedSkill?.icon || '⚔️';
const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 20);
const mpCost = hardcodedSkill?.mpCost || dbSkill?.mpCost || 0;
const btn = document.createElement('button');
btn.className = 'skill-btn';
btn.dataset.skillId = skillId;
btn.innerHTML = `
<span class="skill-name">${icon} ${displayName}</span>
<span class="skill-name">${iconHtml} ${displayName}</span>
<span class="skill-cost ${mpCost === 0 ? 'free' : ''}">${mpCost > 0 ? mpCost + ' MP' : 'Free'}</span>
`;
btn.onclick = () => executePlayerSkill(skillId);

1
package.json

@ -15,6 +15,7 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"web-push": "^3.6.6",
"ws": "^8.14.2"
},

113
server.js

@ -3,9 +3,11 @@ const http = require('http');
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const crypto = require('crypto');
const webpush = require('web-push');
const jwt = require('jsonwebtoken');
const multer = require('multer');
const HikeMapDB = require('./database');
require('dotenv').config();
@ -64,6 +66,38 @@ app.use('/api', (req, res, next) => {
next();
});
// Ensure skill icons directory exists
const skillsIconDir = path.join(__dirname, 'mapgameimgs', 'skills');
if (!fsSync.existsSync(skillsIconDir)) {
fsSync.mkdirSync(skillsIconDir, { recursive: true });
}
// Configure multer for skill icon uploads
const skillIconStorage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, skillsIconDir);
},
filename: (req, file, cb) => {
// Auto-rename to skill ID with original extension
const skillId = req.params.skillId || req.params.id;
const ext = path.extname(file.originalname).toLowerCase() || '.png';
cb(null, `${skillId}${ext}`);
}
});
const skillIconUpload = multer({
storage: skillIconStorage,
limits: { fileSize: 500 * 1024 }, // 500KB max
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only PNG, JPG, GIF, and WebP images are allowed'));
}
}
});
// Serve service-worker.js with no-cache (critical for updates)
app.get('/service-worker.js', (req, res) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
@ -1347,7 +1381,8 @@ app.get('/api/skills', (req, res) => {
targeting_mode: s.targeting_mode || 'same_target',
statusEffect: s.status_effect ? JSON.parse(s.status_effect) : null,
playerUsable: !!s.player_usable,
monsterUsable: !!s.monster_usable
monsterUsable: !!s.monster_usable,
icon: s.icon || null
}));
res.json(formatted);
} catch (err) {
@ -1356,7 +1391,7 @@ app.get('/api/skills', (req, res) => {
}
});
// Get all class skill names (public endpoint)
// Get all class skill names and icons (public endpoint)
// Now reads from class_skills table (what admin panel edits) instead of legacy class_skill_names table
app.get('/api/class-skill-names', (req, res) => {
try {
@ -1367,7 +1402,8 @@ app.get('/api/class-skill-names', (req, res) => {
skillId: n.skill_id,
classId: n.class_id,
customName: n.custom_name,
customDescription: n.custom_description
customDescription: n.custom_description,
customIcon: n.custom_icon || null
}));
res.json(formatted);
} catch (err) {
@ -1388,6 +1424,7 @@ app.get('/api/monster-types/:id/skills', (req, res) => {
weight: s.weight,
minLevel: s.min_level,
customName: s.custom_name,
customIcon: s.custom_icon || null,
animation: s.animation || null, // Skill animation override
// Include skill details - use custom_name if set, otherwise base name
name: s.custom_name || s.name,
@ -1775,6 +1812,7 @@ app.get('/api/admin/skills', adminOnly, (req, res) => {
player_usable: !!s.player_usable,
monster_usable: !!s.monster_usable,
enabled: !!s.enabled,
icon: s.icon || null,
created_at: s.created_at
}));
res.json({ skills: formatted });
@ -1825,6 +1863,74 @@ app.delete('/api/admin/skills/:id', adminOnly, (req, res) => {
}
});
// Upload base skill icon
app.post('/api/admin/skills/:id/icon', adminOnly, skillIconUpload.single('icon'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
db.updateSkillIcon(req.params.id, req.file.filename);
broadcastAdminChange('skill', { action: 'updated', id: req.params.id });
res.json({ success: true, icon: req.file.filename });
} catch (err) {
console.error('Upload skill icon error:', err);
res.status(500).json({ error: 'Failed to upload icon' });
}
});
// Delete skill icon
app.delete('/api/admin/skills/:id/icon', adminOnly, async (req, res) => {
try {
const skill = db.getSkill(req.params.id);
if (skill && skill.icon) {
const iconPath = path.join(skillsIconDir, skill.icon);
await fs.unlink(iconPath).catch(() => {});
db.updateSkillIcon(req.params.id, null);
}
broadcastAdminChange('skill', { action: 'updated', id: req.params.id });
res.json({ success: true });
} catch (err) {
console.error('Delete skill icon error:', err);
res.status(500).json({ error: 'Failed to delete icon' });
}
});
// Upload class-specific skill icon override
app.post('/api/admin/class-skills/:classId/:skillId/icon', adminOnly, (req, res, next) => {
// Override filename for class-specific icon
req.params.id = `${req.params.classId}_${req.params.skillId}`;
next();
}, skillIconUpload.single('icon'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
db.updateClassSkillIcon(req.params.classId, req.params.skillId, req.file.filename);
res.json({ success: true, icon: req.file.filename });
} catch (err) {
console.error('Upload class skill icon error:', err);
res.status(500).json({ error: 'Failed to upload icon' });
}
});
// Upload monster-specific skill icon override
app.post('/api/admin/monster-skills/:monsterTypeId/:skillId/icon', adminOnly, (req, res, next) => {
// Override filename for monster-specific icon
req.params.id = `${req.params.monsterTypeId}_${req.params.skillId}`;
next();
}, skillIconUpload.single('icon'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
db.updateMonsterSkillIcon(req.params.monsterTypeId, req.params.skillId, req.file.filename);
res.json({ success: true, icon: req.file.filename });
} catch (err) {
console.error('Upload monster skill icon error:', err);
res.status(500).json({ error: 'Failed to upload icon' });
}
});
// Get all class skill names (admin)
app.get('/api/admin/class-skill-names', adminOnly, (req, res) => {
try {
@ -1892,6 +1998,7 @@ app.get('/api/admin/monster-skills', adminOnly, (req, res) => {
weight: s.weight,
min_level: s.min_level,
custom_name: s.custom_name,
custom_icon: s.custom_icon || null,
animation: s.animation || null
}));
res.json({ monsterSkills: formatted });

Loading…
Cancel
Save