26-1-2026

This commit is contained in:
2026-01-26 13:05:57 +00:00
parent cdfab8fd1e
commit ccc9a894b7
47 changed files with 3470 additions and 1601 deletions

View File

@@ -49,6 +49,67 @@ struct Position {
y: f32,
}
// Labels response (world_map_icons with icon_type == 16)
#[derive(Serialize)]
struct LabelsResponse {
labels: Vec<Label>,
}
#[derive(Serialize)]
struct Label {
x: f32,
y: f32,
text: String,
font_size: i32,
}
// Entrances response (world_teleporters)
#[derive(Serialize)]
struct EntrancesResponse {
icon_base64: String,
entrances: Vec<Entrance>,
}
#[derive(Serialize)]
struct Entrance {
pos_x: f32,
pos_y: f32,
tp_x: Option<f32>,
tp_y: Option<f32>,
}
// Ground Items response (world_loot)
#[derive(Serialize)]
struct GroundItemsResponse {
icon_base64: String,
items: Vec<GroundItem>,
}
#[derive(Serialize)]
struct GroundItem {
x: f32,
y: f32,
name: String,
amount: i32,
respawn_time: i32,
}
// Houses response (player_houses)
#[derive(Serialize)]
struct HousesResponse {
icon_base64: String,
houses: Vec<House>,
}
#[derive(Serialize)]
struct House {
x: f32,
y: f32,
name: String,
description: String,
price: i32,
}
// Establish database connection
fn establish_connection(database_url: &str) -> Result<DbConnection, diesel::ConnectionError> {
SqliteConnection::establish(database_url)
@@ -201,6 +262,212 @@ async fn get_resources(
Ok(Json(ResourceResponse { resources }))
}
// Get labels from world_map_icons where icon_type == 16
async fn get_labels(State(state): State<Arc<AppState>>) -> Result<Json<LabelsResponse>, StatusCode> {
use cursebreaker_parser::schema::world_map_icons;
let mut conn = establish_connection(&state.database_url).map_err(|e| {
tracing::error!("Database connection error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let results = world_map_icons::table
.filter(world_map_icons::icon_type.eq(16))
.select((
world_map_icons::pos_x,
world_map_icons::pos_y,
world_map_icons::text,
world_map_icons::font_size,
))
.load::<(f32, f32, String, i32)>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying labels: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let labels: Vec<Label> = results
.into_iter()
.map(|(pos_x, pos_y, text, font_size)| Label {
x: pos_x * 5.12,
y: pos_y * 5.12,
text,
font_size,
})
.collect();
info!("Returning {} labels", labels.len());
Ok(Json(LabelsResponse { labels }))
}
// Get entrances from world_teleporters
async fn get_entrances(
State(state): State<Arc<AppState>>,
) -> Result<Json<EntrancesResponse>, StatusCode> {
use cursebreaker_parser::schema::{general_icons, world_teleporters};
let mut conn = establish_connection(&state.database_url).map_err(|e| {
tracing::error!("Database connection error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Get the Entrance icon
let icon_bytes: Vec<u8> = general_icons::table
.filter(general_icons::name.eq("Entrance"))
.select(general_icons::icon_32)
.first::<Option<Vec<u8>>>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying entrance icon: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.unwrap_or_default();
let icon_base64 = base64::engine::general_purpose::STANDARD.encode(&icon_bytes);
// Get teleporter positions
let results = world_teleporters::table
.select((
world_teleporters::pos_x,
world_teleporters::pos_y,
world_teleporters::tp_x,
world_teleporters::tp_y,
))
.load::<(f32, f32, Option<f32>, Option<f32>)>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying teleporters: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let entrances: Vec<Entrance> = results
.into_iter()
.map(|(pos_x, pos_y, tp_x, tp_y)| Entrance {
pos_x: pos_x * 5.12,
pos_y: pos_y * 5.12,
tp_x: tp_x.map(|x| x * 5.12),
tp_y: tp_y.map(|y| y * 5.12),
})
.collect();
info!("Returning {} entrances", entrances.len());
Ok(Json(EntrancesResponse {
icon_base64,
entrances,
}))
}
// Get ground items from world_loot
async fn get_ground_items(
State(state): State<Arc<AppState>>,
) -> Result<Json<GroundItemsResponse>, StatusCode> {
use cursebreaker_parser::schema::{general_icons, items, world_loot};
let mut conn = establish_connection(&state.database_url).map_err(|e| {
tracing::error!("Database connection error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Get the Common_tick icon
let icon_bytes: Vec<u8> = general_icons::table
.filter(general_icons::name.eq("Common_tick"))
.select(general_icons::icon_32)
.first::<Option<Vec<u8>>>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying common_tick icon: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.unwrap_or_default();
let icon_base64 = base64::engine::general_purpose::STANDARD.encode(&icon_bytes);
// Get world loot with item names
let results = world_loot::table
.inner_join(items::table.on(world_loot::item_id.eq(items::id.assume_not_null())))
.select((
world_loot::pos_x,
world_loot::pos_y,
items::name,
world_loot::amount,
world_loot::respawn_time,
))
.load::<(f32, f32, String, i32, i32)>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying ground items: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let ground_items: Vec<GroundItem> = results
.into_iter()
.map(|(pos_x, pos_y, name, amount, respawn_time)| GroundItem {
x: pos_x * 5.12,
y: pos_y * 5.12,
name,
amount,
respawn_time,
})
.collect();
info!("Returning {} ground items", ground_items.len());
Ok(Json(GroundItemsResponse {
icon_base64,
items: ground_items,
}))
}
// Get player houses
async fn get_houses(State(state): State<Arc<AppState>>) -> Result<Json<HousesResponse>, StatusCode> {
use cursebreaker_parser::schema::{general_icons, player_houses};
let mut conn = establish_connection(&state.database_url).map_err(|e| {
tracing::error!("Database connection error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Get the Notifications_House icon (64px)
let icon_bytes: Vec<u8> = general_icons::table
.filter(general_icons::name.eq("Notifications_House"))
.select(general_icons::icon_64)
.first::<Option<Vec<u8>>>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying house icon: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.unwrap_or_default();
let icon_base64 = base64::engine::general_purpose::STANDARD.encode(&icon_bytes);
// Get player houses
let results = player_houses::table
.select((
player_houses::pos_x,
player_houses::pos_z,
player_houses::name,
player_houses::description,
player_houses::price,
))
.load::<(f32, f32, String, String, i32)>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying player houses: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let houses: Vec<House> = results
.into_iter()
.map(|(pos_x, pos_z, name, description, price)| House {
x: pos_x * 5.12,
y: pos_z * 5.12,
name,
description,
price,
})
.collect();
info!("Returning {} houses", houses.len());
Ok(Json(HousesResponse { icon_base64, houses }))
}
#[tokio::main]
async fn main() {
// Initialize tracing
@@ -219,6 +486,10 @@ async fn main() {
.route("/api/bounds", get(get_bounds))
.route("/api/tiles/:z/:x/:y", get(get_tile))
.route("/api/resources", get(get_resources))
.route("/api/labels", get(get_labels))
.route("/api/entrances", get(get_entrances))
.route("/api/ground-items", get(get_ground_items))
.route("/api/houses", get(get_houses))
.nest_service("/", ServeDir::new("static"))
.layer(CorsLayer::permissive())
.with_state(state);

View File

@@ -21,6 +21,46 @@
<p class="subtitle">The Black Grimoire: Cursebreaker</p>
</div>
<div class="filters-section">
<h3>Labels</h3>
<div class="filter-controls">
<label class="filter-label master-toggle">
<input type="checkbox" id="labels-toggle" checked>
<span>Show Labels</span>
</label>
</div>
</div>
<div class="filters-section">
<h3>Entrances</h3>
<div class="filter-controls">
<label class="filter-label master-toggle">
<input type="checkbox" id="entrances-toggle" checked>
<span>Show Entrances</span>
</label>
</div>
</div>
<div class="filters-section">
<h3>Ground Items</h3>
<div class="filter-controls">
<label class="filter-label master-toggle">
<input type="checkbox" id="ground-items-toggle" checked>
<span>Show Ground Items</span>
</label>
</div>
</div>
<div class="filters-section">
<h3>Houses</h3>
<div class="filter-controls">
<label class="filter-label master-toggle">
<input type="checkbox" id="houses-toggle" checked>
<span>Show Houses</span>
</label>
</div>
</div>
<div class="filters-section">
<h3>Resources</h3>
<div class="filter-controls">
@@ -59,5 +99,6 @@
<!-- Custom JS -->
<script src="map.js"></script>
<script src="resources.js"></script>
<script src="markers.js"></script>
</body>
</html>

View File

@@ -120,6 +120,9 @@ async function initMap() {
console.error('Failed to load resources:', error);
});
// Load markers (labels, entrances, ground items, houses)
initMarkers();
} catch (error) {
console.error('Error initializing map:', error);
document.getElementById('map-stats').innerHTML =

View File

@@ -0,0 +1,377 @@
// Markers management for Cursebreaker map (Labels, Entrances, Ground Items, Houses)
// Layer groups for each marker type
let labelsLayerGroup = null;
let entrancesLayerGroup = null;
let groundItemsLayerGroup = null;
let housesLayerGroup = null;
// Store active teleport lines for entrances
let activeTeleportLine = null;
// Initialize all markers when map is ready
function initMarkers() {
// Load all marker types in parallel
Promise.all([
loadLabels(),
loadEntrances(),
loadGroundItems(),
loadHouses(),
]).catch(error => {
console.error('Error loading markers:', error);
});
// Set up toggle handlers
setupMarkerToggles();
}
// Set up toggle event handlers
function setupMarkerToggles() {
const labelsToggle = document.getElementById('labels-toggle');
const entrancesToggle = document.getElementById('entrances-toggle');
const groundItemsToggle = document.getElementById('ground-items-toggle');
const housesToggle = document.getElementById('houses-toggle');
if (labelsToggle) {
labelsToggle.addEventListener('change', (e) => {
toggleLayer(labelsLayerGroup, e.target.checked);
saveMarkerState('labels', e.target.checked);
});
}
if (entrancesToggle) {
entrancesToggle.addEventListener('change', (e) => {
toggleLayer(entrancesLayerGroup, e.target.checked);
saveMarkerState('entrances', e.target.checked);
// Remove active teleport line when hiding entrances
if (!e.target.checked && activeTeleportLine) {
map.removeLayer(activeTeleportLine);
activeTeleportLine = null;
}
});
}
if (groundItemsToggle) {
groundItemsToggle.addEventListener('change', (e) => {
toggleLayer(groundItemsLayerGroup, e.target.checked);
saveMarkerState('groundItems', e.target.checked);
});
}
if (housesToggle) {
housesToggle.addEventListener('change', (e) => {
toggleLayer(housesLayerGroup, e.target.checked);
saveMarkerState('houses', e.target.checked);
});
}
// Restore saved state
restoreMarkerState();
}
// Toggle layer visibility
function toggleLayer(layerGroup, visible) {
if (!layerGroup) return;
if (visible) {
layerGroup.addTo(map);
} else {
map.removeLayer(layerGroup);
}
}
// Save marker visibility state
function saveMarkerState(type, visible) {
try {
const state = JSON.parse(localStorage.getItem('cursebreaker_marker_state') || '{}');
state[type] = visible;
localStorage.setItem('cursebreaker_marker_state', JSON.stringify(state));
} catch (error) {
console.warn('Failed to save marker state:', error);
}
}
// Restore marker visibility state
function restoreMarkerState() {
try {
const state = JSON.parse(localStorage.getItem('cursebreaker_marker_state') || '{}');
// Update checkboxes and layers based on saved state
setTimeout(() => {
if (state.labels === false) {
const toggle = document.getElementById('labels-toggle');
if (toggle) {
toggle.checked = false;
toggleLayer(labelsLayerGroup, false);
}
}
if (state.entrances === false) {
const toggle = document.getElementById('entrances-toggle');
if (toggle) {
toggle.checked = false;
toggleLayer(entrancesLayerGroup, false);
}
}
if (state.groundItems === false) {
const toggle = document.getElementById('ground-items-toggle');
if (toggle) {
toggle.checked = false;
toggleLayer(groundItemsLayerGroup, false);
}
}
if (state.houses === false) {
const toggle = document.getElementById('houses-toggle');
if (toggle) {
toggle.checked = false;
toggleLayer(housesLayerGroup, false);
}
}
}, 200);
} catch (error) {
console.warn('Failed to restore marker state:', error);
}
}
// Load labels (text markers on the map)
async function loadLabels() {
try {
console.log('Loading labels...');
const response = await fetch('/api/labels');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${data.labels.length} labels`);
labelsLayerGroup = L.layerGroup();
for (const label of data.labels) {
// Create a divIcon with the label text
const labelIcon = L.divIcon({
className: 'map-label',
html: `<div class="label-text" style="font-size: ${label.font_size}px;">${label.text}</div>`,
iconSize: null, // Let CSS handle sizing
iconAnchor: [0, 0],
});
const marker = L.marker([label.y, label.x], {
icon: labelIcon,
interactive: false, // Labels shouldn't be clickable
});
marker.addTo(labelsLayerGroup);
}
labelsLayerGroup.addTo(map);
console.log('Labels loaded successfully');
} catch (error) {
console.error('Error loading labels:', error);
}
}
// Load entrances (teleporters with lines)
async function loadEntrances() {
try {
console.log('Loading entrances...');
const response = await fetch('/api/entrances');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${data.entrances.length} entrances`);
entrancesLayerGroup = L.layerGroup();
// Create icon from base64
const iconUrl = `data:image/webp;base64,${data.icon_base64}`;
const entranceIcon = L.icon({
iconUrl: iconUrl,
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -16],
});
for (const entrance of data.entrances) {
const marker = L.marker([entrance.pos_y, entrance.pos_x], {
icon: entranceIcon,
title: 'Entrance',
});
// Store teleport destination on the marker
marker.teleportDest = {
x: entrance.tp_x,
y: entrance.tp_y,
};
// Handle click to show teleport line
marker.on('click', function(e) {
// Remove existing line if any
if (activeTeleportLine) {
map.removeLayer(activeTeleportLine);
activeTeleportLine = null;
}
const dest = this.teleportDest;
if (dest.x !== null && dest.y !== null) {
// Create a line from entrance to destination
activeTeleportLine = L.polyline(
[
[entrance.pos_y, entrance.pos_x],
[dest.y, dest.x]
],
{
color: '#00ffff',
weight: 3,
opacity: 0.8,
dashArray: '10, 10',
}
).addTo(map);
// Add a destination marker
const destMarker = L.circleMarker([dest.y, dest.x], {
radius: 8,
color: '#00ffff',
fillColor: '#00ffff',
fillOpacity: 0.5,
}).addTo(map);
// Remove line and destination marker after 5 seconds
setTimeout(() => {
if (activeTeleportLine) {
map.removeLayer(activeTeleportLine);
activeTeleportLine = null;
}
map.removeLayer(destMarker);
}, 5000);
}
});
marker.addTo(entrancesLayerGroup);
}
entrancesLayerGroup.addTo(map);
console.log('Entrances loaded successfully');
} catch (error) {
console.error('Error loading entrances:', error);
}
}
// Format respawn time as "XXM XXS"
function formatRespawnTime(seconds) {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
if (minutes > 0 && secs > 0) {
return `${minutes}M ${secs}S`;
} else if (minutes > 0) {
return `${minutes}M`;
} else {
return `${secs}S`;
}
}
// Load ground items
async function loadGroundItems() {
try {
console.log('Loading ground items...');
const response = await fetch('/api/ground-items');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${data.items.length} ground items`);
groundItemsLayerGroup = L.layerGroup();
// Create icon from base64
const iconUrl = `data:image/webp;base64,${data.icon_base64}`;
const itemIcon = L.icon({
iconUrl: iconUrl,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12],
});
for (const item of data.items) {
const marker = L.marker([item.y, item.x], {
icon: itemIcon,
title: item.name,
});
// Build popup content
let popupContent = `<strong>${item.name}</strong>`;
if (item.amount > 1) {
popupContent += `<br/>Amount: ${item.amount}`;
}
popupContent += `<br/>Respawn: ${formatRespawnTime(item.respawn_time)}`;
marker.bindPopup(popupContent);
marker.addTo(groundItemsLayerGroup);
}
groundItemsLayerGroup.addTo(map);
console.log('Ground items loaded successfully');
} catch (error) {
console.error('Error loading ground items:', error);
}
}
// Load player houses
async function loadHouses() {
try {
console.log('Loading houses...');
const response = await fetch('/api/houses');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${data.houses.length} houses`);
housesLayerGroup = L.layerGroup();
// Create icon from base64
const iconUrl = `data:image/webp;base64,${data.icon_base64}`;
const houseIcon = L.icon({
iconUrl: iconUrl,
iconSize: [64, 64],
iconAnchor: [32, 32],
popupAnchor: [0, -32],
});
for (const house of data.houses) {
const marker = L.marker([house.y, house.x], {
icon: houseIcon,
title: house.name,
});
// Format price with commas
const formattedPrice = house.price.toLocaleString();
// Build popup content
const popupContent = `
<strong>${house.name}</strong><br/>
<em>${house.description}</em><br/>
<span class="house-price">Price: ${formattedPrice} gold</span>
`;
marker.bindPopup(popupContent);
marker.addTo(housesLayerGroup);
}
housesLayerGroup.addTo(map);
console.log('Houses loaded successfully');
} catch (error) {
console.error('Error loading houses:', error);
}
}
// Call initMarkers after map is loaded
// This is called from map.js after resources are loaded

View File

@@ -259,6 +259,53 @@ body {
background: #2a2a2a;
}
/* Master toggle for marker categories */
.master-toggle {
margin-left: 0 !important;
}
.master-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #8b5cf6;
}
/* Map labels (text overlays) */
.map-label {
background: transparent;
border: none;
}
.label-text {
color: #e0e0e0;
text-shadow:
-1px -1px 2px #000,
1px -1px 2px #000,
-1px 1px 2px #000,
1px 1px 2px #000,
0 0 4px #000;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-weight: bold;
white-space: nowrap;
pointer-events: none;
}
/* House price styling in popup */
.house-price {
color: #ffd700;
font-weight: bold;
}
/* Popup styling for various marker types */
.leaflet-popup-content strong {
color: #8b5cf6;
}
.leaflet-popup-content em {
color: #a0a0a0;
font-size: 12px;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {