604 lines
21 KiB
JavaScript
604 lines
21 KiB
JavaScript
// Map visualization with pan, zoom, and interactive features
|
|
let canvas, ctx;
|
|
let mapData = { locations: [], connections: [], interactables: [], spawn_tables: {} };
|
|
let viewOffset = { x: 0, y: 0 };
|
|
let viewScale = 1.0;
|
|
let isDragging = false;
|
|
let lastMouse = { x: 0, y: 0 };
|
|
let showLabels = true;
|
|
let selectedLocation = null;
|
|
|
|
// Visual settings
|
|
const gridSize = 100; // pixels per game unit
|
|
const nodeRadius = 12;
|
|
const colors = {
|
|
background: '#0a0a1a',
|
|
grid: '#1a1a2e',
|
|
connection: '#3a3a6a',
|
|
nodeSafe: '#4fc3f7',
|
|
nodeLowDanger: '#ffa726',
|
|
nodeMediumDanger: '#ff7043',
|
|
nodeHighDanger: '#e53935',
|
|
nodeSelected: '#00ff88',
|
|
text: '#ffffff',
|
|
label: '#e0e0e0'
|
|
};
|
|
|
|
// Initialize
|
|
window.addEventListener('load', async () => {
|
|
canvas = document.getElementById('mapCanvas');
|
|
ctx = canvas.getContext('2d');
|
|
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
|
|
// Load map data
|
|
await loadMapData();
|
|
|
|
// Event listeners
|
|
canvas.addEventListener('mousedown', onMouseDown);
|
|
canvas.addEventListener('mousemove', onMouseMove);
|
|
canvas.addEventListener('mouseup', onMouseUp);
|
|
canvas.addEventListener('mouseleave', onMouseUp);
|
|
canvas.addEventListener('wheel', onWheel);
|
|
canvas.addEventListener('click', onClick);
|
|
|
|
// Touch event listeners for mobile
|
|
canvas.addEventListener('touchstart', onTouchStart, { passive: false });
|
|
canvas.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
canvas.addEventListener('touchend', onTouchEnd);
|
|
|
|
document.getElementById('zoomIn').addEventListener('click', () => zoom(1.2));
|
|
document.getElementById('zoomOut').addEventListener('click', () => zoom(0.8));
|
|
document.getElementById('resetView').addEventListener('click', resetView);
|
|
document.getElementById('toggleLabels').addEventListener('click', toggleLabels);
|
|
|
|
// Image modal setup
|
|
setupImageModal();
|
|
|
|
draw();
|
|
});
|
|
|
|
function resizeCanvas() {
|
|
canvas.width = canvas.clientWidth;
|
|
canvas.height = canvas.clientHeight;
|
|
draw();
|
|
}
|
|
|
|
async function loadMapData() {
|
|
try {
|
|
const response = await fetch('/map_data.json');
|
|
mapData = await response.json();
|
|
centerView();
|
|
updateStatistics();
|
|
draw();
|
|
} catch (error) {
|
|
console.error('Failed to load map data:', error);
|
|
}
|
|
}
|
|
|
|
function updateStatistics() {
|
|
document.getElementById('totalLocations').textContent = mapData.locations.length;
|
|
document.getElementById('totalConnections').textContent = mapData.connections.length;
|
|
document.getElementById('totalInteractables').textContent = mapData.interactables.length;
|
|
|
|
const uniqueEnemies = new Set();
|
|
Object.values(mapData.spawn_tables).forEach(enemies => {
|
|
enemies.forEach(enemy => uniqueEnemies.add(enemy.npc_id));
|
|
});
|
|
document.getElementById('totalEnemies').textContent = uniqueEnemies.size;
|
|
}
|
|
|
|
function worldToScreen(x, y) {
|
|
return {
|
|
x: (x * gridSize * viewScale) + viewOffset.x + canvas.width / 2,
|
|
y: (-y * gridSize * viewScale) + viewOffset.y + canvas.height / 2
|
|
};
|
|
}
|
|
|
|
function screenToWorld(sx, sy) {
|
|
return {
|
|
x: ((sx - canvas.width / 2 - viewOffset.x) / gridSize) / viewScale,
|
|
y: -((sy - canvas.height / 2 - viewOffset.y) / gridSize) / viewScale
|
|
};
|
|
}
|
|
|
|
function centerView() {
|
|
if (mapData.locations.length === 0) return;
|
|
|
|
// Calculate bounds
|
|
let minX = Infinity, maxX = -Infinity;
|
|
let minY = Infinity, maxY = -Infinity;
|
|
|
|
mapData.locations.forEach(loc => {
|
|
minX = Math.min(minX, loc.x);
|
|
maxX = Math.max(maxX, loc.x);
|
|
minY = Math.min(minY, loc.y);
|
|
maxY = Math.max(maxY, loc.y);
|
|
});
|
|
|
|
const centerX = (minX + maxX) / 2;
|
|
const centerY = (minY + maxY) / 2;
|
|
|
|
viewOffset.x = -centerX * gridSize * viewScale;
|
|
viewOffset.y = centerY * gridSize * viewScale;
|
|
}
|
|
|
|
function resetView() {
|
|
viewScale = 1.0;
|
|
centerView();
|
|
draw();
|
|
}
|
|
|
|
function toggleLabels() {
|
|
showLabels = !showLabels;
|
|
draw();
|
|
}
|
|
|
|
function zoom(factor) {
|
|
const oldScale = viewScale;
|
|
viewScale *= factor;
|
|
viewScale = Math.max(0.3, Math.min(3.0, viewScale));
|
|
|
|
const scaleDiff = viewScale - oldScale;
|
|
viewOffset.x -= (canvas.width / 2) * scaleDiff / oldScale;
|
|
viewOffset.y -= (canvas.height / 2) * scaleDiff / oldScale;
|
|
|
|
draw();
|
|
}
|
|
|
|
function onMouseDown(e) {
|
|
isDragging = true;
|
|
lastMouse = { x: e.clientX, y: e.clientY };
|
|
canvas.style.cursor = 'grabbing';
|
|
}
|
|
|
|
function onMouseMove(e) {
|
|
if (isDragging) {
|
|
const dx = e.clientX - lastMouse.x;
|
|
const dy = e.clientY - lastMouse.y;
|
|
viewOffset.x += dx;
|
|
viewOffset.y += dy;
|
|
lastMouse = { x: e.clientX, y: e.clientY };
|
|
draw();
|
|
}
|
|
}
|
|
|
|
function onMouseUp() {
|
|
isDragging = false;
|
|
canvas.style.cursor = 'grab';
|
|
}
|
|
|
|
// Touch event handlers for mobile
|
|
let lastTouch = { x: 0, y: 0 };
|
|
let touchStartTime = 0;
|
|
let lastTouchDistance = 0;
|
|
|
|
function onTouchStart(e) {
|
|
e.preventDefault();
|
|
|
|
if (e.touches.length === 1) {
|
|
// Single touch - pan
|
|
isDragging = true;
|
|
const touch = e.touches[0];
|
|
lastTouch = { x: touch.clientX, y: touch.clientY };
|
|
lastMouse = { x: touch.clientX, y: touch.clientY };
|
|
touchStartTime = Date.now();
|
|
} else if (e.touches.length === 2) {
|
|
// Two touches - pinch zoom
|
|
isDragging = false;
|
|
const touch1 = e.touches[0];
|
|
const touch2 = e.touches[1];
|
|
lastTouchDistance = Math.sqrt(
|
|
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
|
Math.pow(touch2.clientY - touch1.clientY, 2)
|
|
);
|
|
}
|
|
}
|
|
|
|
function onTouchMove(e) {
|
|
e.preventDefault();
|
|
|
|
if (e.touches.length === 1 && isDragging) {
|
|
// Single touch - pan
|
|
const touch = e.touches[0];
|
|
const dx = touch.clientX - lastTouch.x;
|
|
const dy = touch.clientY - lastTouch.y;
|
|
viewOffset.x += dx;
|
|
viewOffset.y += dy;
|
|
lastTouch = { x: touch.clientX, y: touch.clientY };
|
|
draw();
|
|
} else if (e.touches.length === 2) {
|
|
// Two touches - pinch zoom
|
|
const touch1 = e.touches[0];
|
|
const touch2 = e.touches[1];
|
|
const newDistance = Math.sqrt(
|
|
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
|
Math.pow(touch2.clientY - touch1.clientY, 2)
|
|
);
|
|
|
|
if (lastTouchDistance > 0) {
|
|
const zoomFactor = newDistance / lastTouchDistance;
|
|
|
|
// Calculate center point between two touches
|
|
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
|
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mouseX = centerX - rect.left;
|
|
const mouseY = centerY - rect.top;
|
|
|
|
const worldBefore = screenToWorld(mouseX, mouseY);
|
|
zoom(zoomFactor);
|
|
const worldAfter = screenToWorld(mouseX, mouseY);
|
|
|
|
viewOffset.x += (worldAfter.x - worldBefore.x) * gridSize * viewScale;
|
|
viewOffset.y -= (worldAfter.y - worldBefore.y) * gridSize * viewScale;
|
|
}
|
|
|
|
lastTouchDistance = newDistance;
|
|
draw();
|
|
}
|
|
}
|
|
|
|
function onTouchEnd(e) {
|
|
if (e.touches.length === 0) {
|
|
isDragging = false;
|
|
lastTouchDistance = 0;
|
|
|
|
// Check if it was a quick tap (< 200ms) for click action
|
|
const touchDuration = Date.now() - touchStartTime;
|
|
if (touchDuration < 200 && e.changedTouches.length === 1) {
|
|
const touch = e.changedTouches[0];
|
|
// Simulate click event
|
|
const clickEvent = {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY
|
|
};
|
|
onClick(clickEvent);
|
|
}
|
|
} else if (e.touches.length === 1) {
|
|
// One finger remaining, reset for pan
|
|
const touch = e.touches[0];
|
|
lastTouch = { x: touch.clientX, y: touch.clientY };
|
|
isDragging = true;
|
|
lastTouchDistance = 0;
|
|
}
|
|
}
|
|
|
|
function onWheel(e) {
|
|
e.preventDefault();
|
|
const zoomSpeed = 0.001;
|
|
const delta = -e.deltaY * zoomSpeed;
|
|
const factor = 1 + delta;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
const worldBefore = screenToWorld(mouseX, mouseY);
|
|
zoom(factor);
|
|
const worldAfter = screenToWorld(mouseX, mouseY);
|
|
|
|
viewOffset.x += (worldAfter.x - worldBefore.x) * gridSize * viewScale;
|
|
viewOffset.y -= (worldAfter.y - worldBefore.y) * gridSize * viewScale;
|
|
|
|
draw();
|
|
}
|
|
|
|
function onClick(e) {
|
|
if (isDragging) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
const worldPos = screenToWorld(mouseX, mouseY);
|
|
|
|
// Find clicked location
|
|
for (const location of mapData.locations) {
|
|
const dist = Math.sqrt(
|
|
Math.pow(location.x - worldPos.x, 2) +
|
|
Math.pow(location.y - worldPos.y, 2)
|
|
);
|
|
|
|
if (dist < 0.3) { // Click threshold
|
|
selectedLocation = location;
|
|
showLocationInfo(location);
|
|
draw();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function getDangerLevel(locationId) {
|
|
const spawns = mapData.spawn_tables[locationId];
|
|
if (!spawns || spawns.length === 0) return 'safe';
|
|
|
|
// Calculate average enemy strength
|
|
let totalXP = 0;
|
|
spawns.forEach(enemy => {
|
|
totalXP += enemy.xp_reward * (enemy.spawn_weight / 100);
|
|
});
|
|
|
|
if (totalXP < 20) return 'low';
|
|
if (totalXP < 40) return 'medium';
|
|
return 'high';
|
|
}
|
|
|
|
function getNodeColor(locationId) {
|
|
const danger = getDangerLevel(locationId);
|
|
switch (danger) {
|
|
case 'safe': return colors.nodeSafe;
|
|
case 'low': return colors.nodeLowDanger;
|
|
case 'medium': return colors.nodeMediumDanger;
|
|
case 'high': return colors.nodeHighDanger;
|
|
default: return colors.nodeSafe;
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
// Clear canvas
|
|
ctx.fillStyle = colors.background;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw grid
|
|
drawGrid();
|
|
|
|
// Draw connections
|
|
ctx.lineWidth = 2 * viewScale;
|
|
mapData.connections.forEach(conn => {
|
|
const from = mapData.locations.find(l => l.id === conn.from);
|
|
const to = mapData.locations.find(l => l.id === conn.to);
|
|
|
|
if (from && to) {
|
|
const fromScreen = worldToScreen(from.x, from.y);
|
|
const toScreen = worldToScreen(to.x, to.y);
|
|
|
|
ctx.strokeStyle = colors.connection;
|
|
ctx.beginPath();
|
|
ctx.moveTo(fromScreen.x, fromScreen.y);
|
|
ctx.lineTo(toScreen.x, toScreen.y);
|
|
ctx.stroke();
|
|
|
|
// Draw distance label
|
|
if (showLabels && viewScale > 0.6) {
|
|
const midX = (fromScreen.x + toScreen.x) / 2;
|
|
const midY = (fromScreen.y + toScreen.y) / 2;
|
|
const stamina = Math.ceil(conn.distance * 3);
|
|
|
|
ctx.fillStyle = 'rgba(42, 42, 74, 0.9)';
|
|
ctx.fillRect(midX - 20, midY - 10, 40, 20);
|
|
|
|
ctx.fillStyle = colors.label;
|
|
ctx.font = `${10 * viewScale}px sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(`${stamina}⚡`, midX, midY);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Draw locations
|
|
mapData.locations.forEach(location => {
|
|
const screen = worldToScreen(location.x, location.y);
|
|
const radius = nodeRadius * viewScale;
|
|
|
|
// Node circle
|
|
const isSelected = selectedLocation && selectedLocation.id === location.id;
|
|
ctx.fillStyle = isSelected ? colors.nodeSelected : getNodeColor(location.id);
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineWidth = isSelected ? 3 * viewScale : 2 * viewScale;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(screen.x, screen.y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
// Interactable indicator
|
|
if (location.interactable_count > 0) {
|
|
ctx.fillStyle = '#ffd700';
|
|
ctx.font = `${8 * viewScale}px sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(location.interactable_count, screen.x, screen.y);
|
|
}
|
|
|
|
// Label
|
|
if (showLabels) {
|
|
ctx.fillStyle = colors.label;
|
|
ctx.font = `${12 * viewScale}px sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(location.name, screen.x, screen.y + radius + 5);
|
|
}
|
|
});
|
|
}
|
|
|
|
function drawGrid() {
|
|
ctx.strokeStyle = colors.grid;
|
|
ctx.lineWidth = 1;
|
|
|
|
const step = gridSize * viewScale;
|
|
const startX = (viewOffset.x % step) - step;
|
|
const startY = (viewOffset.y % step) - step;
|
|
|
|
for (let x = startX; x < canvas.width; x += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, canvas.height);
|
|
ctx.stroke();
|
|
}
|
|
|
|
for (let y = startY; y < canvas.height; y += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(canvas.width, y);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function showLocationInfo(location) {
|
|
// Location details
|
|
const locationHTML = `
|
|
${location.image_path ?
|
|
`<img src="/${location.image_path}" class="location-image" onerror="this.style.display='none';" />` :
|
|
`<div class="image-placeholder">🗺️</div>`
|
|
}
|
|
<h3>${location.name}</h3>
|
|
<div class="description">${location.description}</div>
|
|
<p><strong>Coordinates:</strong> (${location.x}, ${location.y})</p>
|
|
<p><strong>Interactables:</strong> ${location.interactable_count}</p>
|
|
<h3>🧭 Connections</h3>
|
|
<div class="connections">
|
|
${mapData.connections
|
|
.filter(c => c.from === location.id)
|
|
.map(c => {
|
|
const dest = mapData.locations.find(l => l.id === c.to);
|
|
const stamina = Math.ceil(c.distance * 3);
|
|
return `<div class="connection-item">
|
|
<strong>${c.direction.toUpperCase()}</strong><br>
|
|
${dest ? dest.name : c.to}<br>
|
|
${stamina}⚡ stamina
|
|
</div>`;
|
|
})
|
|
.join('')
|
|
}
|
|
</div>
|
|
`;
|
|
document.getElementById('locationInfo').innerHTML = locationHTML;
|
|
|
|
// Add click handler to location image
|
|
setTimeout(() => {
|
|
const locationImg = document.querySelector('.location-image');
|
|
if (locationImg) {
|
|
locationImg.addEventListener('click', () => openImageModal(locationImg.src, location.name));
|
|
}
|
|
}, 0);
|
|
|
|
// Interactables
|
|
const locationInteractables = mapData.interactables.filter(i => i.location_id === location.id);
|
|
if (locationInteractables.length > 0) {
|
|
const interactablesHTML = `
|
|
<div class="interactable-list">
|
|
${locationInteractables.map(inter => `
|
|
<div class="interactable-card">
|
|
<div class="interactable-header">
|
|
<div class="interactable-icon">
|
|
${inter.image_path ?
|
|
`<img src="/${inter.image_path}" class="interactable-image" onerror="this.outerHTML='<div class=\\'interactable-image-placeholder\\'>📦</div>';" />` :
|
|
`<div class="interactable-image-placeholder">📦</div>`
|
|
}
|
|
</div>
|
|
<div class="interactable-name">${inter.name}</div>
|
|
</div>
|
|
${inter.actions.map(action => `
|
|
<div class="action-item">
|
|
<div class="action-header">${action.label} (${action.stamina_cost}⚡)</div>
|
|
${action.outcomes.map(outcome => `
|
|
<div class="outcome-item outcome-${outcome.type}">
|
|
<strong>${outcome.type}:</strong> ${outcome.text}
|
|
${Object.keys(outcome.items).length > 0 ?
|
|
`<br>Items: ${Object.entries(outcome.items).map(([id, qty]) => `${id} x${qty}`).join(', ')}` : ''}
|
|
${outcome.damage > 0 ? `<br>⚠️ Damage: ${outcome.damage} HP` : ''}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
document.getElementById('interactablesInfo').innerHTML = interactablesHTML;
|
|
|
|
// Add click handlers to interactable images
|
|
setTimeout(() => {
|
|
document.querySelectorAll('.interactable-image').forEach(img => {
|
|
img.addEventListener('click', () => openImageModal(img.src, img.alt || 'Interactable'));
|
|
});
|
|
}, 0);
|
|
} else {
|
|
document.getElementById('interactablesInfo').innerHTML = '<p class="no-data">No interactables at this location</p>';
|
|
}
|
|
|
|
// Enemies
|
|
const enemies = mapData.spawn_tables[location.id];
|
|
if (enemies && enemies.length > 0) {
|
|
const enemiesHTML = `
|
|
<p><strong>Encounter Rate:</strong> ${enemies[0].encounter_rate}% when traveling</p>
|
|
<div class="enemy-list">
|
|
${enemies.map(enemy => `
|
|
<div class="enemy-card">
|
|
<div class="enemy-header">
|
|
<div class="enemy-icon">
|
|
${enemy.image_url ?
|
|
`<img src="/${enemy.image_url}" class="enemy-image" onerror="this.outerHTML='<div class=\\'enemy-image-placeholder\\'>${enemy.emoji}</div>';" />` :
|
|
`<div class="enemy-image-placeholder">${enemy.emoji}</div>`
|
|
}
|
|
</div>
|
|
<div class="enemy-name">${enemy.name}</div>
|
|
<div>${enemy.spawn_chance}%</div>
|
|
</div>
|
|
<div class="enemy-stats">
|
|
<div class="stat-item">❤️ HP: ${enemy.hp_range[0]}-${enemy.hp_range[1]}</div>
|
|
<div class="stat-item">⚔️ DMG: ${enemy.damage_range[0]}-${enemy.damage_range[1]}</div>
|
|
<div class="stat-item">⭐ XP: ${enemy.xp_reward}</div>
|
|
<div class="stat-item">🎲 Weight: ${enemy.spawn_weight}</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
document.getElementById('enemiesInfo').innerHTML = enemiesHTML;
|
|
|
|
// Add click handlers to enemy images
|
|
setTimeout(() => {
|
|
document.querySelectorAll('.enemy-image').forEach(img => {
|
|
img.addEventListener('click', () => openImageModal(img.src, img.alt || 'Enemy'));
|
|
});
|
|
}, 0);
|
|
} else {
|
|
document.getElementById('enemiesInfo').innerHTML = '<p class="no-data">✅ Safe zone - no enemies spawn here</p>';
|
|
}
|
|
}
|
|
|
|
// Image Modal Functions
|
|
function setupImageModal() {
|
|
const modal = document.getElementById('imageModal');
|
|
const closeBtn = document.querySelector('.image-modal-close');
|
|
|
|
// Close modal when clicking close button, backdrop, or pressing Escape
|
|
closeBtn.addEventListener('click', closeImageModal);
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
closeImageModal();
|
|
}
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modal.classList.contains('active')) {
|
|
closeImageModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
function openImageModal(imageSrc, imageTitle) {
|
|
const modal = document.getElementById('imageModal');
|
|
const modalImg = document.getElementById('modalImage');
|
|
const modalInfo = document.getElementById('modalInfo');
|
|
|
|
modalImg.src = imageSrc;
|
|
modalImg.alt = imageTitle;
|
|
modalInfo.textContent = imageTitle + ' - Click anywhere to close';
|
|
modal.classList.add('active');
|
|
|
|
// Prevent body scrolling when modal is open
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function closeImageModal() {
|
|
const modal = document.getElementById('imageModal');
|
|
modal.classList.remove('active');
|
|
|
|
// Re-enable body scrolling
|
|
document.body.style.overflow = 'auto';
|
|
}
|