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/BoneInterpolator.cpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/OverlayWindow.cpp"
|
"${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/OverlayWindow.cpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/GameOverlay.cpp"
|
"${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/GameOverlay.cpp"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/src/Web/ServerPublisher.cpp"
|
||||||
)
|
)
|
||||||
list(REMOVE_DUPLICATES PROJECT_SOURCES)
|
list(REMOVE_DUPLICATES PROJECT_SOURCES)
|
||||||
|
|
||||||
@@ -100,60 +101,16 @@ set(VOLKDMA_SOURCES
|
|||||||
)
|
)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Embed map PNGs as Windows RCDATA resources
|
# 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
|
||||||
# Maps present in maps/ at configure time are baked into the binary so the
|
# {nullptr, 0} means it will always load from maps/ on disk (or return 404).
|
||||||
# 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.
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
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(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}"
|
file(WRITE "${EMBEDDED_MAPS_CPP}"
|
||||||
"// Auto-generated by CMake - do not edit.
|
"// Auto-generated by CMake — maps served from relay server, no embedded resources.
|
||||||
#ifndef WIN32_LEAN_AND_MEAN
|
|
||||||
#define WIN32_LEAN_AND_MEAN
|
|
||||||
#endif
|
|
||||||
#include <Windows.h>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <utility>
|
|
||||||
#include \"EmbeddedMaps.h\"
|
#include \"EmbeddedMaps.h\"
|
||||||
|
std::pair<const uint8_t*, size_t> GetEmbeddedMap(const std::string&) {
|
||||||
static std::pair<const uint8_t*, size_t> LoadRcData(int resourceId) {
|
return {nullptr, 0};
|
||||||
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};
|
|
||||||
}
|
}
|
||||||
")
|
")
|
||||||
|
|
||||||
@@ -164,11 +121,7 @@ add_executable(dayz-memory-cpp
|
|||||||
${FRAMEWORK_SOURCES}
|
${FRAMEWORK_SOURCES}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wire in the generated RC + CPP files.
|
target_sources(dayz-memory-cpp PRIVATE "${EMBEDDED_MAPS_CPP}")
|
||||||
target_sources(dayz-memory-cpp PRIVATE
|
|
||||||
"${EMBEDDED_MAPS_RC}"
|
|
||||||
"${EMBEDDED_MAPS_CPP}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Include directories
|
# Include directories
|
||||||
|
|||||||
@@ -18,4 +18,7 @@
|
|||||||
- Player detail popover on marker click (worn items, health, distance)
|
- Player detail popover on marker click (worn items, health, distance)
|
||||||
- Minimap inset (fixed position, independent zoom level)
|
- Minimap inset (fixed position, independent zoom level)
|
||||||
- Keyboard shortcuts: G=grid, T=topo, C=combat, P=POIs
|
- Keyboard shortcuts: G=grid, T=topo, C=combat, P=POIs
|
||||||
- Route/waypoint drawing tool (manual overlay path)
|
- 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("webPort")) cfg.webPort = j["webPort"].get<int>();
|
||||||
if (j.contains("webPassword")) cfg.webPassword = j["webPassword"].get<std::string>();
|
if (j.contains("webPassword")) cfg.webPassword = j["webPassword"].get<std::string>();
|
||||||
|
|
||||||
|
|
||||||
spdlog::info("Config: loaded from {}", path);
|
spdlog::info("Config: loaded from {}", path);
|
||||||
} catch (const std::exception& ex) {
|
} catch (const std::exception& ex) {
|
||||||
spdlog::warn("Config: parse error in {} — {} — using defaults", path, ex.what());
|
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["webPort"] = webPort;
|
||||||
j["webPassword"] = webPassword;
|
j["webPassword"] = webPassword;
|
||||||
|
|
||||||
|
|
||||||
std::ofstream f(path);
|
std::ofstream f(path);
|
||||||
f << j.dump(2);
|
f << j.dump(2);
|
||||||
spdlog::debug("Config: saved to {}", path);
|
spdlog::debug("Config: saved to {}", path);
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ struct OverlayConfig {
|
|||||||
int webPort = 7777;
|
int webPort = 7777;
|
||||||
std::string webPassword = "";
|
std::string webPassword = "";
|
||||||
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Load from path (returns default-constructed config on any error).
|
// Load from path (returns default-constructed config on any error).
|
||||||
static OverlayConfig Load(const std::string& path);
|
static OverlayConfig Load(const std::string& path);
|
||||||
|
|||||||
+11
-1
@@ -117,6 +117,16 @@ struct SkeletonBones {
|
|||||||
bool valid = false;
|
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)
|
// Player entry (full)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -130,7 +140,7 @@ struct DayZPlayerEntry {
|
|||||||
bool isAdmin = false; // model matches a known invisible/admin model path
|
bool isAdmin = false; // model matches a known invisible/admin model path
|
||||||
std::string nickname;
|
std::string nickname;
|
||||||
std::string itemInHands;
|
std::string itemInHands;
|
||||||
std::vector<std::string> wornItems;
|
std::vector<WornItem> wornItems;
|
||||||
std::string typeName;
|
std::string typeName;
|
||||||
std::string configName;
|
std::string configName;
|
||||||
std::string modelName;
|
std::string modelName;
|
||||||
|
|||||||
+52
-5
@@ -166,11 +166,15 @@ namespace Offsets {
|
|||||||
constexpr uint64_t ItemQuality = 0x194; // v1.29 [manual]
|
constexpr uint64_t ItemQuality = 0x194; // v1.29 [manual]
|
||||||
constexpr uint64_t Hands = 0x1B0; // v1.29
|
constexpr uint64_t Hands = 0x1B0; // v1.29
|
||||||
constexpr uint64_t HandItemValid = 0x1CC; // v1.29 [manual]
|
constexpr uint64_t HandItemValid = 0x1CC; // v1.29 [manual]
|
||||||
constexpr uint64_t WornClothes = 0x150; // v1.29 [manual] — ptr to weared-clothes grid object
|
// WornClothes: *(inv+0x150) is a DIRECT pointer to the clothes data array.
|
||||||
constexpr uint64_t PlayerCargoGrid = 0x150; // alias kept for compatibility
|
// Each 16-byte entry: { uint32 slotHash, uint32 pad, uint64 entityPtr }
|
||||||
constexpr uint64_t CargoGridCount = 0xC; // v1.29 [manual] — uint32 count at grid+0xC
|
// Count: uint32 at inv+0x15C (embedded in inventory struct, NOT at clothesPtr+0xC).
|
||||||
constexpr uint64_t ItemPtr = 0x8; // v1.29 [manual] — ptr to items array at grid+0x8
|
// IDA: sub_140559180 = *(*(inv+336) + 16*i + 8); sub_14055A230 count = *(inv+348).
|
||||||
constexpr uint64_t ItemSize = 0x10; // v1.29 [manual] — stride per item slot (16 bytes)
|
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 {
|
namespace SlowTable {
|
||||||
@@ -210,3 +214,46 @@ namespace Offsets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} // 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<std::string(uint64_t)> heldItemResolver,
|
||||||
std::function<float(uint64_t)> healthResolver,
|
std::function<float(uint64_t)> healthResolver,
|
||||||
std::function<bool(uint64_t)> adminResolver,
|
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);
|
auto all = EnumerateEntities(near, far, slow);
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public:
|
|||||||
std::function<std::string(uint64_t)> heldItemResolver,
|
std::function<std::string(uint64_t)> heldItemResolver,
|
||||||
std::function<float(uint64_t)> healthResolver,
|
std::function<float(uint64_t)> healthResolver,
|
||||||
std::function<bool(uint64_t)> adminResolver = nullptr,
|
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(
|
static std::vector<DayZAnimalEntry> BuildAnimals(
|
||||||
const std::vector<DayZNearEntityEntry>& near,
|
const std::vector<DayZNearEntityEntry>& near,
|
||||||
|
|||||||
@@ -568,17 +568,14 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
|
|||||||
|| !MemoryValidation::IsValidUserAddress(inventoryAddr))
|
|| !MemoryValidation::IsValidUserAddress(inventoryAddr))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
uint64_t clothesGrid = 0;
|
// *(inv+0x150) = direct ptr to clothes data array; count at inv+0x15C.
|
||||||
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesGrid)
|
uint64_t clothesData = 0;
|
||||||
|| !MemoryValidation::IsValidUserAddress(clothesGrid))
|
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesData)
|
||||||
|
|| !MemoryValidation::IsValidUserAddress(clothesData))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
uint64_t itemsArray = 0;
|
uint32_t count = 0;
|
||||||
uint32_t count = 0;
|
if (!m_memory.TryReadValue<uint32_t>(pid, inventoryAddr + Offsets::Inventory::WornClothesCount, count)
|
||||||
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)
|
|
||||||
|| count == 0 || count > 32)
|
|| count == 0 || count > 32)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -599,9 +596,13 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Each entry is 16 bytes: { uint32 hash, uint32 pad, uint64 entityPtr }
|
||||||
for (uint32_t i = 0; i < count; ++i) {
|
for (uint32_t i = 0; i < count; ++i) {
|
||||||
uint64_t itemAddr = 0;
|
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))
|
|| !MemoryValidation::IsValidUserAddress(itemAddr))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -635,36 +636,42 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- wornClothesResolver ---
|
// --- wornClothesResolver ---
|
||||||
// Chain: entity → +0x650 (inventory) → +0x150 (clothes grid ptr)
|
// Chain: entity → inv (entity+0x650) → clothesData (*(inv+0x150), direct array ptr)
|
||||||
// grid → +0x8 (items array ptr), +0xC (uint32 count)
|
// count at inv+0x15C; each 16-byte entry: { uint32 slotHash, uint32 pad, uint64 entityPtr }
|
||||||
// items[i*8] → item entity → +0x180 (type) → cleanName
|
// IDA evidence: sub_140559180 = *(*(inv+336)+16*i+8); sub_14055A230 count=*(inv+348).
|
||||||
auto wornClothesResolver = [&](uint64_t addr) -> std::vector<std::string> {
|
auto wornClothesResolver = [&](uint64_t addr) -> std::vector<WornItem> {
|
||||||
std::vector<std::string> result;
|
std::vector<WornItem> result;
|
||||||
|
|
||||||
uint64_t inventoryAddr = 0;
|
uint64_t inventoryAddr = 0;
|
||||||
if (!m_memory.TryReadPointer(pid, addr + RuntimeOffsets::Inventory::Base, inventoryAddr)
|
if (!m_memory.TryReadPointer(pid, addr + RuntimeOffsets::Inventory::Base, inventoryAddr)
|
||||||
|| !MemoryValidation::IsValidUserAddress(inventoryAddr))
|
|| !MemoryValidation::IsValidUserAddress(inventoryAddr))
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
uint64_t clothesGrid = 0;
|
// *(inv+0x150) = direct pointer to clothes data array
|
||||||
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesGrid)
|
uint64_t clothesData = 0;
|
||||||
|| !MemoryValidation::IsValidUserAddress(clothesGrid))
|
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesData)
|
||||||
return result;
|
|| !MemoryValidation::IsValidUserAddress(clothesData))
|
||||||
|
|
||||||
uint64_t itemsArray = 0;
|
|
||||||
if (!m_memory.TryReadPointer(pid, clothesGrid + Offsets::Inventory::ItemPtr, itemsArray)
|
|
||||||
|| !MemoryValidation::IsValidUserAddress(itemsArray))
|
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
|
// Count embedded in inventory struct at inv+0x15C
|
||||||
uint32_t count = 0;
|
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)
|
|| count == 0 || count > 32)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
result.reserve(count);
|
result.reserve(count);
|
||||||
for (uint32_t i = 0; i < count; ++i) {
|
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;
|
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))
|
|| !MemoryValidation::IsValidUserAddress(itemAddr))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -674,11 +681,17 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
EntityTypeMetadata meta = ReadEntityTypeMetadata(m_memory, pid, typeAddr);
|
EntityTypeMetadata meta = ReadEntityTypeMetadata(m_memory, pid, typeAddr);
|
||||||
std::string name;
|
std::string itemName;
|
||||||
if (!meta.cleanName.empty()) name = meta.cleanName;
|
if (!meta.cleanName.empty()) itemName = meta.cleanName;
|
||||||
else if (!meta.typeName.empty()) name = FormatEntityName(meta.typeName);
|
else if (!meta.typeName.empty()) itemName = FormatEntityName(meta.typeName);
|
||||||
else if (!meta.configName.empty()) name = meta.configName;
|
else if (!meta.configName.empty()) itemName = meta.configName;
|
||||||
if (!name.empty()) result.push_back(std::move(name));
|
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;
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
WebRadarServer::WebRadarServer(WebRadarConfig config)
|
WebRadarServer::WebRadarServer(WebRadarConfig config)
|
||||||
: m_config(std::move(config))
|
: m_config(std::move(config))
|
||||||
|
, m_relay(m_config.relay)
|
||||||
{}
|
{}
|
||||||
|
|
||||||
WebRadarServer::~WebRadarServer() {
|
WebRadarServer::~WebRadarServer() {
|
||||||
@@ -83,9 +84,12 @@ void WebRadarServer::Start() {
|
|||||||
m_config.bindAddress, m_config.port);
|
m_config.bindAddress, m_config.port);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
m_relay.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
void WebRadarServer::Stop() {
|
void WebRadarServer::Stop() {
|
||||||
|
m_relay.Stop();
|
||||||
m_stopping.store(true);
|
m_stopping.store(true);
|
||||||
m_cv.notify_all();
|
m_cv.notify_all();
|
||||||
if (m_server) m_server->stop();
|
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).
|
// trajectory rendering, and the update is cheap (no JSON involved).
|
||||||
m_bullets.Update(update.bullets, now);
|
m_bullets.Update(update.bullets, now);
|
||||||
|
|
||||||
|
bool mapChanged = false;
|
||||||
if (update.serverMapName.has_value()) {
|
if (update.serverMapName.has_value()) {
|
||||||
const MapInfo* resolved = MapRegistry::Resolve(*update.serverMapName);
|
const MapInfo* resolved = MapRegistry::Resolve(*update.serverMapName);
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
if (m_currentMap != resolved)
|
if (m_currentMap != resolved) {
|
||||||
spdlog::info("Web radar: map changed to '{}'", resolved->id);
|
spdlog::info("Web radar: map changed to '{}'", resolved->id);
|
||||||
|
mapChanged = true;
|
||||||
|
}
|
||||||
m_currentMap = resolved;
|
m_currentMap = resolved;
|
||||||
} else {
|
} else {
|
||||||
spdlog::warn("Web radar: unrecognised server map name '{}' — keeping current map",
|
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;
|
if (now < m_nextSnapshotMs) return;
|
||||||
m_nextSnapshotMs = now + m_config.snapshotIntervalMs;
|
m_nextSnapshotMs = now + m_config.snapshotIntervalMs;
|
||||||
|
|
||||||
|
auto buildT0 = std::chrono::steady_clock::now();
|
||||||
std::string jsonStr = m_snapshotSvc.BuildStateJson(
|
std::string jsonStr = m_snapshotSvc.BuildStateJson(
|
||||||
update, m_bullets.GetSnapshot(), m_currentMap, now);
|
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);
|
std::lock_guard<std::mutex> lk(m_cvMutex);
|
||||||
|
|
||||||
@@ -134,13 +157,21 @@ void WebRadarServer::PushSnapshot(const RuntimeUpdate& update) {
|
|||||||
m_bootstrapJson = m_snapshotSvc.BuildBootstrapJson(
|
m_bootstrapJson = m_snapshotSvc.BuildBootstrapJson(
|
||||||
update, m_currentMap, m_config.port);
|
update, m_currentMap, m_config.port);
|
||||||
m_lastBootstrapMapId = std::move(mapId);
|
m_lastBootstrapMapId = std::move(mapId);
|
||||||
|
sendRelayBootstrap = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
m_latestJson = std::move(jsonStr);
|
m_latestJson = std::move(jsonStr);
|
||||||
m_latestPayload = "event: state\ndata: " + m_latestJson + "\n\n";
|
m_latestPayload = "event: state\ndata: " + m_latestJson + "\n\n";
|
||||||
++m_broadcastSeq;
|
++m_broadcastSeq;
|
||||||
|
|
||||||
|
relayState = m_latestJson;
|
||||||
|
if (sendRelayBootstrap) relayBootstrap = m_bootstrapJson;
|
||||||
}
|
}
|
||||||
m_cv.notify_all();
|
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;
|
int code = 200;
|
||||||
|
auto tileT0 = std::chrono::steady_clock::now();
|
||||||
auto bytes = m_tiles.GetTile(*tileMap, tx, ty, code);
|
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;
|
res.status = code;
|
||||||
if (code == 200) {
|
if (code == 200) {
|
||||||
// Cache tiles aggressively — they're static PNG slices that only
|
// Cache tiles aggressively — they're static PNG slices that only
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include "Web/MapRegistry.h"
|
#include "Web/MapRegistry.h"
|
||||||
#include "Web/MapTileService.h"
|
#include "Web/MapTileService.h"
|
||||||
#include "Web/WebSnapshotService.h"
|
#include "Web/WebSnapshotService.h"
|
||||||
|
#include "Web/ServerPublisher.h"
|
||||||
|
|
||||||
// Forward-declare httplib types to avoid pulling the whole header (which
|
// Forward-declare httplib types to avoid pulling the whole header (which
|
||||||
// includes <Winsock2.h>) into every translation unit that includes this header.
|
// includes <Winsock2.h>) into every translation unit that includes this header.
|
||||||
@@ -24,6 +25,7 @@ struct WebRadarConfig {
|
|||||||
int port = 7777;
|
int port = 7777;
|
||||||
std::string password = "";
|
std::string password = "";
|
||||||
int snapshotIntervalMs = 100; // max web snapshot rate (default 10 Hz)
|
int snapshotIntervalMs = 100; // max web snapshot rate (default 10 Hz)
|
||||||
|
ServerPublisher::Config relay;
|
||||||
};
|
};
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -54,6 +56,7 @@ private:
|
|||||||
MapTileService m_tiles;
|
MapTileService m_tiles;
|
||||||
MapTileService m_topoTiles{"maps/topo"};
|
MapTileService m_topoTiles{"maps/topo"};
|
||||||
const MapInfo* m_currentMap = nullptr;
|
const MapInfo* m_currentMap = nullptr;
|
||||||
|
ServerPublisher m_relay;
|
||||||
|
|
||||||
std::unique_ptr<httplib::Server> m_server;
|
std::unique_ptr<httplib::Server> m_server;
|
||||||
std::thread m_thread;
|
std::thread m_thread;
|
||||||
|
|||||||
@@ -71,7 +71,16 @@ static json PlayerEntityJson(const DayZPlayerEntry& p, const MapInfo* map) {
|
|||||||
obj["steamId"] = "";
|
obj["steamId"] = "";
|
||||||
obj["dead"] = p.isDead;
|
obj["dead"] = p.isDead;
|
||||||
obj["handItem"] = p.itemInHands;
|
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;
|
obj["visibleOnMap"] = true;
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
@@ -84,7 +93,16 @@ static json PlayerListEntryJson(const DayZPlayerEntry& p) {
|
|||||||
obj["visibleOnMap"] = true;
|
obj["visibleOnMap"] = true;
|
||||||
obj["dead"] = p.isDead;
|
obj["dead"] = p.isDead;
|
||||||
obj["handItem"] = p.itemInHands;
|
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;
|
obj["distance"] = -1;
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -47,9 +47,13 @@ int main() {
|
|||||||
// Web radar server — settings come from config.
|
// Web radar server — settings come from config.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
WebRadarConfig webCfg;
|
WebRadarConfig webCfg;
|
||||||
webCfg.bindAddress = cfg.webBindAddress;
|
webCfg.bindAddress = cfg.webBindAddress;
|
||||||
webCfg.port = cfg.webPort;
|
webCfg.port = cfg.webPort;
|
||||||
webCfg.password = cfg.webPassword;
|
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);
|
WebRadarServer webRadar(webCfg);
|
||||||
webRadar.Start();
|
webRadar.Start();
|
||||||
|
|||||||
+302
-35
@@ -69,6 +69,8 @@ const defaultSettings = {
|
|||||||
favoriteLootNames: [],
|
favoriteLootNames: [],
|
||||||
filterStyles: {},
|
filterStyles: {},
|
||||||
filterExpanded: {},
|
filterExpanded: {},
|
||||||
|
gridLineWeight: 2.5,
|
||||||
|
gridLabelSize: 13,
|
||||||
// v2 UI state
|
// v2 UI state
|
||||||
sidebarState: "full",
|
sidebarState: "full",
|
||||||
activeTab: "tab-map",
|
activeTab: "tab-map",
|
||||||
@@ -139,8 +141,12 @@ const state = {
|
|||||||
topoElements: new Map(), // key -> img, O(1) topo lookup
|
topoElements: new Map(), // key -> img, O(1) topo lookup
|
||||||
satElements: new Map(), // "z:tx:ty" -> img, O(1) satellite XYZ tile lookup
|
satElements: new Map(), // "z:tx:ty" -> img, O(1) satellite XYZ tile lookup
|
||||||
tilesRafPending: false, // throttle updateVisibleTiles to one RAF per frame
|
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
|
cachedGridKey: null, // detects when grid needs rebuild
|
||||||
cachedGridNode: null, // cached SVG <g> for the grid
|
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 = {
|
const elements = {
|
||||||
@@ -170,7 +176,6 @@ const elements = {
|
|||||||
showLabels: document.getElementById("showLabels"),
|
showLabels: document.getElementById("showLabels"),
|
||||||
showPOIs: document.getElementById("showPOIs"),
|
showPOIs: document.getElementById("showPOIs"),
|
||||||
showGrid: document.getElementById("showGrid"),
|
showGrid: document.getElementById("showGrid"),
|
||||||
showSatellite: document.getElementById("showSatellite"),
|
|
||||||
showSatmap: document.getElementById("showSatmap"),
|
showSatmap: document.getElementById("showSatmap"),
|
||||||
showDistanceRings: document.getElementById("showDistanceRings"),
|
showDistanceRings: document.getElementById("showDistanceRings"),
|
||||||
showMinimap: document.getElementById("showMinimap"),
|
showMinimap: document.getElementById("showMinimap"),
|
||||||
@@ -194,6 +199,10 @@ const elements = {
|
|||||||
labelSettingsReset: document.getElementById("labelSettingsReset"),
|
labelSettingsReset: document.getElementById("labelSettingsReset"),
|
||||||
textSize: document.getElementById("textSize"),
|
textSize: document.getElementById("textSize"),
|
||||||
textSizeValue: document.getElementById("textSizeValue"),
|
textSizeValue: document.getElementById("textSizeValue"),
|
||||||
|
gridLineWeight: document.getElementById("gridLineWeight"),
|
||||||
|
gridLineWeightValue: document.getElementById("gridLineWeightValue"),
|
||||||
|
gridLabelSize: document.getElementById("gridLabelSize"),
|
||||||
|
gridLabelSizeValue: document.getElementById("gridLabelSizeValue"),
|
||||||
minimapEl: document.getElementById("minimapEl"),
|
minimapEl: document.getElementById("minimapEl"),
|
||||||
minimapImg: document.getElementById("minimapImg"),
|
minimapImg: document.getElementById("minimapImg"),
|
||||||
minimapCanvas: document.getElementById("minimapCanvas"),
|
minimapCanvas: document.getElementById("minimapCanvas"),
|
||||||
@@ -204,12 +213,72 @@ const elements = {
|
|||||||
zoomDisplay: document.getElementById("zoomDisplay"),
|
zoomDisplay: document.getElementById("zoomDisplay"),
|
||||||
ctxMenu: document.getElementById("ctxMenu"),
|
ctxMenu: document.getElementById("ctxMenu"),
|
||||||
ctxAddWaypoint: document.getElementById("ctxAddWaypoint"),
|
ctxAddWaypoint: document.getElementById("ctxAddWaypoint"),
|
||||||
|
ctxAddSharedWaypoint: document.getElementById("ctxAddSharedWaypoint"),
|
||||||
ctxCopyCoords: document.getElementById("ctxCopyCoords"),
|
ctxCopyCoords: document.getElementById("ctxCopyCoords"),
|
||||||
toastStack: document.getElementById("toastStack"),
|
toastStack: document.getElementById("toastStack"),
|
||||||
toggle3d: document.getElementById("toggle3d"),
|
toggle3d: document.getElementById("toggle3d"),
|
||||||
canvas3d: document.getElementById("canvas3d"),
|
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 = {};
|
let filtersByKey = {};
|
||||||
const filterKeyByKind = {
|
const filterKeyByKind = {
|
||||||
players: "players",
|
players: "players",
|
||||||
@@ -449,12 +518,43 @@ function applyTransform() {
|
|||||||
constrainViewState();
|
constrainViewState();
|
||||||
elements.canvas.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.scale})`;
|
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("--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);
|
elements.canvas.classList.toggle("grid-labels-visible", state.scale * MGRS_STEP >= 80);
|
||||||
scheduleVisibleTilesUpdate();
|
if (!state.isZooming) scheduleVisibleTilesUpdate();
|
||||||
updateZoomDisplay();
|
updateZoomDisplay();
|
||||||
if (state.measureMode) drawMeasureLayer();
|
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) {
|
function applyMapMetadata(metadata, clearTiles = false) {
|
||||||
if (!metadata) return false;
|
if (!metadata) return false;
|
||||||
const previous = state.bootstrap;
|
const previous = state.bootstrap;
|
||||||
@@ -491,6 +591,9 @@ function applyMapMetadata(metadata, clearTiles = false) {
|
|||||||
elements.markers.style.height = `${next.mapSize}px`;
|
elements.markers.style.height = `${next.mapSize}px`;
|
||||||
if (clearTiles || changed) {
|
if (clearTiles || changed) {
|
||||||
state.tileState = {};
|
state.tileState = {};
|
||||||
|
state.topoAvailable = false;
|
||||||
|
checkTopoAvailable(next.mapId);
|
||||||
|
loadStaticPOIs(next.mapId, next.mapSize);
|
||||||
for (const img of state.tileElements.values()) img.remove();
|
for (const img of state.tileElements.values()) img.remove();
|
||||||
state.tileElements.clear();
|
state.tileElements.clear();
|
||||||
for (const img of state.topoElements.values()) img.remove();
|
for (const img of state.topoElements.values()) img.remove();
|
||||||
@@ -801,10 +904,57 @@ function findNearestWaypoint(worldX, worldY, radius) {
|
|||||||
return nearest;
|
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) {
|
function showContextMenu(x, y, worldX, worldY) {
|
||||||
state.contextMenuWorldPos = { x: worldX, y: worldY };
|
state.contextMenuWorldPos = { x: worldX, y: worldY };
|
||||||
const old = document.getElementById("ctxRemoveWaypoint");
|
document.getElementById("ctxRemoveWaypoint")?.remove();
|
||||||
if (old) old.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);
|
const near = findNearestWaypoint(worldX, worldY, 30 / state.scale);
|
||||||
if (near) {
|
if (near) {
|
||||||
const removeBtn = document.createElement("button");
|
const removeBtn = document.createElement("button");
|
||||||
@@ -820,6 +970,7 @@ function showContextMenu(x, y, worldX, worldY) {
|
|||||||
});
|
});
|
||||||
elements.ctxMenu.insertBefore(removeBtn, elements.ctxAddWaypoint);
|
elements.ctxMenu.insertBefore(removeBtn, elements.ctxAddWaypoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.ctxMenu.style.left = `${x}px`;
|
elements.ctxMenu.style.left = `${x}px`;
|
||||||
elements.ctxMenu.style.top = `${y}px`;
|
elements.ctxMenu.style.top = `${y}px`;
|
||||||
elements.ctxMenu.classList.add("open");
|
elements.ctxMenu.classList.add("open");
|
||||||
@@ -827,8 +978,8 @@ function showContextMenu(x, y, worldX, worldY) {
|
|||||||
|
|
||||||
function hideContextMenu() {
|
function hideContextMenu() {
|
||||||
elements.ctxMenu.classList.remove("open");
|
elements.ctxMenu.classList.remove("open");
|
||||||
const old = document.getElementById("ctxRemoveWaypoint");
|
document.getElementById("ctxRemoveWaypoint")?.remove();
|
||||||
if (old) old.remove();
|
document.getElementById("ctxRemoveSharedWaypoint")?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Minimap ────────────────────────────────────────────────────────────────
|
// ── Minimap ────────────────────────────────────────────────────────────────
|
||||||
@@ -893,6 +1044,13 @@ function updateMinimap() {
|
|||||||
ctx.fillStyle = "#e2e8f0";
|
ctx.fillStyle = "#e2e8f0";
|
||||||
ctx.fill();
|
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 ────────────────────────────────────────────────────
|
// ── Filter / visibility ────────────────────────────────────────────────────
|
||||||
@@ -1066,9 +1224,8 @@ function estimateLabelSize(text, fontSize) {
|
|||||||
|
|
||||||
function getScaledLabelLayoutSize(kind, itemOrGroup, text, fontSize) {
|
function getScaledLabelLayoutSize(kind, itemOrGroup, text, fontSize) {
|
||||||
const size = estimateLabelSize(text, fontSize);
|
const size = estimateLabelSize(text, fontSize);
|
||||||
if (!useFixedScreenLabel(kind, itemOrGroup)) return size;
|
|
||||||
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
|
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) {
|
function buildPinnedLabelCandidates(entry, size) {
|
||||||
@@ -1412,7 +1569,7 @@ function computeLabelPlacements(entries) {
|
|||||||
|
|
||||||
const groups = buildOrdinaryLootLabelGroups(entries);
|
const groups = buildOrdinaryLootLabelGroups(entries);
|
||||||
for (const group of groups) {
|
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);
|
const candidates = buildOrdinaryGroupLabelCandidates(group, size);
|
||||||
let bestPlacement = null;
|
let bestPlacement = null;
|
||||||
let bestScore = Number.POSITIVE_INFINITY;
|
let bestScore = Number.POSITIVE_INFINITY;
|
||||||
@@ -1846,13 +2003,18 @@ function createMarker(entry) {
|
|||||||
glyph.style.background = markerColor;
|
glyph.style.background = markerColor;
|
||||||
glyph.style.borderWidth = `${borderSize}px`;
|
glyph.style.borderWidth = `${borderSize}px`;
|
||||||
glyph.style.color = markerColor;
|
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") {
|
if (kind === "players") {
|
||||||
const direction = document.createElement("div");
|
const direction = document.createElement("div");
|
||||||
direction.className = "direction entity-direction";
|
direction.className = "direction entity-direction";
|
||||||
direction.style.transform = `translate(-50%, -100%) rotate(${180 - (item.rotation || 0)}deg)`;
|
direction.style.transform = `translate(-50%, -100%) rotate(${180 - (item.rotation || 0)}deg)`;
|
||||||
direction.style.color = markerColor;
|
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);
|
marker.appendChild(direction);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1866,11 +2028,9 @@ function createMarker(entry) {
|
|||||||
labelNode.style.color = markerColor;
|
labelNode.style.color = markerColor;
|
||||||
labelNode.style.left = `${labelPlacement.left}px`;
|
labelNode.style.left = `${labelPlacement.left}px`;
|
||||||
labelNode.style.top = `${labelPlacement.top}px`;
|
labelNode.style.top = `${labelPlacement.top}px`;
|
||||||
if (useFixedScreenLabel(kind, item)) {
|
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
|
||||||
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
|
labelNode.style.transformOrigin = "0 0";
|
||||||
labelNode.style.transformOrigin = "0 0";
|
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
|
||||||
labelNode.style.transform = `scale(${1 / effectiveScale})`;
|
|
||||||
}
|
|
||||||
marker.appendChild(labelNode);
|
marker.appendChild(labelNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1893,10 +2053,10 @@ function createPriorityLabelGroup(group) {
|
|||||||
labelNode.style.color = group.markerColor;
|
labelNode.style.color = group.markerColor;
|
||||||
labelNode.style.left = `${labelPlacement.left}px`;
|
labelNode.style.left = `${labelPlacement.left}px`;
|
||||||
labelNode.style.top = `${labelPlacement.top}px`;
|
labelNode.style.top = `${labelPlacement.top}px`;
|
||||||
if (useFixedScreenLabel(group.kind, group)) {
|
{
|
||||||
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
|
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
|
||||||
labelNode.style.transformOrigin = "0 0";
|
labelNode.style.transformOrigin = "0 0";
|
||||||
labelNode.style.transform = `scale(${1 / effectiveScale})`;
|
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
|
||||||
}
|
}
|
||||||
marker.appendChild(labelNode);
|
marker.appendChild(labelNode);
|
||||||
|
|
||||||
@@ -1941,6 +2101,11 @@ function createLootLabelGroup(group) {
|
|||||||
labelNode.style.color = group.markerColor;
|
labelNode.style.color = group.markerColor;
|
||||||
labelNode.style.left = `${labelPlacement.left}px`;
|
labelNode.style.left = `${labelPlacement.left}px`;
|
||||||
labelNode.style.top = `${labelPlacement.top}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);
|
marker.appendChild(labelNode);
|
||||||
|
|
||||||
for (const entry of group.entries) {
|
for (const entry of group.entries) {
|
||||||
@@ -2211,7 +2376,7 @@ function updateVisibleTiles() {
|
|||||||
const keepMaxX = Math.min(tileCountX - 1, maxTileX + keepMargin);
|
const keepMaxX = Math.min(tileCountX - 1, maxTileX + keepMargin);
|
||||||
const keepMaxY = Math.min(tileCountY - 1, maxTileY + 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.canvas.classList.toggle("satmap-active", satmapOn);
|
||||||
elements.tiles.style.display = satmapOn ? "none" : "";
|
elements.tiles.style.display = satmapOn ? "none" : "";
|
||||||
elements.topoTiles.style.display = satmapOn ? "" : "none";
|
elements.topoTiles.style.display = satmapOn ? "" : "none";
|
||||||
@@ -2285,7 +2450,7 @@ function updateVisibleTiles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Satmap tiles (loaded when satmap is active) ─────────────────────────
|
// ── Satmap tiles (loaded when satmap is active) ─────────────────────────
|
||||||
if (satmapOn) {
|
if (satmapOn && state.topoAvailable) {
|
||||||
for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
|
for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
|
||||||
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
|
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
|
||||||
const key = `${tileX}:${tileY}`;
|
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.style.cssText = `left:${tileX * tileSize}px;top:${tileY * tileSize}px;width:${tileSize + 1}px;height:${tileSize + 1}px`;
|
||||||
img.addEventListener("error", () => {
|
img.addEventListener("error", () => {
|
||||||
img.remove();
|
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);
|
img.src = topoTileUrl(tileX, tileY);
|
||||||
state.topoElements.set(key, img);
|
state.topoElements.set(key, img);
|
||||||
@@ -2372,17 +2541,25 @@ function updateVisibleTiles() {
|
|||||||
// ── Main render ────────────────────────────────────────────────────────────
|
// ── Main render ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function 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) {
|
function renderImpl() {
|
||||||
centerOn(state.lastSnapshot.localPlayer);
|
if (!state.bootstrap) return;
|
||||||
|
const snap = state.lastSnapshot;
|
||||||
|
|
||||||
|
if (snap && state.settings.followPlayer && snap.hasLocalPlayer && snap.localPlayer) {
|
||||||
|
centerOn(snap.localPlayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const markerNodes = [];
|
const markerNodes = [];
|
||||||
const itemLabelNodes = [];
|
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) {
|
if (localPlayer) {
|
||||||
const marker = document.createElement("div");
|
const marker = document.createElement("div");
|
||||||
marker.className = "entity local-player-entity";
|
marker.className = "entity local-player-entity";
|
||||||
@@ -2466,6 +2643,30 @@ function render() {
|
|||||||
markerNodes.push(wrapper);
|
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 gridNodes = buildGridLayer();
|
||||||
const gridLabelNodes = buildGridLabels();
|
const gridLabelNodes = buildGridLabels();
|
||||||
const combatNodes = buildCombatRings(localPlayer);
|
const combatNodes = buildCombatRings(localPlayer);
|
||||||
@@ -2712,11 +2913,7 @@ async function bootstrap() {
|
|||||||
setTheme(state.settings.theme);
|
setTheme(state.settings.theme);
|
||||||
syncLabelSettingsUi();
|
syncLabelSettingsUi();
|
||||||
loadMinimapImage();
|
loadMinimapImage();
|
||||||
|
render(); // show waypoints/POIs even before first C++ state push
|
||||||
// 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>";
|
|
||||||
|
|
||||||
// Enable 3D button when processed data exists for the current map.
|
// Enable 3D button when processed data exists for the current map.
|
||||||
engine3d.manifestUrl = bootstrapData.manifestUrl || null;
|
engine3d.manifestUrl = bootstrapData.manifestUrl || null;
|
||||||
@@ -2738,7 +2935,17 @@ function connectEvents() {
|
|||||||
|
|
||||||
source.addEventListener("state", (event) => {
|
source.addEventListener("state", (event) => {
|
||||||
stopFallbackPolling();
|
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 = () => {
|
source.onerror = () => {
|
||||||
@@ -2756,6 +2963,21 @@ function connectEvents() {
|
|||||||
|
|
||||||
// ── UI sync helpers ────────────────────────────────────────────────────────
|
// ── 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() {
|
function syncLabelSettingsUi() {
|
||||||
elements.ordinaryLootSpread.value = String(state.settings.ordinaryLootSpread);
|
elements.ordinaryLootSpread.value = String(state.settings.ordinaryLootSpread);
|
||||||
elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
|
elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
|
||||||
@@ -2785,7 +3007,6 @@ function syncAllCheckboxes() {
|
|||||||
showLabels: elements.showLabels,
|
showLabels: elements.showLabels,
|
||||||
showPOIs: elements.showPOIs,
|
showPOIs: elements.showPOIs,
|
||||||
showGrid: elements.showGrid,
|
showGrid: elements.showGrid,
|
||||||
showSatellite: elements.showSatellite,
|
|
||||||
showSatmap: elements.showSatmap,
|
showSatmap: elements.showSatmap,
|
||||||
showDistanceRings: elements.showDistanceRings,
|
showDistanceRings: elements.showDistanceRings,
|
||||||
showMinimap: elements.showMinimap,
|
showMinimap: elements.showMinimap,
|
||||||
@@ -2946,7 +3167,6 @@ function bindUi() {
|
|||||||
showLabels: elements.showLabels,
|
showLabels: elements.showLabels,
|
||||||
showPOIs: elements.showPOIs,
|
showPOIs: elements.showPOIs,
|
||||||
showGrid: elements.showGrid,
|
showGrid: elements.showGrid,
|
||||||
showSatellite: elements.showSatellite,
|
|
||||||
showSatmap: elements.showSatmap,
|
showSatmap: elements.showSatmap,
|
||||||
showDistanceRings: elements.showDistanceRings,
|
showDistanceRings: elements.showDistanceRings,
|
||||||
showMinimap: elements.showMinimap,
|
showMinimap: elements.showMinimap,
|
||||||
@@ -2954,7 +3174,13 @@ function bindUi() {
|
|||||||
el.addEventListener("change", () => {
|
el.addEventListener("change", () => {
|
||||||
state.settings[key] = el.checked;
|
state.settings[key] = el.checked;
|
||||||
persistSettings();
|
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 if (key === "showMinimap") updateMinimap();
|
||||||
else render();
|
else render();
|
||||||
});
|
});
|
||||||
@@ -2992,6 +3218,21 @@ function bindUi() {
|
|||||||
// Label settings
|
// Label settings
|
||||||
syncLabelSettingsUi();
|
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", () => {
|
elements.ordinaryLootSpread.addEventListener("input", () => {
|
||||||
state.settings.ordinaryLootSpread = Number(elements.ordinaryLootSpread.value);
|
state.settings.ordinaryLootSpread = Number(elements.ordinaryLootSpread.value);
|
||||||
elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
|
elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
|
||||||
@@ -3069,6 +3310,12 @@ function bindUi() {
|
|||||||
state.lastX = event.clientX;
|
state.lastX = event.clientX;
|
||||||
state.lastY = event.clientY;
|
state.lastY = event.clientY;
|
||||||
state.pinchDistance = 0;
|
state.pinchDistance = 0;
|
||||||
|
if (state.dragging && state.isZooming) {
|
||||||
|
state.isZooming = false;
|
||||||
|
clearTimeout(state.zoomIdleTimer);
|
||||||
|
state.zoomIdleTimer = null;
|
||||||
|
scheduleVisibleTilesUpdate();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pair = getActivePointerPair();
|
const pair = getActivePointerPair();
|
||||||
@@ -3134,10 +3381,16 @@ function bindUi() {
|
|||||||
drawMeasureLayer(event.clientX, event.clientY);
|
drawMeasureLayer(event.clientX, event.clientY);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wheel zoom
|
// Wheel zoom — suppress tile loading while spinning; load once zoom settles.
|
||||||
elements.viewport.addEventListener("wheel", (event) => {
|
elements.viewport.addEventListener("wheel", (event) => {
|
||||||
if (event.target.closest("#ctxMenu")) return;
|
if (event.target.closest("#ctxMenu")) return;
|
||||||
event.preventDefault();
|
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));
|
zoomAt(event.clientX, event.clientY, state.scale * (event.deltaY < 0 ? 1.1 : 0.9));
|
||||||
if (state.measureMode) drawMeasureLayer();
|
if (state.measureMode) drawMeasureLayer();
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
@@ -3168,6 +3421,14 @@ function bindUi() {
|
|||||||
render();
|
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", () => {
|
elements.ctxCopyCoords.addEventListener("click", () => {
|
||||||
if (!state.contextMenuWorldPos) { hideContextMenu(); return; }
|
if (!state.contextMenuWorldPos) { hideContextMenu(); return; }
|
||||||
const mapSize = state.bootstrap?.mapSize || 0;
|
const mapSize = state.bootstrap?.mapSize || 0;
|
||||||
@@ -3211,6 +3472,12 @@ ensureFilterSettings();
|
|||||||
renderFilterCards();
|
renderFilterCards();
|
||||||
bindUi();
|
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()
|
bootstrap()
|
||||||
.then(() => fetch(apiUrl("/api/state"), { cache: "no-store" }))
|
.then(() => fetch(apiUrl("/api/state"), { cache: "no-store" }))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
|||||||
+19
-5
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>DayZ Web Radar</title>
|
<title>DayZ Web Radar</title>
|
||||||
<link rel="stylesheet" href="style.css?v=7">
|
<link rel="stylesheet" href="style.css?v=13">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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="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="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="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>Satellite map</span></label>
|
||||||
<label class="toggle-row"><input type="checkbox" id="showSatmap"><span>Sat 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="showDistanceRings"><span>Distance rings</span></label>
|
||||||
<label class="toggle-row"><input type="checkbox" id="showMinimap"><span>Minimap</span></label>
|
<label class="toggle-row"><input type="checkbox" id="showMinimap"><span>Minimap</span></label>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,6 +151,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="tab-section">
|
||||||
<div class="section-label">Theme</div>
|
<div class="section-label">Theme</div>
|
||||||
<div class="theme-bar">
|
<div class="theme-bar">
|
||||||
@@ -206,7 +219,8 @@
|
|||||||
|
|
||||||
<!-- Right-click context menu -->
|
<!-- Right-click context menu -->
|
||||||
<div class="ctx-menu" id="ctxMenu">
|
<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>
|
<button class="ctx-item" id="ctxCopyCoords">Copy coordinates</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -228,6 +242,6 @@
|
|||||||
<!-- ── Toast notifications ────────────────────────────────────────── -->
|
<!-- ── Toast notifications ────────────────────────────────────────── -->
|
||||||
<div class="toast-stack" id="toastStack"></div>
|
<div class="toast-stack" id="toastStack"></div>
|
||||||
|
|
||||||
<script src="app.js?v=7"></script>
|
<script src="app.js?v=13"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+22
-7
@@ -811,6 +811,18 @@ body.sidebar-hidden .viewport { left: 0; }
|
|||||||
width: 10px; height: 10px;
|
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 {
|
.entity.bullet-phantom .marker.bullets {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
box-shadow: 0 0 6px rgba(148,163,184,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 overlay ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.grid-major { stroke: rgba(255,255,255,0.30); stroke-width: 1; }
|
.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: 0.5; }
|
.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-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: 10px; color: rgba(0,0,0,0.90); text-shadow: 0 1px 1px rgba(255,255,255,0.6); }
|
.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 {
|
.grid-label-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -972,12 +984,13 @@ body.sidebar-hidden .viewport { left: 0; }
|
|||||||
.canvas.grid-labels-visible .grid-label-wrapper { visibility: visible; }
|
.canvas.grid-labels-visible .grid-label-wrapper { visibility: visible; }
|
||||||
|
|
||||||
.grid-label-inner {
|
.grid-label-inner {
|
||||||
font-size: 10px;
|
font-size: var(--grid-label-size, 13px);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: rgba(255,255,255,0.50);
|
color: rgba(255,255,255,0.55);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1;
|
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 ─────────────────────────────────────────────────────── */
|
/* ── Combat mode ─────────────────────────────────────────────────────── */
|
||||||
@@ -1058,6 +1071,8 @@ body.measure-active .viewport { cursor: crosshair; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ctx-item:hover { background: var(--bg-hover); }
|
.ctx-item:hover { background: var(--bg-hover); }
|
||||||
|
.ctx-item-shared { color: #f59e0b; }
|
||||||
|
.ctx-item-shared:hover { color: #fde68a; }
|
||||||
|
|
||||||
/* ── Coord bar ───────────────────────────────────────────────────────── */
|
/* ── Coord bar ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user