Initial commit: Echoes of the Ashes - Telegram RPG Bot

This commit is contained in:
Joan
2025-10-18 19:21:19 +02:00
commit 3ab412bc09
65 changed files with 14484 additions and 0 deletions

202
web-map/README.md Normal file
View 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

File diff suppressed because it is too large Load Diff

470
web-map/editor.js Normal file
View 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

File diff suppressed because it is too large Load Diff

869
web-map/index.html Normal file
View 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">&times;</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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File