Initial commit: Echoes of the Ashes - Telegram RPG Bot
This commit is contained in:
202
web-map/README.md
Normal file
202
web-map/README.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# 🗺️ Echoes of the Ashes - Interactive Map Visualizer
|
||||
|
||||
A web-based interactive map viewer for the RPG game world.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive Canvas Map**: Drag, zoom, and explore the game world
|
||||
- **Real-time Location Data**: Dynamically loaded from game data
|
||||
- **Distance Calculations**: Shows travel distances and stamina costs
|
||||
- **Location Details**: Click on locations to see full information
|
||||
- **Connection Routes**: Visual representation of all travel paths
|
||||
- **Statistics Dashboard**: View map statistics and metrics
|
||||
- **Responsive Design**: Works on desktop and mobile devices
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Docker (Recommended)
|
||||
|
||||
The map server is included in the docker-compose setup:
|
||||
|
||||
```bash
|
||||
docker compose up -d echoes_of_the_ashes_map
|
||||
```
|
||||
|
||||
Access the map at: **http://localhost:8080**
|
||||
|
||||
### Option 2: Standalone Python Server
|
||||
|
||||
```bash
|
||||
cd web-map
|
||||
python server.py
|
||||
```
|
||||
|
||||
Optional: Specify a custom port
|
||||
```bash
|
||||
python server.py --port 3000
|
||||
```
|
||||
|
||||
## Features Overview
|
||||
|
||||
### Map Controls
|
||||
|
||||
- **🖱️ Pan**: Click and drag to move around the map
|
||||
- **🔍 Zoom**: Use mouse wheel or zoom buttons to adjust view
|
||||
- **🎯 Reset**: Return to default view
|
||||
- **🏷️ Toggle Labels**: Show/hide location names
|
||||
|
||||
### Location Information
|
||||
|
||||
Click on any location node to see:
|
||||
- Location name and description
|
||||
- Coordinates (X, Y)
|
||||
- Number of interactables
|
||||
- Connected locations with distances
|
||||
- Estimated stamina costs for travel
|
||||
|
||||
### Map Legend
|
||||
|
||||
- **Green Circles**: Locations
|
||||
- **Blue Lines**: Travel routes
|
||||
- **Orange Circle**: Selected location
|
||||
- **Pink Badge**: Number of interactables at location
|
||||
|
||||
## Map Statistics
|
||||
|
||||
The dashboard shows:
|
||||
- **Total Locations**: Number of places in the game world
|
||||
- **Total Routes**: Number of connections between locations
|
||||
- **Longest Route**: Maximum distance between connected locations
|
||||
- **Average Distance**: Mean distance across all routes
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Data Source
|
||||
|
||||
The map dynamically loads data from `/map_data.json`, which is generated from the game's world loader. The data includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"locations": [
|
||||
{
|
||||
"id": "start_point",
|
||||
"name": "🌆 Ruined Downtown Core",
|
||||
"description": "...",
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"interactable_count": 3
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{
|
||||
"from": "start_point",
|
||||
"to": "gas_station",
|
||||
"direction": "north",
|
||||
"distance": 2.0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Server Architecture
|
||||
|
||||
- **Backend**: Python HTTP server with dynamic data generation
|
||||
- **Frontend**: Vanilla JavaScript with HTML5 Canvas
|
||||
- **Responsive**: CSS Grid and Flexbox layout
|
||||
- **Real-time**: Live data from game world loader
|
||||
|
||||
### Port Configuration
|
||||
|
||||
Default port: **8080**
|
||||
|
||||
To change the port, modify:
|
||||
1. `docker-compose.yml`: Update the ports mapping
|
||||
2. Or use `--port` flag when running standalone
|
||||
|
||||
## Customization
|
||||
|
||||
### Styling
|
||||
|
||||
Edit `index.html` to customize:
|
||||
- Colors and gradients
|
||||
- Card layouts
|
||||
- Typography
|
||||
- Responsive breakpoints
|
||||
|
||||
### Map Appearance
|
||||
|
||||
Edit `map.js` to customize:
|
||||
- Node sizes and colors
|
||||
- Line widths
|
||||
- Scale factors
|
||||
- Animation effects
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### With Reverse Proxy (Nginx/Caddy)
|
||||
|
||||
```nginx
|
||||
location /map {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
```
|
||||
|
||||
### Public Access
|
||||
|
||||
To make it accessible globally:
|
||||
|
||||
1. **Port Forward**: Open port 8080 on your firewall
|
||||
2. **Domain**: Point a subdomain to your server
|
||||
3. **SSL**: Use Let's Encrypt for HTTPS
|
||||
|
||||
Example with Caddy:
|
||||
```
|
||||
map.yourdomain.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Map Not Loading
|
||||
|
||||
1. Check if server is running:
|
||||
```bash
|
||||
docker ps | grep map
|
||||
```
|
||||
|
||||
2. Check logs:
|
||||
```bash
|
||||
docker logs echoes_of_the_ashes_map
|
||||
```
|
||||
|
||||
3. Verify port is accessible:
|
||||
```bash
|
||||
curl http://localhost:8080
|
||||
```
|
||||
|
||||
### Data Not Updating
|
||||
|
||||
The map data is generated dynamically. If you've changed location coordinates:
|
||||
1. Restart the map server
|
||||
2. Hard refresh browser (Ctrl+F5)
|
||||
|
||||
### Connection Issues
|
||||
|
||||
If running in Docker, ensure the container is on the correct network and ports are properly mapped.
|
||||
|
||||
## Development
|
||||
|
||||
To modify the map visualization:
|
||||
|
||||
1. Edit `map.js` for canvas rendering logic
|
||||
2. Edit `index.html` for layout and UI
|
||||
3. Edit `server.py` for data serving logic
|
||||
|
||||
The server auto-loads changes - just refresh your browser!
|
||||
|
||||
## License
|
||||
|
||||
Part of the Echoes of the Ashes RPG project.
|
||||
1160
web-map/editor.html
Normal file
1160
web-map/editor.html
Normal file
File diff suppressed because it is too large
Load Diff
470
web-map/editor.js
Normal file
470
web-map/editor.js
Normal file
@@ -0,0 +1,470 @@
|
||||
// Map Editor JavaScript
|
||||
let currentLocations = [];
|
||||
let availableNPCs = [];
|
||||
let selectedLocationId = null;
|
||||
let canvas, ctx;
|
||||
let scale = 50;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
// 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';
|
||||
}
|
||||
});
|
||||
|
||||
// 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 = 'grid';
|
||||
|
||||
// Initialize canvas
|
||||
canvas = document.getElementById('editorCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
// Load data
|
||||
await loadLocations();
|
||||
await loadAvailableNPCs();
|
||||
|
||||
// Draw map
|
||||
drawMap();
|
||||
|
||||
// Canvas click handler
|
||||
canvas.addEventListener('click', handleCanvasClick);
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
drawMap();
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await fetch('/api/editor/locations');
|
||||
const data = await response.json();
|
||||
currentLocations = data.locations;
|
||||
renderLocationList();
|
||||
} catch (error) {
|
||||
console.error('Failed to load locations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableNPCs() {
|
||||
try {
|
||||
const response = await fetch('/api/editor/available-npcs');
|
||||
const data = await response.json();
|
||||
availableNPCs = data.npcs;
|
||||
} catch (error) {
|
||||
console.error('Failed to load NPCs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLocationList() {
|
||||
const list = document.getElementById('locationList');
|
||||
list.innerHTML = '';
|
||||
|
||||
currentLocations.forEach(location => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'location-item';
|
||||
if (location.id === selectedLocationId) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="location-item-name">${location.name}</div>
|
||||
<div class="location-item-coords">📍 (${location.x}, ${location.y}) | Danger: ${location.danger_level}</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);
|
||||
}
|
||||
|
||||
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 drawMap() {
|
||||
ctx.fillStyle = '#0f0f1e';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw grid
|
||||
ctx.strokeStyle = '#1a1a3e';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
|
||||
// Draw 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();
|
||||
}
|
||||
|
||||
// Draw 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();
|
||||
|
||||
// Draw locations
|
||||
currentLocations.forEach(location => {
|
||||
const screenX = centerX + location.x * scale;
|
||||
const screenY = centerY - location.y * scale;
|
||||
|
||||
// Danger level colors
|
||||
const dangerColors = ['#4caf50', '#8bc34a', '#ffa726', '#ff5722', '#d32f2f'];
|
||||
const color = dangerColors[location.danger_level] || '#9e9e9e';
|
||||
|
||||
// Draw location circle
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(screenX, screenY, 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, screenX, screenY + 30);
|
||||
});
|
||||
}
|
||||
|
||||
function handleCanvasClick(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clickX = event.clientX - rect.left;
|
||||
const clickY = event.clientY - rect.top;
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
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();
|
||||
drawMap();
|
||||
} else {
|
||||
alert('Failed to save: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to save location: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
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: ', ''));
|
||||
|
||||
// Find NPC ID by name
|
||||
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 = '';
|
||||
|
||||
availableNPCs.forEach(npc => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'npc-select-item';
|
||||
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';
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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 logout() {
|
||||
try {
|
||||
await fetch('/api/logout', {method: 'POST'});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
}
|
||||
3025
web-map/editor_enhanced.js
Normal file
3025
web-map/editor_enhanced.js
Normal file
File diff suppressed because it is too large
Load Diff
869
web-map/index.html
Normal file
869
web-map/index.html
Normal file
@@ -0,0 +1,869 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Echoes of the Ashes - Interactive World Map</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 450px;
|
||||
height: 100vh;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0f0f1e;
|
||||
border-right: 2px solid #2a2a4a;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 30px;
|
||||
background: linear-gradient(135deg, #2a2a4a 0%, #1a1a3e 100%);
|
||||
border-bottom: 2px solid #3a3a6a;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin-bottom: 5px;
|
||||
color: #ffa726;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.bot-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #0088cc 0%, #006699 100%);
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 136, 204, 0.3);
|
||||
}
|
||||
|
||||
.bot-link:hover {
|
||||
background: linear-gradient(135deg, #00a0e6 0%, #0088cc 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 136, 204, 0.5);
|
||||
}
|
||||
|
||||
.bot-link:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.bot-link-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#mapCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#mapCanvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: rgba(42, 42, 74, 0.9);
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #3a3a6a;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.controls button {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 5px 0;
|
||||
background: #3a3a6a;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: #ffa726;
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.controls button:hover {
|
||||
background: #ffa726;
|
||||
color: #1a1a3e;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #16162e;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
}
|
||||
|
||||
.info-section h2 {
|
||||
color: #ffa726;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
color: #80cbc4;
|
||||
margin: 15px 0 10px 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.location-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 15px;
|
||||
border: 2px solid #3a3a6a;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: linear-gradient(135deg, #2a2a4a 0%, #1a1a3e 100%);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #3a3a6a;
|
||||
color: #5a5a7a;
|
||||
font-size: 3em;
|
||||
}
|
||||
|
||||
.interactable-image,
|
||||
.enemy-image {
|
||||
width: 60px;
|
||||
height: 45px;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
border: 2px solid #3a3a6a;
|
||||
}
|
||||
|
||||
.interactable-image-placeholder,
|
||||
.enemy-image-placeholder {
|
||||
width: 60px;
|
||||
height: 45px;
|
||||
background: linear-gradient(135deg, #2a2a4a 0%, #1a1a3e 100%);
|
||||
border-radius: 5px;
|
||||
border: 2px solid #3a3a6a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5em;
|
||||
color: #5a5a7a;
|
||||
}
|
||||
|
||||
.description {
|
||||
padding: 10px;
|
||||
background: rgba(42, 42, 74, 0.3);
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.connections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.connection-item {
|
||||
background: rgba(42, 42, 74, 0.5);
|
||||
padding: 8px 12px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.9em;
|
||||
border-left: 3px solid #80cbc4;
|
||||
}
|
||||
|
||||
.interactable-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.interactable-card {
|
||||
background: rgba(42, 42, 74, 0.3);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3a3a6a;
|
||||
}
|
||||
|
||||
.interactable-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.interactable-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.interactable-name {
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
background: rgba(58, 58, 106, 0.3);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 8px 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.action-header {
|
||||
font-weight: bold;
|
||||
color: #80cbc4;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.outcome-item {
|
||||
padding: 5px 10px;
|
||||
margin: 3px 0;
|
||||
border-left: 2px solid #5a5a7a;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.outcome-success {
|
||||
border-left-color: #4caf50;
|
||||
}
|
||||
|
||||
.outcome-failure {
|
||||
border-left-color: #ff9800;
|
||||
}
|
||||
|
||||
.outcome-critical {
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.enemy-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.enemy-card {
|
||||
background: rgba(74, 42, 42, 0.3);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #6a3a3a;
|
||||
}
|
||||
|
||||
.enemy-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.enemy-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.enemy-emoji {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.enemy-name {
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
color: #ff5252;
|
||||
}
|
||||
|
||||
.enemy-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: rgba(58, 58, 106, 0.3);
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
padding: 15px;
|
||||
background: rgba(42, 42, 74, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 8px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 30px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(42, 42, 74, 0.5);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border: 1px solid #3a3a6a;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #ffa726;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Image modal/lightbox styles */
|
||||
.image-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.95);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.image-modal-content {
|
||||
max-width: 90%;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border: 3px solid #ffa726;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 30px rgba(255, 167, 38, 0.5);
|
||||
animation: zoomIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
from { transform: scale(0.8); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.image-modal-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 30px;
|
||||
font-size: 40px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
z-index: 10000;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.image-modal-close:hover {
|
||||
color: #ffa726;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.image-modal-info {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Make images clickable */
|
||||
.location-image,
|
||||
.interactable-image,
|
||||
.enemy-image {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.location-image:hover,
|
||||
.interactable-image:hover,
|
||||
.enemy-image:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 15px rgba(255, 167, 38, 0.6);
|
||||
border-color: #ffa726;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3a3a6a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #4a4a7a;
|
||||
}
|
||||
|
||||
/* Mobile Responsive Styles */
|
||||
@media (max-width: 1024px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr 350px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 15px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
border-right: none;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
max-height: 50vh;
|
||||
border-top: 2px solid #2a2a4a;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
.bot-link {
|
||||
font-size: 0.85em;
|
||||
padding: 6px 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.info-section h2 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.connections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
height: 60vh;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
max-height: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.bot-link {
|
||||
font-size: 0.8em;
|
||||
padding: 6px 12px;
|
||||
margin-top: 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bot-link-icon {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 0.9em;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.info-section h2 {
|
||||
font-size: 1em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
font-size: 0.95em;
|
||||
margin: 10px 0 8px 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
padding: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.connection-item {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.interactable-card,
|
||||
.enemy-card {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
padding: 8px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.enemy-stats {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Modal adjustments for mobile */
|
||||
.image-modal-content {
|
||||
max-width: 95%;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.image-modal-close {
|
||||
top: 10px;
|
||||
right: 15px;
|
||||
font-size: 35px;
|
||||
}
|
||||
|
||||
.image-modal-info {
|
||||
bottom: 10px;
|
||||
padding: 8px 15px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape orientation on mobile */
|
||||
@media (max-width: 768px) and (orientation: landscape) {
|
||||
.container {
|
||||
grid-template-columns: 1fr 300px;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
max-height: 100vh;
|
||||
border-top: none;
|
||||
border-left: 2px solid #2a2a4a;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly controls */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.controls button {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.location-image,
|
||||
.interactable-image,
|
||||
.enemy-image {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Larger tap targets */
|
||||
.connection-item,
|
||||
.action-item,
|
||||
.stat-item {
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="map-section">
|
||||
<div class="header">
|
||||
<h1>🗺️ Interactive World Map</h1>
|
||||
<p class="subtitle">Echoes of the Ashes</p>
|
||||
<a href="https://t.me/echoes_of_the_ash_bot" target="_blank" rel="noopener noreferrer" class="bot-link">
|
||||
<span class="bot-link-icon">🤖</span>
|
||||
<span>Play on Telegram</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="canvas-wrapper">
|
||||
<canvas id="mapCanvas"></canvas>
|
||||
<div class="controls">
|
||||
<button id="zoomIn" title="Zoom In">+</button>
|
||||
<button id="zoomOut" title="Zoom Out">−</button>
|
||||
<button id="resetView" title="Reset View">⟲</button>
|
||||
<button id="toggleLabels" title="Toggle Labels">🏷️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-panel">
|
||||
<div class="info-section">
|
||||
<h2>📍 Location Details</h2>
|
||||
<div id="locationInfo">
|
||||
<p class="no-data">Click on a location to see details</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>🎯 Interactables</h2>
|
||||
<div id="interactablesInfo">
|
||||
<p class="no-data">Select a location to see interactables</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>⚔️ Enemy Encounters</h2>
|
||||
<div id="enemiesInfo">
|
||||
<p class="no-data">Select a location to see possible enemies</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>📊 Map Statistics</h2>
|
||||
<div id="statsInfo">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="totalLocations">-</span>
|
||||
<span class="stat-label">Locations</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="totalConnections">-</span>
|
||||
<span class="stat-label">Routes</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="totalInteractables">-</span>
|
||||
<span class="stat-label">Interactables</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="totalEnemies">-</span>
|
||||
<span class="stat-label">Enemy Types</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>🗺️ Legend</h2>
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #4fc3f7;"></div>
|
||||
<span>Safe Zone</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #ffa726;"></div>
|
||||
<span>Low Danger</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #ff7043;"></div>
|
||||
<span>Medium Danger</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #e53935;"></div>
|
||||
<span>High Danger</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Modal/Lightbox -->
|
||||
<div id="imageModal" class="image-modal">
|
||||
<span class="image-modal-close">×</span>
|
||||
<img id="modalImage" class="image-modal-content" alt="Full size image">
|
||||
<div class="image-modal-info" id="modalInfo">Click anywhere to close</div>
|
||||
</div>
|
||||
|
||||
<script src="map.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
603
web-map/map.js
Normal file
603
web-map/map.js
Normal file
@@ -0,0 +1,603 @@
|
||||
// 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';
|
||||
}
|
||||
3
web-map/requirements.txt
Normal file
3
web-map/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask==3.0.0
|
||||
flask-cors==4.0.0
|
||||
werkzeug==3.0.1
|
||||
117
web-map/server.py
Normal file
117
web-map/server.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Get the directory of this script
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
|
||||
class MapServerHandler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Set the directory to serve files from
|
||||
super().__init__(*args, directory=str(SCRIPT_DIR), **kwargs)
|
||||
|
||||
def end_headers(self):
|
||||
# Add CORS headers to allow access from anywhere
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', '*')
|
||||
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||||
super().end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
# Handle map_data.json request
|
||||
if self.path == '/map_data.json':
|
||||
try:
|
||||
# Try to load from parent directory's data module
|
||||
sys.path.insert(0, str(SCRIPT_DIR.parent))
|
||||
from data.world_loader import export_map_data
|
||||
|
||||
map_data = export_map_data()
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(map_data, indent=2).encode())
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Error generating map data: {e}")
|
||||
# Fall back to static file if it exists
|
||||
map_file = SCRIPT_DIR / 'map_data.json'
|
||||
if map_file.exists():
|
||||
with open(map_file, 'r') as f:
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(f.read().encode())
|
||||
return
|
||||
|
||||
self.send_error(500, f"Failed to generate map data: {str(e)}")
|
||||
return
|
||||
|
||||
# Handle image requests from parent images directory
|
||||
if self.path.startswith('/images/'):
|
||||
try:
|
||||
# Construct path to image in parent directory
|
||||
image_path = SCRIPT_DIR.parent / self.path.lstrip('/')
|
||||
|
||||
if image_path.exists() and image_path.is_file():
|
||||
# Determine content type based on file extension
|
||||
content_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml'
|
||||
}
|
||||
ext = image_path.suffix.lower()
|
||||
content_type = content_types.get(ext, 'application/octet-stream')
|
||||
|
||||
with open(image_path, 'rb') as f:
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', content_type)
|
||||
self.end_headers()
|
||||
self.wfile.write(f.read())
|
||||
return
|
||||
else:
|
||||
self.send_error(404, f"Image not found: {self.path}")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Error serving image {self.path}: {e}")
|
||||
self.send_error(500, f"Failed to serve image: {str(e)}")
|
||||
return
|
||||
|
||||
# Serve other files normally
|
||||
return super().do_GET()
|
||||
|
||||
def run_server(port=8080):
|
||||
server_address = ('', port)
|
||||
httpd = HTTPServer(server_address, MapServerHandler)
|
||||
print(f"""
|
||||
╔════════════════════════════════════════════╗
|
||||
║ Echoes of the Ashes - Map Server ║
|
||||
╚════════════════════════════════════════════╝
|
||||
|
||||
🗺️ Map server running on:
|
||||
→ http://localhost:{port}
|
||||
→ http://0.0.0.0:{port}
|
||||
|
||||
📊 Serving from: {SCRIPT_DIR}
|
||||
|
||||
Press Ctrl+C to stop the server
|
||||
""")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n👋 Shutting down server...")
|
||||
httpd.shutdown()
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='Run the RPG map visualization server')
|
||||
parser.add_argument('--port', type=int, default=8080, help='Port to run the server on (default: 8080)')
|
||||
args = parser.parse_args()
|
||||
|
||||
run_server(args.port)
|
||||
1097
web-map/server_enhanced.py
Normal file
1097
web-map/server_enhanced.py
Normal file
File diff suppressed because it is too large
Load Diff
0
web-map/world-config.json
Normal file
0
web-map/world-config.json
Normal file
Reference in New Issue
Block a user