// 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 ? `` : `
πŸ—ΊοΈ
` }

${location.name}

${location.description}

Coordinates: (${location.x}, ${location.y})

Interactables: ${location.interactable_count}

🧭 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 `
${c.direction.toUpperCase()}
${dest ? dest.name : c.to}
${stamina}⚑ stamina
`; }) .join('') }
`; 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 = `
${locationInteractables.map(inter => `
${inter.image_path ? `` : `
πŸ“¦
` }
${inter.name}
${inter.actions.map(action => `
${action.label} (${action.stamina_cost}⚑)
${action.outcomes.map(outcome => `
${outcome.type}: ${outcome.text} ${Object.keys(outcome.items).length > 0 ? `
Items: ${Object.entries(outcome.items).map(([id, qty]) => `${id} x${qty}`).join(', ')}` : ''} ${outcome.damage > 0 ? `
⚠️ Damage: ${outcome.damage} HP` : ''}
`).join('')}
`).join('')}
`).join('')}
`; 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 = '

No interactables at this location

'; } // Enemies const enemies = mapData.spawn_tables[location.id]; if (enemies && enemies.length > 0) { const enemiesHTML = `

Encounter Rate: ${enemies[0].encounter_rate}% when traveling

${enemies.map(enemy => `
${enemy.image_url ? `` : `
${enemy.emoji}
` }
${enemy.name}
${enemy.spawn_chance}%
❀️ HP: ${enemy.hp_range[0]}-${enemy.hp_range[1]}
βš”οΈ DMG: ${enemy.damage_range[0]}-${enemy.damage_range[1]}
⭐ XP: ${enemy.xp_reward}
🎲 Weight: ${enemy.spawn_weight}
`).join('')}
`; 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 = '

βœ… Safe zone - no enemies spawn here

'; } } // 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'; }