4808 lines
182 KiB
JavaScript
4808 lines
182 KiB
JavaScript
// 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);
|
||
}
|
||
|
||
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) {
|
||
const list = document.getElementById('connectionList');
|
||
|
||
if (!list) {
|
||
console.error('connectionList element not found!');
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = '';
|
||
|
||
// Get connections from the global connections array
|
||
const locationConnections = connections.filter(conn => conn.from === locationId);
|
||
|
||
if (!locationConnections || locationConnections.length === 0) {
|
||
list.innerHTML = '<div style="padding: 10px; text-align: center; opacity: 0.5;">No connections</div>';
|
||
return;
|
||
}
|
||
|
||
locationConnections.forEach(conn => {
|
||
const destLocation = currentLocations.find(l => l.id === conn.to);
|
||
|
||
if (destLocation) {
|
||
const item = document.createElement('div');
|
||
item.className = 'connection-item';
|
||
item.innerHTML = `
|
||
<div class="connection-info">
|
||
<div class="connection-direction" style="font-weight: 500; color: #ffa726;">
|
||
${getDirectionEmoji(conn.direction)} ${conn.direction}
|
||
</div>
|
||
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 4px;">
|
||
→ ${destLocation.name}
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-remove" onclick="deleteConnection('${locationId}', '${conn.to}')"
|
||
style="width: 32px; height: 32px; padding: 0; font-size: 20px; line-height: 1;">×</button>
|
||
`;
|
||
list.appendChild(item);
|
||
}
|
||
});
|
||
}
|
||
|
||
function getDirectionEmoji(direction) {
|
||
const emojiMap = {
|
||
'north': '⬆️',
|
||
'south': '⬇️',
|
||
'east': '➡️',
|
||
'west': '⬅️',
|
||
'northeast': '↗️',
|
||
'northwest': '↖️',
|
||
'southeast': '↘️',
|
||
'southwest': '↙️',
|
||
'up': '⬆️',
|
||
'down': '⬇️',
|
||
'inside': '🚪',
|
||
'outside': '🚪'
|
||
};
|
||
return emojiMap[direction.toLowerCase()] || '🔀';
|
||
}
|
||
|
||
// ==================== 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';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Store connection data temporarily
|
||
let pendingConnection = null;
|
||
|
||
function promptAddConnection(fromId, toId) {
|
||
// Store the connection data
|
||
pendingConnection = { fromId, toId };
|
||
|
||
// Get location names
|
||
const fromLocation = currentLocations.find(loc => loc.id === fromId);
|
||
const toLocation = currentLocations.find(loc => loc.id === toId);
|
||
|
||
// Update modal
|
||
document.getElementById('fromLocationName').textContent = fromLocation ? fromLocation.name : fromId;
|
||
document.getElementById('toLocationName').textContent = toLocation ? toLocation.name : toId;
|
||
|
||
// Reset selection
|
||
document.getElementById('directionSelect').value = '';
|
||
document.getElementById('autoReverseConnection').checked = true;
|
||
|
||
// Close location list modal and open direction modal
|
||
closeAddConnectionModal();
|
||
document.getElementById('selectDirectionModal').style.display = 'flex';
|
||
|
||
// Update reverse connection text based on selection
|
||
updateReverseConnectionText();
|
||
}
|
||
|
||
function updateReverseConnectionText() {
|
||
const directionSelect = document.getElementById('directionSelect');
|
||
const reverseText = document.getElementById('reverseConnectionText');
|
||
const checkbox = document.getElementById('autoReverseConnection');
|
||
|
||
if (!directionSelect || !reverseText || !checkbox) return;
|
||
|
||
const direction = directionSelect.value;
|
||
|
||
if (!direction) {
|
||
reverseText.textContent = 'Will also create a connection back from destination to source';
|
||
return;
|
||
}
|
||
|
||
const reverseDirection = getReverseDirection(direction);
|
||
const fromLocation = currentLocations.find(loc => loc.id === pendingConnection.fromId);
|
||
const toLocation = currentLocations.find(loc => loc.id === pendingConnection.toId);
|
||
|
||
if (checkbox.checked) {
|
||
reverseText.innerHTML = `Will create: <strong>${toLocation?.name || 'destination'}</strong> → ${reverseDirection} → <strong>${fromLocation?.name || 'source'}</strong>`;
|
||
} else {
|
||
reverseText.textContent = 'Only one-way connection will be created';
|
||
}
|
||
}
|
||
|
||
// Listen to direction changes
|
||
if (document.getElementById('directionSelect')) {
|
||
document.getElementById('directionSelect').addEventListener('change', updateReverseConnectionText);
|
||
document.getElementById('autoReverseConnection').addEventListener('change', updateReverseConnectionText);
|
||
}
|
||
|
||
function getReverseDirection(direction) {
|
||
const reverseMap = {
|
||
'north': 'south',
|
||
'south': 'north',
|
||
'east': 'west',
|
||
'west': 'east',
|
||
'northeast': 'southwest',
|
||
'northwest': 'southeast',
|
||
'southeast': 'northwest',
|
||
'southwest': 'northeast',
|
||
'up': 'down',
|
||
'down': 'up',
|
||
'inside': 'outside',
|
||
'outside': 'inside'
|
||
};
|
||
|
||
return reverseMap[direction] || direction;
|
||
}
|
||
|
||
function closeSelectDirectionModal() {
|
||
document.getElementById('selectDirectionModal').style.display = 'none';
|
||
pendingConnection = null;
|
||
}
|
||
|
||
async function confirmAddConnection() {
|
||
if (!pendingConnection) return;
|
||
|
||
const direction = document.getElementById('directionSelect').value;
|
||
const autoReverse = document.getElementById('autoReverseConnection').checked;
|
||
|
||
if (!direction) {
|
||
alert('Please select a direction');
|
||
return;
|
||
}
|
||
|
||
const { fromId, toId } = pendingConnection;
|
||
|
||
try {
|
||
// Add the main connection
|
||
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) {
|
||
alert('Failed to add connection: ' + data.error);
|
||
return;
|
||
}
|
||
|
||
// Add reverse connection if enabled
|
||
if (autoReverse) {
|
||
const reverseDirection = getReverseDirection(direction);
|
||
const reverseResponse = await fetch('/api/editor/connection', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ from: toId, to: fromId, direction: reverseDirection })
|
||
});
|
||
|
||
const reverseData = await reverseResponse.json();
|
||
|
||
if (reverseData.success) {
|
||
showSuccess(`Connection added! (bidirectional: ${direction} ⇄ ${reverseDirection})`);
|
||
} else {
|
||
showSuccess(`Connection added (${direction}), but reverse failed: ${reverseData.error}`);
|
||
}
|
||
} else {
|
||
showSuccess(`Connection added! (one-way: ${direction})`);
|
||
}
|
||
|
||
// Refresh the view
|
||
await loadConnections();
|
||
await selectLocation(fromId); // Refresh form
|
||
drawMap();
|
||
closeSelectDirectionModal();
|
||
|
||
} catch (error) {
|
||
alert('Failed to add connection: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// Store pending deletion data
|
||
let pendingDeletion = null;
|
||
|
||
function deleteConnection(fromId, toId) {
|
||
// Get location information
|
||
const fromLocation = currentLocations.find(loc => loc.id === fromId);
|
||
const toLocation = currentLocations.find(loc => loc.id === toId);
|
||
|
||
if (!fromLocation || !toLocation) {
|
||
alert('Location not found');
|
||
return;
|
||
}
|
||
|
||
// Find the direction of this connection
|
||
const direction = Object.entries(fromLocation.exits || {}).find(([dir, dest]) => dest === toId)?.[0];
|
||
|
||
// Check if reverse connection exists
|
||
const reverseDirection = getReverseDirection(direction);
|
||
const hasReverseConnection = toLocation.exits && toLocation.exits[reverseDirection] === fromId;
|
||
|
||
// Store deletion data
|
||
pendingDeletion = { fromId, toId, direction, hasReverseConnection, reverseDirection };
|
||
|
||
// Update modal content
|
||
document.getElementById('deleteFromLocationName').textContent = fromLocation.name;
|
||
document.getElementById('deleteToLocationName').textContent = toLocation.name;
|
||
document.getElementById('deleteDirectionText').textContent = direction || 'unknown';
|
||
|
||
// Show/hide reverse connection option
|
||
const reverseOption = document.getElementById('deleteReverseOption');
|
||
if (hasReverseConnection) {
|
||
reverseOption.style.display = 'block';
|
||
document.getElementById('deleteReverseText').innerHTML =
|
||
`Also delete: <strong>${toLocation.name}</strong> → ${reverseDirection} → <strong>${fromLocation.name}</strong>`;
|
||
document.getElementById('deleteBothConnections').checked = true;
|
||
} else {
|
||
reverseOption.style.display = 'none';
|
||
document.getElementById('deleteBothConnections').checked = false;
|
||
}
|
||
|
||
// Show modal
|
||
document.getElementById('deleteConnectionModal').style.display = 'flex';
|
||
}
|
||
|
||
function closeDeleteConnectionModal() {
|
||
document.getElementById('deleteConnectionModal').style.display = 'none';
|
||
pendingDeletion = null;
|
||
}
|
||
|
||
async function confirmDeleteConnection() {
|
||
if (!pendingDeletion) return;
|
||
|
||
const { fromId, toId, hasReverseConnection, reverseDirection } = pendingDeletion;
|
||
const deleteBoth = document.getElementById('deleteBothConnections').checked;
|
||
|
||
try {
|
||
// Delete the main connection
|
||
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) {
|
||
alert('Failed to delete connection: ' + data.error);
|
||
return;
|
||
}
|
||
|
||
// Delete reverse connection if requested and exists
|
||
if (deleteBoth && hasReverseConnection) {
|
||
const reverseResponse = await fetch('/api/editor/connection', {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ from: toId, to: fromId })
|
||
});
|
||
|
||
const reverseData = await reverseResponse.json();
|
||
|
||
if (reverseData.success) {
|
||
showSuccess('Both connections deleted!');
|
||
} else {
|
||
showSuccess(`Connection deleted, but reverse failed: ${reverseData.error}`);
|
||
}
|
||
} else {
|
||
showSuccess('Connection deleted!');
|
||
}
|
||
|
||
// Refresh the view
|
||
await loadConnections();
|
||
await selectLocation(fromId); // Refresh form
|
||
drawMap();
|
||
closeDeleteConnectionModal();
|
||
|
||
} 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') && !e.target.closest('.material-item') && !e.target.closest('.tool-item')) {
|
||
document.querySelectorAll('.item-dropdown, .material-dropdown, .tool-dropdown').forEach(dropdown => {
|
||
dropdown.style.display = 'none';
|
||
});
|
||
}
|
||
});
|
||
|
||
// Material dropdown functions
|
||
function showMaterialDropdown(input, prefix) {
|
||
filterMaterialDropdown(input, prefix);
|
||
}
|
||
|
||
function filterMaterialDropdown(input, prefix) {
|
||
const searchText = input.value.toLowerCase();
|
||
const dropdown = input.parentElement.querySelector('.material-dropdown');
|
||
const hiddenInput = input.parentElement.querySelector('.material-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();
|
||
input.value = `${item.emoji || '📦'} ${item.name || item.id}`;
|
||
hiddenInput.value = item.id;
|
||
dropdown.style.display = 'none';
|
||
});
|
||
|
||
dropdown.appendChild(optionDiv);
|
||
});
|
||
}
|
||
|
||
// Tool dropdown functions
|
||
function showToolDropdown(input, prefix) {
|
||
filterToolDropdown(input, prefix);
|
||
}
|
||
|
||
function filterToolDropdown(input, prefix) {
|
||
const searchText = input.value.toLowerCase();
|
||
const dropdown = input.parentElement.querySelector('.tool-dropdown');
|
||
const hiddenInput = input.parentElement.querySelector('.tool-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();
|
||
input.value = `${item.emoji || '🔧'} ${item.name || item.id}`;
|
||
hiddenInput.value = item.id;
|
||
dropdown.style.display = 'none';
|
||
});
|
||
|
||
dropdown.appendChild(optionDiv);
|
||
});
|
||
}
|
||
|
||
// Image preview update function
|
||
function updateItemImagePreview(imagePath) {
|
||
const preview = document.getElementById('itemImagePreview');
|
||
if (!preview) return;
|
||
|
||
if (imagePath && imagePath.trim()) {
|
||
preview.innerHTML = `<img src="${imagePath}" alt="Item image" style="max-width: 100%; max-height: 150px; object-fit: contain;" onerror="this.parentElement.innerHTML='<span style=\\'opacity: 0.5;\\'>Image not found</span>'">`;
|
||
} else {
|
||
preview.innerHTML = '<span style="opacity: 0.5;">No image</span>';
|
||
}
|
||
}
|
||
|
||
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('npcImagePath').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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
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 === 'players') {
|
||
loadPlayerManagement();
|
||
} else if (tabName === 'accounts') {
|
||
loadAccountManagement();
|
||
} 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="npcImagePath" value="${npc.image_path || ''}" placeholder="images/npcs/npc_name.webp" 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_path ? `<img src="${npc.image_path}" 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_path: '',
|
||
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_path: document.getElementById('npcImagePath').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;
|
||
elem.dataset.type = item.type || '';
|
||
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 categoryFilter = document.getElementById('itemCategoryFilter')?.value || 'all';
|
||
const items = document.querySelectorAll('#itemManagementList .management-item');
|
||
|
||
items.forEach(item => {
|
||
const name = item.dataset.name?.toLowerCase() || '';
|
||
const id = item.dataset.id?.toLowerCase() || '';
|
||
const type = item.dataset.type?.toLowerCase() || '';
|
||
|
||
const matchesSearch = !searchTerm || name.includes(searchTerm) || id.includes(searchTerm);
|
||
const matchesCategory = categoryFilter === 'all' || type === categoryFilter;
|
||
|
||
item.style.display = (matchesSearch && matchesCategory) ? '' : '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>
|
||
|
||
<!-- Basic Properties -->
|
||
<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="armor" ${item.type === 'armor' ? 'selected' : ''}>Armor</option>
|
||
<option value="clothing" ${item.type === 'clothing' ? 'selected' : ''}>Clothing</option>
|
||
<option value="backpack" ${item.type === 'backpack' ? 'selected' : ''}>Backpack</option>
|
||
<option value="tool" ${item.type === 'tool' ? 'selected' : ''}>Tool</option>
|
||
<option value="quest" ${item.type === 'quest' ? 'selected' : ''}>Quest Item</option>
|
||
</select>
|
||
</div>
|
||
<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>
|
||
|
||
<div class="property-group">
|
||
<label>Image Path</label>
|
||
<input type="text" id="itemImagePath" value="${item.image_path || ''}"
|
||
placeholder="images/items/example.webp"
|
||
oninput="updateItemImagePreview(this.value)">
|
||
<div class="file-input-wrapper">
|
||
<input type="file" id="itemImageUpload" accept="image/*" onchange="uploadItemImage()">
|
||
<label for="itemImageUpload" class="file-input-label">📤 Upload New Image</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="property-group">
|
||
<label>Image Preview</label>
|
||
<div id="itemImagePreview" style="min-height: 100px; background: #1a1a3e; border-radius: 4px; padding: 10px; display: flex; align-items: center; justify-content: center;">
|
||
${item.image_path ? `<img src="${item.image_path}" alt="Item image" style="max-width: 100%; max-height: 150px; object-fit: contain;" onerror="this.parentElement.innerHTML='<span style=\\'opacity: 0.5;\\'>Image not found</span>'">` : '<span style="opacity: 0.5;">No image</span>'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="property-group">
|
||
<label style="display: inline-block;">
|
||
<input type="checkbox" id="itemStackable" ${item.stackable !== false ? 'checked' : ''}>
|
||
<span style="margin-left: 5px;">Stackable</span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Consumable Properties -->
|
||
<details style="margin-top: 20px;">
|
||
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
|
||
💊 Consumable Properties
|
||
</summary>
|
||
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
|
||
<div class="property-group">
|
||
<label style="display: inline-block;">
|
||
<input type="checkbox" id="itemConsumable" ${item.type === 'consumable' ? 'checked' : ''}>
|
||
<span style="margin-left: 5px;">Consumable</span>
|
||
</label>
|
||
</div>
|
||
|
||
<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 class="property-group">
|
||
<label>Treats (medical)</label>
|
||
<input type="text" id="itemTreats" value="${item.treats || ''}" placeholder="bleeding, poisoned">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Equipment Properties -->
|
||
<details style="margin-top: 15px;">
|
||
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
|
||
⚔️ Equipment Properties
|
||
</summary>
|
||
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
|
||
<div class="property-group">
|
||
<label style="display: inline-block;">
|
||
<input type="checkbox" id="itemEquippable" ${item.equippable ? 'checked' : ''}>
|
||
<span style="margin-left: 5px;">Equippable</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>Slot</label>
|
||
<select id="itemSlot">
|
||
<option value="">None</option>
|
||
<option value="weapon" ${item.slot === 'weapon' ? 'selected' : ''}>Weapon</option>
|
||
<option value="armor" ${item.slot === 'armor' ? 'selected' : ''}>Armor</option>
|
||
<option value="head" ${item.slot === 'head' ? 'selected' : ''}>Head</option>
|
||
<option value="torso" ${item.slot === 'torso' ? 'selected' : ''}>Torso</option>
|
||
<option value="legs" ${item.slot === 'legs' ? 'selected' : ''}>Legs</option>
|
||
<option value="feet" ${item.slot === 'feet' ? 'selected' : ''}>Feet</option>
|
||
<option value="backpack" ${item.slot === 'backpack' ? 'selected' : ''}>Backpack</option>
|
||
<option value="tool" ${item.slot === 'tool' ? 'selected' : ''}>Tool</option>
|
||
</select>
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Durability</label>
|
||
<input type="number" id="itemDurability" value="${item.durability || 0}" min="0">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Tier</label>
|
||
<input type="number" id="itemTier" value="${item.tier || 0}" min="0" max="5">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Encumbrance</label>
|
||
<input type="number" id="itemEncumbrance" value="${item.encumbrance || 0}" min="0">
|
||
</div>
|
||
</div>
|
||
|
||
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Stats</h4>
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>Damage Min</label>
|
||
<input type="number" id="itemDamageMin" value="${item.stats?.damage_min || 0}" min="0">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Damage Max</label>
|
||
<input type="number" id="itemDamageMax" value="${item.stats?.damage_max || 0}" min="0">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Armor</label>
|
||
<input type="number" id="itemArmor" value="${item.stats?.armor || 0}" min="0">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>HP Bonus</label>
|
||
<input type="number" id="itemHpBonus" value="${item.stats?.hp_bonus || 0}">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Stamina Bonus</label>
|
||
<input type="number" id="itemStaminaBonus" value="${item.stats?.stamina_bonus || 0}">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Weight Capacity</label>
|
||
<input type="number" id="itemWeightCapacity" value="${item.stats?.weight_capacity || 0}" min="0">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Volume Capacity</label>
|
||
<input type="number" id="itemVolumeCapacity" value="${item.stats?.volume_capacity || 0}" min="0">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Weapon Effects -->
|
||
<details style="margin-top: 15px;">
|
||
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
|
||
💥 Weapon Effects
|
||
</summary>
|
||
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
|
||
<div id="weaponEffectsList">
|
||
${renderWeaponEffects(item.weapon_effects || {})}
|
||
</div>
|
||
<button class="btn btn-secondary" onclick="addWeaponEffect()" style="margin-top: 10px;">+ Add Effect</button>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Crafting System -->
|
||
<details style="margin-top: 15px;">
|
||
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
|
||
🔨 Crafting
|
||
</summary>
|
||
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
|
||
<div class="property-group">
|
||
<label style="display: inline-block;">
|
||
<input type="checkbox" id="itemCraftable" ${item.craftable ? 'checked' : ''}>
|
||
<span style="margin-left: 5px;">Craftable</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="property-group">
|
||
<label>Craft Level Required</label>
|
||
<input type="number" id="itemCraftLevel" value="${item.craft_level || 0}" min="0">
|
||
</div>
|
||
|
||
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Craft Materials</h4>
|
||
<div id="craftMaterialsList">
|
||
${renderMaterialsList(item.craft_materials || [], 'craft')}
|
||
</div>
|
||
<button class="btn btn-secondary" onclick="addCraftMaterial()" style="margin-top: 10px;">+ Add Material</button>
|
||
|
||
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Craft Tools</h4>
|
||
<div id="craftToolsList">
|
||
${renderToolsList(item.craft_tools || [], 'craft')}
|
||
</div>
|
||
<button class="btn btn-secondary" onclick="addCraftTool()" style="margin-top: 10px;">+ Add Tool</button>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Repair System -->
|
||
<details style="margin-top: 15px;">
|
||
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
|
||
🔧 Repair
|
||
</summary>
|
||
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
|
||
<div class="property-group">
|
||
<label style="display: inline-block;">
|
||
<input type="checkbox" id="itemRepairable" ${item.repairable ? 'checked' : ''}>
|
||
<span style="margin-left: 5px;">Repairable</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="property-group">
|
||
<label>Repair Percentage</label>
|
||
<input type="number" id="itemRepairPercentage" value="${item.repair_percentage || 0}" min="0" max="100" step="5">
|
||
</div>
|
||
|
||
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Repair Materials</h4>
|
||
<div id="repairMaterialsList">
|
||
${renderMaterialsList(item.repair_materials || [], 'repair')}
|
||
</div>
|
||
<button class="btn btn-secondary" onclick="addRepairMaterial()" style="margin-top: 10px;">+ Add Material</button>
|
||
|
||
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Repair Tools</h4>
|
||
<div id="repairToolsList">
|
||
${renderToolsList(item.repair_tools || [], 'repair')}
|
||
</div>
|
||
<button class="btn btn-secondary" onclick="addRepairTool()" style="margin-top: 10px;">+ Add Tool</button>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Uncrafting System -->
|
||
<details style="margin-top: 15px;">
|
||
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
|
||
♻️ Uncrafting (Salvage)
|
||
</summary>
|
||
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
|
||
<div class="property-group">
|
||
<label style="display: inline-block;">
|
||
<input type="checkbox" id="itemUncraftable" ${item.uncraftable ? 'checked' : ''}>
|
||
<span style="margin-left: 5px;">Uncraftable</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="property-group">
|
||
<label>Uncraft Loss Chance (0-1)</label>
|
||
<input type="number" id="itemUncraftLossChance" value="${item.uncraft_loss_chance || 0}" min="0" max="1" step="0.05">
|
||
</div>
|
||
|
||
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Uncraft Yield</h4>
|
||
<div id="uncraftYieldList">
|
||
${renderMaterialsList(item.uncraft_yield || [], 'uncraft')}
|
||
</div>
|
||
<button class="btn btn-secondary" onclick="addUncraftYield()" style="margin-top: 10px;">+ Add Yield</button>
|
||
|
||
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Uncraft Tools</h4>
|
||
<div id="uncraftToolsList">
|
||
${renderToolsList(item.uncraft_tools || [], 'uncraft')}
|
||
</div>
|
||
<button class="btn btn-secondary" onclick="addUncraftTool()" style="margin-top: 10px;">+ Add Tool</button>
|
||
</div>
|
||
</details>
|
||
|
||
<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>
|
||
`;
|
||
}
|
||
|
||
// Helper functions for rendering arrays
|
||
function renderMaterialsList(materials, prefix) {
|
||
if (!materials || materials.length === 0) {
|
||
return '<div style="opacity: 0.5; padding: 10px;">No materials</div>';
|
||
}
|
||
|
||
return materials.map((mat, index) => {
|
||
const selectedItem = availableItems.find(i => i.id === mat.item_id);
|
||
const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${selectedItem.name}` : '';
|
||
|
||
return `
|
||
<div class="material-item" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center; background: #1a1a3e; padding: 10px; border-radius: 4px;">
|
||
<div style="flex: 2; position: relative;">
|
||
<input type="text" class="material-item-search"
|
||
value="${displayValue}"
|
||
placeholder="Type to search items..."
|
||
oninput="filterMaterialDropdown(this, '${prefix}')"
|
||
onfocus="showMaterialDropdown(this, '${prefix}')"
|
||
style="width: 100%; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
|
||
<input type="hidden" class="material-item-id" id="${prefix}MaterialId${index}" value="${mat.item_id || ''}">
|
||
<div class="material-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="Quantity" value="${mat.quantity || 1}" min="1"
|
||
id="${prefix}MaterialQty${index}" style="flex: 1; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
|
||
<button class="btn btn-remove" onclick="remove${capitalize(prefix)}Material(${index})" style="width: 32px; height: 32px; padding: 0;">×</button>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderToolsList(tools, prefix) {
|
||
if (!tools || tools.length === 0) {
|
||
return '<div style="opacity: 0.5; padding: 10px;">No tools required</div>';
|
||
}
|
||
|
||
return tools.map((tool, index) => {
|
||
const selectedItem = availableItems.find(i => i.id === tool.item_id);
|
||
const displayValue = selectedItem ? `${selectedItem.emoji || '🔧'} ${selectedItem.name}` : '';
|
||
|
||
return `
|
||
<div class="tool-item" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center; background: #1a1a3e; padding: 10px; border-radius: 4px;">
|
||
<div style="flex: 2; position: relative;">
|
||
<input type="text" class="tool-item-search"
|
||
value="${displayValue}"
|
||
placeholder="Type to search tools..."
|
||
oninput="filterToolDropdown(this, '${prefix}')"
|
||
onfocus="showToolDropdown(this, '${prefix}')"
|
||
style="width: 100%; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
|
||
<input type="hidden" class="tool-item-id" id="${prefix}ToolId${index}" value="${tool.item_id || ''}">
|
||
<div class="tool-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="Durability Cost" value="${tool.durability_cost || 0}" min="0"
|
||
id="${prefix}ToolDurability${index}" style="flex: 1; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
|
||
<button class="btn btn-remove" onclick="remove${capitalize(prefix)}Tool(${index})" style="width: 32px; height: 32px; padding: 0;">×</button>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderWeaponEffects(effects) {
|
||
if (!effects || Object.keys(effects).length === 0) {
|
||
return '<div style="opacity: 0.5; padding: 10px;">No weapon effects</div>';
|
||
}
|
||
|
||
return Object.entries(effects).map(([effectName, effectData], index) => `
|
||
<div class="weapon-effect-item" style="background: #1a1a3e; padding: 10px; border-radius: 4px; margin-bottom: 10px;">
|
||
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 10px;">
|
||
<input type="text" placeholder="Effect Name" value="${effectName}"
|
||
id="weaponEffectName${index}" style="flex: 1;">
|
||
<button class="btn btn-remove" onclick="removeWeaponEffect(${index})" style="width: 32px; height: 32px; padding: 0;">×</button>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>Chance (0-1)</label>
|
||
<input type="number" id="weaponEffectChance${index}" value="${effectData.chance || 0}" min="0" max="1" step="0.05">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Damage</label>
|
||
<input type="number" id="weaponEffectDamage${index}" value="${effectData.damage || 0}" min="0">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Duration (turns)</label>
|
||
<input type="number" id="weaponEffectDuration${index}" value="${effectData.duration || 0}" min="0">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function capitalize(str) {
|
||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// Get current item to count arrays
|
||
const currentItem = availableItems.find(i => i.id === selectedItemId);
|
||
|
||
// Build complete item data object
|
||
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,
|
||
image_path: document.getElementById('itemImagePath')?.value || '',
|
||
stackable: document.getElementById('itemStackable').checked,
|
||
|
||
// Consumable properties
|
||
hp_restore: parseInt(document.getElementById('itemHpRestore')?.value) || 0,
|
||
stamina_restore: parseInt(document.getElementById('itemStaminaRestore')?.value) || 0,
|
||
treats: document.getElementById('itemTreats')?.value || '',
|
||
|
||
// Equipment properties
|
||
equippable: document.getElementById('itemEquippable')?.checked || false,
|
||
slot: document.getElementById('itemSlot')?.value || '',
|
||
durability: parseInt(document.getElementById('itemDurability')?.value) || 0,
|
||
tier: parseInt(document.getElementById('itemTier')?.value) || 0,
|
||
encumbrance: parseInt(document.getElementById('itemEncumbrance')?.value) || 0,
|
||
|
||
// Stats object
|
||
stats: {},
|
||
|
||
// Weapon effects
|
||
weapon_effects: {},
|
||
|
||
// Crafting
|
||
craftable: document.getElementById('itemCraftable')?.checked || false,
|
||
craft_level: parseInt(document.getElementById('itemCraftLevel')?.value) || 0,
|
||
craft_materials: [],
|
||
craft_tools: [],
|
||
|
||
// Repair
|
||
repairable: document.getElementById('itemRepairable')?.checked || false,
|
||
repair_percentage: parseInt(document.getElementById('itemRepairPercentage')?.value) || 0,
|
||
repair_materials: [],
|
||
repair_tools: [],
|
||
|
||
// Uncrafting
|
||
uncraftable: document.getElementById('itemUncraftable')?.checked || false,
|
||
uncraft_loss_chance: parseFloat(document.getElementById('itemUncraftLossChance')?.value) || 0,
|
||
uncraft_yield: [],
|
||
uncraft_tools: []
|
||
};
|
||
|
||
// Collect stats
|
||
const damageMin = parseInt(document.getElementById('itemDamageMin')?.value) || 0;
|
||
const damageMax = parseInt(document.getElementById('itemDamageMax')?.value) || 0;
|
||
const armor = parseInt(document.getElementById('itemArmor')?.value) || 0;
|
||
const hpBonus = parseInt(document.getElementById('itemHpBonus')?.value) || 0;
|
||
const staminaBonus = parseInt(document.getElementById('itemStaminaBonus')?.value) || 0;
|
||
const weightCapacity = parseInt(document.getElementById('itemWeightCapacity')?.value) || 0;
|
||
const volumeCapacity = parseInt(document.getElementById('itemVolumeCapacity')?.value) || 0;
|
||
|
||
if (damageMin > 0) itemData.stats.damage_min = damageMin;
|
||
if (damageMax > 0) itemData.stats.damage_max = damageMax;
|
||
if (armor > 0) itemData.stats.armor = armor;
|
||
if (hpBonus !== 0) itemData.stats.hp_bonus = hpBonus;
|
||
if (staminaBonus !== 0) itemData.stats.stamina_bonus = staminaBonus;
|
||
if (weightCapacity > 0) itemData.stats.weight_capacity = weightCapacity;
|
||
if (volumeCapacity > 0) itemData.stats.volume_capacity = volumeCapacity;
|
||
|
||
// Collect arrays
|
||
if (currentItem) {
|
||
itemData.craft_materials = collectMaterialsFromForm('craft', currentItem.craft_materials?.length || 0);
|
||
itemData.craft_tools = collectToolsFromForm('craft', currentItem.craft_tools?.length || 0);
|
||
itemData.repair_materials = collectMaterialsFromForm('repair', currentItem.repair_materials?.length || 0);
|
||
itemData.repair_tools = collectToolsFromForm('repair', currentItem.repair_tools?.length || 0);
|
||
itemData.uncraft_yield = collectMaterialsFromForm('uncraft', currentItem.uncraft_yield?.length || 0);
|
||
itemData.uncraft_tools = collectToolsFromForm('uncraft', currentItem.uncraft_tools?.length || 0);
|
||
itemData.weapon_effects = collectWeaponEffectsFromForm(Object.keys(currentItem.weapon_effects || {}).length);
|
||
}
|
||
|
||
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();
|
||
// Re-select to refresh UI with saved data
|
||
selectItem(selectedItemId);
|
||
} else {
|
||
alert('Failed to save: ' + data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Failed to save item: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// Helper functions to collect array data from form
|
||
function collectMaterialsFromForm(prefix, count) {
|
||
const materials = [];
|
||
for (let i = 0; i < count; i++) {
|
||
const idElem = document.getElementById(`${prefix}MaterialId${i}`);
|
||
const qtyElem = document.getElementById(`${prefix}MaterialQty${i}`);
|
||
|
||
if (idElem && qtyElem && idElem.value.trim()) {
|
||
materials.push({
|
||
item_id: idElem.value.trim(),
|
||
quantity: parseInt(qtyElem.value) || 1
|
||
});
|
||
}
|
||
}
|
||
return materials;
|
||
}
|
||
|
||
function collectToolsFromForm(prefix, count) {
|
||
const tools = [];
|
||
for (let i = 0; i < count; i++) {
|
||
const idElem = document.getElementById(`${prefix}ToolId${i}`);
|
||
const durElem = document.getElementById(`${prefix}ToolDurability${i}`);
|
||
|
||
if (idElem && durElem && idElem.value.trim()) {
|
||
tools.push({
|
||
item_id: idElem.value.trim(),
|
||
durability_cost: parseInt(durElem.value) || 0
|
||
});
|
||
}
|
||
}
|
||
return tools;
|
||
}
|
||
|
||
function collectWeaponEffectsFromForm(count) {
|
||
const effects = {};
|
||
for (let i = 0; i < count; i++) {
|
||
const nameElem = document.getElementById(`weaponEffectName${i}`);
|
||
const chanceElem = document.getElementById(`weaponEffectChance${i}`);
|
||
const damageElem = document.getElementById(`weaponEffectDamage${i}`);
|
||
const durationElem = document.getElementById(`weaponEffectDuration${i}`);
|
||
|
||
if (nameElem && nameElem.value.trim()) {
|
||
effects[nameElem.value.trim()] = {
|
||
chance: parseFloat(chanceElem?.value) || 0,
|
||
damage: parseInt(damageElem?.value) || 0,
|
||
duration: parseInt(durationElem?.value) || 0
|
||
};
|
||
}
|
||
}
|
||
return effects;
|
||
}
|
||
|
||
// Array management functions
|
||
function addCraftMaterial() {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item) return;
|
||
if (!item.craft_materials) item.craft_materials = [];
|
||
item.craft_materials.push({ item_id: '', quantity: 1 });
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function removeCraftMaterial(index) {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item || !item.craft_materials) return;
|
||
item.craft_materials.splice(index, 1);
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function addCraftTool() {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item) return;
|
||
if (!item.craft_tools) item.craft_tools = [];
|
||
item.craft_tools.push({ item_id: '', durability_cost: 0 });
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function removeCraftTool(index) {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item || !item.craft_tools) return;
|
||
item.craft_tools.splice(index, 1);
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function addRepairMaterial() {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item) return;
|
||
if (!item.repair_materials) item.repair_materials = [];
|
||
item.repair_materials.push({ item_id: '', quantity: 1 });
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function removeRepairMaterial(index) {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item || !item.repair_materials) return;
|
||
item.repair_materials.splice(index, 1);
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function addRepairTool() {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item) return;
|
||
if (!item.repair_tools) item.repair_tools = [];
|
||
item.repair_tools.push({ item_id: '', durability_cost: 0 });
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function removeRepairTool(index) {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item || !item.repair_tools) return;
|
||
item.repair_tools.splice(index, 1);
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function addUncraftYield() {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item) return;
|
||
if (!item.uncraft_yield) item.uncraft_yield = [];
|
||
item.uncraft_yield.push({ item_id: '', quantity: 1 });
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function removeUncraftYield(index) {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item || !item.uncraft_yield) return;
|
||
item.uncraft_yield.splice(index, 1);
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function addUncraftTool() {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item) return;
|
||
if (!item.uncraft_tools) item.uncraft_tools = [];
|
||
item.uncraft_tools.push({ item_id: '', durability_cost: 0 });
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function removeUncraftTool(index) {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item || !item.uncraft_tools) return;
|
||
item.uncraft_tools.splice(index, 1);
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function addWeaponEffect() {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item) return;
|
||
if (!item.weapon_effects) item.weapon_effects = {};
|
||
const effectName = prompt('Enter effect name (e.g., bleeding, stun):');
|
||
if (!effectName) return;
|
||
item.weapon_effects[effectName] = { chance: 0.1, damage: 1, duration: 3 };
|
||
refreshItemEditor();
|
||
}
|
||
|
||
function removeWeaponEffect(index) {
|
||
const item = availableItems.find(i => i.id === selectedItemId);
|
||
if (!item || !item.weapon_effects) return;
|
||
const effectNames = Object.keys(item.weapon_effects);
|
||
if (index < effectNames.length) {
|
||
delete item.weapon_effects[effectNames[index]];
|
||
refreshItemEditor();
|
||
}
|
||
}
|
||
|
||
// Helper function to refresh item editor while preserving details state
|
||
function refreshItemEditor() {
|
||
// Save the open state of all details elements
|
||
const editor = document.getElementById('itemEditor');
|
||
const detailsElements = editor ? editor.querySelectorAll('details') : [];
|
||
const openStates = Array.from(detailsElements).map(details => details.open);
|
||
|
||
// Refresh the UI
|
||
selectItem(selectedItemId);
|
||
|
||
// Restore the open state after a brief delay to allow DOM to update
|
||
setTimeout(() => {
|
||
const newDetailsElements = document.getElementById('itemEditor')?.querySelectorAll('details');
|
||
if (newDetailsElements) {
|
||
newDetailsElements.forEach((details, index) => {
|
||
if (openStates[index]) {
|
||
details.open = true;
|
||
}
|
||
});
|
||
}
|
||
}, 10);
|
||
}
|
||
|
||
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.webp"
|
||
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>';
|
||
}
|
||
}
|
||
|
||
async function uploadItemImage() {
|
||
const fileInput = document.getElementById('itemImageUpload');
|
||
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('itemImagePath').value = data.image_path;
|
||
updateItemImagePreview(data.image_path);
|
||
showSuccess(data.message);
|
||
} else {
|
||
alert('Upload failed: ' + data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Upload failed: ' + error.message);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// ==================== PLAYER MANAGEMENT ====================
|
||
|
||
let allPlayers = [];
|
||
let selectedPlayerId = null;
|
||
|
||
async function loadPlayerManagement() {
|
||
try {
|
||
const response = await fetch('/api/editor/players', {
|
||
credentials: 'same-origin'
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.players) {
|
||
allPlayers = data.players;
|
||
renderPlayerList();
|
||
} else {
|
||
console.error('Failed to load players:', data.error);
|
||
allPlayers = [];
|
||
renderPlayerList();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading players:', error);
|
||
allPlayers = [];
|
||
renderPlayerList();
|
||
}
|
||
}
|
||
|
||
function renderPlayerList() {
|
||
const list = document.getElementById('playerList');
|
||
if (!list) return;
|
||
|
||
list.innerHTML = '';
|
||
|
||
if (allPlayers.length === 0) {
|
||
list.innerHTML = '<div style="text-align: center; opacity: 0.5; padding: 20px;">No players found</div>';
|
||
return;
|
||
}
|
||
|
||
allPlayers.forEach(player => {
|
||
const elem = document.createElement('div');
|
||
elem.className = 'management-item';
|
||
elem.dataset.name = player.character_name;
|
||
elem.dataset.characterId = player.id;
|
||
elem.dataset.status = player.is_banned ? 'banned' : (player.premium_until ? 'premium' : 'active');
|
||
|
||
if (player.id === selectedPlayerId) {
|
||
elem.classList.add('active');
|
||
}
|
||
|
||
const statusBadge = player.is_banned ? '🚫' : (player.premium_until ? '⭐' : '');
|
||
|
||
elem.innerHTML = `
|
||
<div><strong>${statusBadge} ${player.character_name}</strong></div>
|
||
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 5px;">
|
||
ID: ${player.id} | Lvl ${player.level} | ${player.location_id}
|
||
</div>
|
||
`;
|
||
|
||
elem.onclick = () => selectPlayer(player.id);
|
||
list.appendChild(elem);
|
||
});
|
||
|
||
filterPlayerList();
|
||
}
|
||
|
||
function filterPlayerList() {
|
||
const searchTerm = document.getElementById('playerSearch')?.value.toLowerCase() || '';
|
||
const statusFilter = document.getElementById('playerStatusFilter')?.value || 'all';
|
||
const items = document.querySelectorAll('#playerList .management-item');
|
||
|
||
items.forEach(item => {
|
||
const name = item.dataset.name?.toLowerCase() || '';
|
||
const status = item.dataset.status || '';
|
||
|
||
const matchesSearch = !searchTerm || name.includes(searchTerm) || item.dataset.characterId.includes(searchTerm);
|
||
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
||
|
||
item.style.display = (matchesSearch && matchesStatus) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
async function selectPlayer(characterId) {
|
||
selectedPlayerId = characterId;
|
||
renderPlayerList();
|
||
|
||
try {
|
||
const response = await fetch(`/api/editor/player/${characterId}`, {
|
||
credentials: 'same-origin'
|
||
});
|
||
|
||
const player = await response.json();
|
||
|
||
if (player.error) {
|
||
alert('Error loading player: ' + player.error);
|
||
return;
|
||
}
|
||
|
||
renderPlayerEditor(player);
|
||
} catch (error) {
|
||
console.error('Error loading player details:', error);
|
||
alert('Failed to load player details');
|
||
}
|
||
}
|
||
|
||
function renderPlayerEditor(player) {
|
||
const editor = document.getElementById('playerEditor');
|
||
if (!editor) return;
|
||
|
||
const isBanned = player.is_banned || false;
|
||
const isPremium = player.premium_until && new Date(player.premium_until) > new Date();
|
||
|
||
editor.innerHTML = `
|
||
<h2>Edit Player: ${player.character_name}</h2>
|
||
|
||
<div style="display: flex; gap: 10px; margin-bottom: 20px; padding: 15px; background: ${isBanned ? '#4a1a1a' : '#1a1a3e'}; border-radius: 8px;">
|
||
<div style="flex: 1;">
|
||
<strong>Character ID:</strong> ${player.id}
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<strong>Account:</strong> ${player.email || 'N/A'}
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<strong>Status:</strong> ${isBanned ? '🚫 Banned' : (isPremium ? '⭐ Premium' : '✅ Active')}
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<strong>Created:</strong> ${player.character_created_at ? new Date(player.character_created_at * 1000).toLocaleDateString() : 'N/A'}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Basic Stats -->
|
||
<h3 style="color: #ffa726; margin-top: 20px;">⚔️ Character Stats</h3>
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>Character Name</label>
|
||
<input type="text" id="playerName" value="${player.character_name || ''}">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Location</label>
|
||
<input type="text" id="playerLocation" value="${player.location_id || ''}">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Level</label>
|
||
<input type="number" id="playerLevel" value="${player.level || 1}" min="1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>XP</label>
|
||
<input type="number" id="playerXP" value="${player.xp || 0}" min="0">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>HP</label>
|
||
<input type="number" id="playerHP" value="${player.hp || 100}" min="0">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Max HP</label>
|
||
<input type="number" id="playerMaxHP" value="${player.max_hp || 100}" min="1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Stamina</label>
|
||
<input type="number" id="playerStamina" value="${player.stamina || 100}" min="0">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Max Stamina</label>
|
||
<input type="number" id="playerMaxStamina" value="${player.max_stamina || 100}" min="1">
|
||
</div>
|
||
</div>
|
||
|
||
<h4 style="color: #8bc34a; margin-top: 15px;">Attributes</h4>
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>💪 Strength</label>
|
||
<input type="number" id="playerStrength" value="${player.strength || 5}" min="1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>🏃 Agility</label>
|
||
<input type="number" id="playerAgility" value="${player.agility || 5}" min="1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>❤️ Endurance</label>
|
||
<input type="number" id="playerEndurance" value="${player.endurance || 5}" min="1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>🧠 Intelligence</label>
|
||
<input type="number" id="playerIntelligence" value="${player.intelligence || 5}" min="1">
|
||
</div>
|
||
</div>
|
||
|
||
<h4 style="color: #8bc34a; margin-top: 15px;">Capacity</h4>
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>Weight Capacity</label>
|
||
<input type="number" id="playerWeightCap" value="${player.weight_capacity || 20}" min="0" step="0.1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Volume Capacity</label>
|
||
<input type="number" id="playerVolumeCap" value="${player.volume_capacity || 15}" min="0" step="0.1">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Inventory -->
|
||
<h3 style="color: #ffa726; margin-top: 30px;">🎒 Inventory (Read-Only)</h3>
|
||
<div style="max-height: 300px; overflow-y: auto; background: #0f0f1e; padding: 10px; border-radius: 4px;">
|
||
${player.inventory && player.inventory.length > 0 ?
|
||
player.inventory.map(item => `
|
||
<div style="padding: 8px; margin-bottom: 5px; background: #1a1a3e; border-radius: 4px;">
|
||
<strong>${item.item_id}</strong> x${item.quantity}
|
||
${item.unique_item_data ? ' (Unique)' : ''}
|
||
</div>
|
||
`).join('') :
|
||
'<div style="opacity: 0.5;">No items</div>'
|
||
}
|
||
</div>
|
||
|
||
<!-- Equipment -->
|
||
<h3 style="color: #ffa726; margin-top: 30px;">⚔️ Equipment (Read-Only)</h3>
|
||
<div style="background: #0f0f1e; padding: 10px; border-radius: 4px;">
|
||
${Object.keys(player.equipped || {}).length > 0 ?
|
||
Object.entries(player.equipped).map(([slot, item]) => `
|
||
<div style="padding: 8px; margin-bottom: 5px; background: #1a1a3e; border-radius: 4px;">
|
||
<strong>${slot}:</strong> ${item.item_id}
|
||
${item.unique_item_data ? ' (Unique)' : ''}
|
||
</div>
|
||
`).join('') :
|
||
'<div style="opacity: 0.5;">No equipment</div>'
|
||
}
|
||
</div>
|
||
|
||
<!-- Account Management -->
|
||
<h3 style="color: #ff5252; margin-top: 30px;">🔧 Account Management</h3>
|
||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||
${isBanned ?
|
||
`<button class="btn btn-secondary" onclick="unbanPlayer(${player.account_id})">✅ Unban Account</button>` :
|
||
`<button class="btn btn-danger" onclick="banPlayer(${player.account_id})">🚫 Ban Account</button>`
|
||
}
|
||
<button class="btn btn-warning" onclick="resetPlayer(${player.id})">🔄 Reset Player</button>
|
||
<button class="btn btn-danger" onclick="deletePlayer(${player.account_id})">🗑️ Delete Account</button>
|
||
</div>
|
||
|
||
${isBanned && player.ban_reason ? `
|
||
<div style="margin-top: 10px; padding: 10px; background: #4a1a1a; border-radius: 4px;">
|
||
<strong>Ban Reason:</strong> ${player.ban_reason}
|
||
</div>
|
||
` : ''}
|
||
|
||
<!-- Save Button -->
|
||
<div style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #3a3a6a;">
|
||
<button class="btn btn-primary" style="width: 100%;" onclick="savePlayer()">💾 Save Player Stats</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function savePlayer() {
|
||
if (!selectedPlayerId) return;
|
||
|
||
const playerData = {
|
||
character_name: document.getElementById('playerName').value,
|
||
location_id: document.getElementById('playerLocation').value,
|
||
level: parseInt(document.getElementById('playerLevel').value),
|
||
xp: parseInt(document.getElementById('playerXP').value),
|
||
hp: parseInt(document.getElementById('playerHP').value),
|
||
max_hp: parseInt(document.getElementById('playerMaxHP').value),
|
||
stamina: parseInt(document.getElementById('playerStamina').value),
|
||
max_stamina: parseInt(document.getElementById('playerMaxStamina').value),
|
||
strength: parseInt(document.getElementById('playerStrength').value),
|
||
agility: parseInt(document.getElementById('playerAgility').value),
|
||
endurance: parseInt(document.getElementById('playerEndurance').value),
|
||
intelligence: parseInt(document.getElementById('playerIntelligence').value),
|
||
weight_capacity: parseFloat(document.getElementById('playerWeightCap').value),
|
||
volume_capacity: parseFloat(document.getElementById('playerVolumeCap').value)
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`/api/editor/player/${selectedPlayerId}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(playerData)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showSuccess('Player saved successfully!');
|
||
await loadPlayerManagement();
|
||
} else {
|
||
alert('Failed to save: ' + data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Failed to save player: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function banPlayer(telegramId) {
|
||
const reason = prompt('Enter ban reason:');
|
||
if (!reason) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/editor/account/${telegramId}/ban`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ is_banned: true, ban_reason: reason })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showSuccess('Account banned successfully!');
|
||
await loadPlayerManagement();
|
||
selectPlayer(telegramId);
|
||
} else {
|
||
alert('Failed to ban: ' + data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Failed to ban account: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function unbanPlayer(telegramId) {
|
||
try {
|
||
const response = await fetch(`/api/editor/account/${telegramId}/ban`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ is_banned: false })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showSuccess('Account unbanned successfully!');
|
||
await loadPlayerManagement();
|
||
selectPlayer(telegramId);
|
||
} else {
|
||
alert('Failed to unban: ' + data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Failed to unban account: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function resetPlayer(telegramId) {
|
||
if (!confirm('Reset this player to starting state? This will clear inventory and equipment!')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/editor/player/${telegramId}/reset`, {
|
||
method: 'POST',
|
||
credentials: 'same-origin'
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showSuccess('Player reset successfully!');
|
||
await loadPlayerManagement();
|
||
selectPlayer(telegramId);
|
||
} else {
|
||
alert('Failed to reset: ' + data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Failed to reset player: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function deletePlayer(accountId) {
|
||
const playerName = allPlayers.find(p => p.account_id === accountId)?.character_name || 'this player';
|
||
|
||
if (!confirm(`DELETE ${playerName} and ALL associated data? This CANNOT be undone!`)) {
|
||
return;
|
||
}
|
||
|
||
if (!confirm('Are you ABSOLUTELY SURE? This will permanently delete the account!')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/editor/account/${telegramId}/delete`, {
|
||
method: 'DELETE',
|
||
credentials: 'same-origin'
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showSuccess('Account deleted successfully!');
|
||
selectedPlayerId = null;
|
||
document.getElementById('playerEditor').innerHTML = '<div style="text-align: center; opacity: 0.5; padding: 50px;"><h3>Select a player to edit</h3></div>';
|
||
await loadPlayerManagement();
|
||
} else {
|
||
alert('Failed to delete: ' + data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Failed to delete account: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// ==================== ACCOUNT MANAGEMENT ====================
|
||
|
||
let allAccounts = [];
|
||
let selectedAccountId = null;
|
||
|
||
async function loadAccountManagement() {
|
||
try {
|
||
const response = await fetch('/api/editor/accounts', {
|
||
credentials: 'same-origin'
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.accounts) {
|
||
allAccounts = data.accounts;
|
||
renderAccountList();
|
||
} else {
|
||
console.error('Failed to load accounts:', data.error);
|
||
allAccounts = [];
|
||
renderAccountList();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading accounts:', error);
|
||
allAccounts = [];
|
||
renderAccountList();
|
||
}
|
||
}
|
||
|
||
function renderAccountList() {
|
||
const list = document.getElementById('accountList');
|
||
if (!list) return;
|
||
|
||
list.innerHTML = '';
|
||
|
||
if (allAccounts.length === 0) {
|
||
list.innerHTML = '<div style="text-align: center; opacity: 0.5; padding: 20px;">No accounts found</div>';
|
||
return;
|
||
}
|
||
|
||
allAccounts.forEach(account => {
|
||
const elem = document.createElement('div');
|
||
elem.className = 'management-item';
|
||
elem.dataset.email = account.email.toLowerCase();
|
||
elem.dataset.accountId = account.id;
|
||
|
||
if (account.id === selectedAccountId) {
|
||
elem.classList.add('active');
|
||
}
|
||
|
||
const premiumBadge = account.is_premium ? '⭐' : '';
|
||
|
||
elem.innerHTML = `
|
||
<div><strong>${premiumBadge} ${account.email}</strong></div>
|
||
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 5px;">
|
||
${account.character_count} character${account.character_count !== 1 ? 's' : ''}
|
||
</div>
|
||
`;
|
||
|
||
elem.onclick = () => selectAccount(account.id);
|
||
list.appendChild(elem);
|
||
});
|
||
|
||
filterAccountList();
|
||
}
|
||
|
||
function filterAccountList() {
|
||
const searchTerm = document.getElementById('accountSearch')?.value.toLowerCase() || '';
|
||
const items = document.querySelectorAll('#accountList .management-item');
|
||
|
||
items.forEach(item => {
|
||
const email = item.dataset.email || '';
|
||
const matchesSearch = !searchTerm || email.includes(searchTerm) || item.dataset.accountId.includes(searchTerm);
|
||
item.style.display = matchesSearch ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
async function selectAccount(accountId) {
|
||
selectedAccountId = accountId;
|
||
renderAccountList();
|
||
|
||
try {
|
||
const response = await fetch(`/api/editor/account/${accountId}`, {
|
||
credentials: 'same-origin'
|
||
});
|
||
|
||
const account = await response.json();
|
||
|
||
if (account.error) {
|
||
alert('Error loading account: ' + account.error);
|
||
return;
|
||
}
|
||
|
||
renderAccountEditor(account);
|
||
} catch (error) {
|
||
console.error('Error loading account details:', error);
|
||
alert('Failed to load account details');
|
||
}
|
||
}
|
||
|
||
function renderAccountEditor(account) {
|
||
const editor = document.getElementById('accountEditor');
|
||
if (!editor) return;
|
||
|
||
const isPremium = account.premium_expires_at && account.premium_expires_at > Date.now() / 1000;
|
||
const premiumDate = account.premium_expires_at ? new Date(account.premium_expires_at * 1000).toLocaleDateString() : 'None';
|
||
|
||
editor.innerHTML = `
|
||
<h2>Edit Account: ${account.email}</h2>
|
||
|
||
<div style="display: flex; gap: 10px; margin-bottom: 20px; padding: 15px; background: #1a1a3e; border-radius: 8px;">
|
||
<div style="flex: 1;">
|
||
<strong>Account ID:</strong> ${account.id}
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<strong>Status:</strong> ${isPremium ? '⭐ Premium' : '🆓 Free'}
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<strong>Created:</strong> ${account.created_at ? new Date(account.created_at * 1000).toLocaleDateString() : 'N/A'}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Account Details -->
|
||
<h3 style="color: #ffa726; margin-top: 20px;">📧 Account Details</h3>
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>Email</label>
|
||
<input type="email" id="accountEmail" value="${account.email || ''}">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Premium Expires (Unix Timestamp)</label>
|
||
<input type="number" id="accountPremiumExpires" value="${account.premium_expires_at || ''}" placeholder="Leave empty for free">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Characters List -->
|
||
<h3 style="color: #ffa726; margin-top: 30px;">👥 Characters (${account.characters.length})</h3>
|
||
<div style="max-height: 400px; overflow-y: auto; background: #0f0f1e; padding: 10px; border-radius: 4px; margin-bottom: 20px;">
|
||
${account.characters.length > 0 ?
|
||
account.characters.map(char => `
|
||
<div style="padding: 12px; margin-bottom: 8px; background: #1a1a3e; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||
<div>
|
||
<strong>${char.name}</strong> ${char.is_dead ? '💀' : ''}
|
||
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 4px;">
|
||
Level ${char.level} | ${char.location_id} | HP: ${char.hp}/${char.max_hp}
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-secondary" onclick="viewCharacter(${char.id})">
|
||
👤 View Character
|
||
</button>
|
||
</div>
|
||
`).join('') :
|
||
'<div style="opacity: 0.5; text-align: center; padding: 20px;">No characters</div>'
|
||
}
|
||
</div>
|
||
|
||
<!-- Save Button -->
|
||
<div style="margin-top: 20px; padding-top: 20px; border-top: 2px solid #3a3a6a;">
|
||
<button class="btn btn-primary" style="width: 100%;" onclick="saveAccount()">💾 Save Account</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function saveAccount() {
|
||
if (!selectedAccountId) return;
|
||
|
||
const accountData = {
|
||
email: document.getElementById('accountEmail').value,
|
||
premium_expires_at: parseFloat(document.getElementById('accountPremiumExpires').value) || null
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`/api/editor/account/${selectedAccountId}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(accountData)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showSuccess('Account saved successfully!');
|
||
await loadAccountManagement();
|
||
} else {
|
||
alert('Failed to save: ' + data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Failed to save account: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// Cross-navigation functions
|
||
function viewCharacter(characterId) {
|
||
switchTab('players');
|
||
setTimeout(() => selectPlayer(characterId), 100);
|
||
}
|
||
|
||
function viewAccount(accountId) {
|
||
switchTab('accounts');
|
||
setTimeout(() => selectAccount(accountId), 100);
|
||
}
|
||
|
||
// ==================== EDITABLE INVENTORY ====================
|
||
|
||
let availableItemsData = null;
|
||
|
||
async function loadAvailableItemsData() {
|
||
if (availableItemsData) return availableItemsData;
|
||
|
||
try {
|
||
const response = await fetch('/gamedata/items.json');
|
||
availableItemsData = await response.json();
|
||
return availableItemsData;
|
||
} catch (error) {
|
||
console.error('Error loading items:', error);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function renderEditableInventory(inventory) {
|
||
return `
|
||
<div id="inventoryRows">
|
||
${inventory.map((item, index) => renderInventoryRow(item, index)).join('')}
|
||
</div>
|
||
<button class="btn btn-add" onclick="addInventoryRow()" style="margin-top: 10px;">+ Add Item</button>
|
||
<button class="btn btn-primary" onclick="saveInventory()" style="margin-top: 10px; margin-left: 10px;">💾 Save Inventory</button>
|
||
`;
|
||
}
|
||
|
||
function renderInventoryRow(item, index) {
|
||
return `
|
||
<div class="inventory-row" data-index="${index}" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center; background: #1a1a3e; padding: 10px; border-radius: 4px;">
|
||
<input type="text" placeholder="item_id" value="${item.item_id || ''}"
|
||
class="inv-item-id" style="flex: 2; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;"
|
||
list="itemsList">
|
||
<input type="number" placeholder="Qty" value="${item.quantity || 1}" min="1"
|
||
class="inv-quantity" style="flex: 1; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
|
||
<label style="flex: 0 0 auto; display: flex; align-items: center; gap: 5px;">
|
||
<input type="checkbox" class="inv-equipped" ${item.is_equipped ? 'checked' : ''}>
|
||
Equipped
|
||
</label>
|
||
<button class="btn btn-remove" onclick="removeInventoryRow(${index})" style="width: 32px; height: 32px; padding: 0;">×</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function addInventoryRow() {
|
||
const container = document.getElementById('inventoryRows');
|
||
if (!container) return;
|
||
|
||
const currentRows = container.querySelectorAll('.inventory-row').length;
|
||
const newRow = renderInventoryRow({ item_id: '', quantity: 1, is_equipped: false }, currentRows);
|
||
|
||
container.insertAdjacentHTML('beforeend', newRow);
|
||
}
|
||
|
||
function removeInventoryRow(index) {
|
||
const row = document.querySelector(`.inventory-row[data-index="${index}"]`);
|
||
if (row) {
|
||
row.remove();
|
||
}
|
||
}
|
||
|
||
async function saveInventory() {
|
||
if (!selectedPlayerId) return;
|
||
|
||
const rows = document.querySelectorAll('.inventory-row');
|
||
const inventory = [];
|
||
|
||
rows.forEach(row => {
|
||
const itemId = row.querySelector('.inv-item-id').value.trim();
|
||
const quantity = parseInt(row.querySelector('.inv-quantity').value) || 1;
|
||
const isEquipped = row.querySelector('.inv-equipped').checked;
|
||
|
||
if (itemId) {
|
||
inventory.push({
|
||
item_id: itemId,
|
||
quantity: quantity,
|
||
is_equipped: isEquipped
|
||
});
|
||
}
|
||
});
|
||
|
||
try {
|
||
const response = await fetch(`/api/editor/player/${selectedPlayerId}/inventory`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ inventory: inventory })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showSuccess('Inventory saved successfully!');
|
||
// Reload player to show updated inventory
|
||
selectPlayer(selectedPlayerId);
|
||
} else {
|
||
alert('Failed to save inventory: ' + data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Failed to save inventory: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// Update renderPlayerEditor to use editable inventory and add account link
|
||
const originalRenderPlayerEditor = renderPlayerEditor;
|
||
renderPlayerEditor = function (player) {
|
||
const editor = document.getElementById('playerEditor');
|
||
if (!editor) return;
|
||
|
||
const isBanned = player.is_banned || false;
|
||
const isPremium = player.premium_expires_at && player.premium_expires_at > Date.now() / 1000;
|
||
|
||
editor.innerHTML = `
|
||
<h2>Edit Player: ${player.character_name || player.name}</h2>
|
||
|
||
${player.account_id ? `
|
||
<div style="margin-bottom: 15px;">
|
||
<a href="#" onclick="viewAccount(${player.account_id}); return false;" style="color: #64b5f6; text-decoration: none;">
|
||
📧 View Account: ${player.email || 'Account #' + player.account_id}
|
||
</a>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div style="display: flex; gap: 10px; margin-bottom: 20px; padding: 15px; background: ${isBanned ? '#4a1a1a' : '#1a1a3e'}; border-radius: 8px;">
|
||
<div style="flex: 1;">
|
||
<strong>Character ID:</strong> ${player.id}
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<strong>Account:</strong> ${player.email || 'N/A'}
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<strong>Status:</strong> ${isBanned ? '🚫 Banned' : (isPremium ? '⭐ Premium' : '✅ Active')}
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<strong>Created:</strong> ${player.character_created_at ? new Date(player.character_created_at * 1000).toLocaleDateString() : 'N/A'}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Basic Stats -->
|
||
<h3 style="color: #ffa726; margin-top: 20px;">⚔️ Character Stats</h3>
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>Character Name</label>
|
||
<input type="text" id="playerName" value="${player.character_name || player.name || ''}">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Location</label>
|
||
<input type="text" id="playerLocation" value="${player.location_id || ''}">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Level</label>
|
||
<input type="number" id="playerLevel" value="${player.level || 1}" min="1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>XP</label>
|
||
<input type="number" id="playerXP" value="${player.xp || 0}" min="0">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>HP</label>
|
||
<input type="number" id="playerHP" value="${player.hp || 100}" min="0">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Max HP</label>
|
||
<input type="number" id="playerMaxHP" value="${player.max_hp || 100}" min="1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Stamina</label>
|
||
<input type="number" id="playerStamina" value="${player.stamina || 100}" min="0">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Max Stamina</label>
|
||
<input type="number" id="playerMaxStamina" value="${player.max_stamina || 100}" min="1">
|
||
</div>
|
||
</div>
|
||
|
||
<h4 style="color: #8bc34a; margin-top: 15px;">Attributes</h4>
|
||
<div class="form-grid">
|
||
<div class="property-group">
|
||
<label>💪 Strength</label>
|
||
<input type="number" id="playerStrength" value="${player.strength || 5}" min="1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>🏃 Agility</label>
|
||
<input type="number" id="playerAgility" value="${player.agility || 5}" min="1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>❤️ Endurance</label>
|
||
<input type="number" id="playerEndurance" value="${player.endurance || 5}" min="1">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>🧠 Intelligence</label>
|
||
<input type="number" id="playerIntelligence" value="${player.intellect || 5}" min="1">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Editable Inventory -->
|
||
<h3 style="color: #ffa726; margin-top: 30px;">🎒 Inventory</h3>
|
||
<datalist id="itemsList">
|
||
${Object.keys(availableItemsData || {}).map(itemId => `<option value="${itemId}">${itemId}</option>`).join('')}
|
||
</datalist>
|
||
${renderEditableInventory(player.inventory || [])}
|
||
|
||
<!-- Equipment (from inventory) -->
|
||
<h3 style="color: #ffa726; margin-top: 30px;">⚔️ Equipped Items</h3>
|
||
<div style="background: #0f0f1e; padding: 10px; border-radius: 4px; margin-bottom: 20px;">
|
||
${player.equipped && Object.keys(player.equipped).length > 0 ?
|
||
Object.entries(player.equipped).map(([slot, item]) => `
|
||
<div style="padding: 8px; margin-bottom: 5px; background: #1a1a3e; border-radius: 4px;">
|
||
<strong>${item.item_id}</strong> x${item.quantity}
|
||
</div>
|
||
`).join('') :
|
||
'<div style="opacity: 0.5;">No equipment (use "Equipped" checkbox in inventory)</div>'
|
||
}
|
||
</div>
|
||
|
||
<!-- Account Management -->
|
||
<h3 style="color: #ff5252; margin-top: 30px;">🔧 Account Management</h3>
|
||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||
${isBanned ?
|
||
`<button class="btn btn-secondary" onclick="unbanPlayer(${player.account_id})">✅ Unban Account</button>` :
|
||
`<button class="btn btn-danger" onclick="banPlayer(${player.account_id})">🚫 Ban Account</button>`
|
||
}
|
||
<button class="btn btn-warning" onclick="resetPlayer(${player.id})">🔄 Reset Player</button>
|
||
<button class="btn btn-danger" onclick="deletePlayer(${player.account_id})">🗑️ Delete Account</button>
|
||
</div>
|
||
|
||
${isBanned && player.ban_reason ? `
|
||
<div style="margin-top: 10px; padding: 10px; background: #4a1a1a; border-radius: 4px;">
|
||
<strong>Ban Reason:</strong> ${player.ban_reason}
|
||
</div>
|
||
` : ''}
|
||
|
||
<!-- Save Button -->
|
||
<div style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #3a3a6a;">
|
||
<button class="btn btn-primary" style="width: 100%;" onclick="savePlayer()">💾 Save Player Stats</button>
|
||
</div>
|
||
`;
|
||
|
||
// Load items data for datalist
|
||
loadAvailableItemsData();
|
||
};
|