- Phase 1: POI database (11,309 locations, SQLite with spatial queries) - Phase 2: Tile decoder + Leaflet.js web viewer (3 map layers, search, categories) - Phase 3: .db/.fch parser WIP for fog-of-war overlay - Full README with roadmap, architecture, and usage docs
344 lines
13 KiB
Python
344 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Build a Leaflet.js web viewer for the Nerdhalla Valheim world.
|
|
"""
|
|
import sqlite3, json, os
|
|
|
|
DB_PATH = "/tmp/valheim_poi.db"
|
|
OUTPUT_DIR = "/tmp/valheim_map_output"
|
|
VIEWER_PATH = os.path.join(OUTPUT_DIR, "index.html")
|
|
|
|
WORLD_SIZE = 24000
|
|
PIXELS_TOTAL = 4096
|
|
|
|
def valheim_to_pixel(vx, vz):
|
|
px = (vx + WORLD_SIZE/2) / WORLD_SIZE * PIXELS_TOTAL
|
|
py = (vz + WORLD_SIZE/2) / WORLD_SIZE * PIXELS_TOTAL
|
|
return px, py
|
|
|
|
def query_pois():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute("""
|
|
SELECT id, prefab_name, category, pos_x, pos_z, has_dungeon, has_important
|
|
FROM locations WHERE pos_x IS NOT NULL AND pos_z IS NOT NULL
|
|
ORDER BY category, prefab_name
|
|
""")
|
|
pois = []
|
|
for row in c.fetchall():
|
|
loc_id, name, cat, x, z, has_dungeon, has_important = row
|
|
px, py = valheim_to_pixel(x, z)
|
|
pois.append({
|
|
'id': loc_id, 'name': name, 'category': cat,
|
|
'x': round(x, 1), 'z': round(z, 1),
|
|
'px': round(px, 1), 'py': round(py, 1),
|
|
'has_dungeon': bool(has_dungeon), 'has_important': bool(has_important),
|
|
})
|
|
|
|
c.execute("""
|
|
SELECT l.id, l.prefab_name, l.pos_x, l.pos_z, ic.friendly_name, ic.count
|
|
FROM locations l JOIN important_contents ic ON l.id = ic.location_id
|
|
WHERE l.pos_x IS NOT NULL AND l.pos_z IS NOT NULL
|
|
ORDER BY ic.count DESC
|
|
""")
|
|
contents = []
|
|
for row in c.fetchall():
|
|
loc_id, name, x, z, fname, count = row
|
|
px, py = valheim_to_pixel(x, z)
|
|
contents.append({
|
|
'loc_id': loc_id, 'loc_name': name, 'item': fname, 'count': count,
|
|
'x': round(x, 1), 'z': round(z, 1),
|
|
'px': round(px, 1), 'py': round(py, 1),
|
|
})
|
|
conn.close()
|
|
return pois, contents
|
|
|
|
def build_viewer(pois, contents):
|
|
pois_json = json.dumps(pois)
|
|
contents_json = json.dumps(contents)
|
|
|
|
cat_colors = {
|
|
'boss': '#ff4444', 'dungeon': '#ff8800', 'vegvisir': '#44aaff',
|
|
'altar': '#aa88ff', 'vendor': '#ffdd00', 'structure': '#88cc88',
|
|
'resource': '#ff66aa', 'spawner': '#ff44ff', 'point_of_interest': '#44ffaa',
|
|
'other': '#888888',
|
|
}
|
|
cat_colors_json = json.dumps(cat_colors)
|
|
|
|
cat_labels = {
|
|
'boss': 'Boss Altars', 'dungeon': 'Dungeons', 'vegvisir': 'Vegvisirs',
|
|
'vendor': 'Vendors', 'altar': 'Altars / Stones', 'structure': 'Structures',
|
|
'resource': 'Resources', 'spawner': 'Spawners', 'point_of_interest': 'POIs',
|
|
'other': 'Other',
|
|
}
|
|
cat_labels_json = json.dumps(cat_labels)
|
|
|
|
# Build category counts
|
|
cat_counts = {}
|
|
for p in pois:
|
|
cat_counts[p['category']] = cat_counts.get(p['category'], 0) + 1
|
|
cat_counts_json = json.dumps(cat_counts)
|
|
|
|
html = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Nerdhalla — Valheim World Map</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body { height: 100%; font-family: 'Segoe UI', system-ui, sans-serif; }
|
|
#map { height: 100vh; width: 100vw; }
|
|
.leaflet-container { background: #1a1a2e; }
|
|
.info-panel {
|
|
position: absolute; top: 10px; right: 10px; z-index: 1000;
|
|
background: rgba(20, 20, 35, 0.92); color: #ddd;
|
|
padding: 12px 16px; border-radius: 8px; font-size: 13px;
|
|
max-width: 280px; max-height: 80vh; overflow-y: auto;
|
|
border: 1px solid #444; backdrop-filter: blur(8px);
|
|
}
|
|
.info-panel h3 { color: #ffd700; margin-bottom: 6px; font-size: 15px; }
|
|
.info-panel .stat { display: flex; justify-content: space-between; padding: 2px 0; }
|
|
.info-panel .stat span:last-child { color: #aaa; }
|
|
.legend {
|
|
position: absolute; bottom: 30px; right: 10px; z-index: 1000;
|
|
background: rgba(20, 20, 35, 0.92); color: #ddd;
|
|
padding: 10px 14px; border-radius: 8px; font-size: 12px;
|
|
border: 1px solid #444; backdrop-filter: blur(8px);
|
|
max-height: 50vh; overflow-y: auto;
|
|
}
|
|
.legend-item { display: flex; align-items: center; gap: 6px; padding: 2px 0; cursor: pointer; }
|
|
.legend-item .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
.legend-item .count { color: #888; margin-left: auto; font-size: 11px; }
|
|
.legend-item.active { color: #fff; }
|
|
.legend-item.inactive { opacity: 0.4; }
|
|
.search-box {
|
|
position: absolute; top: 10px; left: 10px; z-index: 1000;
|
|
background: rgba(20, 20, 35, 0.92); border: 1px solid #444;
|
|
border-radius: 8px; padding: 8px 12px; backdrop-filter: blur(8px);
|
|
display: flex; gap: 6px; align-items: center;
|
|
}
|
|
.search-box input {
|
|
background: transparent; border: none; color: #ddd; font-size: 13px;
|
|
outline: none; width: 180px;
|
|
}
|
|
.search-box input::placeholder { color: #666; }
|
|
.search-box .results {
|
|
position: absolute; top: 100%; left: 0; right: 0;
|
|
background: rgba(20, 20, 35, 0.95); border: 1px solid #444;
|
|
border-radius: 0 0 8px 8px; max-height: 200px; overflow-y: auto;
|
|
display: none;
|
|
}
|
|
.search-box .results div { padding: 6px 12px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #333; }
|
|
.search-box .results div:hover { background: rgba(255,255,255,0.1); }
|
|
.search-box .results div .cat { color: #888; font-size: 10px; }
|
|
.popup-content { font-size: 12px; line-height: 1.5; }
|
|
.popup-content .name { font-weight: bold; font-size: 14px; color: #ffd700; }
|
|
.popup-content .coords { color: #888; }
|
|
.popup-content .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin: 2px 2px 0 0; }
|
|
.popup-content .badge-dungeon { background: #ff8800; color: #000; }
|
|
.popup-content .badge-treasure { background: #ffd700; color: #000; }
|
|
.toggle-layer {
|
|
position: absolute; bottom: 30px; left: 10px; z-index: 1000;
|
|
display: flex; flex-direction: column; gap: 4px;
|
|
}
|
|
.toggle-layer button {
|
|
background: rgba(20, 20, 35, 0.92); color: #ddd; border: 1px solid #444;
|
|
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 11px;
|
|
backdrop-filter: blur(8px); text-align: left;
|
|
}
|
|
.toggle-layer button.active { border-color: #ffd700; color: #ffd700; }
|
|
.toggle-layer button:hover { background: rgba(40, 40, 60, 0.95); }
|
|
@media (max-width: 768px) { .info-panel { display: none; } .search-box input { width: 120px; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="map"></div>
|
|
|
|
<div class="search-box">
|
|
<span style="color:#666;">🔍</span>
|
|
<input type="text" id="search" placeholder="Search POIs..." oninput="doSearch(this.value)">
|
|
<div class="results" id="searchResults"></div>
|
|
</div>
|
|
|
|
<div class="info-panel" id="infoPanel">
|
|
<h3>🌍 Nerdhalla</h3>
|
|
<div class="stat"><span>Seed</span><span>yzZ5fr2tGa</span></div>
|
|
<div class="stat"><span>World</span><span>24000 x 24000</span></div>
|
|
<div class="stat"><span>POIs</span><span>""" + str(len(pois)) + """</span></div>
|
|
<div class="stat"><span>Items</span><span>""" + str(len(contents)) + """</span></div>
|
|
<div class="stat"><span>Biomes</span><span>9</span></div>
|
|
<hr style="border-color:#333;margin:6px 0;">
|
|
<div style="font-size:11px;color:#888;">
|
|
Click a marker for details<br>
|
|
Search by name or category
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toggle-layer">
|
|
<button class="active" onclick="toggleLayer('composite')" id="btn-composite">🗺 Composite</button>
|
|
<button onclick="toggleLayer('biome')" id="btn-biome">🌿 Biome</button>
|
|
<button onclick="toggleLayer('height')" id="btn-height">⛰ Height</button>
|
|
</div>
|
|
|
|
<div class="legend" id="legend"></div>
|
|
|
|
<script>
|
|
const POIS = """ + pois_json + """;
|
|
const CONTENTS = """ + contents_json + """;
|
|
const CAT_COLORS = """ + cat_colors_json + """;
|
|
const CAT_LABELS = """ + cat_labels_json + """;
|
|
const CAT_COUNTS = """ + cat_counts_json + """;
|
|
|
|
const map = L.map('map', {
|
|
center: [2048, 2048],
|
|
zoom: -2,
|
|
minZoom: -3,
|
|
maxZoom: 3,
|
|
crs: L.CRS.Simple,
|
|
zoomSnap: 0.5,
|
|
zoomDelta: 0.5,
|
|
});
|
|
|
|
const bounds = [[0, 0], [4096, 4096]];
|
|
|
|
const layers = {};
|
|
layers.composite = L.imageOverlay('world_composite.png', bounds, { opacity: 1.0 }).addTo(map);
|
|
layers.biome = L.imageOverlay('world_biome.png', bounds, { opacity: 0.0 }).addTo(map);
|
|
layers.height = L.imageOverlay('world_height.png', bounds, { opacity: 0.0 }).addTo(map);
|
|
|
|
let activeLayer = 'composite';
|
|
|
|
function toggleLayer(name) {
|
|
if (name === activeLayer) return;
|
|
layers[activeLayer].setOpacity(0.0);
|
|
layers[name].setOpacity(1.0);
|
|
document.getElementById('btn-' + activeLayer).classList.remove('active');
|
|
document.getElementById('btn-' + name).classList.add('active');
|
|
activeLayer = name;
|
|
}
|
|
|
|
let markerLayer = L.layerGroup().addTo(map);
|
|
let allMarkers = [];
|
|
let activeCategories = new Set(Object.keys(CAT_COUNTS));
|
|
|
|
function createMarkers() {
|
|
markerLayer.clearLayers();
|
|
allMarkers = [];
|
|
|
|
POIS.forEach(function(p) {
|
|
if (!activeCategories.has(p.category)) return;
|
|
|
|
const color = CAT_COLORS[p.category] || '#888';
|
|
const icon = L.divIcon({
|
|
className: '',
|
|
html: '<div style="width:10px;height:10px;border-radius:50%;background:' + color + ';border:2px solid rgba(255,255,255,0.5);box-shadow:0 0 4px rgba(0,0,0,0.5);"></div>',
|
|
iconSize: [10, 10],
|
|
iconAnchor: [5, 5],
|
|
});
|
|
|
|
const marker = L.marker([p.py, p.px], { icon: icon }).addTo(markerLayer);
|
|
|
|
let popup = '<div class="popup-content">';
|
|
popup += '<div class="name">' + p.name + '</div>';
|
|
popup += '<div class="coords">(' + p.x + ', ' + p.z + ')</div>';
|
|
popup += '<div style="color:' + color + ';">' + p.category + '</div>';
|
|
if (p.has_dungeon) popup += '<span class="badge badge-dungeon">Dungeon</span>';
|
|
if (p.has_important) popup += '<span class="badge badge-treasure">Has Loot</span>';
|
|
|
|
const items = CONTENTS.filter(function(c) { return c.loc_id === p.id; });
|
|
if (items.length > 0) {
|
|
popup += '<hr style="border-color:#333;margin:4px 0;"><div style="font-size:11px;">';
|
|
for (let i = 0; i < Math.min(5, items.length); i++) {
|
|
popup += '<div>• ' + items[i].item + ' x' + items[i].count + '</div>';
|
|
}
|
|
if (items.length > 5) {
|
|
popup += '<div style="color:#888;">... +' + (items.length - 5) + ' more</div>';
|
|
}
|
|
popup += '</div>';
|
|
}
|
|
|
|
popup += '</div>';
|
|
marker.bindPopup(popup);
|
|
allMarkers.push({ marker: marker, p: p });
|
|
});
|
|
}
|
|
|
|
function buildLegend() {
|
|
const legend = document.getElementById('legend');
|
|
const order = ['boss', 'dungeon', 'vegvisir', 'vendor', 'resource', 'altar', 'structure', 'spawner', 'point_of_interest', 'other'];
|
|
let html = '';
|
|
order.forEach(function(cat) {
|
|
if (!CAT_COUNTS[cat]) return;
|
|
const color = CAT_COLORS[cat] || '#888';
|
|
const active = activeCategories.has(cat) ? 'active' : 'inactive';
|
|
html += '<div class="legend-item ' + active + '" onclick="toggleCategory(\\'' + cat + '\\')">';
|
|
html += '<span class="dot" style="background:' + color + '"></span>';
|
|
html += '<span>' + (CAT_LABELS[cat] || cat) + '</span>';
|
|
html += '<span class="count">' + CAT_COUNTS[cat] + '</span></div>';
|
|
});
|
|
legend.innerHTML = html;
|
|
}
|
|
|
|
function toggleCategory(cat) {
|
|
if (activeCategories.has(cat)) {
|
|
activeCategories.delete(cat);
|
|
} else {
|
|
activeCategories.add(cat);
|
|
}
|
|
createMarkers();
|
|
buildLegend();
|
|
}
|
|
|
|
function doSearch(query) {
|
|
const results = document.getElementById('searchResults');
|
|
if (!query || query.length < 2) { results.style.display = 'none'; return; }
|
|
|
|
const q = query.toLowerCase();
|
|
const matches = POIS.filter(function(p) {
|
|
return p.name.toLowerCase().indexOf(q) !== -1 || p.category.toLowerCase().indexOf(q) !== -1;
|
|
}).slice(0, 20);
|
|
|
|
if (matches.length === 0) { results.style.display = 'none'; return; }
|
|
|
|
let html = '';
|
|
matches.forEach(function(p) {
|
|
html += '<div onclick="flyTo(' + p.px + ', ' + p.py + ', \\'' + p.name + '\\')">';
|
|
html += '<span style="color:' + (CAT_COLORS[p.category] || '#888') + ';">●</span> ';
|
|
html += p.name + ' <span class="cat">' + p.category + '</span></div>';
|
|
});
|
|
results.innerHTML = html;
|
|
results.style.display = 'block';
|
|
}
|
|
|
|
function flyTo(px, py, name) {
|
|
map.flyTo([py, px], 1, { duration: 0.5 });
|
|
document.getElementById('searchResults').style.display = 'none';
|
|
document.getElementById('search').value = name;
|
|
}
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (!e.target.closest('.search-box')) {
|
|
document.getElementById('searchResults').style.display = 'none';
|
|
}
|
|
});
|
|
|
|
createMarkers();
|
|
buildLegend();
|
|
map.fitBounds(bounds);
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
with open(VIEWER_PATH, 'w') as f:
|
|
f.write(html)
|
|
print(f"Viewer written to {VIEWER_PATH}")
|
|
|
|
if __name__ == '__main__':
|
|
print("Building Valheim map viewer...")
|
|
pois, contents = query_pois()
|
|
print(f"Loaded {len(pois)} POIs and {len(contents)} important contents")
|
|
build_viewer(pois, contents)
|
|
print("Done!")
|