@ -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