You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

17156 lines
686 KiB

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HikeMap Trail Navigator</title>
<!-- PWA Meta Tags -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#4CAF50">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="HikeMap">
<link rel="apple-touch-icon" href="/icon-192x192.png">
<!-- Additional PWA Meta Tags -->
<meta name="description" content="GPS trail navigation, tracking, and geocaching app for hikers">
<meta name="mobile-web-app-capable" content="yes">
<!-- Icons for different platforms -->
<link rel="icon" type="image/png" sizes="32x32" href="/icon-72x72.png">
<link rel="icon" type="image/png" sizes="16x16" href="/icon-72x72.png">
<!-- Open Graph Meta Tags for sharing -->
<meta property="og:title" content="HikeMap Trail Navigator">
<meta property="og:description" content="GPS trail navigation, tracking, and geocaching app">
<meta property="og:type" content="website">
<meta property="og:image" content="/icon-512x512.png">
<link href="https://unpkg.com/maplibre-gl@4.1.0/dist/maplibre-gl.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
<style>
:root {
--combat-icon-scale-mobile: 0.7;
--combat-icon-size: 100px;
--combat-status-size: 100px;
}
/* Apply scaled icons only on mobile devices */
@media (max-width: 768px) {
:root {
--combat-icon-size: calc(100px * var(--combat-icon-scale-mobile));
--combat-status-size: calc(100px * var(--combat-icon-scale-mobile));
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: visible;
position: relative;
}
#map {
height: 100vh;
width: 100%;
position: relative;
z-index: 1;
}
/* Ensure MapLibre map doesn't block our popups */
.maplibregl-map {
z-index: 1 !important;
}
/* MapLibre markers z-index */
.maplibregl-marker {
z-index: 600 !important;
}
/* Fog of War overlay */
.fog-overlay {
z-index: 450 !important;
pointer-events: none;
}
#fogCanvas {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
width: 280px;
}
.panel-toggle {
position: absolute;
top: 10px;
right: 10px;
z-index: 1001;
background: white;
padding: 10px 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
cursor: pointer;
font-size: 18px;
border: none;
}
.panel-toggle:hover {
background: #f8f9fa;
}
.controls h3 {
margin-bottom: 10px;
font-size: 14px;
color: #333;
}
.controls input[type="file"] {
width: 100%;
padding: 8px;
border: 2px dashed #ccc;
border-radius: 4px;
cursor: pointer;
margin-bottom: 10px;
}
.controls input[type="file"]:hover {
border-color: #007bff;
}
.section {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section-title {
font-size: 12px;
font-weight: bold;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
}
.tab-bar {
display: flex;
margin-bottom: 15px;
border-bottom: 2px solid #ddd;
}
.tab-btn {
flex: 1;
padding: 10px;
border: none;
background: #f0f0f0;
cursor: pointer;
font-size: 13px;
font-weight: bold;
transition: all 0.2s;
}
.tab-btn:first-child {
border-radius: 4px 0 0 0;
}
.tab-btn:last-child {
border-radius: 0 4px 0 0;
}
.tab-btn:hover {
background: #e0e0e0;
}
.tab-btn.active {
background: #007bff;
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.nav-info {
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
margin-top: 10px;
}
.nav-distance {
font-size: 24px;
font-weight: bold;
color: #007bff;
margin-bottom: 5px;
}
.route-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.route-overlay-content {
background: white;
padding: 30px 40px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
text-align: center;
}
.route-overlay .spinner {
width: 50px;
height: 50px;
border: 5px solid #e0e0e0;
border-top: 5px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
.route-overlay-text {
font-size: 18px;
font-weight: 500;
color: #333;
}
.compass-indicator {
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(255,255,255,0.9);
padding: 5px 15px;
border-radius: 20px;
font-weight: bold;
z-index: 1000;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
display: none;
}
.compass-indicator.active {
display: block;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.nav-instruction {
color: #666;
font-size: 12px;
margin-bottom: 10px;
}
.destination-pin {
font-size: 24px;
margin-left: -12px;
margin-top: -24px;
}
.tool-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.tool-btn {
padding: 8px 12px;
border: 2px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.tool-btn:hover {
border-color: #007bff;
background: #f8f9fa;
}
.tool-btn.active {
border-color: #007bff;
background: #007bff;
color: white;
}
.tool-btn.danger {
border-color: #dc3545;
}
.tool-btn.danger:hover, .tool-btn.danger.active {
background: #dc3545;
border-color: #dc3545;
color: white;
}
.action-btn {
width: 100%;
padding: 10px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
margin-bottom: 6px;
}
.action-btn:hover {
background: #218838;
}
.action-btn.secondary {
background: #6c757d;
}
.action-btn.secondary:hover {
background: #5a6268;
}
.action-btn.danger {
background: #dc3545;
}
.action-btn.danger:hover {
background: #c82333;
}
.status {
margin-top: 10px;
font-size: 12px;
color: #666;
padding: 8px;
background: #f8f9fa;
border-radius: 4px;
}
.status.success {
color: #155724;
background: #d4edda;
}
.status.error {
color: #721c24;
background: #f8d7da;
}
.status.info {
color: #0c5460;
background: #d1ecf1;
}
.slider-group {
margin: 10px 0;
}
.slider-group label {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 4px;
}
.slider-group input[type="range"] {
width: 100%;
}
.slider-value {
font-size: 11px;
color: #333;
text-align: right;
}
.track-list {
max-height: 150px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 10px;
}
.track-item {
padding: 6px 10px;
font-size: 12px;
border-bottom: 1px solid #eee;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.track-item:last-child {
border-bottom: none;
}
.track-item:hover {
background: #f8f9fa;
}
.track-item.selected {
background: #cce5ff;
}
.track-item .delete-btn {
background: none;
border: none;
color: #dc3545;
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
}
.track-item .delete-btn:hover {
background: #dc3545;
color: white;
border-radius: 3px;
}
.slider-group input[type="range"].preview-active {
outline: 3px solid #00cc00;
border-radius: 4px;
}
#applyMergeBtn {
background: #00cc00 !important;
}
#applyMergeBtn:hover {
background: #00aa00 !important;
}
.custom-div-icon {
background: transparent;
border: none;
}
.custom-div-icon i {
display: block;
text-align: center;
line-height: 1;
text-shadow: 1px 1px 2px rgba(0,0,0,0.3);
}
.icon-selector-overlay {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
background: rgba(0, 0, 0, 0.7) !important;
display: flex;
align-items: center;
justify-content: center;
z-index: 999999 !important;
pointer-events: auto !important;
}
.icon-selector-modal {
background: white;
padding: 30px;
border-radius: 12px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.icon-selector-title {
font-size: 24px;
margin-bottom: 10px;
text-align: center;
}
.icon-selector-subtitle {
color: #666;
text-align: center;
margin-bottom: 20px;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.icon-option {
display: flex;
flex-direction: column;
align-items: center;
padding: 15px 10px;
border: 2px solid #ddd;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.icon-option:hover {
border-color: #007bff;
transform: scale(1.05);
background: #f8f9fa;
}
.icon-option i {
font-size: 36px;
margin-bottom: 5px;
}
.icon-option span {
font-size: 11px;
text-align: center;
}
/* Geocache styles */
.geocache-marker {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
/* DEBUG: visible halo showing tap zone - remove when confirmed working */
background: rgba(255, 167, 38, 0.3);
border: 2px dashed rgba(255, 167, 38, 0.7);
border-radius: 50%;
}
.geocache-marker:hover {
transform: scale(1.2);
}
.geocache-marker.in-range {
box-shadow: 0 0 20px rgba(255, 167, 38, 0.8);
}
.geocache-marker i {
font-size: 36px;
pointer-events: none; /* Parent handles all touches */
}
/* PNG cache icon styles */
.geocache-marker-png {
background: none !important;
border: none !important;
}
.cache-icon-container {
position: relative;
width: 64px;
height: 64px;
}
.cache-shadow {
position: absolute;
width: 64px;
height: 64px;
top: 4px;
left: 4px;
z-index: 0;
opacity: 0.5;
pointer-events: none;
}
.cache-main {
position: absolute;
width: 64px;
height: 64px;
top: 0;
left: 0;
z-index: 1;
pointer-events: none;
}
.geocache-dialog {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
background: rgba(0, 0, 0, 0.6) !important;
display: flex;
align-items: center;
justify-content: center;
z-index: 999997 !important;
pointer-events: auto !important;
}
.geocache-dialog-content {
background: white;
padding: 25px;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.geocache-dialog h3 {
margin-bottom: 15px;
text-align: center;
color: #333;
}
.geocache-messages {
flex: 1;
overflow-y: auto;
max-height: 300px;
margin-bottom: 15px;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
}
.geocache-message {
margin-bottom: 12px;
padding: 10px;
background: white;
border-radius: 6px;
border-left: 3px solid #007bff;
}
.geocache-message-header {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 12px;
color: #666;
}
.geocache-message-author {
font-weight: bold;
}
.geocache-message-text {
font-size: 14px;
color: #333;
word-wrap: break-word;
}
.geocache-input-group {
margin-bottom: 15px;
}
.geocache-input-group label {
display: block;
margin-bottom: 5px;
font-size: 13px;
color: #666;
}
.geocache-input-group input,
.geocache-input-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.geocache-input-group textarea {
resize: vertical;
min-height: 80px;
}
.geocache-dialog-buttons {
display: flex;
gap: 10px;
}
.geocache-dialog button {
flex: 1;
padding: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.geocache-submit-btn {
background: #28a745;
color: white;
}
.geocache-cancel-btn {
background: #6c757d;
color: white;
}
.geocache-delete-btn {
background: #dc3545;
color: white;
}
.geocache-alert {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #007bff;
color: white;
padding: 12px 20px;
border-radius: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 1001;
display: none;
}
.geocache-alert.show {
display: block;
animation: slideUp 0.3s ease;
}
/* Geocache List Sidebar */
.geocache-list-sidebar {
position: fixed;
right: -350px;
top: 60px;
bottom: 0;
width: 350px;
background: rgba(30, 30, 30, 0.95);
backdrop-filter: blur(10px);
transition: right 0.3s ease;
z-index: 999;
overflow-y: auto;
padding: 20px;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.5);
}
.geocache-list-sidebar.open {
right: 0;
}
.geocache-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #444;
}
.geocache-list-item {
background: rgba(40, 40, 40, 0.8);
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
cursor: pointer;
transition: background 0.2s;
}
.geocache-list-item:hover {
background: rgba(60, 60, 60, 0.9);
}
.geocache-list-item-title {
font-weight: bold;
color: #FFA726;
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 8px;
}
.geocache-list-item-info {
font-size: 0.9em;
color: #aaa;
}
.geocache-list-item-secret {
display: inline-block;
background: #9C27B0;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.75em;
margin-left: 5px;
}
.geocache-list-toggle {
position: fixed;
right: 20px;
bottom: 140px;
width: 50px;
height: 50px;
background: #FFA726;
border-radius: 50%;
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
z-index: 998;
}
/* Admin Panel Overlay - Half-Height Bottom Sheet */
.admin-panel-overlay {
position: fixed;
bottom: 0;
right: 0;
width: 100%;
max-width: 500px;
height: 50vh;
background: rgba(30, 30, 30, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transform: translateY(100%);
transition: transform 0.3s ease;
z-index: 1002;
border-radius: 20px 20px 0 0;
box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
}
@media (max-width: 768px) {
.admin-panel-overlay {
max-width: 100%;
border-radius: 20px 20px 0 0;
}
}
@media (min-width: 769px) {
.admin-panel-overlay {
border-radius: 20px 0 0 0;
}
}
.admin-panel-overlay.active {
transform: translateY(0);
}
.admin-panel-header {
padding: 20px;
border-bottom: 1px solid #444;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.admin-panel-header h2 {
color: #FFA726;
margin: 0;
font-size: 20px;
}
.admin-panel-close {
background: none;
border: none;
color: #aaa;
font-size: 28px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.admin-panel-close:hover {
color: white;
}
.admin-panel-content {
padding: 20px;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
/* Edit Panel Overlay - Similar to Admin */
.edit-panel-overlay {
position: fixed;
bottom: 0;
right: 0;
width: 100%;
max-width: 500px;
height: 60vh;
background: rgba(30, 30, 30, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transform: translateY(100%);
transition: transform 0.3s ease;
z-index: 1001;
border-radius: 20px 20px 0 0;
box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
}
@media (max-width: 768px) {
.edit-panel-overlay {
max-width: 100%;
border-radius: 20px 20px 0 0;
}
}
@media (min-width: 769px) {
.edit-panel-overlay {
border-radius: 20px 0 0 0;
}
}
.edit-panel-overlay.active {
transform: translateY(0);
}
.edit-panel-header {
padding: 20px;
border-bottom: 1px solid #444;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.edit-panel-header h2 {
color: #4CAF50;
margin: 0;
font-size: 20px;
}
.edit-panel-close {
background: none;
border: none;
color: #aaa;
font-size: 28px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.edit-panel-close:hover {
color: white;
}
.edit-panel-content {
padding: 20px;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
/* Admin Panel Styles - Simple and Working */
.admin-setting-group {
padding: 15px;
}
.admin-input-row {
display: flex;
flex-direction: column;
margin-bottom: 20px;
}
.admin-input-row label {
color: #FFA726;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
display: block;
}
.admin-input-container {
display: flex;
align-items: center;
gap: 10px;
}
.admin-input-row input[type="number"] {
width: 100%;
max-width: 200px;
height: 44px;
padding: 8px 12px;
background: #2a2a2a;
color: white;
border: 2px solid #555;
border-radius: 8px;
font-size: 16px;
text-align: center;
-webkit-appearance: none;
-moz-appearance: textfield;
box-sizing: border-box;
}
.admin-input-row input[type="checkbox"] {
width: 24px;
height: 24px;
cursor: pointer;
margin-right: 10px;
}
.admin-input-row input[type="number"]:focus {
outline: none;
border-color: #4CAF50;
background: #333;
}
.admin-input-row .unit {
color: #aaa;
font-size: 14px;
white-space: nowrap;
}
.admin-checkbox-row {
display: flex;
align-items: center;
margin-bottom: 20px;
}
/* Responsive adjustments */
@media (min-width: 768px) {
.admin-input-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.admin-input-row label {
margin-bottom: 0;
flex: 0 0 auto;
min-width: 200px;
}
.admin-input-container {
flex: 0 0 auto;
}
}
.admin-button-group {
padding: 20px 10px;
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-button-group button {
min-height: 48px;
padding: 12px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
letter-spacing: 0.5px;
}
.admin-button-group button:active {
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.admin-button-group button:hover {
background: #45a049;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.admin-button-group button.danger {
background: #f44336;
}
.admin-button-group button.danger:hover {
background: #da190b;
}
@media (min-width: 768px) {
.admin-button-group {
flex-direction: row;
flex-wrap: wrap;
}
.admin-button-group button {
min-height: 40px;
flex: 1;
min-width: 140px;
}
}
/* Save indicator */
.admin-save-indicator {
position: fixed;
top: 70px;
right: 20px;
background: #4CAF50;
color: white;
padding: 10px 20px;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s;
z-index: 10000;
pointer-events: none;
}
.admin-save-indicator.show {
opacity: 1;
transform: translateY(0);
}
/* Collapsible sections */
.section.collapsible .section-title {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 30px;
}
.section.collapsible .section-title:after {
content: '▼';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
transition: transform 0.3s;
color: #FFA726;
}
.section.collapsed .section-title:after {
transform: translateY(-50%) rotate(-90deg);
}
.section-content {
max-height: 1000px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.section.collapsed .section-content {
max-height: 0;
}
@keyframes slideUp {
from { transform: translateX(-50%) translateY(100%); }
to { transform: translateX(-50%) translateY(0); }
}
/* Navigation confirmation dialogs */
.nav-confirm-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 3003;
}
.nav-confirm-content {
background: white;
padding: 20px;
border-radius: 8px;
max-width: 350px;
text-align: center;
}
.nav-confirm-content h3 {
margin: 0 0 15px 0;
color: #333;
}
.nav-confirm-content p {
margin: 0 0 20px 0;
color: #666;
}
.nav-confirm-buttons {
display: flex;
gap: 10px;
}
.nav-confirm-buttons button {
flex: 1;
padding: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.nav-confirm-yes {
background: #007bff;
color: white;
}
.nav-confirm-no {
background: #6c757d;
color: white;
}
.press-hold-indicator {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 123, 255, 0.9);
color: white;
padding: 10px 20px;
border-radius: 20px;
font-size: 14px;
pointer-events: none;
z-index: 1000;
display: none;
}
/* Auth Modal Styles */
.auth-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 999999;
}
.auth-modal {
background: white;
padding: 30px;
border-radius: 12px;
width: 90%;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.auth-modal h2 {
text-align: center;
margin-bottom: 20px;
color: #333;
}
.auth-tabs {
display: flex;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
}
.auth-tab {
flex: 1;
padding: 10px;
border: none;
background: #f0f0f0;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.2s;
}
.auth-tab:first-child {
border-radius: 4px 0 0 0;
}
.auth-tab:last-child {
border-radius: 0 4px 0 0;
}
.auth-tab.active {
background: #4CAF50;
color: white;
}
.auth-form {
display: none;
}
.auth-form.active {
display: block;
}
.auth-input-group {
margin-bottom: 15px;
}
.auth-input-group label {
display: block;
margin-bottom: 5px;
font-size: 13px;
color: #555;
}
.auth-input-group input {
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.auth-input-group input:focus {
border-color: #4CAF50;
outline: none;
}
.auth-error {
color: #dc3545;
font-size: 13px;
margin-bottom: 10px;
padding: 8px;
background: #f8d7da;
border-radius: 4px;
display: none;
}
.auth-error.visible {
display: block;
}
.auth-submit-btn {
width: 100%;
padding: 12px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
}
.auth-submit-btn:hover {
background: #43a047;
}
.auth-submit-btn:disabled {
background: #9e9e9e;
cursor: not-allowed;
}
.auth-close-btn {
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.auth-guest-divider {
display: flex;
align-items: center;
margin: 20px 0 15px 0;
color: #999;
font-size: 13px;
}
.auth-guest-divider::before,
.auth-guest-divider::after {
content: '';
flex: 1;
height: 1px;
background: #ddd;
}
.auth-guest-divider span {
padding: 0 15px;
}
.auth-guest-btn {
width: 100%;
padding: 12px;
background: transparent;
color: #666;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.auth-guest-btn:hover {
border-color: #999;
color: #333;
background: #f5f5f5;
}
/* Login Screen - Full page login before game loads */
#loginScreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
z-index: 1000000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow-y: auto;
}
#loginScreen.hidden {
display: none;
}
.login-screen-content {
width: 90%;
max-width: 420px;
padding: 20px;
position: relative;
z-index: 1;
}
.login-screen-logo {
text-align: center;
margin-bottom: 30px;
}
.login-screen-logo h1 {
font-size: 48px;
color: #4CAF50;
text-shadow: 0 4px 20px rgba(76, 175, 80, 0.4);
margin: 0;
letter-spacing: 2px;
}
.login-screen-logo p {
color: #8892b0;
font-size: 14px;
margin-top: 8px;
}
.login-card {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 30px;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.login-card .auth-tabs {
margin-bottom: 20px;
}
.login-card .auth-tab {
background: rgba(0, 0, 0, 0.05);
}
.login-card .auth-tab.active {
background: #4CAF50;
color: white;
}
/* Login screen monster background */
.login-monsters-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
opacity: 0.6;
}
.login-monster {
position: absolute;
width: 80px;
height: 80px;
transition: left 4s ease-in-out, top 4s ease-in-out, transform 0.3s ease;
}
.login-monster img {
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
}
.login-version {
text-align: center;
margin-top: 20px;
color: #546e7a;
font-size: 12px;
}
/* Hide game container until logged in */
#gameContainer {
display: none;
}
#gameContainer.visible {
display: block;
}
/* Character Creator Modal */
.char-creator-modal {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
padding: 0;
border-radius: 16px;
width: 95%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
color: white;
}
.char-creator-header {
background: linear-gradient(90deg, #4CAF50 0%, #8BC34A 100%);
padding: 20px;
text-align: center;
border-radius: 16px 16px 0 0;
}
.char-creator-header h2 {
margin: 0;
font-size: 24px;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.char-creator-header p {
margin: 5px 0 0 0;
font-size: 14px;
opacity: 0.9;
}
.char-creator-content {
padding: 20px;
}
.char-creator-step {
display: none;
}
.char-creator-step.active {
display: block;
}
.char-creator-section {
margin-bottom: 20px;
}
.char-creator-section h3 {
margin: 0 0 10px 0;
font-size: 16px;
color: #8BC34A;
}
.char-creator-input {
width: 100%;
padding: 12px;
border: 2px solid #333;
border-radius: 8px;
background: #0f0f23;
color: white;
font-size: 16px;
box-sizing: border-box;
}
.char-creator-input:focus {
border-color: #4CAF50;
outline: none;
}
.char-creator-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.char-creator-option {
background: #0f0f23;
border: 2px solid #333;
border-radius: 12px;
padding: 15px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.char-creator-option:hover {
border-color: #4CAF50;
background: #1a1a2e;
}
.char-creator-option.selected {
border-color: #4CAF50;
background: rgba(76, 175, 80, 0.2);
}
.char-creator-option.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.char-creator-option.disabled:hover {
border-color: #333;
background: #0f0f23;
}
.char-creator-option-icon {
font-size: 36px;
margin-bottom: 8px;
}
.char-creator-option-name {
font-weight: bold;
margin-bottom: 4px;
}
.char-creator-option-desc {
font-size: 12px;
color: #aaa;
}
.char-creator-option-badge {
display: inline-block;
background: #666;
color: #ccc;
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
margin-top: 5px;
}
.char-creator-stats {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 8px;
font-size: 11px;
}
.char-creator-stat {
display: flex;
align-items: center;
gap: 3px;
}
.char-creator-stat.positive { color: #4CAF50; }
.char-creator-stat.negative { color: #f44336; }
.char-creator-stat.neutral { color: #888; }
.char-creator-preview {
background: #0f0f23;
border: 2px solid #333;
border-radius: 12px;
padding: 20px;
text-align: center;
margin-bottom: 20px;
}
.char-creator-preview-icon {
font-size: 48px;
margin-bottom: 10px;
}
.char-creator-preview-name {
font-size: 20px;
font-weight: bold;
color: #8BC34A;
}
.char-creator-preview-info {
font-size: 14px;
color: #aaa;
margin-top: 5px;
}
.char-creator-preview-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 15px;
text-align: left;
}
.char-creator-preview-stat {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: #1a1a2e;
border-radius: 6px;
}
.char-creator-preview-stat-label {
color: #888;
}
.char-creator-preview-stat-value {
font-weight: bold;
}
.char-creator-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.char-creator-btn {
flex: 1;
padding: 14px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.char-creator-btn-back {
background: #333;
color: white;
}
.char-creator-btn-back:hover {
background: #444;
}
.char-creator-btn-next {
background: linear-gradient(90deg, #4CAF50 0%, #8BC34A 100%);
color: white;
}
.char-creator-btn-next:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
}
.char-creator-btn-next:disabled {
background: #333;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.char-step-indicator {
display: flex;
justify-content: center;
gap: 8px;
padding: 15px 0;
background: rgba(0,0,0,0.2);
}
.char-step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #333;
transition: all 0.2s;
}
.char-step-dot.active {
background: #4CAF50;
transform: scale(1.2);
}
.char-step-dot.completed {
background: #8BC34A;
}
/* Character Sheet Modal */
.char-sheet-modal {
background: linear-gradient(135deg, #1a1a2e 0%, #0f0f23 100%);
border-radius: 20px;
padding: 0;
width: 90%;
max-width: 400px;
max-height: 85vh;
overflow-y: auto;
position: relative;
border: 2px solid #4CAF50;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.char-sheet-close {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: #aaa;
font-size: 28px;
cursor: pointer;
z-index: 10;
transition: color 0.2s;
}
.char-sheet-close:hover {
color: #fff;
}
.char-sheet-header {
background: linear-gradient(135deg, #4CAF50, #8BC34A);
padding: 25px 20px;
text-align: center;
border-radius: 18px 18px 0 0;
}
.char-sheet-icon {
font-size: 48px;
margin-bottom: 10px;
}
.char-sheet-name {
font-size: 22px;
font-weight: bold;
color: #fff;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.char-sheet-info {
font-size: 14px;
color: rgba(255,255,255,0.9);
margin-top: 5px;
}
.char-sheet-content {
padding: 20px;
}
.char-sheet-section {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 15px;
margin-bottom: 15px;
}
.char-sheet-section h3 {
color: #8BC34A;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid rgba(139,195,74,0.3);
}
.char-sheet-stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.char-sheet-stat {
display: flex;
flex-direction: column;
gap: 4px;
}
.char-sheet-stat .stat-label {
font-size: 12px;
color: #aaa;
}
.char-sheet-stat .stat-value {
font-size: 16px;
font-weight: bold;
color: #fff;
}
.char-sheet-stat .stat-bar {
height: 8px;
background: rgba(0,0,0,0.3);
border-radius: 4px;
overflow: hidden;
}
.char-sheet-stat .stat-bar.hp-bar .stat-fill {
background: linear-gradient(90deg, #ff6b6b, #ee5a5a);
}
.char-sheet-stat .stat-bar.mp-bar .stat-fill {
background: linear-gradient(90deg, #4ecdc4, #45b7aa);
}
.char-sheet-stat .stat-fill {
height: 100%;
transition: width 0.3s ease;
}
.char-sheet-xp .xp-bar-container {
height: 12px;
background: rgba(0,0,0,0.3);
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}
.char-sheet-xp .xp-bar-fill {
height: 100%;
background: linear-gradient(90deg, #ffd93d, #f0c419);
transition: width 0.3s ease;
}
.char-sheet-xp .xp-text {
font-size: 14px;
font-weight: bold;
color: #ffd93d;
text-align: center;
}
.char-sheet-xp .xp-next {
font-size: 12px;
color: #888;
text-align: center;
margin-top: 4px;
}
.char-sheet-skill {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px;
background: rgba(0,0,0,0.2);
border-radius: 8px;
margin-bottom: 8px;
}
.char-sheet-skill:last-child {
margin-bottom: 0;
}
.char-sheet-skill .skill-icon {
font-size: 24px;
min-width: 30px;
text-align: center;
}
.char-sheet-skill .skill-info {
flex: 1;
}
.char-sheet-skill .skill-name {
font-size: 14px;
font-weight: bold;
color: #fff;
}
.char-sheet-skill .skill-desc {
font-size: 12px;
color: #aaa;
margin-top: 2px;
}
.char-sheet-skill .skill-cost {
font-size: 11px;
color: #4ecdc4;
margin-top: 4px;
}
.char-sheet-skill.locked {
opacity: 0.5;
}
.char-sheet-skill.locked .skill-name {
color: #666;
}
.char-sheet-skill-hint {
font-size: 11px;
color: #666;
text-align: center;
margin-top: 10px;
font-style: italic;
}
/* Skill Loadout Styles */
.skill-loadout-info {
font-size: 12px;
color: #aaa;
margin-bottom: 10px;
padding: 8px;
background: rgba(0,0,0,0.2);
border-radius: 6px;
text-align: center;
}
.skill-loadout-info.at-home {
color: #4ecdc4;
background: rgba(78, 205, 196, 0.1);
border: 1px solid rgba(78, 205, 196, 0.3);
}
.skill-loadout {
display: flex;
flex-direction: column;
gap: 12px;
}
.skill-tier-group {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 10px;
}
.skill-tier-header {
font-size: 12px;
color: #888;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.skill-tier-header .tier-badge {
background: #45aaf2;
color: #fff;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: bold;
}
.skill-loadout-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 6px;
background: rgba(255,255,255,0.05);
margin-bottom: 6px;
}
.skill-loadout-item:last-child {
margin-bottom: 0;
}
.skill-loadout-item.active {
background: rgba(78, 205, 196, 0.15);
border: 1px solid rgba(78, 205, 196, 0.4);
}
.skill-loadout-item .skill-icon {
font-size: 20px;
min-width: 26px;
text-align: center;
}
.skill-loadout-item .skill-details {
flex: 1;
}
.skill-loadout-item .skill-name {
font-size: 13px;
font-weight: bold;
color: #fff;
}
.skill-loadout-item .skill-mp {
font-size: 11px;
color: #4ecdc4;
}
.skill-loadout-item .skill-badge {
font-size: 10px;
padding: 3px 8px;
border-radius: 12px;
font-weight: bold;
text-transform: uppercase;
}
.skill-loadout-item .skill-badge.active-badge {
background: #4ecdc4;
color: #000;
}
.skill-loadout-item .equip-btn {
background: #45aaf2;
color: #fff;
border: none;
padding: 5px 12px;
border-radius: 12px;
font-size: 11px;
cursor: pointer;
font-weight: bold;
}
.skill-loadout-item .equip-btn:hover:not(:disabled) {
background: #3d9ad9;
}
.skill-loadout-item .equip-btn:disabled {
background: #555;
color: #888;
cursor: not-allowed;
}
/* Daily Skills Styles */
.char-sheet-daily-skills {
display: flex;
flex-direction: column;
gap: 10px;
}
.daily-skill {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(0,0,0,0.2);
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
}
.daily-skill .skill-icon {
font-size: 28px;
min-width: 36px;
text-align: center;
}
.daily-skill .skill-info {
flex: 1;
}
.daily-skill .skill-name {
font-size: 14px;
font-weight: bold;
color: #fff;
}
.daily-skill .skill-desc {
font-size: 11px;
color: #aaa;
margin-top: 2px;
}
.daily-skill .skill-status {
font-size: 11px;
margin-top: 4px;
}
.daily-skill .skill-status.active {
color: #4ecdc4;
}
.daily-skill .skill-status.cooldown {
color: #e94560;
}
.daily-skill .skill-status.available {
color: #4CAF50;
}
.daily-skill-btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.daily-skill-btn.activate {
background: linear-gradient(135deg, #4CAF50 0%, #388E3C 100%);
color: white;
}
.daily-skill-btn.activate:hover {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.4);
}
.daily-skill-btn.active {
background: linear-gradient(135deg, #4ecdc4 0%, #3da89f 100%);
color: white;
cursor: default;
}
.daily-skill-btn.cooldown {
background: rgba(255,255,255,0.1);
color: #888;
cursor: not-allowed;
}
.no-daily-skills {
text-align: center;
color: #888;
font-size: 12px;
padding: 10px;
}
.char-sheet-monster-count {
font-size: 16px;
color: #ffd93d;
margin-bottom: 10px;
}
.char-sheet-monster-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.char-sheet-monster {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: rgba(0,0,0,0.2);
border-radius: 8px;
}
.char-sheet-monster .monster-thumb {
width: 32px;
height: 32px;
object-fit: contain;
}
.char-sheet-monster .monster-info {
flex: 1;
font-size: 13px;
color: #fff;
}
.char-sheet-monster .monster-hp {
font-size: 11px;
color: #ff6b6b;
}
.char-sheet-no-monsters {
color: #666;
font-style: italic;
font-size: 13px;
}
/* Skill Choice Modal */
.skill-choice-modal {
background: linear-gradient(135deg, #1a1a2e 0%, #0f0f23 100%);
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 90%;
border: 2px solid #4CAF50;
box-shadow: 0 0 30px rgba(76, 175, 80, 0.3);
}
.skill-choice-header {
text-align: center;
margin-bottom: 20px;
}
.skill-choice-header h2 {
color: #ffd93d;
margin: 0 0 8px 0;
font-size: 24px;
}
.skill-choice-header p {
color: #aaa;
margin: 0;
font-size: 14px;
}
.skill-choice-option {
background: rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 16px;
margin: 12px 0;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
display: flex;
align-items: flex-start;
gap: 12px;
}
.skill-choice-option:hover {
background: rgba(76, 175, 80, 0.2);
transform: scale(1.02);
border-color: #4CAF50;
}
.skill-choice-option:active {
transform: scale(0.98);
}
.skill-choice-icon {
font-size: 32px;
flex-shrink: 0;
}
.skill-choice-details {
flex: 1;
}
.skill-choice-name {
font-weight: bold;
color: #4CAF50;
font-size: 16px;
margin-bottom: 4px;
}
.skill-choice-desc {
color: #aaa;
font-size: 13px;
line-height: 1.4;
margin-bottom: 6px;
}
.skill-choice-cost {
color: #4ecdc4;
font-size: 12px;
font-weight: bold;
}
/* User Profile Display */
.user-profile {
display: flex;
align-items: center;
padding: 10px;
background: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%);
border-radius: 8px;
margin-bottom: 15px;
color: white;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.user-avatar i {
font-size: 24px;
}
.user-info {
flex: 1;
}
.user-name {
font-weight: bold;
font-size: 14px;
}
.user-points {
font-size: 12px;
opacity: 0.9;
}
.user-logout-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.user-logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.login-prompt {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 15px;
}
.login-prompt-text {
font-size: 13px;
color: #666;
margin-bottom: 10px;
}
.login-prompt-btn {
background: #4CAF50;
color: white;
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.login-prompt-btn:hover {
background: #43a047;
}
/* Found It Button */
.found-it-btn {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #FFA726 0%, #FF7043 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
margin-top: 10px;
transition: all 0.3s;
}
.found-it-btn:hover {
transform: scale(1.02);
box-shadow: 0 4px 15px rgba(255, 167, 38, 0.4);
}
.found-it-btn:disabled {
background: #9e9e9e;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.found-it-btn.already-found {
background: linear-gradient(135deg, #66BB6A 0%, #4CAF50 100%);
}
/* Points Animation */
.points-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(135deg, #FFA726 0%, #FF7043 100%);
color: white;
padding: 30px 50px;
border-radius: 20px;
font-size: 36px;
font-weight: bold;
z-index: 999999;
animation: pointsPopup 2s ease-out forwards;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.points-popup .bonus {
font-size: 18px;
display: block;
margin-top: 5px;
opacity: 0.9;
}
@keyframes pointsPopup {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.5);
}
20% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
40% {
transform: translate(-50%, -50%) scale(1);
}
80% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -60%) scale(0.9);
}
}
/* Leaderboard Styles */
.leaderboard-modal {
background: white;
padding: 20px;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.leaderboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.leaderboard-tabs {
display: flex;
gap: 5px;
margin-bottom: 15px;
}
.leaderboard-tab {
padding: 8px 15px;
border: none;
background: #f0f0f0;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
}
.leaderboard-tab.active {
background: #4CAF50;
color: white;
}
.leaderboard-list {
list-style: none;
padding: 0;
margin: 0;
}
.leaderboard-item {
display: flex;
align-items: center;
padding: 12px;
border-bottom: 1px solid #eee;
}
.leaderboard-rank {
width: 30px;
font-weight: bold;
color: #666;
}
.leaderboard-rank.gold { color: #FFD700; }
.leaderboard-rank.silver { color: #C0C0C0; }
.leaderboard-rank.bronze { color: #CD7F32; }
.leaderboard-avatar {
width: 35px;
height: 35px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.leaderboard-user {
flex: 1;
}
.leaderboard-username {
font-weight: bold;
font-size: 14px;
}
.leaderboard-finds {
font-size: 12px;
color: #666;
}
.leaderboard-points {
font-weight: bold;
color: #4CAF50;
}
/* ========================================
RPG COMBAT SYSTEM STYLES
======================================== */
/* Monster Marker Styles */
.monster-marker-container {
background: transparent !important;
border: none !important;
pointer-events: auto !important;
touch-action: none;
}
.monster-marker {
position: relative;
cursor: pointer;
transition: transform 0.2s;
/* Large tap target for mobile */
display: flex;
align-items: center;
justify-content: center;
width: 70px;
height: 70px;
/* Semi-transparent background for tap area */
background: radial-gradient(circle, rgba(255,100,100,0.25) 0%, rgba(255,100,100,0) 70%);
border-radius: 50%;
pointer-events: auto;
touch-action: none;
-webkit-touch-callout: none;
user-select: none;
-webkit-user-select: none;
}
.monster-marker:hover {
transform: scale(1.2);
}
.monster-marker:active {
transform: scale(0.95);
background: radial-gradient(circle, rgba(255,100,100,0.4) 0%, rgba(255,100,100,0) 70%);
}
.monster-marker.combat-zoom-in {
animation: monster-combat-zoom 1.2s ease-in-out forwards;
}
@keyframes monster-combat-zoom {
0% { transform: scale(1); }
100% { transform: scale(5); }
}
.monster-icon {
width: 50px;
height: 50px;
object-fit: contain;
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.6));
animation: monster-bob 2s ease-in-out infinite;
}
@keyframes monster-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.monster-dialogue-bubble {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: white;
border: 2px solid #333;
border-radius: 12px;
padding: 8px 14px;
max-width: 220px;
font-size: 13px;
white-space: normal;
box-shadow: 0 3px 12px rgba(0,0,0,0.25);
z-index: 1000;
pointer-events: none;
margin-bottom: 8px;
}
.monster-dialogue-bubble::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 10px solid transparent;
border-top-color: #333;
}
/* RPG HUD Styles */
.rpg-hud {
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 12px;
border-radius: 12px;
font-size: 11px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 4px;
border: 2px solid #e94560;
box-shadow: 0 4px 15px rgba(233, 69, 96, 0.3);
cursor: pointer;
min-width: 140px;
}
.rpg-hud-bar {
display: flex;
align-items: center;
gap: 6px;
}
.rpg-hud-bar-label {
color: #888;
font-size: 10px;
font-weight: bold;
width: 20px;
text-align: right;
}
.rpg-hud-bar-track {
flex: 1;
height: 10px;
background: #222;
border-radius: 5px;
overflow: hidden;
border: 1px solid #444;
}
.rpg-hud-bar-fill {
height: 100%;
transition: width 0.3s ease;
border-radius: 4px;
}
.rpg-hud-bar-fill.hp-fill {
background: linear-gradient(90deg, #ff6b6b, #ee5a5a);
}
.rpg-hud-bar-fill.mp-fill {
background: linear-gradient(90deg, #4ecdc4, #3dbdb5);
}
.rpg-hud-bar-fill.xp-fill {
background: linear-gradient(90deg, #ffd93d, #f0c419);
}
.rpg-hud-bar-text {
font-size: 9px;
min-width: 42px;
text-align: right;
color: #aaa;
}
/* Combat Overlay Styles */
.combat-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
}
.combat-container {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 3px solid #e94560;
border-radius: 20px;
padding: 20px 24px;
max-width: 480px;
width: 95%;
max-height: 90vh;
overflow-y: auto;
color: white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 0 40px rgba(233, 69, 96, 0.4);
}
.combat-header {
text-align: center;
margin-bottom: 20px;
}
.combat-header h2 {
color: #e94560;
font-size: 32px;
margin: 0;
text-shadow: 0 0 20px rgba(233, 69, 96, 0.6);
letter-spacing: 3px;
}
.combat-arena {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.combatant {
text-align: center;
flex: 1;
}
.combatant-icon {
font-size: 56px;
margin-bottom: 8px;
}
.combatant-name {
font-weight: bold;
font-size: 15px;
margin-bottom: 10px;
}
.combat-vs {
font-size: 28px;
font-weight: bold;
color: #e94560;
padding: 0 15px;
text-shadow: 0 0 10px rgba(233, 69, 96, 0.5);
}
.stat-bars {
width: 100%;
padding: 0 5px;
}
.stat-bar-container {
margin: 6px 0;
}
.stat-bar-label {
font-size: 10px;
color: #888;
margin-bottom: 2px;
}
.hp-bar, .mp-bar {
height: 14px;
background: #333;
border-radius: 7px;
overflow: hidden;
border: 1px solid #555;
}
.hp-fill {
height: 100%;
background: linear-gradient(90deg, #e94560, #ff6b6b);
transition: width 0.4s ease;
border-radius: 6px;
}
.mp-fill {
height: 100%;
background: linear-gradient(90deg, #4ecdc4, #45b7aa);
transition: width 0.4s ease;
border-radius: 6px;
}
.stat-text {
font-size: 11px;
color: #aaa;
margin-top: 5px;
}
.combat-log {
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
padding: 8px 12px;
height: 70px;
overflow-y: auto;
margin-bottom: 12px;
font-size: 12px;
border: 1px solid #333;
}
.combat-log-entry {
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid #333;
}
.combat-log-entry:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.combat-log-damage {
color: #ff6b6b;
}
.combat-log-heal {
color: #4ecdc4;
}
.combat-log-victory {
color: #ffd93d;
font-weight: bold;
}
.combat-log-miss {
color: #888;
font-style: italic;
}
.combat-log-buff {
color: #a8e6cf;
}
.combat-skills {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 10px;
}
.skill-btn {
background: linear-gradient(135deg, #0f3460 0%, #16213e 100%);
border: 2px solid #e94560;
color: white;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.skill-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #e94560 0%, #c73e54 100%);
transform: scale(1.02);
box-shadow: 0 0 12px rgba(233, 69, 96, 0.4);
}
.skill-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.skill-btn .skill-icon-wrapper {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.skill-btn .skill-icon-wrapper img {
width: 32px;
height: 32px;
}
.skill-btn .skill-info {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.skill-btn .skill-name {
font-weight: bold;
font-size: 12px;
line-height: 1.2;
}
.skill-btn .skill-cost {
font-size: 10px;
color: #4ecdc4;
}
.skill-btn .skill-cost.free {
color: #8f8;
}
.skill-btn .skill-cost.locked {
color: #888;
}
.skill-btn.skill-locked {
opacity: 0.5;
background: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%);
border-color: #444;
}
.skill-btn.skill-locked .skill-name {
color: #666;
}
.combat-flee-btn {
width: 100%;
background: #333;
border: 2px solid #555;
color: #aaa;
padding: 10px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
margin-top: 8px;
}
.combat-flee-btn:hover {
background: #444;
border-color: #666;
color: white;
}
/* Multi-monster combat styles */
.combat-arena {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
min-height: 180px;
}
.player-side {
flex: 0 0 140px;
}
.monster-side {
flex: 1;
max-width: 200px;
}
.monster-list {
max-height: 200px;
overflow-y: auto;
padding-right: 5px;
}
.monster-entry {
background: rgba(0, 0, 0, 0.4);
border: 2px solid #444;
border-radius: 10px;
padding: 10px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.monster-entry:last-child {
margin-bottom: 0;
}
.monster-entry:hover {
border-color: #888;
background: rgba(255, 255, 255, 0.05);
}
.monster-entry.selected {
border-color: #e94560;
box-shadow: 0 0 10px rgba(233, 69, 96, 0.4);
background: rgba(233, 69, 96, 0.1);
}
.monster-entry.dead {
opacity: 0.3;
pointer-events: none;
text-decoration: line-through;
}
.monster-entry.attacking {
border-color: #ff6b35;
box-shadow: 0 0 15px rgba(255, 107, 53, 0.6);
background: rgba(255, 107, 53, 0.15);
}
/* Rubber band attack animation for monster icon */
@keyframes monsterAttack {
0% {
transform: translateX(0);
}
20% {
transform: translateX(20px) scale(0.9);
}
50% {
transform: translateX(-30px) scale(1.15);
}
70% {
transform: translateX(-5px) scale(1.05);
}
100% {
transform: translateX(0) scale(1);
}
}
.monster-entry.attacking .monster-entry-icon {
animation: monsterAttack 0.5s ease-out;
}
.monster-entry-header {
display: flex;
align-items: center;
margin-bottom: 6px;
}
.monster-entry-icon {
width: var(--combat-icon-size);
height: var(--combat-icon-size);
object-fit: contain;
}
.monster-entry .sprite-container {
position: relative;
width: var(--combat-icon-size);
height: var(--combat-icon-size);
margin-right: 10px;
flex-shrink: 0;
}
.monster-entry-name {
font-size: 12px;
font-weight: bold;
flex: 1;
}
.monster-entry-name.prefix-1 {
font-size: 11px;
}
.monster-entry-name.prefix-2 {
font-size: 10px;
}
.monster-entry-hp {
margin-top: 4px;
}
.monster-entry-hp .hp-bar {
height: 10px;
}
.monster-entry-hp .stat-text {
font-size: 10px;
margin-top: 2px;
}
.monster-entry-mp {
margin-top: 2px;
}
.monster-entry-mp .mp-bar {
height: 8px;
}
.monster-entry-mp .stat-text {
font-size: 9px;
margin-top: 1px;
color: #4ecdc4;
}
.turn-indicator {
text-align: center;
padding: 8px;
margin-bottom: 12px;
border-radius: 8px;
font-weight: bold;
font-size: 14px;
}
.turn-indicator.player-turn {
background: linear-gradient(90deg, rgba(78, 205, 196, 0.2), transparent);
color: #4ecdc4;
border-left: 3px solid #4ecdc4;
}
.turn-indicator.monster-turn {
background: linear-gradient(90deg, rgba(233, 69, 96, 0.2), transparent);
color: #e94560;
border-left: 3px solid #e94560;
}
.turn-indicator.targeting-mode {
background: linear-gradient(90deg, rgba(255, 193, 7, 0.3), transparent);
color: #ffc107;
border-left: 3px solid #ffc107;
animation: pulse-targeting 1s ease-in-out infinite;
}
@keyframes pulse-targeting {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.monster-entry.targeting-selectable {
cursor: pointer;
animation: glow-selectable 1.5s ease-in-out infinite;
}
@keyframes glow-selectable {
0%, 100% { box-shadow: 0 0 5px rgba(255, 193, 7, 0.5); }
50% { box-shadow: 0 0 15px rgba(255, 193, 7, 0.8); }
}
.target-arrow {
color: #e94560;
font-size: 14px;
margin-right: 4px;
}
/* Status effect overlay styles */
.sprite-container {
position: relative;
display: inline-block;
}
.status-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.status-overlay img {
position: absolute;
width: 24px;
height: 24px;
object-fit: contain;
}
.status-overlay .status-icon {
top: -4px;
right: -4px;
}
.status-overlay .buff-icon {
top: -4px;
left: -4px;
}
.player-side .sprite-container {
width: 80px;
height: 80px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 8px;
}
.player-side .combatant-icon {
font-size: 48px;
}
.player-side .status-overlay img {
width: 28px;
height: 28px;
}
.monster-entry .status-overlay img {
width: var(--combat-status-size);
height: var(--combat-status-size);
top: 0;
left: 0;
}
/* Home Base Button */
.home-base-btn {
position: fixed;
bottom: 100px;
right: 15px;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #4a90d9 0%, #357abd 100%);
border: 3px solid #fff;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
cursor: pointer;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
transition: all 0.2s;
}
.home-base-btn:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.home-base-btn.selecting {
background: linear-gradient(135deg, #f39c12 0%, #d68910 100%);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(243, 156, 18, 0.7); }
50% { box-shadow: 0 0 0 15px rgba(243, 156, 18, 0); }
}
.music-toggle-btn {
position: fixed;
bottom: 160px;
right: 15px;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
border: 3px solid #fff;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
cursor: pointer;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
transition: all 0.2s;
}
.music-toggle-btn:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.music-toggle-btn.muted {
background: linear-gradient(135deg, #7f8c8d 0%, #606c70 100%);
}
/* WASD Control Pad */
.wasd-controls {
position: fixed;
bottom: 20px;
left: 20px;
z-index: 2100;
display: grid;
grid-template-columns: 50px 50px 50px;
grid-template-rows: 50px 50px;
gap: 4px;
opacity: 0.85;
touch-action: none;
-webkit-touch-callout: none;
pointer-events: auto;
}
.wasd-controls.hidden {
display: none;
}
.wasd-btn {
width: 50px;
height: 50px;
border-radius: 8px;
background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
border: 2px solid #4a6785;
color: #fff;
font-size: 18px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4);
transition: all 0.1s;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: none;
-ms-touch-action: none;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
}
.wasd-btn:active {
transform: scale(0.95);
background: linear-gradient(135deg, #34495e 0%, #2c3e50 100%);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
}
.wasd-btn.w-btn {
grid-column: 2;
grid-row: 1;
}
.wasd-btn.a-btn {
grid-column: 1;
grid-row: 2;
}
.wasd-btn.s-btn {
grid-column: 2;
grid-row: 2;
}
.wasd-btn.d-btn {
grid-column: 3;
grid-row: 2;
}
.wasd-mode-indicator {
position: absolute;
top: -25px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: #4fc3f7;
padding: 3px 8px;
border-radius: 4px;
font-size: 10px;
white-space: nowrap;
}
/* Compass/GPS Button */
.compass-btn {
position: fixed;
bottom: 135px;
left: 62px;
z-index: 2100;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
border: 2px solid #4a6785;
color: #fff;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4);
transition: all 0.2s;
opacity: 0.85;
}
.compass-btn:active {
transform: scale(0.95);
}
.compass-btn.active {
background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
border-color: #2ecc71;
animation: pulse-glow 2s infinite;
}
.compass-btn.hidden {
display: none;
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4); }
50% { box-shadow: 0 3px 15px rgba(46, 204, 113, 0.6); }
}
/* Home Base Marker */
.home-base-marker {
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.home-base-marker img {
width: 50px;
height: 50px;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));
}
/* Death Overlay */
.death-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 2000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
text-align: center;
padding: 20px;
}
.death-overlay h1 {
font-size: 48px;
color: #e94560;
margin-bottom: 20px;
text-shadow: 0 0 20px rgba(233, 69, 96, 0.8);
}
.death-overlay p {
font-size: 18px;
margin-bottom: 10px;
max-width: 300px;
}
.death-overlay .xp-lost {
color: #e94560;
font-size: 24px;
margin: 20px 0;
}
.death-overlay .home-distance {
margin-top: 20px;
font-size: 16px;
color: #4ecdc4;
}
/* Dead state HUD styling */
.rpg-hud.dead {
filter: grayscale(100%);
opacity: 0.6;
}
.rpg-hud.dead::after {
content: '💀';
position: absolute;
top: -10px;
right: -10px;
font-size: 24px;
}
/* Home base selection mode hint */
.selection-hint {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(243, 156, 18, 0.95);
color: white;
padding: 12px 24px;
border-radius: 25px;
font-weight: bold;
z-index: 1001;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
/* Homebase Customization Modal */
.homebase-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.homebase-modal-content {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.homebase-modal h2 {
color: #4ecdc4;
text-align: center;
margin-bottom: 20px;
font-size: 24px;
}
.homebase-modal h3 {
color: #fff;
margin: 16px 0 12px;
font-size: 16px;
}
.homebase-icons-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.homebase-icon-option {
aspect-ratio: 1;
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
border: 3px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
padding: 8px;
}
.homebase-icon-option:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.homebase-icon-option.selected {
border-color: #4ecdc4;
background: rgba(78, 205, 196, 0.2);
}
.homebase-icon-option img {
width: 100%;
height: 100%;
object-fit: contain;
}
.homebase-modal-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.homebase-modal-actions button {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.homebase-btn-relocate {
background: linear-gradient(135deg, #f39c12 0%, #d68910 100%);
color: white;
}
.homebase-btn-relocate:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(243, 156, 18, 0.4);
}
.homebase-btn-relocate:disabled {
background: #666;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.homebase-btn-close {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.homebase-btn-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.homebase-relocate-cooldown {
text-align: center;
color: #888;
font-size: 12px;
margin-top: 8px;
}
/* Homebase Skill Loadout */
.homebase-skill-loadout {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.homebase-skill-tier {
background: rgba(0, 0, 0, 0.3);
border-radius: 10px;
padding: 10px;
}
.homebase-skill-tier-label {
font-size: 11px;
color: #888;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 1px;
}
.homebase-skill-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.homebase-skill-btn {
flex: 1;
min-width: 120px;
padding: 10px 12px;
border-radius: 8px;
border: 2px solid transparent;
background: rgba(255, 255, 255, 0.1);
color: #ccc;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.homebase-skill-btn:hover {
background: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.homebase-skill-btn.active {
border-color: #4ecdc4;
background: rgba(78, 205, 196, 0.2);
color: #fff;
}
.homebase-skill-btn.active::after {
content: '✓';
margin-left: auto;
color: #4ecdc4;
font-weight: bold;
}
.homebase-skill-btn .skill-icon {
font-size: 18px;
}
.homebase-skill-btn .skill-info {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.homebase-skill-btn .skill-name {
font-size: 13px;
font-weight: bold;
}
.homebase-skill-btn .skill-mp {
font-size: 10px;
color: #4ecdc4;
}
.homebase-no-skills {
color: #888;
font-size: 13px;
text-align: center;
padding: 12px;
}
.homebase-skill-btn.disabled-away {
opacity: 0.5;
cursor: not-allowed;
}
.homebase-skill-btn.disabled-away:hover {
transform: none;
background: rgba(255, 255, 255, 0.1);
}
.homebase-skill-warning {
color: #ff9800;
font-size: 12px;
text-align: center;
padding: 8px;
margin-bottom: 10px;
background: rgba(255, 152, 0, 0.15);
border-radius: 6px;
}
/* Toast notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
background: #333;
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
font-size: 14px;
max-width: 300px;
animation: toastSlideIn 0.3s ease-out;
pointer-events: auto;
}
.toast.success {
background: #28a745;
}
.toast.error {
background: #dc3545;
}
.toast.warning {
background: #ffc107;
color: #333;
}
.toast.info {
background: #17a2b8;
}
.toast.fade-out {
animation: toastFadeOut 0.3s ease-out forwards;
}
@keyframes toastSlideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes toastFadeOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* Custom Layer & Filter Control */
.layer-filter-control {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
max-height: 80vh;
overflow-y: auto;
}
.layer-filter-toggle {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 20px;
background: white;
border-radius: 8px;
}
.layer-filter-toggle:hover {
background: #f0f0f0;
}
.layer-filter-panel {
display: none;
padding: 12px;
min-width: 220px;
}
.layer-filter-control.expanded .layer-filter-toggle {
display: none;
}
.layer-filter-control.expanded .layer-filter-panel {
display: block;
}
.layer-filter-section {
margin-bottom: 12px;
}
.layer-filter-section:last-child {
margin-bottom: 0;
}
.layer-filter-section-title {
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
color: #666;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid #eee;
}
.layer-filter-section label {
display: block;
padding: 4px 0;
cursor: pointer;
}
.layer-filter-section label:hover {
background: #f5f5f5;
margin: 0 -8px;
padding: 4px 8px;
border-radius: 4px;
}
.layer-filter-section input[type="radio"] {
margin-right: 8px;
}
.filter-slider-row {
display: flex;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.filter-slider-row:last-child {
margin-bottom: 0;
}
.filter-slider-label {
width: 70px;
font-size: 11px;
color: #444;
}
.filter-slider-row input[type="range"] {
flex: 1;
height: 4px;
-webkit-appearance: none;
background: #ddd;
border-radius: 2px;
outline: none;
}
.filter-slider-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #4CAF50;
border-radius: 50%;
cursor: pointer;
}
.filter-slider-value {
width: 36px;
font-size: 11px;
color: #666;
text-align: right;
}
.filter-checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.filter-checkbox-row label {
font-size: 11px;
color: #444;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.filter-reset-btn {
width: 100%;
margin-top: 10px;
padding: 6px 12px;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.filter-reset-btn:hover {
background: #e0e0e0;
}
.blend-section select {
width: 100%;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
margin-bottom: 8px;
background: white;
}
.blend-section label {
display: block;
font-size: 11px;
color: #444;
margin-bottom: 4px;
}
.blend-section .blend-row {
margin-bottom: 10px;
}
.color-picker-row {
display: flex;
align-items: center;
margin-bottom: 8px;
gap: 10px;
}
.color-picker-row:last-child {
margin-bottom: 0;
}
.color-picker-label {
flex: 1;
font-size: 12px;
color: #444;
}
.color-picker-row input[type="color"] {
width: 36px;
height: 28px;
padding: 0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
-webkit-appearance: none;
}
.color-picker-row input[type="color"]::-webkit-color-swatch-wrapper {
padding: 2px;
}
.color-picker-row input[type="color"]::-webkit-color-swatch {
border-radius: 2px;
border: none;
}
.layer-filter-close {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
color: #999;
border-radius: 50%;
}
.layer-filter-close:hover {
background: #f0f0f0;
color: #333;
}
.layer-filter-panel {
position: relative;
}
</style>
<!-- Monster Animation Definitions -->
<script src="/animations.js"></script>
</head>
<body>
<!-- Login Screen - shown before game loads -->
<div id="loginScreen">
<div class="login-monsters-bg" id="loginMonstersBg"></div>
<div class="login-screen-content">
<div class="login-screen-logo">
<h1>HikeMap</h1>
<p>Trail Navigator & RPG Adventure</p>
</div>
<div class="login-card">
<div class="auth-tabs">
<button class="auth-tab active" id="loginScreenLoginTab">Login</button>
<button class="auth-tab" id="loginScreenRegisterTab">Register</button>
</div>
<div id="loginScreenError" class="auth-error"></div>
<!-- Login Form -->
<form id="loginScreenLoginForm" class="auth-form active">
<div class="auth-input-group">
<label for="loginScreenUsername">Username or Email</label>
<input type="text" id="loginScreenUsername" required autocomplete="username">
</div>
<div class="auth-input-group">
<label for="loginScreenPassword">Password</label>
<input type="password" id="loginScreenPassword" required autocomplete="current-password">
</div>
<button type="submit" class="auth-submit-btn" id="loginScreenSubmitBtn">Login</button>
</form>
<!-- Register Form -->
<form id="loginScreenRegisterForm" class="auth-form">
<div class="auth-input-group">
<label for="loginScreenRegUsername">Username</label>
<input type="text" id="loginScreenRegUsername" required minlength="3" maxlength="20" pattern="[a-zA-Z0-9_]+" autocomplete="username">
</div>
<div class="auth-input-group">
<label for="loginScreenRegEmail">Email</label>
<input type="email" id="loginScreenRegEmail" required autocomplete="email">
</div>
<div class="auth-input-group">
<label for="loginScreenRegPassword">Password (min 8 characters)</label>
<input type="password" id="loginScreenRegPassword" required minlength="8" autocomplete="new-password">
</div>
<div class="auth-input-group">
<label for="loginScreenRegPasswordConfirm">Confirm Password</label>
<input type="password" id="loginScreenRegPasswordConfirm" required minlength="8" autocomplete="new-password">
</div>
<button type="submit" class="auth-submit-btn" id="loginScreenRegSubmitBtn">Create Account</button>
</form>
<!-- Guest mode option -->
<div class="auth-guest-divider">
<span>or</span>
</div>
<button type="button" class="auth-guest-btn" id="loginScreenGuestBtn">Continue as Guest</button>
</div>
<div class="login-version">v1.0</div>
</div>
</div>
<!-- Game Container - hidden until logged in -->
<div id="gameContainer">
<div id="map"></div>
<div id="compassIndicator" class="compass-indicator">N</div>
<!-- RPG HUD (shown when player has class) -->
<div id="rpgHud" class="rpg-hud" style="display: none;" onclick="showCharacterSheet()" title="Tap for Character Sheet">
<div class="rpg-hud-bar">
<span class="rpg-hud-bar-label">HP</span>
<div class="rpg-hud-bar-track hp-track">
<div class="rpg-hud-bar-fill hp-fill" id="hudHpBar" style="width: 100%;"></div>
</div>
<span class="rpg-hud-bar-text" id="hudHp">100/100</span>
</div>
<div class="rpg-hud-bar">
<span class="rpg-hud-bar-label">MP</span>
<div class="rpg-hud-bar-track mp-track">
<div class="rpg-hud-bar-fill mp-fill" id="hudMpBar" style="width: 100%;"></div>
</div>
<span class="rpg-hud-bar-text" id="hudMp">50/50</span>
</div>
<div class="rpg-hud-bar">
<span class="rpg-hud-bar-label">XP</span>
<div class="rpg-hud-bar-track xp-track">
<div class="rpg-hud-bar-fill xp-fill" id="hudXpBar" style="width: 0%;"></div>
</div>
<span class="rpg-hud-bar-text" id="hudXpText">0/100</span>
</div>
</div>
<!-- Home Base Button -->
<button id="homeBaseBtn" class="home-base-btn" style="display: none;" onclick="toggleHomeBaseSelection()" title="Set Home Base">🏠</button>
<!-- Music Toggle Button -->
<button id="musicToggleBtn" class="music-toggle-btn" style="display: none;" onclick="toggleMusicMute()" title="Toggle Music">🎵</button>
<!-- Compass/GPS Button -->
<button id="compassBtn" class="compass-btn" title="Toggle GPS Location">🧭</button>
<!-- WASD Control Pad (hidden by default, shown for admins when GPS off) -->
<div id="wasdControls" class="wasd-controls hidden">
<div id="wasdModeIndicator" class="wasd-mode-indicator">TEST MODE</div>
<button class="wasd-btn w-btn" data-dir="w"></button>
<button class="wasd-btn a-btn" data-dir="a"></button>
<button class="wasd-btn s-btn" data-dir="s"></button>
<button class="wasd-btn d-btn" data-dir="d"></button>
</div>
<!-- Home Base Selection Hint -->
<div id="selectionHint" class="selection-hint" style="display: none;">Double-tap on the map to set your home base</div>
<!-- Death Overlay -->
<div id="deathOverlay" class="death-overlay" style="display: none;">
<h1>💀 YOU DIED 💀</h1>
<p>Return to your home base to respawn.</p>
<div class="xp-lost" id="xpLostText">-0 XP</div>
<div class="home-distance" id="homeDistanceText">Distance to home: ???</div>
</div>
<!-- Homebase Customization Modal -->
<div id="homebaseModal" class="homebase-modal" style="display: none;">
<div class="homebase-modal-content">
<h2>🏠 Homebase</h2>
<h3>⚔️ Skill Loadout</h3>
<div id="homebaseSkillLoadout" class="homebase-skill-loadout">
<!-- Skills loaded dynamically -->
</div>
<h3>🎨 Base Icon</h3>
<div id="homebaseIconsGrid" class="homebase-icons-grid">
<!-- Icons loaded dynamically -->
</div>
<div class="homebase-modal-actions">
<button id="relocateBtn" class="homebase-btn-relocate" onclick="startRelocateHomebase()">📍 Relocate</button>
<button class="homebase-btn-close" onclick="closeHomebaseModal()">Close</button>
</div>
<div id="relocateCooldown" class="homebase-relocate-cooldown"></div>
</div>
</div>
<!-- Combat Overlay -->
<div id="combatOverlay" class="combat-overlay" style="display: none;">
<div class="combat-container">
<div class="combat-header">
<h2>⚔️ COMBAT ⚔️</h2>
</div>
<div id="turnIndicator" class="turn-indicator player-turn">⚡ Your Turn</div>
<div class="combat-arena">
<div class="combatant player-side">
<div class="sprite-container">
<div class="combatant-icon" id="playerCombatIcon">🏃</div>
<div class="status-overlay" id="playerStatusOverlay"></div>
</div>
<div class="combatant-name" id="playerCombatName">Trail Runner</div>
<div class="stat-bars">
<div class="stat-bar-container">
<div class="stat-bar-label">HP</div>
<div class="hp-bar"><div class="hp-fill" id="playerHpBar" style="width: 100%;"></div></div>
</div>
<div class="stat-bar-container">
<div class="stat-bar-label">MP</div>
<div class="mp-bar"><div class="mp-fill" id="playerMpBar" style="width: 100%;"></div></div>
</div>
</div>
<div class="stat-text">
HP: <span id="playerHpText">100/100</span> | MP: <span id="playerMpText">50/50</span>
</div>
</div>
<div class="combat-vs">VS</div>
<div class="monster-side">
<div class="monster-list" id="monsterList">
<!-- Monster entries populated dynamically -->
</div>
</div>
</div>
<div class="combat-log" id="combatLog">
<div class="combat-log-entry">Combat begins!</div>
</div>
<div class="combat-skills" id="combatSkills">
<!-- Skills populated dynamically -->
</div>
<button class="combat-flee-btn" id="combatFleeBtn">🏃 Flee</button>
</div>
</div>
<button id="panelToggle" class="panel-toggle" style="right: 10px; display: none;"></button>
<div class="controls" id="controlPanel" style="display: none;">
<!-- User Profile Section -->
<div id="userProfileSection" style="display: none;">
<div class="user-profile">
<div class="user-avatar" id="userAvatar">
<i class="mdi mdi-account"></i>
</div>
<div class="user-info">
<div class="user-name" id="userName">User</div>
<div class="user-points"><span id="userPoints">0</span> points | <span id="userFinds">0</span> finds</div>
</div>
<button class="user-logout-btn" id="logoutBtn">Logout</button>
</div>
</div>
<!-- Login Prompt (shown when not logged in) -->
<div id="loginPromptSection" class="login-prompt">
<div class="login-prompt-text">Login to track your geocache finds and earn points!</div>
<button class="login-prompt-btn" id="openLoginBtn">Login / Register</button>
<button class="action-btn secondary" id="openLeaderboardBtn" style="margin-top: 8px; width: 100%;">View Leaderboard</button>
</div>
<div class="tab-bar">
<button class="tab-btn" id="editTab">Edit</button>
<button class="tab-btn active" id="navTab">Navigate</button>
<button class="tab-btn" id="adminTab">Admin</button>
</div>
<div class="tab-content" id="editContent">
<!-- Content moved to edit overlay -->
</div>
<div class="tab-content active" id="navContent">
<div class="section">
<div class="section-title">GPS</div>
<button class="action-btn" id="gpsBtn" style="width: 100%;">Show My Location</button>
<button class="action-btn secondary" id="rotateMapBtn" style="width: 100%; margin-top: 5px;">Rotate Map: OFF</button>
<button class="action-btn secondary active" id="autoCenterBtn" style="width: 100%; margin-top: 5px;">Auto-Center: ON</button>
</div>
<div class="section">
<div class="section-title">Navigation</div>
<p class="nav-instruction">Enable GPS, then click on a track to set your destination.</p>
<div id="navInfo" class="nav-info" style="display: none;">
<div class="nav-distance"><span id="navDistance">--</span></div>
<div style="color: #666; font-size: 12px; margin-bottom: 10px;">remaining along track</div>
<button class="action-btn secondary" id="clearNavBtn">Clear Destination</button>
</div>
</div>
</div>
<div class="tab-content" id="adminContent">
<!-- Content moved to admin overlay -->
</div>
<div id="status" class="status">Load a KML file or draw tracks</div>
</div>
<!-- Route calculation overlay -->
<div id="routeOverlay" class="route-overlay" style="display: none;">
<div class="route-overlay-content">
<div class="spinner"></div>
<div class="route-overlay-text">Finding route...</div>
</div>
</div>
<!-- Icon selector overlay -->
<div id="iconSelector" class="icon-selector-overlay" style="display: none;">
<div class="icon-selector-modal">
<div class="icon-selector-title">Choose Your Icon</div>
<div class="icon-selector-subtitle">Select how you want to appear to other users</div>
<div class="icon-grid" id="iconGrid">
<!-- Icons will be added dynamically -->
</div>
</div>
</div>
<!-- Admin Save Indicator -->
<div id="adminSaveIndicator" class="admin-save-indicator">Settings Saved ✓</div>
<!-- Geocache dialog -->
<div id="geocacheDialog" class="geocache-dialog" style="display: none;">
<div class="geocache-dialog-content">
<h3 id="geocacheTitle">📍 Geocache</h3>
<div class="geocache-messages" id="geocacheMessages">
<!-- Messages will be added dynamically -->
</div>
<div id="geocacheForm">
<div class="geocache-input-group" id="geocacheTitleGroup" style="display: none;">
<label for="geocacheTitleInput">Geocache Title</label>
<input type="text" id="geocacheTitleInput" placeholder="Name this geocache..." maxlength="100">
</div>
<div class="geocache-input-group" id="geocacheIconGroup" style="display: none;">
<label for="geocacheIconInput">Icon (MDI name, e.g. 'treasure-chest', 'map-marker')</label>
<input type="text" id="geocacheIconInput" placeholder="package-variant" value="package-variant" maxlength="50">
<small style="color: #888; display: block; margin-top: 4px;">Browse icons at <a href="https://pictogrammers.com/library/mdi/" target="_blank" style="color: #FFA726;">Material Design Icons</a></small>
</div>
<div class="geocache-input-group" id="geocacheColorGroup" style="display: none;">
<label for="geocacheColorInput">Icon Color</label>
<div style="display: flex; align-items: center; gap: 10px;">
<input type="color" id="geocacheColorInput" value="#FFA726" style="width: 60px; height: 35px; border: 1px solid #555; border-radius: 4px; cursor: pointer;">
<span id="geocacheColorPreview" style="font-size: 28px;"><i class="mdi mdi-package-variant" style="color: #FFA726;"></i></span>
<button type="button" id="geocacheColorReset" style="padding: 5px 10px; background: #555; color: white; border: none; border-radius: 4px; cursor: pointer;">Reset</button>
</div>
</div>
<div class="geocache-input-group" id="geocacheVisibilityGroup" style="display: none;">
<label for="geocacheVisibilityInput">Visibility Distance (meters, 0 = always visible)</label>
<input type="number" id="geocacheVisibilityInput" placeholder="0" value="0" min="0" max="10000" step="10">
<small style="color: #888; display: block; margin-top: 4px;">Secret caches are only visible when users are within this distance</small>
</div>
<div class="geocache-input-group">
<label for="geocacheName">Your Name</label>
<input type="text" id="geocacheName" placeholder="Enter your name" maxlength="50">
</div>
<div class="geocache-input-group">
<label for="geocacheMessage">Leave a Message</label>
<textarea id="geocacheMessage" placeholder="Write your message..." maxlength="500"></textarea>
</div>
</div>
<div class="geocache-dialog-buttons">
<button class="geocache-cancel-btn" id="geocacheCancel">Close</button>
<button class="geocache-submit-btn" id="geocacheSubmit">Add Message</button>
<button class="geocache-edit-btn" id="geocacheEdit" style="display: none; background: #4CAF50;">Edit Cache</button>
<button class="geocache-delete-btn" id="geocacheDelete" style="display: none;">Delete Geocache</button>
</div>
</div>
</div>
<!-- Geocache alert notification -->
<div id="geocacheAlert" class="geocache-alert">
📍 Geocache nearby! Click to view messages.
</div>
<!-- Auth Modal -->
<div id="authModal" class="auth-modal-overlay" style="display: none;">
<div class="auth-modal" style="position: relative;">
<button class="auth-close-btn" id="authCloseBtn">&times;</button>
<h2>HikeMap</h2>
<div class="auth-tabs">
<button class="auth-tab active" id="loginTabBtn">Login</button>
<button class="auth-tab" id="registerTabBtn">Register</button>
</div>
<div id="authError" class="auth-error"></div>
<!-- Login Form -->
<form id="loginForm" class="auth-form active">
<div class="auth-input-group">
<label for="loginUsername">Username or Email</label>
<input type="text" id="loginUsername" required autocomplete="username">
</div>
<div class="auth-input-group">
<label for="loginPassword">Password</label>
<input type="password" id="loginPassword" required autocomplete="current-password">
</div>
<button type="submit" class="auth-submit-btn" id="loginSubmitBtn">Login</button>
</form>
<!-- Register Form -->
<form id="registerForm" class="auth-form">
<div class="auth-input-group">
<label for="registerUsername">Username</label>
<input type="text" id="registerUsername" required minlength="3" maxlength="20" pattern="[a-zA-Z0-9_]+" autocomplete="username">
</div>
<div class="auth-input-group">
<label for="registerEmail">Email</label>
<input type="email" id="registerEmail" required autocomplete="email">
</div>
<div class="auth-input-group">
<label for="registerPassword">Password (min 8 characters)</label>
<input type="password" id="registerPassword" required minlength="8" autocomplete="new-password">
</div>
<div class="auth-input-group">
<label for="registerPasswordConfirm">Confirm Password</label>
<input type="password" id="registerPasswordConfirm" required minlength="8" autocomplete="new-password">
</div>
<button type="submit" class="auth-submit-btn" id="registerSubmitBtn">Create Account</button>
</form>
<!-- Guest mode option -->
<div class="auth-guest-divider">
<span>or</span>
</div>
<button type="button" class="auth-guest-btn" id="guestModeBtn">Continue as Guest</button>
</div>
</div>
<!-- Leaderboard Modal -->
<div id="leaderboardModal" class="auth-modal-overlay" style="display: none;">
<div class="leaderboard-modal">
<div class="leaderboard-header">
<h2 style="margin: 0;">Leaderboard</h2>
<button id="leaderboardCloseBtn" style="background: none; border: none; font-size: 24px; cursor: pointer;">&times;</button>
</div>
<div class="leaderboard-tabs">
<button class="leaderboard-tab active" data-period="all">All Time</button>
<button class="leaderboard-tab" data-period="monthly">Monthly</button>
<button class="leaderboard-tab" data-period="weekly">Weekly</button>
</div>
<ul class="leaderboard-list" id="leaderboardList">
<!-- Populated dynamically -->
</ul>
</div>
</div>
<!-- Character Creator Modal -->
<div id="charCreatorModal" class="auth-modal-overlay" style="display: none;">
<div class="char-creator-modal">
<div class="char-creator-header">
<h2>Create Your Character</h2>
<p>Begin your hiking adventure</p>
</div>
<div class="char-step-indicator">
<div class="char-step-dot active" data-step="1"></div>
<div class="char-step-dot" data-step="2"></div>
<div class="char-step-dot" data-step="3"></div>
<div class="char-step-dot" data-step="4"></div>
</div>
<div class="char-creator-content">
<!-- Step 1: Name -->
<div class="char-creator-step active" data-step="1">
<div class="char-creator-section">
<h3>What's your character's name?</h3>
<input type="text" class="char-creator-input" id="charNameInput" placeholder="Enter a name..." maxlength="20">
</div>
<div class="char-creator-buttons">
<button class="char-creator-btn char-creator-btn-next" id="charStep1Next" disabled>Next</button>
</div>
</div>
<!-- Step 2: Race -->
<div class="char-creator-step" data-step="2">
<div class="char-creator-section">
<h3>Choose your race</h3>
<div class="char-creator-grid" id="raceSelection">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="char-creator-buttons">
<button class="char-creator-btn char-creator-btn-back" id="charStep2Back">Back</button>
<button class="char-creator-btn char-creator-btn-next" id="charStep2Next" disabled>Next</button>
</div>
</div>
<!-- Step 3: Class -->
<div class="char-creator-step" data-step="3">
<div class="char-creator-section">
<h3>Choose your class</h3>
<div class="char-creator-grid" id="classSelection">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="char-creator-buttons">
<button class="char-creator-btn char-creator-btn-back" id="charStep3Back">Back</button>
<button class="char-creator-btn char-creator-btn-next" id="charStep3Next" disabled>Next</button>
</div>
</div>
<!-- Step 4: Confirm -->
<div class="char-creator-step" data-step="4">
<div class="char-creator-section">
<h3>Confirm your character</h3>
<div class="char-creator-preview">
<div class="char-creator-preview-icon" id="charPreviewIcon">🧑</div>
<div class="char-creator-preview-name" id="charPreviewName">Character Name</div>
<div class="char-creator-preview-info" id="charPreviewInfo">Human Trail Runner</div>
<div class="char-creator-preview-stats" id="charPreviewStats">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
<div class="char-creator-buttons">
<button class="char-creator-btn char-creator-btn-back" id="charStep4Back">Back</button>
<button class="char-creator-btn char-creator-btn-next" id="charCreateBtn">Create Character</button>
</div>
</div>
</div>
</div>
</div>
<!-- Character Sheet Modal -->
<div id="charSheetModal" class="auth-modal-overlay" style="display: none;">
<div class="char-sheet-modal">
<button class="char-sheet-close" id="charSheetClose">&times;</button>
<div class="char-sheet-header">
<div class="char-sheet-icon" id="charSheetIcon">🧑</div>
<div class="char-sheet-name" id="charSheetName">Character Name</div>
<div class="char-sheet-info" id="charSheetInfo">Human Trail Runner - Level 1</div>
</div>
<div class="char-sheet-content">
<div class="char-sheet-section">
<h3>Stats</h3>
<div class="char-sheet-stat-grid" id="charSheetStats">
<!-- Populated by JS -->
</div>
</div>
<div class="char-sheet-section">
<h3>Experience</h3>
<div class="char-sheet-xp" id="charSheetXp">
<!-- XP bar populated by JS -->
</div>
</div>
<div class="char-sheet-section">
<h3>⚔️ Active Skills</h3>
<div class="char-sheet-skills" id="charSheetActiveSkills">
<!-- Populated by JS -->
</div>
<div class="char-sheet-skill-hint">Tap your home base to change loadout</div>
</div>
<div class="char-sheet-section">
<h3>🌟 Daily Skills</h3>
<div class="char-sheet-daily-skills" id="charSheetDailySkills">
<!-- Populated by JS -->
</div>
</div>
<div class="char-sheet-section">
<h3>👹 Monsters</h3>
<div class="char-sheet-monsters" id="charSheetMonsters">
<!-- Populated by JS -->
</div>
</div>
</div>
</div>
</div>
<!-- Skill Choice Modal (Level Up) -->
<div id="skillChoiceModal" class="auth-modal-overlay" style="display: none;">
<div class="skill-choice-modal">
<div class="skill-choice-header">
<h2>🎉 Level Up!</h2>
<p>Choose a new skill:</p>
</div>
<div class="skill-choice-options" id="skillChoiceOptions">
<!-- Populated by JS -->
</div>
</div>
</div>
<!-- Geocache List Sidebar -->
<div id="geocacheListSidebar" class="geocache-list-sidebar">
<div class="geocache-list-header">
<h3 style="color: #FFA726; margin: 0;">📍 Geocaches</h3>
<button id="geocacheListClose" style="background: none; border: none; color: #aaa; font-size: 24px; cursor: pointer;">×</button>
</div>
<div id="geocacheListContent">
<!-- Will be populated dynamically -->
</div>
</div>
<!-- Geocache List Toggle Button -->
<div id="geocacheListToggle" class="geocache-list-toggle">
<i class="mdi mdi-map-marker-multiple" style="font-size: 24px; color: white;"></i>
</div>
<!-- Navigation confirmation dialog -->
<div id="navConfirmDialog" class="nav-confirm-dialog" style="display: none;">
<div class="nav-confirm-content">
<h3>Set Navigation Destination</h3>
<p id="navConfirmMessage">Navigate to this location?</p>
<div class="nav-confirm-buttons">
<button class="nav-confirm-no" id="navConfirmNo">Cancel</button>
<button class="nav-confirm-yes" id="navConfirmYes">Navigate</button>
</div>
</div>
</div>
<!-- Resume navigation dialog -->
<div id="resumeNavDialog" class="nav-confirm-dialog" style="display: none;">
<div class="nav-confirm-content">
<h3>Resume Navigation?</h3>
<p>You have a previous navigation destination. Would you like to resume?</p>
<div class="nav-confirm-buttons">
<button class="nav-confirm-no" id="resumeNavNo">Start Fresh</button>
<button class="nav-confirm-yes" id="resumeNavYes">Resume</button>
</div>
</div>
</div>
<!-- Press and hold indicator -->
<div id="pressHoldIndicator" class="press-hold-indicator">Hold to set destination...</div>
</div><!-- End gameContainer -->
<!-- Audio element for geocache alert sound - using Web Audio API instead -->
<script>
// Create a simple notification sound using Web Audio API
function playGeocacheSound() {
if (!adminSettings.geocacheSoundEnabled) return;
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Create oscillator for beep sound
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Configure the sound (two quick beeps)
oscillator.frequency.value = 800; // Frequency in Hz
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
// Second beep
setTimeout(() => {
const osc2 = audioContext.createOscillator();
const gain2 = audioContext.createGain();
osc2.connect(gain2);
gain2.connect(audioContext.destination);
osc2.frequency.value = 1000;
gain2.gain.setValueAtTime(0.3, audioContext.currentTime);
gain2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
osc2.start(audioContext.currentTime);
osc2.stop(audioContext.currentTime + 0.1);
}, 150);
}
</script>
<!-- Remesh confirmation dialog -->
<div id="remeshDialog" class="nav-confirm-dialog" style="display: none;">
<div class="nav-confirm-content">
<h3>Remesh Track?</h3>
<p id="remeshMessage">This will redistribute points to be evenly spaced.</p>
<div class="slider-group" style="margin: 15px 0;">
<label>Point Spacing (meters)</label>
<input type="range" id="remeshSpacing" min="5" max="20" value="5" step="1">
<div class="slider-value"><span id="remeshSpacingValue">5</span> meters</div>
</div>
<p id="remeshDetails" style="font-size: 12px; color: #666; margin-top: 10px;"></p>
<div class="nav-confirm-buttons">
<button class="nav-confirm-no" id="remeshNo">Cancel</button>
<button class="nav-confirm-yes" id="remeshYes">Remesh</button>
</div>
</div>
</div>
<!-- Edit Panel Overlay -->
<div class="edit-panel-overlay" id="editOverlay">
<div class="edit-panel-header">
<h2>✏️ Edit Tools</h2>
<button class="edit-panel-close" id="editCloseBtn">×</button>
</div>
<div class="edit-panel-content" id="editOverlayContent">
<div class="section">
<div class="section-title">File</div>
<input type="file" id="kmlFile" accept=".kml">
<button class="action-btn" id="reloadBtn" style="background: #17a2b8;">Reload Tracks</button>
<button class="action-btn" id="exportBtn">Export to File</button>
<button class="action-btn" id="saveServerBtn" style="background: #28a745;">Save to Server</button>
</div>
<div class="section">
<div class="section-title">Track Tools</div>
<div class="tool-buttons">
<button class="tool-btn" id="selectTool" title="Select track, then press Delete key to remove">Select</button>
<button class="tool-btn" id="splitTool" title="Click on a track to split it">Split</button>
<button class="tool-btn" id="drawTool" title="Draw a new track">Draw</button>
<button class="tool-btn" id="reshapeTool" title="Drag points to reshape track">Reshape</button>
<button class="tool-btn" id="smoothTool" title="Brush over track to smooth it">Smooth</button>
<button class="action-btn" id="remeshBtn" title="Remesh selected tracks with even spacing">Remesh Selected</button>
<button class="tool-btn" id="geocacheTool" title="Place a geocache">📍 Cache</button>
</div>
<div class="slider-group" id="reshapeControls" style="display: none;">
<label>Anchor Distance (points beyond stay fixed)</label>
<input type="range" id="anchorDistance" min="3" max="50" value="10">
<div class="slider-value"><span id="anchorValue">10</span> points</div>
<label style="margin-top: 8px;">Falloff (how sharply effect fades)</label>
<input type="range" id="reshapeFalloff" min="0.5" max="3" value="1" step="0.1">
<div class="slider-value"><span id="falloffValue">1.0</span></div>
</div>
<div class="slider-group" id="smoothControls" style="display: none;">
<label>Brush Size (pixels)</label>
<input type="range" id="smoothBrushSize" min="10" max="100" value="30">
<div class="slider-value"><span id="brushSizeValue">30</span> px</div>
<label style="margin-top: 8px;">Strength</label>
<input type="range" id="smoothStrength" min="0.1" max="1" value="0.5" step="0.1">
<div class="slider-value"><span id="strengthValue">0.5</span></div>
</div>
</div>
<div class="section">
<div class="section-title">Merge / Simplify</div>
<div class="slider-group">
<label>Merge Threshold</label>
<input type="range" id="mergeThreshold" min="1" max="10" value="5" step="0.5">
<div class="slider-value"><span id="thresholdValue">5</span> meters</div>
</div>
<button class="action-btn" id="previewBtn" style="background: #17a2b8;">Preview Merge</button>
<button class="action-btn" id="applyMergeBtn" style="display: none;">Apply Merge</button>
<button class="action-btn secondary" id="cancelPreviewBtn" style="display: none;">Cancel Preview</button>
<div style="margin-top: 8px;">
<button class="action-btn secondary" id="mergeConnectBtn">Connect End-to-End</button>
</div>
<div style="border-top: 1px solid #eee; margin-top: 10px; padding-top: 10px;">
<button class="action-btn" id="selectAllBtn" style="background: #6c757d;">Select All</button>
<button class="action-btn secondary" id="clearSelectionBtn">Clear Selection</button>
</div>
</div>
<div class="section">
<div class="section-title">Tracks (<span id="trackCount">0</span>)</div>
<div class="track-list" id="trackList"></div>
<button class="action-btn" id="undoBtn" style="background: #6c757d;">Undo</button>
</div>
</div>
</div>
<!-- Admin Panel Overlay -->
<div class="admin-panel-overlay" id="adminOverlay">
<div class="admin-panel-header">
<h2>⚙️ Admin Settings</h2>
<button class="admin-panel-close" id="adminCloseBtn" onclick="document.querySelector('.admin-panel-overlay').classList.remove('active'); document.getElementById('adminTab').classList.remove('active'); switchTab('edit');">×</button>
</div>
<div class="admin-panel-content" id="adminContent">
<div class="section collapsible" data-section="geocache">
<div class="section-title">Geocache Settings</div>
<div class="section-content">
<div class="admin-setting-group">
<div class="admin-input-row">
<label for="geocacheRange">Interaction Range:</label>
<div class="admin-input-container">
<input type="number" id="geocacheRange" min="1" max="50" value="5">
<span class="unit">meters</span>
</div>
</div>
<div class="admin-input-row">
<label for="geocacheAlertRange">Alert Distance:</label>
<div class="admin-input-container">
<input type="number" id="geocacheAlertRange" min="1" max="50" value="5">
<span class="unit">meters</span>
</div>
</div>
<div class="admin-checkbox-row">
<input type="checkbox" id="geocacheSoundEnabled" checked>
<label for="geocacheSoundEnabled">Enable alert sound</label>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Navigation Settings</div>
<div class="admin-setting-group">
<div class="admin-input-row">
<label>Track Proximity:</label>
<input type="number" id="trackProximity" min="10" max="200" value="50">
<span class="unit">meters</span>
</div>
<div class="admin-input-row">
<label>Intersection Threshold:</label>
<input type="number" id="intersectionThreshold" min="5" max="30" value="15">
<span class="unit">meters</span>
</div>
<div class="admin-input-row">
<label>On-Trail Threshold:</label>
<input type="number" id="onTrailThreshold" min="20" max="200" value="100">
<span class="unit">meters</span>
</div>
<div class="admin-input-row">
<label>Node Spacing:</label>
<input type="number" id="nodeSpacing" min="10" max="100" value="50">
<span class="unit">meters</span>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Performance Settings</div>
<div class="admin-setting-group">
<div class="admin-input-row">
<label>GPS Poll Interval:</label>
<input type="number" id="gpsPollInterval" min="1000" max="10000" value="3000" step="500">
<span class="unit">ms</span>
</div>
<div class="admin-input-row">
<label>Proximity Check Interval:</label>
<input type="number" id="proximityCheckInterval" min="1000" max="10000" value="3000" step="500">
<span class="unit">ms</span>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Push Notifications</div>
<div class="admin-setting-group">
<div id="notificationStatus" style="margin-bottom: 10px; color: #666;">
Status: <span id="notificationStatusText">Not configured</span>
</div>
<button class="action-btn" id="enableNotifications" onclick="setupPushNotifications()">
Enable Push Notifications
</button>
<button class="action-btn secondary" id="disableNotifications" onclick="disablePushNotifications()" style="display:none;">
Disable Notifications
</button>
<button class="action-btn" id="testNotification" onclick="sendTestNotification()" style="display:none; margin-top: 10px; background: #9c27b0;">
📨 Send Test Notification to All Users
</button>
</div>
</div>
<div class="section">
<div class="section-title">🛠️ Developer Tools</div>
<div class="admin-setting-group">
<div class="admin-checkbox-row">
<input type="checkbox" id="gpsTestModeToggle">
<label for="gpsTestModeToggle">GPS Test Mode (WASD to move)</label>
</div>
<div id="gpsTestModeInfo" style="display: none; margin-top: 10px; padding: 10px; background: #e3f2fd; border-radius: 4px; font-size: 12px;">
<strong>Controls:</strong><br>
W - Move North<br>
S - Move South<br>
A - Move West<br>
D - Move East<br>
<div style="margin-top: 8px;">
<strong>Position:</strong> <span id="testPositionDisplay">--</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="admin-button-group">
<button class="action-btn" onclick="resetAdminSettings()">Reset to Defaults</button>
<button class="action-btn" onclick="exportAdminSettings()">Export Settings</button>
<button class="action-btn" onclick="importAdminSettings()">Import Settings</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/maplibre-gl@4.1.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
<script>
// =====================
// FANTASY MAP STYLE
// =====================
// Default fantasy colors (can be customized via settings)
const mapColors = {
land: '#1a1a2e',
water: '#0f3460',
roads: '#e94560',
buildings: '#16213e',
parks: '#1b4332'
};
// Build the fantasy map style
function buildFantasyStyle(colors, use3dBuildings = true) {
return {
version: 8,
name: 'HikeMap Fantasy',
sources: {
'openmaptiles': {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet'
}
},
glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
layers: [
// Background
{ id: 'background', type: 'background', paint: { 'background-color': colors.land } },
// Parks
{ id: 'park', type: 'fill', source: 'openmaptiles', 'source-layer': 'park',
paint: { 'fill-color': colors.parks, 'fill-opacity': 0.7 } },
// Landcover - wood
{ id: 'landcover_wood', type: 'fill', source: 'openmaptiles', 'source-layer': 'landcover',
filter: ['==', ['get', 'class'], 'wood'],
paint: { 'fill-color': colors.parks, 'fill-opacity': 0.5 } },
// Landcover - grass
{ id: 'landcover_grass', type: 'fill', source: 'openmaptiles', 'source-layer': 'landcover',
filter: ['==', ['get', 'class'], 'grass'],
paint: { 'fill-color': colors.parks, 'fill-opacity': 0.4 } },
// Water
{ id: 'water', type: 'fill', source: 'openmaptiles', 'source-layer': 'water',
paint: { 'fill-color': colors.water } },
// Waterways
{ id: 'waterway', type: 'line', source: 'openmaptiles', 'source-layer': 'waterway',
paint: { 'line-color': colors.water, 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1, 14, 3] } },
// Buildings (3D or 2D)
use3dBuildings ? {
id: 'buildings-3d', type: 'fill-extrusion', source: 'openmaptiles', 'source-layer': 'building', minzoom: 13,
paint: {
'fill-extrusion-color': colors.buildings,
'fill-extrusion-height': ['*', 2, ['get', 'render_height']],
'fill-extrusion-base': ['*', 2, ['get', 'render_min_height']],
'fill-extrusion-opacity': 0.85
}
} : {
id: 'buildings', type: 'fill', source: 'openmaptiles', 'source-layer': 'building', minzoom: 13,
paint: { 'fill-color': colors.buildings, 'fill-opacity': 0.8 }
},
// Roads - service/track
{ id: 'roads-service', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['match', ['get', 'class'], ['service', 'track'], true, false],
paint: { 'line-color': colors.roads, 'line-width': 1, 'line-opacity': 0.4 } },
// Roads - path/pedestrian
{ id: 'roads-path', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['match', ['get', 'class'], ['path', 'pedestrian'], true, false],
paint: { 'line-color': colors.roads, 'line-width': 1, 'line-dasharray': [2, 1], 'line-opacity': 0.6 } },
// Roads - minor
{ id: 'roads-minor', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['==', ['get', 'class'], 'minor'],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': colors.roads, 'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 13, 1, 20, 10], 'line-opacity': 0.8 } },
// Roads - secondary/tertiary
{ id: 'roads-secondary', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['match', ['get', 'class'], ['secondary', 'tertiary'], true, false],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': colors.roads, 'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 8, 0.5, 20, 13] } },
// Roads - primary/trunk
{ id: 'roads-primary', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['match', ['get', 'class'], ['primary', 'trunk'], true, false],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': colors.roads, 'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 5, 0.5, 20, 18] } },
// Roads - motorway
{ id: 'roads-motorway', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['==', ['get', 'class'], 'motorway'],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': colors.roads, 'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 5, 1, 20, 20] } },
// Rail
{ id: 'rail', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['==', ['get', 'class'], 'rail'],
paint: { 'line-color': '#888888', 'line-width': 2, 'line-dasharray': [3, 3] } },
// Boundaries
{ id: 'boundary', type: 'line', source: 'openmaptiles', 'source-layer': 'boundary',
filter: ['==', ['get', 'admin_level'], 2],
paint: { 'line-color': '#888888', 'line-width': 1, 'line-dasharray': [4, 2] } },
// Road labels
{ id: 'road-labels', type: 'symbol', source: 'openmaptiles', 'source-layer': 'transportation_name', minzoom: 14,
layout: { 'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], 'text-size': 10, 'symbol-placement': 'line', 'text-font': ['Noto Sans Regular'] },
paint: { 'text-color': '#ffffff', 'text-halo-color': colors.land, 'text-halo-width': 1 } },
// Place labels
{ id: 'place-labels', type: 'symbol', source: 'openmaptiles', 'source-layer': 'place',
layout: { 'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 12, 14, 16], 'text-font': ['Noto Sans Bold'] },
paint: { 'text-color': '#ffffff', 'text-halo-color': colors.land, 'text-halo-width': 2 } }
]
};
}
// =====================
// MAP INITIALIZATION (MapLibre GL JS)
// =====================
const map = new maplibregl.Map({
container: 'map',
style: buildFantasyStyle(mapColors, true),
center: [-97.84, 30.49], // Note: [lng, lat] for MapLibre
zoom: 13,
maxZoom: 22,
bearing: 0,
pitch: 45, // 3D building perspective
maxPitch: 60,
doubleClickZoom: false
});
// Add navigation controls
map.addControl(new maplibregl.NavigationControl(), 'top-right');
// =====================
// COORDINATE HELPER FUNCTIONS
// =====================
// Convert [lat, lng] to [lng, lat] for MapLibre
function toMapLibre(coord) {
if (Array.isArray(coord)) return [coord[1], coord[0]];
return [coord.lng, coord.lat];
}
// Convert array of [lat, lng] coords to [lng, lat]
function toMapLibreCoords(coords) {
return coords.map(c => Array.isArray(c) ? [c[1], c[0]] : [c.lng, c.lat]);
}
// Distance in meters between two [lng, lat] points
function distanceMeters(lngLat1, lngLat2) {
return turf.distance(turf.point(lngLat1), turf.point(lngLat2), { units: 'meters' });
}
// Create a circle polygon for a given center and radius
function createCircleGeoJSON(lngLat, radiusMeters) {
return turf.circle(lngLat, radiusMeters / 1000, { units: 'kilometers', steps: 64 });
}
// =====================
// LEAFLET API COMPATIBILITY SHIMS
// =====================
// These allow gradual migration by providing Leaflet-like methods
// map.setView([lat, lng], zoom) - Leaflet-style view setting
map.setView = function(latLng, zoom) {
const center = Array.isArray(latLng) ? [latLng[1], latLng[0]] : [latLng.lng, latLng.lat];
if (zoom !== undefined) {
this.jumpTo({ center: center, zoom: zoom });
} else {
this.setCenter(center);
}
return this;
};
// map.distance(latlng1, latlng2) - Calculate distance in meters
map.distance = function(latlng1, latlng2) {
const lngLat1 = Array.isArray(latlng1) ? [latlng1[1], latlng1[0]] : [latlng1.lng, latlng1.lat];
const lngLat2 = Array.isArray(latlng2) ? [latlng2[1], latlng2[0]] : [latlng2.lng, latlng2.lat];
return distanceMeters(lngLat1, lngLat2);
};
// map.latLngToContainerPoint - For canvas-based drawing (fog of war, etc.)
map.latLngToContainerPoint = function(latlng) {
const lngLat = Array.isArray(latlng) ? [latlng[1], latlng[0]] : [latlng.lng, latlng.lat];
return this.project(lngLat);
};
// =====================
// MAPLIBRE MARKER COMPATIBILITY SHIMS
// =====================
// Add Leaflet-style methods to MapLibre Marker prototype
// Marker.getLatLng() - returns {lat, lng} object like Leaflet
if (!maplibregl.Marker.prototype.getLatLng) {
maplibregl.Marker.prototype.getLatLng = function() {
const lngLat = this.getLngLat();
return { lat: lngLat.lat, lng: lngLat.lng };
};
}
// Marker.setLatLng([lat, lng]) - accepts Leaflet-style coordinates
if (!maplibregl.Marker.prototype.setLatLng) {
maplibregl.Marker.prototype.setLatLng = function(latlng) {
const lng = Array.isArray(latlng) ? latlng[1] : latlng.lng;
const lat = Array.isArray(latlng) ? latlng[0] : latlng.lat;
return this.setLngLat([lng, lat]);
};
}
// =====================
// FULL LEAFLET COMPATIBILITY LAYER
// =====================
// This provides Leaflet-like API for gradual migration
// Track created layers for cleanup
let leafletCompatLayerCounter = 0;
const L = {
// L.latLng - creates an object with distanceTo method
latLng: function(lat, lng) {
let finalLat, finalLng;
if (Array.isArray(lat)) {
// L.latLng([lat, lng]) - array form
finalLat = lat[0];
finalLng = lat[1];
} else if (typeof lat === 'object' && lat !== null) {
// L.latLng({lat, lng}) - object form
finalLat = lat.lat;
finalLng = lat.lng;
} else {
// L.latLng(lat, lng) - separate args
finalLat = lat;
finalLng = lng;
}
return {
lat: finalLat,
lng: finalLng,
distanceTo: function(other) {
return distanceMeters([this.lng, this.lat], [other.lng, other.lat]);
}
};
},
// L.divIcon - creates a marker configuration object
divIcon: function(options) {
return {
type: 'divIcon',
html: options.html || '',
iconSize: options.iconSize || [20, 20],
iconAnchor: options.iconAnchor || [10, 10],
className: options.className || ''
};
},
// L.marker - creates a MapLibre marker
marker: function(latlng, options) {
options = options || {};
const lat = Array.isArray(latlng) ? latlng[0] : latlng.lat;
const lng = Array.isArray(latlng) ? latlng[1] : latlng.lng;
// Create DOM element from divIcon
const el = document.createElement('div');
if (options.icon) {
el.className = options.icon.className || 'custom-div-icon';
el.innerHTML = options.icon.html || '';
if (options.icon.iconSize) {
el.style.width = options.icon.iconSize[0] + 'px';
el.style.height = options.icon.iconSize[1] + 'px';
}
}
const marker = new maplibregl.Marker({
element: el,
anchor: 'center',
draggable: options.draggable || false
}).setLngLat([lng, lat]);
// Add Leaflet-like methods
marker.setLatLng = function(ll) {
const lat = Array.isArray(ll) ? ll[0] : ll.lat;
const lng = Array.isArray(ll) ? ll[1] : ll.lng;
this.setLngLat([lng, lat]);
return this;
};
marker.getLatLng = function() {
const ll = this.getLngLat();
return { lat: ll.lat, lng: ll.lng };
};
marker.setIcon = function(icon) {
const el = this.getElement();
if (icon && icon.html) {
el.innerHTML = icon.html;
}
if (icon && icon.className) {
el.className = icon.className;
}
return this;
};
marker.on = function(event, handler) {
const el = this.getElement();
if (event === 'click') {
// For click events, also handle touch for better mobile support
let touchStartTime = 0;
let touchStartX = 0;
let touchStartY = 0;
el.addEventListener('touchstart', (e) => {
touchStartTime = Date.now();
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}, { passive: true });
el.addEventListener('touchend', (e) => {
const touchDuration = Date.now() - touchStartTime;
const touch = e.changedTouches[0];
const dx = Math.abs(touch.clientX - touchStartX);
const dy = Math.abs(touch.clientY - touchStartY);
// Only trigger if it was a quick tap (not a drag)
if (touchDuration < 300 && dx < 20 && dy < 20) {
e.preventDefault();
e.stopPropagation();
handler(e);
}
}, { passive: false });
// Also keep regular click for desktop
el.addEventListener('click', (e) => {
e.stopPropagation();
handler(e);
});
} else {
el.addEventListener(event, handler);
}
return this;
};
return marker;
},
// L.polyline - creates a GeoJSON line layer
polyline: function(coords, options) {
options = options || {};
const id = 'polyline-' + (++leafletCompatLayerCounter);
// Convert coords to GeoJSON (Leaflet uses [lat, lng], MapLibre uses [lng, lat])
const geoCoords = coords.map(c => {
if (Array.isArray(c)) return [c[1], c[0]];
return [c.lng, c.lat];
});
const layer = {
_id: id,
_coords: coords,
_options: options,
_added: false,
addTo: function(map) {
if (this._added) return this;
map.addSource(this._id, {
type: 'geojson',
data: {
type: 'Feature',
geometry: { type: 'LineString', coordinates: geoCoords }
}
});
map.addLayer({
id: this._id,
type: 'line',
source: this._id,
paint: {
'line-color': options.color || '#3388ff',
'line-width': options.weight || 3,
'line-opacity': options.opacity !== undefined ? options.opacity : 1
}
});
this._added = true;
return this;
},
setLatLngs: function(newCoords) {
this._coords = newCoords;
const geoCoords = newCoords.map(c => {
if (Array.isArray(c)) return [c[1], c[0]];
return [c.lng, c.lat];
});
const source = map.getSource(this._id);
if (source) {
source.setData({
type: 'Feature',
geometry: { type: 'LineString', coordinates: geoCoords }
});
}
return this;
},
setStyle: function(style) {
if (style.color) map.setPaintProperty(this._id, 'line-color', style.color);
if (style.weight) map.setPaintProperty(this._id, 'line-width', style.weight);
if (style.opacity !== undefined) map.setPaintProperty(this._id, 'line-opacity', style.opacity);
return this;
},
remove: function() {
if (map.getLayer(this._id)) map.removeLayer(this._id);
if (map.getSource(this._id)) map.removeSource(this._id);
this._added = false;
return this;
},
on: function(event, handler) {
map.on(event, this._id, handler);
return this;
}
};
return layer;
},
// L.circle - creates a GeoJSON circle (polygon)
circle: function(latlng, options) {
options = options || {};
const id = 'circle-' + (++leafletCompatLayerCounter);
const lat = Array.isArray(latlng) ? latlng[0] : latlng.lat;
const lng = Array.isArray(latlng) ? latlng[1] : latlng.lng;
const radius = options.radius || 10;
const layer = {
_id: id,
_latlng: { lat, lng },
_radius: radius,
_options: options,
_added: false,
addTo: function(map) {
if (this._added) return this;
const circleGeoJSON = createCircleGeoJSON([this._latlng.lng, this._latlng.lat], this._radius);
map.addSource(this._id, {
type: 'geojson',
data: circleGeoJSON
});
map.addLayer({
id: this._id + '-fill',
type: 'fill',
source: this._id,
paint: {
'fill-color': options.fillColor || options.color || '#3388ff',
'fill-opacity': options.fillOpacity !== undefined ? options.fillOpacity : 0.2
}
});
if (options.weight !== 0) {
map.addLayer({
id: this._id + '-stroke',
type: 'line',
source: this._id,
paint: {
'line-color': options.color || '#3388ff',
'line-width': options.weight || 3
}
});
}
this._added = true;
return this;
},
setLatLng: function(ll) {
const lat = Array.isArray(ll) ? ll[0] : ll.lat;
const lng = Array.isArray(ll) ? ll[1] : ll.lng;
this._latlng = { lat, lng };
this._updateData();
return this;
},
setRadius: function(r) {
this._radius = r;
this._updateData();
return this;
},
_updateData: function() {
const source = map.getSource(this._id);
if (source) {
source.setData(createCircleGeoJSON([this._latlng.lng, this._latlng.lat], this._radius));
}
},
remove: function() {
if (map.getLayer(this._id + '-fill')) map.removeLayer(this._id + '-fill');
if (map.getLayer(this._id + '-stroke')) map.removeLayer(this._id + '-stroke');
if (map.getSource(this._id)) map.removeSource(this._id);
this._added = false;
return this;
}
};
return layer;
},
// L.circleMarker - creates a simple circle marker (non-geographic)
circleMarker: function(latlng, options) {
options = options || {};
const lat = Array.isArray(latlng) ? latlng[0] : latlng.lat;
const lng = Array.isArray(latlng) ? latlng[1] : latlng.lng;
const el = document.createElement('div');
const size = (options.radius || 5) * 2;
el.style.width = size + 'px';
el.style.height = size + 'px';
el.style.backgroundColor = options.fillColor || options.color || '#3388ff';
el.style.border = (options.weight || 1) + 'px solid ' + (options.color || '#3388ff');
el.style.borderRadius = '50%';
el.style.opacity = options.fillOpacity !== undefined ? options.fillOpacity : 0.5;
el.className = options.className || 'circle-marker';
const marker = new maplibregl.Marker({
element: el,
anchor: 'center',
draggable: options.draggable || false
}).setLngLat([lng, lat]);
// Add Leaflet-like methods
marker.setLatLng = function(ll) {
const lat = Array.isArray(ll) ? ll[0] : ll.lat;
const lng = Array.isArray(ll) ? ll[1] : ll.lng;
this.setLngLat([lng, lat]);
return this;
};
marker.getLatLng = function() {
const ll = this.getLngLat();
return { lat: ll.lat, lng: ll.lng };
};
marker.setStyle = function(style) {
const el = this.getElement();
if (style.color) el.style.borderColor = style.color;
if (style.fillColor) el.style.backgroundColor = style.fillColor;
if (style.fillOpacity !== undefined) el.style.opacity = style.fillOpacity;
return this;
};
marker.on = function(event, handler) {
this.getElement().addEventListener(event, handler);
return this;
};
return marker;
},
// L.DomEvent compatibility
DomEvent: {
stopPropagation: function(e) {
if (e) e.stopPropagation();
return this;
},
preventDefault: function(e) {
if (e) e.preventDefault();
return this;
},
disableClickPropagation: function(el) {
el.addEventListener('click', function(e) { e.stopPropagation(); });
el.addEventListener('mousedown', function(e) { e.stopPropagation(); });
return this;
},
disableScrollPropagation: function(el) {
el.addEventListener('wheel', function(e) { e.stopPropagation(); });
return this;
}
},
// L.DomUtil compatibility
DomUtil: {
create: function(tagName, className, container) {
const el = document.createElement(tagName);
if (className) el.className = className;
if (container) container.appendChild(el);
return el;
}
}
};
// map.removeLayer compatibility
map.removeLayer = function(layer) {
if (layer && typeof layer.remove === 'function') {
layer.remove();
}
return this;
};
// MapLibre Style Control - Customize fantasy map colors and 3D buildings
class MapStyleControl {
constructor() {
this._use3dBuildings = true;
}
onAdd(map) {
this._map = map;
this._container = document.createElement('div');
this._container.className = 'layer-filter-control';
this._container.innerHTML = `
<div class="layer-filter-toggle" title="Map Style">🗺️</div>
<div class="layer-filter-panel">
<div class="layer-filter-close">✕</div>
<div class="layer-filter-section">
<div class="layer-filter-section-title">Fantasy Theme Colors</div>
<div class="color-picker-row">
<span class="color-picker-label">Land</span>
<input type="color" id="colorLand" value="${mapColors.land}">
</div>
<div class="color-picker-row">
<span class="color-picker-label">Water</span>
<input type="color" id="colorWater" value="${mapColors.water}">
</div>
<div class="color-picker-row">
<span class="color-picker-label">Roads</span>
<input type="color" id="colorRoads" value="${mapColors.roads}">
</div>
<div class="color-picker-row">
<span class="color-picker-label">Buildings</span>
<input type="color" id="colorBuildings" value="${mapColors.buildings}">
</div>
<div class="color-picker-row">
<span class="color-picker-label">Parks</span>
<input type="color" id="colorParks" value="${mapColors.parks}">
</div>
</div>
<div class="layer-filter-section">
<div class="layer-filter-section-title">Options</div>
<div class="filter-checkbox-row">
<label><input type="checkbox" id="toggle3dBuildings" checked> 3D Buildings</label>
</div>
<button class="filter-reset-btn" id="styleReset">Reset to Default</button>
</div>
</div>
`;
// Prevent map interactions on the control
this._container.addEventListener('mousedown', e => e.stopPropagation());
this._container.addEventListener('wheel', e => e.stopPropagation());
// Toggle panel
const toggle = this._container.querySelector('.layer-filter-toggle');
const closeBtn = this._container.querySelector('.layer-filter-close');
toggle.addEventListener('click', () => this._container.classList.add('expanded'));
closeBtn.addEventListener('click', () => this._container.classList.remove('expanded'));
// Color pickers
const colorInputs = [
{ id: 'colorLand', key: 'land' },
{ id: 'colorWater', key: 'water' },
{ id: 'colorRoads', key: 'roads' },
{ id: 'colorBuildings', key: 'buildings' },
{ id: 'colorParks', key: 'parks' }
];
colorInputs.forEach(cfg => {
this._container.querySelector(`#${cfg.id}`).addEventListener('input', (e) => {
mapColors[cfg.key] = e.target.value;
this._updateStyle();
});
});
// 3D Buildings toggle
this._container.querySelector('#toggle3dBuildings').addEventListener('change', (e) => {
this._use3dBuildings = e.target.checked;
this._updateStyle();
});
// Reset button
this._container.querySelector('#styleReset').addEventListener('click', () => {
// Reset to default fantasy colors
mapColors.land = '#1a1a2e';
mapColors.water = '#0f3460';
mapColors.roads = '#e94560';
mapColors.buildings = '#16213e';
mapColors.parks = '#1b4332';
this._use3dBuildings = true;
// Update inputs
this._container.querySelector('#colorLand').value = mapColors.land;
this._container.querySelector('#colorWater').value = mapColors.water;
this._container.querySelector('#colorRoads').value = mapColors.roads;
this._container.querySelector('#colorBuildings').value = mapColors.buildings;
this._container.querySelector('#colorParks').value = mapColors.parks;
this._container.querySelector('#toggle3dBuildings').checked = true;
this._updateStyle();
});
return this._container;
}
onRemove() {
this._container.parentNode.removeChild(this._container);
}
_updateStyle() {
// Save to localStorage for persistence
localStorage.setItem('hikemapColors', JSON.stringify(mapColors));
localStorage.setItem('hikemap3dBuildings', this._use3dBuildings);
// Rebuild and apply the style
this._map.setStyle(buildFantasyStyle(mapColors, this._use3dBuildings));
}
}
// Style control removed - using fixed fantasy theme
// map.addControl(new MapStyleControl(), 'bottom-left');
// =====================
// FOG OF WAR SYSTEM
// =====================
// Fog of War state variables (must be declared before use)
let fogCanvas = null;
let fogCtx = null;
let playerRevealRadius = 800; // Homebase reveal radius (meters)
let playerExploreRadius = 50; // Player's personal reveal radius when exploring (meters)
// Initialize fog of war canvas (directly in map container for simple viewport alignment)
function initFogOfWar() {
const container = map.getContainer();
fogCanvas = document.createElement('canvas');
fogCanvas.id = 'fogCanvas';
fogCanvas.style.position = 'absolute';
fogCanvas.style.top = '0';
fogCanvas.style.left = '0';
fogCanvas.style.zIndex = '450';
fogCanvas.style.pointerEvents = 'none';
container.appendChild(fogCanvas);
fogCtx = fogCanvas.getContext('2d');
resizeFogCanvas();
}
// Resize fog canvas to match map container
function resizeFogCanvas() {
if (!fogCanvas) return;
const container = map.getContainer();
fogCanvas.width = container.clientWidth;
fogCanvas.height = container.clientHeight;
updateFogOfWar();
}
// Helper: Calculate destination point from [lng, lat] + distance (meters) + bearing (degrees)
// Returns [lng, lat] array for MapLibre compatibility
function destinationPoint(lngLat, distance, bearing) {
const R = 6371000; // Earth radius in meters
const d = distance / R;
const brng = bearing * Math.PI / 180;
const lat1 = lngLat[1] * Math.PI / 180;
const lng1 = lngLat[0] * Math.PI / 180;
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(brng)
);
const lng2 = lng1 + Math.atan2(
Math.sin(brng) * Math.sin(d) * Math.cos(lat1),
Math.cos(d) - Math.sin(lat1) * Math.sin(lat2)
);
return [lng2 * 180 / Math.PI, lat2 * 180 / Math.PI]; // [lng, lat]
}
// Flag to track if fog system is ready (navMode must exist)
let fogSystemReady = false;
// Main fog rendering function
function updateFogOfWar() {
// Skip if fog system not ready yet (navMode not defined)
if (!fogSystemReady) return;
// Lazy initialization
if (!fogCanvas) {
initFogOfWar();
}
if (!fogCtx || !fogCanvas) return;
const width = fogCanvas.width;
const height = fogCanvas.height;
// Clear canvas
fogCtx.clearRect(0, 0, width, height);
// In edit mode, no fog
if (!navMode) {
return;
}
// Check if we're zoomed in so far that the viewport is entirely within a revealed area
// If so, skip drawing fog entirely to avoid rendering artifacts
const mapCenter = map.getCenter();
const mapCenterLngLat = [mapCenter.lng, mapCenter.lat];
// Check all four corners of the viewport
const corners = [
map.unproject([0, 0]),
map.unproject([width, 0]),
map.unproject([width, height]),
map.unproject([0, height])
];
// Check if all corners are inside a revealed area
let allCornersRevealed = true;
for (const corner of corners) {
const cornerLngLat = [corner.lng, corner.lat];
let cornerRevealed = false;
// Check homebase
if (playerStats && playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) {
const homeLngLat = [playerStats.homeBaseLng, playerStats.homeBaseLat];
const effectiveHomebaseRadius = playerRevealRadius * homebaseRadiusMultiplier;
if (distanceMeters(cornerLngLat, homeLngLat) <= effectiveHomebaseRadius) {
cornerRevealed = true;
}
}
// Check player explore radius
if (!cornerRevealed && userLocation) {
const playerLngLat = [userLocation.lng, userLocation.lat];
const effectiveExploreRadius = playerExploreRadius * exploreRadiusMultiplier;
if (distanceMeters(cornerLngLat, playerLngLat) <= effectiveExploreRadius) {
cornerRevealed = true;
}
}
if (!cornerRevealed) {
allCornersRevealed = false;
break;
}
}
// If entire viewport is revealed, no need to draw fog
if (allCornersRevealed) {
return;
}
// Draw semi-transparent fog over entire canvas
fogCtx.fillStyle = 'rgba(0, 0, 0, 0.6)';
fogCtx.fillRect(0, 0, width, height);
// Helper function to draw a projected circle (handles rotation AND pitch)
// Returns true if circle was drawn, false if it covers entire canvas
function drawProjectedCircle(ctx, centerLngLat, radiusMeters, numPoints = 48) {
const centerPoint = map.project(centerLngLat);
const points = [];
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * 360;
const edgeLngLat = destinationPoint(centerLngLat, radiusMeters, angle);
const edgePoint = map.project(edgeLngLat);
points.push(edgePoint);
}
// Check if the circle is larger than the canvas (zoomed in too far)
// Calculate approximate radius in pixels
const firstPoint = points[0];
const radiusPixels = Math.hypot(firstPoint.x - centerPoint.x, firstPoint.y - centerPoint.y);
const canvasDiagonal = Math.hypot(width, height);
// If radius is larger than canvas diagonal and center is on screen,
// the entire visible area is revealed
if (radiusPixels > canvasDiagonal &&
centerPoint.x > -radiusPixels && centerPoint.x < width + radiusPixels &&
centerPoint.y > -radiusPixels && centerPoint.y < height + radiusPixels) {
// Draw a rectangle covering the entire canvas instead
ctx.beginPath();
ctx.rect(0, 0, width, height);
ctx.closePath();
return false; // Indicates full canvas clear
}
// Draw the projected shape
ctx.beginPath();
points.forEach((pt, i) => {
if (i === 0) ctx.moveTo(pt.x, pt.y);
else ctx.lineTo(pt.x, pt.y);
});
ctx.closePath();
return true;
}
// Homebase reveal (only if homebase exists)
if (playerStats && playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) {
const homeLngLat = [playerStats.homeBaseLng, playerStats.homeBaseLat];
const effectiveHomebaseRadius = playerRevealRadius * homebaseRadiusMultiplier;
// Cut out revealed area using composite operation
fogCtx.save();
fogCtx.globalCompositeOperation = 'destination-out';
// Draw outer edge with fade (slightly larger, semi-transparent)
fogCtx.fillStyle = 'rgba(0, 0, 0, 0.5)';
drawProjectedCircle(fogCtx, homeLngLat, effectiveHomebaseRadius * 1.05);
fogCtx.fill();
// Draw main revealed area (solid cutout)
fogCtx.fillStyle = 'rgba(0, 0, 0, 1)';
drawProjectedCircle(fogCtx, homeLngLat, effectiveHomebaseRadius * 0.95);
fogCtx.fill();
fogCtx.restore();
}
// Secondary reveal: Player's explore radius when outside homebase
if (userLocation) {
// MapLibre uses [lng, lat] order
const playerLngLat = [userLocation.lng, userLocation.lat];
// Check if player is outside homebase radius (or no homebase)
// Use effective homebase radius with multiplier
const effectiveHomebaseRadius = playerRevealRadius * homebaseRadiusMultiplier;
let isOutsideHomebase = true;
if (playerStats && playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) {
const homeLngLat = [playerStats.homeBaseLng, playerStats.homeBaseLat];
const distToHome = distanceMeters(playerLngLat, homeLngLat);
isOutsideHomebase = distToHome > effectiveHomebaseRadius;
}
if (isOutsideHomebase) {
const effectiveExploreRadius = playerExploreRadius * exploreRadiusMultiplier;
fogCtx.save();
fogCtx.globalCompositeOperation = 'destination-out';
// Draw outer edge with fade (slightly larger, semi-transparent)
fogCtx.fillStyle = 'rgba(0, 0, 0, 0.5)';
drawProjectedCircle(fogCtx, playerLngLat, effectiveExploreRadius * 1.1);
fogCtx.fill();
// Draw main revealed area (solid cutout)
fogCtx.fillStyle = 'rgba(0, 0, 0, 1)';
drawProjectedCircle(fogCtx, playerLngLat, effectiveExploreRadius * 0.85);
fogCtx.fill();
fogCtx.restore();
}
}
}
// Check if a location is within the revealed area (homebase or player explore radius)
function isInRevealedArea(lat, lng) {
// MapLibre uses [lng, lat] order
const checkLngLat = [lng, lat];
// Check homebase radius (with multiplier)
if (playerStats && playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) {
const homeLngLat = [playerStats.homeBaseLng, playerStats.homeBaseLat];
const effectiveHomebaseRadius = playerRevealRadius * homebaseRadiusMultiplier;
if (distanceMeters(homeLngLat, checkLngLat) <= effectiveHomebaseRadius) {
return true;
}
}
// Check player's explore radius (with multiplier)
if (userLocation) {
const playerLngLat = [userLocation.lng, userLocation.lat];
const effectiveExploreRadius = playerExploreRadius * exploreRadiusMultiplier;
if (distanceMeters(playerLngLat, checkLngLat) <= effectiveExploreRadius) {
return true;
}
}
return false;
}
// Hook fog updates to map events (fog will be initialized lazily)
map.on('move', updateFogOfWar);
map.on('zoom', updateFogOfWar);
map.on('resize', resizeFogCanvas);
map.on('rotate', updateFogOfWar);
map.on('pitch', updateFogOfWar);
// NOTE: initFogOfWar() is called lazily from updateFogOfWar() to avoid
// temporal dead zone issues with navMode variable
// Admin Settings (loaded from localStorage or defaults)
let adminSettings = {
geocacheRange: 5,
geocacheAlertRange: 5,
geocacheSoundEnabled: true,
trackProximity: 50,
intersectionThreshold: 15,
onTrailThreshold: 100,
nodeSpacing: 50,
gpsPollInterval: 3000,
proximityCheckInterval: 3000,
snapDistancePx: 15
};
// Generate CSS from MONSTER_ANIMATIONS object (loaded from animations.js)
function generateAnimationCSS() {
if (typeof MONSTER_ANIMATIONS === 'undefined') {
console.warn('MONSTER_ANIMATIONS not loaded, using default animations');
return;
}
let css = '';
for (const [id, anim] of Object.entries(MONSTER_ANIMATIONS)) {
const loopStr = anim.loop ? ' infinite' : '';
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
const easing = anim.easing || 'ease-out';
css += `@keyframes monster_${id} { ${anim.keyframes} }\n`;
css += `.anim-${id} { animation: monster_${id} ${anim.duration}ms ${easing}${loopStr}${fillStr}; }\n`;
// Also generate cache animation classes (reuse same keyframes)
css += `.cache-anim-${id} { animation: monster_${id} ${anim.duration}ms ${easing}${loopStr}${fillStr}; }\n`;
}
const style = document.createElement('style');
style.id = 'monster-animations-css';
style.textContent = css;
document.head.appendChild(style);
console.log('Monster animation CSS generated');
}
// Play a monster animation on an element
function playMonsterAnimation(element, animationId) {
if (!element) return;
const anim = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS[animationId] : null;
if (!anim) {
// Fallback to default attack animation
element.style.animation = 'none';
element.offsetHeight; // Force reflow
element.style.animation = 'monsterAttack 0.5s ease-out';
return;
}
const loopStr = anim.loop ? ' infinite' : '';
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
const easing = anim.easing || 'ease-out';
element.style.animation = 'none';
element.offsetHeight; // Force reflow
element.style.animation = `monster_${animationId} ${anim.duration}ms ${easing}${loopStr}${fillStr}`;
}
// Generate animation CSS on load
generateAnimationCSS();
// Store all tracks
const tracks = [];
let selectedTracks = []; // Now supports multiple selection
let currentTool = 'select';
let isDrawing = false;
let drawingPoints = [];
let drawingLine = null;
// Geocache variables
let geocaches = []; // Array of { id, lat, lng, messages: [{author, text, timestamp}] }
let currentGeocache = null; // Currently selected/nearby geocache
let currentGeocacheEditMode = false; // Whether we're editing an existing geocache
let geocacheMarkers = {}; // Map of geocache id to marker
let lastGeocacheProximityCheck = 0;
let readGeocaches = JSON.parse(localStorage.getItem('readGeocaches') || '[]'); // Track which caches user has read
// Preview state
let previewMode = false;
let previewLayers = [];
let previewData = null;
// Undo stack
const undoStack = [];
const maxUndoSteps = 20;
// Reshape/Dragging state
let isDragging = false;
let dragTrack = null;
let dragPointIndex = -1;
let originalCoords = null;
let dragMarker = null;
let affectedMarkers = [];
// Snap state for endpoint merging
let snapTarget = null; // {track, index, latlng}
let snapMarker = null;
const SNAP_DISTANCE_PX = adminSettings.snapDistancePx; // from admin settings
// Smooth brush state
let isSmoothing = false;
let smoothBrushCircle = null;
let smoothedPoints = new Set(); // Track which points have been smoothed this stroke
// GPS state
let gpsWatchId = null;
let gpsBackupInterval = null;
let gpsMarker = null;
let gpsAccuracyCircle = null;
let gpsFirstFix = true;
let currentHeading = null;
let userLocation = null; // Last known GPS position {lat, lng, accuracy}
// GPS test mode (admin only)
let gpsTestMode = false;
let testPosition = { lat: 37.7749, lng: -122.4194 }; // Default to SF
const GPS_TEST_STEP = 0.000009; // ~1 meter per step
// Navigation state
let navMode = false;
let destinationPin = null;
// Now that navMode exists, fog system can be used
fogSystemReady = true;
// Multi-user tracking
let ws = null;
let userId = null;
let otherUsers = new Map();
// Notification cooldown tracking
let notificationCooldowns = {
nearbyCache: {}, // cacheId -> lastNotificationTime
destinationArrival: 0 // lastNotificationTime
};
const CACHE_COOLDOWN = 10 * 60 * 1000; // 10 minutes
const CACHE_NOTIFY_DISTANCE = 200; // meters
const CACHE_RESET_DISTANCE = 200; // meters to reset cooldown
const DESTINATION_ARRIVAL_DISTANCE = 10; // meters
let wsReconnectTimer = null;
let wsHeartbeatTimer = null; // Client-side ping timer
let wsLastPong = 0; // Timestamp of last successful pong/message
let wsConnected = false; // Connection state for UI indicator
const WS_HEARTBEAT_INTERVAL = 20000; // Send ping every 20 seconds
const WS_PONG_TIMEOUT = 10000; // Consider dead if no response in 10s
let myIcon = null;
let myColor = null;
let isNearTrack = false;
// Use settings value instead of const
// const TRACK_PROXIMITY_THRESHOLD = adminSettings.trackProximity;
let destinationTrack = null;
let destinationIndex = null;
let directionArrow = null;
let currentClosestIndex = null;
let currentRoute = null; // Array of {track, fromIndex, toIndex} segments
let routeHighlight = null; // Polyline showing the full route
let rotateMapMode = false;
let currentBearing = 0;
let autoCenterMode = true; // Start with auto-center on by default
// Trail graph for pathfinding
// Use settings value instead of const
// const adminSettings.intersectionThreshold = adminSettings.intersectionThreshold;
let trailGraph = null; // Cached graph, rebuilt when tracks change
// ==========================================
// RPG COMBAT SYSTEM
// ==========================================
// Player class definitions
// Character races with stat bonuses
const RACES = {
'human': {
name: 'Human',
icon: '🧑',
description: 'Balanced and adaptable',
bonuses: { hp: 5, mp: 5, atk: 0, def: 0 }
},
'elf': {
name: 'Elf',
icon: '🧝',
description: 'Swift and magical',
bonuses: { hp: -5, mp: 15, atk: 0, def: -2 }
},
'dwarf': {
name: 'Dwarf',
icon: '🧔',
description: 'Tough and sturdy',
bonuses: { hp: 15, mp: -5, atk: 0, def: 3 }
},
'halfling': {
name: 'Halfling',
icon: '🧒',
description: 'Quick and nimble',
bonuses: { hp: -5, mp: 0, atk: 2, def: 5 }
}
};
// Player classes (loaded from database via API)
let PLAYER_CLASSES = {};
let classesLoaded = false;
// Skill definitions
const SKILLS = {
'basic_attack': {
name: 'Attack',
icon: '👊',
mpCost: 0,
levelReq: 1,
type: 'damage',
calculate: (atk) => atk,
description: 'A basic attack'
},
'brand_new_hokas': {
name: 'Brand New Hokas',
icon: '👟',
mpCost: 10,
levelReq: 2,
type: 'damage',
calculate: (atk) => Math.floor(atk * 2),
description: 'Strike twice with premium footwear! (2x damage)'
},
'runners_high': {
name: "Runner's High",
icon: '🏃‍♂️',
mpCost: 15,
levelReq: 3,
type: 'heal',
calculate: (maxHp) => Math.floor(maxHp * 0.3),
description: 'Heal 30% of max HP'
},
'shin_kick': {
name: 'Shin Kick',
icon: '🦵',
mpCost: 20,
levelReq: 5,
type: 'damage',
calculate: (atk) => Math.floor(atk * 3),
description: 'Devastating kick! (3x damage)'
},
// Alternative skills for skill selection system
'quick_step': {
name: 'Quick Step',
icon: '⚡',
mpCost: 8,
levelReq: 2,
type: 'buff',
effect: 'dodge',
description: 'Dodge the next enemy attack completely'
},
'second_wind': {
name: 'Second Wind',
icon: '💨',
mpCost: 12,
levelReq: 3,
type: 'restore',
effect: 'mp',
calculate: (maxMp) => Math.floor(maxMp * 0.5),
description: 'Restore 50% of max MP'
},
'finish_line_sprint': {
name: 'Finish Line Sprint',
icon: '🏁',
mpCost: 25,
levelReq: 5,
type: 'damage',
calculate: (atk) => Math.floor(atk * 2),
hits: 3,
description: 'Strike 3 times for 2x ATK each'
},
'admin_banish': {
name: 'Banish All',
icon: '⚡',
mpCost: 0,
levelReq: 1,
type: 'admin_clear',
adminOnly: true,
description: 'Instantly banish all enemies (Admin only)'
},
'whirlwind': {
name: 'Whirlwind',
icon: '🌀',
mpCost: 12,
levelReq: 1,
type: 'damage',
target: 'all_enemies',
calculate: (atk) => Math.floor(atk * 0.75),
description: 'Spinning attack that hits all enemies for 75% ATK'
}
};
// Skill pools for skill selection at level-up milestones (loaded from database)
let SKILL_POOLS = {};
// Monster type definitions (loaded from database via API)
let MONSTER_TYPES = {};
let MONSTER_DIALOGUES = {};
let monsterTypesLoaded = false;
// Skills loaded from database API
let SKILLS_DB = {}; // Base skill definitions from API
let CLASS_SKILL_NAMES = []; // Class-specific skill names
let MONSTER_SKILLS = {}; // Skills assigned to each monster type
let skillsLoaded = false;
// OSM Tags for location-based prefixes
let OSM_TAGS = {};
let OSM_TAG_SETTINGS = { basePrefixChance: 25, doublePrefixChance: 10 };
let osmTagsLoaded = false;
// Load OSM tags from the database
async function loadOsmTags() {
try {
const response = await fetch('/api/osm-tags');
if (response.ok) {
const tags = await response.json();
OSM_TAGS = {};
tags.forEach(t => {
const prefixes = typeof t.prefixes === 'string' ? JSON.parse(t.prefixes || '[]') : (t.prefixes || []);
OSM_TAGS[t.id] = {
prefixes: prefixes,
artwork: t.artwork || 1,
animation: t.animation || null,
animationShadow: t.animation_shadow || null,
visibilityDistance: t.visibility_distance,
spawnRadius: t.spawn_radius,
enabled: t.enabled
};
});
osmTagsLoaded = true;
console.log(`Loaded ${Object.keys(OSM_TAGS).length} OSM tags`);
}
// Also load settings
const settingsResponse = await fetch('/api/osm-tag-settings');
if (settingsResponse.ok) {
const settings = await settingsResponse.json();
if (settings.basePrefixChance !== undefined) {
OSM_TAG_SETTINGS.basePrefixChance = settings.basePrefixChance;
}
if (settings.doublePrefixChance !== undefined) {
OSM_TAG_SETTINGS.doublePrefixChance = settings.doublePrefixChance;
}
console.log('Loaded OSM tag settings:', OSM_TAG_SETTINGS);
}
} catch (err) {
console.error('Failed to load OSM tags:', err);
}
}
// Record a monster kill for tracking (quests/bestiary)
async function recordMonsterKill(monster) {
const fullName = (monster.namePrefix || '') + monster.data.name;
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
await fetch('/api/user/monster-kill', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ monsterName: fullName })
});
} catch (err) {
console.error('Failed to record kill:', err);
}
}
// Discover nearby locations when homebase is set
async function discoverNearbyLocations(lat, lng) {
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
updateStatus('Discovering nearby locations...', 'info');
const response = await fetch('/api/geocaches/discover-nearby', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ lat, lng, radiusMiles: 2 })
});
if (response.ok) {
const { discovered, added } = await response.json();
if (added > 0) {
updateStatus(`Discovered ${added} new locations near your home base!`, 'success');
// Reload geocaches to include new ones
await loadGeocaches();
renderGeocaches();
} else if (discovered > 0) {
updateStatus(`Found ${discovered} locations already in database.`, 'info');
} else {
updateStatus('Home base set!', 'success');
}
}
} catch (err) {
console.error('Failed to discover locations:', err);
}
}
// Load monster types from the database
async function loadMonsterTypes() {
try {
const response = await fetch('/api/monster-types');
if (response.ok) {
const types = await response.json();
types.forEach(t => {
MONSTER_TYPES[t.id] = {
name: t.name,
icon: t.icon,
baseHp: t.baseHp,
baseAtk: t.baseAtk,
baseDef: t.baseDef,
baseMp: t.baseMp || 20,
xpReward: t.xpReward,
accuracy: t.accuracy || 85,
dodge: t.dodge || 5,
minLevel: t.minLevel || 1,
maxLevel: t.maxLevel || 99,
spawnWeight: t.spawnWeight || 100,
spawnLocation: t.spawnLocation || 'anywhere', // Location restriction for spawning
// Animation settings
idleAnimation: t.idleAnimation || 'idle',
attackAnimation: t.attackAnimation || 'attack',
deathAnimation: t.deathAnimation || 'death',
levelScale: {
hp: t.levelScale?.hp || 10,
atk: t.levelScale?.atk || 2,
def: t.levelScale?.def || 1,
mp: t.levelScale?.mp || 5
}
};
MONSTER_DIALOGUES[t.id] = t.dialogues;
});
monsterTypesLoaded = true;
console.log('Loaded monster types from database:', Object.keys(MONSTER_TYPES));
// DEBUG: Show spawnLocation for each monster type
Object.entries(MONSTER_TYPES).forEach(([id, type]) => {
console.log(`[MONSTER TYPE] ${id}: spawnLocation = "${type.spawnLocation}"`);
});
}
} catch (err) {
console.error('Failed to load monster types:', err);
}
}
// Load spawn settings from server
async function loadSpawnSettings() {
try {
const response = await fetch('/api/spawn-settings');
if (response.ok) {
const settings = await response.json();
spawnSettings.spawnInterval = settings.spawnInterval || 20000;
spawnSettings.spawnChance = settings.spawnChance || 50;
spawnSettings.spawnDistance = settings.spawnDistance || 10;
spawnSettings.mpRegenDistance = settings.mpRegenDistance || 5;
spawnSettings.mpRegenAmount = settings.mpRegenAmount || 1;
spawnSettings.hpRegenInterval = settings.hpRegenInterval || 10000;
spawnSettings.hpRegenPercent = settings.hpRegenPercent || 1;
spawnSettings.homeHpMultiplier = settings.homeHpMultiplier || 3;
spawnSettings.homeRegenPercent = settings.homeRegenPercent || 5;
spawnSettings.homeBaseRadius = settings.homeBaseRadius || 20;
// Load inactivity settings
if (settings.inactivityTimeout) {
inactivityTimeout = settings.inactivityTimeout;
}
if (settings.inactivityWarningTime) {
inactivityWarningTime = settings.inactivityWarningTime;
}
// Apply combat icon scale CSS variable (mobile only)
if (settings.combatIconScale) {
document.documentElement.style.setProperty('--combat-icon-scale-mobile', settings.combatIconScale);
}
console.log('Loaded spawn settings:', spawnSettings);
}
} catch (err) {
console.error('Failed to load spawn settings:', err);
}
}
// Load skills from database
async function loadSkillsFromDatabase() {
try {
// Load base skills
const skillsResponse = await fetch('/api/skills');
if (skillsResponse.ok) {
const skills = await skillsResponse.json();
skills.forEach(s => {
SKILLS_DB[s.id] = {
id: s.id,
name: s.name,
description: s.description,
type: s.type,
mpCost: s.mpCost,
basePower: s.basePower,
accuracy: s.accuracy,
hitCount: s.hitCount,
target: s.target,
targeting_mode: s.targeting_mode || 'same_target',
statusEffect: s.statusEffect,
playerUsable: s.playerUsable,
monsterUsable: s.monsterUsable
};
});
console.log('Loaded skills from database:', Object.keys(SKILLS_DB));
}
// Load class skill names
const namesResponse = await fetch('/api/class-skill-names');
if (namesResponse.ok) {
CLASS_SKILL_NAMES = await namesResponse.json();
console.log('Loaded class skill names:', CLASS_SKILL_NAMES.length);
}
skillsLoaded = true;
} catch (err) {
console.error('Failed to load skills:', err);
}
}
// Load skills for a specific monster type
async function loadMonsterSkills(monsterTypeId) {
if (MONSTER_SKILLS[monsterTypeId]) return MONSTER_SKILLS[monsterTypeId];
try {
const response = await fetch(`/api/monster-types/${monsterTypeId}/skills`);
if (response.ok) {
MONSTER_SKILLS[monsterTypeId] = await response.json();
console.log(`Loaded skills for ${monsterTypeId}:`, MONSTER_SKILLS[monsterTypeId].length);
return MONSTER_SKILLS[monsterTypeId];
}
} catch (err) {
console.error(`Failed to load monster skills for ${monsterTypeId}:`, err);
}
return [];
}
// Load player classes from database
async function loadClasses() {
try {
const response = await fetch('/api/classes');
if (response.ok) {
const classes = await response.json();
for (const cls of classes) {
// Load skills for this class
const skillsResponse = await fetch(`/api/classes/${cls.id}/skills`);
let classSkills = [];
if (skillsResponse.ok) {
classSkills = await skillsResponse.json();
}
// Build the class object
PLAYER_CLASSES[cls.id] = {
name: cls.name,
icon: getClassIcon(cls.id),
available: cls.enabled === 1,
description: cls.description,
baseStats: {
hp: cls.base_hp,
mp: cls.base_mp,
atk: cls.base_atk,
def: cls.base_def
},
baseAccuracy: cls.base_accuracy || 90,
baseDodge: cls.base_dodge || 10,
hpPerLevel: cls.hp_per_level,
mpPerLevel: cls.mp_per_level,
atkPerLevel: cls.atk_per_level,
defPerLevel: cls.def_per_level,
skills: classSkills
.filter(s => !s.choice_group) // Auto-learned skills (no choice)
.map(s => s.custom_name ? s.skill_id : s.skill_id)
};
// Build SKILL_POOLS from skills with choice_groups
SKILL_POOLS[cls.id] = {};
classSkills.forEach(skill => {
if (skill.choice_group) {
const level = skill.unlock_level;
if (!SKILL_POOLS[cls.id][level]) {
SKILL_POOLS[cls.id][level] = [];
}
// Use custom name as skill ID for display, but store skill_id
SKILL_POOLS[cls.id][level].push(skill.skill_id);
}
});
}
classesLoaded = true;
console.log('Loaded classes from database:', Object.keys(PLAYER_CLASSES));
console.log('Built skill pools:', SKILL_POOLS);
}
} catch (err) {
console.error('Failed to load classes:', err);
}
}
// Get icon for a class (can be extended to support custom icons in DB later)
function getClassIcon(classId) {
const icons = {
'trail_runner': '🏃',
'gym_bro': '💪',
'yoga_master': '🧘',
'crossfit_crusader': '🏋️'
};
return icons[classId] || '⚔️';
}
// Get skill display name for a class (or base name if no custom)
function getSkillForClass(skillId, classId) {
const baseSkill = SKILLS_DB[skillId] || SKILLS[skillId];
if (!baseSkill) return null;
// Check for class-specific name
const customName = CLASS_SKILL_NAMES.find(
n => n.skillId === skillId && n.classId === classId
);
return {
...baseSkill,
displayName: customName ? customName.customName : baseSkill.name,
displayDescription: customName?.customDescription || baseSkill.description
};
}
// Get skill icon with fallback chain: class/monster override → base skill → emoji
function getSkillIcon(skillId, contextType = null, contextId = null) {
const baseSkill = SKILLS_DB[skillId];
const hardcodedSkill = SKILLS[skillId];
// Check class override
if (contextType === 'class' && contextId) {
const classSkill = CLASS_SKILL_NAMES.find(
n => n.skillId === skillId && n.classId === contextId
);
if (classSkill?.customIcon) {
return { type: 'image', src: `/mapgameimgs/skills/${classSkill.customIcon}` };
}
}
// Check monster override
if (contextType === 'monster' && contextId) {
const monsterSkills = MONSTER_SKILLS[contextId] || [];
const monsterSkill = monsterSkills.find(s => s.skillId === skillId);
if (monsterSkill?.customIcon) {
return { type: 'image', src: `/mapgameimgs/skills/${monsterSkill.customIcon}` };
}
}
// Base skill icon from database
if (baseSkill?.icon) {
return { type: 'image', src: `/mapgameimgs/skills/${baseSkill.icon}` };
}
// Emoji fallback
return { type: 'emoji', value: hardcodedSkill?.icon || '⚔️' };
}
// Render skill icon as HTML (with error fallback to emoji)
function renderSkillIcon(skillId, contextType = null, contextId = null, size = 20) {
const icon = getSkillIcon(skillId, contextType, contextId);
if (icon.type === 'image') {
const fallbackEmoji = SKILLS[skillId]?.icon || '⚔️';
return `<img src="${icon.src}" class="skill-icon" style="width:${size}px;height:${size}px;vertical-align:middle;margin-right:4px;" onerror="this.outerHTML='${fallbackEmoji}'">`;
}
return icon.value;
}
// Calculate hit chance: skill accuracy + (attacker accuracy - 90) - defender dodge
function calculateHitChance(attackerAccuracy, defenderDodge, skillAccuracy) {
const hitChance = skillAccuracy + (attackerAccuracy - 90) - defenderDodge;
return Math.max(5, Math.min(99, hitChance)); // Clamp 5-99%
}
// Roll for hit
function rollHit(hitChance) {
return Math.random() * 100 < hitChance;
}
// Apply damage variance (±15% randomness)
function applyDamageVariance(baseDamage) {
const variance = 0.15; // 15% variance
const multiplier = 1 + (Math.random() * variance * 2 - variance); // 0.85 to 1.15
return Math.max(1, Math.floor(baseDamage * multiplier));
}
// Calculate damage with percentage-based defense reduction
// Formula: damage = rawDamage * (100 / (100 + DEF))
// This prevents DEF from completely nullifying attacks
function calculateDamage(rawDamage, defense) {
const reduction = 100 / (100 + defense);
const damage = Math.floor(rawDamage * reduction);
return Math.max(1, damage);
}
// Select a monster skill using weighted random
function selectMonsterSkill(monsterTypeId, monsterLevel, monsterMp = 999) {
const skills = MONSTER_SKILLS[monsterTypeId] || [];
console.log('[DEBUG] selectMonsterSkill:', monsterTypeId, 'level:', monsterLevel, 'MP:', monsterMp, 'skills loaded:', skills.length);
// Filter by level requirement AND MP cost
const validSkills = skills.filter(s => monsterLevel >= s.minLevel && (s.mpCost || 0) <= monsterMp);
console.log('[DEBUG] validSkills after level+MP filter:', validSkills.length);
if (validSkills.length === 0) {
// Fallback to basic attack
console.log('[DEBUG] Using fallback basic_attack, SKILLS_DB has it:', !!SKILLS_DB['basic_attack']);
return SKILLS_DB['basic_attack'] || { id: 'basic_attack', name: 'Attack', basePower: 100, accuracy: 95, type: 'damage', hitCount: 1, mpCost: 0 };
}
// Weighted random selection
const totalWeight = validSkills.reduce((sum, s) => sum + s.weight, 0);
let random = Math.random() * totalWeight;
for (const skill of validSkills) {
random -= skill.weight;
if (random <= 0) {
return {
id: skill.skillId,
name: skill.name,
basePower: skill.basePower,
accuracy: skill.accuracy,
hitCount: skill.hitCount || 1,
statusEffect: skill.statusEffect,
type: skill.type,
mpCost: skill.mpCost || 0,
animation: skill.animation || null
};
}
}
// Fallback
const lastSkill = validSkills[validSkills.length - 1];
return {
id: lastSkill.skillId,
name: lastSkill.name,
basePower: lastSkill.basePower,
accuracy: lastSkill.accuracy,
hitCount: lastSkill.hitCount || 1,
statusEffect: lastSkill.statusEffect,
type: lastSkill.type,
mpCost: lastSkill.mpCost || 0,
animation: lastSkill.animation || null
};
}
// Dialogue phase thresholds (in minutes)
const DIALOGUE_PHASES = [
{ maxMinutes: 5, phase: 'annoyed' },
{ maxMinutes: 10, phase: 'frustrated' },
{ maxMinutes: 30, phase: 'desperate' },
{ maxMinutes: 60, phase: 'philosophical' },
{ maxMinutes: Infinity, phase: 'existential' }
];
// RPG State variables
let playerStats = null; // Player RPG stats
let statsLoadedFromServer = false; // Flag to prevent saving until server data is loaded
let monsterEntourage = []; // Array of spawned monsters following player
let combatState = null; // Active combat state or null
let monsterActiveAnimations = {}; // Track active animations per monster index {index: {animId, startTime, duration}}
let monsterSpawnTimer = null; // Interval for spawning monsters
let monsterUpdateTimer = null; // Interval for updating monster positions/dialogue
let homeRegenTimer = null; // Interval for passive home base regen
let lastSpawnLocation = null; // Track player location at last spawn (for movement-based spawning)
// Stats synchronization engine - prevents save spam during rapid state changes
let statsSyncState = {
dirty: false, // True if local changes need saving
saveInFlight: false, // True if a save request is in progress
pendingSave: false, // True if another save was requested while one is in flight
lastSaveAttempt: 0, // Timestamp of last save attempt
consecutiveFailures: 0, // Track repeated failures
inCombat: false // Suppress non-critical errors during combat
};
const SYNC_DEBOUNCE_MS = 500; // Wait 500ms after last change before saving
const SYNC_MIN_INTERVAL_MS = 1000; // Never save more than once per second
let syncDebounceTimer = null;
// Spawn settings (loaded from server, with defaults)
let spawnSettings = {
spawnInterval: 20000, // Timer interval in ms
spawnChance: 50, // Percent chance per interval
spawnDistance: 10, // Meters player must move
mpRegenDistance: 5, // Meters per 1 MP regen
mpRegenAmount: 1, // MP gained per distance threshold
hpRegenInterval: 10000, // HP regens every 10 seconds
hpRegenPercent: 1, // Base: 1% of max HP per tick
homeHpMultiplier: 3, // HP regens 3x faster at home base
homeRegenPercent: 5, // Regen 5% of max HP/MP per tick at home
homeBaseRadius: 20 // Meters - radius for home base effects
};
// MP regen tracking (distance-based)
let lastMpRegenLocation = null; // Track location for MP regen distance
let mpRegenAccumulator = 0; // Accumulated distance for MP regen
// HP regen settings (time-based) - now use spawnSettings values
let hpRegenTimer = null; // Timer for passive HP regen
// Home Base state variables
let homeBaseMarker = null; // Leaflet marker for home base
let homeBaseSelectionMode = false; // Whether we're in home base selection mode
let xpLostOnDeath = 0; // Track XP lost for display
let lastHomeRegenTime = 0; // Track last HP/MP regen at home base
let wasAtHomeBase = false; // Track if player was at home base last check
const HOME_REGEN_INTERVAL = 3000; // Regen every 3 seconds when at home (this stays constant)
// Player buffs state (loaded from server)
let playerBuffs = {}; // Buff status keyed by buffType
let mpRegenMultiplier = 1.0; // Current MP regen multiplier
let exploreRadiusMultiplier = 1.0; // Player explore radius multiplier (fog of war)
let homebaseRadiusMultiplier = 1.0; // Homebase reveal radius multiplier (fog of war)
// ==========================================
// MUSIC SYSTEM
// ==========================================
const gameMusic = {
login: new Audio('/mapgamemusic/login.mp3'),
overworld: new Audio('/mapgamemusic/over_world.mp3'),
battle: new Audio('/mapgamemusic/in_fight.mp3'),
victory: new Audio('/mapgamemusic/victory.mp3'),
death: new Audio('/mapgamemusic/he_ded.mp3'),
homebase: new Audio('/mapgamemusic/homebase.mp3'),
current: null,
currentTrack: null, // Track name for easy comparison
muted: localStorage.getItem('musicMuted') === 'true',
volume: parseFloat(localStorage.getItem('musicVolume') || '0.5'),
pausedTracks: {} // Store paused positions for resumable tracks
};
// ==========================================
// SOUND EFFECTS SYSTEM
// ==========================================
const gameSfx = {
missed: new Audio('/sfx/missed.mp3'),
player_attack: new Audio('/sfx/player_attack.mp3'),
player_skill: new Audio('/sfx/player_skill.mp3'),
monster_attack: new Audio('/sfx/monster_attack.mp3'),
monster_skill: new Audio('/sfx/monster_skill.mp3'),
monster_death: new Audio('/sfx/monster_death.mp3'),
monster_spawn: new Audio('/sfx/monster_spawn.mp3'),
volume: parseFloat(localStorage.getItem('sfxVolume') || '0.5'),
muted: localStorage.getItem('sfxMuted') === 'true'
};
// Initialize SFX
function initSfx() {
const sfxNames = ['missed', 'player_attack', 'player_skill', 'monster_attack', 'monster_skill', 'monster_death', 'monster_spawn'];
sfxNames.forEach(sfx => {
const audio = gameSfx[sfx];
audio.preload = 'auto';
audio.volume = gameSfx.volume;
audio.load();
});
}
// Play a sound effect (doesn't interrupt music)
function playSfx(sfxName) {
if (gameSfx.muted) return;
const audio = gameSfx[sfxName];
if (!audio) {
console.error('SFX not found:', sfxName);
return;
}
// Clone and play so multiple can overlap
const clone = audio.cloneNode();
clone.volume = gameSfx.volume;
clone.play().catch(e => console.log('SFX play failed:', e));
}
// Initialize music settings
function initMusic() {
// Set up looping for ambient tracks
gameMusic.login.loop = true;
gameMusic.overworld.loop = true;
gameMusic.battle.loop = true;
gameMusic.homebase.loop = true;
gameMusic.victory.loop = false;
gameMusic.death.loop = false;
// Set initial volume for all tracks
gameMusic.login.volume = gameMusic.volume;
gameMusic.overworld.volume = gameMusic.volume;
gameMusic.battle.volume = gameMusic.volume;
gameMusic.victory.volume = gameMusic.volume;
gameMusic.death.volume = gameMusic.volume;
gameMusic.homebase.volume = gameMusic.volume;
// Preload all audio tracks and add error handling
const tracks = ['login', 'overworld', 'battle', 'victory', 'death', 'homebase'];
tracks.forEach(track => {
const audio = gameMusic[track];
audio.preload = 'auto';
audio.load();
audio.addEventListener('canplaythrough', () => {
console.log('Music track loaded:', track);
}, { once: true });
audio.addEventListener('error', (e) => {
console.error('Failed to load music track:', track, audio.error);
});
});
// When victory music ends, go back to appropriate music
gameMusic.victory.addEventListener('ended', () => {
if (!combatState && playerStats && !playerStats.isDead) {
// Check if at home base
const distToHome = getDistanceToHome();
if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) {
playMusic('homebase');
} else {
playMusic('overworld');
}
}
});
// When death music ends, stay silent (they need to get home)
gameMusic.death.addEventListener('ended', () => {
// Could loop or stay silent - staying silent for now
});
}
// Play a specific music track
function playMusic(track) {
if (gameMusic.muted) return;
const audio = gameMusic[track];
if (!audio) {
console.error('Music track not found:', track);
return;
}
// Don't restart if already playing this track
if (gameMusic.currentTrack === track && !audio.paused) return;
// Pause current music (save position for overworld)
pauseCurrentMusic();
// Play new track
gameMusic.current = audio;
gameMusic.currentTrack = track;
// Resume from saved position for overworld, otherwise start fresh
if (track === 'overworld' && gameMusic.pausedTracks.overworld !== undefined) {
audio.currentTime = gameMusic.pausedTracks.overworld;
delete gameMusic.pausedTracks.overworld;
} else if (track !== 'overworld') {
// Non-overworld tracks always start from beginning
audio.currentTime = 0;
}
// If overworld with no saved position, let it continue or start from 0
audio.play().then(() => {
console.log('Playing music:', track);
}).catch(err => {
console.log('Music autoplay blocked:', err.message);
});
}
// Pause current music (save position for resumable tracks)
function pauseCurrentMusic() {
if (gameMusic.current && gameMusic.currentTrack) {
// Save position for overworld so we can resume later
if (gameMusic.currentTrack === 'overworld') {
gameMusic.pausedTracks.overworld = gameMusic.current.currentTime;
}
gameMusic.current.pause();
}
}
// Stop current music
function stopMusic() {
if (gameMusic.current) {
gameMusic.current.pause();
gameMusic.current.currentTime = 0;
}
gameMusic.currentTrack = null;
}
// Toggle mute
function toggleMusicMute() {
gameMusic.muted = !gameMusic.muted;
localStorage.setItem('musicMuted', gameMusic.muted);
if (gameMusic.muted) {
stopMusic();
} else {
// Resume appropriate music based on game state
if (playerStats && playerStats.isDead) {
playMusic('death');
} else if (combatState && combatState.inCombat) {
playMusic('battle');
} else {
// Check if at home base
const distToHome = getDistanceToHome();
if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) {
playMusic('homebase');
} else {
playMusic('overworld');
}
}
}
updateMusicButton();
}
// Set music volume
function setMusicVolume(vol) {
gameMusic.volume = Math.max(0, Math.min(1, vol));
localStorage.setItem('musicVolume', gameMusic.volume);
gameMusic.login.volume = gameMusic.volume;
gameMusic.overworld.volume = gameMusic.volume;
gameMusic.battle.volume = gameMusic.volume;
gameMusic.victory.volume = gameMusic.volume;
gameMusic.death.volume = gameMusic.volume;
gameMusic.homebase.volume = gameMusic.volume;
}
// Update music button icon
function updateMusicButton() {
const btn = document.getElementById('musicToggleBtn');
if (btn) {
btn.innerHTML = gameMusic.muted ? '🔇' : '🎵';
btn.title = gameMusic.muted ? 'Unmute Music' : 'Mute Music';
}
}
// Find nearest monster to a location (for double-tap to battle on mobile)
function findNearestMonster(latlng, maxDistanceMeters = 50) {
if (monsterEntourage.length === 0) return null;
let nearest = null;
let nearestDist = Infinity;
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(latlng.lat * Math.PI / 180);
monsterEntourage.forEach(monster => {
const dx = (latlng.lng - monster.position.lng) * metersPerDegLng;
const dy = (latlng.lat - monster.position.lat) * metersPerDegLat;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < nearestDist) {
nearestDist = dist;
nearest = monster;
}
});
return nearestDist <= maxDistanceMeters ? { monster: nearest, distance: nearestDist } : null;
}
// Max monsters = 2 per player level
const getMaxMonsters = () => 2 * (playerStats?.level || 1);
// ==========================================
// END RPG COMBAT SYSTEM DEFINITIONS
// ==========================================
// Save current state for undo
function saveStateForUndo() {
const state = {
tracks: tracks.map(t => ({
coords: [...t.coords.map(c => [...c])],
name: t.name,
description: t.description
})),
splitMarkers: splitMarkers.map(m => ({
latlng: [m.getLatLng().lat, m.getLatLng().lng],
popupContent: m.getPopup()?.getContent() || ''
}))
};
undoStack.push(state);
if (undoStack.length > maxUndoSteps) {
undoStack.shift(); // Remove oldest
}
updateUndoButton();
}
// Restore previous state
function undo() {
if (undoStack.length === 0) {
updateStatus('Nothing to undo', 'info');
return;
}
const state = undoStack.pop();
// Clear current tracks
tracks.forEach(t => t.remove());
tracks.length = 0;
selectedTracks = [];
// Clear current split markers
splitMarkers.forEach(m => map.removeLayer(m));
splitMarkers.length = 0;
// Restore tracks
for (const trackData of state.tracks) {
const track = new Track(trackData.coords, trackData.name, trackData.description);
tracks.push(track);
}
// Restore split markers
for (const markerData of state.splitMarkers) {
const marker = L.marker(markerData.latlng, {
icon: L.divIcon({
className: 'split-marker',
html: '<div style="background: #ff4444; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).addTo(map);
if (markerData.popupContent) {
marker.bindPopup(markerData.popupContent);
}
splitMarkers.push(marker);
}
updateTrackList();
updateUndoButton();
updateStatus('Undone', 'success');
}
function updateUndoButton() {
const btn = document.getElementById('undoBtn');
if (btn) {
btn.textContent = undoStack.length > 0 ? `Undo (${undoStack.length})` : 'Undo';
btn.disabled = undoStack.length === 0;
btn.style.opacity = undoStack.length === 0 ? '0.5' : '1';
}
}
// GPS functions
function toggleGPS() {
const btn = document.getElementById('gpsBtn');
const compassBtn = document.getElementById('compassBtn');
const wasdControls = document.getElementById('wasdControls');
if (gpsWatchId !== null) {
// Stop tracking
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
if (gpsBackupInterval) {
clearInterval(gpsBackupInterval);
gpsBackupInterval = null;
}
if (gpsMarker) {
gpsMarker.remove(); // MapLibre marker removal
gpsMarker = null;
}
// Remove MapLibre GPS accuracy layers and source (always try, ignore errors)
try {
if (map.getLayer('gps-accuracy-fill')) map.removeLayer('gps-accuracy-fill');
} catch (e) { console.log('Could not remove gps-accuracy-fill:', e.message); }
try {
if (map.getLayer('gps-accuracy-stroke')) map.removeLayer('gps-accuracy-stroke');
} catch (e) { console.log('Could not remove gps-accuracy-stroke:', e.message); }
try {
if (map.getSource('gps-accuracy')) map.removeSource('gps-accuracy');
} catch (e) { console.log('Could not remove gps-accuracy source:', e.message); }
gpsAccuracyCircle = null;
// Sync test position to last known GPS location for seamless manual mode
if (userLocation) {
testPosition = { lat: userLocation.lat, lng: userLocation.lng };
}
gpsFirstFix = true;
if (btn) {
btn.textContent = 'Show My Location';
btn.classList.remove('active');
}
// Update compass button
if (compassBtn) compassBtn.classList.remove('active');
// Show WASD controls when GPS is off (for all users)
if (wasdControls) wasdControls.classList.remove('hidden');
updateStatus('GPS tracking stopped', 'info');
} else {
// Start tracking
if (!navigator.geolocation) {
updateStatus('GPS not supported by this browser', 'error');
return;
}
// Disable test mode when starting real GPS
if (gpsTestMode) {
gpsTestMode = false;
const infoDiv = document.getElementById('gpsTestModeInfo');
if (infoDiv) infoDiv.style.display = 'none';
const toggle = document.getElementById('gpsTestModeToggle');
if (toggle) toggle.checked = false;
}
if (btn) btn.textContent = 'Locating...';
// Update compass button and hide WASD
if (compassBtn) compassBtn.classList.add('active');
if (wasdControls) wasdControls.classList.add('hidden');
updateStatus('Requesting GPS location...', 'info');
console.log('Starting GPS tracking...');
gpsWatchId = navigator.geolocation.watchPosition(
(position) => {
console.log('GPS success:', position);
onGPSSuccess(position);
},
(error) => {
console.error('GPS error:', error);
onGPSError(error);
},
{
enableHighAccuracy: true,
maximumAge: 1000,
timeout: 15000
}
);
console.log('Watch ID:', gpsWatchId);
// Backup: poll for position every 3 seconds in case watchPosition doesn't fire
gpsBackupInterval = setInterval(() => {
console.log('Backup GPS poll...');
navigator.geolocation.getCurrentPosition(
(position) => {
console.log('Backup GPS success:', position);
onGPSSuccess(position);
},
(error) => {
console.log('Backup GPS error (ignored):', error);
}, // Ignore errors on backup poll
{
enableHighAccuracy: true,
maximumAge: 0,
timeout: 5000
}
);
}, adminSettings.gpsPollInterval);
}
}
function onGPSSuccess(position) {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
const accuracy = position.coords.accuracy;
const btn = document.getElementById('gpsBtn');
btn.textContent = 'Stop Tracking';
btn.classList.add('active');
// Store user location for geocache proximity checks
userLocation = { lat, lng, accuracy };
// Sync test position on first fix so manual mode starts from real location
if (gpsFirstFix) {
testPosition = { lat, lng };
}
// Check if dead player has reached home base for respawn
checkHomeBaseRespawn();
// Check for HP/MP regeneration at home base
checkHomeBaseRegen();
// Check for MP regeneration while walking
checkWalkingMpRegen(lat, lng);
// Check for monster clear at home base
checkHomeBaseMonsterClear();
// Update geocache visibility and fog of war based on new location
if (navMode) {
updateGeocacheVisibility();
updateFogOfWar();
}
// Update or create marker (MapLibre version)
if (!gpsMarker) {
const el = document.createElement('div');
el.className = 'custom-div-icon gps-marker-icon';
el.innerHTML = '<i class="mdi mdi-navigation" style="color: #4285f4; font-size: 36px; transform: rotate(' + (currentHeading || 0) + 'deg);"></i>';
el.style.width = '36px';
el.style.height = '36px';
gpsMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
.setLngLat([lng, lat])
.addTo(map);
} else {
gpsMarker.setLngLat([lng, lat]);
// Update rotation if we have heading
if (currentHeading !== null) {
const el = gpsMarker.getElement();
const icon = el.querySelector('.mdi-navigation');
if (icon) {
icon.style.transform = 'rotate(' + currentHeading + 'deg)';
}
}
}
// Update or create accuracy circle (MapLibre GeoJSON version)
const circleGeoJSON = createCircleGeoJSON([lng, lat], accuracy);
const existingSource = map.getSource('gps-accuracy');
if (existingSource) {
// Update existing source
existingSource.setData(circleGeoJSON);
gpsAccuracyCircle = true;
} else {
// Create new source and layers
try {
map.addSource('gps-accuracy', {
type: 'geojson',
data: circleGeoJSON
});
map.addLayer({
id: 'gps-accuracy-fill',
type: 'fill',
source: 'gps-accuracy',
paint: {
'fill-color': '#4285f4',
'fill-opacity': 0.15
}
});
map.addLayer({
id: 'gps-accuracy-stroke',
type: 'line',
source: 'gps-accuracy',
paint: {
'line-color': '#4285f4',
'line-width': 1
}
});
gpsAccuracyCircle = true;
} catch (e) {
console.log('Error creating GPS accuracy circle:', e.message);
}
}
// Check if we're near a track
const distanceToTrack = getDistanceToNearestTrack(lat, lng);
const wasNearTrack = isNearTrack;
isNearTrack = distanceToTrack <= adminSettings.trackProximity;
// Send location to other users with visibility info
sendLocationToServer(lat, lng, accuracy, isNearTrack);
// Check for notification triggers
checkLocationNotifications(lat, lng);
// Check geocache proximity
checkGeocacheProximity();
// Log proximity status change
if (wasNearTrack !== isNearTrack) {
if (isNearTrack) {
console.log('Now visible to other users (near track)');
updateStatus('On track - visible to others', 'info');
} else {
console.log('Now hidden from other users (off track)');
updateStatus('Off track - hidden from others', 'info');
}
}
// Center map on first fix or when auto-center is enabled
if (gpsFirstFix) {
map.setView([lat, lng], 17);
gpsFirstFix = false;
// Keep auto-center on by default
} else if (autoCenterMode) {
map.setView([lat, lng], map.getZoom());
}
// Update navigation if in nav mode
if (navMode && destinationTrack) {
try {
// Check if we're still on the current route or need to recalculate
if (currentRoute && currentRoute.length > 0) {
// Check distance to current route
const currentPos = L.latLng(lat, lng);
const distToRoute = getDistanceToRoute(currentPos, currentRoute);
if (distToRoute > 50) {
// More than 50m off route - recalculate
updateStatus('Recalculating route...', 'info');
updateNavigation();
} else {
// Still on route - just update arrow and distance (lightweight)
updateNavigationLight(currentPos);
}
} else {
// No route yet - calculate it
updateNavigation();
}
} catch (e) {
console.error('Navigation update error:', e);
updateStatus(`GPS: ${lat.toFixed(6)}, ${lng.toFixed(6)}`, 'success');
}
} else {
updateStatus(`GPS: ${lat.toFixed(6)}, ${lng.toFixed(6)}${Math.round(accuracy)}m)`, 'success');
}
}
function onGPSError(error) {
const btn = document.getElementById('gpsBtn');
let message;
switch (error.code) {
case error.PERMISSION_DENIED:
message = 'GPS permission denied';
break;
case error.POSITION_UNAVAILABLE:
message = 'GPS position unavailable';
break;
case error.TIMEOUT:
message = 'GPS request timed out';
break;
default:
message = 'GPS error: ' + error.message;
}
updateStatus(message, 'error');
// Reset if we haven't got a fix yet
if (gpsFirstFix) {
btn.textContent = 'Show My Location';
btn.classList.remove('active');
// Also reset compass button
const compassBtn = document.getElementById('compassBtn');
if (compassBtn) compassBtn.classList.remove('active');
// Show WASD for all users
const wasd = document.getElementById('wasdControls');
if (wasd) wasd.classList.remove('hidden');
if (gpsWatchId !== null) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
}
}
// GPS Test Mode functions (admin only)
function toggleGpsTestMode(enabled) {
// Only allow admins
if (!currentUser || !currentUser.is_admin) {
updateStatus('Admin access required for GPS test mode', 'error');
document.getElementById('gpsTestModeToggle').checked = false;
return;
}
gpsTestMode = enabled;
const infoDiv = document.getElementById('gpsTestModeInfo');
if (enabled) {
// Initialize test position to user's current location if available, else map center
if (userLocation) {
testPosition = { lat: userLocation.lat, lng: userLocation.lng };
} else {
const center = map.getCenter();
testPosition = { lat: center.lat, lng: center.lng };
}
infoDiv.style.display = 'block';
updateTestPositionDisplay();
// Stop real GPS if running
if (gpsWatchId !== null) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
if (gpsBackupInterval) {
clearInterval(gpsBackupInterval);
gpsBackupInterval = null;
}
// Reset WASD state and show controls
resetWasdState();
const wasdControls = document.getElementById('wasdControls');
if (wasdControls) wasdControls.classList.remove('hidden');
// Trigger initial position update
simulateGpsPosition();
updateStatus('GPS Test Mode enabled - use WASD to move', 'info');
} else {
infoDiv.style.display = 'none';
// Hide WASD controls when disabling test mode
const wasdControls = document.getElementById('wasdControls');
if (wasdControls) wasdControls.classList.add('hidden');
// Clean up any active WASD movement state
resetWasdState();
// Restart GPS tracking since user wants to use real GPS
if (gpsWatchId === null) {
toggleGPS();
}
updateStatus('GPS Test Mode disabled - starting GPS', 'info');
}
}
function simulateGpsPosition() {
if (!gpsTestMode) return;
// Create a fake position object matching the geolocation API format
const fakePosition = {
coords: {
latitude: testPosition.lat,
longitude: testPosition.lng,
accuracy: 5 // Very accurate in test mode
}
};
// Call the normal GPS handler
onGPSSuccess(fakePosition);
updateTestPositionDisplay();
}
function updateTestPositionDisplay() {
const display = document.getElementById('testPositionDisplay');
if (display) {
display.textContent = `${testPosition.lat.toFixed(6)}, ${testPosition.lng.toFixed(6)}`;
}
}
function handleGpsTestKeydown(e) {
// Don't handle if typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Only handle WASD keys
const key = e.key.toLowerCase();
if (!['w', 'a', 's', 'd'].includes(key)) return;
// Auto-enable GPS test mode if not already enabled (same as on-screen buttons)
if (!gpsTestMode) {
// Initialize test position to current map center or user location
if (userLocation) {
testPosition = { lat: userLocation.lat, lng: userLocation.lng };
} else {
const center = map.getCenter();
testPosition = { lat: center.lat, lng: center.lng };
}
gpsTestMode = true;
// Stop real GPS
if (gpsWatchId !== null) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
if (gpsBackupInterval) {
clearInterval(gpsBackupInterval);
gpsBackupInterval = null;
}
// Update admin toggle if it exists
const toggle = document.getElementById('gpsTestModeToggle');
if (toggle) toggle.checked = true;
updateStatus('Test mode enabled via keyboard', 'info');
}
// Move in the specified direction, accounting for map rotation
const mapBearing = map.getBearing();
let moveBearing;
switch (key) {
case 'w':
moveBearing = mapBearing; // Forward
break;
case 'd':
moveBearing = mapBearing + 90; // Right
break;
case 's':
moveBearing = mapBearing + 180; // Backward
break;
case 'a':
moveBearing = mapBearing + 270; // Left
break;
}
moveBearing = ((moveBearing % 360) + 360) % 360;
const stepMeters = GPS_TEST_STEP * 111000;
const currentLngLat = [testPosition.lng, testPosition.lat];
const newLngLat = destinationPoint(currentLngLat, stepMeters, moveBearing);
testPosition.lat = newLngLat[1];
testPosition.lng = newLngLat[0];
e.preventDefault();
simulateGpsPosition();
}
// Add keydown listener for GPS test mode
document.addEventListener('keydown', handleGpsTestKeydown);
// WASD control pad button handlers
let wasdMoveInterval = null;
let wasdCurrentDir = null;
function initWasdControls() {
const wasdControls = document.getElementById('wasdControls');
const buttons = wasdControls.querySelectorAll('.wasd-btn');
// Move once in the specified direction
const doMove = (dir) => {
// Auto-enable GPS test mode if not already enabled
if (!gpsTestMode) {
// Initialize test position to current map center or user location
if (userLocation) {
testPosition = { lat: userLocation.lat, lng: userLocation.lng };
} else {
const center = map.getCenter();
testPosition = { lat: center.lat, lng: center.lng };
}
gpsTestMode = true;
// Stop real GPS
if (gpsWatchId !== null) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
if (gpsBackupInterval) {
clearInterval(gpsBackupInterval);
gpsBackupInterval = null;
}
// Update admin toggle if it exists
const toggle = document.getElementById('gpsTestModeToggle');
if (toggle) toggle.checked = true;
// Force auto-center ON when entering test mode
if (!autoCenterMode) {
autoCenterMode = true;
const btn = document.getElementById('autoCenterBtn');
if (btn) {
btn.textContent = 'Auto-Center: ON';
btn.classList.add('active');
}
}
updateStatus('Test mode enabled via controls', 'info');
}
// Move in the specified direction, accounting for map rotation
// Get current map bearing (0 = north up, 90 = east up, etc.)
const mapBearing = map.getBearing();
// Calculate movement bearing based on key pressed
// W = forward (map bearing), D = right (+90), S = back (+180), A = left (+270)
let moveBearing;
switch (dir) {
case 'w':
moveBearing = mapBearing; // Forward (top of screen)
break;
case 'd':
moveBearing = mapBearing + 90; // Right
break;
case 's':
moveBearing = mapBearing + 180; // Backward (bottom of screen)
break;
case 'a':
moveBearing = mapBearing + 270; // Left
break;
}
// Normalize bearing to 0-360
moveBearing = ((moveBearing % 360) + 360) % 360;
// Use destinationPoint to calculate new position
// GPS_TEST_STEP is in degrees, convert to approximate meters (1 degree ≈ 111000m)
const stepMeters = GPS_TEST_STEP * 111000;
const currentLngLat = [testPosition.lng, testPosition.lat];
const newLngLat = destinationPoint(currentLngLat, stepMeters, moveBearing);
testPosition.lat = newLngLat[1];
testPosition.lng = newLngLat[0];
simulateGpsPosition();
};
// Start moving (on press/touch start)
const startMove = (e, dir) => {
e.preventDefault();
e.stopPropagation();
// Move immediately
doMove(dir);
// Start repeating after a short delay
wasdCurrentDir = dir;
if (wasdMoveInterval) clearInterval(wasdMoveInterval);
wasdMoveInterval = setInterval(() => {
if (wasdCurrentDir) {
doMove(wasdCurrentDir);
}
}, 100); // Move every 100ms while held
};
// Stop moving (on release)
const stopMove = () => {
wasdCurrentDir = null;
if (wasdMoveInterval) {
clearInterval(wasdMoveInterval);
wasdMoveInterval = null;
}
};
// Use Pointer Events for unified touch/mouse handling
buttons.forEach(btn => {
const dir = btn.dataset.dir;
let isPressed = false;
btn.addEventListener('pointerdown', (e) => {
e.preventDefault();
e.stopPropagation();
isPressed = true;
btn.setPointerCapture(e.pointerId);
startMove(e, dir);
});
btn.addEventListener('pointerup', (e) => {
e.preventDefault();
e.stopPropagation();
if (isPressed) {
isPressed = false;
btn.releasePointerCapture(e.pointerId);
stopMove();
}
});
btn.addEventListener('pointercancel', (e) => {
if (isPressed) {
isPressed = false;
btn.releasePointerCapture(e.pointerId);
stopMove();
}
});
// Handle lost pointer capture (e.g., when element is hidden)
btn.addEventListener('lostpointercapture', (e) => {
if (isPressed) {
isPressed = false;
stopMove();
}
});
// Handle pointer leaving button area
btn.addEventListener('pointerleave', (e) => {
// Only stop if we have capture (otherwise normal hover exit)
if (isPressed && !btn.hasPointerCapture(e.pointerId)) {
isPressed = false;
stopMove();
}
});
// Prevent context menu on long press
btn.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
});
}
// Function to reset WASD state (call when showing/hiding controls)
function resetWasdState() {
if (wasdMoveInterval) {
clearInterval(wasdMoveInterval);
wasdMoveInterval = null;
}
wasdCurrentDir = null;
}
// Initialize WASD controls after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWasdControls);
} else {
initWasdControls();
}
// Navigation functions
function setDestination(track, index) {
// Remove old pin if exists
if (destinationPin) {
map.removeLayer(destinationPin);
}
destinationTrack = track;
destinationIndex = index;
// Create destination pin marker
destinationPin = L.marker(track.coords[index], {
icon: L.divIcon({
className: 'destination-pin',
html: '📍',
iconSize: [24, 24],
iconAnchor: [12, 24]
})
}).addTo(map);
// Show nav info panel
document.getElementById('navInfo').style.display = 'block';
// Save destination to localStorage
saveDestination();
updateNavigation();
updateStatus(`Destination set on "${track.name}"`, 'success');
}
function saveDestination() {
if (destinationTrack && destinationIndex !== null) {
localStorage.setItem('navDestination', JSON.stringify({
trackId: destinationTrack.id,
trackName: destinationTrack.name,
index: destinationIndex,
coord: destinationTrack.coords[destinationIndex]
}));
}
}
function restoreDestination() {
const saved = localStorage.getItem('navDestination');
if (!saved) return;
try {
const data = JSON.parse(saved);
// Find the track by ID or name
let track = tracks.find(t => t.id === data.trackId);
if (!track) {
track = tracks.find(t => t.name === data.trackName);
}
if (track) {
// Find closest point to saved coordinate (in case track was edited)
let bestIndex = data.index;
if (bestIndex >= track.coords.length) {
bestIndex = track.coords.length - 1;
}
// Try to find exact or nearest point
if (data.coord) {
let minDist = Infinity;
for (let i = 0; i < track.coords.length; i++) {
const dist = Math.abs(track.coords[i][0] - data.coord[0]) +
Math.abs(track.coords[i][1] - data.coord[1]);
if (dist < minDist) {
minDist = dist;
bestIndex = i;
}
}
}
setDestination(track, bestIndex);
updateStatus(`Restored destination on "${track.name}"`, 'info');
}
} catch (e) {
console.log('Could not restore destination:', e);
localStorage.removeItem('navDestination');
}
}
function clearDestination() {
if (destinationPin) {
map.removeLayer(destinationPin);
destinationPin = null;
}
if (directionArrow) {
map.removeLayer(directionArrow);
directionArrow = null;
}
clearRouteHighlight();
destinationTrack = null;
// Clear from localStorage
localStorage.removeItem('navDestination');
destinationIndex = null;
currentClosestIndex = null;
currentRoute = null;
// Clear saved destination
localStorage.removeItem('navDestination');
document.getElementById('navInfo').style.display = 'none';
document.getElementById('navDistance').textContent = '--';
updateStatus('Destination cleared', 'info');
}
function findClosestPointOnTrack(track, latlng) {
let minDist = Infinity;
let closestIndex = 0;
for (let i = 0; i < track.coords.length; i++) {
const dist = map.distance(latlng, L.latLng(track.coords[i]));
if (dist < minDist) {
minDist = dist;
closestIndex = i;
}
}
return closestIndex;
}
function calculateTrackDistance(track, fromIndex, toIndex) {
let distance = 0;
const start = Math.min(fromIndex, toIndex);
const end = Math.max(fromIndex, toIndex);
for (let i = start; i < end; i++) {
distance += map.distance(
L.latLng(track.coords[i]),
L.latLng(track.coords[i + 1])
);
}
return distance;
}
function updateNavigation() {
if (!destinationTrack || !gpsMarker) return;
const currentPos = gpsMarker.getLatLng();
// Check if arrived (within 10 meters of destination)
const distToDestination = map.distance(currentPos, L.latLng(destinationTrack.coords[destinationIndex]));
if (distToDestination < 10) {
clearRouteHighlight();
updateDirectionArrow(currentPos, []);
document.getElementById('routeOverlay').style.display = 'none';
updateStatus('You have arrived!', 'success');
return;
}
// Find the closest point on ANY track to current position
let closestTrack = null;
let closestIndex = 0;
let minDist = Infinity;
for (const track of tracks) {
for (let i = 0; i < track.coords.length; i++) {
const dist = map.distance(currentPos, L.latLng(track.coords[i]));
if (dist < minDist) {
minDist = dist;
closestTrack = track;
closestIndex = i;
}
}
}
if (!closestTrack) {
document.getElementById('routeOverlay').style.display = 'none';
updateStatus('No trails nearby', 'error');
return;
}
// If far from any trail, show direct arrow to nearest trail
const ON_TRAIL_THRESHOLD = adminSettings.onTrailThreshold; // from admin settings
if (minDist > ON_TRAIL_THRESHOLD) {
clearRouteHighlight();
updateDirectionArrow(currentPos, [closestTrack.coords[closestIndex]], '#3388ff');
document.getElementById('routeOverlay').style.display = 'none';
updateStatus(`${Math.round(minDist)}m to nearest trail`, 'info');
return;
}
// Show loading indicator and defer pathfinding to allow UI update
ensurePopupInBody('routeOverlay');
document.getElementById('routeOverlay').style.display = 'flex';
document.getElementById('navInfo').style.display = 'none';
setTimeout(() => {
// Find path from current position to destination through trail network
const pathResult = findShortestPath(closestTrack, closestIndex, destinationTrack, destinationIndex);
document.getElementById('routeOverlay').style.display = 'none';
document.getElementById('navInfo').style.display = 'block';
finishNavUpdate(currentPos, pathResult);
}, 10);
}
function finishNavUpdate(currentPos, pathResult) {
if (!pathResult) {
// No path found - trails not connected
clearRouteHighlight();
updateDirectionArrow(currentPos, [destinationTrack.coords[destinationIndex]], '#ff0000');
updateStatus('No connected path to destination', 'error');
return;
}
// Convert path to route segments
currentRoute = pathToRouteSegments(pathResult.path);
// Update distance display
const distanceText = pathResult.totalDistance < 1000
? `${Math.round(pathResult.totalDistance)}m`
: `${(pathResult.totalDistance / 1000).toFixed(2)}km`;
document.getElementById('navDistance').textContent = distanceText;
// Get route coordinates and display
const routeCoords = getRouteCoordinates(currentRoute);
updateRouteHighlight(routeCoords);
// Show direction arrow for next few points
const arrowPoints = routeCoords.slice(0, Math.min(6, routeCoords.length));
updateDirectionArrow(currentPos, arrowPoints, '#ff6600');
// Status message
if (currentRoute.length > 1) {
const nextTrack = currentRoute[1] ? currentRoute[1].track.name : '';
updateStatus(`${distanceText} via ${currentRoute.length} trails`, 'info');
} else {
updateStatus(`${distanceText} remaining`, 'info');
}
}
// Check how far user is from the current route
function getDistanceToRoute(currentPos, route) {
if (!route || route.length === 0) return Infinity;
let minDist = Infinity;
for (const segment of route) {
const start = Math.min(segment.fromIndex, segment.toIndex);
const end = Math.max(segment.fromIndex, segment.toIndex);
for (let i = start; i <= end; i++) {
const dist = map.distance(currentPos, L.latLng(segment.track.coords[i]));
if (dist < minDist) {
minDist = dist;
}
}
}
return minDist;
}
// Lightweight navigation update - just update arrow and remaining distance
function updateNavigationLight(currentPos) {
if (!currentRoute || currentRoute.length === 0) return;
// Check if arrived
const distToDestination = map.distance(currentPos, L.latLng(destinationTrack.coords[destinationIndex]));
if (distToDestination < 10) {
clearRouteHighlight();
updateDirectionArrow(currentPos, []);
updateStatus('You have arrived!', 'success');
return;
}
// Find where we are on the route and calculate remaining distance
const routeCoords = getRouteCoordinates(currentRoute);
let closestIdx = 0;
let minDist = Infinity;
for (let i = 0; i < routeCoords.length; i++) {
const dist = map.distance(currentPos, L.latLng(routeCoords[i]));
if (dist < minDist) {
minDist = dist;
closestIdx = i;
}
}
// Calculate remaining distance from current position
let remainingDist = minDist; // Distance to route
for (let i = closestIdx; i < routeCoords.length - 1; i++) {
remainingDist += map.distance(L.latLng(routeCoords[i]), L.latLng(routeCoords[i + 1]));
}
// Update distance display
const distanceText = remainingDist < 1000
? `${Math.round(remainingDist)}m`
: `${(remainingDist / 1000).toFixed(2)}km`;
document.getElementById('navDistance').textContent = distanceText;
// Update route highlight to only show remaining path (trim behind user)
const remainingRoute = routeCoords.slice(closestIdx);
updateRouteHighlight(remainingRoute);
// Update direction arrow to point along route from current position
const arrowPoints = routeCoords.slice(closestIdx, Math.min(closestIdx + 6, routeCoords.length));
updateDirectionArrow(currentPos, arrowPoints, '#ff6600');
// Status message
if (currentRoute.length > 1) {
updateStatus(`${distanceText} via ${currentRoute.length} trails`, 'info');
} else {
updateStatus(`${distanceText} remaining`, 'info');
}
// Update map rotation if enabled
if (rotateMapMode && arrowPoints.length >= 2) {
const bearing = calculateBearing(
arrowPoints[0][0], arrowPoints[0][1],
arrowPoints[1][0], arrowPoints[1][1]
);
rotateMap(bearing);
}
}
// Calculate bearing between two points (in degrees, 0 = North)
function calculateBearing(lat1, lng1, lat2, lng2) {
const dLng = (lng2 - lng1) * Math.PI / 180;
const lat1Rad = lat1 * Math.PI / 180;
const lat2Rad = lat2 * Math.PI / 180;
const y = Math.sin(dLng) * Math.cos(lat2Rad);
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng);
let bearing = Math.atan2(y, x) * 180 / Math.PI;
return (bearing + 360) % 360; // Normalize to 0-360
}
// Update GPS marker to show direction of travel
function rotateMap(bearing) {
currentBearing = bearing;
// Rotate the map using leaflet-rotate plugin
// Negate bearing so direction of travel points UP
map.setBearing(-bearing);
// Update compass indicator
const compass = document.getElementById('compassIndicator');
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const index = Math.round(bearing / 45) % 8;
compass.textContent = `Heading: ${directions[index]} ${Math.round(bearing)}°`;
}
// Reset map rotation
function resetMapRotation() {
currentBearing = 0;
map.setBearing(0);
document.getElementById('compassIndicator').classList.remove('active');
}
// Toggle rotate mode
function toggleRotateMap() {
rotateMapMode = !rotateMapMode;
const btn = document.getElementById('rotateMapBtn');
if (rotateMapMode) {
btn.textContent = 'Rotate Map: ON';
btn.classList.add('active');
document.getElementById('compassIndicator').classList.add('active');
} else {
btn.textContent = 'Rotate Map: OFF';
btn.classList.remove('active');
resetMapRotation();
}
}
function toggleAutoCenter() {
autoCenterMode = !autoCenterMode;
const btn = document.getElementById('autoCenterBtn');
if (autoCenterMode) {
btn.textContent = 'Auto-Center: ON';
btn.classList.add('active');
// Immediately center on current GPS position if available
if (gpsMarker) {
map.setView(gpsMarker.getLatLng(), map.getZoom());
}
} else {
btn.textContent = 'Auto-Center: OFF';
btn.classList.remove('active');
}
}
// Admin Settings Functions
function loadAdminSettings() {
const saved = localStorage.getItem('adminSettings');
if (saved) {
try {
adminSettings = { ...adminSettings, ...JSON.parse(saved) };
} catch (e) {
console.error('Error loading admin settings:', e);
}
}
applyAdminSettings();
}
function saveAdminSettings() {
localStorage.setItem('adminSettings', JSON.stringify(adminSettings));
}
function applyAdminSettings() {
// Update UI elements if they exist
if (document.getElementById('geocacheRange')) {
document.getElementById('geocacheRange').value = adminSettings.geocacheRange;
document.getElementById('geocacheAlertRange').value = adminSettings.geocacheAlertRange;
document.getElementById('geocacheSoundEnabled').checked = adminSettings.geocacheSoundEnabled !== false;
document.getElementById('trackProximity').value = adminSettings.trackProximity;
document.getElementById('intersectionThreshold').value = adminSettings.intersectionThreshold;
document.getElementById('onTrailThreshold').value = adminSettings.onTrailThreshold;
document.getElementById('nodeSpacing').value = adminSettings.nodeSpacing;
document.getElementById('gpsPollInterval').value = adminSettings.gpsPollInterval;
document.getElementById('proximityCheckInterval').value = adminSettings.proximityCheckInterval;
}
}
// Debounce function for saving
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Show save indicator
function showSaveIndicator(message = 'Settings Saved ✓') {
const indicator = document.getElementById('adminSaveIndicator');
if (indicator) {
indicator.textContent = message;
indicator.classList.add('show');
setTimeout(() => {
indicator.classList.remove('show');
}, 2000);
}
}
// Debounced save function
const debouncedSave = debounce(() => {
saveAdminSettings();
showSaveIndicator();
}, 500);
function setupAdminInputListeners() {
// Add change and input listeners to all admin inputs
const adminInputs = [
{ id: 'geocacheRange', setting: 'geocacheRange', type: 'number' },
{ id: 'geocacheAlertRange', setting: 'geocacheAlertRange', type: 'number' },
{ id: 'geocacheSoundEnabled', setting: 'geocacheSoundEnabled', type: 'checkbox' },
{ id: 'trackProximity', setting: 'trackProximity', type: 'number' },
{ id: 'intersectionThreshold', setting: 'intersectionThreshold', type: 'number' },
{ id: 'onTrailThreshold', setting: 'onTrailThreshold', type: 'number' },
{ id: 'nodeSpacing', setting: 'nodeSpacing', type: 'number' },
{ id: 'gpsPollInterval', setting: 'gpsPollInterval', type: 'number' },
{ id: 'proximityCheckInterval', setting: 'proximityCheckInterval', type: 'number' }
];
adminInputs.forEach(input => {
const element = document.getElementById(input.id);
if (element) {
// Use both input and change events for better responsiveness
const updateHandler = function() {
let value;
if (input.type === 'checkbox') {
value = this.checked;
} else if (input.type === 'number') {
value = parseFloat(this.value);
} else {
value = this.value;
}
adminSettings[input.setting] = value;
debouncedSave();
};
if (input.type === 'checkbox') {
element.addEventListener('change', updateHandler);
} else {
element.addEventListener('input', updateHandler);
element.addEventListener('change', updateHandler);
}
}
});
// GPS Test Mode toggle (not a saved setting)
const gpsTestToggle = document.getElementById('gpsTestModeToggle');
if (gpsTestToggle) {
gpsTestToggle.addEventListener('change', function() {
toggleGpsTestMode(this.checked);
});
}
// Setup collapsible sections
document.querySelectorAll('.section.collapsible').forEach(section => {
const title = section.querySelector('.section-title');
if (title) {
title.addEventListener('click', () => {
section.classList.toggle('collapsed');
// Save collapsed state
const sectionName = section.dataset.section;
if (sectionName) {
const collapsedSections = JSON.parse(localStorage.getItem('collapsedSections') || '{}');
collapsedSections[sectionName] = section.classList.contains('collapsed');
localStorage.setItem('collapsedSections', JSON.stringify(collapsedSections));
}
});
// Restore collapsed state
const sectionName = section.dataset.section;
if (sectionName) {
const collapsedSections = JSON.parse(localStorage.getItem('collapsedSections') || '{}');
if (collapsedSections[sectionName]) {
section.classList.add('collapsed');
}
}
}
});
}
function resetAdminSettings() {
if (confirm('Reset all settings to defaults?')) {
adminSettings = {
geocacheRange: 5,
geocacheAlertRange: 5,
geocacheSoundEnabled: true,
trackProximity: 50,
intersectionThreshold: 15,
onTrailThreshold: 100,
nodeSpacing: 50,
gpsPollInterval: 3000,
proximityCheckInterval: 3000,
snapDistancePx: 15
};
saveAdminSettings();
applyAdminSettings();
}
}
function exportAdminSettings() {
const dataStr = JSON.stringify(adminSettings, null, 2);
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
const exportFileDefaultName = 'hikemap-settings.json';
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
}
function importAdminSettings() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = e => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = event => {
try {
const imported = JSON.parse(event.target.result);
adminSettings = { ...adminSettings, ...imported };
saveAdminSettings();
applyAdminSettings();
alert('Settings imported successfully');
} catch (error) {
alert('Error importing settings: ' + error.message);
}
};
reader.readAsText(file);
};
input.click();
}
// Icon selection
const availableIcons = [
{ icon: 'mdi-walk', name: 'Walking', color: '#ff6b6b' },
{ icon: 'mdi-bike', name: 'Biking', color: '#4ecdc4' },
{ icon: 'mdi-run', name: 'Running', color: '#45b7d1' },
{ icon: 'mdi-hiking', name: 'Hiking', color: '#f7b731' },
{ icon: 'mdi-ski', name: 'Skiing', color: '#5f27cd' },
{ icon: 'mdi-skateboard', name: 'Skateboarding', color: '#00d2d3' },
{ icon: 'mdi-wheelchair', name: 'Wheelchair', color: '#fd79a8' },
{ icon: 'mdi-car', name: 'Driving', color: '#636e72' },
{ icon: 'mdi-motorbike', name: 'Motorcycling', color: '#e17055' },
{ icon: 'mdi-horse', name: 'Horseback', color: '#a29bfe' }
];
function showIconSelector() {
let selector = document.getElementById('iconSelector');
if (!selector) {
console.error('Icon selector element not found!');
return;
}
// CRITICAL: Move popup to body if it's inside another container
if (selector.parentElement !== document.body) {
console.log('Moving icon selector to body from:', selector.parentElement.id || selector.parentElement.className);
document.body.appendChild(selector);
}
const iconGrid = document.getElementById('iconGrid');
iconGrid.innerHTML = '';
availableIcons.forEach((iconConfig, index) => {
const iconDiv = document.createElement('div');
iconDiv.className = 'icon-option';
iconDiv.innerHTML = `
<i class="mdi ${iconConfig.icon}" style="color: ${iconConfig.color};"></i>
<span>${iconConfig.name}</span>
`;
iconDiv.onclick = () => selectIcon(iconConfig);
iconGrid.appendChild(iconDiv);
});
// Force display and ensure it's visible
selector.style.display = 'flex';
selector.style.visibility = 'visible';
selector.style.opacity = '1';
selector.style.zIndex = '999999';
document.body.style.overflow = 'visible';
// Force a reflow to ensure rendering
selector.offsetHeight;
console.log('Icon selector parent:', selector.parentElement.tagName,
'Display:', selector.style.display,
'Computed:', window.getComputedStyle(selector).display,
'Position:', selector.getBoundingClientRect());
}
function selectIcon(iconConfig) {
myIcon = iconConfig.icon;
myColor = iconConfig.color;
// Store in localStorage
localStorage.setItem('userIcon', myIcon);
localStorage.setItem('userColor', myColor);
document.getElementById('iconSelector').style.display = 'none';
// Send icon info to server if connected
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'iconUpdate',
icon: myIcon,
color: myColor
}));
}
}
function loadUserIcon() {
myIcon = localStorage.getItem('userIcon');
myColor = localStorage.getItem('userColor');
// Note: Icon selector now accessible from profile, not auto-shown on load
// Users can set their icon after logging in or from the profile section
}
// Calculate minimum distance from a point to any track
function getDistanceToNearestTrack(lat, lng) {
if (tracks.length === 0) return Infinity;
let minDistance = Infinity;
const point = L.latLng(lat, lng);
for (const track of tracks) {
for (let i = 0; i < track.coords.length - 1; i++) {
const p1 = L.latLng(track.coords[i]);
const p2 = L.latLng(track.coords[i + 1]);
// Calculate distance from point to line segment
const distance = distanceToLineSegment(point, p1, p2);
if (distance < minDistance) {
minDistance = distance;
}
}
}
return minDistance;
}
// Calculate distance from point to line segment
function distanceToLineSegment(point, lineStart, lineEnd) {
const x = point.lat;
const y = point.lng;
const x1 = lineStart.lat;
const y1 = lineStart.lng;
const x2 = lineEnd.lat;
const y2 = lineEnd.lng;
const A = x - x1;
const B = y - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) {
param = dot / lenSq;
}
let xx, yy;
if (param < 0) {
xx = x1;
yy = y1;
} else if (param > 1) {
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = x - xx;
const dy = y - yy;
// Return distance in meters
return L.latLng(x, y).distanceTo(L.latLng(xx, yy));
}
// Geocache Functions
function generateGeocacheId() {
return 'gc_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function shouldShowGeocache(geocache) {
// In edit mode, always show all geocaches
if (!navMode) return true;
// Check fog of war - cache must be in revealed area
if (!isInRevealedArea(geocache.lat, geocache.lng)) {
return false;
}
// Determine visibility distance: OSM tag setting takes precedence over per-cache setting
let visibilityDist = 0;
// Check OSM tag configuration first (this is the admin-controlled setting)
if (geocache.tags && geocache.tags.length > 0) {
const tagConfig = OSM_TAGS[geocache.tags[0]];
if (tagConfig && tagConfig.visibilityDistance) {
visibilityDist = tagConfig.visibilityDistance;
}
}
// Fall back to per-cache setting if OSM tag doesn't have one
if (!visibilityDist && geocache.visibilityDistance) {
visibilityDist = geocache.visibilityDistance;
}
// If no visibility restriction from either source, always show
if (!visibilityDist || visibilityDist === 0) return true;
// In nav mode, only show if user is within visibility distance
if (!userLocation) return false;
const distance = L.latLng(userLocation.lat, userLocation.lng)
.distanceTo(L.latLng(geocache.lat, geocache.lng));
return distance <= visibilityDist;
}
function placeGeocache(latlng) {
const id = generateGeocacheId();
const geocache = {
id: id,
lat: latlng.lat,
lng: latlng.lng,
title: '', // Will be set when user submits
icon: 'package-variant', // Default icon
color: '#FFA726', // Default orange color
visibilityDistance: 0, // 0 = always visible, >0 = only visible within that distance
messages: [],
createdAt: Date.now()
};
geocaches.push(geocache);
createGeocacheMarker(geocache);
showGeocacheDialog(geocache, true); // true = can add message immediately
}
// Check if a geocache has any tags with prefixes configured
function geocacheHasPrefixes(geocache) {
if (!geocache.tags || geocache.tags.length === 0) return false;
for (const tagId of geocache.tags) {
const tagConfig = OSM_TAGS[tagId];
if (tagConfig && tagConfig.prefixes && tagConfig.prefixes.length > 0) {
return true;
}
}
return false;
}
function createGeocacheMarker(geocache) {
// Only show geocaches that have tags with prefixes configured
if (!geocacheHasPrefixes(geocache)) {
return; // Skip caches without any prefix-enabled tags
}
// Check visibility based on mode and distance
if (!shouldShowGeocache(geocache)) {
console.log(`Geocache ${geocache.id} not visible due to distance restriction`);
return;
}
console.log(`Creating geocache marker for ${geocache.id} at ${geocache.lat}, ${geocache.lng}`);
// Get artwork number and animations from the first OSM tag
let artworkNum = 1;
let animation = null;
let animationShadow = null;
if (geocache.tags && geocache.tags.length > 0) {
const tagConfig = OSM_TAGS[geocache.tags[0]];
if (tagConfig) {
artworkNum = tagConfig.artwork || 1;
animation = tagConfig.animation || null;
animationShadow = tagConfig.animationShadow || null;
}
}
// Build PNG icon with shadow layer
const basePath = 'mapgameimgs/cacheicons/cacheIcon100-';
const padNum = String(artworkNum).padStart(2, '0');
const mainSrc = `${basePath}${padNum}.png`;
const shadowSrc = `${basePath}${padNum}_shadow.png`;
// In edit mode, make secret caches slightly transparent
const opacity = (!navMode && geocache.visibilityDistance > 0) ? 0.7 : 1.0;
// Animation classes for main and shadow layers (independent)
const mainAnimClass = animation ? `cache-anim-${animation}` : '';
const shadowAnimClass = animationShadow ? `cache-anim-${animationShadow}` : '';
const iconHtml = `
<div class="cache-icon-container" style="opacity: ${opacity};">
<img src="${shadowSrc}" class="cache-shadow ${shadowAnimClass}" onerror="this.style.display='none'">
<img src="${mainSrc}" class="cache-main ${mainAnimClass}">
</div>
`;
const marker = L.marker([geocache.lat, geocache.lng], {
icon: L.divIcon({
className: 'geocache-marker-png',
html: iconHtml,
iconSize: [64, 64],
iconAnchor: [32, 32] // Centered for intuitive mobile tapping
}),
zIndexOffset: 1000 // Ensure geocaches appear above GPS markers
});
marker.geocacheId = geocache.id;
marker._isInRange = false; // Initialize animation state tracking
marker.on('click', function(e) {
L.DomEvent.stopPropagation(e);
L.DomEvent.preventDefault(e);
console.log('Geocache clicked, marker still exists:', !!geocacheMarkers[geocache.id]);
const gc = geocaches.find(g => g.id === geocache.id);
if (gc) {
showGeocacheDialog(gc, false);
// Check marker after dialog shown
setTimeout(() => {
console.log('After dialog, marker exists:', !!geocacheMarkers[geocache.id]);
if (geocacheMarkers[geocache.id] && geocacheMarkers[geocache.id]._icon) {
const icon = geocacheMarkers[geocache.id]._icon;
console.log('Icon visibility:', icon.style.display, icon.style.visibility, icon.style.opacity);
console.log('Icon className:', icon.className);
console.log('Icon innerHTML:', icon.innerHTML);
}
}, 100);
}
return false;
});
marker.addTo(map);
geocacheMarkers[geocache.id] = marker;
console.log(`Geocache marker added to map. Total markers: ${Object.keys(geocacheMarkers).length}`);
// Check if marker is actually visible after adding
setTimeout(() => {
if (marker._icon) {
console.log(`Marker ${geocache.id} icon after add:`, {
exists: true,
className: marker._icon.className,
display: marker._icon.style.display,
visibility: marker._icon.style.visibility,
innerHTML: marker._icon.innerHTML.substring(0, 50) + '...'
});
} else {
console.log(`WARNING: Marker ${geocache.id} has no _icon after adding!`);
}
}, 100);
}
// Helper function to ensure popups are in body
function ensurePopupInBody(elementId) {
const element = document.getElementById(elementId);
if (element && element.parentElement !== document.body) {
console.log(`Moving ${elementId} to body from:`, element.parentElement.id || element.parentElement.className);
document.body.appendChild(element);
}
return element;
}
function showGeocacheDialog(geocache, isNew = false) {
// In nav mode, check if user is close enough to view/interact
let userDistance = Infinity;
if (navMode && userLocation) {
userDistance = L.latLng(userLocation.lat, userLocation.lng)
.distanceTo(L.latLng(geocache.lat, geocache.lng));
}
currentGeocache = geocache;
// Ensure dialog is in body
const dialog = ensurePopupInBody('geocacheDialog');
const messagesDiv = document.getElementById('geocacheMessages');
const deleteBtn = document.getElementById('geocacheDelete');
const submitBtn = document.getElementById('geocacheSubmit');
const form = document.getElementById('geocacheForm');
const titleGroup = document.getElementById('geocacheTitleGroup');
const iconGroup = document.getElementById('geocacheIconGroup');
const colorGroup = document.getElementById('geocacheColorGroup');
const visibilityGroup = document.getElementById('geocacheVisibilityGroup');
const titleInput = document.getElementById('geocacheTitleInput');
const iconInput = document.getElementById('geocacheIconInput');
const colorInput = document.getElementById('geocacheColorInput');
const visibilityInput = document.getElementById('geocacheVisibilityInput');
const dialogTitle = document.getElementById('geocacheTitle');
const editBtn = document.getElementById('geocacheEdit');
// Clear previous messages
messagesDiv.innerHTML = '';
// Update dialog title
dialogTitle.textContent = geocache.title ? `📍 ${geocache.title}` : '📍 Geocache';
// Show/hide creation fields for new geocaches or edit mode
const isEditing = currentGeocacheEditMode === true;
if ((isNew && !geocache.title) || isEditing) {
titleGroup.style.display = 'block';
iconGroup.style.display = 'block';
colorGroup.style.display = 'block';
visibilityGroup.style.display = 'block';
titleInput.value = isEditing ? geocache.title : '';
iconInput.value = geocache.icon || 'package-variant';
colorInput.value = geocache.color || '#FFA726';
visibilityInput.value = geocache.visibilityDistance || 0;
// Update color preview
updateColorPreview();
} else {
titleGroup.style.display = 'none';
iconGroup.style.display = 'none';
colorGroup.style.display = 'none';
visibilityGroup.style.display = 'none';
}
// Show edit button only in edit mode and for existing caches
editBtn.style.display = (!navMode && geocache.title && !isEditing) ? 'block' : 'none';
// Check if user can view messages (within 5m in nav mode, or in edit mode)
const canViewMessages = !navMode || userDistance <= adminSettings.geocacheRange;
if (canViewMessages) {
// Mark as read when viewing messages
if (!readGeocaches.includes(geocache.id)) {
readGeocaches.push(geocache.id);
localStorage.setItem('readGeocaches', JSON.stringify(readGeocaches));
// Update marker icon
updateGeocacheMarkerIcon(geocache.id, true);
}
// Show messages
if (geocache.messages && geocache.messages.length > 0) {
geocache.messages.forEach(msg => {
const msgDiv = document.createElement('div');
msgDiv.className = 'geocache-message';
const headerDiv = document.createElement('div');
headerDiv.className = 'geocache-message-header';
const authorSpan = document.createElement('span');
authorSpan.className = 'geocache-message-author';
authorSpan.textContent = msg.author || 'Anonymous';
const timestampSpan = document.createElement('span');
const date = new Date(msg.timestamp);
timestampSpan.textContent = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
headerDiv.appendChild(authorSpan);
headerDiv.appendChild(timestampSpan);
const textDiv = document.createElement('div');
textDiv.className = 'geocache-message-text';
textDiv.textContent = msg.text;
msgDiv.appendChild(headerDiv);
msgDiv.appendChild(textDiv);
messagesDiv.appendChild(msgDiv);
});
} else {
messagesDiv.innerHTML = '<p style="text-align: center; color: #999;">No messages yet. Be the first to leave one!</p>';
}
} else {
// User is too far away - show distance message
const distanceText = Math.round(userDistance);
messagesDiv.innerHTML = `<p style="text-align: center; color: #ff9800;">📍 You are ${distanceText} meters away.<br><br>Get within 5 meters to view and sign this geocache!</p>`;
}
// Show delete button only in edit mode
deleteBtn.style.display = !navMode ? 'block' : 'none';
// In nav mode, only show form if user is within 5m or if in edit mode
const canAddMessage = !navMode || userDistance <= adminSettings.geocacheRange;
if (canAddMessage) {
form.style.display = 'block';
submitBtn.style.display = 'block';
// Clear input fields
document.getElementById('geocacheName').value = '';
document.getElementById('geocacheMessage').value = '';
} else {
form.style.display = 'none';
submitBtn.style.display = 'none';
}
// Show dialog
dialog.style.display = 'flex';
}
function hideGeocacheDialog() {
document.getElementById('geocacheDialog').style.display = 'none';
currentGeocache = null;
currentGeocacheEditMode = false;
}
function updateColorPreview() {
const iconInput = document.getElementById('geocacheIconInput');
const colorInput = document.getElementById('geocacheColorInput');
const preview = document.getElementById('geocacheColorPreview');
const icon = iconInput.value || 'package-variant';
const color = colorInput.value || '#FFA726';
preview.innerHTML = `<i class="mdi mdi-${icon}" style="color: ${color};"></i>`;
}
function startEditingGeocache() {
if (!currentGeocache || !currentGeocache.title) return;
currentGeocacheEditMode = true;
showGeocacheDialog(currentGeocache, false);
}
function updateGeocacheList() {
const content = document.getElementById('geocacheListContent');
content.innerHTML = '';
if (geocaches.length === 0) {
content.innerHTML = '<p style="color: #aaa; text-align: center;">No geocaches placed yet</p>';
return;
}
geocaches.forEach(cache => {
const div = document.createElement('div');
div.className = 'geocache-list-item';
const titleDiv = document.createElement('div');
titleDiv.className = 'geocache-list-item-title';
titleDiv.innerHTML = `
<i class="mdi mdi-${cache.icon || 'package-variant'}" style="color: ${cache.color || '#FFA726'};"></i>
<span>${cache.title || 'Untitled Cache'}</span>
${cache.visibilityDistance > 0 ? '<span class="geocache-list-item-secret">SECRET</span>' : ''}
`;
const infoDiv = document.createElement('div');
infoDiv.className = 'geocache-list-item-info';
const messageCount = cache.messages ? cache.messages.length : 0;
const createdDate = new Date(cache.createdAt).toLocaleDateString();
infoDiv.innerHTML = `
${messageCount} message${messageCount !== 1 ? 's' : ''} • Created ${createdDate}
${cache.visibilityDistance > 0 ? `<br>Visible within ${cache.visibilityDistance}m` : ''}
`;
div.appendChild(titleDiv);
div.appendChild(infoDiv);
// Click to go to cache
div.addEventListener('click', () => {
map.setView([cache.lat, cache.lng], 16);
showGeocacheDialog(cache, false);
document.getElementById('geocacheListSidebar').classList.remove('open');
});
content.appendChild(div);
});
}
function updateGeocacheVisibility() {
// Update visibility of all geocache markers based on current user location
geocaches.forEach(cache => {
// Only consider caches that have tags with prefixes configured
const hasPrefixes = geocacheHasPrefixes(cache);
const shouldShow = hasPrefixes && shouldShowGeocache(cache);
const marker = geocacheMarkers[cache.id];
if (shouldShow && !marker) {
// Create marker if it should be visible but doesn't exist
createGeocacheMarker(cache);
} else if (!shouldShow && marker) {
// Remove marker if it shouldn't be visible (or lost its prefixes)
map.removeLayer(marker);
delete geocacheMarkers[cache.id];
}
});
}
function updateGeocacheMarkerIcon(geocacheId, isRead) {
// Since we're not changing icons for read/unread anymore, this function
// doesn't need to do anything. The animation state is handled by checkGeocacheProximity
return;
}
function addGeocacheMessage() {
if (!currentGeocache) return;
const nameInput = document.getElementById('geocacheName');
const messageInput = document.getElementById('geocacheMessage');
const titleInput = document.getElementById('geocacheTitleInput');
const iconInput = document.getElementById('geocacheIconInput');
const colorInput = document.getElementById('geocacheColorInput');
const visibilityInput = document.getElementById('geocacheVisibilityInput');
// For new geocaches or when editing, update all properties
if (!currentGeocache.title || currentGeocacheEditMode) {
const title = titleInput.value.trim();
const icon = iconInput.value.trim();
const color = colorInput.value || '#FFA726';
const visibilityDistance = parseInt(visibilityInput.value) || 0;
if (!title) {
alert('Please enter a title for this geocache');
return;
}
currentGeocache.title = title;
currentGeocache.icon = icon || 'package-variant';
currentGeocache.color = color;
currentGeocache.visibilityDistance = visibilityDistance;
// Update or recreate the marker with new properties
if (geocacheMarkers[currentGeocache.id]) {
map.removeLayer(geocacheMarkers[currentGeocache.id]);
delete geocacheMarkers[currentGeocache.id];
}
createGeocacheMarker(currentGeocache);
// If we were editing, exit edit mode
if (currentGeocacheEditMode) {
currentGeocacheEditMode = false;
// Don't add a message when just editing properties
if (!messageInput.value.trim()) {
showGeocacheDialog(currentGeocache, false);
// Broadcast the update
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'geocacheUpdate',
geocache: currentGeocache
}));
}
return;
}
}
}
const name = nameInput.value.trim() || 'Anonymous';
const text = messageInput.value.trim();
if (!text) {
alert('Please enter a message');
return;
}
// In nav mode, check if user is close enough
if (navMode && userLocation) {
const distance = L.latLng(userLocation.lat, userLocation.lng)
.distanceTo(L.latLng(currentGeocache.lat, currentGeocache.lng));
if (distance > 5) { // 5 meters
alert('You must be within 5 meters of the geocache to leave a message!');
return;
}
}
const message = {
author: name,
text: text,
timestamp: Date.now()
};
if (!currentGeocache.messages) {
currentGeocache.messages = [];
}
currentGeocache.messages.push(message);
// Update display
showGeocacheDialog(currentGeocache);
// Broadcast via WebSocket if connected
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'geocacheUpdate',
geocache: currentGeocache
}));
}
}
function deleteGeocache() {
if (!currentGeocache) return;
const index = geocaches.findIndex(g => g.id === currentGeocache.id);
if (index > -1) {
geocaches.splice(index, 1);
// Remove marker
if (geocacheMarkers[currentGeocache.id]) {
map.removeLayer(geocacheMarkers[currentGeocache.id]);
delete geocacheMarkers[currentGeocache.id];
}
// Broadcast deletion
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'geocacheDelete',
geocacheId: currentGeocache.id
}));
}
hideGeocacheDialog();
}
}
function checkGeocacheProximity() {
if (!navMode || !userLocation) return;
const now = Date.now();
// Only check at configured interval
if (now - lastGeocacheProximityCheck < adminSettings.proximityCheckInterval) return;
lastGeocacheProximityCheck = now;
geocaches.forEach(geocache => {
const distance = L.latLng(userLocation.lat, userLocation.lng)
.distanceTo(L.latLng(geocache.lat, geocache.lng));
const isInRange = distance <= adminSettings.geocacheAlertRange;
// ONLY show alert - don't touch the marker at all
if (isInRange && !geocache.alerted) {
geocache.alerted = true;
showGeocacheAlert();
} else if (!isInRange) {
geocache.alerted = false;
}
});
}
function showGeocacheAlert() {
const alert = document.getElementById('geocacheAlert');
alert.classList.add('show');
// Play alert sound
playGeocacheSound();
setTimeout(() => {
alert.classList.remove('show');
}, 3000);
}
// Setup geocache dialog handlers - wrapped to prevent errors
function setupGeocacheListeners() {
try {
const geocacheCancel = document.getElementById('geocacheCancel');
if (geocacheCancel) geocacheCancel.addEventListener('click', hideGeocacheDialog);
const geocacheSubmit = document.getElementById('geocacheSubmit');
if (geocacheSubmit) geocacheSubmit.addEventListener('click', addGeocacheMessage);
const geocacheDelete = document.getElementById('geocacheDelete');
if (geocacheDelete) geocacheDelete.addEventListener('click', deleteGeocache);
const geocacheEdit = document.getElementById('geocacheEdit');
if (geocacheEdit) geocacheEdit.addEventListener('click', startEditingGeocache);
// Color picker events
const iconInput = document.getElementById('geocacheIconInput');
if (iconInput) iconInput.addEventListener('input', updateColorPreview);
const colorInput = document.getElementById('geocacheColorInput');
if (colorInput) colorInput.addEventListener('input', updateColorPreview);
const colorReset = document.getElementById('geocacheColorReset');
if (colorReset) {
colorReset.addEventListener('click', () => {
const input = document.getElementById('geocacheColorInput');
if (input) input.value = '#FFA726';
updateColorPreview();
});
}
// Geocache list sidebar events
const listToggle = document.getElementById('geocacheListToggle');
if (listToggle) {
listToggle.addEventListener('click', () => {
const sidebar = document.getElementById('geocacheListSidebar');
if (sidebar) {
sidebar.classList.toggle('open');
updateGeocacheList();
}
});
}
const listClose = document.getElementById('geocacheListClose');
if (listClose) {
listClose.addEventListener('click', () => {
const sidebar = document.getElementById('geocacheListSidebar');
if (sidebar) sidebar.classList.remove('open');
});
}
const geocacheAlert = document.getElementById('geocacheAlert');
if (geocacheAlert) {
geocacheAlert.addEventListener('click', function() {
// Find nearest geocache and open it
if (userLocation) {
let nearestCache = null;
let minDistance = Infinity;
geocaches.forEach(gc => {
const dist = L.latLng(userLocation.lat, userLocation.lng)
.distanceTo(L.latLng(gc.lat, gc.lng));
if (dist < minDistance && dist <= 5) {
minDistance = dist;
nearestCache = gc;
}
});
if (nearestCache) {
showGeocacheDialog(nearestCache);
}
}
});
}
} catch (err) {
console.warn('Geocache listeners setup error:', err);
}
}
// Call it after a short delay to ensure DOM is ready
setTimeout(setupGeocacheListeners, 100);
// WebSocket multi-user tracking
function connectWebSocket() {
if (ws && ws.readyState === WebSocket.OPEN) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Connected to multi-user tracking');
clearTimeout(wsReconnectTimer);
wsConnected = true;
wsLastPong = Date.now();
updateConnectionIndicator(true);
// Start client-side heartbeat
startWsHeartbeat();
// Flush any pending stats immediately on reconnect
if (statsSyncState.dirty) {
console.log('[WS] Reconnected - flushing pending stats');
flushStatsSync();
}
// Register authenticated user for real-time updates
if (currentUser && currentUser.id) {
ws.send(JSON.stringify({
type: 'auth',
authUserId: currentUser.id,
serverSessionId: sessionStorage.getItem('serverSessionId')
}));
}
// Send our icon info if we have it
if (myIcon && myColor) {
setTimeout(() => {
ws.send(JSON.stringify({
type: 'iconUpdate',
icon: myIcon,
color: myColor
}));
}, 100);
}
};
ws.onmessage = (event) => {
// Update last pong time on any message received
wsLastPong = Date.now();
const data = JSON.parse(event.data);
// Handle pong response from server
if (data.type === 'pong') {
return; // Just update lastPong timestamp, no further processing
}
console.log('[WS] Received message type:', data.type);
switch (data.type) {
case 'init':
userId = data.userId;
// Check if server restarted (session ID changed)
const storedSessionId = sessionStorage.getItem('serverSessionId');
if (storedSessionId && data.serverSessionId && storedSessionId !== data.serverSessionId) {
console.log('[WS] Server restarted - session ID mismatch, forcing re-login');
// Clear auth and show login
localStorage.removeItem('accessToken');
localStorage.removeItem('currentUser');
currentUser = null;
playerStats = null;
statsLoadedFromServer = false;
document.getElementById('rpgHud').style.display = 'none';
document.getElementById('deathOverlay').style.display = 'none';
stopMonsterSpawning();
if (homeBaseMarker) {
map.removeLayer(homeBaseMarker);
homeBaseMarker = null;
}
sessionStorage.setItem('serverSessionId', data.serverSessionId);
document.getElementById('authModal').style.display = 'flex';
updateStatus('Server restarted - please log in again', 'info');
break;
}
// Store server session ID for future checks
if (data.serverSessionId) {
sessionStorage.setItem('serverSessionId', data.serverSessionId);
}
// Add existing users
if (data.users) {
data.users.forEach(user => {
if (user.userId !== userId) {
updateOtherUser(user.userId, user.lat, user.lng, user.accuracy, user.icon, user.color);
}
});
}
break;
case 'userLocation':
if (data.visible === false) {
// User is off-track, hide them
removeOtherUser(data.userId);
} else {
// User is on-track, show them
updateOtherUser(data.userId, data.lat, data.lng, data.accuracy, data.icon, data.color);
}
break;
case 'userDisconnected':
removeOtherUser(data.userId);
break;
case 'force_logout':
// Another session has started - log out this tab
console.log('Force logout received:', data.reason);
// Mark that we were force-logged out to prevent auto-reconnect
sessionStorage.setItem('forceLoggedOut', 'true');
// Clear auth tokens
localStorage.removeItem('accessToken');
localStorage.removeItem('currentUser');
// Show login screen
currentUser = null;
playerStats = null;
statsLoadedFromServer = false;
document.getElementById('rpgHud').style.display = 'none';
document.getElementById('deathOverlay').style.display = 'none';
stopMonsterSpawning();
if (homeBaseMarker) {
map.removeLayer(homeBaseMarker);
homeBaseMarker = null;
}
// Close WebSocket to prevent reconnection
if (ws) {
ws.close();
ws = null;
}
// Show auth modal with message
document.getElementById('authModal').style.display = 'flex';
alert('You were logged out because another session started.\n\nPlease log in again on THIS tab, or close it.');
break;
case 'settings_updated':
// Admin updated game settings - reload them
console.log('Settings updated by admin:', data.settings);
// Update local spawnSettings with the new values
if (data.settings) {
if (data.settings.monsterSpawnInterval !== undefined) spawnSettings.spawnInterval = data.settings.monsterSpawnInterval;
if (data.settings.monsterSpawnChance !== undefined) spawnSettings.spawnChance = data.settings.monsterSpawnChance;
if (data.settings.monsterSpawnDistance !== undefined) spawnSettings.spawnDistance = data.settings.monsterSpawnDistance;
if (data.settings.mpRegenDistance !== undefined) spawnSettings.mpRegenDistance = data.settings.mpRegenDistance;
if (data.settings.mpRegenAmount !== undefined) spawnSettings.mpRegenAmount = data.settings.mpRegenAmount;
if (data.settings.hpRegenInterval !== undefined) spawnSettings.hpRegenInterval = data.settings.hpRegenInterval;
if (data.settings.hpRegenPercent !== undefined) spawnSettings.hpRegenPercent = data.settings.hpRegenPercent;
if (data.settings.homeHpMultiplier !== undefined) spawnSettings.homeHpMultiplier = data.settings.homeHpMultiplier;
if (data.settings.homeRegenPercent !== undefined) spawnSettings.homeRegenPercent = data.settings.homeRegenPercent;
if (data.settings.homeBaseRadius !== undefined) spawnSettings.homeBaseRadius = data.settings.homeBaseRadius;
}
// Restart HP regen timer with new interval if it changed
if (data.settings.hpRegenInterval !== undefined) {
stopHpRegenTimer();
startHpRegenTimer();
}
showNotification('Game settings updated - refreshing...', 'info');
// Stop saving stats to prevent version conflicts during reload
statsLoadedFromServer = false;
setTimeout(() => location.reload(), 1500);
break;
case 'admin_update':
// Admin made a change
console.log('Admin update:', data.changeType, data.details);
// Handle monster type updates without full page reload
if (data.changeType === 'monster') {
showNotification('Monster types updated - applying changes...', 'info');
// Reload monster types and update existing monster markers
loadMonsterTypes().then(() => {
// Update markers for any spawned monsters to reflect new animations
monsterEntourage.forEach(monster => {
if (monster.marker) {
monster.marker.remove();
}
createMonsterMarker(monster);
});
console.log('Monster types reloaded, markers updated');
});
} else {
// For other changes, refresh the page
showNotification(`Game data updated: ${data.changeType} - refreshing...`, 'info');
// Stop saving stats to prevent version conflicts during reload
statsLoadedFromServer = false;
setTimeout(() => location.reload(), 1500);
}
break;
case 'geocacheUpdate':
// Another user or device added or updated a geocache
if (data.geocache) {
const existingIndex = geocaches.findIndex(g => g.id === data.geocache.id);
if (existingIndex >= 0) {
// Check if this is a new message
const oldCache = geocaches[existingIndex];
const oldMessageCount = oldCache.messages ? oldCache.messages.length : 0;
const newMessageCount = data.geocache.messages ? data.geocache.messages.length : 0;
const newMessagesCount = newMessageCount - oldMessageCount;
// Update existing geocache
geocaches[existingIndex] = data.geocache;
// Send notification if new message added and we're nearby
// This will notify even if it's from your own account on another device
if (newMessagesCount > 0 && userLocation && pushSubscription) {
const distance = L.latLng(userLocation.lat, userLocation.lng)
.distanceTo(L.latLng(data.geocache.lat, data.geocache.lng));
if (distance <= CACHE_NOTIFY_DISTANCE) {
const latestMessage = data.geocache.messages[data.geocache.messages.length - 1];
sendPushNotification(
'💬 New Cache Message',
`Someone left a note at "${data.geocache.title || 'Untitled Geocache'}"`,
'cacheMessage'
);
}
}
// Refresh dialog if it's open for this geocache
if (currentGeocache && currentGeocache.id === data.geocache.id) {
showGeocacheDialog(data.geocache);
}
} else {
// Add new geocache
geocaches.push(data.geocache);
createGeocacheMarker(data.geocache);
}
}
break;
case 'geocacheDelete':
// Another user deleted a geocache
if (data.geocacheId) {
const index = geocaches.findIndex(g => g.id === data.geocacheId);
if (index > -1) {
geocaches.splice(index, 1);
if (geocacheMarkers[data.geocacheId]) {
map.removeLayer(geocacheMarkers[data.geocacheId]);
delete geocacheMarkers[data.geocacheId];
}
// Close dialog if it's open for this geocache
if (currentGeocache && currentGeocache.id === data.geocacheId) {
hideGeocacheDialog();
}
}
}
break;
case 'geocachesInit':
// Receive all geocaches on connection
console.log('Received geocachesInit message with', data.geocaches ? data.geocaches.length : 0, 'geocaches');
if (data.geocaches) {
geocaches = data.geocaches;
// Clear existing markers
Object.values(geocacheMarkers).forEach(marker => {
map.removeLayer(marker);
});
geocacheMarkers = {};
// Create markers for all geocaches
geocaches.forEach(geocache => {
createGeocacheMarker(geocache);
});
}
break;
case 'statsUpdated':
// Admin updated our stats - refresh from server
console.log('Stats updated by admin, refreshing...');
refreshPlayerStats();
break;
}
};
ws.onclose = () => {
console.log('WebSocket disconnected');
wsConnected = false;
stopWsHeartbeat();
updateConnectionIndicator(false);
// Save progress to localStorage immediately as safety net (with timestamp)
if (playerStats) {
const backupStats = { ...playerStats, localSaveTimestamp: Date.now() };
localStorage.setItem('hikemap_rpg_stats', JSON.stringify(backupStats));
console.log('[WS] Disconnected - saved backup to localStorage');
}
// Attempt reconnect after 3 seconds
wsReconnectTimer = setTimeout(connectWebSocket, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
wsConnected = false;
updateConnectionIndicator(false);
};
} catch (err) {
console.error('Failed to create WebSocket:', err);
wsConnected = false;
updateConnectionIndicator(false);
}
}
// Client-side heartbeat to detect zombie connections
function startWsHeartbeat() {
stopWsHeartbeat(); // Clear any existing timer
wsHeartbeatTimer = setInterval(() => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
stopWsHeartbeat();
return;
}
// Check if we've received any message recently
const timeSinceLastPong = Date.now() - wsLastPong;
if (timeSinceLastPong > WS_HEARTBEAT_INTERVAL + WS_PONG_TIMEOUT) {
console.warn('[WS] Connection appears dead - no response in', timeSinceLastPong, 'ms');
wsConnected = false;
updateConnectionIndicator(false);
// Force close and reconnect
ws.close();
return;
}
// Send ping to server
try {
ws.send(JSON.stringify({ type: 'ping' }));
} catch (err) {
console.error('[WS] Failed to send ping:', err);
}
}, WS_HEARTBEAT_INTERVAL);
}
function stopWsHeartbeat() {
if (wsHeartbeatTimer) {
clearInterval(wsHeartbeatTimer);
wsHeartbeatTimer = null;
}
}
// Connection state indicator
function updateConnectionIndicator(connected) {
let indicator = document.getElementById('connectionIndicator');
if (!indicator) {
// Create indicator if it doesn't exist
indicator = document.createElement('div');
indicator.id = 'connectionIndicator';
indicator.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
padding: 6px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
z-index: 2000;
display: none;
transition: opacity 0.3s, background-color 0.3s;
pointer-events: none;
`;
document.body.appendChild(indicator);
}
if (connected) {
// Hide indicator when connected (only show when there's a problem)
indicator.style.display = 'none';
} else {
// Show warning when disconnected
indicator.style.display = 'block';
indicator.style.backgroundColor = 'rgba(255, 100, 100, 0.9)';
indicator.style.color = 'white';
indicator.innerHTML = '⚠️ Reconnecting...';
}
}
// Handle tab visibility changes
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
console.log('[WS] Tab became visible - checking connection');
// Check if connection is stale
const timeSinceLastPong = Date.now() - wsLastPong;
if (timeSinceLastPong > WS_HEARTBEAT_INTERVAL + WS_PONG_TIMEOUT) {
console.warn('[WS] Connection stale after tab visibility change');
if (ws) {
ws.close();
}
// Reconnect immediately
clearTimeout(wsReconnectTimer);
connectWebSocket();
} else if (ws && ws.readyState === WebSocket.OPEN) {
// Send immediate ping to verify connection
try {
ws.send(JSON.stringify({ type: 'ping' }));
} catch (err) {
console.error('[WS] Failed to send ping on visibility change:', err);
connectWebSocket();
}
} else {
// WebSocket not open, reconnect
clearTimeout(wsReconnectTimer);
connectWebSocket();
}
// Also flush any pending stats
if (statsSyncState.dirty) {
console.log('[WS] Tab visible - flushing pending stats');
flushStatsSync();
}
}
});
function sendLocationToServer(lat, lng, accuracy, visible = true) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'location',
lat: lat,
lng: lng,
accuracy: accuracy,
icon: myIcon,
color: myColor,
visible: visible
}));
}
}
async function checkLocationNotifications(lat, lng) {
if (!pushSubscription) return; // No push notifications enabled
const now = Date.now();
const userPos = L.latLng(lat, lng);
// 1. Check for nearby geocaches
geocaches.forEach(cache => {
if (!cache || !cache.lat || !cache.lng) return;
const cachePos = L.latLng(cache.lat, cache.lng);
const distance = userPos.distanceTo(cachePos);
// Check if we should notify about this cache
const lastNotified = notificationCooldowns.nearbyCache[cache.id] || 0;
const timeSinceNotification = now - lastNotified;
if (distance <= CACHE_NOTIFY_DISTANCE) {
// Within notification distance
if (timeSinceNotification > CACHE_COOLDOWN || lastNotified === 0) {
// Send notification
sendPushNotification(
'📍 Geocache Nearby!',
`"${cache.title || 'Untitled Geocache'}" is ${Math.round(distance)}m away`,
'nearbyCache'
);
notificationCooldowns.nearbyCache[cache.id] = now;
}
} else if (distance > CACHE_RESET_DISTANCE && lastNotified > 0) {
// Reset cooldown if we've moved far enough away
delete notificationCooldowns.nearbyCache[cache.id];
}
});
// 2. Check for destination arrival (only in nav mode)
if (navMode && destinationPin) {
const destPos = destinationPin.getLatLng();
const distance = userPos.distanceTo(destPos);
if (distance <= DESTINATION_ARRIVAL_DISTANCE) {
const timeSinceNotification = now - notificationCooldowns.destinationArrival;
if (timeSinceNotification > 60000) { // 1 minute cooldown for arrival
sendPushNotification(
'🎯 Destination Reached!',
'You have arrived at your destination',
'destinationArrival'
);
notificationCooldowns.destinationArrival = now;
}
}
}
}
async function sendPushNotification(title, body, type) {
try {
// Send to server to trigger push notification
const response = await fetch('/send-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: title,
body: body,
type: type,
userId: userId // Send to self
})
});
if (!response.ok) {
console.error('Failed to send push notification');
}
} catch (error) {
console.error('Error sending push notification:', error);
}
}
function updateOtherUser(userId, lat, lng, accuracy, icon, color) {
let userMarker = otherUsers.get(userId);
if (!userMarker) {
// Use provided icon/color or fall back to defaults
if (!icon || !color) {
const defaults = [
{ icon: 'mdi-walk', color: '#ff6b6b' },
{ icon: 'mdi-bike', color: '#4ecdc4' },
{ icon: 'mdi-run', color: '#45b7d1' },
{ icon: 'mdi-hiking', color: '#f7b731' },
{ icon: 'mdi-ski', color: '#5f27cd' },
{ icon: 'mdi-skateboard', color: '#00d2d3' }
];
const defaultConfig = defaults[otherUsers.size % defaults.length];
icon = icon || defaultConfig.icon;
color = color || defaultConfig.color;
}
const userIcon = L.divIcon({
html: '<i class="mdi ' + icon + '" style="color: ' + color + '; font-size: 32px;"></i>',
iconSize: [32, 32],
iconAnchor: [16, 16],
className: 'custom-div-icon'
});
userMarker = {
marker: L.marker([lat, lng], { icon: userIcon }).addTo(map),
color: color,
icon: icon
};
userMarker.marker.bindTooltip(`User ${userId}`, { permanent: false, direction: 'top' });
otherUsers.set(userId, userMarker);
} else {
// Update existing marker position
userMarker.marker.setLatLng([lat, lng]);
// Update icon if it changed
if (icon && color && (userMarker.icon !== icon || userMarker.color !== color)) {
const newIcon = L.divIcon({
html: '<i class="mdi ' + icon + '" style="color: ' + color + '; font-size: 32px;"></i>',
iconSize: [32, 32],
iconAnchor: [16, 16],
className: 'custom-div-icon'
});
userMarker.marker.setIcon(newIcon);
userMarker.icon = icon;
userMarker.color = color;
}
}
}
function removeOtherUser(userId) {
const userMarker = otherUsers.get(userId);
if (userMarker) {
map.removeLayer(userMarker.marker);
otherUsers.delete(userId);
}
}
function updateRouteHighlight(coords) {
clearRouteHighlight();
if (coords.length < 2) return;
routeHighlight = L.polyline(coords, {
color: '#00ff00',
weight: 10,
opacity: 0.7
}).addTo(map);
// Make sure route is behind other markers
routeHighlight.bringToBack();
}
function clearRouteHighlight() {
if (routeHighlight) {
map.removeLayer(routeHighlight);
routeHighlight = null;
}
}
function updateDirectionArrow(currentPos, nextPoints, color = '#ff6600') {
if (directionArrow) {
map.removeLayer(directionArrow);
directionArrow = null;
}
if (nextPoints.length === 0) return;
const arrowCoords = [
[currentPos.lat, currentPos.lng],
...nextPoints
];
directionArrow = L.polyline(arrowCoords, {
color: color,
weight: 5,
opacity: 0.8,
dashArray: '12, 8'
}).addTo(map);
}
// Build simplified trail network graph for pathfinding
// Only uses endpoints and intersection points as nodes (much faster)
function buildTrailGraph() {
const graph = {
nodes: new Map() // Key: "trackId:pointIndex", Value: {track, index, neighbors: [{key, distance}]}
};
const nodeKey = (track, index) => `${track.id}:${index}`;
// Step 1: Find all intersection points and endpoints
const keyPoints = new Map(); // trackId -> Set of indices that are key points
for (const track of tracks) {
const points = new Set([0, track.coords.length - 1]); // Start and end
// Add intermediate nodes every ~50 meters for better routing flexibility
let accumulatedDist = 0;
for (let i = 1; i < track.coords.length - 1; i++) {
const dist = map.distance(
L.latLng(track.coords[i-1]),
L.latLng(track.coords[i])
);
accumulatedDist += dist;
if (accumulatedDist >= adminSettings.nodeSpacing) { // Create node at configured spacing
points.add(i);
accumulatedDist = 0;
}
}
keyPoints.set(track.id, points);
}
// Find intersections between tracks
for (const track of tracks) {
for (const otherTrack of tracks) {
if (track.id >= otherTrack.id) continue; // Avoid duplicate checks
// Sample points to find intersections (check more frequently for accuracy)
for (let i = 0; i < track.coords.length; i += Math.max(1, Math.floor(track.coords.length / 10))) {
const point = L.latLng(track.coords[i]);
for (let j = 0; j < otherTrack.coords.length; j += Math.max(1, Math.floor(otherTrack.coords.length / 10))) {
const otherPoint = L.latLng(otherTrack.coords[j]);
const dist = map.distance(point, otherPoint);
if (dist <= adminSettings.intersectionThreshold) {
keyPoints.get(track.id).add(i);
keyPoints.get(otherTrack.id).add(j);
}
}
}
}
}
// Step 2: Create nodes for key points
for (const track of tracks) {
const indices = Array.from(keyPoints.get(track.id)).sort((a, b) => a - b);
for (const idx of indices) {
const key = nodeKey(track, idx);
graph.nodes.set(key, {
track: track,
index: idx,
neighbors: []
});
}
// Connect consecutive key points on same track
for (let i = 0; i < indices.length - 1; i++) {
const fromIdx = indices[i];
const toIdx = indices[i + 1];
const fromKey = nodeKey(track, fromIdx);
const toKey = nodeKey(track, toIdx);
// Calculate distance along track
let dist = 0;
for (let j = fromIdx; j < toIdx; j++) {
dist += map.distance(
L.latLng(track.coords[j]),
L.latLng(track.coords[j + 1])
);
}
graph.nodes.get(fromKey).neighbors.push({ key: toKey, distance: dist });
graph.nodes.get(toKey).neighbors.push({ key: fromKey, distance: dist });
}
}
// Step 3: Connect intersection points across tracks
for (const track of tracks) {
const trackKeyPoints = keyPoints.get(track.id);
for (const idx of trackKeyPoints) {
const point = L.latLng(track.coords[idx]);
const key = nodeKey(track, idx);
const node = graph.nodes.get(key);
for (const otherTrack of tracks) {
if (track === otherTrack) continue;
const otherKeyPoints = keyPoints.get(otherTrack.id);
for (const otherIdx of otherKeyPoints) {
const otherPoint = L.latLng(otherTrack.coords[otherIdx]);
const dist = map.distance(point, otherPoint);
if (dist <= adminSettings.intersectionThreshold) {
const otherKey = nodeKey(otherTrack, otherIdx);
if (!node.neighbors.some(n => n.key === otherKey)) {
node.neighbors.push({ key: otherKey, distance: dist });
}
}
}
}
}
}
return graph;
}
// Find nearest graph node on a track to a given index
function findNearestGraphNode(graph, track, index) {
let bestKey = null;
let bestDist = Infinity;
let bestIdx = 0;
for (const [key, node] of graph.nodes) {
if (node.track.id === track.id) {
const dist = Math.abs(node.index - index);
if (dist < bestDist) {
bestDist = dist;
bestKey = key;
bestIdx = node.index;
}
}
}
return { key: bestKey, index: bestIdx };
}
// Calculate distance along track between two indices
function trackDistanceBetween(track, idx1, idx2) {
let dist = 0;
const start = Math.min(idx1, idx2);
const end = Math.max(idx1, idx2);
for (let i = start; i < end; i++) {
dist += map.distance(
L.latLng(track.coords[i]),
L.latLng(track.coords[i + 1])
);
}
return dist;
}
// Find shortest path using Dijkstra's algorithm
function findShortestPath(startTrack, startIndex, endTrack, endIndex) {
const graph = buildTrailGraph();
if (graph.nodes.size === 0) return null;
// Find nearest graph nodes to start and end
const startNode = findNearestGraphNode(graph, startTrack, startIndex);
const endNode = findNearestGraphNode(graph, endTrack, endIndex);
if (!startNode.key || !endNode.key) return null;
// Distance from actual start to nearest graph node
const startToNodeDist = trackDistanceBetween(startTrack, startIndex, startNode.index);
// Distance from nearest graph node to actual end
const nodeToEndDist = trackDistanceBetween(endTrack, endNode.index, endIndex);
const startKey = startNode.key;
const endKey = endNode.key;
// Always use full pathfinding to find optimal route
// (removed same-track shortcut that was causing inefficient routes)
// Dijkstra's algorithm
const distances = new Map();
const previous = new Map();
const unvisited = new Set(graph.nodes.keys());
for (const key of graph.nodes.keys()) {
distances.set(key, Infinity);
}
distances.set(startKey, startToNodeDist);
while (unvisited.size > 0) {
let currentKey = null;
let minDist = Infinity;
for (const key of unvisited) {
if (distances.get(key) < minDist) {
minDist = distances.get(key);
currentKey = key;
}
}
if (currentKey === null || minDist === Infinity) break;
if (currentKey === endKey) break;
unvisited.delete(currentKey);
const currentNode = graph.nodes.get(currentKey);
for (const neighbor of currentNode.neighbors) {
if (!unvisited.has(neighbor.key)) continue;
const newDist = distances.get(currentKey) + neighbor.distance;
if (newDist < distances.get(neighbor.key)) {
distances.set(neighbor.key, newDist);
previous.set(neighbor.key, currentKey);
}
}
}
if (!previous.has(endKey) && startKey !== endKey) {
return null;
}
// Reconstruct path through graph nodes
const graphPath = [];
let current = endKey;
while (current) {
const node = graph.nodes.get(current);
graphPath.unshift({ track: node.track, index: node.index });
current = previous.get(current);
}
// Build full path including actual start and end points
const fullPath = [{ track: startTrack, index: startIndex }];
for (const node of graphPath) {
// Avoid duplicates
const last = fullPath[fullPath.length - 1];
if (last.track.id !== node.track.id || last.index !== node.index) {
fullPath.push(node);
}
}
// Add end point if different from last
const last = fullPath[fullPath.length - 1];
if (last.track.id !== endTrack.id || last.index !== endIndex) {
fullPath.push({ track: endTrack, index: endIndex });
}
return {
path: fullPath,
totalDistance: distances.get(endKey) + nodeToEndDist
};
}
// Convert path to route segments for display
function pathToRouteSegments(path) {
if (!path || path.length === 0) return [];
const segments = [];
let currentSegment = null;
for (const node of path) {
if (!currentSegment || currentSegment.track !== node.track) {
// Start new segment
if (currentSegment) {
segments.push(currentSegment);
}
currentSegment = {
track: node.track,
fromIndex: node.index,
toIndex: node.index
};
} else {
// Extend current segment
currentSegment.toIndex = node.index;
}
}
if (currentSegment) {
segments.push(currentSegment);
}
return segments;
}
// Get all coordinates along the route
function getRouteCoordinates(segments) {
const coords = [];
for (const segment of segments) {
const start = Math.min(segment.fromIndex, segment.toIndex);
const end = Math.max(segment.fromIndex, segment.toIndex);
const reverse = segment.fromIndex > segment.toIndex;
const segmentCoords = [];
for (let i = start; i <= end; i++) {
segmentCoords.push(segment.track.coords[i]);
}
if (reverse) {
segmentCoords.reverse();
}
// Avoid duplicate points at segment boundaries
if (coords.length > 0 && segmentCoords.length > 0) {
const lastCoord = coords[coords.length - 1];
const firstCoord = segmentCoords[0];
if (lastCoord[0] === firstCoord[0] && lastCoord[1] === firstCoord[1]) {
segmentCoords.shift();
}
}
coords.push(...segmentCoords);
}
return coords;
}
function switchTab(tabName) {
const editTab = document.getElementById('editTab');
const navTab = document.getElementById('navTab');
const adminTab = document.getElementById('adminTab');
const editContent = document.getElementById('editContent');
const navContent = document.getElementById('navContent');
const adminContent = document.getElementById('adminContent');
// Save nav mode state
localStorage.setItem('navMode', tabName === 'navigate' ? 'true' : 'false');
// Remove active class from all tabs and content
[editTab, navTab, adminTab].forEach(tab => tab.classList.remove('active'));
[editContent, navContent, adminContent].forEach(content => content.classList.remove('active'));
// Close any open overlays
const editOverlay = document.querySelector('.edit-panel-overlay');
const adminOverlay = document.querySelector('.admin-panel-overlay');
if (editOverlay) editOverlay.classList.remove('active');
if (adminOverlay) adminOverlay.classList.remove('active');
if (tabName === 'edit') {
// Check if user is admin
if (!currentUser || !currentUser.is_admin) {
updateStatus('Admin access required', 'error');
return;
}
// Show edit overlay instead of tab content
editTab.classList.add('active');
const editOverlay = document.querySelector('.edit-panel-overlay');
if (editOverlay) {
editOverlay.classList.add('active');
}
navMode = false;
document.body.classList.remove('nav-mode');
// Show geocache list toggle in edit mode
document.getElementById('geocacheListToggle').style.display = 'flex';
updateFogOfWar();
updateGeocacheVisibility();
// In edit mode, disable auto-center
if (autoCenterMode) {
autoCenterMode = false;
const btn = document.getElementById('autoCenterBtn');
btn.textContent = 'Auto-Center: OFF';
btn.classList.remove('active');
}
// Clear any navigation visuals when leaving nav mode
if (directionArrow) {
map.removeLayer(directionArrow);
directionArrow = null;
}
clearRouteHighlight();
// Reset map rotation when leaving nav mode
if (rotateMapMode) {
toggleRotateMap();
}
} else if (tabName === 'navigate') {
navTab.classList.add('active');
navContent.classList.add('active');
navMode = true;
document.body.classList.add('nav-mode');
// Hide geocache list toggle in nav mode
document.getElementById('geocacheListToggle').style.display = 'none';
document.getElementById('geocacheListSidebar').classList.remove('open');
updateFogOfWar();
updateGeocacheVisibility();
// Deactivate edit tools when entering nav mode
Object.values(toolButtons).forEach(btn => btn.classList.remove('active'));
document.getElementById('reshapeControls').style.display = 'none';
document.getElementById('smoothControls').style.display = 'none';
map.getContainer().style.cursor = '';
currentTool = null;
// Cancel any in-progress operations
if (isDrawing) cancelDrawing();
if (isDragging) cancelReshapeDrag();
// Update nav track list
updateNavTrackList();
} else if (tabName === 'admin') {
// Check if user is admin
if (!currentUser || !currentUser.is_admin) {
updateStatus('Admin access required', 'error');
return;
}
// Show admin overlay instead of tab content
adminTab.classList.add('active');
const adminOverlay = document.querySelector('.admin-panel-overlay');
if (adminOverlay) {
adminOverlay.classList.add('active');
}
navMode = false;
// In admin mode, disable auto-center
if (autoCenterMode) {
autoCenterMode = false;
const btn = document.getElementById('autoCenterBtn');
if (btn) {
btn.textContent = 'Auto-Center: OFF';
btn.classList.remove('active');
}
}
// Clear any navigation visuals when leaving nav mode
if (directionArrow) {
map.removeLayer(directionArrow);
directionArrow = null;
}
clearRouteHighlight();
// Reset map rotation when leaving nav mode
if (rotateMapMode) {
toggleRotateMap();
}
// Deactivate edit tools
Object.values(toolButtons).forEach(btn => btn.classList.remove('active'));
document.getElementById('reshapeControls').style.display = 'none';
document.getElementById('smoothControls').style.display = 'none';
map.getContainer().style.cursor = '';
currentTool = null;
}
}
function updateNavTrackList() {
const listEl = document.getElementById('trackListNav');
const countEl = document.getElementById('trackCountNav');
if (!listEl || !countEl) return;
countEl.textContent = tracks.length;
listEl.innerHTML = '';
tracks.forEach(track => {
const item = document.createElement('div');
item.className = 'track-item';
item.innerHTML = `<span>${track.name}</span>`;
listEl.appendChild(item);
});
}
// Tool buttons - initialize later after DOM is ready
let toolButtons = {};
// Initialize tool buttons after DOM elements exist
function initializeToolButtons() {
toolButtons = {
select: document.getElementById('selectTool'),
split: document.getElementById('splitTool'),
draw: document.getElementById('drawTool'),
reshape: document.getElementById('reshapeTool'),
smooth: document.getElementById('smoothTool'),
geocache: document.getElementById('geocacheTool')
};
// Tool button event listeners
Object.keys(toolButtons).forEach(tool => {
if (toolButtons[tool]) {
toolButtons[tool].addEventListener('click', () => setTool(tool));
} else {
console.warn(`Tool button not found: ${tool}`);
}
});
}
// Set active tool
function setTool(tool) {
currentTool = tool;
Object.keys(toolButtons).forEach(t => {
if (toolButtons[t]) {
toolButtons[t].classList.toggle('active', t === tool);
}
});
// Cancel any drawing in progress
if (tool !== 'draw' && isDrawing) {
cancelDrawing();
}
// Show/hide tool-specific controls
const reshapeControls = document.getElementById('reshapeControls');
const smoothControls = document.getElementById('smoothControls');
if (reshapeControls) reshapeControls.style.display = tool === 'reshape' ? 'block' : 'none';
if (smoothControls) smoothControls.style.display = tool === 'smooth' ? 'block' : 'none';
// Update cursor
const container = map.getContainer();
container.style.cursor = (tool === 'draw' || tool === 'reshape' || tool === 'smooth') ? 'crosshair' : '';
updateStatus(getToolHint(tool), 'info');
}
function getToolHint(tool) {
switch(tool) {
case 'select': return 'Click a track to select it, press Delete to remove';
case 'split': return 'Click on a track to split it at that point';
case 'draw': return 'Click to add points, double-click to finish';
case 'reshape': return 'Drag any point on a track to reshape it';
case 'smooth': return 'Click and drag over a track to smooth it';
default: return '';
}
}
// Initialize tool buttons when DOM is ready
setTimeout(() => {
initializeToolButtons();
}, 100);
// Track class
class Track {
constructor(coords, name = 'Track', description = '') {
this.id = Date.now() + Math.random();
this.coords = coords; // Array of [lat, lng]
this.name = name;
this.description = description;
this.layer = null;
this.pointMarkers = [];
this.createLayer();
}
createLayer() {
if (this.layer) {
map.removeLayer(this.layer);
}
this.layer = L.polyline(this.coords, {
color: '#3388ff',
weight: 4,
opacity: 0.8
});
this.layer.on('click', (e) => this.onClick(e));
this.layer.addTo(map);
}
onClick(e) {
L.DomEvent.stopPropagation(e);
// Don't allow track selection in nav mode
if (navMode) {
return;
}
switch(currentTool) {
case 'select':
selectTrack(this);
break;
case 'split':
splitTrack(this, e.latlng);
break;
}
}
setSelected(selected) {
this.layer.setStyle({
color: selected ? '#ff4444' : '#3388ff',
weight: selected ? 6 : 4
});
// Show/hide point markers in edit mode
if (!navMode) {
if (selected) {
this.showPointMarkers();
updateStatus(`Selected: ${this.name} (${this.coords.length} points)`, 'info');
} else {
this.hidePointMarkers();
}
}
}
remove() {
if (this.layer) {
map.removeLayer(this.layer);
}
if (this.pointMarkers) {
this.pointMarkers.forEach(marker => map.removeLayer(marker));
this.pointMarkers = [];
}
}
updateDisplay() {
// Recreate the layer with new coordinates
if (this.layer) {
this.layer.setLatLngs(this.coords);
} else {
this.createLayer();
}
// Update point markers if selected
if (this.pointMarkers && this.pointMarkers.length > 0) {
this.hidePointMarkers();
this.showPointMarkers();
}
}
showPointMarkers() {
this.hidePointMarkers();
this.pointMarkers = [];
// Find intersection points
const intersectionIndices = new Set();
for (const otherTrack of tracks) {
if (otherTrack === this) continue;
for (let i = 0; i < this.coords.length; i++) {
const point = L.latLng(this.coords[i]);
for (let j = 0; j < otherTrack.coords.length; j++) {
const otherPoint = L.latLng(otherTrack.coords[j]);
if (map.distance(point, otherPoint) <= adminSettings.intersectionThreshold) {
intersectionIndices.add(i);
break;
}
}
}
}
// Add markers for each point
this.coords.forEach((coord, i) => {
const isIntersection = intersectionIndices.has(i);
const isEndpoint = i === 0 || i === this.coords.length - 1;
let color = '#3388ff'; // Regular point
let radius = 3;
if (isEndpoint) {
color = '#00ff00'; // Green for endpoints
radius = 5;
} else if (isIntersection) {
color = '#ff9900'; // Orange for intersections
radius = 4;
}
const marker = L.circleMarker(coord, {
radius: radius,
color: color,
fillColor: color,
fillOpacity: 0.8,
weight: 1
}).addTo(map);
this.pointMarkers.push(marker);
});
}
hidePointMarkers() {
if (this.pointMarkers) {
this.pointMarkers.forEach(marker => map.removeLayer(marker));
this.pointMarkers = [];
}
}
getStartPoint() {
return this.coords[0];
}
getEndPoint() {
return this.coords[this.coords.length - 1];
}
}
// === RESHAPE TOOL FUNCTIONS ===
// Find the nearest point on any track to a given latlng
function findNearestTrackPoint(latlng, threshold = 20) {
let best = null;
let bestDist = threshold;
for (const track of tracks) {
const point = map.latLngToContainerPoint(latlng);
for (let i = 0; i < track.coords.length; i++) {
const trackPoint = map.latLngToContainerPoint(L.latLng(track.coords[i]));
const dx = point.x - trackPoint.x;
const dy = point.y - trackPoint.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestDist) {
bestDist = dist;
best = { track, index: i, dist };
}
}
}
return best;
}
// Calculate distance between two lat/lng points (simple Euclidean for small areas)
function pointDistance(p1, p2) {
const dx = p1[0] - p2[0];
const dy = p1[1] - p2[1];
return Math.sqrt(dx * dx + dy * dy);
}
// Constrain point A to be at most maxDist from point B
function constrainDistance(pointA, pointB, maxDist) {
const dx = pointA[0] - pointB[0];
const dy = pointA[1] - pointB[1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= maxDist) {
return [...pointA]; // Already within range, no change
}
// Move point A to be exactly maxDist from B (along the line A-B)
const ratio = maxDist / dist;
return [
pointB[0] + dx * ratio,
pointB[1] + dy * ratio
];
}
// Smooth reshape: weighted translation that preserves curve shape
// Each point moves by the drag delta, weighted by distance from anchor
function applyRopePhysics(originalCoords, dragIndex, newPos, anchorDist) {
const coords = originalCoords.map(c => [...c]); // Deep copy
// Get falloff exponent from slider (controls how sharply weight drops off)
const falloff = parseFloat(document.getElementById('reshapeFalloff').value);
// Calculate anchor indices (both directions from drag point)
const anchorBefore = Math.max(0, dragIndex - anchorDist);
const anchorAfter = Math.min(coords.length - 1, dragIndex + anchorDist);
// Calculate the drag delta (how much the drag point moved)
const deltaLat = newPos[0] - originalCoords[dragIndex][0];
const deltaLng = newPos[1] - originalCoords[dragIndex][1];
// Set dragged point to new position
coords[dragIndex] = [newPos[0], newPos[1]];
// === Handle chain BEFORE drag point ===
if (dragIndex > anchorBefore) {
const chainLength = dragIndex - anchorBefore;
for (let i = anchorBefore; i < dragIndex; i++) {
if (i === anchorBefore) {
// Anchor stays fixed
coords[i] = [...originalCoords[anchorBefore]];
} else {
// Weight: 0 at anchor, 1 at drag point
// Falloff controls the curve: <1 = gradual, 1 = linear, >1 = sharp
const linearWeight = (i - anchorBefore) / chainLength;
const weight = Math.pow(linearWeight, falloff);
// Apply weighted delta to original position
coords[i] = [
originalCoords[i][0] + deltaLat * weight,
originalCoords[i][1] + deltaLng * weight
];
}
}
}
// === Handle chain AFTER drag point ===
if (dragIndex < anchorAfter) {
const chainLength = anchorAfter - dragIndex;
for (let i = dragIndex + 1; i <= anchorAfter; i++) {
if (i === anchorAfter) {
// Anchor stays fixed
coords[i] = [...originalCoords[anchorAfter]];
} else {
// Weight: 1 near drag, 0 at anchor
const linearWeight = 1 - (i - dragIndex) / chainLength;
const weight = Math.pow(linearWeight, falloff);
// Apply weighted delta to original position
coords[i] = [
originalCoords[i][0] + deltaLat * weight,
originalCoords[i][1] + deltaLng * weight
];
}
}
}
return coords;
}
// === SMOOTH BRUSH FUNCTIONS ===
// Start smoothing brush stroke
function startSmoothing(latlng) {
isSmoothing = true;
smoothedPoints.clear();
saveStateForUndo();
// Create brush circle visualization
const brushSize = parseInt(document.getElementById('smoothBrushSize').value);
smoothBrushCircle = L.circle(latlng, {
radius: brushSize * getMetersPerPixel(),
color: '#00aaff',
fillColor: '#00aaff',
fillOpacity: 0.2,
weight: 2
}).addTo(map);
// Apply smoothing at initial position
applySmoothing(latlng);
}
// Continue smoothing as brush moves
function continueSmoothing(latlng) {
if (!isSmoothing) return;
// Update brush circle position and size
const brushSize = parseInt(document.getElementById('smoothBrushSize').value);
if (smoothBrushCircle) {
smoothBrushCircle.setLatLng(latlng);
smoothBrushCircle.setRadius(brushSize * getMetersPerPixel());
}
// Apply smoothing at current position
applySmoothing(latlng);
}
// Finish smoothing stroke
function finishSmoothing() {
isSmoothing = false;
smoothedPoints.clear();
if (smoothBrushCircle) {
map.removeLayer(smoothBrushCircle);
smoothBrushCircle = null;
}
updateStatus('Smoothing complete', 'success');
}
// Get approximate meters per pixel at current zoom
function getMetersPerPixel() {
const center = map.getCenter();
const zoom = map.getZoom();
// Approximate meters per pixel at equator, adjusted for latitude
return 156543.03392 * Math.cos(center.lat * Math.PI / 180) / Math.pow(2, zoom);
}
// Apply smoothing to points within brush radius
function applySmoothing(latlng) {
const brushSize = parseInt(document.getElementById('smoothBrushSize').value);
const strength = parseFloat(document.getElementById('smoothStrength').value);
const brushRadius = brushSize * getMetersPerPixel();
for (const track of tracks) {
let modified = false;
for (let i = 1; i < track.coords.length - 1; i++) {
const pointLatLng = L.latLng(track.coords[i]);
const dist = map.distance(latlng, pointLatLng);
if (dist < brushRadius) {
// Point is within brush - smooth it using wider neighborhood
const curr = track.coords[i];
// Use wider neighborhood for stronger smoothing (up to 3 points each side)
let sumLat = 0, sumLng = 0, count = 0;
const neighborRange = Math.max(1, Math.floor(strength * 3)); // 1-3 neighbors based on strength
for (let j = Math.max(0, i - neighborRange); j <= Math.min(track.coords.length - 1, i + neighborRange); j++) {
sumLat += track.coords[j][0];
sumLng += track.coords[j][1];
count++;
}
const smoothedLat = sumLat / count;
const smoothedLng = sumLng / count;
// Blend based on strength and distance from brush center
const distFactor = 1 - (dist / brushRadius); // 1 at center, 0 at edge
const blend = strength * distFactor;
track.coords[i] = [
curr[0] + (smoothedLat - curr[0]) * blend,
curr[1] + (smoothedLng - curr[1]) * blend
];
modified = true;
}
}
if (modified) {
track.layer.setLatLngs(track.coords);
}
}
}
// Start dragging a point
function startReshapeDrag(latlng) {
const nearest = findNearestTrackPoint(latlng);
if (!nearest) return false;
saveStateForUndo();
isDragging = true;
dragTrack = nearest.track;
dragPointIndex = nearest.index;
originalCoords = dragTrack.coords.map(c => [...c]); // Deep copy
// Create marker at drag point
dragMarker = L.circleMarker(latlng, {
radius: 8,
color: '#ff0000',
fillColor: '#ff0000',
fillOpacity: 1,
weight: 2
}).addTo(map);
// Show affected range markers
showAffectedRange();
// Disable map dragging while we drag the point
map.dragging.disable();
updateStatus(`Dragging point ${dragPointIndex} of "${dragTrack.name}"`, 'info');
return true;
}
// Show markers for affected points range
function showAffectedRange() {
clearAffectedMarkers();
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
const anchorBefore = Math.max(0, dragPointIndex - anchorDist);
const anchorAfter = Math.min(originalCoords.length - 1, dragPointIndex + anchorDist);
// Show anchor points (green - fixed)
if (anchorBefore < dragPointIndex) {
const anchorMarkerBefore = L.circleMarker(originalCoords[anchorBefore], {
radius: 8,
color: '#00cc00',
fillColor: '#00cc00',
fillOpacity: 1,
weight: 2
}).addTo(map);
affectedMarkers.push(anchorMarkerBefore);
}
if (anchorAfter > dragPointIndex) {
const anchorMarkerAfter = L.circleMarker(originalCoords[anchorAfter], {
radius: 8,
color: '#00cc00',
fillColor: '#00cc00',
fillOpacity: 1,
weight: 2
}).addTo(map);
affectedMarkers.push(anchorMarkerAfter);
}
// Show chain points (orange - will move)
for (let i = anchorBefore + 1; i < anchorAfter; i++) {
if (i === dragPointIndex) continue; // Skip the drag point itself
const marker = L.circleMarker(originalCoords[i], {
radius: 5,
color: '#ff8800',
fillColor: '#ff8800',
fillOpacity: 0.7,
weight: 1
}).addTo(map);
affectedMarkers.push(marker);
}
}
// Clear affected range markers
function clearAffectedMarkers() {
affectedMarkers.forEach(m => map.removeLayer(m));
affectedMarkers = [];
}
// Find nearest snap target on other tracks
function findSnapTarget(latlng) {
if (!dragTrack) return null;
const snapDistPx = SNAP_DISTANCE_PX;
let nearest = null;
let minDist = Infinity;
for (const track of tracks) {
if (track === dragTrack) continue; // Skip the track we're dragging
for (let i = 0; i < track.coords.length; i++) {
const pointLatLng = L.latLng(track.coords[i]);
const pixelDist = map.latLngToLayerPoint(latlng).distanceTo(
map.latLngToLayerPoint(pointLatLng)
);
if (pixelDist < snapDistPx && pixelDist < minDist) {
minDist = pixelDist;
nearest = {
track: track,
index: i,
latlng: pointLatLng
};
}
}
}
return nearest;
}
// Show/hide snap indicator
function updateSnapIndicator(target) {
if (target) {
if (!snapMarker) {
snapMarker = L.circleMarker(target.latlng, {
radius: 12,
color: '#00ffff',
fillColor: '#00ffff',
fillOpacity: 0.5,
weight: 3
}).addTo(map);
} else {
snapMarker.setLatLng(target.latlng);
}
} else if (snapMarker) {
map.removeLayer(snapMarker);
snapMarker = null;
}
}
// Continue dragging
function continueReshapeDrag(latlng) {
if (!isDragging || !dragTrack) return;
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
const newPos = [latlng.lat, latlng.lng];
// Check for snap target if dragging an endpoint
const isEndpoint = dragPointIndex === 0 || dragPointIndex === originalCoords.length - 1;
if (isEndpoint) {
snapTarget = findSnapTarget(latlng);
updateSnapIndicator(snapTarget);
// If snapping, use snap position instead of mouse position
if (snapTarget) {
newPos[0] = snapTarget.latlng.lat;
newPos[1] = snapTarget.latlng.lng;
}
} else {
snapTarget = null;
updateSnapIndicator(null);
}
// Apply rope physics and update track
const newCoords = applyRopePhysics(originalCoords, dragPointIndex, newPos, anchorDist);
dragTrack.coords = newCoords;
dragTrack.layer.setLatLngs(newCoords);
// Update drag marker position
if (dragMarker) {
dragMarker.setLatLng(snapTarget ? snapTarget.latlng : latlng);
}
// Update affected markers positions
updateAffectedMarkersPositions(newCoords);
}
// Update affected markers to new positions
function updateAffectedMarkersPositions(newCoords) {
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
const anchorBefore = Math.max(0, dragPointIndex - anchorDist);
const anchorAfter = Math.min(newCoords.length - 1, dragPointIndex + anchorDist);
let markerIdx = 0;
// Skip anchor markers (they don't move), update chain markers
// First marker(s) are anchors, then chain points
if (anchorBefore < dragPointIndex) markerIdx++; // Skip anchor before
if (anchorAfter > dragPointIndex) markerIdx++; // Account for anchor after being in list
// Actually, let's rebuild to be clearer - anchors don't move, only chain points do
// Anchors are at indices 0 and 1 (if both exist), chain points follow
let anchorCount = 0;
if (anchorBefore < dragPointIndex) anchorCount++;
if (anchorAfter > dragPointIndex) anchorCount++;
let chainMarkerIdx = anchorCount;
for (let i = anchorBefore + 1; i < anchorAfter; i++) {
if (i === dragPointIndex) continue;
if (chainMarkerIdx < affectedMarkers.length) {
affectedMarkers[chainMarkerIdx].setLatLng(newCoords[i]);
chainMarkerIdx++;
}
}
}
// Finish dragging
function finishReshapeDrag() {
if (!isDragging) return;
const trackToMerge = dragTrack;
const wasSnapping = snapTarget !== null;
const snapInfo = snapTarget;
const draggingFromStart = dragPointIndex === 0;
const draggingFromEnd = dragPointIndex === originalCoords.length - 1;
isDragging = false;
dragTrack = null;
dragPointIndex = -1;
originalCoords = null;
// Remove markers
if (dragMarker) {
map.removeLayer(dragMarker);
dragMarker = null;
}
clearAffectedMarkers();
updateSnapIndicator(null);
snapTarget = null;
// Re-enable map dragging
map.dragging.enable();
// If snapping, connect the tracks at intersection
if (wasSnapping && snapInfo) {
mergeTracks(trackToMerge, draggingFromStart, snapInfo);
updateStatus('Tracks connected at intersection', 'success');
} else {
updateStatus('Reshape complete', 'success');
}
}
// Connect tracks at snap point (split target if snapping to middle)
function mergeTracks(sourceTrack, fromStart, snapInfo) {
const targetTrack = snapInfo.track;
const targetIndex = snapInfo.index;
const isTargetStart = targetIndex === 0;
const isTargetEnd = targetIndex === targetTrack.coords.length - 1;
const snapCoord = targetTrack.coords[targetIndex];
// Update source track endpoint to snap position
if (fromStart) {
sourceTrack.coords[0] = [...snapCoord];
} else {
sourceTrack.coords[sourceTrack.coords.length - 1] = [...snapCoord];
}
sourceTrack.layer.setLatLngs(sourceTrack.coords);
// If snapping to middle of target, split it into two tracks
if (!isTargetStart && !isTargetEnd) {
// Create first part (start to snap point)
const coords1 = targetTrack.coords.slice(0, targetIndex + 1);
// Create second part (snap point to end)
const coords2 = targetTrack.coords.slice(targetIndex);
// Update target track to be first part
targetTrack.coords = coords1;
targetTrack.layer.setLatLngs(coords1);
targetTrack.name = targetTrack.name + ' (1)';
// Create new track for second part
const newTrack = new Track(coords2, targetTrack.name.replace(' (1)', ' (2)'));
tracks.push(newTrack);
}
updateTrackList();
}
// Cancel dragging (restore original)
function cancelReshapeDrag() {
if (!isDragging || !dragTrack || !originalCoords) return;
// Restore original coordinates
dragTrack.coords = originalCoords;
dragTrack.layer.setLatLngs(originalCoords);
// Remove the undo state we just added
undoStack.pop();
updateUndoButton();
isDragging = false;
dragTrack = null;
dragPointIndex = -1;
originalCoords = null;
if (dragMarker) {
map.removeLayer(dragMarker);
dragMarker = null;
}
clearAffectedMarkers();
updateSnapIndicator(null);
snapTarget = null;
map.dragging.enable();
updateStatus('Reshape cancelled', 'info');
}
// Toggle track selection (multi-select)
function selectTrack(track) {
const idx = selectedTracks.indexOf(track);
if (idx > -1) {
// Deselect
selectedTracks.splice(idx, 1);
track.setSelected(false);
} else {
// Add to selection
selectedTracks.push(track);
track.setSelected(true);
}
updateTrackList();
if (selectedTracks.length === 0) {
updateStatus('Click tracks to select them', 'info');
} else if (selectedTracks.length === 1) {
updateStatus(`Selected: ${selectedTracks[0].name} (${selectedTracks[0].coords.length} points)`);
} else {
updateStatus(`Selected ${selectedTracks.length} tracks`);
}
}
// Clear all selections
function clearSelection() {
selectedTracks.forEach(t => t.setSelected(false));
selectedTracks = [];
updateTrackList();
updateStatus('Selection cleared', 'info');
}
// Select all tracks
function selectAll() {
selectedTracks.forEach(t => t.setSelected(false));
selectedTracks = [...tracks];
selectedTracks.forEach(t => t.setSelected(true));
updateTrackList();
updateStatus(`Selected all ${tracks.length} tracks`);
}
// Store split markers
const splitMarkers = [];
// Split a track at a point
function remeshTrack(track, targetSpacing = 5) {
saveStateForUndo();
const coords = track.coords;
if (coords.length < 2) return;
const newCoords = [];
// Find all intersection points with other tracks
const intersectionIndices = new Set();
for (const otherTrack of tracks) {
if (otherTrack === track) continue;
for (let i = 0; i < coords.length; i++) {
const point = L.latLng(coords[i]);
for (const otherCoord of otherTrack.coords) {
const otherPoint = L.latLng(otherCoord);
const dist = map.distance(point, otherPoint);
if (dist <= adminSettings.intersectionThreshold) {
intersectionIndices.add(i);
break;
}
}
}
}
// Always start with first point
newCoords.push(coords[0]);
// Walk along the track and place points at regular intervals
let distanceFromLastPoint = 0;
for (let i = 1; i < coords.length; i++) {
const prevCoord = coords[i - 1];
const currCoord = coords[i];
const prevPoint = L.latLng(prevCoord);
const currPoint = L.latLng(currCoord);
const segmentDist = map.distance(prevPoint, currPoint);
// If this segment is longer than target spacing, we need to add intermediate points
if (distanceFromLastPoint + segmentDist > targetSpacing) {
// Calculate how many points we need to add
let remainingDist = segmentDist;
let segmentStart = prevPoint;
while (distanceFromLastPoint + remainingDist > targetSpacing) {
// Calculate where to place the next point
const distToNext = targetSpacing - distanceFromLastPoint;
const ratio = distToNext / remainingDist;
// Interpolate the position
const lat = segmentStart.lat + ratio * (currPoint.lat - segmentStart.lat);
const lng = segmentStart.lng + ratio * (currPoint.lng - segmentStart.lng);
newCoords.push([lat, lng]);
// Update for next iteration
segmentStart = L.latLng(lat, lng);
remainingDist = map.distance(segmentStart, currPoint);
distanceFromLastPoint = 0;
}
// Check if we should add the current point (intersection or endpoint)
if (intersectionIndices.has(i) || i === coords.length - 1) {
newCoords.push(currCoord);
distanceFromLastPoint = 0;
} else {
distanceFromLastPoint = remainingDist;
}
} else {
// Segment is short, accumulate distance
distanceFromLastPoint += segmentDist;
// Add point if it's an intersection or the last point
if (intersectionIndices.has(i) || i === coords.length - 1) {
newCoords.push(currCoord);
distanceFromLastPoint = 0;
}
}
}
// Update track with new coordinates
track.coords = newCoords;
track.updateDisplay();
// Clear track cache and force graph rebuild
trailGraph = null;
// Visual feedback - briefly highlight the track
track.layer.setStyle({ color: '#00ff00', weight: 6 });
setTimeout(() => {
track.layer.setStyle({ color: '#3388ff', weight: 4 });
}, 500);
updateStatus(`Remeshed "${track.name}": ${coords.length} points → ${newCoords.length} points`, 'success');
}
function splitTrack(track, latlng) {
saveStateForUndo();
// Find closest point on track
let minDist = Infinity;
let splitIndex = 0;
for (let i = 0; i < track.coords.length; i++) {
const dist = map.distance(latlng, L.latLng(track.coords[i]));
if (dist < minDist) {
minDist = dist;
splitIndex = i;
}
}
if (splitIndex === 0 || splitIndex === track.coords.length - 1) {
updateStatus('Cannot split at track endpoints', 'error');
return;
}
// Get the actual split point coordinates
const splitPoint = track.coords[splitIndex];
// Create two new tracks
const coords1 = track.coords.slice(0, splitIndex + 1);
const coords2 = track.coords.slice(splitIndex);
const track1 = new Track(coords1, track.name + ' (part 1)', track.description);
const track2 = new Track(coords2, track.name + ' (part 2)', track.description);
// Add a marker at the split point
const splitMarker = L.marker(splitPoint, {
icon: L.divIcon({
className: 'split-marker',
html: '<div style="background: #ff4444; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).addTo(map);
splitMarker.bindPopup(`Split point<br><small>${track.name}</small>`);
splitMarkers.push(splitMarker);
// Remove original and add new tracks
const idx = tracks.indexOf(track);
track.remove();
tracks.splice(idx, 1, track1, track2);
updateTrackList();
updateStatus(`Split "${track.name}" into 2 tracks`);
}
// Delete a track
function deleteTrack(track) {
saveStateForUndo();
const idx = tracks.indexOf(track);
if (idx > -1) {
track.remove();
tracks.splice(idx, 1);
// Remove from selection if selected
const selIdx = selectedTracks.indexOf(track);
if (selIdx > -1) {
selectedTracks.splice(selIdx, 1);
}
updateTrackList();
updateStatus(`Deleted: ${track.name}`);
}
}
// Drawing functions
function startDrawing(latlng) {
isDrawing = true;
drawingPoints = [latlng];
drawingLine = L.polyline(drawingPoints, {
color: '#ff8800',
weight: 3,
dashArray: '5, 10'
}).addTo(map);
}
function continueDrawing(latlng) {
drawingPoints.push(latlng);
drawingLine.setLatLngs(drawingPoints);
}
function finishDrawing() {
if (drawingPoints.length < 2) {
cancelDrawing();
return;
}
const name = prompt('Enter track name:', `Track ${tracks.length + 1}`);
if (name !== null) {
const coords = drawingPoints.map(ll => [ll.lat, ll.lng]);
const track = new Track(coords, name || `Track ${tracks.length + 1}`);
tracks.push(track);
updateTrackList();
updateStatus(`Created: ${track.name}`);
}
cancelDrawing();
}
function cancelDrawing() {
isDrawing = false;
drawingPoints = [];
if (drawingLine) {
map.removeLayer(drawingLine);
drawingLine = null;
}
}
// Navigation press-and-hold variables
let pressTimer = null;
let isPressing = false;
let pendingDestination = null;
let touchStartTime = 0;
let lastTapTime = 0;
let lastTapLocation = null;
// Navigation confirmation dialog handlers
const el_navConfirmYes = document.getElementById('navConfirmYes');
if (el_navConfirmYes) {
el_navConfirmYes.addEventListener('click', () => {
document.getElementById('navConfirmDialog').style.display = 'none';
if (pendingDestination) {
setDestination(pendingDestination.track, pendingDestination.index);
pendingDestination = null;
}
});
}
const el_navConfirmNo = document.getElementById('navConfirmNo');
if (el_navConfirmNo) {
el_navConfirmNo.addEventListener('click', () => {
document.getElementById('navConfirmDialog').style.display = 'none';
pendingDestination = null;
});
}
// Press and hold handlers for navigation mode
function startPressHold(e) {
if (!navMode) return false;
// Don't allow press-hold navigation when monsters are present
if (monsterEntourage.length > 0) return false;
const nearest = findNearestTrackPoint(e.latlng, 100);
if (!nearest) return false;
isPressing = true;
pendingDestination = nearest;
// Show indicator
document.getElementById('pressHoldIndicator').style.display = 'block';
// Start timer for 500ms hold
pressTimer = setTimeout(() => {
// Re-check for monsters (they might have spawned during the hold)
if (isPressing && monsterEntourage.length === 0) {
document.getElementById('pressHoldIndicator').style.display = 'none';
// Show confirmation dialog
const message = `Navigate to ${nearest.track.name}?`;
document.getElementById('navConfirmMessage').textContent = message;
ensurePopupInBody('navConfirmDialog');
document.getElementById('navConfirmDialog').style.display = 'flex';
isPressing = false;
} else if (isPressing) {
// Monsters appeared during press - cancel silently
cancelPressHold();
}
}, 500);
return true;
}
function cancelPressHold() {
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
isPressing = false;
document.getElementById('pressHoldIndicator').style.display = 'none';
}
// Map mouse/touch handlers
map.on('mousedown', (e) => {
if (navMode) {
if (startPressHold(e)) {
L.DomEvent.stopPropagation(e);
L.DomEvent.preventDefault(e);
}
}
});
// Direct touch event binding for mobile (Leaflet doesn't support touchstart through map.on)
const mapContainer = map.getContainer();
// Variables for home base double-tap detection
let homeBaseTapTime = 0;
let homeBaseTapLocation = null;
// Fix for Chrome and PWA - use native addEventListener with passive: false
mapContainer.addEventListener('touchstart', function(e) {
// Home base selection mode takes priority - capture touch for double-tap detection
if (homeBaseSelectionMode && e.touches.length === 1) {
e.preventDefault();
e.stopPropagation();
touchStartTime = Date.now();
return;
}
if (navMode && e.touches.length === 1) {
// Check if touch target is a geocache marker - let those through to Leaflet
if (e.target.closest('.geocache-marker')) {
return; // Let Leaflet handle geocache taps
}
// ALWAYS prevent default in navMode to stop Leaflet from synthesizing dblclick
// This fixes the 50/50 bug where both touchend and dblclick handlers race
e.preventDefault();
touchStartTime = Date.now();
const touch = e.touches[0];
const rect = mapContainer.getBoundingClientRect();
// Accurate coordinate calculation using getBoundingClientRect
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
const containerPoint = L.point(x, y);
const latlng = map.containerPointToLatLng(containerPoint);
// Start press-hold (will return false if monsters present)
startPressHold({ latlng: latlng });
}
}, { passive: false, capture: true });
mapContainer.addEventListener('touchend', function(e) {
// Home base selection mode - handle double-tap to place home base
if (homeBaseSelectionMode) {
e.preventDefault();
e.stopPropagation();
const now = Date.now();
const timeSinceLastTap = now - homeBaseTapTime;
// Get current tap location
let currentTapLocation = null;
if (e.changedTouches && e.changedTouches.length > 0) {
const touch = e.changedTouches[0];
currentTapLocation = { x: touch.clientX, y: touch.clientY };
}
// Check for double-tap (two taps within 400ms at roughly same location)
if (timeSinceLastTap < 400 && homeBaseTapLocation && currentTapLocation) {
const dx = currentTapLocation.x - homeBaseTapLocation.x;
const dy = currentTapLocation.y - homeBaseTapLocation.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Only trigger if taps are within 50 pixels of each other
if (distance < 50) {
// Convert touch to latlng and set home base
const rect = mapContainer.getBoundingClientRect();
const x = currentTapLocation.x - rect.left;
const y = currentTapLocation.y - rect.top;
const containerPoint = L.point(x, y);
const latlng = map.containerPointToLatLng(containerPoint);
setHomeBase(latlng.lat, latlng.lng);
// Reset double-tap detection
homeBaseTapTime = 0;
homeBaseTapLocation = null;
return;
}
}
// Store this tap for double-tap detection
homeBaseTapTime = now;
homeBaseTapLocation = currentTapLocation;
return;
}
if (navMode) {
const now = Date.now();
const timeSinceLastTap = now - lastTapTime;
// Get current tap location
let currentTapLocation = null;
if (e.changedTouches && e.changedTouches.length > 0) {
const touch = e.changedTouches[0];
currentTapLocation = { x: touch.clientX, y: touch.clientY };
}
// Check for double-tap (two taps within 300ms at roughly same location)
if (timeSinceLastTap < 300 && lastTapLocation && currentTapLocation) {
// Calculate distance between taps
const dx = currentTapLocation.x - lastTapLocation.x;
const dy = currentTapLocation.y - lastTapLocation.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Only trigger if taps are within 30 pixels of each other
if (distance < 30) {
e.preventDefault();
e.stopPropagation();
document.getElementById('pressHoldIndicator').style.display = 'none';
// Get latlng from touch position
const rect = mapContainer.getBoundingClientRect();
const x = currentTapLocation.x - rect.left;
const y = currentTapLocation.y - rect.top;
const containerPoint = L.point(x, y);
const latlng = map.containerPointToLatLng(containerPoint);
// If monsters are present, double-tap is for combat only
if (monsterEntourage.length > 0) {
const nearestMonster = findNearestMonster(latlng, 50);
if (nearestMonster) {
initiateCombat(nearestMonster.monster);
}
// Don't allow navigation when monsters are around
lastTapTime = 0;
lastTapLocation = null;
cancelPressHold();
return;
}
// No monsters - show navigation dialog if we have a destination
if (pendingDestination) {
const message = `Navigate to ${pendingDestination.track.name}?`;
document.getElementById('navConfirmMessage').textContent = message;
ensurePopupInBody('navConfirmDialog');
document.getElementById('navConfirmDialog').style.display = 'flex';
}
lastTapTime = 0; // Reset to prevent triple tap
lastTapLocation = null;
} else {
// Taps too far apart - treat as new first tap
lastTapTime = now;
lastTapLocation = currentTapLocation;
}
} else {
// Store this tap for double-tap detection
lastTapTime = now;
lastTapLocation = currentTapLocation;
}
if (isPressing) {
e.preventDefault();
}
cancelPressHold();
} else if (isPressing) {
e.preventDefault();
cancelPressHold();
}
}, { passive: false });
mapContainer.addEventListener('touchcancel', cancelPressHold, { passive: false });
mapContainer.addEventListener('touchmove', function(e) {
if (isPressing) {
e.preventDefault();
cancelPressHold();
}
}, { passive: false });
// Mouse events for desktop
map.on('mouseup', cancelPressHold);
map.on('mousemove', (e) => {
if (isPressing) {
// Cancel if mouse moves too much during press
cancelPressHold();
}
});
// Map click handler
map.on('click', (e) => {
// Handle home base selection mode
if (homeBaseSelectionMode) {
setHomeBase(e.latlng.lat, e.latlng.lng);
return;
}
// In navigation mode, clicks are handled by press-and-hold
if (navMode) {
return;
}
if (currentTool === 'draw') {
if (!isDrawing) {
startDrawing(e.latlng);
} else {
continueDrawing(e.latlng);
}
} else if (currentTool === 'geocache') {
// Place a new geocache
if (!navMode) {
// In edit mode, place anywhere
placeGeocache(e.latlng);
} else {
// In nav mode, must have GPS enabled and be at the location
if (userLocation) {
const distance = L.latLng(userLocation.lat, userLocation.lng).distanceTo(e.latlng);
if (distance <= 10) { // Within 10 meters of click location
placeGeocache(e.latlng);
} else {
alert('You must be at the location to place a geocache! (within 10 meters)');
}
} else {
alert('GPS tracking must be enabled to place geocaches in navigation mode!');
}
}
}
// Don't auto-deselect on map click - use Clear Selection button instead
});
map.on('dblclick', (e) => {
// Skip on touch devices - handled by touchend handler instead
// This prevents the 50/50 race condition between handlers
if ('ontouchstart' in window) return;
// Home base selection mode - double-click to place
if (homeBaseSelectionMode) {
L.DomEvent.stopPropagation(e);
L.DomEvent.preventDefault(e);
setHomeBase(e.latlng.lat, e.latlng.lng);
return;
}
if (navMode) {
L.DomEvent.stopPropagation(e);
L.DomEvent.preventDefault(e);
// If monsters are present, double-tap is for combat only
if (monsterEntourage.length > 0) {
const nearestMonster = findNearestMonster(e.latlng, 50);
if (nearestMonster) {
initiateCombat(nearestMonster.monster);
}
// Don't allow navigation when monsters are around
return;
}
// No monsters - double-tap sets navigation destination
const nearest = findNearestTrackPoint(e.latlng);
if (nearest && nearest.distance < 50) {
pendingDestination = nearest;
const message = `Navigate to ${nearest.track.name}?`;
document.getElementById('navConfirmMessage').textContent = message;
ensurePopupInBody('navConfirmDialog');
document.getElementById('navConfirmDialog').style.display = 'flex';
}
} else if (currentTool === 'draw' && isDrawing) {
L.DomEvent.stopPropagation(e);
finishDrawing();
}
});
// Reshape tool mouse handlers
map.on('mousedown', (e) => {
if (currentTool === 'reshape' && !isDragging) {
if (startReshapeDrag(e.latlng)) {
L.DomEvent.stopPropagation(e);
}
}
if (currentTool === 'smooth' && !isSmoothing) {
startSmoothing(e.latlng);
map.dragging.disable();
}
});
map.on('mousemove', (e) => {
if (currentTool === 'reshape' && isDragging) {
continueReshapeDrag(e.latlng);
}
if (currentTool === 'smooth' && isSmoothing) {
continueSmoothing(e.latlng);
}
});
map.on('mouseup', (e) => {
if (currentTool === 'reshape' && isDragging) {
finishReshapeDrag();
}
if (currentTool === 'smooth' && isSmoothing) {
finishSmoothing();
map.dragging.enable();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Escape to cancel reshape
if (e.key === 'Escape' && isDragging) {
cancelReshapeDrag();
}
// Delete key to delete selected tracks
if (e.key === 'Delete' && selectedTracks.length > 0) {
saveStateForUndo();
const count = selectedTracks.length;
selectedTracks.forEach(track => {
const idx = tracks.indexOf(track);
if (idx > -1) {
track.remove();
tracks.splice(idx, 1);
}
});
selectedTracks = [];
updateTrackList();
updateStatus(`Deleted ${count} track(s)`, 'success');
}
});
// Merge selected tracks by connecting end-to-end (in selection order)
function mergeConnect() {
if (selectedTracks.length < 2) {
updateStatus('Select at least 2 tracks to merge', 'error');
return;
}
saveStateForUndo();
// Connect tracks in selection order, finding best endpoint connections
let mergedCoords = [...selectedTracks[0].coords];
for (let i = 1; i < selectedTracks.length; i++) {
const nextCoords = selectedTracks[i].coords;
// Find best connection: check all 4 endpoint combinations
const currentEnd = L.latLng(mergedCoords[mergedCoords.length - 1]);
const currentStart = L.latLng(mergedCoords[0]);
const nextStart = L.latLng(nextCoords[0]);
const nextEnd = L.latLng(nextCoords[nextCoords.length - 1]);
const d1 = map.distance(currentEnd, nextStart); // end -> start (normal)
const d2 = map.distance(currentEnd, nextEnd); // end -> end (reverse next)
const d3 = map.distance(currentStart, nextStart); // start -> start (reverse current)
const d4 = map.distance(currentStart, nextEnd); // start -> end (reverse both)
const minDist = Math.min(d1, d2, d3, d4);
if (minDist === d1) {
// Normal: append next
mergedCoords = [...mergedCoords, ...nextCoords.slice(1)];
} else if (minDist === d2) {
// Reverse next track
mergedCoords = [...mergedCoords, ...nextCoords.slice(0, -1).reverse()];
} else if (minDist === d3) {
// Reverse current, then append next
mergedCoords = [...mergedCoords.reverse(), ...nextCoords.slice(1)];
} else {
// Reverse current, reverse next
mergedCoords = [...mergedCoords.reverse(), ...nextCoords.slice(0, -1).reverse()];
}
}
// Create new track
const numMerged = selectedTracks.length;
const name = prompt('Enter name for merged track:', selectedTracks.map(t => t.name).join(' + '));
if (name === null) return;
const newTrack = new Track(mergedCoords, name || 'Merged Track');
// Remove old tracks
selectedTracks.forEach(track => {
const idx = tracks.indexOf(track);
if (idx > -1) {
track.remove();
tracks.splice(idx, 1);
}
});
tracks.push(newTrack);
selectedTracks = [newTrack];
newTrack.setSelected(true);
updateTrackList();
updateStatus(`Merged ${numMerged} tracks into "${newTrack.name}"`, 'success');
}
// Merge selected tracks by averaging overlapping points
function mergeAverage() {
if (selectedTracks.length < 2) {
updateStatus('Select at least 2 tracks to merge', 'error');
return;
}
const threshold = 25; // meters - points within this distance get averaged
// Use the longest track as the base
const sortedByLength = [...selectedTracks].sort((a, b) => b.coords.length - a.coords.length);
const baseTrack = sortedByLength[0];
const otherTracks = sortedByLength.slice(1);
// For each point in other tracks, check if it's near ANY point in base track
const otherTrackData = otherTracks.map(track => ({
track,
points: track.coords.map(coord => {
// Check if this point is near any base track point
let isNearBase = false;
for (const baseCoord of baseTrack.coords) {
if (map.distance(L.latLng(coord), L.latLng(baseCoord)) < threshold) {
isNearBase = true;
break;
}
}
return { coord, isNearBase };
})
}));
// For each point in base, find and average nearby points from other tracks
const result = [];
const usedPoints = otherTrackData.map(d => d.points.map(() => false));
for (let i = 0; i < baseTrack.coords.length; i++) {
const basePt = L.latLng(baseTrack.coords[i]);
let sumLat = baseTrack.coords[i][0];
let sumLng = baseTrack.coords[i][1];
let count = 1;
// Find closest point from each other track
for (let t = 0; t < otherTrackData.length; t++) {
const otherData = otherTrackData[t];
let closestDist = Infinity;
let closestIdx = -1;
for (let j = 0; j < otherData.points.length; j++) {
if (usedPoints[t][j]) continue;
const pt = otherData.points[j];
const dist = map.distance(basePt, L.latLng(pt.coord));
if (dist < closestDist && dist < threshold) {
closestDist = dist;
closestIdx = j;
}
}
if (closestIdx >= 0) {
const pt = otherData.points[closestIdx];
sumLat += pt.coord[0];
sumLng += pt.coord[1];
count++;
usedPoints[t][closestIdx] = true;
}
}
result.push([sumLat / count, sumLng / count]);
}
// Collect non-overlapping segments (points NOT near base track)
const branches = [];
for (let t = 0; t < otherTrackData.length; t++) {
const otherData = otherTrackData[t];
let currentBranch = [];
for (let i = 0; i < otherData.points.length; i++) {
const pt = otherData.points[i];
if (!pt.isNearBase) {
// This point is NOT near the base track - it's a branch
currentBranch.push(pt.coord);
} else {
// Point is near base, save any accumulated branch
if (currentBranch.length >= 2) {
branches.push({
coords: [...currentBranch],
trackName: otherData.track.name
});
}
currentBranch = [];
}
}
// Don't forget trailing branch
if (currentBranch.length >= 2) {
branches.push({
coords: [...currentBranch],
trackName: otherData.track.name
});
}
}
// First, remove old tracks
const numMerged = selectedTracks.length;
selectedTracks.forEach(track => {
const idx = tracks.indexOf(track);
if (idx > -1) {
track.remove();
tracks.splice(idx, 1);
}
});
// Create branch tracks (in orange so they're visible)
const branchTracks = [];
for (let i = 0; i < branches.length; i++) {
const branch = branches[i];
const branchTrack = new Track(branch.coords, `${branch.trackName} (branch ${i + 1})`);
branchTrack.layer.setStyle({ color: '#ff8800', weight: 4 }); // Orange
tracks.push(branchTrack);
branchTracks.push(branchTrack);
}
// Create new track for the averaged main path
const name = prompt('Enter name for averaged track:', baseTrack.name + ' (averaged)');
if (name === null) {
// User cancelled, but we already deleted tracks... restore branches at least
updateTrackList();
updateStatus('Cancelled - branches preserved', 'info');
return;
}
const newTrack = new Track(result, name || 'Averaged Track');
tracks.push(newTrack);
selectedTracks = [newTrack];
newTrack.setSelected(true);
updateTrackList();
const branchMsg = branches.length > 0 ? ` + ${branches.length} branch(es) in orange` : '';
updateStatus(`Averaged ${numMerged} tracks${branchMsg}`, 'success');
}
// === PREVIEW SYSTEM ===
function startPreview() {
if (selectedTracks.length === 0) {
updateStatus('Select at least 1 track to preview', 'error');
return;
}
previewMode = true;
const threshold = parseInt(document.getElementById('mergeThreshold').value);
// Hide original tracks
selectedTracks.forEach(t => t.layer.setStyle({ opacity: 0.2 }));
// Generate and show preview
updatePreview(threshold);
// Update UI
document.getElementById('previewBtn').style.display = 'none';
document.getElementById('applyMergeBtn').style.display = 'block';
document.getElementById('cancelPreviewBtn').style.display = 'block';
document.getElementById('mergeThreshold').classList.add('preview-active');
updateStatus('Adjust slider to fine-tune, then Apply or Cancel', 'info');
}
function updatePreview(threshold) {
// Clear old preview layers
previewLayers.forEach(layer => map.removeLayer(layer));
previewLayers = [];
if (selectedTracks.length === 1) {
// Single track: simplify by merging points that double back
previewData = simplifySingleTrack(selectedTracks[0].coords, threshold);
} else {
// Multiple tracks: merge them together
previewData = mergeMultipleTracks(selectedTracks, threshold);
}
// Show preview of main track (green dashed)
if (previewData.mainCoords.length > 0) {
const mainPreview = L.polyline(previewData.mainCoords, {
color: '#00cc00',
weight: 5,
opacity: 0.9,
dashArray: '10, 5'
}).addTo(map);
previewLayers.push(mainPreview);
}
// Show preview of branches (orange dashed)
previewData.branches.forEach(branch => {
const branchPreview = L.polyline(branch.coords, {
color: '#ff8800',
weight: 4,
opacity: 0.9,
dashArray: '10, 5'
}).addTo(map);
previewLayers.push(branchPreview);
});
// Update status with stats
const reduction = selectedTracks.reduce((sum, t) => sum + t.coords.length, 0) - previewData.mainCoords.length;
const branchInfo = previewData.branches.length > 0 ? `, ${previewData.branches.length} branch(es)` : '';
updateStatus(`Preview: ${previewData.mainCoords.length} points${branchInfo} (${reduction} points merged)`, 'info');
}
function cancelPreview() {
previewMode = false;
// Remove preview layers
previewLayers.forEach(layer => map.removeLayer(layer));
previewLayers = [];
previewData = null;
// Restore original tracks
selectedTracks.forEach(t => {
t.layer.setStyle({ opacity: 0.8 });
t.setSelected(true);
});
// Update UI
document.getElementById('previewBtn').style.display = 'block';
document.getElementById('applyMergeBtn').style.display = 'none';
document.getElementById('cancelPreviewBtn').style.display = 'none';
document.getElementById('mergeThreshold').classList.remove('preview-active');
updateStatus('Preview cancelled', 'info');
}
function applyMerge() {
if (!previewData) return;
saveStateForUndo();
// Remove preview layers
previewLayers.forEach(layer => map.removeLayer(layer));
previewLayers = [];
// Remove old tracks
const numMerged = selectedTracks.length;
const oldNames = selectedTracks.map(t => t.name);
selectedTracks.forEach(track => {
const idx = tracks.indexOf(track);
if (idx > -1) {
track.remove();
tracks.splice(idx, 1);
}
});
// Create branch tracks
previewData.branches.forEach((branch, i) => {
const branchTrack = new Track(branch.coords, `${oldNames[0]} (branch ${i + 1})`);
branchTrack.layer.setStyle({ color: '#ff8800' });
tracks.push(branchTrack);
});
// Create main merged track
const defaultName = numMerged === 1 ? `${oldNames[0]} (simplified)` : oldNames.join(' + ');
const name = prompt('Enter name for merged track:', defaultName);
if (name !== null) {
const newTrack = new Track(previewData.mainCoords, name || defaultName);
tracks.push(newTrack);
selectedTracks = [newTrack];
newTrack.setSelected(true);
} else {
selectedTracks = [];
}
// Reset state
previewMode = false;
previewData = null;
// Update UI
document.getElementById('previewBtn').style.display = 'block';
document.getElementById('applyMergeBtn').style.display = 'none';
document.getElementById('cancelPreviewBtn').style.display = 'none';
document.getElementById('mergeThreshold').classList.remove('preview-active');
updateTrackList();
updateStatus(`Applied merge: ${numMerged} track(s)`, 'success');
}
// Simplify a single track: merge points that are spatially close AND temporally far apart
// This detects double-backs while preserving normal path segments
function simplifySingleTrack(coords, threshold) {
if (coords.length < 3) {
return { mainCoords: coords, branches: [] };
}
// Minimum sequence gap required to consider merging
// Points closer than this in sequence won't be merged even if spatially close
const minSequenceGap = 15;
const clusters = []; // { points: [], centroid: [], pointIndices: [] }
const pointToCluster = new Array(coords.length).fill(-1);
for (let i = 0; i < coords.length; i++) {
const pt = L.latLng(coords[i]);
// Find if this point should join an existing cluster
// Requirements:
// 1. Spatially close (within threshold)
// 2. Temporally far (no points from that cluster in recent window)
let bestCluster = -1;
let bestDist = Infinity;
for (let c = 0; c < clusters.length; c++) {
const cluster = clusters[c];
const dist = map.distance(pt, L.latLng(cluster.centroid));
if (dist < threshold && dist < bestDist) {
// Check temporal distance - cluster must not have recent points
const mostRecentInCluster = Math.max(...cluster.pointIndices);
const sequenceGap = i - mostRecentInCluster;
if (sequenceGap >= minSequenceGap) {
// Far enough apart temporally - this is a double-back
bestDist = dist;
bestCluster = c;
}
}
}
if (bestCluster >= 0) {
// Merge with existing cluster (double-back detected)
const cluster = clusters[bestCluster];
cluster.points.push(coords[i]);
cluster.pointIndices.push(i);
// Recalculate centroid
let sumLat = 0, sumLng = 0;
for (const p of cluster.points) {
sumLat += p[0];
sumLng += p[1];
}
cluster.centroid = [sumLat / cluster.points.length, sumLng / cluster.points.length];
pointToCluster[i] = bestCluster;
} else {
// Create new cluster (new territory or too recent to merge)
clusters.push({
points: [coords[i]],
centroid: [...coords[i]],
pointIndices: [i]
});
pointToCluster[i] = clusters.length - 1;
}
}
// Rebuild path following original order, using cluster centroids
// Remove consecutive duplicates (same cluster visited multiple times in a row)
const result = [];
let lastCluster = -1;
for (let i = 0; i < coords.length; i++) {
const clusterIdx = pointToCluster[i];
if (clusterIdx !== lastCluster) {
result.push([...clusters[clusterIdx].centroid]);
lastCluster = clusterIdx;
}
}
return { mainCoords: result, branches: [] };
}
// Merge multiple tracks together
function mergeMultipleTracks(tracksToMerge, threshold) {
// Use the longest track as the base
const sortedByLength = [...tracksToMerge].sort((a, b) => b.coords.length - a.coords.length);
const baseTrack = sortedByLength[0];
const otherTracks = sortedByLength.slice(1);
// For each point in other tracks, check if it's near ANY point in base track
const otherTrackData = otherTracks.map(track => ({
track,
points: track.coords.map(coord => {
let isNearBase = false;
for (const baseCoord of baseTrack.coords) {
if (map.distance(L.latLng(coord), L.latLng(baseCoord)) < threshold) {
isNearBase = true;
break;
}
}
return { coord, isNearBase };
})
}));
// For each base point, find corresponding points in other tracks using sequence matching
const result = [];
// Track position in each other track (to maintain order)
const trackPositions = otherTracks.map(() => 0);
for (let i = 0; i < baseTrack.coords.length; i++) {
const basePt = L.latLng(baseTrack.coords[i]);
let sumLat = baseTrack.coords[i][0];
let sumLng = baseTrack.coords[i][1];
let count = 1;
// For each other track, find the best matching point near current position
for (let t = 0; t < otherTracks.length; t++) {
const otherCoords = otherTracks[t].coords;
let bestIdx = -1;
let bestDist = Infinity;
// Search in a window around the current track position
const searchStart = Math.max(0, trackPositions[t] - 10);
const searchEnd = Math.min(otherCoords.length, trackPositions[t] + 50);
for (let j = searchStart; j < searchEnd; j++) {
const dist = map.distance(basePt, L.latLng(otherCoords[j]));
if (dist < threshold && dist < bestDist) {
bestDist = dist;
bestIdx = j;
}
}
if (bestIdx >= 0) {
sumLat += otherCoords[bestIdx][0];
sumLng += otherCoords[bestIdx][1];
count++;
// Move track position forward (only forward, to maintain order)
if (bestIdx >= trackPositions[t]) {
trackPositions[t] = bestIdx + 1;
}
}
}
result.push([sumLat / count, sumLng / count]);
}
// Collect branches (non-overlapping segments)
const branches = [];
for (let t = 0; t < otherTrackData.length; t++) {
const otherData = otherTrackData[t];
let currentBranch = [];
for (let i = 0; i < otherData.points.length; i++) {
const pt = otherData.points[i];
if (!pt.isNearBase) {
currentBranch.push(pt.coord);
} else {
if (currentBranch.length >= 2) {
branches.push({ coords: [...currentBranch] });
}
currentBranch = [];
}
}
if (currentBranch.length >= 2) {
branches.push({ coords: [...currentBranch] });
}
}
return { mainCoords: result, branches };
}
// Parse KML
function parseKML(kmlText) {
const parser = new DOMParser();
const kml = parser.parseFromString(kmlText, 'text/xml');
const placemarks = kml.querySelectorAll('Placemark');
let count = 0;
placemarks.forEach(placemark => {
const name = placemark.querySelector('name')?.textContent || 'Track';
const description = placemark.querySelector('description')?.textContent || '';
// Handle LineString
const lineStrings = placemark.querySelectorAll('LineString');
lineStrings.forEach(lineString => {
const coordsText = lineString.querySelector('coordinates')?.textContent;
const coords = parseCoordinates(coordsText);
if (coords.length > 0) {
const track = new Track(coords, name, description);
tracks.push(track);
count++;
}
});
});
return count;
}
// Parse coordinates
function parseCoordinates(coordString) {
if (!coordString) return [];
const coords = [];
const points = coordString.trim().split(/\s+/);
points.forEach(point => {
const parts = point.split(',');
if (parts.length >= 2) {
const lng = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
if (!isNaN(lat) && !isNaN(lng)) {
coords.push([lat, lng]);
}
}
});
return coords;
}
// Export to KML
function exportToKML() {
let placemarks = '';
tracks.forEach(track => {
const coords = track.coords.map(c => `${c[1]},${c[0]},0`).join(' ');
placemarks += `
<Placemark>
<name>${escapeXml(track.name)}</name>
<description>${escapeXml(track.description)}</description>
<LineString>
<coordinates>${coords}</coordinates>
</LineString>
</Placemark>`;
});
const kml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Exported Tracks</name>${placemarks}
</Document>
</kml>`;
const blob = new Blob([kml], { type: 'application/vnd.google-earth.kml+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'tracks-export.kml';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
updateStatus('Exported KML file', 'success');
}
function reloadTracks() {
// Clear existing tracks
tracks.forEach(track => track.remove());
tracks.length = 0;
selectedTracks = [];
// Reload from server with cache busting
fetch('default.kml?t=' + Date.now())
.then(response => {
if (!response.ok) throw new Error('default.kml not found');
return response.text();
})
.then(kmlText => {
const count = parseKML(kmlText);
updateTrackList();
updateStatus(`Reloaded ${count} track(s) from server`, 'success');
if (tracks.length > 0) {
const bounds = L.latLngBounds(tracks.flatMap(t => t.coords));
map.fitBounds(bounds, { padding: [20, 20] });
}
})
.catch(err => {
updateStatus('Failed to reload tracks: ' + err.message, 'error');
});
}
function saveToServer() {
// Generate KML content
let placemarks = '';
tracks.forEach(track => {
const coords = track.coords.map(c => `${c[1]},${c[0]},0`).join(' ');
placemarks += `
<Placemark>
<name>${escapeXml(track.name)}</name>
<description>${escapeXml(track.description)}</description>
<LineString>
<coordinates>${coords}</coordinates>
</LineString>
</Placemark>`;
});
const kml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Saved Tracks</name>${placemarks}
</Document>
</kml>`;
// Send to server
updateStatus('Saving to server...', 'info');
fetch('/save-kml', {
method: 'POST',
headers: {
'Content-Type': 'application/xml'
},
body: kml
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error(text || 'Failed to save');
});
}
return response.json();
})
.then(result => {
updateStatus(result.message || 'Saved to server successfully', 'success');
})
.catch(err => {
updateStatus('Failed to save: ' + err.message, 'error');
});
}
function escapeXml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// Update UI
function updateStatus(message, type = '') {
const statusEl = document.getElementById('status');
statusEl.textContent = message;
statusEl.className = 'status' + (type ? ' ' + type : '');
}
// Toast notification system
function showNotification(message, type = 'info', duration = 8000) {
// Get or create toast container
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
// Create toast element
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
// Auto-remove after duration
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 300);
}, duration);
return toast;
}
function updateTrackList() {
const listEl = document.getElementById('trackList');
const countEl = document.getElementById('trackCount');
if (countEl) {
countEl.textContent = tracks.length;
}
if (listEl) {
listEl.innerHTML = tracks.map((track, i) => `
<div class="track-item ${selectedTracks.includes(track) ? 'selected' : ''}" data-index="${i}">
<span>${track.name}</span>
<button class="delete-btn" data-index="${i}">×</button>
</div>
`).join('');
// Add click handlers
listEl.querySelectorAll('.track-item').forEach(item => {
item.addEventListener('click', (e) => {
if (!e.target.classList.contains('delete-btn')) {
const idx = parseInt(item.dataset.index);
selectTrack(tracks[idx]);
map.fitBounds(tracks[idx].layer.getBounds(), { padding: [50, 50] });
}
});
});
listEl.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.index);
deleteTrack(tracks[idx]);
});
});
}
}
// Event listeners
const kmlFileEl = document.getElementById('kmlFile');
if (kmlFileEl) {
kmlFileEl.addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
updateStatus('Loading...', 'info');
const reader = new FileReader();
reader.onload = function(e) {
try {
const count = parseKML(e.target.result);
updateTrackList();
if (tracks.length > 0) {
const bounds = L.latLngBounds(tracks.flatMap(t => t.coords));
map.fitBounds(bounds, { padding: [20, 20] });
}
updateStatus(`Loaded ${count} track(s) from ${file.name}`, 'success');
} catch (err) {
updateStatus(`Error: ${err.message}`, 'error');
}
};
reader.readAsText(file);
});
}
const el_exportBtn = document.getElementById('exportBtn');
if (el_exportBtn) {
el_exportBtn.addEventListener('click', exportToKML);
}
const reloadBtn = document.getElementById('reloadBtn');
if (reloadBtn) {
reloadBtn.addEventListener('click', reloadTracks);
}
const el_saveServerBtn = document.getElementById('saveServerBtn');
if (el_saveServerBtn) {
el_saveServerBtn.addEventListener('click', saveToServer);
}
const gpsBtn = document.getElementById('gpsBtn');
if (gpsBtn) {
gpsBtn.addEventListener('click', toggleGPS);
}
// Compass button - same as GPS button
const compassBtn = document.getElementById('compassBtn');
if (compassBtn) {
compassBtn.addEventListener('click', toggleGPS);
}
const el_rotateMapBtn = document.getElementById('rotateMapBtn');
if (el_rotateMapBtn) {
el_rotateMapBtn.addEventListener('click', toggleRotateMap);
}
const autoCenterBtn = document.getElementById('autoCenterBtn');
if (autoCenterBtn) {
autoCenterBtn.addEventListener('click', toggleAutoCenter);
}
// Tab switching
const el_editTab = document.getElementById('editTab');
if (el_editTab) {
el_editTab.addEventListener('click', () => switchTab('edit'));
}
const navTab = document.getElementById('navTab');
if (navTab) {
navTab.addEventListener('click', () => switchTab('navigate'));
}
const el_adminTab = document.getElementById('adminTab');
if (el_adminTab) {
el_adminTab.addEventListener('click', () => switchTab('admin'));
}
// Edit overlay close button
setTimeout(() => {
const editCloseBtn = document.getElementById('editCloseBtn');
if (editCloseBtn) {
editCloseBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const editOverlay = document.querySelector('.edit-panel-overlay');
if (editOverlay) {
editOverlay.classList.remove('active');
}
// Remove active class from edit tab
document.getElementById('editTab').classList.remove('active');
// Switch to navigate tab
switchTab('navigate');
});
}
}, 100);
// Admin overlay close button - defer to ensure DOM element exists
setTimeout(() => {
const adminCloseBtn = document.getElementById('adminCloseBtn');
if (adminCloseBtn) {
console.log('Admin close button found, attaching listener');
adminCloseBtn.addEventListener('click', (e) => {
console.log('Admin close button clicked');
e.preventDefault();
e.stopPropagation();
const adminOverlay = document.querySelector('.admin-panel-overlay');
if (adminOverlay) {
adminOverlay.classList.remove('active');
}
// Remove active class from admin tab
document.getElementById('adminTab').classList.remove('active');
// Switch back to edit tab
switchTab('edit');
});
} else {
console.error('Admin close button not found!');
}
}, 100);
// Navigation
const clearNavBtn = document.getElementById('clearNavBtn');
if (clearNavBtn) clearNavBtn.addEventListener('click', clearDestination);
// Panel toggle - wrap to prevent error if element doesn't exist
const panelToggle = document.getElementById('panelToggle');
if (panelToggle) {
panelToggle.addEventListener('click', function() {
console.log('Hamburger clicked!'); // Debug
const panel = document.getElementById('controlPanel');
const toggleBtn = document.getElementById('panelToggle');
if (panel && toggleBtn) {
if (panel.style.display === 'none') {
panel.style.display = 'block';
toggleBtn.style.right = '300px';
} else {
panel.style.display = 'none';
toggleBtn.style.right = '10px';
}
}
});
} else {
console.error('panelToggle element not found!');
}
const undoBtn = document.getElementById('undoBtn');
if (undoBtn) undoBtn.addEventListener('click', undo);
const mergeConnectBtn = document.getElementById('mergeConnectBtn');
if (mergeConnectBtn) mergeConnectBtn.addEventListener('click', mergeConnect);
const selectAllBtn = document.getElementById('selectAllBtn');
if (selectAllBtn) selectAllBtn.addEventListener('click', selectAll);
const clearSelectionBtn = document.getElementById('clearSelectionBtn');
if (clearSelectionBtn) clearSelectionBtn.addEventListener('click', clearSelection);
// Remesh button and dialog
const remeshSlider = document.getElementById('remeshSpacing');
const remeshValueDisplay = document.getElementById('remeshSpacingValue');
remeshSlider.addEventListener('input', () => {
remeshValueDisplay.textContent = remeshSlider.value;
});
const el_remeshBtn = document.getElementById('remeshBtn');
if (el_remeshBtn) {
el_remeshBtn.addEventListener('click', () => {
if (selectedTracks.length === 0) {
updateStatus('Please select tracks to remesh first', 'error');
return;
}
// Reset slider to default
remeshSlider.value = 5;
remeshValueDisplay.textContent = '5';
// Show confirmation dialog
const trackNames = selectedTracks.map(t => t.name).join(', ');
const totalPoints = selectedTracks.reduce((sum, t) => sum + t.coords.length, 0);
document.getElementById('remeshDetails').innerHTML =
`<strong>Selected tracks:</strong> ${trackNames}<br>` +
`<strong>Current total points:</strong> ${totalPoints}`;
ensurePopupInBody('remeshDialog');
document.getElementById('remeshDialog').style.display = 'flex';
});
}
const el_remeshYes = document.getElementById('remeshYes');
if (el_remeshYes) {
el_remeshYes.addEventListener('click', () => {
document.getElementById('remeshDialog').style.display = 'none';
// Get the spacing value from slider
const spacing = parseInt(remeshSlider.value);
// Remesh all selected tracks with the chosen spacing
selectedTracks.forEach(track => {
remeshTrack(track, spacing);
});
// Clear selection after remeshing
clearSelection();
});
}
const el_remeshNo = document.getElementById('remeshNo');
if (el_remeshNo) {
el_remeshNo.addEventListener('click', () => {
document.getElementById('remeshDialog').style.display = 'none';
});
}
// Preview system
const el_previewBtn = document.getElementById('previewBtn');
if (el_previewBtn) {
el_previewBtn.addEventListener('click', startPreview);
const applyMergeBtn = document.getElementById('applyMergeBtn');
if (applyMergeBtn) {
applyMergeBtn.addEventListener('click', applyMerge);
}
}
const el_cancelPreviewBtn = document.getElementById('cancelPreviewBtn');
if (el_cancelPreviewBtn) {
el_cancelPreviewBtn.addEventListener('click', cancelPreview);
}
// Live slider update during preview
const mergeThreshold = document.getElementById('mergeThreshold');
if (mergeThreshold) {
mergeThreshold.addEventListener('input', (e) => {
document.getElementById('thresholdValue').textContent = e.target.value;
if (previewMode) {
updatePreview(parseInt(e.target.value));
}
});
}
// Anchor distance slider update
const el_anchorDistance = document.getElementById('anchorDistance');
if (el_anchorDistance) {
el_anchorDistance.addEventListener('input', (e) => {
document.getElementById('anchorValue').textContent = e.target.value;
// If currently dragging, update the affected markers display
if (isDragging && originalCoords) {
showAffectedRange();
// Re-apply rope physics with new anchor distance
const anchorDist = parseInt(e.target.value);
const draggedPoint = dragTrack.coords[dragPointIndex];
const newCoords = applyRopePhysics(originalCoords, dragPointIndex, draggedPoint, anchorDist);
dragTrack.coords = newCoords;
dragTrack.layer.setLatLngs(newCoords);
updateAffectedMarkersPositions(newCoords);
}
});
}
// Falloff slider update
const el_reshapeFalloff = document.getElementById('reshapeFalloff');
if (el_reshapeFalloff) {
el_reshapeFalloff.addEventListener('input', (e) => {
document.getElementById('falloffValue').textContent = parseFloat(e.target.value).toFixed(1);
// If currently dragging, re-apply with new falloff
if (isDragging && originalCoords) {
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
const draggedPoint = dragTrack.coords[dragPointIndex];
const newCoords = applyRopePhysics(originalCoords, dragPointIndex, draggedPoint, anchorDist);
dragTrack.coords = newCoords;
dragTrack.layer.setLatLngs(newCoords);
updateAffectedMarkersPositions(newCoords);
}
});
}
// Smooth brush size slider update
const el_smoothBrushSize = document.getElementById('smoothBrushSize');
if (el_smoothBrushSize) {
el_smoothBrushSize.addEventListener('input', (e) => {
document.getElementById('brushSizeValue').textContent = e.target.value;
// Update brush circle if currently smoothing
if (isSmoothing && smoothBrushCircle) {
const brushSize = parseInt(e.target.value);
smoothBrushCircle.setRadius(brushSize * getMetersPerPixel());
}
});
}
// Smooth strength slider update
const el_smoothStrength = document.getElementById('smoothStrength');
if (el_smoothStrength) {
el_smoothStrength.addEventListener('input', (e) => {
document.getElementById('strengthValue').textContent = parseFloat(e.target.value).toFixed(1);
});
}
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('Service Worker registered:', registration.scope);
// Check for updates on every page load
registration.update();
// Handle service worker updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('New service worker installing...');
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available - skip waiting and reload
console.log('New version available, activating...');
newWorker.postMessage({ type: 'SKIP_WAITING' });
}
});
});
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // Check every hour
})
.catch(err => {
console.log('Service Worker registration failed:', err);
});
});
// Reload page when new service worker takes control
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('New service worker activated, reloading...');
window.location.reload();
});
// Listen for app install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent Chrome 67 and earlier from automatically showing the prompt
e.preventDefault();
// Stash the event so it can be triggered later
deferredPrompt = e;
console.log('Install prompt ready');
});
}
// Push Notification Functions
let pushSubscription = null;
async function setupPushNotifications() {
try {
// Check if notifications are supported
if (!('Notification' in window)) {
alert('This browser does not support notifications');
return;
}
// Request notification permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
alert('Notification permission denied');
return;
}
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
// Get VAPID public key from server
const response = await fetch('/vapid-public-key');
const { publicKey } = await response.json();
if (!publicKey) {
alert('Push notifications not configured on server');
return;
}
// Subscribe to push notifications
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey)
});
// Send subscription to server
const subResponse = await fetch('/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
});
if (subResponse.ok) {
pushSubscription = subscription;
updateNotificationUI(true);
updateStatus('Push notifications enabled!', 'success');
// Test notification using service worker
if ('serviceWorker' in navigator && registration) {
registration.showNotification('HikeMap Notifications Active', {
body: 'You will receive alerts about new geocaches and trail updates',
icon: '/icon-192x192.png',
badge: '/icon-72x72.png',
vibrate: [200, 100, 200]
});
}
}
} catch (error) {
console.error('Failed to setup push notifications:', error);
alert('Failed to enable notifications: ' + error.message);
}
}
async function disablePushNotifications() {
try {
if (pushSubscription) {
// Unsubscribe from push
await pushSubscription.unsubscribe();
// Tell server to remove subscription
await fetch('/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
endpoint: pushSubscription.endpoint
})
});
pushSubscription = null;
updateNotificationUI(false);
updateStatus('Push notifications disabled', 'info');
}
} catch (error) {
console.error('Failed to disable notifications:', error);
}
}
function updateNotificationUI(enabled) {
const statusText = document.getElementById('notificationStatusText');
const enableBtn = document.getElementById('enableNotifications');
const disableBtn = document.getElementById('disableNotifications');
const testBtn = document.getElementById('testNotification');
if (enabled) {
statusText.textContent = 'Enabled';
statusText.style.color = '#4CAF50';
enableBtn.style.display = 'none';
disableBtn.style.display = 'block';
testBtn.style.display = 'block';
} else {
statusText.textContent = 'Disabled';
statusText.style.color = '#666';
enableBtn.style.display = 'block';
disableBtn.style.display = 'none';
testBtn.style.display = 'none';
}
}
async function sendTestNotification() {
try {
const response = await fetch('/test-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: 'Test notification from HikeMap admin'
})
});
const result = await response.json();
if (result.success) {
updateStatus(`Test notification sent to ${result.sent} users!`, 'success');
} else {
updateStatus('Failed to send test notification', 'error');
}
} catch (error) {
console.error('Error sending test notification:', error);
updateStatus('Error sending test notification', 'error');
}
}
// Helper function to convert VAPID key
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Check existing push subscription on load
async function checkPushSubscription() {
if ('serviceWorker' in navigator && 'PushManager' in window) {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
pushSubscription = subscription;
updateNotificationUI(true);
}
} catch (error) {
console.error('Error checking push subscription:', error);
}
}
}
// Initialize
setTool('select');
updateTrackList();
updateUndoButton();
// Start in navigation mode
navMode = true;
document.body.classList.add('nav-mode');
// Load admin settings
loadAdminSettings();
// Setup admin panel event handlers
setupAdminInputListeners();
// Load user's icon choice or show selector
loadUserIcon();
// Connect to WebSocket for multi-user tracking
connectWebSocket();
// Check if push notifications are already enabled
checkPushSubscription();
// Setup resume navigation dialog handlers
const el_resumeNavYes = document.getElementById('resumeNavYes');
if (el_resumeNavYes) {
el_resumeNavYes.addEventListener('click', () => {
document.getElementById('resumeNavDialog').style.display = 'none';
// Restore saved navigation
const savedNav = localStorage.getItem('navMode');
if (savedNav === 'true') {
switchTab('navigate');
restoreDestination();
}
});
}
const el_resumeNavNo = document.getElementById('resumeNavNo');
if (el_resumeNavNo) {
el_resumeNavNo.addEventListener('click', () => {
document.getElementById('resumeNavDialog').style.display = 'none';
localStorage.removeItem('navDestination');
localStorage.removeItem('navMode');
});
}
// Auto-load default.kml with cache busting
fetch('default.kml?t=' + Date.now())
.then(response => {
if (!response.ok) throw new Error('default.kml not found');
return response.text();
})
.then(kmlText => {
const count = parseKML(kmlText);
updateTrackList();
updateStatus(`Loaded ${count} track(s) from default.kml`, 'success');
// Check for saved navigation state after tracks are loaded
const savedDestination = localStorage.getItem('navDestination');
const savedNavMode = localStorage.getItem('navMode');
if (savedDestination && savedNavMode === 'true') {
// Show resume navigation dialog
ensurePopupInBody('resumeNavDialog');
document.getElementById('resumeNavDialog').style.display = 'flex';
} else {
// Start in navigate mode by default if no saved state
if (savedNavMode !== 'false') {
switchTab('navigate');
}
}
// Restore saved destination after tracks are loaded
restoreDestination();
})
.catch(err => {
console.log('No default.kml found, starting empty');
});
// Auto-start GPS and zoom to location
if (navigator.geolocation) {
toggleGPS();
}
// ============================================
// Authentication System
// ============================================
// Auth state
let currentUser = null;
let accessToken = localStorage.getItem('accessToken');
let refreshToken = localStorage.getItem('refreshToken');
// API helper with auth
async function authFetch(url, options = {}) {
if (!options.headers) options.headers = {};
if (accessToken) {
options.headers['Authorization'] = `Bearer ${accessToken}`;
}
let response = await fetch(url, options);
// If token expired, try to refresh
if (response.status === 401) {
const data = await response.json();
if (data.code === 'TOKEN_EXPIRED' && refreshToken) {
const refreshed = await refreshAccessToken();
if (refreshed) {
options.headers['Authorization'] = `Bearer ${accessToken}`;
response = await fetch(url, options);
}
}
}
return response;
}
async function refreshAccessToken() {
try {
const response = await fetch('/api/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
if (response.ok) {
const data = await response.json();
accessToken = data.accessToken;
refreshToken = data.refreshToken;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
return true;
} else {
// Refresh failed, logout
logout();
return false;
}
} catch (err) {
console.error('Token refresh failed:', err);
return false;
}
}
function updateAuthUI() {
const profileSection = document.getElementById('userProfileSection');
const loginPrompt = document.getElementById('loginPromptSection');
const userName = document.getElementById('userName');
const userPoints = document.getElementById('userPoints');
const userFinds = document.getElementById('userFinds');
const userAvatar = document.getElementById('userAvatar');
const editTab = document.getElementById('editTab');
const adminTab = document.getElementById('adminTab');
const panelToggle = document.getElementById('panelToggle');
if (currentUser) {
profileSection.style.display = 'block';
loginPrompt.style.display = 'none';
userName.textContent = currentUser.username;
userPoints.textContent = currentUser.total_points || 0;
userFinds.textContent = currentUser.finds_count || 0;
userAvatar.innerHTML = `<i class="mdi mdi-${currentUser.avatar_icon || 'account'}" style="color: ${currentUser.avatar_color || '#fff'}"></i>`;
// Show Edit/Admin tabs and hamburger only for admins
if (currentUser.is_admin) {
editTab.style.display = '';
adminTab.style.display = '';
if (panelToggle) panelToggle.style.display = '';
// Show WASD controls for admins (if GPS not active)
const wasdControls = document.getElementById('wasdControls');
if (wasdControls && gpsWatchId === null) {
wasdControls.classList.remove('hidden');
}
// Auto-enable GPS Test Mode (WASD) for admins
const gpsTestToggle = document.getElementById('gpsTestModeToggle');
if (gpsTestToggle && !gpsTestMode) {
gpsTestToggle.checked = true;
toggleGpsTestMode(true);
}
} else {
editTab.style.display = 'none';
adminTab.style.display = 'none';
if (panelToggle) panelToggle.style.display = 'none';
// Show WASD controls for all users (if GPS not active)
const wasdControls = document.getElementById('wasdControls');
if (wasdControls && gpsWatchId === null) {
wasdControls.classList.remove('hidden');
}
}
} else {
profileSection.style.display = 'none';
loginPrompt.style.display = 'block';
editTab.style.display = 'none';
adminTab.style.display = 'none';
if (panelToggle) panelToggle.style.display = 'none';
// Show WASD controls for all users (if GPS not active)
const wasdControls = document.getElementById('wasdControls');
if (wasdControls && gpsWatchId === null) {
wasdControls.classList.remove('hidden');
}
}
}
// Register authenticated user with WebSocket for real-time updates
function registerWebSocketAuth() {
if (ws && ws.readyState === WebSocket.OPEN && currentUser && currentUser.id) {
ws.send(JSON.stringify({
type: 'auth',
authUserId: currentUser.id,
serverSessionId: sessionStorage.getItem('serverSessionId')
}));
console.log('Registered auth user', currentUser.id, 'with WebSocket');
}
}
async function login(username, password) {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (response.ok) {
const data = await response.json();
currentUser = data.user;
accessToken = data.accessToken;
refreshToken = data.refreshToken;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
updateAuthUI();
registerWebSocketAuth();
// Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username);
return { success: true };
} else {
const error = await response.json();
return { success: false, error: error.error };
}
}
async function register(username, email, password) {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password })
});
if (response.ok) {
const data = await response.json();
currentUser = data.user;
accessToken = data.accessToken;
refreshToken = data.refreshToken;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
updateAuthUI();
registerWebSocketAuth();
// Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username);
return { success: true };
} else {
const error = await response.json();
return { success: false, error: error.error };
}
}
async function logout() {
try {
if (accessToken) {
await authFetch('/api/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
}
} catch (err) {
console.error('Logout error:', err);
}
// Clean up RPG system
stopMonsterSpawning();
monsterEntourage.forEach(m => {
if (m.marker) m.marker.remove();
});
monsterEntourage = [];
playerStats = null;
playerBuffs = {};
mpRegenMultiplier = 1.0;
document.getElementById('rpgHud').style.display = 'none';
document.getElementById('deathOverlay').style.display = 'none';
// Clear home base marker
if (homeBaseMarker) {
homeBaseMarker.remove();
homeBaseMarker = null;
}
currentUser = null;
accessToken = null;
refreshToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('hikemap_rpg_stats'); // Clear cached RPG stats to prevent stale data
sessionStorage.removeItem('guestMode'); // Clear guest mode too
stopInactivityTimer();
updateAuthUI();
// Show login screen to force re-authentication
showLoginScreen();
}
// ==========================================
// INACTIVITY LOGOUT SYSTEM
// ==========================================
let inactivityTimeout = 10 * 60 * 1000; // Default 10 minutes, can be overridden by server settings
let inactivityWarningTime = 60 * 1000; // Warning 60 seconds before logout
let inactivityTimer = null;
let inactivityWarningTimer = null;
function resetInactivityTimer() {
// Only track if user is logged in
if (!accessToken) {
console.log('[Inactivity] No access token, skipping timer');
return;
}
// Clear existing timers
if (inactivityTimer) clearTimeout(inactivityTimer);
if (inactivityWarningTimer) clearTimeout(inactivityWarningTimer);
// Hide warning if showing
const warningEl = document.getElementById('inactivityWarning');
if (warningEl) warningEl.style.display = 'none';
// Set warning timer
const warningTime = Math.max(0, inactivityTimeout - inactivityWarningTime);
console.log('[Inactivity] Timer reset. Warning in', warningTime/1000, 's, logout in', inactivityTimeout/1000, 's');
inactivityWarningTimer = setTimeout(() => {
console.log('[Inactivity] Showing warning');
showInactivityWarning();
}, warningTime);
// Set logout timer
inactivityTimer = setTimeout(() => {
console.log('[Inactivity] Logging out due to inactivity');
logout();
}, inactivityTimeout);
}
function showInactivityWarning() {
let warningEl = document.getElementById('inactivityWarning');
if (!warningEl) {
warningEl = document.createElement('div');
warningEl.id = 'inactivityWarning';
warningEl.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 152, 0, 0.95);
color: #000;
padding: 12px 24px;
border-radius: 8px;
z-index: 10000;
font-weight: bold;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
`;
document.body.appendChild(warningEl);
}
const seconds = Math.round(inactivityWarningTime / 1000);
warningEl.textContent = `You will be logged out in ${seconds} seconds due to inactivity`;
warningEl.style.display = 'block';
}
function stopInactivityTimer() {
if (inactivityTimer) {
clearTimeout(inactivityTimer);
inactivityTimer = null;
}
if (inactivityWarningTimer) {
clearTimeout(inactivityWarningTimer);
inactivityWarningTimer = null;
}
const warningEl = document.getElementById('inactivityWarning');
if (warningEl) warningEl.style.display = 'none';
}
function startInactivityTracking() {
// Note: mousemove excluded - too sensitive, resets on every tiny movement
const activityEvents = ['mousedown', 'keypress', 'scroll', 'touchstart', 'click'];
activityEvents.forEach(event => {
document.addEventListener(event, () => {
console.log('[Inactivity] Activity detected:', event);
resetInactivityTimer();
}, { passive: true });
});
console.log('[Inactivity] *** TRACKING STARTED *** timeout:', inactivityTimeout / 1000, 'seconds');
resetInactivityTimer();
}
async function loadCurrentUser() {
if (!accessToken) return;
try {
const response = await authFetch('/api/user/me');
if (response.ok) {
currentUser = await response.json();
updateAuthUI();
registerWebSocketAuth();
// Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username);
// Start inactivity tracking
console.log('[DEBUG] About to call startInactivityTracking');
startInactivityTracking();
console.log('[DEBUG] Called startInactivityTracking');
} else {
// Token invalid
logout();
}
} catch (err) {
console.error('Failed to load user:', err);
}
}
// Auth modal handlers
function showAuthModal() {
document.getElementById('authModal').style.display = 'flex';
}
function hideAuthModal() {
document.getElementById('authModal').style.display = 'none';
document.getElementById('authError').classList.remove('visible');
}
function showAuthError(message) {
const errorEl = document.getElementById('authError');
errorEl.textContent = message;
errorEl.classList.add('visible');
}
// Auth tab switching
document.getElementById('loginTabBtn').addEventListener('click', () => {
document.getElementById('loginTabBtn').classList.add('active');
document.getElementById('registerTabBtn').classList.remove('active');
document.getElementById('loginForm').classList.add('active');
document.getElementById('registerForm').classList.remove('active');
document.getElementById('authError').classList.remove('visible');
});
document.getElementById('registerTabBtn').addEventListener('click', () => {
document.getElementById('registerTabBtn').classList.add('active');
document.getElementById('loginTabBtn').classList.remove('active');
document.getElementById('registerForm').classList.add('active');
document.getElementById('loginForm').classList.remove('active');
document.getElementById('authError').classList.remove('visible');
});
// Login form
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('loginSubmitBtn');
btn.disabled = true;
btn.textContent = 'Logging in...';
const username = document.getElementById('loginUsername').value;
const password = document.getElementById('loginPassword').value;
const result = await login(username, password);
btn.disabled = false;
btn.textContent = 'Login';
if (result.success) {
hideAuthModal();
updateStatus(`Welcome back, ${currentUser.username}!`, 'success');
} else {
showAuthError(result.error);
}
});
// Register form
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('registerSubmitBtn');
const username = document.getElementById('registerUsername').value;
const email = document.getElementById('registerEmail').value;
const password = document.getElementById('registerPassword').value;
const confirmPassword = document.getElementById('registerPasswordConfirm').value;
if (password !== confirmPassword) {
showAuthError('Passwords do not match');
return;
}
btn.disabled = true;
btn.textContent = 'Creating account...';
const result = await register(username, email, password);
btn.disabled = false;
btn.textContent = 'Create Account';
if (result.success) {
hideAuthModal();
updateStatus(`Welcome to HikeMap, ${currentUser.username}!`, 'success');
} else {
showAuthError(result.error);
}
});
// Open/close buttons
document.getElementById('openLoginBtn').addEventListener('click', showAuthModal);
document.getElementById('authCloseBtn').addEventListener('click', hideAuthModal);
document.getElementById('logoutBtn').addEventListener('click', logout);
// Guest mode button (old auth modal - kept for backwards compatibility)
document.getElementById('guestModeBtn').addEventListener('click', () => {
sessionStorage.setItem('guestMode', 'true');
hideAuthModal();
updateStatus('Continuing as guest - log in to save progress!', 'info');
});
// ==========================================
// LOGIN SCREEN HANDLERS
// ==========================================
// Track if login music has started (for autoplay restrictions)
let loginMusicStarted = false;
function startLoginMusic() {
const alreadyLoggedIn = localStorage.getItem('accessToken') || sessionStorage.getItem('guestMode');
if (!loginMusicStarted && !gameMusic.muted && !alreadyLoggedIn) {
loginMusicStarted = true;
playMusic('login');
}
}
function hideLoginScreen() {
document.getElementById('loginScreen').classList.add('hidden');
document.getElementById('gameContainer').classList.add('visible');
// Stop login music and transition to appropriate game music
stopMusic();
// Stop login monster animations
stopLoginMonsters();
// Fix Leaflet map sizing - must recalculate after container becomes visible
setTimeout(() => {
if (map) {
map.resize(); // MapLibre equivalent of invalidateSize
// Re-center on user position if available
if (gpsMarker) {
const lngLat = gpsMarker.getLngLat();
map.setCenter([lngLat.lng, lngLat.lat]);
}
// Force refresh of markers that may have been added while map was hidden
refreshMapMarkers();
}
}, 150);
}
// Refresh all map markers after map becomes visible
function refreshMapMarkers() {
// Refresh GPS marker by recreating it (MapLibre version)
if (gpsMarker && userLocation) {
const lat = userLocation.lat;
const lng = userLocation.lng;
gpsMarker.remove();
gpsMarker = null;
// Recreate the marker
const el = document.createElement('div');
el.className = 'custom-div-icon gps-marker-icon';
el.innerHTML = '<i class="mdi mdi-navigation" style="color: #4285f4; font-size: 36px; transform: rotate(' + (currentHeading || 0) + 'deg);"></i>';
el.style.width = '36px';
el.style.height = '36px';
gpsMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
.setLngLat([lng, lat])
.addTo(map);
}
// Refresh accuracy circle (MapLibre - just update the source data)
if (gpsAccuracyCircle && userLocation) {
const lat = userLocation.lat;
const lng = userLocation.lng;
const source = map.getSource('gps-accuracy');
if (source) {
source.setData(createCircleGeoJSON([lng, lat], userLocation.accuracy || 10));
}
}
// Refresh home base marker
if (typeof updateHomeBaseMarker === 'function' && playerStats && playerStats.homeBaseLat != null) {
if (homeBaseMarker) {
map.removeLayer(homeBaseMarker);
homeBaseMarker = null;
}
updateHomeBaseMarker();
}
// Refresh monster markers
if (monsterEntourage && monsterEntourage.length > 0) {
monsterEntourage.forEach(monster => {
if (monster.marker) {
map.removeLayer(monster.marker);
monster.marker.addTo(map);
}
});
}
}
function showLoginScreen() {
document.getElementById('loginScreen').classList.remove('hidden');
document.getElementById('gameContainer').classList.remove('visible');
stopMusic();
}
function showLoginScreenError(message) {
const errorEl = document.getElementById('loginScreenError');
errorEl.textContent = message;
errorEl.classList.add('visible');
}
function hideLoginScreenError() {
document.getElementById('loginScreenError').classList.remove('visible');
}
// Login screen animated monsters background
const loginMonsterAnimations = ['idle', 'attack', 'skill', 'flipy', 'flipz', 'shrink_grow'];
let loginMonstersInterval = null;
let loginMonstersWanderInterval = null;
async function initLoginMonsters() {
const container = document.getElementById('loginMonstersBg');
if (!container) return;
// Fallback monster IDs if we can't load from API
let monsterIds = ['moop', 'slime', 'goblin', 'bat', 'spider', 'rat'];
// Try to load monster types from API
try {
const response = await fetch('/api/monster-types');
if (response.ok) {
const types = await response.json();
if (types.length > 0) {
monsterIds = types.map(t => t.id);
}
}
} catch (e) {
console.log('Using fallback monster IDs for login screen');
}
// Create 16-24 monsters at random positions
const numMonsters = 16 + Math.floor(Math.random() * 9);
for (let i = 0; i < numMonsters; i++) {
const monsterId = monsterIds[Math.floor(Math.random() * monsterIds.length)];
const monster = document.createElement('div');
monster.className = 'login-monster';
monster.style.left = `${Math.random() * 90}%`;
monster.style.top = `${Math.random() * 85}%`;
monster.style.transform = `scale(${0.6 + Math.random() * 0.8})`;
monster.innerHTML = `<img src="/mapgameimgs/monsters/${monsterId}100.png"
onerror="this.src='/mapgameimgs/monsters/default100.png'" alt="monster">`;
container.appendChild(monster);
}
// Start random animations and wandering
animateLoginMonsters();
wanderLoginMonsters();
loginMonstersInterval = setInterval(animateLoginMonsters, 2500);
loginMonstersWanderInterval = setInterval(wanderLoginMonsters, 3000);
}
function wanderLoginMonsters() {
const monsters = document.querySelectorAll('.login-monster');
monsters.forEach(monster => {
// 30% chance to wander each cycle
if (Math.random() > 0.3) return;
monster.style.left = `${Math.random() * 90}%`;
monster.style.top = `${Math.random() * 85}%`;
});
}
function animateLoginMonsters() {
const monsters = document.querySelectorAll('.login-monster img');
if (!monsters.length || typeof MONSTER_ANIMATIONS === 'undefined') return;
monsters.forEach(img => {
// 40% chance to animate each monster per cycle
if (Math.random() > 0.4) return;
const animName = loginMonsterAnimations[Math.floor(Math.random() * loginMonsterAnimations.length)];
const anim = MONSTER_ANIMATIONS[animName];
if (!anim) return;
// Generate unique animation name and apply
const uniqueAnimName = `login-${animName}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
const styleSheet = document.styleSheets[0];
try {
styleSheet.insertRule(`@keyframes ${uniqueAnimName} { ${anim.keyframes} }`, styleSheet.cssRules.length);
} catch (e) { return; }
img.style.animation = `${uniqueAnimName} ${anim.duration}ms ${anim.easing || 'ease'} ${anim.loop ? 'infinite' : '1'}`;
// Clean up after animation
if (!anim.loop) {
setTimeout(() => {
img.style.animation = '';
}, anim.duration + 100);
}
});
}
function stopLoginMonsters() {
if (loginMonstersInterval) {
clearInterval(loginMonstersInterval);
loginMonstersInterval = null;
}
if (loginMonstersWanderInterval) {
clearInterval(loginMonstersWanderInterval);
loginMonstersWanderInterval = null;
}
}
// Initialize login monsters when page loads
initLoginMonsters();
// Login screen tab switching
document.getElementById('loginScreenLoginTab').addEventListener('click', () => {
document.getElementById('loginScreenLoginTab').classList.add('active');
document.getElementById('loginScreenRegisterTab').classList.remove('active');
document.getElementById('loginScreenLoginForm').classList.add('active');
document.getElementById('loginScreenRegisterForm').classList.remove('active');
hideLoginScreenError();
});
document.getElementById('loginScreenRegisterTab').addEventListener('click', () => {
document.getElementById('loginScreenRegisterTab').classList.add('active');
document.getElementById('loginScreenLoginTab').classList.remove('active');
document.getElementById('loginScreenRegisterForm').classList.add('active');
document.getElementById('loginScreenLoginForm').classList.remove('active');
hideLoginScreenError();
});
// Login screen login form
document.getElementById('loginScreenLoginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('loginScreenSubmitBtn');
btn.disabled = true;
btn.textContent = 'Logging in...';
const username = document.getElementById('loginScreenUsername').value;
const password = document.getElementById('loginScreenPassword').value;
const result = await login(username, password);
btn.disabled = false;
btn.textContent = 'Login';
if (result.success) {
hideLoginScreen();
updateStatus(`Welcome back, ${currentUser.username}!`, 'success');
} else {
showLoginScreenError(result.error);
}
});
// Login screen register form
document.getElementById('loginScreenRegisterForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('loginScreenRegSubmitBtn');
const username = document.getElementById('loginScreenRegUsername').value;
const email = document.getElementById('loginScreenRegEmail').value;
const password = document.getElementById('loginScreenRegPassword').value;
const confirmPassword = document.getElementById('loginScreenRegPasswordConfirm').value;
if (password !== confirmPassword) {
showLoginScreenError('Passwords do not match');
return;
}
btn.disabled = true;
btn.textContent = 'Creating account...';
const result = await register(username, email, password);
btn.disabled = false;
btn.textContent = 'Create Account';
if (result.success) {
hideLoginScreen();
updateStatus(`Welcome to HikeMap, ${currentUser.username}!`, 'success');
} else {
showLoginScreenError(result.error);
}
});
// Login screen guest mode button
document.getElementById('loginScreenGuestBtn').addEventListener('click', () => {
sessionStorage.setItem('guestMode', 'true');
hideLoginScreen();
updateStatus('Continuing as guest - log in to save progress!', 'info');
});
// Try to autostart login music immediately (works if user has interacted before)
// Falls back to first interaction if autoplay is blocked
// Skip if user is already logged in (will go straight to game music)
setTimeout(() => {
const alreadyLoggedIn = localStorage.getItem('accessToken') || sessionStorage.getItem('guestMode');
if (!loginMusicStarted && !gameMusic.muted && !alreadyLoggedIn) {
const loginAudio = gameMusic.login;
loginAudio.play().then(() => {
loginMusicStarted = true;
gameMusic.current = loginAudio;
gameMusic.currentTrack = 'login';
console.log('Login music autostarted');
}).catch(() => {
console.log('Autoplay blocked - waiting for user interaction');
});
}
}, 500);
// Fallback: Start login music on first interaction with the login screen
document.getElementById('loginScreen').addEventListener('click', startLoginMusic, { once: true });
document.getElementById('loginScreen').addEventListener('touchstart', startLoginMusic, { once: true });
// ==========================================
// CHARACTER CREATOR
// ==========================================
let charCreatorState = {
step: 1,
name: '',
race: null,
class: null
};
function showCharCreatorModal() {
// Reset state
charCreatorState = { step: 1, name: '', race: null, class: null };
document.getElementById('charNameInput').value = '';
// Populate race options
const raceGrid = document.getElementById('raceSelection');
raceGrid.innerHTML = Object.entries(RACES).map(([id, race]) => {
const bonusHtml = Object.entries(race.bonuses)
.filter(([stat, val]) => val !== 0)
.map(([stat, val]) => {
const statLabel = stat.toUpperCase();
const sign = val > 0 ? '+' : '';
const cls = val > 0 ? 'positive' : 'negative';
return `<span class="char-creator-stat ${cls}">${sign}${val} ${statLabel}</span>`;
}).join('');
return `
<div class="char-creator-option" data-race="${id}">
<div class="char-creator-option-icon">${race.icon}</div>
<div class="char-creator-option-name">${race.name}</div>
<div class="char-creator-option-desc">${race.description}</div>
<div class="char-creator-stats">${bonusHtml || '<span class="char-creator-stat neutral">Balanced</span>'}</div>
</div>
`;
}).join('');
// Add click handlers to race options
raceGrid.querySelectorAll('.char-creator-option').forEach(opt => {
opt.addEventListener('click', () => {
raceGrid.querySelectorAll('.char-creator-option').forEach(o => o.classList.remove('selected'));
opt.classList.add('selected');
charCreatorState.race = opt.dataset.race;
document.getElementById('charStep2Next').disabled = false;
});
});
// Populate class options
const classGrid = document.getElementById('classSelection');
classGrid.innerHTML = Object.entries(PLAYER_CLASSES).map(([id, cls]) => {
const disabled = !cls.available;
const stats = cls.baseStats;
return `
<div class="char-creator-option ${disabled ? 'disabled' : ''}" data-class="${id}">
<div class="char-creator-option-icon">${cls.icon}</div>
<div class="char-creator-option-name">${cls.name}</div>
<div class="char-creator-option-desc">${cls.description}</div>
${disabled ? '<div class="char-creator-option-badge">Coming Soon</div>' : `
<div class="char-creator-stats">
<span class="char-creator-stat neutral">${stats.hp} HP</span>
<span class="char-creator-stat neutral">${stats.mp} MP</span>
</div>
`}
</div>
`;
}).join('');
// Add click handlers to class options
classGrid.querySelectorAll('.char-creator-option:not(.disabled)').forEach(opt => {
opt.addEventListener('click', () => {
classGrid.querySelectorAll('.char-creator-option').forEach(o => o.classList.remove('selected'));
opt.classList.add('selected');
charCreatorState.class = opt.dataset.class;
document.getElementById('charStep3Next').disabled = false;
});
});
// Show modal and first step
goToCharStep(1);
document.getElementById('charCreatorModal').style.display = 'flex';
}
function hideCharCreatorModal() {
document.getElementById('charCreatorModal').style.display = 'none';
}
function goToCharStep(step) {
charCreatorState.step = step;
// Update step visibility
document.querySelectorAll('.char-creator-step').forEach(s => {
s.classList.toggle('active', parseInt(s.dataset.step) === step);
});
// Update step indicators
document.querySelectorAll('.char-step-dot').forEach(dot => {
const dotStep = parseInt(dot.dataset.step);
dot.classList.remove('active', 'completed');
if (dotStep === step) dot.classList.add('active');
else if (dotStep < step) dot.classList.add('completed');
});
// Update preview on step 4
if (step === 4) {
updateCharPreview();
}
}
function updateCharPreview() {
const race = RACES[charCreatorState.race];
const cls = PLAYER_CLASSES[charCreatorState.class];
if (!race || !cls) return;
// Calculate final stats (base + race bonuses)
const finalStats = {
hp: cls.baseStats.hp + (race.bonuses.hp || 0),
mp: cls.baseStats.mp + (race.bonuses.mp || 0),
atk: cls.baseStats.atk + (race.bonuses.atk || 0),
def: cls.baseStats.def + (race.bonuses.def || 0)
};
document.getElementById('charPreviewIcon').textContent = race.icon;
document.getElementById('charPreviewName').textContent = charCreatorState.name;
document.getElementById('charPreviewInfo').textContent = `${race.name} ${cls.name}`;
document.getElementById('charPreviewStats').innerHTML = `
<div class="char-creator-preview-stat">
<span class="char-creator-preview-stat-label">❤️ HP</span>
<span class="char-creator-preview-stat-value">${finalStats.hp}</span>
</div>
<div class="char-creator-preview-stat">
<span class="char-creator-preview-stat-label">💙 MP</span>
<span class="char-creator-preview-stat-value">${finalStats.mp}</span>
</div>
<div class="char-creator-preview-stat">
<span class="char-creator-preview-stat-label">⚔️ ATK</span>
<span class="char-creator-preview-stat-value">${finalStats.atk}</span>
</div>
<div class="char-creator-preview-stat">
<span class="char-creator-preview-stat-label">🛡️ DEF</span>
<span class="char-creator-preview-stat-value">${finalStats.def}</span>
</div>
`;
}
async function createCharacter() {
const race = RACES[charCreatorState.race];
const cls = PLAYER_CLASSES[charCreatorState.class];
if (!race || !cls || !charCreatorState.name) {
updateStatus('Please complete all character creation steps', 'error');
return;
}
// Calculate final stats with race bonuses
const finalStats = {
hp: cls.baseStats.hp + (race.bonuses.hp || 0),
mp: cls.baseStats.mp + (race.bonuses.mp || 0),
atk: cls.baseStats.atk + (race.bonuses.atk || 0),
def: cls.baseStats.def + (race.bonuses.def || 0)
};
const characterData = {
name: charCreatorState.name,
race: charCreatorState.race,
class: charCreatorState.class,
level: 1,
xp: 0,
hp: finalStats.hp,
maxHp: finalStats.hp,
mp: finalStats.mp,
maxMp: finalStats.mp,
atk: finalStats.atk,
def: finalStats.def,
unlockedSkills: ['basic_attack'] // Start with basic attack only
};
try {
const token = localStorage.getItem('accessToken');
const response = await fetch('/api/user/character', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(characterData)
});
if (response.ok) {
hideCharCreatorModal();
updateStatus(`Welcome, ${charCreatorState.name}! Your adventure begins!`, 'success');
// Initialize RPG with the new character
playerStats = characterData;
playerStats.dataVersion = 1; // Initial version for new character
// Mark as loaded from server (we just created it there)
statsLoadedFromServer = true;
savePlayerStats();
document.getElementById('rpgHud').style.display = 'flex';
updateRpgHud();
startMonsterSpawning();
// Start auto-save for new character
startAutoSave();
} else {
const error = await response.json();
updateStatus(error.error || 'Failed to create character', 'error');
}
} catch (err) {
console.error('Character creation error:', err);
updateStatus('Failed to create character', 'error');
}
}
// Character creator event listeners
document.getElementById('charNameInput').addEventListener('input', (e) => {
charCreatorState.name = e.target.value.trim();
document.getElementById('charStep1Next').disabled = charCreatorState.name.length < 2;
});
document.getElementById('charStep1Next').addEventListener('click', () => goToCharStep(2));
document.getElementById('charStep2Back').addEventListener('click', () => goToCharStep(1));
document.getElementById('charStep2Next').addEventListener('click', () => goToCharStep(3));
document.getElementById('charStep3Back').addEventListener('click', () => goToCharStep(2));
document.getElementById('charStep3Next').addEventListener('click', () => goToCharStep(4));
document.getElementById('charStep4Back').addEventListener('click', () => goToCharStep(3));
document.getElementById('charCreateBtn').addEventListener('click', createCharacter);
// Character Sheet
function showCharacterSheet() {
if (!playerStats) return;
const race = RACES[playerStats.race] || RACES['human'];
const cls = PLAYER_CLASSES[playerStats.class] || PLAYER_CLASSES['trail_runner'];
// Update header
document.getElementById('charSheetIcon').textContent = race.icon;
document.getElementById('charSheetName').textContent = playerStats.name || 'Adventurer';
document.getElementById('charSheetInfo').textContent =
`${race.name} ${cls.name} - Level ${playerStats.level}`;
// Update stats grid
const hpPercent = (playerStats.hp / playerStats.maxHp) * 100;
const mpPercent = (playerStats.mp / playerStats.maxMp) * 100;
document.getElementById('charSheetStats').innerHTML = `
<div class="char-sheet-stat">
<span class="stat-label">❤️ HP</span>
<div class="stat-bar hp-bar">
<div class="stat-fill" style="width: ${hpPercent}%"></div>
</div>
<span class="stat-value">${playerStats.hp}/${playerStats.maxHp}</span>
</div>
<div class="char-sheet-stat">
<span class="stat-label">💙 MP</span>
<div class="stat-bar mp-bar">
<div class="stat-fill" style="width: ${mpPercent}%"></div>
</div>
<span class="stat-value">${playerStats.mp}/${playerStats.maxMp}</span>
</div>
<div class="char-sheet-stat">
<span class="stat-label">⚔️ ATK</span>
<span class="stat-value">${playerStats.atk}</span>
</div>
<div class="char-sheet-stat">
<span class="stat-label">🛡️ DEF</span>
<span class="stat-value">${playerStats.def}</span>
</div>
`;
// Update XP section
const xpNeeded = playerStats.level * 100;
const xpPercent = Math.min((playerStats.xp / xpNeeded) * 100, 100);
document.getElementById('charSheetXp').innerHTML = `
<div class="xp-bar-container">
<div class="xp-bar-fill" style="width: ${xpPercent}%"></div>
</div>
<div class="xp-text">${playerStats.xp}/${xpNeeded} XP</div>
<div class="xp-next">Next level: ${Math.max(0, xpNeeded - playerStats.xp)} XP needed</div>
`;
// Update Active Skills section (only show active combat skills)
const activeSkills = playerStats.activeSkills || playerStats.unlockedSkills || ['basic_attack'];
document.getElementById('charSheetActiveSkills').innerHTML = activeSkills
.filter(skillId => {
// Filter out utility skills (shown in Daily Skills)
const skill = SKILLS_DB[skillId] || SKILLS[skillId];
return skill && skill.type !== 'utility';
})
.map(skillId => {
const dbSkill = SKILLS_DB[skillId];
const hardcodedSkill = SKILLS[skillId];
const skill = dbSkill || hardcodedSkill;
if (!skill) return '';
// Get class-specific display name and description
const skillInfo = getSkillForClass(skillId, playerStats.class);
const displayName = skillInfo?.displayName || skill.name;
const displayDesc = skillInfo?.displayDescription || skill.description;
// Calculate skill stats for display
let statsText = '';
const accuracy = dbSkill?.accuracy || 95;
const mpCost = skill.mpCost || 0;
if (skill.type === 'damage') {
// Calculate damage based on player ATK
let damage;
const hits = skill.hitCount || skill.hits || 1;
if (hardcodedSkill && hardcodedSkill.calculate) {
damage = hardcodedSkill.calculate(playerStats.atk);
} else if (dbSkill) {
damage = Math.floor(playerStats.atk * (dbSkill.basePower / 100));
} else {
damage = playerStats.atk;
}
const minDmg = Math.max(1, Math.floor(damage * 0.9));
const maxDmg = Math.floor(damage * 1.1);
if (hits > 1) {
statsText = `${minDmg}-${maxDmg} x${hits} | ${accuracy}% | ${mpCost} MP`;
} else {
statsText = `${minDmg}-${maxDmg} dmg | ${accuracy}% | ${mpCost} MP`;
}
} else if (skill.type === 'heal') {
const healAmount = hardcodedSkill?.calculate ? hardcodedSkill.calculate(playerStats.maxHp) : 0;
statsText = `+${healAmount} HP | ${mpCost} MP`;
} else if (skill.type === 'restore') {
const restoreAmount = hardcodedSkill?.calculate ? hardcodedSkill.calculate(playerStats.maxMp) : 0;
statsText = `+${restoreAmount} MP | ${mpCost} MP`;
} else if (skill.type === 'buff') {
statsText = `Buff | ${mpCost} MP`;
} else {
statsText = `${mpCost} MP`;
}
const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 24);
return `
<div class="char-sheet-skill">
<span class="skill-icon">${iconHtml}</span>
<div class="skill-info">
<div class="skill-name">${displayName}</div>
<div class="skill-desc">${displayDesc}</div>
<div class="skill-cost">${statsText}</div>
</div>
</div>
`;
}).join('');
// Update monsters section
const maxMonsters = getMaxMonsters();
const monsterCount = monsterEntourage.length;
let monstersHtml = `<div class="char-sheet-monster-count">${monsterCount}/${maxMonsters} nearby</div>`;
if (monsterCount > 0) {
monstersHtml += '<div class="char-sheet-monster-list">';
monsterEntourage.forEach(m => {
const type = MONSTER_TYPES[m.type] || { name: 'Unknown', icon: '👹' };
monstersHtml += `
<div class="char-sheet-monster">
<img src="/mapgameimgs/monsters/${m.type}50.png" onerror="this.src='/mapgameimgs/monsters/default50.png'" alt="${type.name}" class="monster-thumb">
<span class="monster-info">Lv${m.level} ${type.name}</span>
<span class="monster-hp">${m.hp}/${m.maxHp} HP</span>
</div>
`;
});
monstersHtml += '</div>';
} else {
monstersHtml += '<div class="char-sheet-no-monsters">No monsters nearby</div>';
}
document.getElementById('charSheetMonsters').innerHTML = monstersHtml;
// Update Daily Skills section
updateDailySkillsSection();
document.getElementById('charSheetModal').style.display = 'flex';
}
// Format time remaining as human-readable string
function formatTimeRemaining(seconds) {
if (seconds <= 0) return 'Ready';
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
// Update the Daily Skills section in character sheet
function updateDailySkillsSection() {
const container = document.getElementById('charSheetDailySkills');
if (!container) return;
const unlockedSkills = playerStats?.unlockedSkills || ['basic_attack'];
// Find all unlocked utility skills
const utilitySkills = unlockedSkills.filter(skillId => {
const skill = SKILLS_DB[skillId] || SKILLS[skillId];
return skill && skill.type === 'utility';
});
if (utilitySkills.length === 0) {
container.innerHTML = '<div class="no-daily-skills">No daily skills unlocked yet</div>';
return;
}
container.innerHTML = utilitySkills.map(skillId => {
const skill = SKILLS_DB[skillId] || SKILLS[skillId];
const buff = playerBuffs[skillId];
let statusClass = 'available';
let statusText = 'Ready to use';
let buttonClass = 'activate';
let buttonText = 'Activate';
let buttonDisabled = false;
if (buff) {
if (buff.isActive) {
statusClass = 'active';
statusText = `Active - ${formatTimeRemaining(buff.expiresIn)} remaining`;
buttonClass = 'active';
buttonText = 'Active';
buttonDisabled = true;
} else if (buff.isOnCooldown) {
statusClass = 'cooldown';
statusText = `On cooldown - ${formatTimeRemaining(buff.cooldownEndsIn)}`;
buttonClass = 'cooldown';
buttonText = 'Cooldown';
buttonDisabled = true;
}
}
const iconHtml = renderSkillIcon(skillId, 'class', playerStats?.class, 24);
const displayName = skill.name;
const displayDesc = skill.description;
return `
<div class="daily-skill">
<span class="skill-icon">${iconHtml}</span>
<div class="skill-info">
<div class="skill-name">${displayName}</div>
<div class="skill-desc">${displayDesc}</div>
<div class="skill-status ${statusClass}">${statusText}</div>
</div>
<button class="daily-skill-btn ${buttonClass}"
onclick="activateUtilitySkill('${skillId}')"
${buttonDisabled ? 'disabled' : ''}>
${buttonText}
</button>
</div>
`;
}).join('');
}
// Activate any utility skill buff
async function activateUtilitySkill(skillId) {
console.log('activateUtilitySkill called:', skillId);
const token = localStorage.getItem('accessToken');
if (!token) {
showNotification('Please log in to use skills', 'error');
return;
}
const skill = SKILLS_DB[skillId] || SKILLS[skillId];
if (!skill) {
showNotification('Unknown skill', 'error');
return;
}
try {
const response = await fetch('/api/user/buffs/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ buffType: skillId })
});
if (response.ok) {
const data = await response.json();
// Update local buff state
playerBuffs[skillId] = {
isActive: true,
isOnCooldown: true,
expiresIn: data.expiresIn,
cooldownEndsIn: data.cooldownEndsIn,
effectType: data.effectType,
effectValue: data.effectValue
};
// Apply effect based on type
switch (data.effectType) {
case 'mp_regen_multiplier':
mpRegenMultiplier = data.effectValue;
break;
case 'explore_radius_multiplier':
exploreRadiusMultiplier = data.effectValue;
updateFogOfWar();
updateGeocacheVisibility();
break;
case 'homebase_radius_multiplier':
homebaseRadiusMultiplier = data.effectValue;
updateFogOfWar();
updateGeocacheVisibility();
break;
}
updateStatus(`${skill.name} activated!`, 'success');
updateDailySkillsSection();
} else {
const error = await response.json();
if (error.cooldownEndsIn) {
updateStatus(`${skill.name} on cooldown for ${formatTimeRemaining(error.cooldownEndsIn)}`, 'error');
} else {
updateStatus(error.error || `Failed to activate ${skill.name}`, 'error');
}
}
} catch (err) {
console.error('Error activating utility skill:', err);
updateStatus(`Failed to activate ${skill.name}`, 'error');
}
}
// Backwards compatibility wrapper
function activateSecondWind() {
activateUtilitySkill('second_wind');
}
// Fetch player buffs from server
async function fetchPlayerBuffs() {
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
const response = await fetch('/api/user/buffs', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const buffs = await response.json();
// Reset all multipliers to base values
mpRegenMultiplier = 1.0;
exploreRadiusMultiplier = 1.0;
homebaseRadiusMultiplier = 1.0;
// Convert array to object keyed by buffType
playerBuffs = {};
buffs.forEach(b => {
playerBuffs[b.buffType] = b;
// Apply active buff effects based on effectType
if (b.isActive) {
switch (b.effectType) {
case 'mp_regen_multiplier':
mpRegenMultiplier = b.effectValue;
break;
case 'explore_radius_multiplier':
exploreRadiusMultiplier = b.effectValue;
break;
case 'homebase_radius_multiplier':
homebaseRadiusMultiplier = b.effectValue;
break;
}
}
});
// Update fog of war if any radius multipliers changed
updateFogOfWar();
updateGeocacheVisibility();
}
} catch (err) {
console.error('Error fetching player buffs:', err);
}
}
// Handle buff expiry by resetting multipliers
function handleBuffExpiry(effectType) {
switch (effectType) {
case 'mp_regen_multiplier':
mpRegenMultiplier = 1.0;
break;
case 'explore_radius_multiplier':
exploreRadiusMultiplier = 1.0;
updateFogOfWar();
updateGeocacheVisibility();
break;
case 'homebase_radius_multiplier':
homebaseRadiusMultiplier = 1.0;
updateFogOfWar();
updateGeocacheVisibility();
break;
}
}
// Periodic buff timer update (runs every second when character sheet is open)
setInterval(() => {
const charSheetModal = document.getElementById('charSheetModal');
if (charSheetModal && charSheetModal.style.display === 'flex') {
let needsUpdate = false;
// Decrement expiresIn and cooldownEndsIn for all buffs
Object.keys(playerBuffs).forEach(key => {
const buff = playerBuffs[key];
if (buff.expiresIn > 0) {
buff.expiresIn--;
needsUpdate = true;
}
if (buff.cooldownEndsIn > 0) {
buff.cooldownEndsIn--;
needsUpdate = true;
}
// Check if buff just expired
if (buff.isActive && buff.expiresIn <= 0) {
buff.isActive = false;
// Reset corresponding multiplier
handleBuffExpiry(buff.effectType);
}
// Check if cooldown just ended
if (buff.isOnCooldown && buff.cooldownEndsIn <= 0) {
buff.isOnCooldown = false;
needsUpdate = true;
}
});
if (needsUpdate) {
updateDailySkillsSection();
}
}
}, 1000);
function hideCharacterSheet() {
document.getElementById('charSheetModal').style.display = 'none';
}
// Character Sheet event listeners
document.getElementById('charSheetClose').addEventListener('click', hideCharacterSheet);
document.getElementById('charSheetModal').addEventListener('click', (e) => {
if (e.target.id === 'charSheetModal') {
hideCharacterSheet();
}
});
// Skill Choice Modal (Level Up)
let pendingSkillChoice = null;
function showSkillChoice(level) {
const pool = SKILL_POOLS[playerStats.class];
if (!pool || !pool[level]) return;
const options = pool[level];
pendingSkillChoice = { level, options };
const optionsHtml = options.map(skillId => {
// Get skill with class-specific name
const skillInfo = getSkillForClass(skillId, playerStats.class);
const hardcodedSkill = SKILLS[skillId];
const skill = skillInfo || hardcodedSkill;
if (!skill) return '';
const displayName = skillInfo?.displayName || skill.name;
const displayDesc = skillInfo?.displayDescription || skill.description;
const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 32);
const mpCost = skill.mpCost || skill.mp_cost || 0;
return `
<div class="skill-choice-option" onclick="selectSkill('${skillId}')">
<span class="skill-choice-icon">${iconHtml}</span>
<div class="skill-choice-details">
<div class="skill-choice-name">${displayName}</div>
<div class="skill-choice-desc">${displayDesc}</div>
<div class="skill-choice-cost">${mpCost} MP</div>
</div>
</div>
`;
}).join('');
document.getElementById('skillChoiceOptions').innerHTML = optionsHtml;
document.getElementById('skillChoiceModal').style.display = 'flex';
}
function selectSkill(skillId) {
if (!pendingSkillChoice) return;
// Initialize unlockedSkills if needed
if (!playerStats.unlockedSkills) {
playerStats.unlockedSkills = ['basic_attack'];
}
// Initialize activeSkills if needed
if (!playerStats.activeSkills) {
playerStats.activeSkills = ['basic_attack'];
}
// Unlock ALL skills at this level (not just the selected one)
pendingSkillChoice.options.forEach(optionSkillId => {
if (!playerStats.unlockedSkills.includes(optionSkillId)) {
playerStats.unlockedSkills.push(optionSkillId);
}
});
// Add only the SELECTED skill to activeSkills
if (!playerStats.activeSkills.includes(skillId)) {
playerStats.activeSkills.push(skillId);
}
// Save to server
savePlayerStats();
// Close modal
document.getElementById('skillChoiceModal').style.display = 'none';
const selectedLevel = pendingSkillChoice.level;
pendingSkillChoice = null;
// Show notification with class-specific name
const skillInfo = getSkillForClass(skillId, playerStats.class);
const skill = skillInfo || SKILLS[skillId];
const displayName = skillInfo?.displayName || skill?.name || skillId;
// Count how many skills were learned at this level
const pool = SKILL_POOLS[playerStats.class];
const skillCount = pool && pool[selectedLevel] ? pool[selectedLevel].length : 1;
if (skillCount > 1) {
showNotification(`Learned ${skillCount} new skills! ${displayName} is now active.`, 'success');
} else {
showNotification(`Learned ${displayName}!`, 'success');
}
}
// Leaderboard
async function loadLeaderboard(period = 'all') {
try {
const response = await fetch(`/api/leaderboard?period=${period}`);
if (response.ok) {
const data = await response.json();
displayLeaderboard(data);
}
} catch (err) {
console.error('Failed to load leaderboard:', err);
}
}
function displayLeaderboard(entries) {
const list = document.getElementById('leaderboardList');
list.innerHTML = entries.map((entry, index) => {
const rankClass = index === 0 ? 'gold' : index === 1 ? 'silver' : index === 2 ? 'bronze' : '';
return `
<li class="leaderboard-item">
<span class="leaderboard-rank ${rankClass}">#${index + 1}</span>
<div class="leaderboard-avatar" style="background: ${entry.avatar_color || '#4CAF50'}">
<i class="mdi mdi-${entry.avatar_icon || 'account'}" style="color: white;"></i>
</div>
<div class="leaderboard-user">
<div class="leaderboard-username">${entry.username}</div>
<div class="leaderboard-finds">${entry.finds_count} finds</div>
</div>
<div class="leaderboard-points">${entry.total_points} pts</div>
</li>
`;
}).join('');
}
function showLeaderboard() {
document.getElementById('leaderboardModal').style.display = 'flex';
loadLeaderboard('all');
}
function hideLeaderboard() {
document.getElementById('leaderboardModal').style.display = 'none';
}
document.getElementById('openLeaderboardBtn').addEventListener('click', showLeaderboard);
document.getElementById('leaderboardCloseBtn').addEventListener('click', hideLeaderboard);
// Leaderboard tabs
document.querySelectorAll('.leaderboard-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.leaderboard-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
loadLeaderboard(tab.dataset.period);
});
});
// "Found It" button for geocaches
async function claimGeocacheFind(geocacheId) {
if (!currentUser) {
showAuthModal();
return;
}
if (!gpsMarker) {
updateStatus('Please enable GPS to claim a find', 'error');
return;
}
const userPos = gpsMarker.getLatLng();
try {
const response = await authFetch('/api/geocaches/' + geocacheId + '/find', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lat: userPos.lat,
lng: userPos.lng,
accuracy: lastGpsAccuracy || 10
})
});
if (response.ok) {
const result = await response.json();
// Update local user data
currentUser.total_points = result.total_points;
currentUser.finds_count = result.finds_count;
updateAuthUI();
// Show points popup
showPointsPopup(result.points_earned, result.is_first_finder);
// Update geocache dialog
updateGeocacheFoundButton(geocacheId, true);
} else {
const error = await response.json();
updateStatus(error.error, 'error');
}
} catch (err) {
console.error('Claim find error:', err);
updateStatus('Failed to claim find', 'error');
}
}
function showPointsPopup(points, isFirstFinder) {
const popup = document.createElement('div');
popup.className = 'points-popup';
popup.innerHTML = `+${points} pts` + (isFirstFinder ? '<span class="bonus">First Finder!</span>' : '');
document.body.appendChild(popup);
setTimeout(() => popup.remove(), 2500);
}
function updateGeocacheFoundButton(geocacheId, found) {
const foundBtn = document.getElementById('geocacheFoundBtn');
if (foundBtn) {
if (found) {
foundBtn.className = 'found-it-btn already-found';
foundBtn.textContent = 'Already Found!';
foundBtn.disabled = true;
}
}
}
// Track last GPS accuracy for find validation
let lastGpsAccuracy = null;
// ==========================================
// RPG COMBAT SYSTEM FUNCTIONS
// ==========================================
// Initialize player stats for RPG system
async function initializePlayerStats(username) {
const token = localStorage.getItem('accessToken');
if (!token) {
playerStats = null;
document.getElementById('rpgHud').style.display = 'none';
return;
}
// Check if user has a character
try {
const hasCharResponse = await fetch('/api/user/has-character', {
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-store'
});
if (hasCharResponse.ok) {
const { hasCharacter } = await hasCharResponse.json();
if (!hasCharacter) {
// No character - show character creator
console.log('No character found, showing character creator');
showCharCreatorModal();
return;
}
}
} catch (e) {
console.error('Failed to check character status:', e);
}
// Try to load character from server (always fetch fresh, never use cache)
try {
const response = await fetch('/api/user/rpg-stats', {
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-store' // Force fresh fetch, bypass all caches
});
if (response.ok) {
const serverStats = await response.json();
if (serverStats && serverStats.name) {
// Check if localStorage has a more recent version (in case of sync issues)
const localSaved = localStorage.getItem('hikemap_rpg_stats');
if (localSaved) {
try {
const localStats = JSON.parse(localSaved);
const serverVersion = serverStats.dataVersion || 0;
const localVersion = localStats.dataVersion || 0;
const localTimestamp = localStats.localSaveTimestamp || 0;
// Use localStorage if it has higher version AND matches the same character
// OR if same version but local was saved more recently (failed server sync)
const useLocal = localStats.name === serverStats.name && (
localVersion > serverVersion ||
(localVersion === serverVersion && localTimestamp > Date.now() - 60000) // Local save within last minute
);
if (useLocal) {
console.warn(`[SYNC] localStorage has newer data (v${localVersion}) than server (v${serverVersion}) - using local`);
playerStats = localStats;
// Mark dirty to push local changes to server
statsLoadedFromServer = true;
statsSyncState.dirty = true;
showNotification('Restored unsaved progress from local backup', 'info');
// Show RPG HUD and start game
document.getElementById('rpgHud').style.display = 'flex';
updateRpgHud();
updateHomeBaseMarker();
// Fetch player buffs (like Second Wind)
await fetchPlayerBuffs();
// If player is dead, show death overlay
if (playerStats.isDead) {
document.getElementById('deathOverlay').style.display = 'flex';
} else {
startMonsterSpawning();
}
// Start auto-save and immediately flush
startAutoSave();
flushStatsSync();
console.log('RPG system initialized (from local backup) for', username);
return;
}
} catch (e) {
console.error('Failed to parse local stats for comparison:', e);
}
}
// Use server stats (normal case)
playerStats = serverStats;
console.log('Loaded RPG stats from server (fresh):', playerStats);
// Mark that we've loaded from server - safe to save now
statsLoadedFromServer = true;
// Update localStorage to match server (for offline viewing only)
localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats));
// Show RPG HUD and start game
document.getElementById('rpgHud').style.display = 'flex';
updateRpgHud();
updateHomeBaseMarker();
// Load reveal radius for fog of war
playerRevealRadius = serverStats.revealRadius || 800;
updateFogOfWar();
updateGeocacheVisibility();
// Fetch player buffs (like Second Wind)
await fetchPlayerBuffs();
// If player is dead, show death overlay
if (playerStats.isDead) {
document.getElementById('deathOverlay').style.display = 'flex';
} else {
startMonsterSpawning();
}
// Start auto-save now that we have valid server data
startAutoSave();
console.log('RPG system initialized for', username);
return;
}
}
} catch (e) {
console.error('Failed to load RPG stats from server:', e);
}
// Server didn't have valid stats - check if user has never played
// (localStorage fallback is only for reading, never write back to server without explicit action)
const saved = localStorage.getItem('hikemap_rpg_stats');
if (saved) {
try {
const localStats = JSON.parse(saved);
if (localStats && localStats.name) {
console.log('Found localStorage stats but server has no data - showing character creator');
// Don't auto-migrate localStorage to server - user should create fresh character
// This prevents stale localStorage data from overwriting server progress
}
} catch (e) {
console.error('Failed to parse saved RPG stats:', e);
}
}
// No valid stats found - show character creator
console.log('No valid character data, showing character creator');
showCharCreatorModal();
}
// Refresh player stats from server (used when admin updates stats)
async function refreshPlayerStats() {
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
const response = await fetch('/api/user/rpg-stats', {
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-store'
});
if (response.ok) {
const serverStats = await response.json();
if (serverStats && serverStats.name) {
playerStats = serverStats;
console.log('Refreshed RPG stats from server:', playerStats);
// Update localStorage backup with fresh server data
localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats));
// Update UI
updateRpgHud();
updateHomeBaseMarker();
// Update fog of war with new reveal radius
playerRevealRadius = serverStats.revealRadius || 800;
updateFogOfWar();
updateGeocacheVisibility();
// Handle death state changes
if (playerStats.isDead) {
document.getElementById('deathOverlay').style.display = 'flex';
stopMonsterSpawning();
} else {
document.getElementById('deathOverlay').style.display = 'none';
startMonsterSpawning();
}
showNotification('Stats updated by admin', 'info');
}
}
} catch (e) {
console.error('Failed to refresh RPG stats:', e);
}
}
// ========== STATS SYNC ENGINE ==========
// Replaces direct saves with debounced, rate-limited sync to prevent version conflicts
// Mark stats as needing sync (call this instead of immediate save)
function markStatsDirty() {
statsSyncState.dirty = true;
// Debounce: wait for rapid changes to settle before saving
if (syncDebounceTimer) {
clearTimeout(syncDebounceTimer);
}
syncDebounceTimer = setTimeout(() => {
flushStatsSync();
}, SYNC_DEBOUNCE_MS);
}
// Force an immediate sync (for critical moments like page unload)
function flushStatsSync() {
if (syncDebounceTimer) {
clearTimeout(syncDebounceTimer);
syncDebounceTimer = null;
}
if (!statsSyncState.dirty) return;
if (!playerStats || !statsLoadedFromServer) return;
// Always save to localStorage immediately as backup (with local timestamp)
const statsWithTimestamp = { ...playerStats, localSaveTimestamp: Date.now() };
localStorage.setItem('hikemap_rpg_stats', JSON.stringify(statsWithTimestamp));
// If a save is already in flight, just mark that we need another
if (statsSyncState.saveInFlight) {
statsSyncState.pendingSave = true;
return;
}
// Rate limiting: don't spam the server
const now = Date.now();
const timeSinceLastSave = now - statsSyncState.lastSaveAttempt;
if (timeSinceLastSave < SYNC_MIN_INTERVAL_MS) {
// Schedule for later
setTimeout(flushStatsSync, SYNC_MIN_INTERVAL_MS - timeSinceLastSave);
return;
}
// Execute the save
executeStatsSave();
}
// Internal: actually perform the HTTP save
async function executeStatsSave() {
const token = localStorage.getItem('accessToken');
if (!token) {
console.warn('No access token - stats only saved to localStorage');
statsSyncState.dirty = false;
return;
}
statsSyncState.saveInFlight = true;
statsSyncState.lastSaveAttempt = Date.now();
statsSyncState.dirty = false; // Clear dirty flag before save
try {
const response = await fetch('/api/user/rpg-stats', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(playerStats)
});
if (response.ok) {
const data = await response.json();
// Update version in live playerStats
if (data.dataVersion) {
playerStats.dataVersion = data.dataVersion;
console.log('Stats saved, version now:', data.dataVersion);
}
statsSyncState.consecutiveFailures = 0;
} else if (response.status === 409) {
// Version conflict - sync version from server silently
console.log('Version conflict - syncing silently...');
await handleVersionConflict(token);
// Mark dirty again so we retry with correct version
statsSyncState.dirty = true;
} else {
throw new Error(`Server rejected save: ${response.status}`);
}
} catch (err) {
console.error('Failed to save RPG stats to server:', err);
statsSyncState.consecutiveFailures++;
statsSyncState.dirty = true; // Retry later
// Only show error to user for persistent network failures, not during combat
if (!statsSyncState.inCombat && statsSyncState.consecutiveFailures >= 3) {
showNotification('Connection issue - progress will sync when restored', 'warning');
statsSyncState.consecutiveFailures = 0; // Reset to avoid spam
}
} finally {
statsSyncState.saveInFlight = false;
// If another save was requested while this one was in flight, do it now
if (statsSyncState.pendingSave || statsSyncState.dirty) {
statsSyncState.pendingSave = false;
setTimeout(flushStatsSync, 100); // Small delay to prevent tight loops
}
}
}
// Handle 409 version conflicts silently
async function handleVersionConflict(token) {
try {
const response = await fetch('/api/user/rpg-stats', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const serverStats = await response.json();
if (serverStats && serverStats.dataVersion) {
const oldVersion = playerStats.dataVersion;
playerStats.dataVersion = serverStats.dataVersion;
console.log(`Version synced silently: ${oldVersion} -> ${serverStats.dataVersion}`);
}
}
} catch (err) {
console.error('Failed to sync version from server:', err);
// Don't show error - will retry on next save
}
}
// Backward compatible alias - all existing call sites continue to work
function savePlayerStats() {
markStatsDirty();
}
// Update the RPG HUD display
function updateRpgHud() {
if (!playerStats) return;
// Update HP bar
const hpPercent = Math.min(100, (playerStats.hp / playerStats.maxHp) * 100);
document.getElementById('hudHpBar').style.width = hpPercent + '%';
document.getElementById('hudHp').textContent = `${playerStats.hp}/${playerStats.maxHp}`;
// Update MP bar
const mpPercent = Math.min(100, (playerStats.mp / playerStats.maxMp) * 100);
document.getElementById('hudMpBar').style.width = mpPercent + '%';
document.getElementById('hudMp').textContent = `${playerStats.mp}/${playerStats.maxMp}`;
// Update XP bar
const xpNeeded = playerStats.level * 100;
const xpPercent = Math.min(100, (playerStats.xp / xpNeeded) * 100);
document.getElementById('hudXpBar').style.width = xpPercent + '%';
document.getElementById('hudXpText').textContent = `${playerStats.xp}/${xpNeeded}`;
// Update dead state styling
const hud = document.getElementById('rpgHud');
if (playerStats.isDead) {
hud.classList.add('dead');
} else {
hud.classList.remove('dead');
}
// Show/hide home base button
document.getElementById('homeBaseBtn').style.display = playerStats ? 'flex' : 'none';
// Show/hide music button and update state
const musicBtn = document.getElementById('musicToggleBtn');
if (musicBtn) {
musicBtn.style.display = playerStats ? 'flex' : 'none';
musicBtn.classList.toggle('muted', gameMusic.muted);
updateMusicButton();
}
}
// ==========================================
// HOME BASE SYSTEM
// ==========================================
// Create or update home base marker on map
function updateHomeBaseMarker() {
if (!playerStats || playerStats.homeBaseLat == null || playerStats.homeBaseLng == null) {
if (homeBaseMarker) {
map.removeLayer(homeBaseMarker);
homeBaseMarker = null;
}
updateHomeBaseButton();
return;
}
// Use selected icon or default to '00' (use 100px, CSS scales to 50)
const iconId = playerStats.homeBaseIcon || '00';
const iconSrc = `/mapgameimgs/bases/homebase${iconId}-100.png`;
const iconHtml = `
<div class="home-base-marker">
<img src="${iconSrc}" alt="Home Base" style="width:50px;height:50px;"
onerror="this.src='/mapgameimgs/monsters/default50.png'">
</div>
`;
const divIcon = L.divIcon({
html: iconHtml,
iconSize: [50, 50],
iconAnchor: [25, 25],
className: 'home-base-icon'
});
if (homeBaseMarker) {
// Update the icon (need to recreate the marker to change icon)
homeBaseMarker.setLatLng([playerStats.homeBaseLat, playerStats.homeBaseLng]);
homeBaseMarker.setIcon(divIcon);
} else {
homeBaseMarker = L.marker([playerStats.homeBaseLat, playerStats.homeBaseLng], {
icon: divIcon,
interactive: false
}).addTo(map);
}
updateHomeBaseButton();
}
// Update home base button text based on whether home is set
function updateHomeBaseButton() {
const btn = document.getElementById('homeBaseBtn');
if (playerStats && playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) {
btn.innerHTML = '🏠';
btn.title = 'Homebase Settings';
} else {
btn.innerHTML = '🏠';
btn.title = 'Set Home Base';
}
}
// Toggle home base selection mode OR open customization modal
async function toggleHomeBaseSelection() {
// If in selection mode, cancel it
if (homeBaseSelectionMode) {
homeBaseSelectionMode = false;
document.getElementById('homeBaseBtn').classList.remove('selecting');
document.getElementById('selectionHint').style.display = 'none';
return;
}
// If home base is already set, open the customization modal
if (playerStats && playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) {
openHomebaseModal();
return;
}
// Otherwise, start setting home base
await startSettingHomeBase();
}
// Start the home base selection process
async function startSettingHomeBase() {
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
const response = await fetch('/api/user/can-set-home', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (!data.canSet) {
alert('You can only set your home base once per day. Try again tomorrow!');
return;
}
// Enter selection mode
homeBaseSelectionMode = true;
document.getElementById('homeBaseBtn').classList.add('selecting');
document.getElementById('selectionHint').style.display = 'block';
} catch (err) {
console.error('Failed to check home base availability:', err);
}
}
// Open the homebase customization modal
async function openHomebaseModal() {
const modal = document.getElementById('homebaseModal');
modal.style.display = 'flex';
// Load skill loadout
populateHomebaseSkillLoadout();
// Load available icons
await loadHomebaseIcons();
// Check if relocation is available
await updateRelocateButton();
}
// Populate skill loadout in homebase modal
function populateHomebaseSkillLoadout() {
const container = document.getElementById('homebaseSkillLoadout');
if (!container) return;
// Check if player is at home base
const distToHome = getDistanceToHome();
const isAtHome = distToHome !== null && distToHome <= spawnSettings.homeBaseRadius;
const unlockedSkills = playerStats.unlockedSkills || ['basic_attack'];
const activeSkills = playerStats.activeSkills || playerStats.unlockedSkills || ['basic_attack'];
const pool = SKILL_POOLS[playerStats.class];
// Helper to check if a skill is a combat skill (not utility)
const isCombatSkill = (skillId) => {
const skill = SKILLS_DB[skillId] || SKILLS[skillId];
if (!skill) return false;
return skill.type !== 'utility';
};
// Build skill tiers from SKILL_POOLS
const tierSkills = {};
// Group unlocked skills by their tier level
if (pool) {
Object.keys(pool).forEach(level => {
const tierLevel = parseInt(level);
const skillsAtLevel = pool[level];
// Get ALL skills at this tier (for swapping), filter out basic_attack and utility
const allAtTier = skillsAtLevel.filter(sid =>
sid !== 'basic_attack' && isCombatSkill(sid)
);
// Only show tier if player has unlocked at least one skill from it
const hasUnlocked = allAtTier.some(sid => unlockedSkills.includes(sid));
if (hasUnlocked && allAtTier.length > 0) {
tierSkills[tierLevel] = allAtTier;
}
});
}
// Generate HTML
let html = '';
// Show warning if not at home base
if (!isAtHome) {
html += '<div class="homebase-skill-warning">⚠️ Return to home base to change skills</div>';
}
const sortedTiers = Object.keys(tierSkills).map(Number).sort((a, b) => a - b);
if (sortedTiers.length === 0) {
html += '<div class="homebase-no-skills">No skills to configure yet. Level up to unlock skills!</div>';
} else {
sortedTiers.forEach(tier => {
const skills = tierSkills[tier];
if (!skills || skills.length === 0) return;
// Only show tiers where player has multiple skills unlocked (can swap)
const unlockedAtTier = skills.filter(sid => unlockedSkills.includes(sid));
if (unlockedAtTier.length < 2) {
// Show single skill but no swap option
if (unlockedAtTier.length === 1) {
const skillId = unlockedAtTier[0];
const skillInfo = getSkillForClass(skillId, playerStats.class);
const baseSkill = SKILLS_DB[skillId] || SKILLS[skillId];
if (!baseSkill) return;
const displayName = skillInfo?.displayName || baseSkill.name;
const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 24);
const mpCost = baseSkill.mpCost || 0;
const isActive = activeSkills.includes(skillId);
html += `
<div class="homebase-skill-tier">
<div class="homebase-skill-tier-label">Level ${tier}</div>
<div class="homebase-skill-options">
<div class="homebase-skill-btn active" style="cursor: default;">
<span class="skill-icon">${iconHtml}</span>
<div class="skill-info">
<span class="skill-name">${displayName}</span>
<span class="skill-mp">${mpCost > 0 ? mpCost + ' MP' : 'Free'}</span>
</div>
</div>
</div>
</div>
`;
}
return;
}
html += `<div class="homebase-skill-tier">`;
html += `<div class="homebase-skill-tier-label">Level ${tier} - Choose One</div>`;
html += `<div class="homebase-skill-options">`;
unlockedAtTier.forEach(skillId => {
const skillInfo = getSkillForClass(skillId, playerStats.class);
const baseSkill = SKILLS_DB[skillId] || SKILLS[skillId];
if (!baseSkill) return;
const displayName = skillInfo?.displayName || baseSkill.name;
const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 24);
const mpCost = baseSkill.mpCost || 0;
const isActive = activeSkills.includes(skillId);
const canSwap = isAtHome && !isActive;
html += `
<button class="homebase-skill-btn ${isActive ? 'active' : ''} ${!isAtHome && !isActive ? 'disabled-away' : ''}"
onclick="swapSkillFromHomebase(${tier}, '${skillId}')"
${!canSwap && !isActive ? 'disabled' : ''}>
<span class="skill-icon">${iconHtml}</span>
<div class="skill-info">
<span class="skill-name">${displayName}</span>
<span class="skill-mp">${mpCost > 0 ? mpCost + ' MP' : 'Free'}</span>
</div>
</button>
`;
});
html += `</div></div>`;
});
}
// If no swappable tiers but we generated nothing, show message
if (html === '') {
html = '<div class="homebase-no-skills">Level up to unlock more skill choices!</div>';
}
container.innerHTML = html;
}
// Swap skill from homebase modal (with instant feedback)
async function swapSkillFromHomebase(tier, newSkillId) {
// Check if player is at home base
const distToHome = getDistanceToHome();
if (distToHome === null || distToHome > spawnSettings.homeBaseRadius) {
showNotification('You must be at your home base to change skills!', 'warning');
return;
}
const unlockedSkills = playerStats.unlockedSkills || ['basic_attack'];
const activeSkills = playerStats.activeSkills || [...unlockedSkills];
const pool = SKILL_POOLS[playerStats.class];
// Validate skill is unlocked
if (!unlockedSkills.includes(newSkillId)) {
showNotification('Skill not unlocked!', 'warning');
return;
}
// If already active, do nothing
if (activeSkills.includes(newSkillId)) {
return;
}
// Find the old skill at this tier to remove
let oldSkillId = null;
if (pool && pool[tier]) {
oldSkillId = pool[tier].find(sid => activeSkills.includes(sid));
}
// Build new active skills array
let newActiveSkills = [...activeSkills];
// Remove old skill from this tier if found
if (oldSkillId && oldSkillId !== newSkillId) {
newActiveSkills = newActiveSkills.filter(sid => sid !== oldSkillId);
}
// Add new skill
if (!newActiveSkills.includes(newSkillId)) {
newActiveSkills.push(newSkillId);
}
// Update local state immediately for instant feedback
playerStats.activeSkills = newActiveSkills;
// Refresh UI immediately
populateHomebaseSkillLoadout();
// Save to server
const token = localStorage.getItem('accessToken');
if (token) {
try {
const response = await fetch('/api/user/swap-skill', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
tier,
newSkillId,
currentActiveSkills: newActiveSkills,
unlockedSkills
})
});
if (response.ok) {
const data = await response.json();
playerStats.activeSkills = data.activeSkills;
savePlayerStats();
// Get skill name for notification
const skill = SKILLS_DB[newSkillId] || SKILLS[newSkillId];
const skillInfo = getSkillForClass(newSkillId, playerStats.class);
const skillName = skillInfo?.displayName || skill?.name || newSkillId;
showNotification(`${skillName} equipped!`, 'success');
} else {
const error = await response.json();
showNotification(error.error || 'Failed to swap skill', 'warning');
// Revert local state
playerStats.activeSkills = activeSkills;
populateHomebaseSkillLoadout();
}
} catch (err) {
console.error('Failed to swap skill:', err);
showNotification('Failed to swap skill', 'warning');
playerStats.activeSkills = activeSkills;
populateHomebaseSkillLoadout();
}
}
}
// Close the homebase modal
function closeHomebaseModal() {
document.getElementById('homebaseModal').style.display = 'none';
}
// Load available homebase icons from server
async function loadHomebaseIcons() {
const grid = document.getElementById('homebaseIconsGrid');
grid.innerHTML = '<div style="color: #888; text-align: center; grid-column: 1/-1;">Loading icons...</div>';
try {
const response = await fetch('/api/homebase-icons');
const icons = await response.json();
if (icons.length === 0) {
grid.innerHTML = '<div style="color: #888; text-align: center; grid-column: 1/-1;">No homebase icons found</div>';
return;
}
grid.innerHTML = icons.map(icon => `
<div class="homebase-icon-option ${playerStats.homeBaseIcon === icon.id ? 'selected' : ''}"
onclick="selectHomebaseIcon('${icon.id}')"
data-icon-id="${icon.id}">
<img src="${icon.preview}" alt="Homebase ${icon.id}"
onerror="this.src='/mapgameimgs/monsters/default50.png'">
</div>
`).join('');
} catch (err) {
console.error('Failed to load homebase icons:', err);
grid.innerHTML = '<div style="color: #e94560; text-align: center; grid-column: 1/-1;">Failed to load icons</div>';
}
}
// Select a homebase icon
async function selectHomebaseIcon(iconId) {
console.log('selectHomebaseIcon called with:', iconId);
const token = localStorage.getItem('accessToken');
if (!token) {
console.error('No access token for icon selection');
showNotification('Please log in to change icon', 'error');
return;
}
try {
const response = await fetch('/api/user/home-base/icon', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ iconId })
});
if (response.ok) {
playerStats.homeBaseIcon = iconId;
// Update selected state in UI
document.querySelectorAll('.homebase-icon-option').forEach(el => {
el.classList.toggle('selected', el.dataset.iconId === iconId);
});
// Update the marker on the map
updateHomeBaseMarker();
savePlayerStats();
showNotification('Base icon updated!', 'success');
console.log('Home base icon updated to:', iconId);
} else {
const error = await response.json();
showNotification(error.error || 'Failed to update icon', 'error');
}
} catch (err) {
console.error('Failed to update homebase icon:', err);
showNotification('Failed to update icon', 'error');
}
}
// Update the relocate button based on cooldown
async function updateRelocateButton() {
const btn = document.getElementById('relocateBtn');
const cooldownText = document.getElementById('relocateCooldown');
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
const response = await fetch('/api/user/can-set-home', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.canSet) {
btn.disabled = false;
cooldownText.textContent = '';
} else {
btn.disabled = true;
// Calculate time remaining
if (playerStats.lastHomeSet) {
const lastSet = new Date(playerStats.lastHomeSet);
const nextAvailable = new Date(lastSet.getTime() + 24 * 60 * 60 * 1000);
const now = new Date();
const hoursLeft = Math.ceil((nextAvailable - now) / (1000 * 60 * 60));
cooldownText.textContent = `Available in ~${hoursLeft} hour${hoursLeft !== 1 ? 's' : ''}`;
} else {
cooldownText.textContent = 'Try again tomorrow';
}
}
} catch (err) {
console.error('Failed to check relocate availability:', err);
}
}
// Start relocating homebase
async function startRelocateHomebase() {
closeHomebaseModal();
await startSettingHomeBase();
}
// Set home base at the given location
async function setHomeBase(lat, lng) {
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
const response = await fetch('/api/user/home-base', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ lat, lng })
});
if (response.ok) {
const data = await response.json();
playerStats.homeBaseLat = data.homeBaseLat;
playerStats.homeBaseLng = data.homeBaseLng;
updateHomeBaseMarker();
console.log('Home base set at:', lat, lng);
// Update fog of war to reveal area around new homebase
updateFogOfWar();
updateGeocacheVisibility();
// Discover nearby locations via Overpass API
discoverNearbyLocations(lat, lng);
} else {
const error = await response.json();
alert(error.error || 'Failed to set home base');
}
} catch (err) {
console.error('Failed to set home base:', err);
}
// Exit selection mode
homeBaseSelectionMode = false;
document.getElementById('homeBaseBtn').classList.remove('selecting');
document.getElementById('selectionHint').style.display = 'none';
}
// Calculate distance to home base in meters
function getDistanceToHome() {
if (!userLocation || !playerStats || playerStats.homeBaseLat == null) return null;
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180);
const dx = (userLocation.lng - playerStats.homeBaseLng) * metersPerDegLng;
const dy = (userLocation.lat - playerStats.homeBaseLat) * metersPerDegLat;
return Math.sqrt(dx * dx + dy * dy);
}
// Check if player has reached home base for respawn
function checkHomeBaseRespawn() {
if (!playerStats || !playerStats.isDead) return;
if (playerStats.homeBaseLat == null) return;
const distance = getDistanceToHome();
if (distance === null) return;
// Update distance display
const distanceText = distance < 1000
? `${Math.round(distance)}m to home`
: `${(distance / 1000).toFixed(1)}km to home`;
document.getElementById('homeDistanceText').textContent = distanceText;
// Respawn if within home base radius
if (distance <= spawnSettings.homeBaseRadius) {
respawnPlayer();
}
}
// Check for HP/MP regeneration at home base
function checkHomeBaseRegen() {
// Skip if dead, no home base, or no player stats
if (!playerStats || playerStats.isDead) return;
if (playerStats.homeBaseLat == null) return;
// Skip if already at max MP (HP is handled by time-based regen)
if (playerStats.mp >= playerStats.maxMp) return;
// Check distance to home
const distance = getDistanceToHome();
if (distance === null || distance > spawnSettings.homeBaseRadius) return;
// Check if enough time has passed since last regen
const now = Date.now();
if (now - lastHomeRegenTime < HOME_REGEN_INTERVAL) return;
// Regenerate MP only (HP is handled by time-based regen with 3x at home)
const mpRegen = Math.ceil(playerStats.maxMp * (spawnSettings.homeRegenPercent / 100));
if (playerStats.mp < playerStats.maxMp) {
const oldMp = playerStats.mp;
playerStats.mp = Math.min(playerStats.maxMp, playerStats.mp + mpRegen);
if (playerStats.mp > oldMp) {
lastHomeRegenTime = now;
updateRpgHud();
savePlayerStats();
// Show subtle MP regen indicator
showHomeRegenEffect();
}
}
}
// Show a subtle visual effect for home regen
function showHomeRegenEffect() {
const hud = document.getElementById('rpgHud');
if (!hud) return;
// Add a brief glow effect
hud.style.boxShadow = '0 0 15px rgba(76, 175, 80, 0.6)';
setTimeout(() => {
hud.style.boxShadow = '';
}, 500);
}
// Check for MP regeneration while walking (distance-based)
function checkWalkingMpRegen(lat, lng) {
// Skip if dead, in combat, or no player stats
if (!playerStats || playerStats.isDead) return;
if (combatState && combatState.inCombat) return;
// Skip if already at max MP
if (playerStats.mp >= playerStats.maxMp) return;
// Get the regen distance setting (default 5 meters per 1 MP)
const regenDistance = spawnSettings.mpRegenDistance || 5;
if (regenDistance <= 0) return; // Disabled if 0 or negative
// Initialize last location if not set
if (!lastMpRegenLocation) {
lastMpRegenLocation = { lat, lng };
return;
}
// Calculate distance walked since last check (in meters)
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(lat * Math.PI / 180);
const dx = (lng - lastMpRegenLocation.lng) * metersPerDegLng;
const dy = (lat - lastMpRegenLocation.lat) * metersPerDegLat;
const distanceWalked = Math.sqrt(dx * dx + dy * dy);
// Update last location
lastMpRegenLocation = { lat, lng };
// Skip very small movements (GPS jitter) or very large jumps (teleport/error)
if (distanceWalked < 0.5 || distanceWalked > 50) return;
// Accumulate distance
mpRegenAccumulator += distanceWalked;
// Check if we've walked enough for MP regen
if (mpRegenAccumulator >= regenDistance) {
// Apply MP regen multiplier (e.g., 2x with Second Wind active)
const regenTicks = Math.floor(mpRegenAccumulator / regenDistance);
const baseMpToRegen = regenTicks * (spawnSettings.mpRegenAmount || 1);
const mpToRegen = Math.floor(baseMpToRegen * mpRegenMultiplier);
mpRegenAccumulator = mpRegenAccumulator % regenDistance; // Keep remainder
const oldMp = playerStats.mp;
playerStats.mp = Math.min(playerStats.maxMp, playerStats.mp + mpToRegen);
if (playerStats.mp > oldMp) {
updateRpgHud();
savePlayerStats();
// Show blue glow for MP regen (green if boosted by Second Wind)
showMpRegenEffect(mpRegenMultiplier > 1);
}
}
}
// Check for HP regeneration (time-based, runs on timer)
function checkTimeBasedHpRegen() {
// Skip if dead, in combat, or no player stats
if (!playerStats || playerStats.isDead) return;
if (combatState && combatState.inCombat) return;
// Skip if already at max HP
if (playerStats.hp >= playerStats.maxHp) return;
// Check if at home base for 3x boost
const distanceToHome = getDistanceToHome();
const isAtHome = distanceToHome !== null && distanceToHome <= spawnSettings.homeBaseRadius;
// Calculate HP to regen (1% base, 3% at home)
const multiplier = isAtHome ? spawnSettings.homeHpMultiplier : 1;
const hpToRegen = Math.max(1, Math.ceil(playerStats.maxHp * (spawnSettings.hpRegenPercent / 100) * multiplier));
const oldHp = playerStats.hp;
playerStats.hp = Math.min(playerStats.maxHp, playerStats.hp + hpToRegen);
if (playerStats.hp > oldHp) {
updateRpgHud();
savePlayerStats();
// Show red glow for HP regen (green if at home)
showHpRegenEffect(isAtHome);
}
}
// Start the time-based HP regen timer
function startHpRegenTimer() {
if (hpRegenTimer) clearInterval(hpRegenTimer);
// HP regens every 10 seconds
hpRegenTimer = setInterval(() => {
checkTimeBasedHpRegen();
}, spawnSettings.hpRegenInterval);
}
// Stop the HP regen timer
function stopHpRegenTimer() {
if (hpRegenTimer) {
clearInterval(hpRegenTimer);
hpRegenTimer = null;
}
}
// Show a subtle visual effect for MP regen (walking)
function showMpRegenEffect(boosted = false) {
const hud = document.getElementById('rpgHud');
if (!hud) return;
const glowColor = boosted ? 'rgba(76, 175, 80, 0.8)' : 'rgba(33, 150, 243, 0.6)';
hud.style.boxShadow = `0 0 ${boosted ? '20' : '15'}px ${glowColor}`;
setTimeout(() => {
hud.style.boxShadow = '';
}, boosted ? 500 : 300);
}
// Show a subtle visual effect for HP regen (time-based)
function showHpRegenEffect(boosted = false) {
const hud = document.getElementById('rpgHud');
if (!hud) return;
const glowColor = boosted ? 'rgba(76, 175, 80, 0.8)' : 'rgba(239, 83, 80, 0.6)';
hud.style.boxShadow = `0 0 ${boosted ? '20' : '15'}px ${glowColor}`;
setTimeout(() => {
hud.style.boxShadow = '';
}, boosted ? 500 : 300);
}
// Check if at home base and clear monsters if entering
function checkHomeBaseMonsterClear() {
const distance = getDistanceToHome();
const isAtHome = distance !== null && distance <= spawnSettings.homeBaseRadius;
// Check if player just entered home base
if (isAtHome && !wasAtHomeBase) {
clearAllMonsters();
// Play homebase music (unless dead or in combat)
if (playerStats && !playerStats.isDead && !combatState) {
playMusic('homebase');
}
}
// Check if player just left home base
if (!isAtHome && wasAtHomeBase) {
// Switch to overworld music (unless dead or in combat)
if (playerStats && !playerStats.isDead && !combatState) {
playMusic('overworld');
}
}
// Update tracking state
wasAtHomeBase = isAtHome;
}
// Clear all monsters from the map (used when entering home base)
function clearAllMonsters() {
if (monsterEntourage.length === 0) return;
// Remove all monster markers from the map
for (const monster of monsterEntourage) {
if (monster.marker) {
monster.marker.remove();
}
}
// Clear the array
const count = monsterEntourage.length;
monsterEntourage.length = 0;
// Show notification
if (count > 0) {
console.log(`🏠 Entered home base - ${count} monster${count > 1 ? 's' : ''} fled!`);
}
// Update HUD
updateRpgHud();
}
// Handle player death
async function handlePlayerDeath() {
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
const response = await fetch('/api/user/death', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
xpLostOnDeath = data.xpLost;
playerStats.xp = data.newXp;
playerStats.hp = 0;
playerStats.isDead = true;
// Clear local monster entourage
monsterEntourage.forEach(m => {
if (m.marker) map.removeLayer(m.marker);
});
monsterEntourage = [];
// Show death overlay
document.getElementById('xpLostText').textContent = `-${xpLostOnDeath} XP`;
document.getElementById('deathOverlay').style.display = 'flex';
// Play death music
playMusic('death');
// Update HUD
updateRpgHud();
console.log('Player died, lost', xpLostOnDeath, 'XP');
}
} catch (err) {
console.error('Failed to handle death:', err);
}
}
// Respawn player at home base
async function respawnPlayer() {
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
const response = await fetch('/api/user/respawn', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
playerStats.isDead = false;
playerStats.hp = data.hp;
playerStats.mp = data.mp;
// Hide death overlay
document.getElementById('deathOverlay').style.display = 'none';
// Play homebase music (player respawns at home)
playMusic('homebase');
// Update HUD
updateRpgHud();
savePlayerStats();
// Restart monster spawning and home regen timer
startMonsterSpawning();
console.log('Player respawned with full HP/MP');
}
} catch (err) {
console.error('Failed to respawn:', err);
}
}
// Save monsters to server (debounced)
let monsterSaveTimeout = null;
function saveMonsters() {
if (monsterSaveTimeout) clearTimeout(monsterSaveTimeout);
monsterSaveTimeout = setTimeout(() => {
const token = localStorage.getItem('accessToken');
if (!token) return;
// Prepare monsters for saving (remove marker reference)
const monstersToSave = monsterEntourage.map(m => ({
id: m.id,
type: m.type,
level: m.level,
hp: m.hp,
maxHp: m.maxHp,
atk: m.atk,
def: m.def,
position: m.position,
spawnTime: m.spawnTime,
lastDialogueTime: m.lastDialogueTime || 0
}));
fetch('/api/user/monsters', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(monstersToSave)
}).catch(err => console.error('Failed to save monsters:', err));
}, 1000); // Debounce 1 second
}
// Load monsters from server
async function loadMonsters() {
const token = localStorage.getItem('accessToken');
if (!token) return false;
try {
const response = await fetch('/api/user/monsters', {
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-store' // Prevent browser caching - each user must get fresh data
});
if (response.ok) {
const savedMonsters = await response.json();
if (savedMonsters && savedMonsters.length > 0) {
console.log('Loading', savedMonsters.length, 'saved monsters');
// Clear any existing monsters first
monsterEntourage.forEach(m => {
if (m.marker) m.marker.remove();
});
monsterEntourage = [];
// Recreate each monster with markers
for (const m of savedMonsters) {
// If monster has no position, place near player or map center
let position = m.position;
if (!position || !position.lat || !position.lng) {
if (userLocation) {
// Random offset 30-50 meters from player
const angle = Math.random() * 2 * Math.PI;
const distance = 30 + Math.random() * 20;
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180);
position = {
lat: userLocation.lat + (distance * Math.cos(angle)) / metersPerDegLat,
lng: userLocation.lng + (distance * Math.sin(angle)) / metersPerDegLng
};
} else {
// Default to map center
const center = map.getCenter();
position = { lat: center.lat, lng: center.lng };
}
}
const monster = {
id: m.id,
type: m.type,
level: m.level,
hp: m.hp,
maxHp: m.maxHp,
atk: m.atk,
def: m.def,
position: position,
spawnTime: m.spawnTime,
lastDialogueTime: m.lastDialogueTime || 0,
marker: null
};
createMonsterMarker(monster);
monsterEntourage.push(monster);
}
updateRpgHud();
// Set last spawn location so player needs to move before more spawn
if (userLocation) {
lastSpawnLocation = { lat: userLocation.lat, lng: userLocation.lng };
}
return true;
}
}
} catch (e) {
console.error('Failed to load monsters:', e);
}
return false;
}
// Start monster spawning timer
async function startMonsterSpawning() {
if (monsterSpawnTimer) clearInterval(monsterSpawnTimer);
if (monsterUpdateTimer) clearInterval(monsterUpdateTimer);
// First, try to load existing monsters
await loadMonsters();
// Spawn check based on settings (interval and chance)
monsterSpawnTimer = setInterval(() => {
const chanceRoll = Math.random() * 100;
if (chanceRoll < spawnSettings.spawnChance) {
spawnMonsterNearPlayer();
}
}, spawnSettings.spawnInterval);
// Update monster positions and dialogue every 2 seconds
monsterUpdateTimer = setInterval(() => {
updateMonsterPositions();
}, 2000);
// Only do initial spawn if no monsters were loaded
if (monsterEntourage.length === 0) {
setTimeout(() => {
spawnMonsterNearPlayer();
}, 5000);
}
// Start passive home base regen timer (MP at home)
startHomeRegenTimer();
// Start time-based HP regen timer
startHpRegenTimer();
}
// Start passive home base regeneration timer (runs even when standing still)
function startHomeRegenTimer() {
if (homeRegenTimer) clearInterval(homeRegenTimer);
// Check for home base regen every 3 seconds, independent of GPS updates
homeRegenTimer = setInterval(() => {
checkHomeBaseRegen();
}, HOME_REGEN_INTERVAL);
}
// Auto-save stats periodically (every 30 seconds)
let autoSaveTimer = null;
function startAutoSave() {
if (autoSaveTimer) clearInterval(autoSaveTimer);
autoSaveTimer = setInterval(() => {
// Only flush if there are pending changes (dirty flag set)
if (playerStats && statsSyncState.dirty) {
flushStatsSync();
}
}, 30000); // Every 30 seconds
}
// Save stats when page is about to close
window.addEventListener('beforeunload', () => {
// Only save if we've loaded from server to prevent overwriting good data
if (playerStats && statsLoadedFromServer) {
// Flush sync engine first (saves to localStorage)
flushStatsSync();
// Use sendBeacon for reliable save on page close
const token = localStorage.getItem('accessToken');
if (token) {
navigator.sendBeacon('/api/user/rpg-stats-beacon', new Blob([JSON.stringify({
token: token,
stats: playerStats
})], { type: 'application/json' }));
}
}
});
// Also save on pagehide (more reliable on mobile)
window.addEventListener('pagehide', () => {
if (playerStats && statsLoadedFromServer) {
// Flush sync engine first (saves to localStorage)
flushStatsSync();
const token = localStorage.getItem('accessToken');
if (token) {
navigator.sendBeacon('/api/user/rpg-stats-beacon', new Blob([JSON.stringify({
token: token,
stats: playerStats
})], { type: 'application/json' }));
}
}
});
// Refresh stats from server when page becomes visible (switching tabs/apps)
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible' && currentUser && statsLoadedFromServer) {
console.log('[SYNC] Page visible - refreshing stats from server...');
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
const response = await fetch('/api/user/rpg-stats', {
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-store' // Force fresh fetch
});
if (response.ok) {
const serverStats = await response.json();
if (serverStats && serverStats.name) {
// Check if server has newer data
const serverVersion = serverStats.dataVersion || 0;
const localVersion = playerStats?.dataVersion || 0;
if (serverVersion > localVersion) {
console.log(`[SYNC] Server has newer data (v${serverVersion} > v${localVersion}), updating...`);
playerStats = serverStats;
localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats));
updateRpgHud();
updateHomeBaseMarker();
// Update fog of war
playerRevealRadius = serverStats.revealRadius || 800;
updateFogOfWar();
updateGeocacheVisibility();
updateStatus('Stats synced from server', 'info');
} else {
console.log(`[SYNC] Local data is current (v${localVersion})`);
}
}
}
} catch (e) {
console.error('[SYNC] Failed to refresh stats:', e);
}
}
});
// Note: startAutoSave() is now called in initializePlayerStats() after server data loads
// This prevents auto-save from running before we have valid server data
// Stop monster spawning
function stopMonsterSpawning() {
if (monsterSpawnTimer) {
clearInterval(monsterSpawnTimer);
monsterSpawnTimer = null;
}
if (monsterUpdateTimer) {
clearInterval(monsterUpdateTimer);
monsterUpdateTimer = null;
}
if (homeRegenTimer) {
clearInterval(homeRegenTimer);
homeRegenTimer = null;
}
stopHpRegenTimer();
}
// Spawn a monster near the player
function spawnMonsterNearPlayer() {
if (!userLocation || !playerStats) return;
if (playerStats.isDead) return; // Don't spawn when dead
if (combatState) return; // Don't spawn during combat
if (monsterEntourage.length >= getMaxMonsters()) return;
if (!monsterTypesLoaded || Object.keys(MONSTER_TYPES).length === 0) return;
// Wait for geocaches and OSM tags to load before spawning location-restricted monsters
if (geocaches.length === 0 || !osmTagsLoaded) return;
// Don't spawn monsters at home base
const distanceToHome = getDistanceToHome();
if (distanceToHome !== null && distanceToHome <= spawnSettings.homeBaseRadius) return;
// Movement-based spawning: first monster can spawn standing still,
// but subsequent monsters require player to move the configured distance
if (monsterEntourage.length > 0 && lastSpawnLocation) {
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180);
const dx = (userLocation.lng - lastSpawnLocation.lng) * metersPerDegLng;
const dy = (userLocation.lat - lastSpawnLocation.lat) * metersPerDegLat;
const distanceMoved = Math.sqrt(dx * dx + dy * dy);
if (distanceMoved < spawnSettings.spawnDistance) {
// Player hasn't moved far enough yet, skip spawn
return;
}
}
// Pick a random monster type that the player can encounter at their level
// Only include monsters whose minLevel <= player level AND spawn location matches
const playerLevel = playerStats.level;
const playerPos = L.latLng(userLocation.lat, userLocation.lng);
// Random offset 30-60 meters from player (calculate FIRST so we can check spawn position)
const angle = Math.random() * 2 * Math.PI;
const distance = 30 + Math.random() * 30; // 30-60 meters
// Convert meters to degrees (rough approximation)
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180);
const offsetLat = (distance * Math.cos(angle)) / metersPerDegLat;
const offsetLng = (distance * Math.sin(angle)) / metersPerDegLng;
// Calculate spawn position BEFORE filtering eligible types
const spawnLat = userLocation.lat + offsetLat;
const spawnLng = userLocation.lng + offsetLng;
const spawnPos = L.latLng(spawnLat, spawnLng);
// Helper: check if SPAWN POSITION is near a geocache with a specific tag
// Uses the tag's configured spawn_radius from OSM_TAGS
function isSpawnNearTaggedLocation(tag) {
// Get the spawn radius from OSM_TAGS config, default to 400m
const tagConfig = OSM_TAGS[tag];
const maxDist = tagConfig?.spawnRadius || 400;
for (const cache of geocaches) {
if (cache.tags && cache.tags.includes(tag)) {
const dist = spawnPos.distanceTo(L.latLng(cache.lat, cache.lng));
if (dist <= maxDist) return true;
}
}
return false;
}
const eligibleTypes = Object.entries(MONSTER_TYPES).filter(([id, type]) => {
const minLevel = type.minLevel || 1;
if (minLevel > playerLevel) return false;
// Check spawn location restriction
const spawnLoc = type.spawnLocation || 'anywhere';
if (spawnLoc === 'anywhere') return true;
// Monster has a location restriction - check if SPAWN POSITION is near matching geocache
return isSpawnNearTaggedLocation(spawnLoc);
});
if (eligibleTypes.length === 0) {
console.log('No eligible monster types for player level', playerLevel, 'at this location');
return;
}
const [typeId, monsterType] = eligibleTypes[Math.floor(Math.random() * eligibleTypes.length)];
// Calculate monster level:
// - Base is player level with slight variation (-1 to +1)
// - Must be at least the monster's minLevel
// - NEVER exceeds player level (monsters can't be higher level than player)
const minLevel = monsterType.minLevel || 1;
const maxLevel = monsterType.maxLevel || 99;
const baseLevel = Math.max(1, playerLevel + Math.floor(Math.random() * 3) - 1);
// Clamp: at least minLevel, at most the lesser of maxLevel or playerLevel
const monsterLevel = Math.max(minLevel, Math.min(baseLevel, Math.min(maxLevel, playerLevel)));
const monster = {
id: `monster_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: typeId,
level: monsterLevel,
position: {
lat: spawnLat,
lng: spawnLng
},
spawnTime: Date.now(),
hp: monsterType.baseHp + (monsterLevel - 1) * monsterType.levelScale.hp,
maxHp: monsterType.baseHp + (monsterLevel - 1) * monsterType.levelScale.hp,
mp: (monsterType.baseMp || 20) + (monsterLevel - 1) * (monsterType.levelScale.mp || 5),
maxMp: (monsterType.baseMp || 20) + (monsterLevel - 1) * (monsterType.levelScale.mp || 5),
atk: monsterType.baseAtk + (monsterLevel - 1) * monsterType.levelScale.atk,
def: monsterType.baseDef + (monsterLevel - 1) * monsterType.levelScale.def,
marker: null,
lastDialogueTime: 0,
namePrefix: '', // Will be set below based on location
prefixes: [] // Array of prefix strings for font sizing
};
// Check if spawning near a special location and set name prefix
// Only apply prefix to "anywhere" monsters - location-specific monsters don't need it
const monsterSpawnLoc = monsterType.spawnLocation || 'anywhere';
if (monsterSpawnLoc === 'anywhere' && osmTagsLoaded) {
const spawnPos = L.latLng(monster.position.lat, monster.position.lng);
// Collect all eligible prefixes from nearby tagged locations
const eligiblePrefixes = [];
for (const cache of geocaches) {
if (!cache.tags || cache.tags.length === 0) continue;
for (const tagId of cache.tags) {
const tagConfig = OSM_TAGS[tagId];
if (!tagConfig || !tagConfig.enabled) continue;
if (!tagConfig.prefixes || tagConfig.prefixes.length === 0) continue;
const dist = spawnPos.distanceTo(L.latLng(cache.lat, cache.lng));
if (dist <= tagConfig.spawnRadius) {
// Add all prefixes from this tag (avoiding duplicates)
tagConfig.prefixes.forEach(prefix => {
if (!eligiblePrefixes.find(p => p.prefix === prefix)) {
eligiblePrefixes.push({ prefix, tagId });
}
});
}
}
}
if (eligiblePrefixes.length > 0) {
// Roll for prefix chance
if (Math.random() * 100 < OSM_TAG_SETTINGS.basePrefixChance) {
// Pick first prefix
const firstPick = eligiblePrefixes[Math.floor(Math.random() * eligiblePrefixes.length)];
monster.prefixes.push(firstPick.prefix);
// Check for double prefix (need at least 2 different prefixes available)
if (eligiblePrefixes.length >= 2 && Math.random() * 100 < OSM_TAG_SETTINGS.doublePrefixChance) {
const remainingPrefixes = eligiblePrefixes.filter(p => p.prefix !== firstPick.prefix);
if (remainingPrefixes.length > 0) {
const secondPick = remainingPrefixes[Math.floor(Math.random() * remainingPrefixes.length)];
monster.prefixes.push(secondPick.prefix);
}
}
// Randomize order and build display string
monster.prefixes.sort(() => Math.random() - 0.5);
monster.namePrefix = monster.prefixes.map(p => p + ' ').join('');
}
}
}
createMonsterMarker(monster);
monsterEntourage.push(monster);
updateRpgHud();
saveMonsters(); // Persist to server
// Play spawn sound effect
playSfx('monster_spawn');
// Update last spawn location for movement-based spawning
lastSpawnLocation = { lat: userLocation.lat, lng: userLocation.lng };
console.log('Spawned monster:', monster.id, 'at level', monsterLevel);
}
// Create a Leaflet marker for a monster
function createMonsterMarker(monster) {
const monsterType = MONSTER_TYPES[monster.type];
// Get idle animation for this monster type (party monsters only)
const idleAnim = monsterType?.idleAnimation || 'idle';
const iconHtml = `
<div class="monster-marker" data-monster-id="${monster.id}">
<img class="monster-icon anim-${idleAnim}" src="/mapgameimgs/monsters/${monster.type}50.png"
onerror="this.src='/mapgameimgs/monsters/default50.png'" alt="${monsterType.name}">
<div class="monster-dialogue-bubble" style="display: none;"></div>
</div>
`;
const divIcon = L.divIcon({
html: iconHtml,
className: 'monster-marker-container',
iconSize: [70, 70],
iconAnchor: [35, 35]
});
monster.marker = L.marker([monster.position.lat, monster.position.lng], {
icon: divIcon,
zIndexOffset: 2000 // High z-index so monsters are on top and easy to tap
}).addTo(map);
// Click to initiate combat
monster.marker.on('click', () => {
initiateCombat(monster);
});
}
// Update monster positions (follow player) and dialogue
function updateMonsterPositions() {
if (!userLocation || monsterEntourage.length === 0) return;
monsterEntourage.forEach(monster => {
// Calculate distance to player
const dx = userLocation.lng - monster.position.lng;
const dy = userLocation.lat - monster.position.lat;
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180);
const distanceMeters = Math.sqrt(
Math.pow(dy * metersPerDegLat, 2) +
Math.pow(dx * metersPerDegLng, 2)
);
// Follow if more than 40 meters away
if (distanceMeters > 40) {
const followSpeed = 0.00003; // degrees per update
const angle = Math.atan2(dy, dx);
monster.position.lat += Math.sin(angle) * followSpeed;
monster.position.lng += Math.cos(angle) * followSpeed;
monster.marker.setLatLng([monster.position.lat, monster.position.lng]);
}
// Update dialogue
updateMonsterDialogue(monster);
});
}
// Update monster dialogue based on time
function updateMonsterDialogue(monster) {
const now = Date.now();
const minutesSinceSpawn = (now - monster.spawnTime) / 60000;
// Only show dialogue every 15-30 seconds
if (now - monster.lastDialogueTime < 15000 + Math.random() * 15000) return;
// Determine phase
let phase = 'annoyed';
for (const p of DIALOGUE_PHASES) {
if (minutesSinceSpawn < p.maxMinutes) {
phase = p.phase;
break;
}
}
const dialogueSet = MONSTER_DIALOGUES[monster.type];
if (!dialogueSet || !dialogueSet[phase]) return;
const messages = dialogueSet[phase];
const message = messages[Math.floor(Math.random() * messages.length)];
showMonsterDialogue(monster, message);
monster.lastDialogueTime = now;
}
// Show dialogue bubble on monster
function showMonsterDialogue(monster, message) {
const markerEl = document.querySelector(`[data-monster-id="${monster.id}"]`);
if (!markerEl) return;
const bubble = markerEl.querySelector('.monster-dialogue-bubble');
if (!bubble) return;
bubble.textContent = message;
bubble.style.display = 'block';
// Hide after 4 seconds
setTimeout(() => {
bubble.style.display = 'none';
}, 4000);
}
// Remove a monster from the entourage
function removeMonster(monsterId) {
const idx = monsterEntourage.findIndex(m => m.id === monsterId);
if (idx !== -1) {
const monster = monsterEntourage[idx];
if (monster.marker) {
monster.marker.remove();
}
monsterEntourage.splice(idx, 1);
updateRpgHud();
saveMonsters(); // Persist removal to server
}
}
// ==========================================
// COMBAT SYSTEM
// ==========================================
// Initiate combat with a monster
async function initiateCombat(clickedMonster) {
if (combatState) return; // Already in combat
if (!playerStats) return;
if (monsterEntourage.length === 0) return;
if (playerStats.isDead) return; // Can't fight when dead
// Store current camera state for later restoration
const preCombatCamera = {
center: map.getCenter(),
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing()
};
// Animate camera to zoom in on monster (drone-style)
if (clickedMonster.position) {
const monsterPos = [clickedMonster.position.lng, clickedMonster.position.lat];
// Scale up the monster marker during zoom animation
const monsterMarkerEl = document.querySelector(`.monster-marker[data-monster-id="${clickedMonster.id}"]`);
if (monsterMarkerEl) {
monsterMarkerEl.classList.add('combat-zoom-in');
}
// Fly to monster with dramatic zoom and pitch
map.flyTo({
center: monsterPos,
zoom: 21, // Very close zoom
pitch: 60, // Tilt camera down like a drone
bearing: map.getBearing(), // Keep current rotation
duration: 1200, // 1.2 second animation
easing: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2 // Ease in-out
});
// Wait for animation to complete before showing combat UI
await new Promise(resolve => setTimeout(resolve, 1200));
}
// Mark sync engine as in combat (suppresses non-critical save errors)
statsSyncState.inCombat = true;
// Load skills for each unique monster type
const uniqueTypes = [...new Set(monsterEntourage.map(m => m.type))];
await Promise.all(uniqueTypes.map(type => loadMonsterSkills(type)));
// Gather ALL monsters from entourage for multi-monster combat
const monstersInCombat = monsterEntourage.map(m => {
const monsterType = MONSTER_TYPES[m.type];
// Calculate MP if not set (for monsters spawned before MP was added)
const baseMp = monsterType?.baseMp || 20;
const mpScale = monsterType?.levelScale?.mp || 5;
const calculatedMp = baseMp + (m.level - 1) * mpScale;
return {
id: m.id,
type: m.type,
level: m.level,
hp: m.hp,
maxHp: m.maxHp,
mp: m.mp ?? calculatedMp,
maxMp: m.maxMp ?? calculatedMp,
atk: m.atk,
def: m.def,
accuracy: monsterType?.accuracy || 85,
dodge: monsterType?.dodge || 5,
data: monsterType,
namePrefix: m.namePrefix || '', // Location-based name prefix
prefixes: m.prefixes || [] // Array of prefix strings for font sizing
};
});
// Find the clicked monster's index to make it the initial target
const clickedIndex = monstersInCombat.findIndex(m => m.id === clickedMonster.id);
combatState = {
player: {
hp: playerStats.hp,
maxHp: playerStats.maxHp,
mp: playerStats.mp,
maxMp: playerStats.maxMp,
atk: playerStats.atk,
def: playerStats.def,
accuracy: playerStats.accuracy || 90,
dodge: playerStats.dodge || 10
},
monsters: monstersInCombat,
selectedTargetIndex: clickedIndex >= 0 ? clickedIndex : 0,
turn: 'player',
currentMonsterTurn: 0,
log: [],
playerStatusEffects: [], // [{type, damage, turnsLeft}]
defenseBuffTurns: 0, // Turns remaining for defense buff
// Multi-hit targeting state
targetingMode: false, // True when selecting targets for multi-hit skill
pendingSkill: null, // The skill waiting to be executed
pendingTargets: [], // Array of target indices for each hit
totalHitsToSelect: 0, // Total number of hits to select targets for
preCombatCamera: preCombatCamera // Store camera state for restoration after combat
};
showCombatUI();
}
// Show the combat UI
function showCombatUI() {
const overlay = document.getElementById('combatOverlay');
overlay.style.display = 'flex';
// Play battle music
playMusic('battle');
// Render monster list
renderMonsterList();
// Clear and repopulate combat log
const log = document.getElementById('combatLog');
const monsterCount = combatState.monsters.length;
log.innerHTML = `<div class="combat-log-entry">Combat begins! ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'} engaged!</div>`;
// Populate skills (only show ACTIVE skills, not all unlocked)
const skillsContainer = document.getElementById('combatSkills');
skillsContainer.innerHTML = '';
// Use activeSkills for combat (one skill per tier)
// Fall back to unlockedSkills for migration, then to basic_attack only
const activeSkills = playerStats.activeSkills || playerStats.unlockedSkills || ['basic_attack'];
activeSkills.forEach(skillId => {
// Check both hardcoded SKILLS and database SKILLS_DB
const hardcodedSkill = SKILLS[skillId];
const dbSkill = SKILLS_DB[skillId];
const skillInfo = getSkillForClass(skillId, playerStats.class);
if (!hardcodedSkill && !dbSkill) return; // Skip if skill doesn't exist
// Skip utility skills (like second_wind) - they're not combat skills
const baseSkill = dbSkill || hardcodedSkill;
if (baseSkill.type === 'utility') return;
const displayName = skillInfo?.displayName || hardcodedSkill?.name || dbSkill?.name || skillId;
const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 32);
const mpCost = hardcodedSkill?.mpCost || dbSkill?.mpCost || 0;
// Calculate skill effect for display
const skill = hardcodedSkill || dbSkill;
let effectText = '';
if (skill.type === 'damage') {
let dmg = 0;
if (skill.calculate) {
dmg = skill.calculate(playerStats.atk);
} else if (skill.basePower) {
dmg = Math.floor(playerStats.atk * (skill.basePower / 100));
}
const hits = skill.hits || skill.hitCount || 1;
effectText = dmg > 0 ? (hits > 1 ? `${hits}×${dmg}` : `${dmg} dmg`) : '';
} else if (skill.type === 'heal') {
let heal = 0;
if (skill.calculate) {
heal = skill.calculate(playerStats.maxHp);
} else if (skill.basePower) {
heal = Math.floor(playerStats.maxHp * (skill.basePower / 100));
}
effectText = heal > 0 ? `+${heal} HP` : '';
} else if (skill.type === 'restore') {
let restore = 0;
if (skill.calculate) {
restore = skill.calculate(playerStats.maxMp);
} else if (skill.basePower) {
restore = Math.floor(playerStats.maxMp * (skill.basePower / 100));
}
effectText = restore > 0 ? `+${restore} MP` : '';
} else if (skill.type === 'buff') {
effectText = skill.effect === 'dodge' ? 'Dodge' : (skill.effect || 'Buff');
} else if (skill.type === 'status') {
effectText = skill.effect || 'Status';
}
const mpText = mpCost > 0 ? `${mpCost} MP` : 'Free';
const costLine = effectText ? `${mpText}${effectText}` : mpText;
const btn = document.createElement('button');
btn.className = 'skill-btn';
btn.dataset.skillId = skillId;
btn.innerHTML = `
<span class="skill-icon-wrapper">${iconHtml}</span>
<span class="skill-info">
<span class="skill-name">${displayName}</span>
<span class="skill-cost ${mpCost === 0 ? 'free' : ''}">${costLine}</span>
</span>
`;
btn.onclick = () => executePlayerSkill(skillId);
skillsContainer.appendChild(btn);
});
// Add admin-only Banish All skill for admins
if (currentUser && currentUser.is_admin) {
const adminSkill = SKILLS['admin_banish'];
const btn = document.createElement('button');
btn.className = 'skill-btn';
btn.dataset.skillId = 'admin_banish';
btn.style.borderColor = '#ff6b35';
btn.innerHTML = `
<span class="skill-icon-wrapper" style="font-size: 28px;">${adminSkill.icon}</span>
<span class="skill-info">
<span class="skill-name">${adminSkill.name}</span>
<span class="skill-cost free">Admin</span>
</span>
`;
btn.onclick = () => executePlayerSkill('admin_banish');
skillsContainer.appendChild(btn);
}
// Set up flee button
document.getElementById('combatFleeBtn').onclick = fleeCombat;
updateCombatUI();
}
// Generate status overlay HTML for a monster based on its buffs/debuffs
function getMonsterStatusOverlayHtml(monster) {
let html = '';
if (!monster.buffs) return html;
// Check for buffs (defense, generic, etc.)
if (monster.buffs.defense && monster.buffs.defense.turnsLeft > 0) {
html += `<img class="buff-icon" src="/mapgameimgs/effects/defense100.png"
onerror="this.src='/mapgameimgs/effects/default_buff100.png'"
alt="Defense Up" title="Defense +${monster.buffs.defense.percent}% (${monster.buffs.defense.turnsLeft} turns)">`;
} else if (monster.buffs.generic && monster.buffs.generic.turnsLeft > 0) {
html += `<img class="buff-icon" src="/mapgameimgs/effects/default_buff100.png"
alt="Buff" title="Buffed (${monster.buffs.generic.turnsLeft} turns)">`;
}
// Check for status effects (poison, etc.) - monsters could have these in future
if (monster.statusEffects) {
monster.statusEffects.forEach(effect => {
const effectType = effect.type || 'status';
html += `<img class="status-icon" src="/mapgameimgs/effects/${effectType}100.png"
onerror="this.src='/mapgameimgs/effects/default_status100.png'"
alt="${effectType}" title="${effectType} (${effect.turnsLeft} turns)">`;
});
}
return html;
}
// Generate status overlay HTML for the player based on buffs/debuffs
function getPlayerStatusOverlayHtml() {
let html = '';
if (!combatState) return html;
// Check for player defense buff
if (combatState.defenseBuffTurns > 0) {
html += `<img class="buff-icon" src="/mapgameimgs/effects/defense100.png"
onerror="this.src='/mapgameimgs/effects/default_buff100.png'"
alt="Defense Up" title="Defense Up (${combatState.defenseBuffTurns} turns)">`;
}
// Check for status effects (poison, etc.)
if (combatState.playerStatusEffects && combatState.playerStatusEffects.length > 0) {
combatState.playerStatusEffects.forEach(effect => {
const effectType = effect.type || 'status';
html += `<img class="status-icon" src="/mapgameimgs/effects/${effectType}100.png"
onerror="this.src='/mapgameimgs/effects/default_status100.png'"
alt="${effectType}" title="${effectType} (${effect.turnsLeft} turns)">`;
});
}
return html;
}
// Update the player's status overlay in combat UI
function updatePlayerStatusOverlay() {
const overlay = document.getElementById('playerStatusOverlay');
if (overlay) {
overlay.innerHTML = getPlayerStatusOverlayHtml();
}
}
// Scroll to the attacking monster and trigger animation
// animationType can be: 'attack', 'skill', 'miss', 'death', or a custom animation ID
function animateMonsterAttack(monsterIndex, animationType = 'attack') {
try {
const container = document.getElementById('monsterList');
if (!container) return;
const entries = container.querySelectorAll('.monster-entry');
const entry = entries[monsterIndex];
if (!entry) return;
// Scroll the monster into view smoothly
entry.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
// Get the monster's animation override if available
const monster = combatState.monsters[monsterIndex];
let actualAnimation = animationType;
if (monster && MONSTER_TYPES[monster.type]) {
const monsterType = MONSTER_TYPES[monster.type];
// Check for animation override based on type
if (animationType === 'attack' && monsterType.attackAnimation) {
actualAnimation = monsterType.attackAnimation;
} else if (animationType === 'death' && monsterType.deathAnimation) {
actualAnimation = monsterType.deathAnimation;
}
}
// Wait for scroll to complete (~300ms) plus 300ms pause, then play animation
// Re-query DOM inside timeout since updateCombatUI may have re-rendered the list
setTimeout(() => {
const currentContainer = document.getElementById('monsterList');
if (!currentContainer) return;
const currentEntries = currentContainer.querySelectorAll('.monster-entry');
const currentEntry = currentEntries[monsterIndex];
if (!currentEntry) return;
const icon = currentEntry.querySelector('.monster-entry-icon');
if (icon) {
// Track this animation so it survives re-renders
const anim = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS[actualAnimation] : null;
if (anim) {
monsterActiveAnimations[monsterIndex] = {
animId: actualAnimation,
startTime: Date.now(),
duration: anim.duration
};
// Clear tracking after animation completes
setTimeout(() => {
delete monsterActiveAnimations[monsterIndex];
}, anim.duration + 100);
}
playMonsterAnimation(icon, actualAnimation);
}
}, 600);
} catch (e) {
console.error('Animation error:', e);
}
}
// Render the monster list in combat UI
function renderMonsterList() {
const container = document.getElementById('monsterList');
container.innerHTML = '';
combatState.monsters.forEach((monster, index) => {
const entry = document.createElement('div');
entry.className = 'monster-entry';
entry.dataset.index = index;
if (index === combatState.selectedTargetIndex) {
entry.classList.add('selected');
}
if (monster.hp <= 0) {
entry.classList.add('dead');
}
// Add visual feedback during targeting mode for living monsters
if (combatState.targetingMode && monster.hp > 0) {
entry.classList.add('targeting-selectable');
}
// Highlight the currently attacking monster
if (combatState.turn === 'monster' && index === combatState.currentMonsterTurn && monster.hp > 0) {
entry.classList.add('attacking');
}
const hpPct = Math.max(0, (monster.hp / monster.maxHp) * 100);
const mpPct = Math.max(0, (monster.mp / monster.maxMp) * 100);
// Generate status overlay HTML for monster
const monsterOverlayHtml = getMonsterStatusOverlayHtml(monster);
// Determine animation style - check for active animations or death state
let animStyle = '';
if (monster.hp <= 0) {
// Dead monster - apply death animation
const monsterType = monster.data;
const deathAnimId = monsterType?.deathAnimation || 'death';
const deathAnim = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS[deathAnimId] : null;
if (deathAnim) {
const fillStr = deathAnim.fillMode ? ` ${deathAnim.fillMode}` : ' forwards';
const easing = deathAnim.easing || 'ease-out';
animStyle = `animation: monster_${deathAnimId} ${deathAnim.duration}ms ${easing}${fillStr};`;
}
} else if (monsterActiveAnimations[index]) {
// Living monster with active animation - preserve it
const activeAnim = monsterActiveAnimations[index];
const elapsed = Date.now() - activeAnim.startTime;
if (elapsed < activeAnim.duration) {
const anim = MONSTER_ANIMATIONS[activeAnim.animId];
if (anim) {
const remaining = activeAnim.duration - elapsed;
const easing = anim.easing || 'ease-out';
const loopStr = anim.loop ? ' infinite' : '';
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
// Use remaining time to continue animation from current point
animStyle = `animation: monster_${activeAnim.animId} ${anim.duration}ms ${easing}${loopStr}${fillStr}; animation-delay: -${elapsed}ms;`;
}
}
}
entry.innerHTML = `
<div class="monster-entry-header">
${index === combatState.selectedTargetIndex ? '<span class="target-arrow">▶</span>' : ''}
<div class="sprite-container">
<img class="monster-entry-icon" src="/mapgameimgs/monsters/${monster.type}100.png"
style="${animStyle}"
onerror="this.src='/mapgameimgs/monsters/default100.png'" alt="${monster.namePrefix || ''}${monster.data.name}">
<div class="status-overlay">${monsterOverlayHtml}</div>
</div>
<span class="monster-entry-name${monster.prefixes?.length >= 2 ? ' prefix-2' : (monster.prefixes?.length === 1 || monster.namePrefix ? ' prefix-1' : '')}">${monster.namePrefix || ''}${monster.data.name} Lv.${monster.level}</span>
</div>
<div class="monster-entry-hp">
<div class="hp-bar"><div class="hp-fill" style="width: ${hpPct}%;"></div></div>
<div class="stat-text">HP: ${Math.max(0, monster.hp)}/${monster.maxHp}</div>
</div>
<div class="monster-entry-mp">
<div class="mp-bar"><div class="mp-fill" style="width: ${mpPct}%;"></div></div>
<div class="stat-text">MP: ${Math.max(0, monster.mp)}/${monster.maxMp}</div>
</div>
`;
// Click to select target (only if alive and player's turn)
entry.onclick = () => selectTarget(index);
container.appendChild(entry);
});
}
// Select a monster as target
function selectTarget(index) {
if (!combatState || combatState.turn !== 'player') return;
if (combatState.monsters[index].hp <= 0) return; // Can't target dead monsters
// If in targeting mode for multi-hit skill, add to pending targets
if (combatState.targetingMode) {
combatState.pendingTargets.push(index);
const hitNum = combatState.pendingTargets.length;
const targetName = combatState.monsters[index].data.name;
addCombatLog(`Hit ${hitNum}: targeting ${targetName}`, 'info');
// Check if all hits have been assigned
if (combatState.pendingTargets.length >= combatState.totalHitsToSelect) {
// Execute the skill with the selected targets
executeMultiHitSkill();
} else {
// Update UI to show next hit selection
updateCombatUI();
renderMonsterList();
}
return;
}
// Normal target selection (not in targeting mode)
combatState.selectedTargetIndex = index;
renderMonsterList();
addCombatLog(`Targeting ${combatState.monsters[index].namePrefix || ''}${combatState.monsters[index].data.name}!`);
}
// Cancel multi-hit targeting mode
function cancelTargeting() {
if (!combatState || !combatState.targetingMode) return;
// Refund MP since skill wasn't executed
if (combatState.pendingSkill) {
combatState.player.mp += combatState.pendingSkill.mpCost || 0;
}
combatState.targetingMode = false;
combatState.pendingSkill = null;
combatState.pendingTargets = [];
combatState.totalHitsToSelect = 0;
addCombatLog('Targeting cancelled.', 'info');
updateCombatUI();
renderMonsterList();
}
// Execute multi-hit skill with selected targets
function executeMultiHitSkill() {
if (!combatState || !combatState.targetingMode || !combatState.pendingSkill) return;
const skill = combatState.pendingSkill;
const displayName = skill.displayName || skill.name;
const targetIndices = combatState.pendingTargets;
const rawSkill = skill.rawSkill;
// Exit targeting mode
combatState.targetingMode = false;
// Calculate base damage
let rawDamage;
if (rawSkill && rawSkill.calculate) {
rawDamage = rawSkill.calculate(combatState.player.atk);
} else if (rawSkill && rawSkill.basePower) {
rawDamage = Math.floor(combatState.player.atk * (rawSkill.basePower / 100));
} else {
rawDamage = combatState.player.atk;
}
const skillAccuracy = rawSkill ? (rawSkill.accuracy || 95) : 95;
let grandTotalDamage = 0;
let hitsLanded = 0;
let monstersKilled = 0;
const hitResults = []; // Track results for each hit
// Process each hit with its selected target
for (let hitNum = 0; hitNum < targetIndices.length; hitNum++) {
const targetIndex = targetIndices[hitNum];
const currentTarget = combatState.monsters[targetIndex];
// Skip if target is already dead (from previous hit)
if (currentTarget.hp <= 0) {
hitResults.push({ hit: hitNum + 1, target: currentTarget.data.name, result: 'already defeated' });
continue;
}
// Calculate hit chance
const hitChance = calculateHitChance(
combatState.player.accuracy,
currentTarget.dodge,
skillAccuracy
);
// Roll for hit
if (!rollHit(hitChance)) {
hitResults.push({ hit: hitNum + 1, target: currentTarget.data.name, result: 'miss', hitChance });
continue;
}
// Calculate effective defense
let effectiveMonsterDef = currentTarget.def;
if (currentTarget.buffs && currentTarget.buffs.defense && currentTarget.buffs.defense.turnsLeft > 0) {
const buffPercent = currentTarget.buffs.defense.percent || 50;
effectiveMonsterDef = Math.floor(currentTarget.def * (1 + buffPercent / 100));
}
// Calculate and apply damage
const baseDamage = calculateDamage(rawDamage, effectiveMonsterDef);
const damage = applyDamageVariance(baseDamage);
currentTarget.hp -= damage;
grandTotalDamage += damage;
hitsLanded++;
hitResults.push({ hit: hitNum + 1, target: currentTarget.data.name, result: 'hit', damage });
// Check if this killed the monster
if (currentTarget.hp <= 0) {
monstersKilled++;
recordMonsterKill(currentTarget); // Track kill for bestiary
// Play death animation
animateMonsterAttack(targetIndex, 'death');
playSfx('monster_death');
// Award XP immediately
const xpReward = (currentTarget.data?.xpReward || 10) * currentTarget.level;
playerStats.xp += xpReward;
combatState.player.xpGained = (combatState.player.xpGained || 0) + xpReward;
hitResults[hitResults.length - 1].killed = true;
hitResults[hitResults.length - 1].xp = xpReward;
removeMonster(currentTarget.id);
checkLevelUp();
savePlayerStats();
updateRpgHud();
}
}
// Log results for each hit
hitResults.forEach(r => {
if (r.result === 'miss') {
addCombatLog(`❌ Hit ${r.hit}: Missed ${r.target}! (${r.hitChance}% chance)`, 'miss');
playSfx('missed');
} else if (r.result === 'already defeated') {
addCombatLog(`💨 Hit ${r.hit}: ${r.target} already defeated!`, 'info');
} else if (r.result === 'hit') {
if (r.killed) {
addCombatLog(`⚔️ Hit ${r.hit}: ${r.damage} damage to ${r.target}!`, 'damage');
addCombatLog(`💀 ${r.target} defeated! +${r.xp} XP`, 'victory');
} else {
addCombatLog(`⚔️ Hit ${r.hit}: ${r.damage} damage to ${r.target}!`, 'damage');
}
playSfx('player_attack');
}
});
// Summary
if (hitsLanded > 0) {
addCombatLog(`${displayName} complete: ${hitsLanded} hits, ${grandTotalDamage} total damage!`, 'damage');
} else {
addCombatLog(`${displayName}: All hits missed!`, 'miss');
}
// Clear pending state
combatState.pendingSkill = null;
combatState.pendingTargets = [];
combatState.totalHitsToSelect = 0;
// Auto-retarget if current target died
const currentTarget = combatState.monsters[combatState.selectedTargetIndex];
if (currentTarget && currentTarget.hp <= 0) {
autoRetarget();
}
updateCombatUI();
// Check if ALL monsters are defeated
const livingMonsters = combatState.monsters.filter(m => m.hp > 0);
if (livingMonsters.length === 0) {
handleCombatVictory();
return;
}
endPlayerTurn();
}
// Update combat UI bars and text
function updateCombatUI() {
if (!combatState) return;
// Update turn indicator
const turnIndicator = document.getElementById('turnIndicator');
if (combatState.targetingMode) {
// Show targeting mode indicator
const currentHit = combatState.pendingTargets.length + 1;
const totalHits = combatState.totalHitsToSelect;
const skillName = combatState.pendingSkill?.displayName || 'Attack';
turnIndicator.className = 'turn-indicator targeting-mode';
turnIndicator.innerHTML = `🎯 ${skillName}: Select target for hit ${currentHit}/${totalHits} <button onclick="cancelTargeting()" style="margin-left: 10px; padding: 2px 8px; font-size: 12px; cursor: pointer;">Cancel</button>`;
} else if (combatState.turn === 'player') {
turnIndicator.className = 'turn-indicator player-turn';
turnIndicator.textContent = '⚡ Your Turn';
} else {
const attackingMonster = combatState.monsters[combatState.currentMonsterTurn];
if (attackingMonster) {
turnIndicator.className = 'turn-indicator monster-turn';
turnIndicator.textContent = `🔥 ${attackingMonster.data.name}'s Turn`;
}
}
// Update player HP/MP bars
const playerHpPct = (combatState.player.hp / combatState.player.maxHp) * 100;
const playerMpPct = (combatState.player.mp / combatState.player.maxMp) * 100;
document.getElementById('playerHpBar').style.width = Math.max(0, playerHpPct) + '%';
document.getElementById('playerMpBar').style.width = Math.max(0, playerMpPct) + '%';
document.getElementById('playerHpText').textContent =
`${Math.max(0, combatState.player.hp)}/${combatState.player.maxHp}`;
document.getElementById('playerMpText').textContent =
`${Math.max(0, combatState.player.mp)}/${combatState.player.maxMp}`;
// Re-render monster list to update HP bars and status overlays
renderMonsterList();
// Update player status overlays
updatePlayerStatusOverlay();
// Update skill button states
document.querySelectorAll('.skill-btn').forEach(btn => {
const skillId = btn.dataset.skillId;
const skill = SKILLS_DB[skillId] || SKILLS[skillId];
if (!skill) {
btn.disabled = true;
return;
}
// Disable all skills during targeting mode or monster turns
btn.disabled = combatState.targetingMode || combatState.player.mp < (skill.mpCost || 0) || combatState.turn !== 'player';
});
// Disable flee button during monster turns or targeting mode
const fleeBtn = document.getElementById('combatFleeBtn');
if (fleeBtn) {
fleeBtn.disabled = combatState.targetingMode || combatState.turn !== 'player';
}
}
// Add entry to combat log
function addCombatLog(message, type = '') {
const log = document.getElementById('combatLog');
const entry = document.createElement('div');
entry.className = 'combat-log-entry';
if (type) entry.classList.add(`combat-log-${type}`);
entry.textContent = message;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
// Process status effects at start of player turn
function processPlayerStatusEffects() {
if (!combatState || combatState.playerStatusEffects.length === 0) return;
let totalDamage = 0;
const effectsToRemove = [];
combatState.playerStatusEffects.forEach((effect, i) => {
if (effect.type === 'poison') {
totalDamage += effect.damage;
addCombatLog(`☠️ Poison deals ${effect.damage} damage!`, 'damage');
}
effect.turnsLeft--;
if (effect.turnsLeft <= 0) {
effectsToRemove.push(i);
addCombatLog(`💨 ${effect.type.charAt(0).toUpperCase() + effect.type.slice(1)} wore off!`);
}
});
// Remove expired effects (in reverse order to preserve indices)
effectsToRemove.reverse().forEach(i => {
combatState.playerStatusEffects.splice(i, 1);
});
if (totalDamage > 0) {
combatState.player.hp -= totalDamage;
updateCombatUI();
// Check for defeat from status damage
if (combatState.player.hp <= 0) {
handleCombatDefeat();
return false;
}
}
// Decrement defense buff
if (combatState.defenseBuffTurns > 0) {
combatState.defenseBuffTurns--;
if (combatState.defenseBuffTurns === 0) {
addCombatLog(`🛡️ Defense buff expired!`);
}
}
return true;
}
// Execute a player skill
function executePlayerSkill(skillId) {
console.log('[DEBUG] executePlayerSkill called with:', skillId);
if (!combatState || combatState.turn !== 'player') {
console.log('[DEBUG] Early return - combatState:', !!combatState, 'turn:', combatState?.turn);
return;
}
// Get skill from DB first, then fall back to hardcoded SKILLS
const dbSkill = SKILLS_DB[skillId];
const hardcodedSkill = SKILLS[skillId];
const skill = dbSkill || hardcodedSkill;
console.log('[DEBUG] Skill found:', skill?.name, 'type:', skill?.type);
if (!skill) {
addCombatLog(`Unknown skill: ${skillId}`);
return;
}
// Get class-specific display name early so it's available for all messages
const skillDisplay = getSkillForClass(skillId, playerStats.class) || skill;
const displayName = skillDisplay.displayName || skill.name;
const levelReq = skill.levelReq || 1;
if (playerStats.level < levelReq) {
addCombatLog(`You need to be level ${levelReq} to use ${displayName}!`);
return;
}
if (combatState.player.mp < skill.mpCost) {
addCombatLog("Not enough MP!");
return;
}
// Check for multi-hit targeting mode
const hitCount = skill.hitCount || skill.hits || 1;
const livingMonsters = combatState.monsters.filter(m => m.hp > 0);
const skillTarget = dbSkill ? dbSkill.target : (skill.target || 'enemy');
const targetingMode = dbSkill ? (dbSkill.targeting_mode || 'same_target') : 'same_target';
// For multi-hit single-target damage skills with per_hit targeting and multiple living enemies
if (skill.type === 'damage' && hitCount > 1 && skillTarget !== 'all_enemies' && livingMonsters.length > 1 && targetingMode === 'per_hit') {
// Deduct MP now (will be refunded if cancelled)
combatState.player.mp -= skill.mpCost;
// Enter targeting mode
combatState.targetingMode = true;
combatState.pendingSkill = { ...skill, displayName, rawSkill: dbSkill || hardcodedSkill };
combatState.pendingTargets = [];
combatState.totalHitsToSelect = hitCount;
addCombatLog(`🎯 ${displayName}: Select target for hit 1/${hitCount}`, 'info');
updateCombatUI();
renderMonsterList();
return;
}
// For multi-hit skills with random targeting
if (skill.type === 'damage' && hitCount > 1 && skillTarget !== 'all_enemies' && livingMonsters.length > 1 && targetingMode === 'random') {
// Auto-generate random targets
combatState.player.mp -= skill.mpCost;
const randomTargets = [];
for (let i = 0; i < hitCount; i++) {
const aliveMonsters = combatState.monsters.filter(m => m.hp > 0);
if (aliveMonsters.length > 0) {
const randomIndex = combatState.monsters.indexOf(aliveMonsters[Math.floor(Math.random() * aliveMonsters.length)]);
randomTargets.push(randomIndex);
}
}
// Execute with random targets
combatState.targetingMode = true;
combatState.pendingSkill = { ...skill, displayName, rawSkill: dbSkill || hardcodedSkill };
combatState.pendingTargets = randomTargets;
combatState.totalHitsToSelect = hitCount;
executeMultiHitSkill();
return;
}
// Deduct MP for non-multi-hit skills
combatState.player.mp -= skill.mpCost;
// Get the targeted monster
const target = combatState.monsters[combatState.selectedTargetIndex];
if (skill.type === 'admin_clear') {
// Admin-only: instantly defeat all monsters (no XP)
if (!currentUser || !currentUser.is_admin) {
addCombatLog('This skill requires admin privileges!');
return;
}
const monsterCount = combatState.monsters.length;
combatState.monsters.forEach(m => m.hp = 0);
addCombatLog(`⚡ Admin Banish! All ${monsterCount} enemies vanished!`, 'victory');
updateCombatUI();
// End combat immediately (no XP awarded)
setTimeout(() => {
const monsterIds = combatState.monsters.map(m => m.id);
monsterIds.forEach(id => removeMonster(id));
playerStats.hp = combatState.player.hp;
playerStats.mp = combatState.player.mp;
savePlayerStats();
updateRpgHud();
closeCombatUI();
}, 1000);
return;
} else if (skill.type === 'damage') {
// Determine targets based on skill.target (reuse variables from above)
const targets = skillTarget === 'all_enemies' ? livingMonsters : [target];
// Calculate base damage - support both old calculate() and new basePower
let rawDamage;
if (hardcodedSkill && hardcodedSkill.calculate) {
rawDamage = hardcodedSkill.calculate(combatState.player.atk);
} else if (dbSkill) {
rawDamage = Math.floor(combatState.player.atk * (dbSkill.basePower / 100));
} else {
rawDamage = combatState.player.atk;
}
const hitCount = skill.hitCount || skill.hits || 1;
const skillAccuracy = dbSkill ? dbSkill.accuracy : 95;
let grandTotalDamage = 0;
let monstersHit = 0;
let monstersKilled = 0;
// Apply damage to each target
for (const currentTarget of targets) {
// Calculate hit chance for this target
const hitChance = calculateHitChance(
combatState.player.accuracy,
currentTarget.dodge,
skillAccuracy
);
// Roll for hit
if (!rollHit(hitChance)) {
if (targets.length === 1) {
addCombatLog(`${displayName} missed ${currentTarget.data.name}! (${hitChance}% chance)`, 'miss');
playSfx('missed');
}
continue; // Miss this target, continue to next
}
monstersHit++;
let totalDamage = 0;
// Calculate effective defense (with buff if active)
let effectiveMonsterDef = currentTarget.def;
if (currentTarget.buffs && currentTarget.buffs.defense && currentTarget.buffs.defense.turnsLeft > 0) {
const buffPercent = currentTarget.buffs.defense.percent || 50;
effectiveMonsterDef = Math.floor(currentTarget.def * (1 + buffPercent / 100));
}
for (let hit = 0; hit < hitCount; hit++) {
const baseDamage = calculateDamage(rawDamage, effectiveMonsterDef);
const damage = applyDamageVariance(baseDamage);
totalDamage += damage;
currentTarget.hp -= damage;
}
grandTotalDamage += totalDamage;
// Log for single-target skills
if (targets.length === 1) {
if (hitCount > 1) {
addCombatLog(`${displayName} hits ${currentTarget.data.name} ${hitCount} times for ${totalDamage} total damage!`, 'damage');
playSfx('player_skill');
} else {
addCombatLog(`⚔️ ${displayName} hits ${currentTarget.data.name} for ${totalDamage} damage!`, 'damage');
playSfx('player_attack');
}
}
// Check if this monster died
if (currentTarget.hp <= 0) {
monstersKilled++;
recordMonsterKill(currentTarget); // Track kill for bestiary
playSfx('monster_death');
// Award XP immediately for this kill
const xpReward = (currentTarget.data?.xpReward || 10) * currentTarget.level;
playerStats.xp += xpReward;
combatState.player.xpGained = (combatState.player.xpGained || 0) + xpReward;
if (targets.length === 1) {
addCombatLog(`💀 ${currentTarget.data.name} was defeated! +${xpReward} XP`, 'victory');
}
// Remove this monster from entourage immediately
removeMonster(currentTarget.id);
// Check for level up
checkLevelUp();
savePlayerStats();
updateRpgHud();
}
}
// Log for multi-target skills
if (targets.length > 1) {
if (monstersHit === 0) {
addCombatLog(`${displayName} missed all enemies!`, 'miss');
playSfx('missed');
} else {
addCombatLog(`🌟 ${displayName} hits ${monstersHit} enemies for ${grandTotalDamage} total damage!`, 'damage');
playSfx('player_skill');
if (monstersKilled > 0) {
const totalXpGained = combatState.player.xpGained || 0;
addCombatLog(`💀 ${monstersKilled} enemy${monstersKilled > 1 ? 'ies' : ''} defeated! +${totalXpGained} XP`, 'victory');
}
}
}
// Auto-retarget if current target died
if (target.hp <= 0) {
autoRetarget();
}
} else if (skill.type === 'heal') {
let healAmount;
if (hardcodedSkill && hardcodedSkill.calculate) {
healAmount = hardcodedSkill.calculate(combatState.player.maxHp);
} else if (dbSkill) {
healAmount = Math.floor(combatState.player.maxHp * (dbSkill.basePower / 100));
} else {
healAmount = 30;
}
combatState.player.hp = Math.min(combatState.player.maxHp, combatState.player.hp + healAmount);
addCombatLog(`💚 ${displayName}! Healed ${healAmount} HP!`, 'heal');
} else if (skill.type === 'buff') {
// Handle defense buff
if (dbSkill && dbSkill.statusEffect && dbSkill.statusEffect.type === 'defense_up') {
combatState.defenseBuffTurns = dbSkill.statusEffect.duration || 2;
const buffPercent = dbSkill.statusEffect.percent || 50;
addCombatLog(`🛡️ ${displayName}! DEF +${buffPercent}% for ${combatState.defenseBuffTurns} turns!`, 'buff');
} else if (hardcodedSkill && hardcodedSkill.effect === 'dodge') {
// Legacy dodge buff
addCombatLog(`${displayName}! Next attack will be dodged!`);
}
}
updateCombatUI();
// Check if ALL monsters are defeated (recalculate to account for kills)
const remainingMonsters = combatState.monsters.filter(m => m.hp > 0);
console.log('[DEBUG] Victory check - remainingMonsters:', remainingMonsters.length, 'monsters:', combatState.monsters.map(m => ({type: m.type, hp: m.hp})));
if (remainingMonsters.length === 0) {
console.log('[DEBUG] All monsters defeated, calling handleCombatVictory');
handleCombatVictory();
return;
}
endPlayerTurn();
}
// End player turn and start monster turns
function endPlayerTurn() {
console.log('[DEBUG] endPlayerTurn called');
combatState.turn = 'monsters';
combatState.currentMonsterTurn = 0;
updateCombatUI();
console.log('[DEBUG] Scheduling executeMonsterTurns in 800ms');
setTimeout(executeMonsterTurns, 800);
}
// Auto-retarget to next living monster
function autoRetarget() {
const livingIndices = [];
combatState.monsters.forEach((m, i) => {
if (m.hp > 0) livingIndices.push(i);
});
if (livingIndices.length > 0) {
// Find next living monster (prefer one after current target)
let newTarget = livingIndices.find(i => i > combatState.selectedTargetIndex);
if (newTarget === undefined) {
newTarget = livingIndices[0]; // Wrap to first living monster
}
combatState.selectedTargetIndex = newTarget;
}
}
// Execute all monster turns sequentially
function executeMonsterTurns() {
console.log('[DEBUG] executeMonsterTurns called, combatState:', !!combatState);
if (!combatState) return;
console.log('[DEBUG] currentMonsterTurn:', combatState.currentMonsterTurn, 'total:', combatState.monsters.length);
// Find next living monster starting from currentMonsterTurn
while (combatState.currentMonsterTurn < combatState.monsters.length) {
const monster = combatState.monsters[combatState.currentMonsterTurn];
if (monster.hp > 0) {
// This monster is alive, execute its attack
executeOneMonsterAttack(combatState.currentMonsterTurn);
return; // Will continue in executeOneMonsterAttack
}
combatState.currentMonsterTurn++;
}
// All monsters have attacked, return to player turn
combatState.turn = 'player';
updateCombatUI();
// Process status effects at start of player turn
setTimeout(() => {
if (combatState && combatState.playerStatusEffects.length > 0) {
const survived = processPlayerStatusEffects();
if (!survived) return; // Player died from status effects
}
}, 200);
}
// Execute one monster's attack
function executeOneMonsterAttack(monsterIndex) {
console.log('[DEBUG] executeOneMonsterAttack called, monsterIndex:', monsterIndex);
if (!combatState) return;
const monster = combatState.monsters[monsterIndex];
console.log('[DEBUG] Monster:', monster?.type, 'HP:', monster?.hp);
combatState.currentMonsterTurn = monsterIndex;
updateCombatUI();
// Decrement monster buff durations at start of its turn
if (monster.buffs) {
if (monster.buffs.defense && monster.buffs.defense.turnsLeft > 0) {
monster.buffs.defense.turnsLeft--;
if (monster.buffs.defense.turnsLeft <= 0) {
addCombatLog(`${monster.namePrefix || ''}${monster.data.name}'s defense buff wore off.`, 'info');
}
}
}
// Select a skill using weighted random (or basic attack if none)
// Pass monster's current MP to filter out skills they can't afford
const selectedSkill = selectMonsterSkill(monster.type, monster.level, monster.mp || 0);
console.log('[DEBUG] Selected skill:', selectedSkill?.id, selectedSkill?.name, 'type:', selectedSkill?.type, 'mpCost:', selectedSkill?.mpCost);
// Determine animation to use - check for skill-specific animation first
const skillAnimation = selectedSkill?.animation || (selectedSkill?.id === 'basic_attack' ? 'attack' : 'skill');
// Deduct MP cost from monster
const skillMpCost = selectedSkill?.mpCost || 0;
if (skillMpCost > 0 && monster.mp !== undefined) {
monster.mp = Math.max(0, monster.mp - skillMpCost);
console.log('[DEBUG] Monster MP after skill:', monster.mp, '/', monster.maxMp);
// Update UI immediately to show MP decrease
updateCombatUI();
}
// Calculate hit chance
const skillAccuracy = selectedSkill.accuracy || 85;
const hitChance = calculateHitChance(
monster.accuracy,
combatState.player.dodge,
skillAccuracy
);
// Roll for hit
if (!rollHit(hitChance)) {
// Play miss animation (attack followed by stumble)
setTimeout(() => animateMonsterAttack(monsterIndex, 'miss'), 50);
addCombatLog(`${monster.namePrefix || ''}${monster.data.name}'s ${selectedSkill.name} missed! (${hitChance}% chance)`, 'miss');
setTimeout(() => playSfx('missed'), 650); // Sync with animation timing
combatState.currentMonsterTurn++;
setTimeout(executeMonsterTurns, 1500); // Longer delay to allow miss animation
return;
}
// Scroll to and animate the attacking monster with skill animation
setTimeout(() => animateMonsterAttack(monsterIndex, skillAnimation), 50);
// Calculate effective defense (with buff if active)
let effectiveDef = combatState.player.def;
if (combatState.defenseBuffTurns > 0) {
effectiveDef = Math.floor(effectiveDef * 1.5);
}
// Handle different skill types
if (selectedSkill.type === 'buff') {
// Buff skill (like defend) - monster buffs itself
if (!monster.buffs) monster.buffs = {};
if (selectedSkill.statusEffect && selectedSkill.statusEffect.type === 'defense_up') {
const duration = selectedSkill.statusEffect.duration || 2;
const percent = selectedSkill.statusEffect.percent || 50;
monster.buffs.defense = { turnsLeft: duration, percent: percent };
addCombatLog(`🛡️ ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! Defense increased by ${percent}%!`, 'buff');
} else {
// Generic buff
monster.buffs.generic = { turnsLeft: 2 };
addCombatLog(`${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}!`, 'buff');
}
} else if (selectedSkill.type === 'heal') {
// Heal skill - monster heals itself
const healAmount = Math.floor(selectedSkill.basePower || 50);
const oldHp = monster.hp;
monster.hp = Math.min(monster.maxHp, monster.hp + healAmount);
const actualHeal = monster.hp - oldHp;
addCombatLog(`💚 ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! Restored ${actualHeal} HP!`, 'heal');
} else if (selectedSkill.type === 'status') {
// Status effect skill (like poison)
const baseDamage = selectedSkill.basePower || 20;
const damage = Math.max(1, Math.floor(monster.atk * (baseDamage / 100)) - effectiveDef);
combatState.player.hp -= damage;
// Apply status effect
if (selectedSkill.statusEffect) {
const effect = selectedSkill.statusEffect;
// Check if already poisoned
const existing = combatState.playerStatusEffects.find(e => e.type === effect.type);
if (!existing) {
// Scale status effect damage with monster ATK: baseDamage × (1 + ATK/20)
const baseDmg = effect.damage || 5;
const scaledDamage = Math.ceil(baseDmg * (1 + monster.atk / 20));
combatState.playerStatusEffects.push({
type: effect.type,
damage: scaledDamage,
turnsLeft: effect.duration || 3
});
addCombatLog(`🔥 ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! ${damage} damage + ${effect.type} (${scaledDamage}/turn) applied!`, 'damage');
setTimeout(() => playSfx('monster_skill'), 650); // Sync with animation
} else {
addCombatLog(`🔥 ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! ${damage} damage! (Already ${effect.type}ed)`, 'damage');
setTimeout(() => playSfx('monster_skill'), 650); // Sync with animation
}
}
} else {
// Regular damage skill or basic attack
const basePower = selectedSkill.basePower || 100;
const rawDamage = Math.floor(monster.atk * (basePower / 100));
const hitCount = selectedSkill.hitCount || 1;
let totalDamage = 0;
// Apply monster's defense buff reduction to player's damage? No - this is monster attacking player
// But we should factor in monster's attack buff if it has one
for (let hit = 0; hit < hitCount; hit++) {
const baseDamage = calculateDamage(rawDamage, effectiveDef);
const damage = applyDamageVariance(baseDamage);
totalDamage += damage;
combatState.player.hp -= damage;
}
// Only show generic "attacks" if it's basic_attack with no custom name
const isGenericAttack = (selectedSkill.id === 'basic_attack' && selectedSkill.name === 'Attack');
if (isGenericAttack) {
addCombatLog(`⚔️ ${monster.namePrefix || ''}${monster.data.name} attacks! You take ${totalDamage} damage!`, 'damage');
setTimeout(() => playSfx('monster_attack'), 650); // Sync with animation
} else if (hitCount > 1) {
addCombatLog(`🔥 ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! ${hitCount} hits for ${totalDamage} total damage!`, 'damage');
setTimeout(() => playSfx('monster_skill'), 650); // Sync with animation
} else {
addCombatLog(`🔥 ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! You take ${totalDamage} damage!`, 'damage');
setTimeout(() => playSfx('monster_skill'), 650); // Sync with animation
}
}
updateCombatUI();
// Check for defeat
if (combatState.player.hp <= 0) {
handleCombatDefeat();
return;
}
// Move to next monster
// Wait for animation to complete: 650ms delay + up to 1000ms animation
combatState.currentMonsterTurn++;
setTimeout(executeMonsterTurns, 1700);
}
// Handle combat victory
function handleCombatVictory() {
console.log('[DEBUG] handleCombatVictory called');
// Play victory music
playMusic('victory');
try {
// XP was already awarded per-kill, just show total
const totalXp = combatState.player.xpGained || 0;
const monsterCount = combatState.monsters.length;
addCombatLog(`Victory! Defeated ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'}!`, 'victory');
// Monsters already removed per-kill, just update player HP/MP
playerStats.hp = combatState.player.hp;
playerStats.mp = combatState.player.mp;
// Check for level up
checkLevelUp();
savePlayerStats();
updateRpgHud();
} catch (err) {
console.error('[DEBUG] Error in handleCombatVictory:', err);
}
// Always close combat UI, even if there were errors above
console.log('[DEBUG] Scheduling closeCombatUI in 2500ms');
setTimeout(closeCombatUI, 2500);
}
// Handle combat defeat
function handleCombatDefeat() {
const monsterCount = combatState.monsters.filter(m => m.hp > 0).length;
// If player has a home base, trigger death system
if (playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) {
addCombatLog(`💀 You have been slain! Return to your home base to respawn.`, 'damage');
playerStats.mp = combatState.player.mp;
// Close combat first, then handle death
setTimeout(() => {
closeCombatUI();
handlePlayerDeath();
}, 1500);
} else {
// No home base - use old behavior (restore HP to 50%)
addCombatLog(`You were defeated! ${monsterCount} ${monsterCount === 1 ? 'enemy remains' : 'enemies remain'}. HP restored to 50%.`, 'damage');
playerStats.hp = Math.floor(playerStats.maxHp * 0.5);
playerStats.mp = combatState.player.mp;
savePlayerStats();
updateRpgHud();
setTimeout(closeCombatUI, 2500);
}
}
// Flee from combat
function fleeCombat() {
addCombatLog("You fled from battle!");
// Save current HP/MP
playerStats.hp = combatState.player.hp;
playerStats.mp = combatState.player.mp;
savePlayerStats();
updateRpgHud();
setTimeout(closeCombatUI, 1000);
}
// Close combat UI
function closeCombatUI() {
console.log('[DEBUG] closeCombatUI called');
document.getElementById('combatOverlay').style.display = 'none';
// Restore camera to pre-combat position with smooth animation
if (combatState && combatState.preCombatCamera) {
const cam = combatState.preCombatCamera;
map.flyTo({
center: cam.center,
zoom: cam.zoom,
pitch: cam.pitch,
bearing: cam.bearing,
duration: 800, // Slightly faster return
easing: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
});
}
combatState = null;
monsterActiveAnimations = {}; // Clear animation tracking
// Combat ended - clear sync engine combat flag and flush any pending saves
statsSyncState.inCombat = false;
flushStatsSync();
// Update fog of war to reflect current position after combat
updateFogOfWar();
// If victory music isn't playing, switch to appropriate ambient music
if (gameMusic.currentTrack !== 'victory' || gameMusic.victory.paused) {
const distToHome = getDistanceToHome();
if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) {
playMusic('homebase');
} else {
playMusic('overworld');
}
}
}
// Check for level up
function checkLevelUp() {
const xpNeeded = playerStats.level * 100;
if (playerStats.xp >= xpNeeded) {
const newLevel = playerStats.level + 1;
playerStats.level = newLevel;
playerStats.xp -= xpNeeded;
const classData = PLAYER_CLASSES[playerStats.class] || {
hpPerLevel: 10, mpPerLevel: 5, atkPerLevel: 2, defPerLevel: 1
};
playerStats.maxHp += classData.hpPerLevel || 10;
playerStats.hp = playerStats.maxHp; // Full heal on level up
playerStats.maxMp += classData.mpPerLevel || 5;
playerStats.mp = playerStats.maxMp; // Full MP restore
playerStats.atk += classData.atkPerLevel || 2;
playerStats.def += classData.defPerLevel || 1;
addCombatLog(`LEVEL UP! Now level ${newLevel}!`, 'victory');
// Check for skill choice at this level
const pool = SKILL_POOLS[playerStats.class];
if (pool && pool[newLevel]) {
// Delay showing modal to let combat UI update first
setTimeout(() => showSkillChoice(newLevel), 500);
}
// Check for another level up (in case of huge XP gain)
checkLevelUp();
}
}
// ==========================================
// END RPG COMBAT SYSTEM FUNCTIONS
// ==========================================
// Load monster types, skills, classes, and spawn settings from database, then initialize auth
Promise.all([loadMonsterTypes(), loadSkillsFromDatabase(), loadClasses(), loadSpawnSettings(), loadOsmTags()]).then(() => {
loadCurrentUser();
});
// Initialize music and sound effects systems
initMusic();
initSfx();
// Start appropriate music on first user interaction (required due to autoplay restrictions)
let musicStarted = false;
function startMusicOnInteraction() {
if (!musicStarted && playerStats && !gameMusic.muted) {
musicStarted = true;
// Play appropriate music based on game state
if (playerStats.isDead) {
playMusic('death');
} else {
const distToHome = getDistanceToHome();
if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) {
playMusic('homebase');
} else {
playMusic('overworld');
}
}
}
}
// Try to autostart game music (works if user has interacted before)
function tryAutostartGameMusic() {
if (musicStarted || !playerStats || gameMusic.muted) return;
let track = 'overworld';
if (playerStats.isDead) {
track = 'death';
} else {
const distToHome = getDistanceToHome();
if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) {
track = 'homebase';
}
}
const audio = gameMusic[track];
audio.volume = gameMusic.volume;
audio.loop = true;
audio.play().then(() => {
musicStarted = true;
gameMusic.current = audio;
gameMusic.currentTrack = track;
console.log('Game music autostarted:', track);
}).catch(() => {
console.log('Game music autoplay blocked - waiting for interaction');
});
}
// Try autostart after a short delay (to allow playerStats to load)
setTimeout(tryAutostartGameMusic, 1500);
document.addEventListener('click', startMusicOnInteraction, { once: false });
document.addEventListener('touchstart', startMusicOnInteraction, { once: false });
// Handle login screen visibility on page load
if (localStorage.getItem('accessToken') || sessionStorage.getItem('guestMode')) {
// Already logged in or guest mode - hide login screen, show game
hideLoginScreen();
}
// Otherwise, login screen is visible by default (no need to show it)
</script>
</body>
</html>