feat: add relay-backed web radar sharing
- publish radar state/bootstrap snapshots to an HTTP relay - add shared waypoint sync through relay APIs and SSE updates - add remote Caddy/deploy tooling and mock relay push script - add static POIs, topo-tile availability checks, and tile-load throttling - add WASM 3D map engine and Python map data-prep pipeline - update worn clothing reads to include slot metadata - add grid controls, render perf HUD, and marker/label scaling tweaks - remove embedded map resource generation in favor of disk/relay maps
This commit is contained in:
+8
-55
@@ -90,6 +90,7 @@ list(APPEND PROJECT_SOURCES
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/BoneInterpolator.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/OverlayWindow.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/GameOverlay.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/Web/ServerPublisher.cpp"
|
||||
)
|
||||
list(REMOVE_DUPLICATES PROJECT_SOURCES)
|
||||
|
||||
@@ -100,60 +101,16 @@ set(VOLKDMA_SOURCES
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Embed map PNGs as Windows RCDATA resources
|
||||
#
|
||||
# Maps present in maps/ at configure time are baked into the binary so the
|
||||
# server works out-of-the-box without copying anything next to the exe.
|
||||
# A PNG placed on disk at runtime (maps/<id>.png next to the exe) still
|
||||
# takes priority, so maps can be updated without rebuilding.
|
||||
#
|
||||
# Re-run CMake if you add new PNGs to maps/ after the initial configure.
|
||||
# EmbeddedMaps stub — maps are served from the relay server, not baked into the binary.
|
||||
# MapTileService falls back to this when no disk PNG is found; returning
|
||||
# {nullptr, 0} means it will always load from maps/ on disk (or return 404).
|
||||
# -------------------------------------------------------------------------
|
||||
set(MAPS_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/maps")
|
||||
set(EMBEDDED_MAPS_RC "${CMAKE_CURRENT_BINARY_DIR}/maps_embedded.rc")
|
||||
set(EMBEDDED_MAPS_CPP "${CMAKE_CURRENT_BINARY_DIR}/EmbeddedMaps.cpp")
|
||||
|
||||
set(MAP_IDS chernarusplus livonia namalsk banov deadfall deerisle esseker lux sakhal takistan alteria)
|
||||
set(MAP_RIDS 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011)
|
||||
|
||||
# ── Base map RCDATA embedding ─────────────────────────────────────────────────
|
||||
|
||||
set(RC_LINES "")
|
||||
set(CPP_CASES "")
|
||||
foreach(MAP_ID MAP_RID IN ZIP_LISTS MAP_IDS MAP_RIDS)
|
||||
if(EXISTS "${MAPS_SOURCE_DIR}/${MAP_ID}.png")
|
||||
string(APPEND RC_LINES "${MAP_RID} RCDATA \"${MAPS_SOURCE_DIR}/${MAP_ID}.png\"\n")
|
||||
string(APPEND CPP_CASES " if (mapId == \"${MAP_ID}\") return LoadRcData(${MAP_RID});\n")
|
||||
message(STATUS "Embedding map: ${MAP_ID}")
|
||||
else()
|
||||
message(STATUS "Skipping map (not found): ${MAP_ID}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
file(WRITE "${EMBEDDED_MAPS_RC}" "// Auto-generated by CMake - do not edit.\n${RC_LINES}")
|
||||
file(WRITE "${EMBEDDED_MAPS_CPP}"
|
||||
"// Auto-generated by CMake - do not edit.
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#include <cstdint>
|
||||
#include <utility>
|
||||
"// Auto-generated by CMake — maps served from relay server, no embedded resources.
|
||||
#include \"EmbeddedMaps.h\"
|
||||
|
||||
static std::pair<const uint8_t*, size_t> LoadRcData(int resourceId) {
|
||||
HRSRC hRes = FindResource(nullptr, MAKEINTRESOURCE(resourceId), RT_RCDATA);
|
||||
if (!hRes) return {nullptr, 0};
|
||||
HGLOBAL hData = LoadResource(nullptr, hRes);
|
||||
if (!hData) return {nullptr, 0};
|
||||
const void* ptr = LockResource(hData);
|
||||
DWORD sz = SizeofResource(nullptr, hRes);
|
||||
if (!ptr || sz == 0) return {nullptr, 0};
|
||||
return {static_cast<const uint8_t*>(ptr), sz};
|
||||
}
|
||||
|
||||
std::pair<const uint8_t*, size_t> GetEmbeddedMap(const std::string& mapId) {
|
||||
${CPP_CASES} return {nullptr, 0};
|
||||
std::pair<const uint8_t*, size_t> GetEmbeddedMap(const std::string&) {
|
||||
return {nullptr, 0};
|
||||
}
|
||||
")
|
||||
|
||||
@@ -164,11 +121,7 @@ add_executable(dayz-memory-cpp
|
||||
${FRAMEWORK_SOURCES}
|
||||
)
|
||||
|
||||
# Wire in the generated RC + CPP files.
|
||||
target_sources(dayz-memory-cpp PRIVATE
|
||||
"${EMBEDDED_MAPS_RC}"
|
||||
"${EMBEDDED_MAPS_CPP}"
|
||||
)
|
||||
target_sources(dayz-memory-cpp PRIVATE "${EMBEDDED_MAPS_CPP}")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Include directories
|
||||
|
||||
@@ -19,3 +19,6 @@
|
||||
- Minimap inset (fixed position, independent zoom level)
|
||||
- Keyboard shortcuts: G=grid, T=topo, C=combat, P=POIs
|
||||
- Route/waypoint drawing tool (manual overlay path)
|
||||
|
||||
## Bullet trails
|
||||
- change bullet trails to be bright red boxes bright pure blue trails
|
||||
@@ -69,6 +69,7 @@ OverlayConfig OverlayConfig::Load(const std::string& path) {
|
||||
if (j.contains("webPort")) cfg.webPort = j["webPort"].get<int>();
|
||||
if (j.contains("webPassword")) cfg.webPassword = j["webPassword"].get<std::string>();
|
||||
|
||||
|
||||
spdlog::info("Config: loaded from {}", path);
|
||||
} catch (const std::exception& ex) {
|
||||
spdlog::warn("Config: parse error in {} — {} — using defaults", path, ex.what());
|
||||
@@ -130,6 +131,7 @@ void OverlayConfig::Save(const std::string& path) const {
|
||||
j["webPort"] = webPort;
|
||||
j["webPassword"] = webPassword;
|
||||
|
||||
|
||||
std::ofstream f(path);
|
||||
f << j.dump(2);
|
||||
spdlog::debug("Config: saved to {}", path);
|
||||
|
||||
@@ -63,6 +63,7 @@ struct OverlayConfig {
|
||||
int webPort = 7777;
|
||||
std::string webPassword = "";
|
||||
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Load from path (returns default-constructed config on any error).
|
||||
static OverlayConfig Load(const std::string& path);
|
||||
|
||||
+11
-1
@@ -117,6 +117,16 @@ struct SkeletonBones {
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Worn item — one entry from a player's WornClothes array
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct WornItem {
|
||||
std::string slot; // "headgear","vest","back","legs","feet","gloves","armband",
|
||||
// "shoulder","melee","chest_holster", or "" if hash unknown
|
||||
std::string item; // cleanName (or typeName fallback) of the worn entity
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Player entry (full)
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -130,7 +140,7 @@ struct DayZPlayerEntry {
|
||||
bool isAdmin = false; // model matches a known invisible/admin model path
|
||||
std::string nickname;
|
||||
std::string itemInHands;
|
||||
std::vector<std::string> wornItems;
|
||||
std::vector<WornItem> wornItems;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
|
||||
+52
-5
@@ -166,11 +166,15 @@ namespace Offsets {
|
||||
constexpr uint64_t ItemQuality = 0x194; // v1.29 [manual]
|
||||
constexpr uint64_t Hands = 0x1B0; // v1.29
|
||||
constexpr uint64_t HandItemValid = 0x1CC; // v1.29 [manual]
|
||||
constexpr uint64_t WornClothes = 0x150; // v1.29 [manual] — ptr to weared-clothes grid object
|
||||
constexpr uint64_t PlayerCargoGrid = 0x150; // alias kept for compatibility
|
||||
constexpr uint64_t CargoGridCount = 0xC; // v1.29 [manual] — uint32 count at grid+0xC
|
||||
constexpr uint64_t ItemPtr = 0x8; // v1.29 [manual] — ptr to items array at grid+0x8
|
||||
constexpr uint64_t ItemSize = 0x10; // v1.29 [manual] — stride per item slot (16 bytes)
|
||||
// WornClothes: *(inv+0x150) is a DIRECT pointer to the clothes data array.
|
||||
// Each 16-byte entry: { uint32 slotHash, uint32 pad, uint64 entityPtr }
|
||||
// Count: uint32 at inv+0x15C (embedded in inventory struct, NOT at clothesPtr+0xC).
|
||||
// IDA: sub_140559180 = *(*(inv+336) + 16*i + 8); sub_14055A230 count = *(inv+348).
|
||||
constexpr uint64_t WornClothes = 0x150; // v1.29 [IDA sub_140559180] — data ptr
|
||||
constexpr uint64_t WornClothesCount = 0x15C; // v1.29 [IDA sub_14055A230] — uint32 count
|
||||
constexpr uint64_t WornSlotStride = 16; // bytes per entry
|
||||
constexpr uint64_t WornSlotHashOffset = 0; // uint32 slot-ID hash at entry+0
|
||||
constexpr uint64_t WornSlotEntityOffset = 8; // uint64 entity ptr at entry+8
|
||||
}
|
||||
|
||||
namespace SlowTable {
|
||||
@@ -210,3 +214,46 @@ namespace Offsets {
|
||||
}
|
||||
|
||||
} // namespace Offsets
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slot hash helpers (Enfusion rolling hash, case-insensitive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
constexpr uint32_t ComputeSlotHash(const char* s) noexcept {
|
||||
uint32_t h = 0;
|
||||
while (*s) {
|
||||
char c = *s++;
|
||||
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + ('a' - 'A'));
|
||||
h = static_cast<uint32_t>(c) + 37u * h;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
namespace SlotHashes {
|
||||
constexpr uint32_t Headgear = ComputeSlotHash("slotHeadgear");
|
||||
constexpr uint32_t Vest = ComputeSlotHash("slotVest");
|
||||
constexpr uint32_t Back = ComputeSlotHash("slotBack");
|
||||
constexpr uint32_t Legs = ComputeSlotHash("slotLegs");
|
||||
constexpr uint32_t Feet = ComputeSlotHash("slotFeet");
|
||||
constexpr uint32_t Gloves = ComputeSlotHash("slotGloves");
|
||||
constexpr uint32_t Armband = ComputeSlotHash("slotArmband");
|
||||
constexpr uint32_t Shoulder = ComputeSlotHash("slotShoulder");
|
||||
constexpr uint32_t Melee = ComputeSlotHash("slotMelee");
|
||||
constexpr uint32_t ChestHolster = ComputeSlotHash("ChestHolster");
|
||||
}
|
||||
|
||||
inline const char* SlotNameFromHash(uint32_t hash) noexcept {
|
||||
switch (hash) {
|
||||
case SlotHashes::Headgear: return "headgear";
|
||||
case SlotHashes::Vest: return "vest";
|
||||
case SlotHashes::Back: return "back";
|
||||
case SlotHashes::Legs: return "legs";
|
||||
case SlotHashes::Feet: return "feet";
|
||||
case SlotHashes::Gloves: return "gloves";
|
||||
case SlotHashes::Armband: return "armband";
|
||||
case SlotHashes::Shoulder: return "shoulder";
|
||||
case SlotHashes::Melee: return "melee";
|
||||
case SlotHashes::ChestHolster: return "chest_holster";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ std::vector<DayZPlayerEntry> EntityCategoryProjector::BuildPlayers(
|
||||
std::function<std::string(uint64_t)> heldItemResolver,
|
||||
std::function<float(uint64_t)> healthResolver,
|
||||
std::function<bool(uint64_t)> adminResolver,
|
||||
std::function<std::vector<std::string>(uint64_t)> wornClothesResolver)
|
||||
std::function<std::vector<WornItem>(uint64_t)> wornClothesResolver)
|
||||
{
|
||||
auto all = EnumerateEntities(near, far, slow);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ public:
|
||||
std::function<std::string(uint64_t)> heldItemResolver,
|
||||
std::function<float(uint64_t)> healthResolver,
|
||||
std::function<bool(uint64_t)> adminResolver = nullptr,
|
||||
std::function<std::vector<std::string>(uint64_t)> wornClothesResolver = nullptr);
|
||||
std::function<std::vector<WornItem>(uint64_t)> wornClothesResolver = nullptr);
|
||||
|
||||
static std::vector<DayZAnimalEntry> BuildAnimals(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
|
||||
@@ -568,17 +568,14 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
|
||||
|| !MemoryValidation::IsValidUserAddress(inventoryAddr))
|
||||
return false;
|
||||
|
||||
uint64_t clothesGrid = 0;
|
||||
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesGrid)
|
||||
|| !MemoryValidation::IsValidUserAddress(clothesGrid))
|
||||
// *(inv+0x150) = direct ptr to clothes data array; count at inv+0x15C.
|
||||
uint64_t clothesData = 0;
|
||||
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesData)
|
||||
|| !MemoryValidation::IsValidUserAddress(clothesData))
|
||||
return false;
|
||||
|
||||
uint64_t itemsArray = 0;
|
||||
uint32_t count = 0;
|
||||
if (!m_memory.TryReadPointer(pid, clothesGrid + Offsets::Inventory::ItemPtr, itemsArray)
|
||||
|| !MemoryValidation::IsValidUserAddress(itemsArray))
|
||||
return false;
|
||||
if (!m_memory.TryReadValue<uint32_t>(pid, clothesGrid + Offsets::Inventory::CargoGridCount, count)
|
||||
if (!m_memory.TryReadValue<uint32_t>(pid, inventoryAddr + Offsets::Inventory::WornClothesCount, count)
|
||||
|| count == 0 || count > 32)
|
||||
return false;
|
||||
|
||||
@@ -599,9 +596,13 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Each entry is 16 bytes: { uint32 hash, uint32 pad, uint64 entityPtr }
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
uint64_t itemAddr = 0;
|
||||
if (!m_memory.TryReadPointer(pid, itemsArray + i * sizeof(uint64_t), itemAddr)
|
||||
if (!m_memory.TryReadPointer(pid,
|
||||
clothesData + i * Offsets::Inventory::WornSlotStride
|
||||
+ Offsets::Inventory::WornSlotEntityOffset,
|
||||
itemAddr)
|
||||
|| !MemoryValidation::IsValidUserAddress(itemAddr))
|
||||
continue;
|
||||
|
||||
@@ -635,36 +636,42 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
|
||||
};
|
||||
|
||||
// --- wornClothesResolver ---
|
||||
// Chain: entity → +0x650 (inventory) → +0x150 (clothes grid ptr)
|
||||
// grid → +0x8 (items array ptr), +0xC (uint32 count)
|
||||
// items[i*8] → item entity → +0x180 (type) → cleanName
|
||||
auto wornClothesResolver = [&](uint64_t addr) -> std::vector<std::string> {
|
||||
std::vector<std::string> result;
|
||||
// Chain: entity → inv (entity+0x650) → clothesData (*(inv+0x150), direct array ptr)
|
||||
// count at inv+0x15C; each 16-byte entry: { uint32 slotHash, uint32 pad, uint64 entityPtr }
|
||||
// IDA evidence: sub_140559180 = *(*(inv+336)+16*i+8); sub_14055A230 count=*(inv+348).
|
||||
auto wornClothesResolver = [&](uint64_t addr) -> std::vector<WornItem> {
|
||||
std::vector<WornItem> result;
|
||||
|
||||
uint64_t inventoryAddr = 0;
|
||||
if (!m_memory.TryReadPointer(pid, addr + RuntimeOffsets::Inventory::Base, inventoryAddr)
|
||||
|| !MemoryValidation::IsValidUserAddress(inventoryAddr))
|
||||
return result;
|
||||
|
||||
uint64_t clothesGrid = 0;
|
||||
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesGrid)
|
||||
|| !MemoryValidation::IsValidUserAddress(clothesGrid))
|
||||
return result;
|
||||
|
||||
uint64_t itemsArray = 0;
|
||||
if (!m_memory.TryReadPointer(pid, clothesGrid + Offsets::Inventory::ItemPtr, itemsArray)
|
||||
|| !MemoryValidation::IsValidUserAddress(itemsArray))
|
||||
// *(inv+0x150) = direct pointer to clothes data array
|
||||
uint64_t clothesData = 0;
|
||||
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesData)
|
||||
|| !MemoryValidation::IsValidUserAddress(clothesData))
|
||||
return result;
|
||||
|
||||
// Count embedded in inventory struct at inv+0x15C
|
||||
uint32_t count = 0;
|
||||
if (!m_memory.TryReadValue<uint32_t>(pid, clothesGrid + Offsets::Inventory::CargoGridCount, count)
|
||||
if (!m_memory.TryReadValue<uint32_t>(pid, inventoryAddr + Offsets::Inventory::WornClothesCount, count)
|
||||
|| count == 0 || count > 32)
|
||||
return result;
|
||||
|
||||
result.reserve(count);
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
const uint64_t entryBase = clothesData
|
||||
+ i * Offsets::Inventory::WornSlotStride;
|
||||
|
||||
// Read slot hash at entry+0 and entity ptr at entry+8 in one range.
|
||||
uint32_t slotHash = 0;
|
||||
m_memory.TryReadValue<uint32_t>(pid,
|
||||
entryBase + Offsets::Inventory::WornSlotHashOffset, slotHash);
|
||||
|
||||
uint64_t itemAddr = 0;
|
||||
if (!m_memory.TryReadPointer(pid, itemsArray + i * sizeof(uint64_t), itemAddr)
|
||||
if (!m_memory.TryReadPointer(pid,
|
||||
entryBase + Offsets::Inventory::WornSlotEntityOffset, itemAddr)
|
||||
|| !MemoryValidation::IsValidUserAddress(itemAddr))
|
||||
continue;
|
||||
|
||||
@@ -674,11 +681,17 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
|
||||
continue;
|
||||
|
||||
EntityTypeMetadata meta = ReadEntityTypeMetadata(m_memory, pid, typeAddr);
|
||||
std::string name;
|
||||
if (!meta.cleanName.empty()) name = meta.cleanName;
|
||||
else if (!meta.typeName.empty()) name = FormatEntityName(meta.typeName);
|
||||
else if (!meta.configName.empty()) name = meta.configName;
|
||||
if (!name.empty()) result.push_back(std::move(name));
|
||||
std::string itemName;
|
||||
if (!meta.cleanName.empty()) itemName = meta.cleanName;
|
||||
else if (!meta.typeName.empty()) itemName = FormatEntityName(meta.typeName);
|
||||
else if (!meta.configName.empty()) itemName = meta.configName;
|
||||
if (itemName.empty()) continue;
|
||||
|
||||
const char* slotNamePtr = SlotNameFromHash(slotHash);
|
||||
WornItem wi;
|
||||
wi.slot = slotNamePtr ? slotNamePtr : "";
|
||||
wi.item = std::move(itemName);
|
||||
result.push_back(std::move(wi));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
WebRadarServer::WebRadarServer(WebRadarConfig config)
|
||||
: m_config(std::move(config))
|
||||
, m_relay(m_config.relay)
|
||||
{}
|
||||
|
||||
WebRadarServer::~WebRadarServer() {
|
||||
@@ -83,9 +84,12 @@ void WebRadarServer::Start() {
|
||||
m_config.bindAddress, m_config.port);
|
||||
}
|
||||
});
|
||||
|
||||
m_relay.Start();
|
||||
}
|
||||
|
||||
void WebRadarServer::Stop() {
|
||||
m_relay.Stop();
|
||||
m_stopping.store(true);
|
||||
m_cv.notify_all();
|
||||
if (m_server) m_server->stop();
|
||||
@@ -103,11 +107,14 @@ void WebRadarServer::PushSnapshot(const RuntimeUpdate& update) {
|
||||
// trajectory rendering, and the update is cheap (no JSON involved).
|
||||
m_bullets.Update(update.bullets, now);
|
||||
|
||||
bool mapChanged = false;
|
||||
if (update.serverMapName.has_value()) {
|
||||
const MapInfo* resolved = MapRegistry::Resolve(*update.serverMapName);
|
||||
if (resolved) {
|
||||
if (m_currentMap != resolved)
|
||||
if (m_currentMap != resolved) {
|
||||
spdlog::info("Web radar: map changed to '{}'", resolved->id);
|
||||
mapChanged = true;
|
||||
}
|
||||
m_currentMap = resolved;
|
||||
} else {
|
||||
spdlog::warn("Web radar: unrecognised server map name '{}' — keeping current map",
|
||||
@@ -121,9 +128,25 @@ void WebRadarServer::PushSnapshot(const RuntimeUpdate& update) {
|
||||
if (now < m_nextSnapshotMs) return;
|
||||
m_nextSnapshotMs = now + m_config.snapshotIntervalMs;
|
||||
|
||||
auto buildT0 = std::chrono::steady_clock::now();
|
||||
std::string jsonStr = m_snapshotSvc.BuildStateJson(
|
||||
update, m_bullets.GetSnapshot(), m_currentMap, now);
|
||||
auto buildUs = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::steady_clock::now() - buildT0).count();
|
||||
|
||||
// Throttled perf log (~once / 5 s): payload build time + serialized size.
|
||||
static int64_t s_nextPerfLogMs = 0;
|
||||
if (now >= s_nextPerfLogMs) {
|
||||
s_nextPerfLogMs = now + 5000;
|
||||
size_t entities = update.players.size() + update.zombies.size()
|
||||
+ update.animals.size() + update.carsAndBoats.size()
|
||||
+ update.otherEntities.size() + update.items.size();
|
||||
spdlog::info("[perf] state json: build={:.2f} ms size={:.1f} KB entities={}",
|
||||
buildUs / 1000.0, jsonStr.size() / 1024.0, entities);
|
||||
}
|
||||
|
||||
std::string relayState, relayBootstrap;
|
||||
bool sendRelayBootstrap = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_cvMutex);
|
||||
|
||||
@@ -134,13 +157,21 @@ void WebRadarServer::PushSnapshot(const RuntimeUpdate& update) {
|
||||
m_bootstrapJson = m_snapshotSvc.BuildBootstrapJson(
|
||||
update, m_currentMap, m_config.port);
|
||||
m_lastBootstrapMapId = std::move(mapId);
|
||||
sendRelayBootstrap = true;
|
||||
}
|
||||
|
||||
m_latestJson = std::move(jsonStr);
|
||||
m_latestPayload = "event: state\ndata: " + m_latestJson + "\n\n";
|
||||
++m_broadcastSeq;
|
||||
|
||||
relayState = m_latestJson;
|
||||
if (sendRelayBootstrap) relayBootstrap = m_bootstrapJson;
|
||||
}
|
||||
m_cv.notify_all();
|
||||
|
||||
m_relay.PushState(std::move(relayState));
|
||||
if (sendRelayBootstrap)
|
||||
m_relay.PushBootstrap(std::move(relayBootstrap));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -297,7 +328,18 @@ void WebRadarServer::SetupRoutes() {
|
||||
}
|
||||
|
||||
int code = 200;
|
||||
auto tileT0 = std::chrono::steady_clock::now();
|
||||
auto bytes = m_tiles.GetTile(*tileMap, tx, ty, code);
|
||||
auto tileUs = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::steady_clock::now() - tileT0).count();
|
||||
// Throttled tile-serve latency log (~once / 5 s).
|
||||
static int64_t s_nextTilePerfMs = 0;
|
||||
int64_t tileNow = NowMs();
|
||||
if (tileNow >= s_nextTilePerfMs) {
|
||||
s_nextTilePerfMs = tileNow + 5000;
|
||||
spdlog::info("[perf] tile serve: {:.2f} ms size={:.1f} KB code={}",
|
||||
tileUs / 1000.0, bytes.size() / 1024.0, code);
|
||||
}
|
||||
res.status = code;
|
||||
if (code == 200) {
|
||||
// Cache tiles aggressively — they're static PNG slices that only
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "Web/MapRegistry.h"
|
||||
#include "Web/MapTileService.h"
|
||||
#include "Web/WebSnapshotService.h"
|
||||
#include "Web/ServerPublisher.h"
|
||||
|
||||
// Forward-declare httplib types to avoid pulling the whole header (which
|
||||
// includes <Winsock2.h>) into every translation unit that includes this header.
|
||||
@@ -24,6 +25,7 @@ struct WebRadarConfig {
|
||||
int port = 7777;
|
||||
std::string password = "";
|
||||
int snapshotIntervalMs = 100; // max web snapshot rate (default 10 Hz)
|
||||
ServerPublisher::Config relay;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -54,6 +56,7 @@ private:
|
||||
MapTileService m_tiles;
|
||||
MapTileService m_topoTiles{"maps/topo"};
|
||||
const MapInfo* m_currentMap = nullptr;
|
||||
ServerPublisher m_relay;
|
||||
|
||||
std::unique_ptr<httplib::Server> m_server;
|
||||
std::thread m_thread;
|
||||
|
||||
@@ -71,7 +71,16 @@ static json PlayerEntityJson(const DayZPlayerEntry& p, const MapInfo* map) {
|
||||
obj["steamId"] = "";
|
||||
obj["dead"] = p.isDead;
|
||||
obj["handItem"] = p.itemInHands;
|
||||
obj["wornItems"] = p.wornItems;
|
||||
{
|
||||
json arr = json::array();
|
||||
for (const auto& w : p.wornItems) {
|
||||
json e;
|
||||
e["slot"] = w.slot;
|
||||
e["item"] = w.item;
|
||||
arr.push_back(std::move(e));
|
||||
}
|
||||
obj["wornItems"] = std::move(arr);
|
||||
}
|
||||
obj["visibleOnMap"] = true;
|
||||
return obj;
|
||||
}
|
||||
@@ -84,7 +93,16 @@ static json PlayerListEntryJson(const DayZPlayerEntry& p) {
|
||||
obj["visibleOnMap"] = true;
|
||||
obj["dead"] = p.isDead;
|
||||
obj["handItem"] = p.itemInHands;
|
||||
obj["wornItems"] = p.wornItems;
|
||||
{
|
||||
json arr = json::array();
|
||||
for (const auto& w : p.wornItems) {
|
||||
json e;
|
||||
e["slot"] = w.slot;
|
||||
e["item"] = w.item;
|
||||
arr.push_back(std::move(e));
|
||||
}
|
||||
obj["wornItems"] = std::move(arr);
|
||||
}
|
||||
obj["distance"] = -1;
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ int main() {
|
||||
webCfg.bindAddress = cfg.webBindAddress;
|
||||
webCfg.port = cfg.webPort;
|
||||
webCfg.password = cfg.webPassword;
|
||||
webCfg.relay.enabled = true;
|
||||
webCfg.relay.host = "139.99.193.190";
|
||||
webCfg.relay.port = 9000;
|
||||
webCfg.relay.secret = "changeme";
|
||||
|
||||
WebRadarServer webRadar(webCfg);
|
||||
webRadar.Start();
|
||||
|
||||
+300
-33
@@ -69,6 +69,8 @@ const defaultSettings = {
|
||||
favoriteLootNames: [],
|
||||
filterStyles: {},
|
||||
filterExpanded: {},
|
||||
gridLineWeight: 2.5,
|
||||
gridLabelSize: 13,
|
||||
// v2 UI state
|
||||
sidebarState: "full",
|
||||
activeTab: "tab-map",
|
||||
@@ -139,8 +141,12 @@ const state = {
|
||||
topoElements: new Map(), // key -> img, O(1) topo lookup
|
||||
satElements: new Map(), // "z:tx:ty" -> img, O(1) satellite XYZ tile lookup
|
||||
tilesRafPending: false, // throttle updateVisibleTiles to one RAF per frame
|
||||
isZooming: false, // true while wheel is spinning; suppresses tile loads
|
||||
zoomIdleTimer: null, // setTimeout handle for zoom-settle detection
|
||||
cachedGridKey: null, // detects when grid needs rebuild
|
||||
cachedGridNode: null, // cached SVG <g> for the grid
|
||||
sharedWaypoints: [], // relay-backed waypoints visible to all clients
|
||||
topoAvailable: true, // set false on first topo-tile 404; reset on map change
|
||||
};
|
||||
|
||||
const elements = {
|
||||
@@ -170,7 +176,6 @@ const elements = {
|
||||
showLabels: document.getElementById("showLabels"),
|
||||
showPOIs: document.getElementById("showPOIs"),
|
||||
showGrid: document.getElementById("showGrid"),
|
||||
showSatellite: document.getElementById("showSatellite"),
|
||||
showSatmap: document.getElementById("showSatmap"),
|
||||
showDistanceRings: document.getElementById("showDistanceRings"),
|
||||
showMinimap: document.getElementById("showMinimap"),
|
||||
@@ -194,6 +199,10 @@ const elements = {
|
||||
labelSettingsReset: document.getElementById("labelSettingsReset"),
|
||||
textSize: document.getElementById("textSize"),
|
||||
textSizeValue: document.getElementById("textSizeValue"),
|
||||
gridLineWeight: document.getElementById("gridLineWeight"),
|
||||
gridLineWeightValue: document.getElementById("gridLineWeightValue"),
|
||||
gridLabelSize: document.getElementById("gridLabelSize"),
|
||||
gridLabelSizeValue: document.getElementById("gridLabelSizeValue"),
|
||||
minimapEl: document.getElementById("minimapEl"),
|
||||
minimapImg: document.getElementById("minimapImg"),
|
||||
minimapCanvas: document.getElementById("minimapCanvas"),
|
||||
@@ -204,12 +213,72 @@ const elements = {
|
||||
zoomDisplay: document.getElementById("zoomDisplay"),
|
||||
ctxMenu: document.getElementById("ctxMenu"),
|
||||
ctxAddWaypoint: document.getElementById("ctxAddWaypoint"),
|
||||
ctxAddSharedWaypoint: document.getElementById("ctxAddSharedWaypoint"),
|
||||
ctxCopyCoords: document.getElementById("ctxCopyCoords"),
|
||||
toastStack: document.getElementById("toastStack"),
|
||||
toggle3d: document.getElementById("toggle3d"),
|
||||
canvas3d: document.getElementById("canvas3d"),
|
||||
};
|
||||
|
||||
// ── Perf instrumentation (enable with ?perf=1) ──────────────────────────────
|
||||
// Zero-cost when disabled. Answers "is the render loop coupled to data arrival?"
|
||||
// with numbers: it samples true display fps (a continuous rAF ticker) separately
|
||||
// from how often render() actually runs and how often snapshots arrive. If
|
||||
// renders/s ≈ data Hz while display fps stays ~60, rendering is data-coupled.
|
||||
// Reports once/sec to an on-screen HUD.
|
||||
const PERF = (() => {
|
||||
const enabled = params.get("perf") === "1" || params.get("perf") === "true";
|
||||
if (!enabled) {
|
||||
const noop = () => {};
|
||||
return { enabled: false, onSnapshot: noop, onRender: noop };
|
||||
}
|
||||
const acc = { snaps: 0, bytes: 0, parseMs: 0, applyMs: 0, renders: 0,
|
||||
renderMs: 0, renderMax: 0, frames: 0, lastEntities: 0, lastBytes: 0 };
|
||||
const hud = document.createElement("div");
|
||||
hud.id = "perfHud";
|
||||
hud.style.cssText = "position:fixed;top:48px;right:8px;z-index:99999;background:rgba(0,0,0,.82);" +
|
||||
"color:#9effa0;font:11px/1.45 ui-monospace,Menlo,Consolas,monospace;padding:8px 10px;" +
|
||||
"border:1px solid #333;border-radius:6px;white-space:pre;pointer-events:none;min-width:190px";
|
||||
const attach = () => document.body && document.body.appendChild(hud);
|
||||
if (document.body) attach(); else addEventListener("DOMContentLoaded", attach);
|
||||
|
||||
// Continuous rAF ticker — measures real display refresh, independent of data.
|
||||
const tick = () => { acc.frames++; requestAnimationFrame(tick); };
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
let lastT = performance.now();
|
||||
setInterval(() => {
|
||||
const dt = (performance.now() - lastT) / 1000; lastT = performance.now();
|
||||
const per = (n) => n / dt;
|
||||
hud.textContent =
|
||||
"PERF (?perf=1)\n" +
|
||||
"data " + per(acc.snaps).toFixed(1) + " Hz\n" +
|
||||
"renders " + per(acc.renders).toFixed(1) + " /s\n" +
|
||||
"display " + per(acc.frames).toFixed(0) + " fps\n" +
|
||||
"entities " + acc.lastEntities + "\n" +
|
||||
"payload " + (acc.lastBytes / 1024).toFixed(1) + " KB\n" +
|
||||
"parse " + (acc.snaps ? acc.parseMs / acc.snaps : 0).toFixed(2) + " ms\n" +
|
||||
"apply " + (acc.snaps ? acc.applyMs / acc.snaps : 0).toFixed(2) + " ms\n" +
|
||||
"render avg " + (acc.renders ? acc.renderMs / acc.renders : 0).toFixed(2) + " ms\n" +
|
||||
"render max " + acc.renderMax.toFixed(2) + " ms";
|
||||
acc.snaps = acc.bytes = acc.parseMs = acc.applyMs = acc.renders = acc.renderMs = acc.frames = 0;
|
||||
acc.renderMax = 0;
|
||||
}, 1000);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
onSnapshot(bytes, parseMs, applyMs) {
|
||||
acc.snaps++; acc.bytes += bytes; acc.parseMs += parseMs; acc.applyMs += applyMs;
|
||||
acc.lastBytes = bytes;
|
||||
const s = state.lastSnapshot;
|
||||
acc.lastEntities = s ? ((s.players || []).length + (s.zombies || []).length +
|
||||
(s.animals || []).length + (s.vehicles || []).length + (s.otherEntities || []).length +
|
||||
(s.loot || []).length + (s.bullets || []).length) : 0;
|
||||
},
|
||||
onRender(ms) { acc.renders++; acc.renderMs += ms; if (ms > acc.renderMax) acc.renderMax = ms; },
|
||||
};
|
||||
})();
|
||||
|
||||
let filtersByKey = {};
|
||||
const filterKeyByKind = {
|
||||
players: "players",
|
||||
@@ -449,12 +518,43 @@ function applyTransform() {
|
||||
constrainViewState();
|
||||
elements.canvas.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.scale})`;
|
||||
elements.canvas.style.setProperty("--inv-scale", 1 / state.scale);
|
||||
elements.canvas.style.setProperty("--sqrt-inv-scale", 1 / Math.sqrt(state.scale));
|
||||
elements.canvas.classList.toggle("grid-labels-visible", state.scale * MGRS_STEP >= 80);
|
||||
scheduleVisibleTilesUpdate();
|
||||
if (!state.isZooming) scheduleVisibleTilesUpdate();
|
||||
updateZoomDisplay();
|
||||
if (state.measureMode) drawMeasureLayer();
|
||||
}
|
||||
|
||||
async function loadStaticPOIs(mapId, mapSize) {
|
||||
if (!mapId || !mapSize || !state.bootstrap) return;
|
||||
if ((state.bootstrap.pois || []).length > 0) return; // C++ already provided them
|
||||
try {
|
||||
const r = await fetch(`${serverOrigin}/pois/${encodeURIComponent(mapId)}.json`, { cache: "no-store" });
|
||||
if (!r.ok || !state.bootstrap) return;
|
||||
const data = await r.json();
|
||||
if (!Array.isArray(data)) return;
|
||||
state.bootstrap.pois = data.map((p) => ({
|
||||
id: p.id || "",
|
||||
label: p.label || "",
|
||||
type: p.type || "",
|
||||
x: Number(p.x) || 0,
|
||||
y: mapSize - (Number(p.z) || 0),
|
||||
}));
|
||||
render();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function checkTopoAvailable(mapId) {
|
||||
if (!mapId) { state.topoAvailable = false; scheduleVisibleTilesUpdate(); return; }
|
||||
try {
|
||||
const r = await fetch(apiUrl(`/api/topo?mapId=${encodeURIComponent(mapId)}`), { cache: "no-store" });
|
||||
state.topoAvailable = r.ok;
|
||||
} catch {
|
||||
state.topoAvailable = false;
|
||||
}
|
||||
scheduleVisibleTilesUpdate();
|
||||
}
|
||||
|
||||
function applyMapMetadata(metadata, clearTiles = false) {
|
||||
if (!metadata) return false;
|
||||
const previous = state.bootstrap;
|
||||
@@ -491,6 +591,9 @@ function applyMapMetadata(metadata, clearTiles = false) {
|
||||
elements.markers.style.height = `${next.mapSize}px`;
|
||||
if (clearTiles || changed) {
|
||||
state.tileState = {};
|
||||
state.topoAvailable = false;
|
||||
checkTopoAvailable(next.mapId);
|
||||
loadStaticPOIs(next.mapId, next.mapSize);
|
||||
for (const img of state.tileElements.values()) img.remove();
|
||||
state.tileElements.clear();
|
||||
for (const img of state.topoElements.values()) img.remove();
|
||||
@@ -801,10 +904,57 @@ function findNearestWaypoint(worldX, worldY, radius) {
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function findNearestSharedWaypoint(worldX, worldY, radius) {
|
||||
let nearest = null;
|
||||
let nearestDist = radius;
|
||||
for (const wp of (state.sharedWaypoints || [])) {
|
||||
if (typeof wp.x !== "number" || typeof wp.y !== "number") continue;
|
||||
const dist = Math.hypot(wp.x - worldX, wp.y - worldY);
|
||||
if (dist < nearestDist) { nearestDist = dist; nearest = wp; }
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
async function postSharedWaypoint(x, y, name) {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/waypoints"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ x, y, name }),
|
||||
});
|
||||
if (!res.ok) showToast("Failed to save shared waypoint", "danger");
|
||||
} catch {
|
||||
showToast("Failed to save shared waypoint", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSharedWaypoint(id) {
|
||||
try {
|
||||
const res = await fetch(apiUrl(`/api/waypoints/${id}`), { method: "DELETE" });
|
||||
if (!res.ok) showToast("Failed to delete waypoint", "danger");
|
||||
} catch {
|
||||
showToast("Failed to delete waypoint", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function showContextMenu(x, y, worldX, worldY) {
|
||||
state.contextMenuWorldPos = { x: worldX, y: worldY };
|
||||
const old = document.getElementById("ctxRemoveWaypoint");
|
||||
if (old) old.remove();
|
||||
document.getElementById("ctxRemoveWaypoint")?.remove();
|
||||
document.getElementById("ctxRemoveSharedWaypoint")?.remove();
|
||||
|
||||
const nearShared = findNearestSharedWaypoint(worldX, worldY, 30 / state.scale);
|
||||
if (nearShared) {
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.id = "ctxRemoveSharedWaypoint";
|
||||
removeBtn.className = "ctx-item";
|
||||
removeBtn.textContent = `Remove shared${nearShared.name ? `: ${nearShared.name}` : " waypoint"}`;
|
||||
removeBtn.addEventListener("click", () => {
|
||||
deleteSharedWaypoint(nearShared.id);
|
||||
hideContextMenu();
|
||||
});
|
||||
elements.ctxMenu.insertBefore(removeBtn, elements.ctxAddWaypoint);
|
||||
}
|
||||
|
||||
const near = findNearestWaypoint(worldX, worldY, 30 / state.scale);
|
||||
if (near) {
|
||||
const removeBtn = document.createElement("button");
|
||||
@@ -820,6 +970,7 @@ function showContextMenu(x, y, worldX, worldY) {
|
||||
});
|
||||
elements.ctxMenu.insertBefore(removeBtn, elements.ctxAddWaypoint);
|
||||
}
|
||||
|
||||
elements.ctxMenu.style.left = `${x}px`;
|
||||
elements.ctxMenu.style.top = `${y}px`;
|
||||
elements.ctxMenu.classList.add("open");
|
||||
@@ -827,8 +978,8 @@ function showContextMenu(x, y, worldX, worldY) {
|
||||
|
||||
function hideContextMenu() {
|
||||
elements.ctxMenu.classList.remove("open");
|
||||
const old = document.getElementById("ctxRemoveWaypoint");
|
||||
if (old) old.remove();
|
||||
document.getElementById("ctxRemoveWaypoint")?.remove();
|
||||
document.getElementById("ctxRemoveSharedWaypoint")?.remove();
|
||||
}
|
||||
|
||||
// ── Minimap ────────────────────────────────────────────────────────────────
|
||||
@@ -893,6 +1044,13 @@ function updateMinimap() {
|
||||
ctx.fillStyle = "#e2e8f0";
|
||||
ctx.fill();
|
||||
}
|
||||
for (const wp of (state.sharedWaypoints || [])) {
|
||||
if (typeof wp.x !== "number" || typeof wp.y !== "number") continue;
|
||||
ctx.beginPath();
|
||||
ctx.arc(wp.x * mmScale, wp.y * mmScale, 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = wp.color || "#f59e0b";
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filter / visibility ────────────────────────────────────────────────────
|
||||
@@ -1066,9 +1224,8 @@ function estimateLabelSize(text, fontSize) {
|
||||
|
||||
function getScaledLabelLayoutSize(kind, itemOrGroup, text, fontSize) {
|
||||
const size = estimateLabelSize(text, fontSize);
|
||||
if (!useFixedScreenLabel(kind, itemOrGroup)) return size;
|
||||
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
|
||||
return { width: size.width / effectiveScale, height: size.height / effectiveScale };
|
||||
return { width: size.width / Math.sqrt(effectiveScale), height: size.height / Math.sqrt(effectiveScale) };
|
||||
}
|
||||
|
||||
function buildPinnedLabelCandidates(entry, size) {
|
||||
@@ -1412,7 +1569,7 @@ function computeLabelPlacements(entries) {
|
||||
|
||||
const groups = buildOrdinaryLootLabelGroups(entries);
|
||||
for (const group of groups) {
|
||||
const size = estimateLabelSize(group.labelText, group.label.textSize);
|
||||
const size = getScaledLabelLayoutSize("loot", group, group.labelText, group.label.textSize);
|
||||
const candidates = buildOrdinaryGroupLabelCandidates(group, size);
|
||||
let bestPlacement = null;
|
||||
let bestScore = Number.POSITIVE_INFINITY;
|
||||
@@ -1846,13 +2003,18 @@ function createMarker(entry) {
|
||||
glyph.style.background = markerColor;
|
||||
glyph.style.borderWidth = `${borderSize}px`;
|
||||
glyph.style.color = markerColor;
|
||||
{
|
||||
const _s = Math.max(minMapScale, Number(state.scale) || 1);
|
||||
glyph.style.transform = `translate(-50%, -50%) scale(${1 / Math.sqrt(_s)})`;
|
||||
}
|
||||
|
||||
if (kind === "players") {
|
||||
const direction = document.createElement("div");
|
||||
direction.className = "direction entity-direction";
|
||||
direction.style.transform = `translate(-50%, -100%) rotate(${180 - (item.rotation || 0)}deg)`;
|
||||
direction.style.color = markerColor;
|
||||
direction.style.height = `${Math.max(10, Number(filterStyle?.directionLength || 28))}px`;
|
||||
const _dirScale = Math.max(minMapScale, Number(state.scale) || 1);
|
||||
direction.style.height = `${Math.max(10, Number(filterStyle?.directionLength || 28)) / Math.sqrt(_dirScale)}px`;
|
||||
marker.appendChild(direction);
|
||||
}
|
||||
|
||||
@@ -1866,11 +2028,9 @@ function createMarker(entry) {
|
||||
labelNode.style.color = markerColor;
|
||||
labelNode.style.left = `${labelPlacement.left}px`;
|
||||
labelNode.style.top = `${labelPlacement.top}px`;
|
||||
if (useFixedScreenLabel(kind, item)) {
|
||||
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
|
||||
labelNode.style.transformOrigin = "0 0";
|
||||
labelNode.style.transform = `scale(${1 / effectiveScale})`;
|
||||
}
|
||||
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
|
||||
marker.appendChild(labelNode);
|
||||
}
|
||||
|
||||
@@ -1893,10 +2053,10 @@ function createPriorityLabelGroup(group) {
|
||||
labelNode.style.color = group.markerColor;
|
||||
labelNode.style.left = `${labelPlacement.left}px`;
|
||||
labelNode.style.top = `${labelPlacement.top}px`;
|
||||
if (useFixedScreenLabel(group.kind, group)) {
|
||||
{
|
||||
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
|
||||
labelNode.style.transformOrigin = "0 0";
|
||||
labelNode.style.transform = `scale(${1 / effectiveScale})`;
|
||||
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
|
||||
}
|
||||
marker.appendChild(labelNode);
|
||||
|
||||
@@ -1941,6 +2101,11 @@ function createLootLabelGroup(group) {
|
||||
labelNode.style.color = group.markerColor;
|
||||
labelNode.style.left = `${labelPlacement.left}px`;
|
||||
labelNode.style.top = `${labelPlacement.top}px`;
|
||||
{
|
||||
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
|
||||
labelNode.style.transformOrigin = "0 0";
|
||||
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
|
||||
}
|
||||
marker.appendChild(labelNode);
|
||||
|
||||
for (const entry of group.entries) {
|
||||
@@ -2211,7 +2376,7 @@ function updateVisibleTiles() {
|
||||
const keepMaxX = Math.min(tileCountX - 1, maxTileX + keepMargin);
|
||||
const keepMaxY = Math.min(tileCountY - 1, maxTileY + keepMargin);
|
||||
|
||||
const satmapOn = state.settings.showSatmap;
|
||||
const satmapOn = state.settings.showSatmap && state.topoAvailable;
|
||||
elements.canvas.classList.toggle("satmap-active", satmapOn);
|
||||
elements.tiles.style.display = satmapOn ? "none" : "";
|
||||
elements.topoTiles.style.display = satmapOn ? "" : "none";
|
||||
@@ -2285,7 +2450,7 @@ function updateVisibleTiles() {
|
||||
}
|
||||
|
||||
// ── Satmap tiles (loaded when satmap is active) ─────────────────────────
|
||||
if (satmapOn) {
|
||||
if (satmapOn && state.topoAvailable) {
|
||||
for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
|
||||
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
|
||||
const key = `${tileX}:${tileY}`;
|
||||
@@ -2297,7 +2462,11 @@ function updateVisibleTiles() {
|
||||
img.style.cssText = `left:${tileX * tileSize}px;top:${tileY * tileSize}px;width:${tileSize + 1}px;height:${tileSize + 1}px`;
|
||||
img.addEventListener("error", () => {
|
||||
img.remove();
|
||||
// keep key in state.topoElements as a "do not retry" sentinel
|
||||
if (!state.topoAvailable) return;
|
||||
state.topoAvailable = false;
|
||||
for (const el of state.topoElements.values()) el.remove();
|
||||
state.topoElements.clear();
|
||||
scheduleVisibleTilesUpdate();
|
||||
});
|
||||
img.src = topoTileUrl(tileX, tileY);
|
||||
state.topoElements.set(key, img);
|
||||
@@ -2372,17 +2541,25 @@ function updateVisibleTiles() {
|
||||
// ── Main render ────────────────────────────────────────────────────────────
|
||||
|
||||
function render() {
|
||||
if (!state.lastSnapshot || !state.bootstrap) return;
|
||||
if (!PERF.enabled) return renderImpl();
|
||||
const t = performance.now();
|
||||
renderImpl();
|
||||
PERF.onRender(performance.now() - t);
|
||||
}
|
||||
|
||||
if (state.settings.followPlayer && state.lastSnapshot.hasLocalPlayer && state.lastSnapshot.localPlayer) {
|
||||
centerOn(state.lastSnapshot.localPlayer);
|
||||
function renderImpl() {
|
||||
if (!state.bootstrap) return;
|
||||
const snap = state.lastSnapshot;
|
||||
|
||||
if (snap && state.settings.followPlayer && snap.hasLocalPlayer && snap.localPlayer) {
|
||||
centerOn(snap.localPlayer);
|
||||
}
|
||||
|
||||
const markerNodes = [];
|
||||
const itemLabelNodes = [];
|
||||
const pathNodes = buildBulletPathNodes(getVisibleCollection("bullets", state.lastSnapshot?.bullets || []));
|
||||
const pathNodes = buildBulletPathNodes(getVisibleCollection("bullets", snap?.bullets || []));
|
||||
|
||||
const localPlayer = state.lastSnapshot.hasLocalPlayer ? state.lastSnapshot.localPlayer : null;
|
||||
const localPlayer = snap?.hasLocalPlayer ? snap.localPlayer : null;
|
||||
if (localPlayer) {
|
||||
const marker = document.createElement("div");
|
||||
marker.className = "entity local-player-entity";
|
||||
@@ -2466,6 +2643,30 @@ function render() {
|
||||
markerNodes.push(wrapper);
|
||||
}
|
||||
|
||||
// Shared waypoints (relay-backed, visible to all clients)
|
||||
for (const wp of (state.sharedWaypoints || [])) {
|
||||
if (typeof wp.x !== "number" || typeof wp.y !== "number") continue;
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "entity waypoint-entity";
|
||||
wrapper.style.left = `${wp.x}px`;
|
||||
wrapper.style.top = `${wp.y}px`;
|
||||
const inner = document.createElement("div");
|
||||
inner.style.cssText = "position:absolute;left:0;top:0;transform:scale(var(--inv-scale));transform-origin:0 0";
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "marker shared-waypoint";
|
||||
if (wp.color) dot.style.borderColor = wp.color;
|
||||
inner.appendChild(dot);
|
||||
if (wp.name) {
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "waypoint-label shared-waypoint-label";
|
||||
if (wp.color) lbl.style.color = wp.color;
|
||||
lbl.textContent = wp.name;
|
||||
inner.appendChild(lbl);
|
||||
}
|
||||
wrapper.appendChild(inner);
|
||||
markerNodes.push(wrapper);
|
||||
}
|
||||
|
||||
const gridNodes = buildGridLayer();
|
||||
const gridLabelNodes = buildGridLabels();
|
||||
const combatNodes = buildCombatRings(localPlayer);
|
||||
@@ -2712,11 +2913,7 @@ async function bootstrap() {
|
||||
setTheme(state.settings.theme);
|
||||
syncLabelSettingsUi();
|
||||
loadMinimapImage();
|
||||
|
||||
// Enable/disable satellite checkbox based on whether tiles exist.
|
||||
const hasSat = !!bootstrapData.satUrl;
|
||||
elements.showSatellite.disabled = !hasSat;
|
||||
elements.showSatellite.title = hasSat ? "" : "No satellite data — run: py -m dayzmap satellite --map <mapId>";
|
||||
render(); // show waypoints/POIs even before first C++ state push
|
||||
|
||||
// Enable 3D button when processed data exists for the current map.
|
||||
engine3d.manifestUrl = bootstrapData.manifestUrl || null;
|
||||
@@ -2738,7 +2935,17 @@ function connectEvents() {
|
||||
|
||||
source.addEventListener("state", (event) => {
|
||||
stopFallbackPolling();
|
||||
applySnapshot(JSON.parse(event.data));
|
||||
if (!PERF.enabled) { applySnapshot(JSON.parse(event.data)); return; }
|
||||
const t0 = performance.now();
|
||||
const snap = JSON.parse(event.data);
|
||||
const t1 = performance.now();
|
||||
applySnapshot(snap);
|
||||
PERF.onSnapshot(event.data.length, t1 - t0, performance.now() - t1);
|
||||
});
|
||||
|
||||
source.addEventListener("waypoints", (event) => {
|
||||
state.sharedWaypoints = JSON.parse(event.data);
|
||||
render();
|
||||
});
|
||||
|
||||
source.onerror = () => {
|
||||
@@ -2756,6 +2963,21 @@ function connectEvents() {
|
||||
|
||||
// ── UI sync helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function syncGridSettings() {
|
||||
const lw = Number(state.settings.gridLineWeight) || 2.5;
|
||||
const ls = Number(state.settings.gridLabelSize) || 13;
|
||||
elements.canvas.style.setProperty("--grid-line-weight", lw);
|
||||
elements.canvas.style.setProperty("--grid-label-size", `${ls}px`);
|
||||
if (elements.gridLineWeight) {
|
||||
elements.gridLineWeight.value = String(lw);
|
||||
elements.gridLineWeightValue.textContent = `${lw.toFixed(1)} px`;
|
||||
}
|
||||
if (elements.gridLabelSize) {
|
||||
elements.gridLabelSize.value = String(ls);
|
||||
elements.gridLabelSizeValue.textContent = `${ls} px`;
|
||||
}
|
||||
}
|
||||
|
||||
function syncLabelSettingsUi() {
|
||||
elements.ordinaryLootSpread.value = String(state.settings.ordinaryLootSpread);
|
||||
elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
|
||||
@@ -2785,7 +3007,6 @@ function syncAllCheckboxes() {
|
||||
showLabels: elements.showLabels,
|
||||
showPOIs: elements.showPOIs,
|
||||
showGrid: elements.showGrid,
|
||||
showSatellite: elements.showSatellite,
|
||||
showSatmap: elements.showSatmap,
|
||||
showDistanceRings: elements.showDistanceRings,
|
||||
showMinimap: elements.showMinimap,
|
||||
@@ -2946,7 +3167,6 @@ function bindUi() {
|
||||
showLabels: elements.showLabels,
|
||||
showPOIs: elements.showPOIs,
|
||||
showGrid: elements.showGrid,
|
||||
showSatellite: elements.showSatellite,
|
||||
showSatmap: elements.showSatmap,
|
||||
showDistanceRings: elements.showDistanceRings,
|
||||
showMinimap: elements.showMinimap,
|
||||
@@ -2954,7 +3174,13 @@ function bindUi() {
|
||||
el.addEventListener("change", () => {
|
||||
state.settings[key] = el.checked;
|
||||
persistSettings();
|
||||
if (key === "showSatmap") updateVisibleTiles();
|
||||
if (key === "showSatmap") {
|
||||
if (el.checked && !state.topoAvailable && state.bootstrap?.mapId) {
|
||||
checkTopoAvailable(state.bootstrap.mapId);
|
||||
} else {
|
||||
updateVisibleTiles();
|
||||
}
|
||||
}
|
||||
else if (key === "showMinimap") updateMinimap();
|
||||
else render();
|
||||
});
|
||||
@@ -2992,6 +3218,21 @@ function bindUi() {
|
||||
// Label settings
|
||||
syncLabelSettingsUi();
|
||||
|
||||
// Grid settings
|
||||
syncGridSettings();
|
||||
elements.gridLineWeight.addEventListener("input", () => {
|
||||
state.settings.gridLineWeight = Number(elements.gridLineWeight.value);
|
||||
elements.gridLineWeightValue.textContent = `${state.settings.gridLineWeight.toFixed(1)} px`;
|
||||
elements.canvas.style.setProperty("--grid-line-weight", state.settings.gridLineWeight);
|
||||
persistSettings();
|
||||
});
|
||||
elements.gridLabelSize.addEventListener("input", () => {
|
||||
state.settings.gridLabelSize = Number(elements.gridLabelSize.value);
|
||||
elements.gridLabelSizeValue.textContent = `${state.settings.gridLabelSize} px`;
|
||||
elements.canvas.style.setProperty("--grid-label-size", `${state.settings.gridLabelSize}px`);
|
||||
persistSettings();
|
||||
});
|
||||
|
||||
elements.ordinaryLootSpread.addEventListener("input", () => {
|
||||
state.settings.ordinaryLootSpread = Number(elements.ordinaryLootSpread.value);
|
||||
elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
|
||||
@@ -3069,6 +3310,12 @@ function bindUi() {
|
||||
state.lastX = event.clientX;
|
||||
state.lastY = event.clientY;
|
||||
state.pinchDistance = 0;
|
||||
if (state.dragging && state.isZooming) {
|
||||
state.isZooming = false;
|
||||
clearTimeout(state.zoomIdleTimer);
|
||||
state.zoomIdleTimer = null;
|
||||
scheduleVisibleTilesUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const pair = getActivePointerPair();
|
||||
@@ -3134,10 +3381,16 @@ function bindUi() {
|
||||
drawMeasureLayer(event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
// Wheel zoom
|
||||
// Wheel zoom — suppress tile loading while spinning; load once zoom settles.
|
||||
elements.viewport.addEventListener("wheel", (event) => {
|
||||
if (event.target.closest("#ctxMenu")) return;
|
||||
event.preventDefault();
|
||||
state.isZooming = true;
|
||||
clearTimeout(state.zoomIdleTimer);
|
||||
state.zoomIdleTimer = setTimeout(() => {
|
||||
state.isZooming = false;
|
||||
scheduleVisibleTilesUpdate();
|
||||
}, 150);
|
||||
zoomAt(event.clientX, event.clientY, state.scale * (event.deltaY < 0 ? 1.1 : 0.9));
|
||||
if (state.measureMode) drawMeasureLayer();
|
||||
}, { passive: false });
|
||||
@@ -3168,6 +3421,14 @@ function bindUi() {
|
||||
render();
|
||||
});
|
||||
|
||||
elements.ctxAddSharedWaypoint.addEventListener("click", () => {
|
||||
if (!state.contextMenuWorldPos) { hideContextMenu(); return; }
|
||||
const name = (window.prompt("Shared waypoint name (optional):") ?? "").trim();
|
||||
const { x, y } = state.contextMenuWorldPos;
|
||||
hideContextMenu();
|
||||
postSharedWaypoint(x, y, name);
|
||||
});
|
||||
|
||||
elements.ctxCopyCoords.addEventListener("click", () => {
|
||||
if (!state.contextMenuWorldPos) { hideContextMenu(); return; }
|
||||
const mapSize = state.bootstrap?.mapSize || 0;
|
||||
@@ -3211,6 +3472,12 @@ ensureFilterSettings();
|
||||
renderFilterCards();
|
||||
bindUi();
|
||||
|
||||
// Fetch shared waypoints once on startup; SSE will handle live updates after connect.
|
||||
fetch(apiUrl("/api/waypoints"), { cache: "no-store" })
|
||||
.then((r) => r.ok ? r.json() : [])
|
||||
.then((wps) => { if (Array.isArray(wps)) { state.sharedWaypoints = wps; render(); } })
|
||||
.catch(() => {});
|
||||
|
||||
bootstrap()
|
||||
.then(() => fetch(apiUrl("/api/state"), { cache: "no-store" }))
|
||||
.then((response) => {
|
||||
|
||||
+19
-5
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DayZ Web Radar</title>
|
||||
<link rel="stylesheet" href="style.css?v=7">
|
||||
<link rel="stylesheet" href="style.css?v=13">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -53,8 +53,7 @@
|
||||
<label class="toggle-row"><input type="checkbox" id="showLabels" checked><span>Labels</span></label>
|
||||
<label class="toggle-row"><input type="checkbox" id="showPOIs" checked><span>Locations</span></label>
|
||||
<label class="toggle-row"><input type="checkbox" id="showGrid"><span>Grid <kbd>G</kbd></span></label>
|
||||
<label class="toggle-row"><input type="checkbox" id="showSatellite"><span>Satellite</span></label>
|
||||
<label class="toggle-row"><input type="checkbox" id="showSatmap"><span>Sat map</span></label>
|
||||
<label class="toggle-row"><input type="checkbox" id="showSatmap"><span>Satellite map</span></label>
|
||||
<label class="toggle-row"><input type="checkbox" id="showDistanceRings"><span>Distance rings</span></label>
|
||||
<label class="toggle-row"><input type="checkbox" id="showMinimap"><span>Minimap</span></label>
|
||||
</div>
|
||||
@@ -152,6 +151,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-section">
|
||||
<div class="section-label">Grid</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-name">Line weight</span>
|
||||
<span id="gridLineWeightValue" class="setting-val">2.5 px</span>
|
||||
<input type="range" id="gridLineWeight" min="0.5" max="6" step="0.5" value="2.5">
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-name">Label size</span>
|
||||
<span id="gridLabelSizeValue" class="setting-val">13 px</span>
|
||||
<input type="range" id="gridLabelSize" min="8" max="24" step="1" value="13">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="tab-section">
|
||||
<div class="section-label">Theme</div>
|
||||
<div class="theme-bar">
|
||||
@@ -206,7 +219,8 @@
|
||||
|
||||
<!-- Right-click context menu -->
|
||||
<div class="ctx-menu" id="ctxMenu">
|
||||
<button class="ctx-item" id="ctxAddWaypoint">Add waypoint</button>
|
||||
<button class="ctx-item" id="ctxAddWaypoint">Add waypoint (local)</button>
|
||||
<button class="ctx-item ctx-item-shared" id="ctxAddSharedWaypoint">Add shared waypoint</button>
|
||||
<button class="ctx-item" id="ctxCopyCoords">Copy coordinates</button>
|
||||
</div>
|
||||
|
||||
@@ -228,6 +242,6 @@
|
||||
<!-- ── Toast notifications ────────────────────────────────────────── -->
|
||||
<div class="toast-stack" id="toastStack"></div>
|
||||
|
||||
<script src="app.js?v=7"></script>
|
||||
<script src="app.js?v=13"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+22
-7
@@ -811,6 +811,18 @@ body.sidebar-hidden .viewport { left: 0; }
|
||||
width: 10px; height: 10px;
|
||||
}
|
||||
|
||||
.marker.shared-waypoint {
|
||||
background: #f59e0b;
|
||||
border: 2px solid #fde68a;
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 2px;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.shared-waypoint-label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.entity.bullet-phantom .marker.bullets {
|
||||
opacity: 0.45;
|
||||
box-shadow: 0 0 6px rgba(148,163,184,0.45);
|
||||
@@ -957,11 +969,11 @@ body.sidebar-hidden .viewport { left: 0; }
|
||||
|
||||
/* ── Grid overlay ────────────────────────────────────────────────────── */
|
||||
|
||||
.grid-major { stroke: rgba(255,255,255,0.30); stroke-width: 1; }
|
||||
.grid-minor { stroke: rgba(255,255,255,0.12); stroke-width: 0.5; }
|
||||
.grid-major { stroke: rgba(255,255,255,0.30); stroke-width: calc(var(--grid-line-weight, 2.5) * var(--inv-scale, 1)); }
|
||||
.grid-minor { stroke: rgba(255,255,255,0.12); stroke-width: calc(0.5 * var(--inv-scale, 1)); }
|
||||
|
||||
.canvas.satmap-active .grid-major { stroke: rgba(0,0,0,0.45); }
|
||||
.canvas.satmap-active .grid-label-inner { font-size: 10px; color: rgba(0,0,0,0.90); text-shadow: 0 1px 1px rgba(255,255,255,0.6); }
|
||||
.canvas.satmap-active .grid-major { stroke: rgba(0,0,0,0.45); stroke-width: calc(var(--grid-line-weight, 2.5) * var(--inv-scale, 1)); }
|
||||
.canvas.satmap-active .grid-label-inner { font-size: var(--grid-label-size, 13px); color: rgba(0,0,0,0.90); text-shadow: 0 1px 1px rgba(255,255,255,0.6); }
|
||||
|
||||
.grid-label-wrapper {
|
||||
position: absolute;
|
||||
@@ -972,12 +984,13 @@ body.sidebar-hidden .viewport { left: 0; }
|
||||
.canvas.grid-labels-visible .grid-label-wrapper { visibility: visible; }
|
||||
|
||||
.grid-label-inner {
|
||||
font-size: 10px;
|
||||
font-size: var(--grid-label-size, 13px);
|
||||
font-family: monospace;
|
||||
color: rgba(255,255,255,0.50);
|
||||
color: rgba(255,255,255,0.55);
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.85);
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.95);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Combat mode ─────────────────────────────────────────────────────── */
|
||||
@@ -1058,6 +1071,8 @@ body.measure-active .viewport { cursor: crosshair; }
|
||||
}
|
||||
|
||||
.ctx-item:hover { background: var(--bg-hover); }
|
||||
.ctx-item-shared { color: #f59e0b; }
|
||||
.ctx-item-shared:hover { color: #fde68a; }
|
||||
|
||||
/* ── Coord bar ───────────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user