Files
echoes-of-the-ash/web-map/editor_enhanced.js

3026 lines
111 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Enhanced Map Editor with Zoom, Pan, Connections, and Live Stats
let currentLocations = [];
let availableNPCs = [];
let availableItems = [];
let availableInteractables = [];
let selectedLocationId = null;
let selectedNPCId = null;
let selectedItemId = null;
let selectedInteractableId = null;
let connections = [];
let liveStats = {players: {}, enemies: {}};
let canvas, ctx;
let currentTab = 'locations';
// View state
let scale = 50;
let minScale = 10;
let maxScale = 200;
let offsetX = 0;
let offsetY = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let clickStartTime = 0;
let clickStartPos = {x: 0, y: 0};
// Live stats refresh
let liveStatsInterval = null;
// Check authentication on load
window.addEventListener('DOMContentLoaded', async () => {
const response = await fetch('/api/check-auth');
const data = await response.json();
if (data.authenticated) {
showEditor();
} else {
document.getElementById('loginContainer').style.display = 'flex';
}
});
// Ensure canvas is properly sized after everything loads
window.addEventListener('load', () => {
if (canvas) {
setTimeout(resizeCanvas, 100);
}
});
// Login form handler
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password})
});
const data = await response.json();
if (data.success) {
showEditor();
} else {
showError(data.message);
}
} catch (error) {
showError('Login failed: ' + error.message);
}
});
function showError(message) {
const errorDiv = document.getElementById('loginError');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
function showSuccess(message) {
const successDiv = document.getElementById('saveSuccess');
successDiv.textContent = message;
successDiv.style.display = 'block';
setTimeout(() => {
successDiv.style.display = 'none';
}, 3000);
}
async function showEditor() {
document.getElementById('loginContainer').style.display = 'none';
document.getElementById('editorContainer').style.display = 'flex';
// Initialize canvas
canvas = document.getElementById('editorCanvas');
ctx = canvas.getContext('2d');
// Load data
await loadLocations();
await loadConnections();
await loadAvailableNPCs();
await loadAvailableInteractablesForLocations(); // Load interactables for location instances
await loadAvailableItemsForLocations(); // Load items for interactable rewards
await loadLiveStats();
// Resize canvas after everything is loaded and rendered
setTimeout(() => {
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}, 100);
// Start live stats refresh (every 5 seconds)
liveStatsInterval = setInterval(loadLiveStats, 5000);
// Draw map
drawMap();
// Canvas event handlers
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('wheel', handleWheel, {passive: false});
canvas.addEventListener('mouseleave', () => { isDragging = false; });
}
function resizeCanvas() {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
drawMap();
}
async function loadLocations() {
try {
const response = await fetch('/api/editor/locations', {
credentials: 'same-origin'
});
const data = await response.json();
currentLocations = data.locations;
renderLocationList();
// Trigger resize after locations are rendered
setTimeout(resizeCanvas, 50);
} catch (error) {
console.error('Failed to load locations:', error);
}
}
async function loadConnections() {
try {
const response = await fetch('/api/editor/connections', {
credentials: 'same-origin'
});
const data = await response.json();
connections = data.connections;
console.log('Loaded connections:', connections.length);
} catch (error) {
console.error('Failed to load connections:', error);
}
}
async function loadAvailableNPCs() {
try {
const response = await fetch('/api/editor/available-npcs', {
credentials: 'same-origin'
});
const data = await response.json();
availableNPCs = data.npcs;
} catch (error) {
console.error('Failed to load NPCs:', error);
}
}
async function loadLiveStats() {
try {
const response = await fetch('/api/editor/live-stats', {
credentials: 'same-origin'
});
if (!response.ok) {
console.error('Live stats request failed:', response.status);
return;
}
const data = await response.json();
liveStats.players = data.players_by_location || {};
liveStats.enemies = data.enemies_by_location || {};
drawMap(); // Redraw to show updated stats
} catch (error) {
console.error('Failed to load live stats:', error);
}
}
function renderLocationList() {
const list = document.getElementById('locationList');
list.innerHTML = '';
currentLocations.forEach(location => {
const item = document.createElement('div');
item.className = 'location-item';
item.dataset.name = location.name;
item.dataset.id = location.id;
if (location.id === selectedLocationId) {
item.classList.add('active');
}
const playerCount = liveStats.players[location.id] || 0;
const enemyCount = liveStats.enemies[location.id] || 0;
item.innerHTML = `
<div class="location-item-name">${location.name}</div>
<div class="location-item-coords">📍 (${location.x}, ${location.y}) | Danger: ${location.danger_level}</div>
${playerCount > 0 || enemyCount > 0 ? `<div class="location-item-stats">👥 ${playerCount} | 👹 ${enemyCount}</div>` : ''}
`;
item.onclick = () => selectLocation(location.id);
list.appendChild(item);
});
}
async function selectLocation(locationId) {
selectedLocationId = locationId;
renderLocationList();
// Load full location details
try {
const response = await fetch(`/api/editor/location/${locationId}`);
const location = await response.json();
populateForm(location);
} catch (error) {
console.error('Failed to load location details:', error);
}
drawMap();
}
function populateForm(location) {
document.getElementById('noSelectionMessage').classList.add('hidden');
document.getElementById('propertiesForm').classList.remove('hidden');
document.getElementById('locationId').value = location.id;
document.getElementById('locationName').value = location.name;
document.getElementById('locationDescription').value = location.description;
document.getElementById('locationX').value = location.x;
document.getElementById('locationY').value = location.y;
document.getElementById('dangerLevel').value = location.danger_level;
document.getElementById('encounterRate').value = location.encounter_rate;
document.getElementById('wanderingChance').value = location.wandering_chance;
document.getElementById('imagePath').value = location.image_path || '';
// Update image preview
updateImagePreview(location.image_path);
// Render spawn list
renderSpawnList(location.spawn_npcs);
// Render interactables list
renderInteractablesList(location.interactables || {});
// Render connections
renderConnectionsList(location.id, location.exits);
}
function updateImagePreview(imagePath) {
const preview = document.getElementById('imagePreview');
if (imagePath) {
preview.innerHTML = `<img src="/${imagePath}" alt="Location image">`;
} else {
preview.innerHTML = '<span>No image</span>';
}
}
function renderSpawnList(spawns) {
const list = document.getElementById('spawnList');
list.innerHTML = '';
spawns.forEach((spawn, index) => {
const item = document.createElement('div');
item.className = 'spawn-item';
item.innerHTML = `
<div class="spawn-item-info">
<div class="spawn-item-name">${spawn.emoji} ${spawn.name}</div>
<div class="spawn-item-weight">Weight: ${spawn.weight}</div>
</div>
<button class="btn btn-remove" onclick="removeSpawn(${index})">Remove</button>
`;
list.appendChild(item);
});
}
function renderConnectionsList(locationId, exits) {
const list = document.getElementById('connectionsList');
if (!list) return;
list.innerHTML = '';
for (const [direction, destId] of Object.entries(exits)) {
const destLocation = currentLocations.find(l => l.id === destId);
if (destLocation) {
const item = document.createElement('div');
item.className = 'connection-item';
item.innerHTML = `
<div class="connection-info">
<div class="connection-direction">${direction}${destLocation.name}</div>
</div>
<button class="btn btn-remove" onclick="deleteConnection('${locationId}', '${destId}')">×</button>
`;
list.appendChild(item);
}
}
}
// ==================== CANVAS DRAWING ====================
function drawMap() {
if (!ctx) return;
// Clear canvas
ctx.fillStyle = '#0f0f1e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const centerX = canvas.width / 2 + offsetX;
const centerY = canvas.height / 2 + offsetY;
// Draw grid
drawGrid(centerX, centerY);
// Draw connections first (under locations)
drawConnections(centerX, centerY);
// Draw locations
drawLocations(centerX, centerY);
// Draw zoom indicator
ctx.fillStyle = '#e0e0e0';
ctx.font = '12px monospace';
ctx.textAlign = 'left';
ctx.fillText(`Zoom: ${Math.round(scale)}px/unit`, 10, 20);
}
function drawGrid(centerX, centerY) {
ctx.strokeStyle = '#1a1a3e';
ctx.lineWidth = 1;
// Vertical lines
for (let x = centerX % scale; x < canvas.width; x += scale) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
// Horizontal lines
for (let y = centerY % scale; y < canvas.height; y += scale) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
// Draw axes
ctx.strokeStyle = '#3a3a6a';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(centerX, 0);
ctx.lineTo(centerX, canvas.height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, centerY);
ctx.lineTo(canvas.width, centerY);
ctx.stroke();
}
function drawConnections(centerX, centerY) {
console.log('Drawing connections, total:', connections.length);
connections.forEach(conn => {
const fromLoc = currentLocations.find(l => l.id === conn.from);
const toLoc = currentLocations.find(l => l.id === conn.to);
if (fromLoc && toLoc) {
const x1 = centerX + fromLoc.x * scale;
const y1 = centerY - fromLoc.y * scale;
const x2 = centerX + toLoc.x * scale;
const y2 = centerY - toLoc.y * scale;
// Draw arrow
ctx.strokeStyle = '#00bcd4'; // Cyan color to avoid confusion with axes
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
// Draw arrowhead
const angle = Math.atan2(y2 - y1, x2 - x1);
const headLength = 10;
ctx.strokeStyle = '#00bcd4'; // Match arrow color
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(
x2 - headLength * Math.cos(angle - Math.PI / 6),
y2 - headLength * Math.sin(angle - Math.PI / 6)
);
ctx.moveTo(x2, y2);
ctx.lineTo(
x2 - headLength * Math.cos(angle + Math.PI / 6),
y2 - headLength * Math.sin(angle + Math.PI / 6)
);
ctx.stroke();
// Calculate positions for labels
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
// Position "from" direction closer to origin (25% along the line)
const fromLabelX = x1 + (x2 - x1) * 0.25;
const fromLabelY = y1 + (y2 - y1) * 0.25;
// Position cost in the middle
const costX = midX;
const costY = midY;
// Position "to" direction closer to destination (75% along the line)
const toLabelX = x1 + (x2 - x1) * 0.75;
const toLabelY = y1 + (y2 - y1) * 0.75;
// Calculate perpendicular offset for text positioning
const perpAngle = angle + Math.PI / 2;
const textOffset = 8;
ctx.fillStyle = '#8a8aaa';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
// Check if there's a reverse connection
const reverseConn = connections.find(c => c.from === conn.to && c.to === conn.from);
if (reverseConn) {
// If bidirectional, offset labels to avoid overlap
// "from" direction on one side
ctx.fillText(
conn.direction,
fromLabelX + Math.cos(perpAngle) * textOffset,
fromLabelY + Math.sin(perpAngle) * textOffset
);
// "to" direction (reverse) on the other side
ctx.fillText(
reverseConn.direction,
toLabelX - Math.cos(perpAngle) * textOffset,
toLabelY - Math.sin(perpAngle) * textOffset
);
} else {
// Single direction - center the label
ctx.fillText(conn.direction, fromLabelX, fromLabelY - 5);
}
// Draw distance/cost in the middle
const distance = Math.sqrt(Math.pow(toLoc.x - fromLoc.x, 2) + Math.pow(toLoc.y - fromLoc.y, 2));
const cost = conn.stamina_cost || Math.ceil(distance * 2); // Use actual cost if available
ctx.fillStyle = '#ffb74d';
ctx.fillText(`${cost}`, costX, costY + 3);
}
});
}
function drawLocations(centerX, centerY) {
const dangerColors = ['#4caf50', '#8bc34a', '#ffa726', '#ff5722', '#d32f2f'];
currentLocations.forEach(location => {
const x = centerX + location.x * scale;
const y = centerY - location.y * scale;
// Draw location circle
const color = dangerColors[location.danger_level] || '#9e9e9e';
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, 15, 0, Math.PI * 2);
ctx.fill();
// Highlight selected
if (location.id === selectedLocationId) {
ctx.strokeStyle = '#ffa726';
ctx.lineWidth = 3;
ctx.stroke();
}
// Draw label
ctx.fillStyle = '#e0e0e0';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(location.name, x, y + 30);
// Draw live stats badges
const playerCount = liveStats.players[location.id] || 0;
const enemyCount = liveStats.enemies[location.id] || 0;
let badgeY = y - 20;
if (playerCount > 0) {
drawBadge(x - 10, badgeY, `👥${playerCount}`, '#2196f3');
badgeY -= 15;
}
if (enemyCount > 0) {
drawBadge(x + 10, badgeY, `👹${enemyCount}`, '#f44336');
}
});
}
function drawBadge(x, y, text, color) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x, y);
ctx.textBaseline = 'alphabetic';
}
// ==================== MOUSE INTERACTION ====================
function handleMouseDown(e) {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
clickStartTime = Date.now();
clickStartPos = {x: mouseX, y: mouseY};
dragStartX = mouseX - offsetX;
dragStartY = mouseY - offsetY;
}
function handleMouseMove(e) {
if (e.buttons === 1) { // Left mouse button
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Check if moved enough to be a drag
const dx = mouseX - clickStartPos.x;
const dy = mouseY - clickStartPos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 5) {
isDragging = true;
canvas.style.cursor = 'grabbing';
offsetX = mouseX - dragStartX;
offsetY = mouseY - dragStartY;
drawMap();
}
}
}
function handleMouseUp(e) {
const clickDuration = Date.now() - clickStartTime;
if (!isDragging && clickDuration < 300) {
// This was a click, not a drag
handleCanvasClick(e);
}
isDragging = false;
canvas.style.cursor = 'crosshair';
}
function handleWheel(e) {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get world position before zoom
const centerX = canvas.width / 2 + offsetX;
const centerY = canvas.height / 2 + offsetY;
const worldX = (mouseX - centerX) / scale;
const worldY = -(mouseY - centerY) / scale;
// Zoom
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
const newScale = Math.max(minScale, Math.min(maxScale, scale * zoomFactor));
if (newScale !== scale) {
// Adjust offset to keep mouse position stable
const newCenterX = canvas.width / 2 + offsetX;
const newCenterY = canvas.height / 2 + offsetY;
const newScreenX = newCenterX + worldX * newScale;
const newScreenY = newCenterY - worldY * newScale;
offsetX += mouseX - newScreenX;
offsetY += mouseY - newScreenY;
scale = newScale;
drawMap();
}
}
function handleCanvasClick(e) {
const rect = canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
const centerX = canvas.width / 2 + offsetX;
const centerY = canvas.height / 2 + offsetY;
// Check if clicked on existing location
for (const location of currentLocations) {
const screenX = centerX + location.x * scale;
const screenY = centerY - location.y * scale;
const distance = Math.sqrt(Math.pow(clickX - screenX, 2) + Math.pow(clickY - screenY, 2));
if (distance < 15) {
selectLocation(location.id);
return;
}
}
// Clicked on empty space - create new location
const worldX = ((clickX - centerX) / scale).toFixed(1);
const worldY = (-(clickY - centerY) / scale).toFixed(1);
if (confirm(`Create new location at (${worldX}, ${worldY})?`)) {
createLocationAt(parseFloat(worldX), parseFloat(worldY));
}
}
// ==================== ZOOM CONTROLS ====================
function zoomIn() {
scale = Math.min(maxScale, scale * 1.2);
drawMap();
}
function zoomOut() {
scale = Math.max(minScale, scale / 1.2);
drawMap();
}
function resetView() {
scale = 50;
offsetX = 0;
offsetY = 0;
drawMap();
}
// ==================== LOCATION MANAGEMENT ====================
function createLocationAt(x, y) {
const newId = 'location_' + Date.now();
const newLocation = {
id: newId,
name: 'New Location',
description: 'Enter description...',
image_path: '',
x: x,
y: y,
danger_level: 0,
encounter_rate: 0.0,
wandering_chance: 0.0,
spawn_npcs: []
};
currentLocations.push(newLocation);
selectedLocationId = newId;
renderLocationList();
populateForm(newLocation);
drawMap();
}
function createNewLocation() {
createLocationAt(0, 0);
}
async function saveLocation() {
const locationData = {
id: document.getElementById('locationId').value,
name: document.getElementById('locationName').value,
description: document.getElementById('locationDescription').value,
x: parseFloat(document.getElementById('locationX').value),
y: parseFloat(document.getElementById('locationY').value),
danger_level: parseInt(document.getElementById('dangerLevel').value),
encounter_rate: parseFloat(document.getElementById('encounterRate').value),
wandering_chance: parseFloat(document.getElementById('wanderingChance').value),
image_path: document.getElementById('imagePath').value,
spawn_npcs: getCurrentSpawns(),
interactables: getInteractableInstances()
};
try {
const response = await fetch('/api/editor/location', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(locationData)
});
const data = await response.json();
if (data.success) {
showSuccess('Location saved successfully!');
await loadLocations();
await loadConnections();
drawMap();
} else {
alert('Failed to save: ' + data.error);
}
} catch (error) {
alert('Failed to save location: ' + error.message);
}
}
async function deleteCurrentLocation() {
const locationId = document.getElementById('locationId').value;
if (!confirm(`Delete location "${locationId}"? This will also remove all connections to/from this location.`)) {
return;
}
try {
const response = await fetch(`/api/editor/location/${locationId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showSuccess('Location deleted successfully!');
selectedLocationId = null;
document.getElementById('noSelectionMessage').classList.remove('hidden');
document.getElementById('propertiesForm').classList.add('hidden');
await loadLocations();
await loadConnections();
drawMap();
} else {
alert('Failed to delete: ' + data.error);
}
} catch (error) {
alert('Failed to delete location: ' + error.message);
}
}
// Wrapper functions for header buttons
function saveCurrentLocation() {
if (!selectedLocationId) {
alert('Please select a location first');
return;
}
saveLocation();
}
// ==================== CONNECTION MANAGEMENT ====================
function showAddConnectionModal() {
const modal = document.getElementById('addConnectionModal');
const searchInput = document.getElementById('connectionSearch');
// Clear search
searchInput.value = '';
// Populate the list
filterConnectionList();
modal.style.display = 'flex';
searchInput.focus();
}
function filterConnectionList() {
const list = document.getElementById('connectionTargetList');
const searchTerm = document.getElementById('connectionSearch').value.toLowerCase();
const currentId = document.getElementById('locationId').value;
list.innerHTML = '';
const filteredLocations = currentLocations.filter(location => {
if (location.id === currentId) return false;
if (!searchTerm) return true;
return location.name.toLowerCase().includes(searchTerm) ||
location.id.toLowerCase().includes(searchTerm);
});
if (filteredLocations.length === 0) {
list.innerHTML = '<div style="padding: 20px; text-align: center; opacity: 0.5;">No locations found</div>';
return;
}
filteredLocations.forEach(location => {
const item = document.createElement('div');
item.className = 'connection-target-item';
item.innerHTML = `
<div><strong>${location.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7;">
📍 (${location.x}, ${location.y}) | Danger: ${location.danger_level}
</div>
`;
item.onclick = () => promptAddConnection(currentId, location.id);
list.appendChild(item);
});
}
function closeAddConnectionModal() {
document.getElementById('addConnectionModal').style.display = 'none';
}
function filterLocationList() {
const searchTerm = document.getElementById('locationSearchInput').value.toLowerCase();
const locationList = document.getElementById('locationList');
const items = locationList.getElementsByClassName('location-item');
Array.from(items).forEach(item => {
const name = item.dataset.name ? item.dataset.name.toLowerCase() : '';
const id = item.dataset.id ? item.dataset.id.toLowerCase() : '';
if (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
}
function filterNPCList() {
const searchTerm = document.getElementById('npcSearch').value.toLowerCase();
const list = document.getElementById('npcSelectList');
const items = list.getElementsByClassName('npc-item');
Array.from(items).forEach(item => {
const name = item.dataset.name ? item.dataset.name.toLowerCase() : '';
const id = item.dataset.id ? item.dataset.id.toLowerCase() : '';
if (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
}
async function promptAddConnection(fromId, toId) {
const direction = prompt('Enter direction (north, south, east, west, northeast, etc.):');
if (!direction) return;
try {
const response = await fetch('/api/editor/connection', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({from: fromId, to: toId, direction: direction.toLowerCase()})
});
const data = await response.json();
if (data.success) {
showSuccess('Connection added!');
await loadConnections();
await selectLocation(fromId); // Refresh form
drawMap();
closeAddConnectionModal();
} else {
alert('Failed to add connection: ' + data.error);
}
} catch (error) {
alert('Failed to add connection: ' + error.message);
}
}
async function deleteConnection(fromId, toId) {
if (!confirm('Delete this connection?')) return;
try {
const response = await fetch('/api/editor/connection', {
method: 'DELETE',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({from: fromId, to: toId})
});
const data = await response.json();
if (data.success) {
showSuccess('Connection deleted!');
await loadConnections();
await selectLocation(fromId); // Refresh form
drawMap();
} else {
alert('Failed to delete connection: ' + data.error);
}
} catch (error) {
alert('Failed to delete connection: ' + error.message);
}
}
// ==================== SPAWN MANAGEMENT ====================
function getCurrentSpawns() {
const spawns = [];
const spawnItems = document.querySelectorAll('.spawn-item');
spawnItems.forEach(item => {
const nameDiv = item.querySelector('.spawn-item-name');
const weightDiv = item.querySelector('.spawn-item-weight');
if (nameDiv && weightDiv) {
const text = nameDiv.textContent;
const npcName = text.substring(text.indexOf(' ') + 1).trim();
const weight = parseInt(weightDiv.textContent.replace('Weight: ', ''));
const npc = availableNPCs.find(n => n.name === npcName);
if (npc) {
spawns.push({npc_id: npc.id, weight: weight});
}
}
});
return spawns;
}
function showAddSpawnModal() {
const modal = document.getElementById('addSpawnModal');
const list = document.getElementById('npcSelectList');
list.innerHTML = '';
// Clear search input
document.getElementById('npcSearch').value = '';
availableNPCs.forEach(npc => {
const item = document.createElement('div');
item.className = 'npc-item npc-select-item';
item.dataset.name = npc.name;
item.dataset.id = npc.id;
item.innerHTML = `
<div><strong>${npc.emoji} ${npc.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7;">
HP: ${npc.hp_range[0]}-${npc.hp_range[1]} |
DMG: ${npc.damage_range[0]}-${npc.damage_range[1]} |
XP: ${npc.xp_reward}
</div>
`;
item.onclick = () => addSpawn(npc);
list.appendChild(item);
});
modal.style.display = 'flex';
// Focus the search input
setTimeout(() => document.getElementById('npcSearch').focus(), 100);
}
function closeAddSpawnModal() {
document.getElementById('addSpawnModal').style.display = 'none';
}
function addSpawn(npc) {
const weight = prompt(`Enter spawn weight for ${npc.name}:`, '50');
if (weight && !isNaN(weight)) {
const list = document.getElementById('spawnList');
const item = document.createElement('div');
item.className = 'spawn-item';
item.innerHTML = `
<div class="spawn-item-info">
<div class="spawn-item-name">${npc.emoji} ${npc.name}</div>
<div class="spawn-item-weight">Weight: ${weight}</div>
</div>
<button class="btn btn-remove" onclick="this.parentElement.remove()">Remove</button>
`;
list.appendChild(item);
}
closeAddSpawnModal();
}
function removeSpawn(index) {
const spawnItems = document.querySelectorAll('.spawn-item');
if (spawnItems[index]) {
spawnItems[index].remove();
}
}
// ==================== INTERACTABLE INSTANCES ====================
let currentEditingInteractableInstanceId = null;
async function showAddInteractableModal() {
const modal = document.getElementById('addInteractableModal');
const list = document.getElementById('interactableSelectList');
list.innerHTML = '<div style="padding: 20px; text-align: center; opacity: 0.5;">Loading interactables...</div>';
modal.style.display = 'flex';
// Clear search input
document.getElementById('interactableSearch').value = '';
console.log('Current availableInteractables:', availableInteractables);
// Ensure interactables are loaded
if (!availableInteractables || availableInteractables.length === 0) {
console.log('Loading interactables...');
await loadAvailableInteractablesForLocations();
}
// Clear the loading message
list.innerHTML = '';
console.log('After loading, availableInteractables:', availableInteractables);
// Check again after loading
if (!availableInteractables || availableInteractables.length === 0) {
console.log('No interactables found after loading');
list.innerHTML = '<div style="padding: 20px; text-align: center; opacity: 0.5;">No interactables available. Create one in the Interactables tab first.</div>';
return;
}
console.log(`Rendering ${availableInteractables.length} interactables`);
availableInteractables.forEach(interactable => {
const item = document.createElement('div');
item.className = 'npc-item npc-select-item';
item.dataset.name = interactable.name;
item.dataset.id = interactable.id;
const actionCount = Object.keys(interactable.actions || {}).length;
item.innerHTML = `
<div><strong>${interactable.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7;">
${interactable.description}<br>
${actionCount} action(s)
</div>
`;
item.onclick = () => addInteractableToLocation(interactable);
list.appendChild(item);
});
// Focus the search input
setTimeout(() => document.getElementById('interactableSearch').focus(), 100);
}
function closeAddInteractableModal() {
document.getElementById('addInteractableModal').style.display = 'none';
}
function filterInteractableSelectList() {
const search = document.getElementById('interactableSearch').value.toLowerCase();
const items = document.querySelectorAll('#interactableSelectList .npc-select-item');
items.forEach(item => {
const name = item.dataset.name.toLowerCase();
if (name.includes(search)) {
item.style.display = 'flex';
} else {
item.style.display = 'none';
}
});
}
function addInteractableToLocation(interactable) {
// Generate a unique instance ID
const instanceId = `${interactable.id}_${Date.now()}`;
// Create instance with default outcomes
const instance = {
template_id: interactable.id,
outcomes: {}
};
// Initialize default outcomes for each action
// Actions is an object, not an array
for (const [actionId, action] of Object.entries(interactable.actions || {})) {
instance.outcomes[actionId] = {
success_rate: 0.5,
stamina_cost: action.stamina_cost || 0,
text: {
success: `You successfully ${action.label.toLowerCase()}.`,
failure: `You failed to ${action.label.toLowerCase()}.`
},
rewards: {
items: [],
damage: 0
}
};
}
// Add to UI list
const list = document.getElementById('interactablesList');
const item = document.createElement('div');
item.className = 'spawn-item';
item.dataset.instanceId = instanceId;
const actionCount = Object.keys(interactable.actions || {}).length;
item.innerHTML = `
<div class="spawn-item-info">
<div class="spawn-item-name">${interactable.name}</div>
<div class="spawn-item-weight">${actionCount} action(s)</div>
</div>
<div style="display: flex; gap: 5px;">
<button class="btn btn-primary" onclick="editInteractableInstance('${instanceId}', '${interactable.id}')">Edit</button>
<button class="btn btn-remove" onclick="removeInteractableInstance('${instanceId}')">Remove</button>
</div>
`;
// Store instance data
item.dataset.instanceData = JSON.stringify(instance);
list.appendChild(item);
closeAddInteractableModal();
}
function removeInteractableInstance(instanceId) {
const items = document.querySelectorAll('.spawn-item');
items.forEach(item => {
if (item.dataset.instanceId === instanceId) {
item.remove();
}
});
}
function editInteractableInstance(instanceId, templateId) {
currentEditingInteractableInstanceId = instanceId;
// Find the instance element
const items = document.querySelectorAll('.spawn-item');
let instanceElement = null;
items.forEach(item => {
if (item.dataset.instanceId === instanceId) {
instanceElement = item;
}
});
if (!instanceElement) return;
const instanceData = JSON.parse(instanceElement.dataset.instanceData);
const template = availableInteractables.find(i => i.id === templateId);
if (!template) {
alert('Template not found!');
return;
}
// Build the editor UI
const editor = document.getElementById('interactableInstanceEditor');
editor.innerHTML = `
<div style="background: rgba(255,255,255,0.05); padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<h4 style="margin: 0 0 5px 0;">${template.name}</h4>
<p style="margin: 0; opacity: 0.7; font-size: 0.9em;">${template.description}</p>
</div>
<div style="display: flex; flex-direction: column; gap: 20px;">
${Object.entries(template.actions || {}).map(([actionId, action]) => {
const outcome = instanceData.outcomes[actionId] || {};
return `
<div style="background: rgba(255,255,255,0.03); padding: 15px; border-radius: 8px;">
<h4 style="margin: 0 0 15px 0;">Action: ${action.label}</h4>
<div class="property-group">
<label>Success Rate (0.0-1.0)</label>
<input type="number" id="success_rate_${actionId}"
min="0" max="1" step="0.1"
value="${outcome.success_rate || 0.5}">
</div>
<div class="property-group">
<label>Stamina Cost</label>
<input type="number" id="stamina_cost_${actionId}"
min="0" step="1"
value="${outcome.stamina_cost || action.stamina_cost || 0}">
</div>
<div class="property-group">
<label>Success Text</label>
<textarea id="success_text_${actionId}" rows="2">${outcome.text?.success || ''}</textarea>
</div>
<div class="property-group">
<label>Failure Text</label>
<textarea id="failure_text_${actionId}" rows="2">${outcome.text?.failure || ''}</textarea>
</div>
<div class="property-group">
<label>Critical Success Text</label>
<textarea id="crit_success_text_${actionId}" rows="2">${outcome.text?.crit_success || ''}</textarea>
</div>
<div class="property-group">
<label>Critical Failure Text</label>
<textarea id="crit_failure_text_${actionId}" rows="2">${outcome.text?.crit_failure || ''}</textarea>
</div>
<div class="property-group">
<label>Critical Success Chance (0.0-1.0)</label>
<input type="number" id="crit_success_chance_${actionId}"
min="0" max="1" step="0.05"
value="${outcome.crit_success_chance || 0.1}">
</div>
<div class="property-group">
<label>Critical Failure Chance (0.0-1.0)</label>
<input type="number" id="crit_failure_chance_${actionId}"
min="0" max="1" step="0.05"
value="${outcome.crit_failure_chance || 0.1}">
</div>
<div class="property-group">
<label>Damage on Failure</label>
<input type="number" id="damage_${actionId}"
min="0" step="1"
value="${outcome.rewards?.damage || 0}">
</div>
<div class="property-group">
<label>Damage on Critical Failure</label>
<input type="number" id="crit_damage_${actionId}"
min="0" step="1"
value="${outcome.rewards?.crit_damage || 0}">
</div>
<div class="property-group" style="grid-column: 1 / -1;">
<label>Item Rewards</label>
<div style="display: flex; gap: 10px; margin-bottom: 5px; font-size: 0.9em; opacity: 0.7; padding: 0 5px;">
<div style="flex: 2;">Item (type to search)</div>
<div style="flex: 1;">Quantity</div>
<div style="flex: 1;">Chance (0-1)</div>
<div style="width: 40px;"></div>
</div>
<div id="item_rewards_${actionId}" style="margin-bottom: 10px;">
${(outcome.rewards?.items || []).map((reward, idx) => {
const selectedItem = availableItems.find(i => i.id === reward.item_id);
const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${selectedItem.name}` : '';
return `
<div class="reward-item" style="display: flex; gap: 10px; margin-bottom: 5px; align-items: center; position: relative;">
<div style="flex: 2; position: relative;">
<input type="text" class="reward-item-search"
value="${displayValue}"
placeholder="Type to search items..."
oninput="filterItemDropdown(this)"
onfocus="showItemDropdown(this)"
style="width: 100%; padding: 8px; background: #1a1a3e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<input type="hidden" class="reward-item-id" value="${reward.item_id || ''}">
<div class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
<input type="number" placeholder="1" value="${reward.quantity || 1}"
min="1" style="flex: 1;" class="reward-quantity">
<input type="number" placeholder="1.0" value="${reward.chance || 1}"
min="0" max="1" step="0.1" style="flex: 1;" class="reward-chance">
<button class="btn btn-remove" onclick="this.parentElement.remove()">×</button>
</div>
`;
}).join('')}
</div>
<button class="btn btn-add" onclick="addRewardRow('${actionId}')">+ Add Item Reward</button>
</div>
<div class="property-group" style="grid-column: 1 / -1;">
<label>Critical Success Item Rewards</label>
<div style="display: flex; gap: 10px; margin-bottom: 5px; font-size: 0.9em; opacity: 0.7; padding: 0 5px;">
<div style="flex: 2;">Item (type to search)</div>
<div style="flex: 1;">Quantity</div>
<div style="flex: 1;">Chance (0-1)</div>
<div style="width: 40px;"></div>
</div>
<div id="crit_item_rewards_${actionId}" style="margin-bottom: 10px;">
${(outcome.rewards?.crit_items || []).map((reward, idx) => {
const selectedItem = availableItems.find(i => i.id === reward.item_id);
const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${selectedItem.name}` : '';
return `
<div class="reward-item" style="display: flex; gap: 10px; margin-bottom: 5px; align-items: center; position: relative;">
<div style="flex: 2; position: relative;">
<input type="text" class="reward-item-search"
value="${displayValue}"
placeholder="Type to search items..."
oninput="filterItemDropdown(this)"
onfocus="showItemDropdown(this)"
style="width: 100%; padding: 8px; background: #1a1a3e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<input type="hidden" class="reward-item-id" value="${reward.item_id || ''}">
<div class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
<input type="number" placeholder="1" value="${reward.quantity || 1}"
min="1" style="flex: 1;" class="reward-quantity">
<input type="number" placeholder="1.0" value="${reward.chance || 1}"
min="0" max="1" step="0.1" style="flex: 1;" class="reward-chance">
<button class="btn btn-remove" onclick="this.parentElement.remove()">×</button>
</div>
`;
}).join('')}
</div>
<button class="btn btn-add" onclick="addCritRewardRow('${actionId}')">+ Add Critical Success Reward</button>
</div>
</div>
`;
}).join('')}
</div>
`;
document.getElementById('editInteractableInstanceModal').style.display = 'flex';
}
function closeEditInteractableInstanceModal() {
document.getElementById('editInteractableInstanceModal').style.display = 'none';
currentEditingInteractableInstanceId = null;
}
function addRewardRow(actionId) {
const container = document.getElementById(`item_rewards_${actionId}`);
const div = document.createElement('div');
div.className = 'reward-item';
div.style.display = 'flex';
div.style.gap = '10px';
div.style.marginBottom = '5px';
div.style.alignItems = 'center';
div.style.position = 'relative';
div.innerHTML = `
<div style="flex: 2; position: relative;">
<input type="text" class="reward-item-search"
placeholder="Type to search items..."
oninput="filterItemDropdown(this)"
onfocus="showItemDropdown(this)"
style="width: 100%; padding: 8px; background: #1a1a3e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<input type="hidden" class="reward-item-id" value="">
<div class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
<input type="number" placeholder="1" value="1" min="1" style="flex: 1;" class="reward-quantity">
<input type="number" placeholder="1.0" value="1" min="0" max="1" step="0.1" style="flex: 1;" class="reward-chance">
<button class="btn btn-remove" onclick="this.parentElement.remove()">×</button>
`;
container.appendChild(div);
}
function addCritRewardRow(actionId) {
const container = document.getElementById(`crit_item_rewards_${actionId}`);
const div = document.createElement('div');
div.className = 'reward-item';
div.style.display = 'flex';
div.style.gap = '10px';
div.style.marginBottom = '5px';
div.style.alignItems = 'center';
div.style.position = 'relative';
div.innerHTML = `
<div style="flex: 2; position: relative;">
<input type="text" class="reward-item-search"
placeholder="Type to search items..."
oninput="filterItemDropdown(this)"
onfocus="showItemDropdown(this)"
style="width: 100%; padding: 8px; background: #1a1a3e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<input type="hidden" class="reward-item-id" value="">
<div class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
<input type="number" placeholder="1" value="1" min="1" style="flex: 1;" class="reward-quantity">
<input type="number" placeholder="1.0" value="1" min="0" max="1" step="0.1" style="flex: 1;" class="reward-chance">
<button class="btn btn-remove" onclick="this.parentElement.remove()">×</button>
`;
container.appendChild(div);
}
// Autocomplete functions for item selection
function showItemDropdown(input) {
filterItemDropdown(input);
}
function filterItemDropdown(input) {
const searchText = input.value.toLowerCase();
const dropdown = input.parentElement.querySelector('.item-dropdown');
const hiddenInput = input.parentElement.querySelector('.reward-item-id');
if (!dropdown) return;
// Filter items
const filteredItems = availableItems.filter(item => {
const itemText = `${item.emoji || ''} ${item.name || ''} ${item.id}`.toLowerCase();
return itemText.includes(searchText);
});
// Build dropdown HTML
dropdown.innerHTML = '';
dropdown.style.display = 'block';
if (filteredItems.length === 0) {
dropdown.innerHTML = '<div style="padding: 8px; opacity: 0.5;">No items found</div>';
return;
}
// Create elements dynamically with proper event listeners
filteredItems.forEach(item => {
const optionDiv = document.createElement('div');
optionDiv.className = 'dropdown-item';
optionDiv.style.padding = '8px';
optionDiv.style.cursor = 'pointer';
optionDiv.style.borderBottom = '1px solid #2a2a4a';
optionDiv.innerHTML = `
${item.emoji || '📦'} ${item.name || item.id}
<span style="opacity: 0.5; font-size: 0.85em; margin-left: 5px;">${item.id}</span>
`;
// Add hover effects
optionDiv.addEventListener('mouseover', function() {
this.style.background = '#2a2a4a';
});
optionDiv.addEventListener('mouseout', function() {
this.style.background = 'transparent';
});
// Add click handler
optionDiv.addEventListener('click', function(e) {
e.stopPropagation();
// Set the visible input to show emoji + name
input.value = `${item.emoji || '📦'} ${item.name || item.id}`;
// Set the hidden input to store the item_id
hiddenInput.value = item.id;
dropdown.style.display = 'none';
});
dropdown.appendChild(optionDiv);
});
}
function selectItem(element) {
const itemId = element.dataset.id;
const itemName = element.dataset.name;
const container = element.closest('.reward-item');
const searchInput = container.querySelector('.reward-item-search');
const hiddenInput = container.querySelector('.reward-item-id');
const dropdown = container.querySelector('.item-dropdown');
searchInput.value = itemName;
hiddenInput.value = itemId;
dropdown.style.display = 'none';
}
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.reward-item')) {
document.querySelectorAll('.item-dropdown').forEach(dropdown => {
dropdown.style.display = 'none';
});
}
});
function saveInteractableInstance() {
if (!currentEditingInteractableInstanceId) return;
// Find the instance element
const items = document.querySelectorAll('.spawn-item');
let instanceElement = null;
items.forEach(item => {
if (item.dataset.instanceId === currentEditingInteractableInstanceId) {
instanceElement = item;
}
});
if (!instanceElement) return;
const instanceData = JSON.parse(instanceElement.dataset.instanceData);
const template = availableInteractables.find(i => i.id === instanceData.template_id);
if (!template) return;
// Collect all outcomes
for (const [actionId, action] of Object.entries(template.actions || {})) {
const successRate = parseFloat(document.getElementById(`success_rate_${actionId}`).value);
const staminaCost = parseInt(document.getElementById(`stamina_cost_${actionId}`).value);
const successText = document.getElementById(`success_text_${actionId}`).value;
const failureText = document.getElementById(`failure_text_${actionId}`).value;
const critSuccessText = document.getElementById(`crit_success_text_${actionId}`).value;
const critFailureText = document.getElementById(`crit_failure_text_${actionId}`).value;
const critSuccessChance = parseFloat(document.getElementById(`crit_success_chance_${actionId}`).value);
const critFailureChance = parseFloat(document.getElementById(`crit_failure_chance_${actionId}`).value);
const damage = parseInt(document.getElementById(`damage_${actionId}`).value);
const critDamage = parseInt(document.getElementById(`crit_damage_${actionId}`).value);
// Collect item rewards
const rewardContainer = document.getElementById(`item_rewards_${actionId}`);
const rewardItems = rewardContainer.querySelectorAll('.reward-item');
const items = [];
rewardItems.forEach(rewardItem => {
const itemId = rewardItem.querySelector('.reward-item-id').value;
const quantity = parseInt(rewardItem.querySelector('.reward-quantity').value);
const chance = parseFloat(rewardItem.querySelector('.reward-chance').value);
if (itemId) {
items.push({
item_id: itemId,
quantity: quantity || 1,
chance: chance || 1
});
}
});
// Collect critical success item rewards
const critRewardContainer = document.getElementById(`crit_item_rewards_${actionId}`);
const critRewardItems = critRewardContainer.querySelectorAll('.reward-item');
const critItems = [];
critRewardItems.forEach(rewardItem => {
const itemId = rewardItem.querySelector('.reward-item-id').value;
const quantity = parseInt(rewardItem.querySelector('.reward-quantity').value);
const chance = parseFloat(rewardItem.querySelector('.reward-chance').value);
if (itemId) {
critItems.push({
item_id: itemId,
quantity: quantity || 1,
chance: chance || 1
});
}
});
instanceData.outcomes[actionId] = {
success_rate: successRate,
stamina_cost: staminaCost,
crit_success_chance: critSuccessChance,
crit_failure_chance: critFailureChance,
text: {
success: successText,
failure: failureText,
crit_success: critSuccessText,
crit_failure: critFailureText
},
rewards: {
items: items,
damage: damage,
crit_items: critItems,
crit_damage: critDamage
}
};
}
// Update the stored data
instanceElement.dataset.instanceData = JSON.stringify(instanceData);
closeEditInteractableInstanceModal();
showSuccess('Interactable instance updated!');
}
function getInteractableInstances() {
const instances = {};
const items = document.querySelectorAll('#interactablesList .spawn-item');
items.forEach(item => {
const instanceId = item.dataset.instanceId;
const instanceData = JSON.parse(item.dataset.instanceData);
instances[instanceId] = instanceData;
});
return instances;
}
function renderInteractablesList(interactables) {
console.log('renderInteractablesList called with:', interactables);
console.log('availableInteractables:', availableInteractables);
const list = document.getElementById('interactablesList');
if (!list) {
console.error('interactablesList element not found!');
return;
}
list.innerHTML = '';
if (!interactables || Object.keys(interactables).length === 0) {
console.log('No interactables to render');
return;
}
console.log('Rendering', Object.keys(interactables).length, 'interactables');
Object.entries(interactables).forEach(([instanceId, instance]) => {
// Support both old format (template_id) and new format (id)
const templateId = instance.template_id || instance.id;
let displayName = instance.name || 'Unknown';
let actionCount = 0;
console.log(`Processing instance ${instanceId}, template_id: ${templateId}`);
// Try to find template in available interactables
const template = availableInteractables.find(i => i.id === templateId);
if (template) {
displayName = template.name;
actionCount = Object.keys(template.actions || {}).length;
console.log(`Found template: ${displayName} with ${actionCount} actions`);
} else {
console.log(`Template ${templateId} not found in availableInteractables`);
// For new format instances that have full data
if (instance.name) {
displayName = instance.name;
}
if (instance.actions) {
actionCount = Object.keys(instance.actions).length;
} else if (instance.outcomes) {
actionCount = Object.keys(instance.outcomes).length;
}
}
const item = document.createElement('div');
item.className = 'spawn-item';
item.dataset.instanceId = instanceId;
item.dataset.instanceData = JSON.stringify(instance);
item.innerHTML = `
<div class="spawn-item-info">
<div class="spawn-item-name">${displayName}</div>
<div class="spawn-item-weight">${actionCount} action(s)</div>
</div>
<div style="display: flex; gap: 5px;">
<button class="btn btn-primary" onclick="editInteractableInstance('${instanceId}', '${templateId}')">Edit</button>
<button class="btn btn-remove" onclick="removeInteractableInstance('${instanceId}')">Remove</button>
</div>
`;
list.appendChild(item);
console.log(`Appended item for ${instanceId}`);
});
console.log('Finished rendering interactables');
}
// ==================== IMAGE UPLOAD ====================
async function uploadImage() {
const fileInput = document.getElementById('imageUpload');
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
try {
const response = await fetch('/api/editor/upload-image', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
document.getElementById('imagePath').value = data.image_path;
updateImagePreview(data.image_path);
showSuccess(data.message);
} else {
alert('Upload failed: ' + data.error);
}
} catch (error) {
alert('Upload failed: ' + error.message);
}
}
async function uploadNPCImage() {
const fileInput = document.getElementById('npcImageUpload');
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
try {
const response = await fetch('/api/editor/upload-image', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
document.getElementById('npcImageUrl').value = data.image_path;
updateNPCImagePreview(data.image_path);
showSuccess(data.message);
} else {
alert('Upload failed: ' + data.error);
}
} catch (error) {
alert('Upload failed: ' + error.message);
}
}
function updateNPCImagePreview(imagePath) {
const preview = document.getElementById('npcImagePreview');
if (imagePath && imagePath.trim() !== '') {
preview.innerHTML = `<img src="${imagePath}" alt="NPC image" onerror="this.parentElement.innerHTML='<span>Image not found</span>'">`;
} else {
preview.innerHTML = '<span>No image</span>';
}
}
async function uploadInteractableImage() {
const fileInput = document.getElementById('interactableImageUpload');
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
try {
const response = await fetch('/api/editor/upload-image', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
document.getElementById('interactableImagePath').value = data.image_path;
updateInteractableImagePreview(data.image_path);
showSuccess(data.message);
} else {
alert('Upload failed: ' + data.error);
}
} catch (error) {
alert('Upload failed: ' + error.message);
}
}
// ==================== PYTHON EXPORT ====================
async function exportToPython() {
try {
const response = await fetch('/api/editor/export-python');
const data = await response.json();
if (data.success) {
// Create a downloadable file
const blob = new Blob([data.python_code], {type: 'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'world_loader_updates.py';
a.click();
URL.revokeObjectURL(url);
showSuccess('Python code exported! Review before applying to world_loader.py');
} else {
alert('Export failed: ' + data.error);
}
} catch (error) {
alert('Export failed: ' + error.message);
}
}
// ==================== UTILITY ====================
async function logout() {
try {
await fetch('/api/logout', {method: 'POST'});
if (liveStatsInterval) {
clearInterval(liveStatsInterval);
}
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
}
}
async function restartBot() {
if (!confirm('Are you sure you want to restart the bot? This will temporarily disconnect active users.')) {
return;
}
try {
const response = await fetch('/api/editor/restart-bot', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (response.ok) {
showSuccess('✅ Bot restarted successfully! Changes will be loaded.');
} else {
showError(`Failed to restart bot: ${data.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Restart failed:', error);
showError('Failed to restart bot. Check console for details.');
}
}
async function refreshBotLogs() {
try {
const lineCount = document.getElementById('logsLineCount').value || 100;
const logsViewer = document.getElementById('logsViewer');
logsViewer.innerHTML = '<div style="opacity: 0.5; text-align: center; padding: 20px;">Loading logs...</div>';
const response = await fetch(`/api/editor/bot-logs?lines=${lineCount}`);
const data = await response.json();
if (response.ok && data.success) {
displayLogs(data.logs);
} else {
logsViewer.innerHTML = `<div class="log-error">Failed to load logs: ${data.error || 'Unknown error'}</div>`;
}
} catch (error) {
console.error('Failed to fetch logs:', error);
document.getElementById('logsViewer').innerHTML = '<div class="log-error">Failed to fetch logs. Check console for details.</div>';
}
}
function displayLogs(logsText) {
const logsViewer = document.getElementById('logsViewer');
if (!logsText || logsText.trim() === '') {
logsViewer.innerHTML = '<div style="opacity: 0.5; text-align: center; padding: 20px;">No logs available</div>';
return;
}
const lines = logsText.split('\n');
let html = '';
lines.forEach(line => {
if (!line.trim()) return;
let className = 'log-line';
// Color code based on log level
if (line.includes('ERROR') || line.includes('Error') || line.includes('error')) {
className += ' log-error';
} else if (line.includes('WARNING') || line.includes('Warning') || line.includes('warning')) {
className += ' log-warning';
} else if (line.includes('INFO') || line.includes('Info')) {
className += ' log-info';
} else if (line.includes('✅') || line.includes('SUCCESS') || line.includes('Success')) {
className += ' log-success';
}
// Escape HTML to prevent XSS
const escapedLine = line
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
html += `<div class="${className}">${escapedLine}</div>`;
});
logsViewer.innerHTML = html;
// Auto-scroll to bottom
logsViewer.scrollTop = logsViewer.scrollHeight;
}
function clearLogsDisplay() {
document.getElementById('logsViewer').innerHTML = '<div style="opacity: 0.5; text-align: center; padding: 20px;">Logs cleared. Click "Refresh" to reload.</div>';
}
// ==================== TAB SWITCHING ====================
function switchTab(tabName) {
currentTab = tabName;
// Update tab buttons
document.querySelectorAll('.tab-button').forEach(btn => {
if (btn.textContent.toLowerCase().includes(tabName)) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Update tab content - ensure all are hidden first
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
content.style.display = 'none';
});
// Show only the selected tab
const activeTab = document.getElementById(`tab-${tabName}`);
if (activeTab) {
activeTab.classList.add('active');
activeTab.style.display = (tabName === 'locations') ? 'grid' : 'flex';
}
// Load data for the tab
if (tabName === 'npcs') {
loadNPCManagement();
} else if (tabName === 'items') {
loadItemManagement();
} else if (tabName === 'interactables') {
loadInteractableManagement();
} else if (tabName === 'logs') {
// Auto-load logs when switching to logs tab
refreshBotLogs();
} else if (tabName === 'locations') {
// Load interactables for the locations tab (needed for adding instances)
loadAvailableInteractablesForLocations();
// Force canvas resize when switching to locations tab
setTimeout(() => {
resizeCanvas();
}, 10);
}
}
async function loadAvailableInteractablesForLocations() {
try {
const response = await fetch('/api/editor/interactables');
const data = await response.json();
console.log('Interactables API response:', data);
if (data.interactables) {
// Check if it's an array or object
if (Array.isArray(data.interactables)) {
availableInteractables = data.interactables;
} else {
// If it's an object, convert to array
availableInteractables = Object.entries(data.interactables).map(([id, interactable]) => ({
id,
...interactable
}));
}
console.log('Loaded interactables:', availableInteractables.length);
} else {
console.error('No interactables in response');
availableInteractables = [];
}
} catch (error) {
console.error('Failed to load interactables:', error);
availableInteractables = [];
}
}
async function loadAvailableItemsForLocations() {
try {
const response = await fetch('/api/editor/items');
const data = await response.json();
availableItems = data.items || [];
} catch (error) {
console.error('Failed to load items:', error);
}
}
function saveCurrentItem() {
if (currentTab === 'locations') {
saveCurrentLocation();
} else if (currentTab === 'npcs') {
saveCurrentNPC();
} else if (currentTab === 'items') {
saveCurrentItem_();
} else if (currentTab === 'interactables') {
saveCurrentInteractable();
}
}
// ==================== NPC MANAGEMENT ====================
async function loadNPCManagement() {
try {
const response = await fetch('/api/editor/npcs', {
credentials: 'same-origin'
});
const data = await response.json();
availableNPCs = data.npcs || [];
renderNPCManagementList();
} catch (error) {
console.error('Failed to load NPCs:', error);
}
}
function renderNPCManagementList() {
const list = document.getElementById('npcManagementList');
list.innerHTML = '';
availableNPCs.forEach(npc => {
const item = document.createElement('div');
item.className = 'management-item';
item.dataset.name = npc.name;
item.dataset.id = npc.id;
if (npc.id === selectedNPCId) {
item.classList.add('active');
}
item.innerHTML = `
<div><strong>${npc.emoji} ${npc.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 5px;">
HP: ${npc.hp_min}-${npc.hp_max} | DMG: ${npc.damage_min}-${npc.damage_max}
</div>
`;
item.onclick = () => selectNPC(npc.id);
list.appendChild(item);
});
}
function filterNPCManagementList() {
const searchTerm = document.getElementById('npcManagementSearch').value.toLowerCase();
const items = document.querySelectorAll('#npcManagementList .management-item');
items.forEach(item => {
const name = item.dataset.name?.toLowerCase() || '';
const id = item.dataset.id?.toLowerCase() || '';
item.style.display = (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) ? '' : 'none';
});
}
function selectNPC(npcId) {
selectedNPCId = npcId;
renderNPCManagementList();
const npc = availableNPCs.find(n => n.id === npcId);
if (!npc) return;
const editor = document.getElementById('npcEditor');
editor.innerHTML = `
<h2>Edit NPC: ${npc.name}</h2>
<div class="form-grid">
<div class="property-group">
<label>ID</label>
<input type="text" id="npcId" value="${npc.id}" readonly style="opacity: 0.6;">
</div>
<div class="property-group">
<label>Name</label>
<input type="text" id="npcName" value="${npc.name}">
</div>
<div class="property-group">
<label>Emoji</label>
<input type="text" id="npcEmoji" value="${npc.emoji}" maxlength="2">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>HP Min</label>
<input type="number" id="npcHpMin" value="${npc.hp_min}" min="1">
</div>
<div class="property-group">
<label>HP Max</label>
<input type="number" id="npcHpMax" value="${npc.hp_max}" min="1">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>Damage Min</label>
<input type="number" id="npcDamageMin" value="${npc.damage_min}" min="0">
</div>
<div class="property-group">
<label>Damage Max</label>
<input type="number" id="npcDamageMax" value="${npc.damage_max}" min="0">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>XP Reward</label>
<input type="number" id="npcXp" value="${npc.xp_reward}" min="0">
</div>
<div class="property-group">
<label>Defense</label>
<input type="number" id="npcDefense" value="${npc.defense || 0}" min="0">
</div>
</div>
<div class="property-group">
<label>Description</label>
<textarea id="npcDescription" rows="3" style="width: 100%;">${npc.description || ''}</textarea>
</div>
<div class="property-group">
<label>NPC Image</label>
<input type="text" id="npcImageUrl" value="${npc.image_url || ''}" placeholder="images/npcs/npc_name.png" oninput="updateNPCImagePreview(this.value)">
<div class="file-input-wrapper">
<input type="file" id="npcImageUpload" accept="image/*" onchange="uploadNPCImage()">
<label for="npcImageUpload" class="file-input-label">📤 Upload New Image</label>
</div>
<div class="image-preview" id="npcImagePreview">
${npc.image_url ? `<img src="${npc.image_url}" alt="NPC image" onerror="this.parentElement.innerHTML='<span>Image not found</span>'">` : '<span>No image</span>'}
</div>
</div>
<h3 style="color: #ffa726; margin: 30px 0 15px;">Loot Table</h3>
<div id="npcLootTable"></div>
<button class="btn btn-add" onclick="addNPCLoot()">+ Add Loot Item</button>
<h3 style="color: #ffa726; margin: 30px 0 15px;">Corpse Loot</h3>
<div id="npcCorpseLoot"></div>
<button class="btn btn-add" onclick="addNPCCorpseLoot()">+ Add Corpse Loot</button>
<div style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #3a3a6a; display: flex; gap: 10px;">
<button class="btn btn-danger" style="flex: 1;" onclick="deleteCurrentNPC()">🗑️ Delete NPC</button>
</div>
`;
renderNPCLootTable(npc.loot_table || []);
renderNPCCorpseLoot(npc.corpse_loot || []);
}
function renderNPCLootTable(lootTable) {
const container = document.getElementById('npcLootTable');
container.innerHTML = '';
lootTable.forEach((loot, index) => {
// Handle both old and new formats for quantities
let quantityMin = loot.quantity_min || 1;
let quantityMax = loot.quantity_max || 1;
// Parse quantity_range if it exists (old format)
if (Array.isArray(loot.quantity_range)) {
quantityMin = loot.quantity_range[0] || 1;
quantityMax = loot.quantity_range[1] || 1;
} else if (typeof loot.quantity_range === 'string') {
try {
const parsed = JSON.parse(loot.quantity_range);
quantityMin = parsed[0] || 1;
quantityMax = parsed[1] || 1;
} catch (e) {
quantityMin = 1;
quantityMax = 1;
}
}
const item = document.createElement('div');
item.className = 'array-item';
item.innerHTML = `
<button class="array-item-remove" onclick="removeNPCLoot(${index})">×</button>
<div class="form-grid">
<div class="property-group">
<label>Item</label>
<div style="position: relative;">
<input type="text"
id="lootItem${index}"
value="${loot.item_id}"
placeholder="Type to search items..."
oninput="filterNPCLootItemDropdown(${index})"
onfocus="showNPCLootItemDropdown(${index})"
autocomplete="off">
<div id="lootItemDropdown${index}" class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
</div>
<div class="property-group">
<label>Quantity Min</label>
<input type="number" id="lootQuantityMin${index}" value="${quantityMin}" min="1">
</div>
<div class="property-group">
<label>Quantity Max</label>
<input type="number" id="lootQuantityMax${index}" value="${quantityMax}" min="1">
</div>
<div class="property-group">
<label>Drop Chance (0-1)</label>
<input type="number" id="lootChance${index}" value="${loot.drop_chance || 0.5}" step="0.01" min="0" max="1">
</div>
</div>
`;
container.appendChild(item);
});
}
function renderNPCCorpseLoot(corpseLoot) {
const container = document.getElementById('npcCorpseLoot');
container.innerHTML = '';
corpseLoot.forEach((loot, index) => {
// Handle both old and new formats for quantities
let quantityMin = loot.quantity_min || 1;
let quantityMax = loot.quantity_max || 1;
const item = document.createElement('div');
item.className = 'array-item';
item.innerHTML = `
<button class="array-item-remove" onclick="removeNPCCorpseLoot(${index})">×</button>
<div class="form-grid">
<div class="property-group">
<label>Item</label>
<div style="position: relative;">
<input type="text"
id="corpseItem${index}"
value="${loot.item_id}"
placeholder="Type to search items..."
oninput="filterNPCCorpseItemDropdown(${index})"
onfocus="showNPCCorpseItemDropdown(${index})"
autocomplete="off">
<div id="corpseItemDropdown${index}" class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
</div>
<div class="property-group">
<label>Quantity Min</label>
<input type="number" id="corpseQuantityMin${index}" value="${quantityMin}" min="1">
</div>
<div class="property-group">
<label>Quantity Max</label>
<input type="number" id="corpseQuantityMax${index}" value="${quantityMax}" min="1">
</div>
<div class="property-group">
<label>Required Tool (optional)</label>
<input type="text" id="corpseRequiredTool${index}" value="${loot.required_tool || ''}" placeholder="e.g., knife">
</div>
</div>
`;
container.appendChild(item);
});
}
function createNewNPC() {
const npcId = prompt('Enter NPC ID (e.g., zombie, raider):');
if (!npcId) return;
const newNPC = {
id: npcId,
name: 'New NPC',
emoji: '👹',
hp_min: 10,
hp_max: 20,
damage_min: 1,
damage_max: 5,
xp_reward: 10,
defense: 0,
description: '',
image_url: '',
loot_table: [],
corpse_loot: []
};
availableNPCs.push(newNPC);
renderNPCManagementList();
selectNPC(npcId);
}
async function saveCurrentNPC() {
if (!selectedNPCId) {
alert('No NPC selected');
return;
}
const npcData = {
id: document.getElementById('npcId').value,
name: document.getElementById('npcName').value,
emoji: document.getElementById('npcEmoji').value,
hp_min: parseInt(document.getElementById('npcHpMin').value),
hp_max: parseInt(document.getElementById('npcHpMax').value),
damage_min: parseInt(document.getElementById('npcDamageMin').value),
damage_max: parseInt(document.getElementById('npcDamageMax').value),
xp_reward: parseInt(document.getElementById('npcXp').value),
defense: parseInt(document.getElementById('npcDefense').value),
description: document.getElementById('npcDescription').value,
image_url: document.getElementById('npcImageUrl').value,
loot_table: getNPCLootTable(),
corpse_loot: getNPCCorpseLoot()
};
try {
const response = await fetch('/api/editor/npc', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify(npcData)
});
const data = await response.json();
if (data.success) {
showSuccess('NPC saved successfully!');
await loadNPCManagement();
} else {
alert('Failed to save: ' + data.error);
}
} catch (error) {
alert('Failed to save NPC: ' + error.message);
}
}
function getNPCLootTable() {
const container = document.getElementById('npcLootTable');
const items = container.querySelectorAll('.array-item');
const lootTable = [];
items.forEach((item, index) => {
const itemId = document.getElementById(`lootItem${index}`).value;
const quantityMin = parseInt(document.getElementById(`lootQuantityMin${index}`).value);
const quantityMax = parseInt(document.getElementById(`lootQuantityMax${index}`).value);
const chance = parseFloat(document.getElementById(`lootChance${index}`).value);
if (itemId) {
lootTable.push({
item_id: itemId,
quantity_min: quantityMin,
quantity_max: quantityMax,
drop_chance: chance
});
}
});
return lootTable;
}
function getNPCCorpseLoot() {
const container = document.getElementById('npcCorpseLoot');
const items = container.querySelectorAll('.array-item');
const corpseLoot = [];
items.forEach((item, index) => {
const itemId = document.getElementById(`corpseItem${index}`).value;
const quantityMin = parseInt(document.getElementById(`corpseQuantityMin${index}`).value);
const quantityMax = parseInt(document.getElementById(`corpseQuantityMax${index}`).value);
const requiredTool = document.getElementById(`corpseRequiredTool${index}`).value || null;
if (itemId) {
corpseLoot.push({
item_id: itemId,
quantity_min: quantityMin,
quantity_max: quantityMax,
required_tool: requiredTool
});
}
});
return corpseLoot;
}
// Item dropdown functions for NPC loot tables
function showNPCLootItemDropdown(index) {
filterNPCLootItemDropdown(index);
}
function filterNPCLootItemDropdown(index) {
const input = document.getElementById(`lootItem${index}`);
const dropdown = document.getElementById(`lootItemDropdown${index}`);
const searchTerm = input.value.toLowerCase();
dropdown.innerHTML = '';
dropdown.style.display = 'block';
const filtered = availableItems.filter(item =>
item.id.toLowerCase().includes(searchTerm) ||
(item.name && item.name.toLowerCase().includes(searchTerm)) ||
(item.emoji && item.emoji.includes(searchTerm))
);
if (filtered.length === 0) {
dropdown.innerHTML = '<div style="padding: 8px; opacity: 0.5;">No items found</div>';
return;
}
filtered.forEach(item => {
const option = document.createElement('div');
option.className = 'dropdown-item';
option.style.padding = '8px';
option.style.cursor = 'pointer';
option.style.borderBottom = '1px solid #2a2a4a';
option.innerHTML = `${item.emoji || '📦'} ${item.name || item.id} <span style="opacity: 0.6; font-size: 0.9em;">(${item.id})</span>`;
// Add hover effects
option.addEventListener('mouseover', function() {
this.style.background = '#2a2a4a';
});
option.addEventListener('mouseout', function() {
this.style.background = 'transparent';
});
option.addEventListener('click', () => {
input.value = item.id;
dropdown.style.display = 'none';
});
dropdown.appendChild(option);
});
}
function showNPCCorpseItemDropdown(index) {
filterNPCCorpseItemDropdown(index);
}
function filterNPCCorpseItemDropdown(index) {
const input = document.getElementById(`corpseItem${index}`);
const dropdown = document.getElementById(`corpseItemDropdown${index}`);
const searchTerm = input.value.toLowerCase();
dropdown.innerHTML = '';
dropdown.style.display = 'block';
const filtered = availableItems.filter(item =>
item.id.toLowerCase().includes(searchTerm) ||
(item.name && item.name.toLowerCase().includes(searchTerm)) ||
(item.emoji && item.emoji.includes(searchTerm))
);
if (filtered.length === 0) {
dropdown.innerHTML = '<div style="padding: 8px; opacity: 0.5;">No items found</div>';
return;
}
filtered.forEach(item => {
const option = document.createElement('div');
option.className = 'dropdown-item';
option.style.padding = '8px';
option.style.cursor = 'pointer';
option.style.borderBottom = '1px solid #2a2a4a';
option.innerHTML = `${item.emoji || '📦'} ${item.name || item.id} <span style="opacity: 0.6; font-size: 0.9em;">(${item.id})</span>`;
// Add hover effects
option.addEventListener('mouseover', function() {
this.style.background = '#2a2a4a';
});
option.addEventListener('mouseout', function() {
this.style.background = 'transparent';
});
option.addEventListener('click', () => {
input.value = item.id;
dropdown.style.display = 'none';
});
dropdown.appendChild(option);
});
}
// Hide dropdowns when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.matches('[id^="lootItem"], [id^="corpseItem"]')) {
document.querySelectorAll('.item-dropdown').forEach(dropdown => {
dropdown.style.display = 'none';
});
}
});
function addNPCLoot() {
const npc = availableNPCs.find(n => n.id === selectedNPCId);
if (!npc) return;
if (!npc.loot_table) npc.loot_table = [];
npc.loot_table.push({item_id: '', quantity_range: [1, 1], drop_chance: 0.5});
renderNPCLootTable(npc.loot_table);
}
function removeNPCLoot(index) {
const npc = availableNPCs.find(n => n.id === selectedNPCId);
if (!npc || !npc.loot_table) return;
npc.loot_table.splice(index, 1);
renderNPCLootTable(npc.loot_table);
}
function addNPCCorpseLoot() {
const npc = availableNPCs.find(n => n.id === selectedNPCId);
if (!npc) return;
if (!npc.corpse_loot) npc.corpse_loot = [];
npc.corpse_loot.push({item_id: '', quantity: 1});
renderNPCCorpseLoot(npc.corpse_loot);
}
function removeNPCCorpseLoot(index) {
const npc = availableNPCs.find(n => n.id === selectedNPCId);
if (!npc || !npc.corpse_loot) return;
npc.corpse_loot.splice(index, 1);
renderNPCCorpseLoot(npc.corpse_loot);
}
async function deleteCurrentNPC() {
if (!selectedNPCId) return;
if (!confirm(`Delete NPC "${selectedNPCId}"? This cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/editor/npc/${selectedNPCId}`, {
method: 'DELETE',
credentials: 'same-origin'
});
const data = await response.json();
if (data.success) {
showSuccess('NPC deleted successfully!');
selectedNPCId = null;
document.getElementById('npcEditor').innerHTML = '<div style="text-align: center; opacity: 0.5; padding: 50px;"><h3>Select an NPC or create a new one</h3></div>';
await loadNPCManagement();
} else {
alert('Failed to delete: ' + data.error);
}
} catch (error) {
alert('Failed to delete NPC: ' + error.message);
}
}
// ==================== ITEM MANAGEMENT ====================
async function loadItemManagement() {
try {
const response = await fetch('/api/editor/items', {
credentials: 'same-origin'
});
const data = await response.json();
availableItems = data.items || [];
renderItemManagementList();
} catch (error) {
console.error('Failed to load items:', error);
}
}
function renderItemManagementList() {
const list = document.getElementById('itemManagementList');
list.innerHTML = '';
availableItems.forEach(item => {
const elem = document.createElement('div');
elem.className = 'management-item';
elem.dataset.name = item.name;
elem.dataset.id = item.id;
if (item.id === selectedItemId) {
elem.classList.add('active');
}
elem.innerHTML = `
<div><strong>${item.emoji ? item.emoji + ' ' : ''}${item.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 5px;">
Type: ${item.type} | Weight: ${item.weight}kg
</div>
`;
elem.onclick = () => selectItem(item.id);
list.appendChild(elem);
});
}
function filterItemManagementList() {
const searchTerm = document.getElementById('itemManagementSearch').value.toLowerCase();
const items = document.querySelectorAll('#itemManagementList .management-item');
items.forEach(item => {
const name = item.dataset.name?.toLowerCase() || '';
const id = item.dataset.id?.toLowerCase() || '';
item.style.display = (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) ? '' : 'none';
});
}
function selectItem(itemId) {
selectedItemId = itemId;
renderItemManagementList();
const item = availableItems.find(i => i.id === itemId);
if (!item) return;
const editor = document.getElementById('itemEditor');
editor.innerHTML = `
<h2>Edit Item: ${item.name}</h2>
<div class="form-grid">
<div class="property-group">
<label>ID</label>
<input type="text" id="itemId" value="${item.id}" readonly style="opacity: 0.6;">
</div>
<div class="property-group">
<label>Name</label>
<input type="text" id="itemName" value="${item.name}">
</div>
<div class="property-group">
<label>Emoji</label>
<input type="text" id="itemEmoji" value="${item.emoji || ''}" maxlength="2">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>Type</label>
<select id="itemType">
<option value="resource" ${item.type === 'resource' ? 'selected' : ''}>Resource</option>
<option value="consumable" ${item.type === 'consumable' ? 'selected' : ''}>Consumable</option>
<option value="weapon" ${item.type === 'weapon' ? 'selected' : ''}>Weapon</option>
<option value="equipment" ${item.type === 'equipment' ? 'selected' : ''}>Equipment</option>
<option value="quest" ${item.type === 'quest' ? 'selected' : ''}>Quest Item</option>
</select>
</div>
<div class="property-group">
<label>Stackable</label>
<select id="itemStackable">
<option value="true" ${item.stackable !== false ? 'selected' : ''}>Yes</option>
<option value="false" ${item.stackable === false ? 'selected' : ''}>No</option>
</select>
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>Weight (kg)</label>
<input type="number" id="itemWeight" value="${item.weight}" step="0.1" min="0">
</div>
<div class="property-group">
<label>Volume</label>
<input type="number" id="itemVolume" value="${item.volume}" step="0.1" min="0">
</div>
</div>
<div class="property-group">
<label>Description</label>
<textarea id="itemDescription" rows="3" style="width: 100%;">${item.description || ''}</textarea>
</div>
<h3 style="color: #ffa726; margin: 30px 0 15px;">Properties</h3>
<div class="form-grid">
<div class="property-group">
<label>HP Restore</label>
<input type="number" id="itemHpRestore" value="${item.hp_restore || 0}" min="0">
</div>
<div class="property-group">
<label>Stamina Restore</label>
<input type="number" id="itemStaminaRestore" value="${item.stamina_restore || 0}" min="0">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>Damage</label>
<input type="number" id="itemDamage" value="${item.damage || 0}" min="0">
</div>
<div class="property-group">
<label>Defense</label>
<input type="number" id="itemDefense" value="${item.defense || 0}" min="0">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>Capacity Weight Bonus</label>
<input type="number" id="itemCapacityWeight" value="${item.capacity_weight || 0}" min="0">
</div>
<div class="property-group">
<label>Capacity Volume Bonus</label>
<input type="number" id="itemCapacityVolume" value="${item.capacity_volume || 0}" min="0">
</div>
</div>
<div class="property-group">
<label style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="itemStackable" ${item.stackable ? 'checked' : ''}>
<span>Stackable</span>
</label>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #3a3a6a; display: flex; gap: 10px;">
<button class="btn btn-danger" style="flex: 1;" onclick="deleteCurrentItem()">🗑️ Delete Item</button>
</div>
`;
}
function createNewItem() {
const itemId = prompt('Enter Item ID (e.g., health_potion, iron_sword):');
if (!itemId) return;
const newItem = {
id: itemId,
name: 'New Item',
emoji: '',
type: 'resource',
weight: 0.1,
volume: 0.1,
description: '',
stackable: true,
hp_restore: 0,
stamina_restore: 0,
damage: 0,
defense: 0,
capacity_weight: 0,
capacity_volume: 0
};
availableItems.push(newItem);
renderItemManagementList();
selectItem(itemId);
}
async function saveCurrentItem_() {
if (!selectedItemId) {
alert('No item selected');
return;
}
const itemData = {
id: document.getElementById('itemId').value,
name: document.getElementById('itemName').value,
emoji: document.getElementById('itemEmoji').value,
type: document.getElementById('itemType').value,
weight: parseFloat(document.getElementById('itemWeight').value),
volume: parseFloat(document.getElementById('itemVolume').value),
description: document.getElementById('itemDescription').value,
stackable: document.getElementById('itemStackable').value === 'true',
hp_restore: parseInt(document.getElementById('itemHpRestore').value) || 0,
stamina_restore: parseInt(document.getElementById('itemStaminaRestore').value) || 0,
damage: parseInt(document.getElementById('itemDamage').value) || 0,
defense: parseInt(document.getElementById('itemDefense').value) || 0,
capacity_weight: parseInt(document.getElementById('itemCapacityWeight').value) || 0,
capacity_volume: parseInt(document.getElementById('itemCapacityVolume').value) || 0
};
try {
const response = await fetch('/api/editor/item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify(itemData)
});
const data = await response.json();
if (data.success) {
showSuccess('Item saved successfully!');
await loadItemManagement();
} else {
alert('Failed to save: ' + data.error);
}
} catch (error) {
alert('Failed to save item: ' + error.message);
}
}
async function deleteCurrentItem() {
if (!selectedItemId) return;
if (!confirm(`Delete item "${selectedItemId}"? This cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/editor/item/${selectedItemId}`, {
method: 'DELETE',
credentials: 'same-origin'
});
const data = await response.json();
if (data.success) {
showSuccess('Item deleted successfully!');
selectedItemId = null;
document.getElementById('itemEditor').innerHTML = '<div style="text-align: center; opacity: 0.5; padding: 50px;"><h3>Select an item or create a new one</h3></div>';
await loadItemManagement();
} else {
alert('Failed to delete: ' + data.error);
}
} catch (error) {
alert('Failed to delete item: ' + error.message);
}
}
// ==================== INTERACTABLES MANAGEMENT ====================
async function loadInteractableManagement() {
try {
const response = await fetch('/api/editor/interactables', {
credentials: 'same-origin'
});
const data = await response.json();
availableInteractables = data.interactables;
renderInteractableManagementList();
} catch (error) {
console.error('Failed to load interactables:', error);
}
}
function renderInteractableManagementList() {
const list = document.getElementById('interactableManagementList');
list.innerHTML = '';
availableInteractables.forEach(inter => {
const elem = document.createElement('div');
elem.className = 'management-item';
elem.dataset.name = inter.name;
elem.dataset.id = inter.id;
if (inter.id === selectedInteractableId) {
elem.classList.add('active');
}
const actionCount = Object.keys(inter.actions || {}).length;
elem.innerHTML = `
<div><strong>${inter.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 5px;">
Actions: ${actionCount}
</div>
`;
elem.onclick = () => selectInteractable(inter.id);
list.appendChild(elem);
});
}
function filterInteractableManagementList() {
const searchTerm = document.getElementById('interactableManagementSearch').value.toLowerCase();
const items = document.querySelectorAll('#interactableManagementList .management-item');
items.forEach(item => {
const name = item.dataset.name?.toLowerCase() || '';
const id = item.dataset.id?.toLowerCase() || '';
item.style.display = (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) ? '' : 'none';
});
}
function selectInteractable(interId) {
selectedInteractableId = interId;
renderInteractableManagementList();
const inter = availableInteractables.find(i => i.id === interId);
if (!inter) return;
const editor = document.getElementById('interactableEditor');
// Render actions
let actionsHtml = '';
for (const [actionId, action] of Object.entries(inter.actions || {})) {
actionsHtml += `
<div class="array-item" style="margin-bottom: 15px; position: relative;">
<button class="btn btn-remove" style="position: absolute; top: 10px; right: 10px;"
onclick="deleteInteractableAction('${actionId}')">× Remove</button>
<h4 style="color: #ffa726; margin-bottom: 10px;">${action.label}</h4>
<div class="form-grid">
<div class="property-group">
<label>Action ID</label>
<input type="text" value="${actionId}" readonly style="opacity: 0.6;">
</div>
<div class="property-group">
<label>Label</label>
<input type="text" class="action-label" data-action="${actionId}" value="${action.label}">
</div>
<div class="property-group">
<label>Stamina Cost</label>
<input type="number" class="action-stamina" data-action="${actionId}" value="${action.stamina_cost}" min="0">
</div>
</div>
</div>
`;
}
editor.innerHTML = `
<h2>Edit Interactable: ${inter.name}</h2>
<div class="form-grid">
<div class="property-group">
<label>ID</label>
<input type="text" id="interactableId" value="${inter.id}" readonly style="opacity: 0.6;">
</div>
<div class="property-group">
<label>Name</label>
<input type="text" id="interactableName" value="${inter.name}">
</div>
</div>
<div class="property-group">
<label>Description</label>
<textarea id="interactableDescription" rows="3" style="width: 100%;">${inter.description || ''}</textarea>
</div>
<div class="property-group">
<label>Interactable Image</label>
<input type="text" id="interactableImagePath" value="${inter.image_path || ''}"
placeholder="images/interactables/name.png"
oninput="updateInteractableImagePreview(this.value)">
<div class="file-input-wrapper">
<input type="file" id="interactableImageUpload" accept="image/*" onchange="uploadInteractableImage()">
<label for="interactableImageUpload" class="file-input-label">📤 Upload New Image</label>
</div>
<div class="image-preview" id="interactableImagePreview">
${inter.image_path ? `<img src="/${inter.image_path}" alt="Interactable image" onerror="this.parentElement.innerHTML='<span>Image not found</span>'">` : '<span>No image</span>'}
</div>
</div>
<h3 style="color: #ffa726; margin: 30px 0 15px;">Actions</h3>
<div id="interactableActions">
${actionsHtml}
</div>
<button class="btn btn-add" onclick="addNewInteractableAction()" style="margin-top: 10px;">+ Add Action</button>
<div style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #3a3a6a; display: flex; gap: 10px;">
<button class="btn btn-danger" style="flex: 1;" onclick="deleteCurrentInteractable()">🗑️ Delete Interactable</button>
</div>
`;
}
function createNewInteractable() {
const interId = prompt('Enter Interactable ID (e.g., rubble, dumpster):');
if (!interId) return;
const newInteractable = {
id: interId,
name: 'New Interactable',
description: '',
image_path: '',
actions: {
'search': {
id: 'search',
label: '🔎 Search',
stamina_cost: 2
}
}
};
availableInteractables.push(newInteractable);
renderInteractableManagementList();
selectInteractable(interId);
}
async function saveCurrentInteractable() {
if (!selectedInteractableId) {
alert('No interactable selected');
return;
}
// Get basic fields
const interData = {
id: document.getElementById('interactableId').value,
name: document.getElementById('interactableName').value,
description: document.getElementById('interactableDescription').value,
image_path: document.getElementById('interactableImagePath').value,
actions: {}
};
// Get actions
const inter = availableInteractables.find(i => i.id === selectedInteractableId);
if (inter && inter.actions) {
for (const [actionId, action] of Object.entries(inter.actions)) {
const labelInput = document.querySelector(`.action-label[data-action="${actionId}"]`);
const staminaInput = document.querySelector(`.action-stamina[data-action="${actionId}"]`);
interData.actions[actionId] = {
id: actionId,
label: labelInput ? labelInput.value : action.label,
stamina_cost: staminaInput ? parseInt(staminaInput.value) : action.stamina_cost
};
}
}
try {
const response = await fetch('/api/editor/interactable', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify(interData)
});
const data = await response.json();
if (data.success) {
showSuccess('Interactable saved successfully!');
await loadInteractableManagement();
} else {
alert('Failed to save: ' + data.error);
}
} catch (error) {
alert('Failed to save interactable: ' + error.message);
}
}
async function deleteCurrentInteractable() {
if (!selectedInteractableId) {
alert('No interactable selected');
return;
}
if (!confirm(`Delete interactable "${selectedInteractableId}"?`)) {
return;
}
try {
const response = await fetch(`/api/editor/interactable/${selectedInteractableId}`, {
method: 'DELETE',
credentials: 'same-origin'
});
const data = await response.json();
if (data.success) {
showSuccess('Interactable deleted successfully!');
selectedInteractableId = null;
document.getElementById('interactableEditor').innerHTML = `
<h3>Select an interactable to edit</h3>
<p style="opacity: 0.7;">Choose from the list or create a new one.</p>
`;
await loadInteractableManagement();
} else {
alert('Failed to delete: ' + data.error);
}
} catch (error) {
alert('Failed to delete interactable: ' + error.message);
}
}
function updateInteractableImagePreview(imagePath) {
const preview = document.getElementById('interactableImagePreview');
if (imagePath && imagePath.trim()) {
preview.innerHTML = `<img src="/${imagePath}" alt="Interactable image" onerror="this.parentElement.innerHTML='<span>Image not found</span>'">`;
} else {
preview.innerHTML = '<span>No image</span>';
}
}
function addNewInteractableAction() {
const actionId = prompt('Enter Action ID (e.g., search, break, open):');
if (!actionId) return;
const inter = availableInteractables.find(i => i.id === selectedInteractableId);
if (!inter) return;
// Check if action already exists
if (inter.actions && inter.actions[actionId]) {
alert('Action with this ID already exists!');
return;
}
// Add new action
if (!inter.actions) {
inter.actions = {};
}
inter.actions[actionId] = {
id: actionId,
label: actionId.charAt(0).toUpperCase() + actionId.slice(1),
stamina_cost: 2
};
// Re-render the editor
selectInteractable(selectedInteractableId);
}
function deleteInteractableAction(actionId) {
if (!confirm(`Delete action "${actionId}"?`)) {
return;
}
const inter = availableInteractables.find(i => i.id === selectedInteractableId);
if (!inter || !inter.actions) return;
delete inter.actions[actionId];
// Re-render the editor
selectInteractable(selectedInteractableId);
}