Browse Source

Add status effect overlays and monster skill customization

- Add visual status/buff overlays on combat sprites (100x100px full overlay)
- Monster skills can now have custom names per monster
- Skills admin page for full skill CRUD
- Fix monster buff skills (defend) to properly buff instead of damage
- Fix custom skill names appearing in combat log
- Auto-copy default images when creating new monsters
- Add monster toggle endpoint fix

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
master
HikeMap User 1 month ago
parent
commit
5b7e1bcc56
  1. 661
      admin.html
  2. 51
      database.js
  3. 1
      docker-compose.yml
  4. 195
      index.html
  5. BIN
      mapgameimgs/default_buff100.png
  6. BIN
      mapgameimgs/default_status100.png
  7. BIN
      mapgameimgs/defense100.png
  8. BIN
      mapgameimgs/defense_up100.png
  9. BIN
      mapgameimgs/moop_fanciest100.png
  10. BIN
      mapgameimgs/moop_fanciest50.png
  11. BIN
      mapgameimgs/moop_fancy100.png
  12. BIN
      mapgameimgs/moop_fancy50.png
  13. BIN
      mapgameimgs/moop_sub_par100.png
  14. BIN
      mapgameimgs/moop_sub_par50.png
  15. BIN
      mapgameimgs/playerattack50.png
  16. BIN
      mapgameimgs/poison100.png
  17. 44
      server.js

661
admin.html

@ -344,6 +344,65 @@
gap: 15px; gap: 15px;
} }
.form-row-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
}
.modal-wide {
max-width: 700px;
}
.status-effect-section {
margin-top: 20px;
padding: 15px;
background: rgba(255,255,255,0.02);
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
}
.status-effect-section h4 {
margin-bottom: 5px;
color: #aaa;
font-size: 0.9rem;
}
/* Skill Type Badges */
.skill-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.skill-type-damage {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
}
.skill-type-heal {
background: rgba(76, 175, 80, 0.2);
color: #4CAF50;
}
.skill-type-buff {
background: rgba(33, 150, 243, 0.2);
color: #2196F3;
}
.skill-type-debuff {
background: rgba(156, 39, 176, 0.2);
color: #9C27B0;
}
.skill-type-status {
background: rgba(255, 152, 0, 0.2);
color: #FF9800;
}
.form-actions { .form-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@ -444,6 +503,89 @@
padding: 8px 12px; padding: 8px 12px;
} }
/* Skills Editor */
.skills-section {
margin-top: 20px;
padding: 15px;
background: rgba(255,255,255,0.02);
border-radius: 8px;
}
.skills-section h4 {
margin-bottom: 15px;
color: #aaa;
font-size: 0.9rem;
}
.add-skill-row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 10px;
}
.add-skill-row select {
flex: 1;
}
.monster-skill-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(255,255,255,0.05);
border-radius: 6px;
margin-bottom: 8px;
}
.monster-skill-item .skill-name {
flex: 1;
font-weight: 500;
}
.monster-skill-item .skill-weight,
.monster-skill-item .skill-min-level {
width: 60px;
text-align: center;
}
.monster-skill-item label {
font-size: 11px;
color: #888;
}
.skill-name-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.skill-custom-name {
width: 100%;
padding: 4px 8px;
font-size: 13px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 4px;
color: #fff;
}
.skill-custom-name:focus {
outline: none;
border-color: #4CAF50;
}
.skill-custom-name::placeholder {
color: #888;
font-style: italic;
}
.skill-base-name {
font-size: 10px;
color: #666;
}
/* Stats Display */ /* Stats Display */
.stats-grid { .stats-grid {
display: grid; display: grid;
@ -517,6 +659,9 @@
<a class="nav-item active" data-section="monsters"> <a class="nav-item active" data-section="monsters">
<span class="icon">&#128126;</span> Monsters <span class="icon">&#128126;</span> Monsters
</a> </a>
<a class="nav-item" data-section="skills">
<span class="icon">&#9889;</span> Skills
</a>
<a class="nav-item" data-section="users"> <a class="nav-item" data-section="users">
<span class="icon">&#128100;</span> Users <span class="icon">&#128100;</span> Users
</a> </a>
@ -557,6 +702,34 @@
</table> </table>
</section> </section>
<!-- Skills Section -->
<section id="skills-section" class="section">
<div class="section-header">
<h2>Skills Database</h2>
<button class="btn btn-primary" id="addSkillBtn">+ Add Skill</button>
</div>
<table class="data-table" id="skillTable">
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th>Type</th>
<th>Power</th>
<th>Accuracy</th>
<th>MP</th>
<th>Target</th>
<th>Player</th>
<th>Monster</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="skillTableBody">
<tr><td colspan="11" class="loading">Loading...</td></tr>
</tbody>
</table>
</section>
<!-- Users Section --> <!-- Users Section -->
<section id="users-section" class="section"> <section id="users-section" class="section">
<div class="section-header"> <div class="section-header">
@ -678,6 +851,21 @@
</label> </label>
</div> </div>
<div class="skills-section">
<h4>Monster Skills</h4>
<div id="monsterSkillsList"></div>
<div class="add-skill-row">
<select id="addSkillSelect" onchange="onSkillSelectChange()">
<option value="">-- Select a skill --</option>
</select>
<input type="text" id="addSkillCustomName" placeholder="Custom name (optional)" style="width: 150px;">
<input type="number" id="addSkillWeight" placeholder="Weight" value="10" min="1" style="width: 70px;">
<input type="number" id="addSkillMinLevel" placeholder="Min Lvl" value="1" min="1" style="width: 70px;">
<button type="button" class="btn btn-secondary btn-small" onclick="addMonsterSkill()">Add</button>
</div>
<p style="font-size: 11px; color: #666; margin-top: 5px;">Custom name overrides skill name for this monster. Weight = selection probability.</p>
</div>
<div class="dialogue-section"> <div class="dialogue-section">
<h4>Dialogues (one per line)</h4> <h4>Dialogues (one per line)</h4>
<div class="form-group"> <div class="form-group">
@ -771,12 +959,128 @@
</div> </div>
</div> </div>
<!-- Skill Edit Modal -->
<div class="modal-overlay" id="skillModal">
<div class="modal modal-wide">
<div class="modal-header">
<h3 id="skillModalTitle">Edit Skill</h3>
<button class="modal-close" onclick="closeSkillModal()">&times;</button>
</div>
<form id="skillForm">
<input type="hidden" id="skillEditId">
<div class="form-row">
<div class="form-group">
<label>Skill Name</label>
<input type="text" id="skillName" required placeholder="e.g., Double Attack">
</div>
<div class="form-group">
<label>ID (unique identifier)</label>
<input type="text" id="skillId" required placeholder="e.g., double_attack">
</div>
</div>
<div class="form-group">
<label>Description</label>
<input type="text" id="skillDescription" placeholder="e.g., Attack twice with reduced power">
</div>
<div class="form-row">
<div class="form-group">
<label>Type</label>
<select id="skillType" required>
<option value="damage">Damage</option>
<option value="heal">Heal</option>
<option value="buff">Buff</option>
<option value="debuff">Debuff</option>
<option value="status">Status Effect</option>
</select>
</div>
<div class="form-group">
<label>Target</label>
<select id="skillTarget">
<option value="enemy">Enemy</option>
<option value="self">Self</option>
<option value="all_enemies">All Enemies</option>
</select>
</div>
</div>
<div class="form-row-4">
<div class="form-group">
<label>MP Cost</label>
<input type="number" id="skillMpCost" value="0" min="0">
</div>
<div class="form-group">
<label>Base Power</label>
<input type="number" id="skillBasePower" value="100" min="0">
</div>
<div class="form-group">
<label>Accuracy (%)</label>
<input type="number" id="skillAccuracy" value="95" min="0" max="100">
</div>
<div class="form-group">
<label>Hit Count</label>
<input type="number" id="skillHitCount" value="1" min="1">
</div>
</div>
<div class="status-effect-section">
<h4>Status Effect (optional)</h4>
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">For skills that apply effects over time (poison, burn, etc.)</p>
<div class="form-row-3">
<div class="form-group">
<label>Effect Type</label>
<select id="skillStatusType">
<option value="">None</option>
<option value="poison">Poison</option>
<option value="burn">Burn</option>
<option value="bleed">Bleed</option>
<option value="regen">Regeneration</option>
<option value="stun">Stun</option>
</select>
</div>
<div class="form-group">
<label>Damage/Turn</label>
<input type="number" id="skillStatusDamage" value="5" min="0">
</div>
<div class="form-group">
<label>Duration (turns)</label>
<input type="number" id="skillStatusDuration" value="3" min="1">
</div>
</div>
</div>
<div class="form-row-3" style="margin-top: 15px;">
<div class="form-group">
<label>
<input type="checkbox" id="skillPlayerUsable" checked> Player Can Use
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="skillMonsterUsable" checked> Monster Can Use
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="skillEnabled" checked> Enabled
</label>
</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>
</div>
</form>
</div>
</div>
<script> <script>
// State // State
let accessToken = localStorage.getItem('accessToken'); let accessToken = localStorage.getItem('accessToken');
let monsters = []; let monsters = [];
let users = []; let users = [];
let settings = {}; let settings = {};
let allSkills = [];
let currentMonsterSkills = []; // Skills for the monster being edited
// API Helper // API Helper
async function api(endpoint, options = {}) { async function api(endpoint, options = {}) {
@ -864,6 +1168,7 @@
loadMonsters(); loadMonsters();
loadUsers(); loadUsers();
loadSettings(); loadSettings();
loadSkillsAdmin(); // Load skills for admin (includes disabled)
} }
// ============= MONSTERS ============= // ============= MONSTERS =============
@ -877,6 +1182,164 @@
} }
} }
async function loadSkills() {
try {
const data = await api('/api/skills');
allSkills = data.skills || [];
populateSkillSelect();
} catch (e) {
console.error('Failed to load skills:', e);
}
}
function populateSkillSelect() {
const select = document.getElementById('addSkillSelect');
select.innerHTML = '<option value="">-- Select a skill --</option>';
// Support both snake_case (admin API) and camelCase (public API)
allSkills.filter(s => s.monster_usable || s.monsterUsable).forEach(skill => {
const opt = document.createElement('option');
opt.value = skill.id;
opt.textContent = `${skill.name} (${skill.type})`;
select.appendChild(opt);
});
}
async function loadMonsterSkills(monsterTypeId) {
if (!monsterTypeId) {
currentMonsterSkills = [];
renderMonsterSkills();
return;
}
try {
const data = await api('/api/admin/monster-skills');
currentMonsterSkills = (data.monsterSkills || []).filter(ms => ms.monster_type_id === monsterTypeId);
renderMonsterSkills();
} catch (e) {
console.error('Failed to load monster skills:', e);
currentMonsterSkills = [];
renderMonsterSkills();
}
}
function renderMonsterSkills() {
const container = document.getElementById('monsterSkillsList');
if (currentMonsterSkills.length === 0) {
container.innerHTML = '<p style="color: #666; font-size: 12px;">No skills assigned. Monster will only use basic attack.</p>';
return;
}
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;
return `
<div class="monster-skill-item" data-id="${ms.id}">
<div class="skill-name-section">
<input type="text" class="skill-custom-name" value="${escapeHtml(ms.custom_name || '')}"
placeholder="${escapeHtml(baseName)}"
onchange="updateMonsterSkillName(${ms.id}, this.value)"
title="Custom name (leave empty for default: ${escapeHtml(baseName)})">
<span class="skill-base-name">${escapeHtml(baseName)}</span>
</div>
<div>
<label>Wt</label>
<input type="number" class="skill-weight" value="${ms.weight}" min="1"
onchange="updateMonsterSkill(${ms.id}, 'weight', this.value)">
</div>
<div>
<label>Lvl</label>
<input type="number" class="skill-min-level" value="${ms.min_level}" min="1"
onchange="updateMonsterSkill(${ms.id}, 'min_level', this.value)">
</div>
<button type="button" class="btn btn-danger btn-small" onclick="removeMonsterSkill(${ms.id})"></button>
</div>
`;
}).join('');
}
function onSkillSelectChange() {
// Clear custom name when selecting a new skill
document.getElementById('addSkillCustomName').value = '';
}
async function addMonsterSkill() {
const monsterId = document.getElementById('monsterId').value;
const skillId = document.getElementById('addSkillSelect').value;
const customName = document.getElementById('addSkillCustomName').value.trim();
const weight = parseInt(document.getElementById('addSkillWeight').value) || 10;
const minLevel = parseInt(document.getElementById('addSkillMinLevel').value) || 1;
if (!skillId) {
showToast('Please select a skill', 'error');
return;
}
if (!monsterId) {
showToast('Please save the monster first before adding skills', 'error');
return;
}
// Check if skill already assigned
if (currentMonsterSkills.some(ms => ms.skill_id === skillId)) {
showToast('This skill is already assigned to this monster', 'error');
return;
}
try {
await api('/api/admin/monster-skills', {
method: 'POST',
body: JSON.stringify({
monster_type_id: monsterId,
skill_id: skillId,
custom_name: customName || null,
weight: weight,
min_level: minLevel
})
});
showToast('Skill added');
await loadMonsterSkills(monsterId);
document.getElementById('addSkillSelect').value = '';
document.getElementById('addSkillCustomName').value = '';
} catch (e) {
showToast('Failed to add skill: ' + e.message, 'error');
}
}
async function updateMonsterSkillName(id, value) {
try {
await api(`/api/admin/monster-skills/${id}`, {
method: 'PUT',
body: JSON.stringify({ custom_name: value.trim() || null })
});
// Update local state
const ms = currentMonsterSkills.find(s => s.id === id);
if (ms) ms.custom_name = value.trim() || null;
} catch (e) {
showToast('Failed to update skill name: ' + e.message, 'error');
}
}
async function updateMonsterSkill(id, field, value) {
try {
await api(`/api/admin/monster-skills/${id}`, {
method: 'PUT',
body: JSON.stringify({ [field]: parseInt(value) })
});
} catch (e) {
showToast('Failed to update skill: ' + e.message, 'error');
}
}
async function removeMonsterSkill(id) {
try {
await api(`/api/admin/monster-skills/${id}`, { method: 'DELETE' });
showToast('Skill removed');
const monsterId = document.getElementById('monsterId').value;
await loadMonsterSkills(monsterId);
} catch (e) {
showToast('Failed to remove skill: ' + e.message, 'error');
}
}
function renderMonsterTable() { function renderMonsterTable() {
const tbody = document.getElementById('monsterTableBody'); const tbody = document.getElementById('monsterTableBody');
if (monsters.length === 0) { if (monsters.length === 0) {
@ -911,8 +1374,8 @@
async function toggleMonster(id, enabled) { async function toggleMonster(id, enabled) {
try { try {
await api(`/api/admin/monster-types/${id}`, {
method: 'PUT',
await api(`/api/admin/monster-types/${id}/enabled`, {
method: 'PATCH',
body: JSON.stringify({ enabled }) body: JSON.stringify({ enabled })
}); });
showToast(enabled ? 'Monster enabled' : 'Monster disabled'); showToast(enabled ? 'Monster enabled' : 'Monster disabled');
@ -935,7 +1398,7 @@
} }
} }
function editMonster(id) {
async function editMonster(id) {
const monster = monsters.find(m => m.id === id); const monster = monsters.find(m => m.id === id);
if (!monster) return; if (!monster) return;
@ -960,6 +1423,9 @@
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n'); document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n'); document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
// Load monster skills
await loadMonsterSkills(monster.id);
document.getElementById('monsterModal').classList.add('active'); document.getElementById('monsterModal').classList.add('active');
} }
@ -997,6 +1463,10 @@
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n'); document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n'); document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
// Clear skills (cloned monster needs to be saved first)
currentMonsterSkills = [];
document.getElementById('monsterSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save monster first, then edit to add skills.</p>';
document.getElementById('monsterModal').classList.add('active'); document.getElementById('monsterModal').classList.add('active');
} }
@ -1005,6 +1475,9 @@
document.getElementById('monsterForm').reset(); document.getElementById('monsterForm').reset();
document.getElementById('monsterId').value = ''; document.getElementById('monsterId').value = '';
document.getElementById('monsterEnabled').checked = true; document.getElementById('monsterEnabled').checked = true;
// Clear skills (new monster needs to be saved first)
currentMonsterSkills = [];
document.getElementById('monsterSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save monster first, then edit to add skills.</p>';
document.getElementById('monsterModal').classList.add('active'); document.getElementById('monsterModal').classList.add('active');
}); });
@ -1059,6 +1532,188 @@
} }
}); });
// ============= SKILLS DATABASE =============
async function loadSkillsAdmin() {
try {
const data = await api('/api/admin/skills');
allSkills = data.skills || [];
renderSkillTable();
populateSkillSelect(); // Also update the monster skill dropdown
} catch (e) {
showToast('Failed to load skills: ' + e.message, 'error');
}
}
function renderSkillTable() {
const tbody = document.getElementById('skillTableBody');
if (allSkills.length === 0) {
tbody.innerHTML = '<tr><td colspan="11">No skills found</td></tr>';
return;
}
tbody.innerHTML = allSkills.map(s => {
const statusEffect = s.status_effect ? JSON.parse(s.status_effect) : null;
return `
<tr>
<td><strong>${escapeHtml(s.name)}</strong></td>
<td><code>${escapeHtml(s.id)}</code></td>
<td><span class="skill-type-badge skill-type-${s.type}">${s.type}</span></td>
<td>${s.base_power}${s.hit_count > 1 ? ' ×' + s.hit_count : ''}</td>
<td>${s.accuracy}%</td>
<td>${s.mp_cost}</td>
<td>${s.target}</td>
<td>${s.player_usable ? '✓' : '✗'}</td>
<td>${s.monster_usable ? '✓' : '✗'}</td>
<td>
<label class="toggle">
<input type="checkbox" ${s.enabled ? 'checked' : ''}
onchange="toggleSkill('${s.id}', this.checked)">
<span class="toggle-slider"></span>
</label>
</td>
<td class="actions">
<button class="btn btn-secondary btn-small" onclick="editSkill('${s.id}')">Edit</button>
<button class="btn btn-danger btn-small" onclick="deleteSkill('${s.id}')">Delete</button>
</td>
</tr>
`}).join('');
}
async function toggleSkill(id, enabled) {
try {
await api(`/api/admin/skills/${id}`, {
method: 'PUT',
body: JSON.stringify({ enabled })
});
showToast(enabled ? 'Skill enabled' : 'Skill disabled');
loadSkillsAdmin();
} catch (e) {
showToast('Failed to toggle skill: ' + e.message, 'error');
loadSkillsAdmin();
}
}
async function deleteSkill(id) {
if (!confirm('Are you sure you want to delete this skill? This will also remove it from all monsters.')) return;
try {
await api(`/api/admin/skills/${id}`, { method: 'DELETE' });
showToast('Skill deleted');
loadSkillsAdmin();
} catch (e) {
showToast('Failed to delete skill: ' + e.message, 'error');
}
}
function editSkill(id) {
const skill = allSkills.find(s => s.id === id);
if (!skill) return;
document.getElementById('skillModalTitle').textContent = 'Edit Skill';
document.getElementById('skillEditId').value = skill.id;
document.getElementById('skillId').value = skill.id;
document.getElementById('skillId').disabled = true; // Can't change ID on edit
document.getElementById('skillName').value = skill.name;
document.getElementById('skillDescription').value = skill.description || '';
document.getElementById('skillType').value = skill.type;
document.getElementById('skillTarget').value = skill.target;
document.getElementById('skillMpCost').value = skill.mp_cost;
document.getElementById('skillBasePower').value = skill.base_power;
document.getElementById('skillAccuracy').value = skill.accuracy;
document.getElementById('skillHitCount').value = skill.hit_count;
document.getElementById('skillPlayerUsable').checked = skill.player_usable;
document.getElementById('skillMonsterUsable').checked = skill.monster_usable;
document.getElementById('skillEnabled').checked = skill.enabled;
// Parse status effect
if (skill.status_effect) {
try {
const effect = JSON.parse(skill.status_effect);
document.getElementById('skillStatusType').value = effect.type || '';
document.getElementById('skillStatusDamage').value = effect.damage || 5;
document.getElementById('skillStatusDuration').value = effect.duration || 3;
} catch {
document.getElementById('skillStatusType').value = '';
}
} else {
document.getElementById('skillStatusType').value = '';
document.getElementById('skillStatusDamage').value = 5;
document.getElementById('skillStatusDuration').value = 3;
}
document.getElementById('skillModal').classList.add('active');
}
document.getElementById('addSkillBtn').addEventListener('click', () => {
document.getElementById('skillModalTitle').textContent = 'Add Skill';
document.getElementById('skillForm').reset();
document.getElementById('skillEditId').value = '';
document.getElementById('skillId').disabled = false;
document.getElementById('skillPlayerUsable').checked = true;
document.getElementById('skillMonsterUsable').checked = true;
document.getElementById('skillEnabled').checked = true;
document.getElementById('skillModal').classList.add('active');
});
function closeSkillModal() {
document.getElementById('skillModal').classList.remove('active');
document.getElementById('skillId').disabled = false;
}
document.getElementById('skillForm').addEventListener('submit', async (e) => {
e.preventDefault();
const editId = document.getElementById('skillEditId').value;
const skillId = document.getElementById('skillId').value;
// Build status effect JSON if type is selected
let statusEffect = null;
const statusType = document.getElementById('skillStatusType').value;
if (statusType) {
statusEffect = JSON.stringify({
type: statusType,
damage: parseInt(document.getElementById('skillStatusDamage').value) || 5,
duration: parseInt(document.getElementById('skillStatusDuration').value) || 3
});
}
const data = {
id: skillId,
name: document.getElementById('skillName').value,
description: document.getElementById('skillDescription').value,
type: document.getElementById('skillType').value,
target: document.getElementById('skillTarget').value,
mp_cost: parseInt(document.getElementById('skillMpCost').value) || 0,
base_power: parseInt(document.getElementById('skillBasePower').value) || 100,
accuracy: parseInt(document.getElementById('skillAccuracy').value) || 95,
hit_count: parseInt(document.getElementById('skillHitCount').value) || 1,
status_effect: statusEffect,
player_usable: document.getElementById('skillPlayerUsable').checked,
monster_usable: document.getElementById('skillMonsterUsable').checked,
enabled: document.getElementById('skillEnabled').checked
};
try {
if (editId) {
await api(`/api/admin/skills/${editId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
showToast('Skill updated');
} else {
await api('/api/admin/skills', {
method: 'POST',
body: JSON.stringify(data)
});
showToast('Skill created');
}
closeSkillModal();
loadSkillsAdmin();
} catch (e) {
showToast('Failed to save skill: ' + e.message, 'error');
}
});
// ============= USERS ============= // ============= USERS =============
async function loadUsers() { async function loadUsers() {
try { try {

51
database.js

@ -204,6 +204,11 @@ class HikeMapDB {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN dodge INTEGER DEFAULT 5`); this.db.exec(`ALTER TABLE monster_types ADD COLUMN dodge INTEGER DEFAULT 5`);
} catch (e) { /* Column already exists */ } } catch (e) { /* Column already exists */ }
// Migration: Add custom_name to monster_skills for per-monster skill renaming
try {
this.db.exec(`ALTER TABLE monster_skills ADD COLUMN custom_name TEXT`);
} catch (e) { /* Column already exists */ }
// Game settings table - key/value store for game configuration // Game settings table - key/value store for game configuration
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS game_settings ( CREATE TABLE IF NOT EXISTS game_settings (
@ -702,6 +707,11 @@ class HikeMapDB {
return stmt.run(id); return stmt.run(id);
} }
toggleMonsterEnabled(id, enabled) {
const stmt = this.db.prepare(`UPDATE monster_types SET enabled = ? WHERE id = ?`);
return stmt.run(enabled ? 1 : 0, id);
}
seedDefaultMonsters() { seedDefaultMonsters() {
// Check if Moop already exists // Check if Moop already exists
const existing = this.getMonsterType('moop'); const existing = this.getMonsterType('moop');
@ -910,30 +920,41 @@ class HikeMapDB {
createMonsterSkill(data) { createMonsterSkill(data) {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO monster_skills (monster_type_id, skill_id, weight, min_level)
VALUES (?, ?, ?, ?)
INSERT INTO monster_skills (monster_type_id, skill_id, weight, min_level, custom_name)
VALUES (?, ?, ?, ?, ?)
`); `);
return stmt.run( return stmt.run(
data.monsterTypeId || data.monster_type_id, data.monsterTypeId || data.monster_type_id,
data.skillId || data.skill_id, data.skillId || data.skill_id,
data.weight || 10, data.weight || 10,
data.minLevel || data.min_level || 1
data.minLevel || data.min_level || 1,
data.customName || data.custom_name || null
); );
} }
updateMonsterSkill(id, data) { updateMonsterSkill(id, data) {
const stmt = this.db.prepare(`
UPDATE monster_skills SET
monster_type_id = ?, skill_id = ?, weight = ?, min_level = ?
WHERE id = ?
`);
return stmt.run(
data.monsterTypeId || data.monster_type_id,
data.skillId || data.skill_id,
data.weight || 10,
data.minLevel || data.min_level || 1,
id
);
// Build dynamic update for partial updates
const updates = [];
const values = [];
if (data.weight !== undefined) {
updates.push('weight = ?');
values.push(data.weight);
}
if (data.min_level !== undefined || data.minLevel !== undefined) {
updates.push('min_level = ?');
values.push(data.min_level || data.minLevel);
}
if (data.custom_name !== undefined || data.customName !== undefined) {
updates.push('custom_name = ?');
values.push(data.custom_name || data.customName || null);
}
if (updates.length === 0) return;
values.push(id);
const stmt = this.db.prepare(`UPDATE monster_skills SET ${updates.join(', ')} WHERE id = ?`);
return stmt.run(...values);
} }
deleteMonsterSkill(id) { deleteMonsterSkill(id) {

1
docker-compose.yml

@ -8,6 +8,7 @@ services:
- ./server.js:/app/server.js:ro - ./server.js:/app/server.js:ro
- ./database.js:/app/database.js:ro - ./database.js:/app/database.js:ro
- ./:/app/data - ./:/app/data
- ./mapgameimgs:/app/mapgameimgs
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=production - NODE_ENV=production

195
index.html

@ -2301,10 +2301,16 @@
margin-bottom: 6px; margin-bottom: 6px;
} }
.monster-entry-icon { .monster-entry-icon {
width: 32px;
height: 32px;
width: 100px;
height: 100px;
object-fit: contain; object-fit: contain;
margin-right: 8px;
}
.monster-entry .sprite-container {
position: relative;
width: 100px;
height: 100px;
margin-right: 10px;
flex-shrink: 0;
} }
.monster-entry-name { .monster-entry-name {
font-size: 12px; font-size: 12px;
@ -2345,6 +2351,56 @@
margin-right: 4px; margin-right: 4px;
} }
/* Status effect overlay styles */
.sprite-container {
position: relative;
display: inline-block;
}
.status-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.status-overlay img {
position: absolute;
width: 24px;
height: 24px;
object-fit: contain;
}
.status-overlay .status-icon {
top: -4px;
right: -4px;
}
.status-overlay .buff-icon {
top: -4px;
left: -4px;
}
.player-side .sprite-container {
width: 80px;
height: 80px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 8px;
}
.player-side .combatant-icon {
font-size: 48px;
}
.player-side .status-overlay img {
width: 28px;
height: 28px;
}
.monster-entry .status-overlay img {
width: 100px;
height: 100px;
top: 0;
left: 0;
}
</style> </style>
</head> </head>
<body> <body>
@ -2387,8 +2443,11 @@
<div class="combat-arena"> <div class="combat-arena">
<div class="combatant player-side"> <div class="combatant player-side">
<div class="combatant-icon">🏃</div>
<div class="combatant-name">Trail Runner</div>
<div class="sprite-container">
<div class="combatant-icon" id="playerCombatIcon">🏃</div>
<div class="status-overlay" id="playerStatusOverlay"></div>
</div>
<div class="combatant-name" id="playerCombatName">Trail Runner</div>
<div class="stat-bars"> <div class="stat-bars">
<div class="stat-bar-container"> <div class="stat-bar-container">
<div class="stat-bar-label">HP</div> <div class="stat-bar-label">HP</div>
@ -2462,7 +2521,7 @@
<div class="section-title">GPS</div> <div class="section-title">GPS</div>
<button class="action-btn" id="gpsBtn" style="width: 100%;">Show My Location</button> <button class="action-btn" id="gpsBtn" style="width: 100%;">Show My Location</button>
<button class="action-btn secondary" id="rotateMapBtn" style="width: 100%; margin-top: 5px;">Rotate Map: OFF</button> <button class="action-btn secondary" id="rotateMapBtn" style="width: 100%; margin-top: 5px;">Rotate Map: OFF</button>
<button class="action-btn secondary" id="autoCenterBtn" style="width: 100%; margin-top: 5px;">Auto-Center: OFF</button>
<button class="action-btn secondary active" id="autoCenterBtn" style="width: 100%; margin-top: 5px;">Auto-Center: ON</button>
</div> </div>
<div class="section"> <div class="section">
@ -3196,7 +3255,7 @@
let routeHighlight = null; // Polyline showing the full route let routeHighlight = null; // Polyline showing the full route
let rotateMapMode = false; let rotateMapMode = false;
let currentBearing = 0; let currentBearing = 0;
let autoCenterMode = false; // Start with auto-center off (will enable on first GPS fix)
let autoCenterMode = true; // Start with auto-center on by default
// Trail graph for pathfinding // Trail graph for pathfinding
// Use settings value instead of const // Use settings value instead of const
@ -10135,6 +10194,67 @@
updateCombatUI(); updateCombatUI();
} }
// Generate status overlay HTML for a monster based on its buffs/debuffs
function getMonsterStatusOverlayHtml(monster) {
let html = '';
if (!monster.buffs) return html;
// Check for buffs (defense, generic, etc.)
if (monster.buffs.defense && monster.buffs.defense.turnsLeft > 0) {
html += `<img class="buff-icon" src="/mapgameimgs/defense100.png"
onerror="this.src='/mapgameimgs/default_buff100.png'"
alt="Defense Up" title="Defense +${monster.buffs.defense.percent}% (${monster.buffs.defense.turnsLeft} turns)">`;
} else if (monster.buffs.generic && monster.buffs.generic.turnsLeft > 0) {
html += `<img class="buff-icon" src="/mapgameimgs/default_buff100.png"
alt="Buff" title="Buffed (${monster.buffs.generic.turnsLeft} turns)">`;
}
// Check for status effects (poison, etc.) - monsters could have these in future
if (monster.statusEffects) {
monster.statusEffects.forEach(effect => {
const effectType = effect.type || 'status';
html += `<img class="status-icon" src="/mapgameimgs/${effectType}100.png"
onerror="this.src='/mapgameimgs/default_status100.png'"
alt="${effectType}" title="${effectType} (${effect.turnsLeft} turns)">`;
});
}
return html;
}
// Generate status overlay HTML for the player based on buffs/debuffs
function getPlayerStatusOverlayHtml() {
let html = '';
if (!combatState) return html;
// Check for player defense buff
if (combatState.defenseBuffTurns > 0) {
html += `<img class="buff-icon" src="/mapgameimgs/defense100.png"
onerror="this.src='/mapgameimgs/default_buff100.png'"
alt="Defense Up" title="Defense Up (${combatState.defenseBuffTurns} turns)">`;
}
// Check for status effects (poison, etc.)
if (combatState.playerStatusEffects && combatState.playerStatusEffects.length > 0) {
combatState.playerStatusEffects.forEach(effect => {
const effectType = effect.type || 'status';
html += `<img class="status-icon" src="/mapgameimgs/${effectType}100.png"
onerror="this.src='/mapgameimgs/default_status100.png'"
alt="${effectType}" title="${effectType} (${effect.turnsLeft} turns)">`;
});
}
return html;
}
// Update the player's status overlay in combat UI
function updatePlayerStatusOverlay() {
const overlay = document.getElementById('playerStatusOverlay');
if (overlay) {
overlay.innerHTML = getPlayerStatusOverlayHtml();
}
}
// Render the monster list in combat UI // Render the monster list in combat UI
function renderMonsterList() { function renderMonsterList() {
const container = document.getElementById('monsterList'); const container = document.getElementById('monsterList');
@ -10154,11 +10274,17 @@
const hpPct = Math.max(0, (monster.hp / monster.maxHp) * 100); const hpPct = Math.max(0, (monster.hp / monster.maxHp) * 100);
// Generate status overlay HTML for monster
const monsterOverlayHtml = getMonsterStatusOverlayHtml(monster);
entry.innerHTML = ` entry.innerHTML = `
<div class="monster-entry-header"> <div class="monster-entry-header">
${index === combatState.selectedTargetIndex ? '<span class="target-arrow"></span>' : ''} ${index === combatState.selectedTargetIndex ? '<span class="target-arrow"></span>' : ''}
<div class="sprite-container">
<img class="monster-entry-icon" src="/mapgameimgs/${monster.type}100.png" <img class="monster-entry-icon" src="/mapgameimgs/${monster.type}100.png"
onerror="this.src='/mapgameimgs/default100.png'" alt="${monster.data.name}"> onerror="this.src='/mapgameimgs/default100.png'" alt="${monster.data.name}">
<div class="status-overlay">${monsterOverlayHtml}</div>
</div>
<span class="monster-entry-name">${monster.data.name} Lv.${monster.level}</span> <span class="monster-entry-name">${monster.data.name} Lv.${monster.level}</span>
</div> </div>
<div class="monster-entry-hp"> <div class="monster-entry-hp">
@ -10212,9 +10338,12 @@
document.getElementById('playerMpText').textContent = document.getElementById('playerMpText').textContent =
`${Math.max(0, combatState.player.mp)}/${combatState.player.maxMp}`; `${Math.max(0, combatState.player.mp)}/${combatState.player.maxMp}`;
// Re-render monster list to update HP bars
// Re-render monster list to update HP bars and status overlays
renderMonsterList(); renderMonsterList();
// Update player status overlays
updatePlayerStatusOverlay();
// Update skill button states // Update skill button states
document.querySelectorAll('.skill-btn').forEach(btn => { document.querySelectorAll('.skill-btn').forEach(btn => {
const skillId = btn.dataset.skillId; const skillId = btn.dataset.skillId;
@ -10373,8 +10502,15 @@
const hitCount = skill.hitCount || skill.hits || 1; const hitCount = skill.hitCount || skill.hits || 1;
let totalDamage = 0; let totalDamage = 0;
// Calculate effective defense (with buff if active)
let effectiveMonsterDef = target.def;
if (target.buffs && target.buffs.defense && target.buffs.defense.turnsLeft > 0) {
const buffPercent = target.buffs.defense.percent || 50;
effectiveMonsterDef = Math.floor(target.def * (1 + buffPercent / 100));
}
for (let hit = 0; hit < hitCount; hit++) { for (let hit = 0; hit < hitCount; hit++) {
const damage = Math.max(1, rawDamage - target.def);
const damage = Math.max(1, rawDamage - effectiveMonsterDef);
totalDamage += damage; totalDamage += damage;
target.hp -= damage; target.hp -= damage;
} }
@ -10486,6 +10622,16 @@
combatState.currentMonsterTurn = monsterIndex; combatState.currentMonsterTurn = monsterIndex;
updateCombatUI(); updateCombatUI();
// Decrement monster buff durations at start of its turn
if (monster.buffs) {
if (monster.buffs.defense && monster.buffs.defense.turnsLeft > 0) {
monster.buffs.defense.turnsLeft--;
if (monster.buffs.defense.turnsLeft <= 0) {
addCombatLog(`${monster.data.name}'s defense buff wore off.`, 'info');
}
}
}
// Select a skill using weighted random (or basic attack if none) // Select a skill using weighted random (or basic attack if none)
const selectedSkill = selectMonsterSkill(monster.type, monster.level); const selectedSkill = selectMonsterSkill(monster.type, monster.level);
@ -10512,7 +10658,28 @@
} }
// Handle different skill types // Handle different skill types
if (selectedSkill.type === 'status') {
if (selectedSkill.type === 'buff') {
// Buff skill (like defend) - monster buffs itself
if (!monster.buffs) monster.buffs = {};
if (selectedSkill.statusEffect && selectedSkill.statusEffect.type === 'defense_up') {
const duration = selectedSkill.statusEffect.duration || 2;
const percent = selectedSkill.statusEffect.percent || 50;
monster.buffs.defense = { turnsLeft: duration, percent: percent };
addCombatLog(`🛡️ ${monster.data.name} uses ${selectedSkill.name}! Defense increased by ${percent}%!`, 'buff');
} else {
// Generic buff
monster.buffs.generic = { turnsLeft: 2 };
addCombatLog(`✨ ${monster.data.name} uses ${selectedSkill.name}!`, 'buff');
}
} else if (selectedSkill.type === 'heal') {
// Heal skill - monster heals itself
const healAmount = Math.floor(selectedSkill.basePower || 50);
const oldHp = monster.hp;
monster.hp = Math.min(monster.maxHp, monster.hp + healAmount);
const actualHeal = monster.hp - oldHp;
addCombatLog(`💚 ${monster.data.name} uses ${selectedSkill.name}! Restored ${actualHeal} HP!`, 'heal');
} else if (selectedSkill.type === 'status') {
// Status effect skill (like poison) // Status effect skill (like poison)
const baseDamage = selectedSkill.basePower || 20; const baseDamage = selectedSkill.basePower || 20;
const damage = Math.max(1, Math.floor(monster.atk * (baseDamage / 100)) - effectiveDef); const damage = Math.max(1, Math.floor(monster.atk * (baseDamage / 100)) - effectiveDef);
@ -10541,13 +10708,19 @@
const hitCount = selectedSkill.hitCount || 1; const hitCount = selectedSkill.hitCount || 1;
let totalDamage = 0; let totalDamage = 0;
// Apply monster's defense buff reduction to player's damage? No - this is monster attacking player
// But we should factor in monster's attack buff if it has one
for (let hit = 0; hit < hitCount; hit++) { for (let hit = 0; hit < hitCount; hit++) {
const damage = Math.max(1, rawDamage - effectiveDef); const damage = Math.max(1, rawDamage - effectiveDef);
totalDamage += damage; totalDamage += damage;
combatState.player.hp -= damage; combatState.player.hp -= damage;
} }
if (selectedSkill.id === 'basic_attack' || selectedSkill.name === 'Attack') {
// Only show generic "attacks" if it's basic_attack with no custom name
const isGenericAttack = (selectedSkill.id === 'basic_attack' && selectedSkill.name === 'Attack');
if (isGenericAttack) {
addCombatLog(`⚔️ ${monster.data.name} attacks! You take ${totalDamage} damage!`, 'damage'); addCombatLog(`⚔️ ${monster.data.name} attacks! You take ${totalDamage} damage!`, 'damage');
} else if (hitCount > 1) { } else if (hitCount > 1) {
addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${hitCount} hits for ${totalDamage} total damage!`, 'damage'); addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${hitCount} hits for ${totalDamage} total damage!`, 'damage');

BIN
mapgameimgs/default_buff100.png

After

Width: 102  |  Height: 100  |  Size: 2.3 KiB

BIN
mapgameimgs/default_status100.png

After

Width: 102  |  Height: 100  |  Size: 2.4 KiB

BIN
mapgameimgs/defense100.png

After

Width: 102  |  Height: 100  |  Size: 2.3 KiB

BIN
mapgameimgs/defense_up100.png

After

Width: 102  |  Height: 100  |  Size: 2.3 KiB

BIN
mapgameimgs/moop_fanciest100.png

After

Width: 102  |  Height: 100  |  Size: 15 KiB

BIN
mapgameimgs/moop_fanciest50.png

After

Width: 50  |  Height: 50  |  Size: 5.7 KiB

BIN
mapgameimgs/moop_fancy100.png

After

Width: 102  |  Height: 100  |  Size: 16 KiB

BIN
mapgameimgs/moop_fancy50.png

After

Width: 50  |  Height: 50  |  Size: 6.0 KiB

BIN
mapgameimgs/moop_sub_par100.png

After

Width: 102  |  Height: 100  |  Size: 15 KiB

BIN
mapgameimgs/moop_sub_par50.png

After

Width: 50  |  Height: 50  |  Size: 5.7 KiB

BIN
mapgameimgs/playerattack50.png

After

Width: 50  |  Height: 50  |  Size: 6.7 KiB

BIN
mapgameimgs/poison100.png

After

Width: 102  |  Height: 100  |  Size: 2.4 KiB

44
server.js

@ -938,8 +938,10 @@ app.get('/api/monster-types/:id/skills', (req, res) => {
monsterTypeId: s.monster_type_id, monsterTypeId: s.monster_type_id,
weight: s.weight, weight: s.weight,
minLevel: s.min_level, minLevel: s.min_level,
// Include skill details
name: s.name,
customName: s.custom_name,
// Include skill details - use custom_name if set, otherwise base name
name: s.custom_name || s.name,
baseName: s.name,
description: s.description, description: s.description,
type: s.type, type: s.type,
mpCost: s.mp_cost, mpCost: s.mp_cost,
@ -1049,7 +1051,7 @@ app.get('/api/admin/monster-types', adminOnly, (req, res) => {
}); });
// Create monster type // Create monster type
app.post('/api/admin/monster-types', adminOnly, (req, res) => {
app.post('/api/admin/monster-types', adminOnly, async (req, res) => {
try { try {
const data = req.body; const data = req.body;
// Accept either 'id' or 'key' as the monster identifier // Accept either 'id' or 'key' as the monster identifier
@ -1060,6 +1062,27 @@ app.post('/api/admin/monster-types', adminOnly, (req, res) => {
// Ensure id is set for the database function // Ensure id is set for the database function
data.id = monsterId; data.id = monsterId;
db.createMonsterType(data); db.createMonsterType(data);
// Copy default images for the new monster
const imgDir = path.join(__dirname, 'mapgameimgs');
const sizes = ['50', '100'];
for (const size of sizes) {
const defaultImg = path.join(imgDir, `default${size}.png`);
const newImg = path.join(imgDir, `${monsterId}${size}.png`);
try {
// Only copy if the new image doesn't already exist
await fs.access(newImg);
} catch {
// File doesn't exist, copy the default
try {
await fs.copyFile(defaultImg, newImg);
console.log(`Created ${monsterId}${size}.png from default`);
} catch (copyErr) {
console.warn(`Could not copy default${size}.png:`, copyErr.message);
}
}
}
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Admin create monster type error:', err); console.error('Admin create monster type error:', err);
@ -1079,6 +1102,18 @@ app.put('/api/admin/monster-types/:id', adminOnly, (req, res) => {
} }
}); });
// Toggle monster enabled status
app.patch('/api/admin/monster-types/:id/enabled', adminOnly, (req, res) => {
try {
const { enabled } = req.body;
db.toggleMonsterEnabled(req.params.id, enabled);
res.json({ success: true });
} catch (err) {
console.error('Admin toggle monster error:', err);
res.status(500).json({ error: 'Failed to toggle monster' });
}
});
// Delete monster type // Delete monster type
app.delete('/api/admin/monster-types/:id', adminOnly, (req, res) => { app.delete('/api/admin/monster-types/:id', adminOnly, (req, res) => {
try { try {
@ -1330,7 +1365,8 @@ app.get('/api/admin/monster-skills', adminOnly, (req, res) => {
monster_type_id: s.monster_type_id, monster_type_id: s.monster_type_id,
skill_id: s.skill_id, skill_id: s.skill_id,
weight: s.weight, weight: s.weight,
min_level: s.min_level
min_level: s.min_level,
custom_name: s.custom_name
})); }));
res.json({ monsterSkills: formatted }); res.json({ monsterSkills: formatted });
} catch (err) { } catch (err) {

Loading…
Cancel
Save