Files

736 lines
36 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PoGo Mover</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
:root {
--red: #e3350d;
--yellow: #ffcb05;
--blue: #3d7dca;
--dark: #1a1a2e;
--darker: #0f0f1a;
--panel: #16213e;
--border: #0f3460;
--text: #e8f4f8;
--muted: #8899aa;
--green: #4caf50;
--orange: #ff9800;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--darker); color: var(--text); font-family: 'Nunito', sans-serif; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
header { background: var(--red); padding: 0 20px; height: 56px; display: flex; align-items: center; justify-content: space-between; border-bottom: 4px solid #8b0000; flex-shrink: 0; position: relative; z-index: 1000; }
.logo { font-family: 'Press Start 2P', monospace; font-size: 14px; color: var(--yellow); text-shadow: 2px 2px 0 #8b6000, -1px -1px 0 #8b6000; letter-spacing: 1px; }
.logo span { color: white; }
.status-pill { display: flex; align-items: center; gap: 8px; background: rgba(0,0,0,0.3); padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: 700; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #555; }
.status-dot.connected { background: var(--green); animation: pulse 1.5s infinite; }
.status-dot.error { background: var(--red); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.main { display: flex; flex: 1; overflow: hidden; }
#map { flex: 1; cursor: crosshair; }
.sidebar { width: 280px; background: var(--panel); border-left: 3px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; flex-shrink: 0; }
.section { padding: 16px; border-bottom: 2px solid var(--border); }
.section-title { font-family: 'Press Start 2P', monospace; font-size: 8px; color: var(--yellow); margin-bottom: 12px; letter-spacing: 1px; }
.dpad-container { display: flex; justify-content: center; margin: 8px 0; }
.dpad { display: grid; grid-template-columns: 52px 52px 52px; grid-template-rows: 52px 52px 52px; gap: 4px; }
.dpad-btn { background: var(--dark); border: 2px solid var(--border); border-radius: 8px; color: var(--text); font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.1s; user-select: none; -webkit-user-select: none; }
.dpad-btn:hover { background: var(--border); border-color: var(--blue); }
.dpad-btn:active, .dpad-btn.pressed { background: var(--blue); border-color: var(--yellow); transform: scale(0.93); box-shadow: 0 0 10px rgba(61,125,202,0.5); }
.dpad-center { background: var(--darker); border-radius: 50%; border: 2px solid var(--border); font-size: 10px; font-family: 'Press Start 2P', monospace; color: var(--muted); display: flex; align-items: center; justify-content: center; cursor: default; }
.key-hint { text-align: center; font-size: 10px; color: var(--muted); margin-top: 6px; }
.key-hint kbd { background: var(--dark); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-family: monospace; font-size: 10px; color: var(--yellow); }
.speed-label { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-weight: 700; }
.speed-badge { background: var(--blue); padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 800; }
.speed-presets { display: flex; gap: 6px; margin-bottom: 10px; }
.preset-btn { flex: 1; background: var(--dark); border: 2px solid var(--border); border-radius: 8px; color: var(--text); font-family: 'Nunito', sans-serif; font-size: 11px; font-weight: 700; padding: 6px 4px; cursor: pointer; transition: all 0.15s; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.preset-btn:hover, .preset-btn.active { border-color: var(--yellow); background: var(--border); }
.preset-btn.active { background: var(--blue); }
.preset-icon { font-size: 14px; }
input[type=range] { width: 100%; accent-color: var(--yellow); cursor: pointer; }
.coords-display { background: var(--darker); border: 2px solid var(--border); border-radius: 8px; padding: 10px 12px; font-family: monospace; font-size: 12px; line-height: 1.8; }
.coords-display .label { color: var(--muted); font-size: 10px; }
.coords-display .value { color: var(--yellow); font-weight: bold; }
.click-tip { background: rgba(61,125,202,0.15); border: 1px solid rgba(61,125,202,0.4); border-radius: 8px; padding: 10px; font-size: 11px; color: var(--muted); line-height: 1.6; margin-top: 8px; }
.click-tip strong { color: var(--text); }
.server-warning { background: rgba(227,53,13,0.15); border: 2px solid rgba(227,53,13,0.5); border-radius: 8px; padding: 12px; font-size: 11px; line-height: 1.7; margin: 16px; }
.server-warning strong { color: var(--yellow); display: block; margin-bottom: 4px; font-size: 10px; font-family: 'Press Start 2P', monospace; }
.move-queue-indicator { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-size: 11px; color: var(--muted); }
.moving-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); display: none; }
.moving-dot.active { display: block; animation: pulse 0.5s infinite; }
.character-marker { width: 36px; height: 36px; background: var(--blue); border: 3px solid var(--yellow); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 18px; box-shadow: 0 0 0 3px rgba(61,125,202,0.3), 0 4px 12px rgba(0,0,0,0.5); animation: float 2s ease-in-out infinite; }
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
.destination-marker { width: 20px; height: 20px; background: var(--yellow); border: 2px solid var(--red); border-radius: 50%; box-shadow: 0 0 8px rgba(255,203,5,0.6); }
.teleport-btn { width: 100%; margin-top: 10px; padding: 10px; background: linear-gradient(135deg, #1a0030, #2d0050); border: 2px solid #7b2fff; border-radius: 10px; color: #d4aaff; font-family: 'Press Start 2P', monospace; font-size: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: all 0.2s; letter-spacing: 1px; }
.teleport-btn:hover { background: linear-gradient(135deg, #2d0050, #4a00a0); border-color: #bf80ff; color: #fff; box-shadow: 0 0 16px rgba(123,47,255,0.5); }
.teleport-btn.active { background: linear-gradient(135deg, #4a00a0, #7b2fff); border-color: #ffcb05; color: #ffcb05; box-shadow: 0 0 20px rgba(123,47,255,0.7); }
.teleport-btn .t-icon { font-size: 16px; }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 9999; align-items: center; justify-content: center; backdrop-filter: blur(4px); }
.modal-overlay.visible { display: flex; }
.modal { background: var(--panel); border: 3px solid #7b2fff; border-radius: 16px; padding: 28px 28px 24px; max-width: 420px; width: 90%; box-shadow: 0 0 40px rgba(123,47,255,0.4), 0 20px 60px rgba(0,0,0,0.6); animation: modalIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); }
@keyframes modalIn { from { opacity: 0; transform: scale(0.85) translateY(20px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.modal-icon { font-size: 32px; line-height: 1; }
.modal-title { font-family: 'Press Start 2P', monospace; font-size: 11px; color: #bf80ff; line-height: 1.6; }
.modal-body { font-size: 13px; line-height: 1.8; color: var(--text); margin-bottom: 20px; }
.modal-body .warning-box { background: rgba(227,53,13,0.12); border: 2px solid rgba(227,53,13,0.5); border-radius: 10px; padding: 12px 14px; margin-bottom: 14px; font-weight: 700; color: #ffb3a7; }
.modal-body .warning-box .warn-title { font-family: 'Press Start 2P', monospace; font-size: 8px; color: var(--red); margin-bottom: 8px; display: block; }
.modal-body .cooldown-box { background: rgba(123,47,255,0.1); border: 1px solid rgba(123,47,255,0.4); border-radius: 10px; padding: 12px 14px; font-size: 12px; color: #d4aaff; }
.modal-body .cooldown-box strong { color: #ffcb05; }
.modal-dest { font-family: monospace; font-size: 11px; color: var(--muted); background: var(--darker); border-radius: 6px; padding: 6px 10px; margin-bottom: 16px; }
.modal-actions { display: flex; gap: 10px; }
.modal-btn { flex: 1; padding: 12px; border-radius: 10px; border: 2px solid transparent; font-family: 'Nunito', sans-serif; font-size: 14px; font-weight: 800; cursor: pointer; transition: all 0.15s; }
.modal-btn-cancel { background: var(--dark); border-color: var(--border); color: var(--muted); }
.modal-btn-cancel:hover { border-color: var(--muted); color: var(--text); }
.modal-btn-confirm { background: linear-gradient(135deg, #4a00a0, #7b2fff); border-color: #bf80ff; color: white; }
.modal-btn-confirm:hover { background: linear-gradient(135deg, #6200d0, #9b4fff); box-shadow: 0 0 16px rgba(123,47,255,0.5); }
.cooldown-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 9998; align-items: center; justify-content: center; flex-direction: column; gap: 20px; backdrop-filter: blur(6px); }
.cooldown-overlay.visible { display: flex; }
.cooldown-title { font-family: 'Press Start 2P', monospace; font-size: 13px; color: #bf80ff; text-align: center; line-height: 1.8; }
.cooldown-ring { position: relative; width: 140px; height: 140px; }
.cooldown-ring svg { transform: rotate(-90deg); }
.cooldown-ring-track { fill: none; stroke: rgba(123,47,255,0.2); stroke-width: 8; }
.cooldown-ring-progress { fill: none; stroke: #7b2fff; stroke-width: 8; stroke-linecap: round; transition: stroke-dashoffset 1s linear; }
.cooldown-time { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; font-family: 'Press Start 2P', monospace; font-size: 22px; color: #ffcb05; line-height: 1; }
.cooldown-time .cd-unit { font-size: 8px; color: var(--muted); margin-top: 4px; }
.cooldown-msg { font-size: 13px; color: var(--muted); text-align: center; max-width: 300px; line-height: 1.7; }
.cooldown-skip { background: transparent; border: 2px solid var(--border); border-radius: 8px; color: var(--muted); font-family: 'Nunito', sans-serif; font-size: 12px; padding: 8px 20px; cursor: pointer; transition: all 0.15s; margin-top: 8px; }
.cooldown-skip:hover { border-color: var(--muted); color: var(--text); }
.locate-btn { width: 44px; height: 44px; background: var(--panel); border: 2px solid var(--border); border-radius: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.4); transition: all 0.2s; position: relative; }
.locate-btn:hover { background: var(--border); border-color: var(--blue); box-shadow: 0 0 12px rgba(61,125,202,0.4); }
.locate-btn:active { transform: scale(0.93); }
.locate-btn.locating svg { animation: locateSpin 1s linear infinite; }
.locate-btn.located svg #arrow { fill: var(--blue); filter: drop-shadow(0 0 4px rgba(61,125,202,0.8)); }
@keyframes locateSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.locate-toast { position: absolute; bottom: 60px; left: 0; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 11px; white-space: nowrap; color: var(--text); pointer-events: none; opacity: 0; transform: translateY(4px); transition: opacity 0.2s, transform 0.2s; box-shadow: 0 2px 8px rgba(0,0,0,0.4); }
.locate-toast.visible { opacity: 1; transform: translateY(0); }
.leaflet-control-layers { background: var(--panel) !important; border: 2px solid var(--border) !important; border-radius: 10px !important; box-shadow: 0 2px 10px rgba(0,0,0,0.5) !important; padding: 2px 4px !important; margin-bottom: 8px !important; }
.leaflet-control-layers-list { display: flex !important; gap: 4px !important; }
.leaflet-control-layers label { display: flex !important; align-items: center !important; gap: 5px !important; padding: 5px 8px !important; border-radius: 7px !important; cursor: pointer !important; font-family: 'Nunito', sans-serif !important; font-size: 12px !important; font-weight: 700 !important; color: var(--muted) !important; transition: all 0.15s !important; white-space: nowrap !important; }
.leaflet-control-layers label:has(input:checked) { background: var(--blue) !important; color: white !important; }
.leaflet-control-layers label:hover { color: var(--text) !important; background: var(--border) !important; }
.leaflet-control-layers input[type=radio] { display: none !important; }
.leaflet-control-layers-toggle { display: none !important; }
.leaflet-control-layers-separator { display: none !important; }
footer { background: var(--darker); border-top: 2px solid var(--border); padding: 6px 20px; font-size: 10px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
</style>
</head>
<body>
<header>
<div class="logo">PoGo<span>Mover</span></div>
<div class="status-pill">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Connecting...</span>
</div>
</header>
<div class="main">
<div id="map"></div>
<div class="sidebar">
<div class="section">
<div class="section-title">Movement</div>
<div class="dpad-container">
<div class="dpad">
<div></div>
<button class="dpad-btn" id="btn-up" data-dir="up">▲</button>
<div></div>
<button class="dpad-btn" id="btn-left" data-dir="left">◀</button>
<div class="dpad-center">✦</div>
<button class="dpad-btn" id="btn-right" data-dir="right">▶</button>
<div></div>
<button class="dpad-btn" id="btn-down" data-dir="down">▼</button>
<div></div>
</div>
</div>
<div class="key-hint"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> or arrow keys</div>
<div class="move-queue-indicator">
<div class="moving-dot" id="movingDot"></div>
<span id="movingLabel">Ready</span>
</div>
</div>
<div class="section">
<div class="section-title">Speed</div>
<div class="speed-presets">
<button class="preset-btn active" data-speed="1.4" id="preset-walk"><span class="preset-icon">🚶</span>Walk</button>
<button class="preset-btn" data-speed="4" id="preset-jog"><span class="preset-icon">🏃</span>Jog</button>
<button class="preset-btn" data-speed="11" id="preset-bike"><span class="preset-icon">🚲</span>Bike</button>
<button class="preset-btn" data-speed="28" id="preset-car"><span class="preset-icon">🚗</span>Drive</button>
</div>
<button class="teleport-btn" id="teleportToggle"><span class="t-icon">✨</span> TELEPORT MODE</button>
<div class="speed-label" id="speedLabelRow" style="margin-top:10px">
<span>Speed</span>
<div class="speed-badge" id="speedBadge">1.4 m/s</div>
</div>
<input type="range" id="speedSlider" min="0.5" max="40" step="0.5" value="1.4">
</div>
<div class="section">
<div class="section-title">Position</div>
<div class="coords-display">
<div class="label">Latitude</div>
<div class="value" id="dispLat">—</div>
<div class="label" style="margin-top:6px">Longitude</div>
<div class="value" id="dispLng">—</div>
</div>
<div class="click-tip" id="clickTip">
<strong>Click the map</strong> to add waypoints. The player moves through them in order. Click multiple times to build a route.
</div>
</div>
<div id="serverWarning" class="server-warning" style="display:none">
<strong>⚠ Server offline</strong>
Start <code>start.py</code> on your Mac and connect your iPhone via USB.
</div>
</div>
</div>
<footer>
<span id="footerMode">🎮 Click the map or use WASD to move</span>
<span id="footerCoords"></span>
</footer>
<div class="modal-overlay" id="teleportModal">
<div class="modal">
<div class="modal-header"><div class="modal-icon">✨</div><div class="modal-title">TELEPORT<br>WARNING</div></div>
<div class="modal-body">
<div class="warning-box"><span class="warn-title">⚠ IMPORTANT — READ BEFORE TELEPORTING</span>Teleporting instantly moves your character across large distances. Pokémon GO can detect this and issue a <strong>soft ban</strong>.</div>
<div class="cooldown-box"><strong>Before you teleport:</strong><br>1. Force-close Pokémon GO on your iPhone<br>2. Confirm the teleport here<br>3. Wait the recommended cooldown time<br>4. Then reopen Pokémon GO<br><br><strong>Recommended wait time:</strong> ~2 hours for long distances, 30 min for short hops</div>
</div>
<div class="modal-dest" id="modalDestCoords"></div>
<div class="modal-actions">
<button class="modal-btn modal-btn-cancel" id="teleportCancel">Cancel</button>
<button class="modal-btn modal-btn-confirm" id="teleportConfirm">✨ Teleport &amp; Start Timer</button>
</div>
</div>
</div>
<div class="cooldown-overlay" id="cooldownOverlay">
<div class="cooldown-title">COOLDOWN TIMER<br><span style="color:var(--muted);font-size:9px">Safe to open Pokémon GO when timer ends</span></div>
<div class="cooldown-ring">
<svg width="140" height="140" viewBox="0 0 140 140">
<circle class="cooldown-ring-track" cx="70" cy="70" r="58"/>
<circle class="cooldown-ring-progress" id="cooldownArc" cx="70" cy="70" r="58" stroke-dasharray="364.4" stroke-dashoffset="0"/>
</svg>
<div class="cooldown-time"><span id="cooldownDisplay">—</span><span class="cd-unit">remaining</span></div>
</div>
<div class="cooldown-msg">Keep Pokémon GO <strong>closed</strong> until this timer reaches zero to avoid a soft ban.</div>
<button class="cooldown-skip" id="cooldownSkip">Skip timer (not recommended)</button>
</div>
<script>
const SERVER = "http://localhost:7777";
const STEP_INTERVAL_MS = 700;
const _savedSpeed = parseFloat(localStorage.getItem("pm_speed") || "1.4");
const _savedMode = localStorage.getItem("pm_mode") || "walk";
let speedMs = _savedSpeed;
let lat = 33.4484;
let lng = -112.0740;
let serverOnline = false;
let moveInterval = null;
let clickPath = null;
let clickPathIndex = 0;
let clickAnimating = false;
let keysDown = {};
const _savedZoom = parseInt(localStorage.getItem("pm_zoom") || "16");
const _savedLayer = localStorage.getItem("pm_layer") || "street";
const map = L.map("map", { zoomControl: true }).setView([lat, lng], _savedZoom);
const streetLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
maxZoom: 19
});
const satelliteLayer = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", {
attribution: "© Esri, Maxar, Earthstar Geographics",
maxZoom: 19
});
const satelliteLabels = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", {
attribution: "",
maxZoom: 19,
opacity: 0.8
});
if (_savedLayer === "satellite") {
satelliteLayer.addTo(map);
satelliteLabels.addTo(map);
} else {
streetLayer.addTo(map);
}
L.control.layers(
{ "🗺 Street": streetLayer, "🛰 Satellite": satelliteLayer },
{},
{ position: "bottomleft", collapsed: false }
).addTo(map);
map.on("baselayerchange", e => {
if (e.name === "🛰 Satellite") {
satelliteLabels.addTo(map);
localStorage.setItem("pm_layer", "satellite");
} else {
if (map.hasLayer(satelliteLabels)) map.removeLayer(satelliteLabels);
localStorage.setItem("pm_layer", "street");
}
});
map.on("zoomend", () => { localStorage.setItem("pm_zoom", map.getZoom()); });
const modeIcons = { walk: "🚶", jog: "🏃", bike: "🚲", car: "🚗", teleport: "✨" };
let currentMode = _savedMode;
function charIconHtml(mode) { return `<div class="character-marker">${modeIcons[mode] || "🚶"}</div>`; }
const charIcon = L.divIcon({ className: "", html: charIconHtml(_savedMode), iconSize: [36, 36], iconAnchor: [18, 18] });
function updateCharIcon(mode) {
currentMode = mode;
charMarker.setIcon(L.divIcon({ className: "", html: charIconHtml(mode), iconSize: [36, 36], iconAnchor: [18, 18] }));
}
const destIcon = L.divIcon({ className: "", html: '<div class="destination-marker"></div>', iconSize: [20, 20], iconAnchor: [10, 10] });
const LocateControl = L.Control.extend({
options: { position: "bottomleft" },
onAdd() {
const container = L.DomUtil.create("div");
L.DomEvent.disableClickPropagation(container);
container.innerHTML = `<button class="locate-btn" id="locateBtn" title="Set player to my real GPS location">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="3" fill="#ffcb05"/>
<circle cx="12" cy="12" r="6" stroke="#3d7dca" stroke-width="1.5" fill="none" stroke-dasharray="3 2"/>
<polygon id="arrow" points="12,2 15.5,9 12,7.5 8.5,9" fill="#e8f4f8"/>
<line x1="12" y1="12" x2="12" y2="2.5" stroke="#e8f4f8" stroke-width="1.5" stroke-linecap="round"/>
<line x1="12" y1="22" x2="12" y2="18" stroke="#8899aa" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="12" x2="6" y2="12" stroke="#8899aa" stroke-width="1.5" stroke-linecap="round"/>
<line x1="18" y1="12" x2="22" y2="12" stroke="#8899aa" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="locate-toast" id="locateToast"></div>
</button>`;
return container;
}
});
new LocateControl().addTo(map);
const charMarker = L.marker([lat, lng], { icon: charIcon }).addTo(map);
function showLocateToast(msg, color) {
const toast = document.getElementById("locateToast");
toast.textContent = msg;
toast.style.color = color || "var(--text)";
toast.classList.add("visible");
setTimeout(() => toast.classList.remove("visible"), 2500);
}
document.addEventListener("click", e => {
if (!e.target.closest("#locateBtn")) return;
const btn = document.getElementById("locateBtn");
if (!navigator.geolocation) { showLocateToast("Geolocation not supported", "var(--red)"); return; }
btn.classList.add("locating");
btn.classList.remove("located");
showLocateToast("Finding your location…", "var(--muted)");
navigator.geolocation.getCurrentPosition(
pos => {
btn.classList.remove("locating");
btn.classList.add("located");
const realLat = pos.coords.latitude;
const realLng = pos.coords.longitude;
stopClickPath();
stopKeyLoop();
keysDown = {};
sendCoords(realLat, realLng);
map.setView([realLat, realLng], map.getZoom());
showLocateToast("📍 Moved to your location!", "var(--green)");
setTimeout(() => btn.classList.remove("located"), 3000);
},
err => {
btn.classList.remove("locating");
const msgs = { 1: "Location permission denied", 2: "Location unavailable", 3: "Request timed out" };
showLocateToast(msgs[err.code] || "Location error", "var(--red)");
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
);
});
function metersPerDegLat() { return 111320; }
function metersPerDegLng(latDeg) { return 111320 * Math.cos(latDeg * Math.PI / 180); }
function updateDisplay() {
document.getElementById("dispLat").textContent = lat.toFixed(6);
document.getElementById("dispLng").textContent = lng.toFixed(6);
document.getElementById("footerCoords").textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
charMarker.setLatLng([lat, lng]);
}
async function sendCoords(newLat, newLng) {
lat = newLat;
lng = newLng;
updateDisplay();
if (!serverOnline) return;
try {
await fetch(`${SERVER}/move`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lat, lng })
});
} catch (e) {}
}
function stepSize() { return (speedMs * STEP_INTERVAL_MS / 1000); }
const dirHandlers = {
up: () => sendCoords(lat + stepSize() / metersPerDegLat(), lng),
down: () => sendCoords(lat - stepSize() / metersPerDegLat(), lng),
left: () => sendCoords(lat, lng - stepSize() / metersPerDegLng(lat)),
right: () => sendCoords(lat, lng + stepSize() / metersPerDegLng(lat))
};
function getActiveDirections() {
const dirs = [];
if (keysDown["w"] || keysDown["arrowup"]) dirs.push("up");
if (keysDown["s"] || keysDown["arrowdown"]) dirs.push("down");
if (keysDown["a"] || keysDown["arrowleft"]) dirs.push("left");
if (keysDown["d"] || keysDown["arrowright"]) dirs.push("right");
return dirs;
}
function startKeyLoop() {
if (moveInterval) return;
stopClickPath();
moveInterval = setInterval(() => {
const dirs = getActiveDirections();
if (dirs.length === 0) { stopKeyLoop(); return; }
dirs.forEach(d => dirHandlers[d]());
setMoving(true);
}, STEP_INTERVAL_MS);
}
function stopKeyLoop() {
if (moveInterval) { clearInterval(moveInterval); moveInterval = null; }
if (Object.keys(keysDown).length === 0) setMoving(false);
}
document.addEventListener("keydown", e => {
const key = e.key.toLowerCase();
if (!["w","a","s","d","arrowup","arrowdown","arrowleft","arrowright"].includes(key)) return;
e.preventDefault();
if (keysDown[key]) return;
keysDown[key] = true;
getActiveDirections().forEach(d => { const btn = document.getElementById(`btn-${d}`); if (btn) btn.classList.add("pressed"); });
startKeyLoop();
});
document.addEventListener("keyup", e => {
const key = e.key.toLowerCase();
delete keysDown[key];
const dir = { w:"up", a:"left", s:"down", d:"right", arrowup:"up", arrowdown:"down", arrowleft:"left", arrowright:"right" }[key];
if (dir) { const btn = document.getElementById(`btn-${dir}`); if (btn) btn.classList.remove("pressed"); }
if (getActiveDirections().length === 0) stopKeyLoop();
});
document.querySelectorAll(".dpad-btn").forEach(btn => {
const dir = btn.dataset.dir;
let interval = null;
const start = () => {
stopClickPath();
dirHandlers[dir]();
setMoving(true);
interval = setInterval(() => dirHandlers[dir](), STEP_INTERVAL_MS);
};
const stop = () => { if (interval) { clearInterval(interval); interval = null; } setMoving(false); };
btn.addEventListener("mousedown", start);
btn.addEventListener("touchstart", e => { e.preventDefault(); start(); });
btn.addEventListener("mouseup", stop);
btn.addEventListener("mouseleave", stop);
btn.addEventListener("touchend", stop);
});
const waypointQueue = [];
let waypointMarkers = [];
let waypointLines = [];
function buildPath(fromLat, fromLng, toLat, toLng) {
const dLat = (toLat - fromLat) * metersPerDegLat();
const dLng = (toLng - fromLng) * metersPerDegLng(fromLat);
const totalMeters = Math.sqrt(dLat * dLat + dLng * dLng);
if (totalMeters < 1) return [];
const steps = Math.ceil(totalMeters / stepSize());
const path = [];
for (let i = 1; i <= steps; i++) {
const t = i / steps;
path.push([fromLat + (toLat - fromLat) * t, fromLng + (toLng - fromLng) * t]);
}
return path;
}
function clearWaypointVisuals() {
waypointMarkers.forEach(m => map.removeLayer(m));
waypointLines.forEach(l => map.removeLayer(l));
waypointMarkers = [];
waypointLines = [];
}
function rebuildWaypointVisuals() {
clearWaypointVisuals();
let prevLat = lat, prevLng = lng;
waypointQueue.forEach((wp, i) => {
const marker = L.marker([wp.lat, wp.lng], { icon: destIcon }).addTo(map);
const line = L.polyline([[prevLat, prevLng], [wp.lat, wp.lng]], {
color: i === 0 ? "#ffcb05" : "#aaaaff",
weight: 2,
dashArray: "6 6",
opacity: 0.7
}).addTo(map);
waypointMarkers.push(marker);
waypointLines.push(line);
prevLat = wp.lat;
prevLng = wp.lng;
});
}
function stopClickPath() {
if (clickPath) { clearInterval(clickPath); clickPath = null; }
clickAnimating = false;
waypointQueue.length = 0;
clearWaypointVisuals();
}
function processNextWaypoint() {
if (waypointQueue.length === 0) { setMoving(false); clickAnimating = false; return; }
const wp = waypointQueue[0];
const path = buildPath(lat, lng, wp.lat, wp.lng);
if (path.length === 0) { waypointQueue.shift(); rebuildWaypointVisuals(); processNextWaypoint(); return; }
let i = 0;
clickAnimating = true;
setMoving(true);
clickPath = setInterval(() => {
if (i >= path.length) {
clearInterval(clickPath);
clickPath = null;
waypointQueue.shift();
rebuildWaypointVisuals();
processNextWaypoint();
return;
}
sendCoords(path[i][0], path[i][1]);
i++;
}, STEP_INTERVAL_MS);
}
function addWaypoint(toLat, toLng) {
waypointQueue.push({ lat: toLat, lng: toLng });
rebuildWaypointVisuals();
if (!clickAnimating) processNextWaypoint();
}
map.on("click", e => {
const toLat = e.latlng.lat;
const toLng = e.latlng.lng;
if (teleportMode) { showTeleportModal(toLat, toLng); return; }
stopKeyLoop();
keysDown = {};
addWaypoint(toLat, toLng);
});
function setMoving(active) {
const dot = document.getElementById("movingDot");
const label = document.getElementById("movingLabel");
dot.classList.toggle("active", active);
if (active) {
const remaining = waypointQueue.length;
label.textContent = remaining > 1 ? `Moving… (${remaining} waypoints)` : "Moving…";
} else {
label.textContent = "Ready";
}
}
const speedSlider = document.getElementById("speedSlider");
const speedBadge = document.getElementById("speedBadge");
speedSlider.addEventListener("input", () => {
speedMs = parseFloat(speedSlider.value);
speedBadge.textContent = `${speedMs.toFixed(1)} m/s`;
document.querySelectorAll(".preset-btn").forEach(b => b.classList.remove("active"));
localStorage.setItem("pm_speed", speedMs);
if (speedMs <= 2) updateCharIcon("walk");
else if (speedMs <= 6) updateCharIcon("jog");
else if (speedMs <= 18) updateCharIcon("bike");
else updateCharIcon("car");
});
document.querySelectorAll(".preset-btn").forEach(btn => {
btn.addEventListener("click", () => {
speedMs = parseFloat(btn.dataset.speed);
speedSlider.value = speedMs;
speedBadge.textContent = `${speedMs.toFixed(1)} m/s`;
document.querySelectorAll(".preset-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
const modeMap = { "preset-walk": "walk", "preset-jog": "jog", "preset-bike": "bike", "preset-car": "car" };
const mode = modeMap[btn.id] || "walk";
localStorage.setItem("pm_speed", speedMs);
localStorage.setItem("pm_mode", mode);
updateCharIcon(mode);
});
});
function applyInitialSpeed() {
speedSlider.value = speedMs;
speedBadge.textContent = `${speedMs.toFixed(1)} m/s`;
const modeToPreset = { walk: "preset-walk", jog: "preset-jog", bike: "preset-bike", car: "preset-car" };
const presetId = modeToPreset[currentMode];
if (presetId) {
document.querySelectorAll(".preset-btn").forEach(b => b.classList.remove("active"));
const btn = document.getElementById(presetId);
if (btn) btn.classList.add("active");
}
updateCharIcon(currentMode);
}
applyInitialSpeed();
let teleportMode = false;
let pendingTeleportLat = null;
let pendingTeleportLng = null;
let cooldownTimer = null;
const CIRCUMFERENCE = 364.4;
const teleportToggle = document.getElementById("teleportToggle");
const teleportModal = document.getElementById("teleportModal");
const teleportCancel = document.getElementById("teleportCancel");
const teleportConfirm = document.getElementById("teleportConfirm");
const cooldownOverlay = document.getElementById("cooldownOverlay");
const cooldownSkip = document.getElementById("cooldownSkip");
const clickTip = document.getElementById("clickTip");
const speedLabelRow = document.getElementById("speedLabelRow");
const footerMode = document.getElementById("footerMode");
function setTeleportMode(active) {
teleportMode = active;
teleportToggle.classList.toggle("active", active);
document.getElementById("speedSlider").disabled = active;
document.querySelectorAll(".preset-btn").forEach(b => b.disabled = active);
document.querySelectorAll(".dpad-btn").forEach(b => b.disabled = active);
speedLabelRow.style.opacity = active ? "0.35" : "1";
updateCharIcon(active ? "teleport" : currentMode);
if (active) {
clickTip.innerHTML = '<strong style="color:#bf80ff">✨ TELEPORT MODE ON</strong> — Click anywhere on the map to instantly jump there. A cooldown timer will guide you on when to reopen Pokémon GO.';
clickTip.style.background = "rgba(123,47,255,0.12)";
clickTip.style.borderColor = "rgba(123,47,255,0.4)";
footerMode.textContent = "✨ Teleport Mode — click the map to jump";
} else {
clickTip.innerHTML = '<strong>Click the map</strong> to add waypoints. The player moves through them in order. Click multiple times to build a route.';
clickTip.style.background = "rgba(61,125,202,0.15)";
clickTip.style.borderColor = "rgba(61,125,202,0.4)";
footerMode.textContent = "🎮 Click the map or use WASD to move";
}
}
teleportToggle.addEventListener("click", () => setTeleportMode(!teleportMode));
function distanceKm(lat1, lng1, lat2, lng2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLng/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
function cooldownMinutes(km) {
if (km < 10) return 30;
if (km < 50) return 60;
if (km < 500) return 90;
return 120;
}
function showTeleportModal(toLat, toLng) {
pendingTeleportLat = toLat;
pendingTeleportLng = toLng;
const km = distanceKm(lat, lng, toLat, toLng);
const mins = cooldownMinutes(km);
document.getElementById("modalDestCoords").textContent =
`Destination: ${toLat.toFixed(5)}, ${toLng.toFixed(5)} • ~${km < 1 ? (km*1000).toFixed(0)+"m" : km.toFixed(1)+"km"} away • Suggested cooldown: ${mins} min`;
teleportModal.classList.add("visible");
}
teleportCancel.addEventListener("click", () => {
teleportModal.classList.remove("visible");
pendingTeleportLat = null;
pendingTeleportLng = null;
});
teleportConfirm.addEventListener("click", async () => {
teleportModal.classList.remove("visible");
if (pendingTeleportLat === null) return;
stopClickPath();
stopKeyLoop();
keysDown = {};
const km = distanceKm(lat, lng, pendingTeleportLat, pendingTeleportLng);
const totalSecs = cooldownMinutes(km) * 60;
await sendCoords(pendingTeleportLat, pendingTeleportLng);
clearWaypointVisuals();
map.setView([pendingTeleportLat, pendingTeleportLng], map.getZoom());
pendingTeleportLat = null;
pendingTeleportLng = null;
startCooldown(totalSecs);
});
function startCooldown(totalSecs) {
cooldownOverlay.classList.add("visible");
let remaining = totalSecs;
const arc = document.getElementById("cooldownArc");
function tick() {
if (remaining <= 0) { clearInterval(cooldownTimer); cooldownOverlay.classList.remove("visible"); setTeleportMode(false); return; }
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
document.getElementById("cooldownDisplay").textContent = mins > 0 ? `${mins}:${String(secs).padStart(2,"0")}` : `${secs}s`;
const pct = remaining / totalSecs;
arc.style.strokeDashoffset = CIRCUMFERENCE * (1 - pct);
remaining--;
}
tick();
cooldownTimer = setInterval(tick, 1000);
}
cooldownSkip.addEventListener("click", () => {
if (cooldownTimer) clearInterval(cooldownTimer);
cooldownOverlay.classList.remove("visible");
setTeleportMode(false);
});
let initialViewSet = false;
async function checkServer() {
try {
const r = await fetch(`${SERVER}/status`, { signal: AbortSignal.timeout(2000) });
if (r.ok) {
serverOnline = true;
document.getElementById("statusDot").className = "status-dot connected";
document.getElementById("statusText").textContent = "iPhone Connected";
document.getElementById("serverWarning").style.display = "none";
const coords = await fetch(`${SERVER}/coords`).then(r => r.json());
lat = coords.lat; lng = coords.lng;
if (!initialViewSet) {
map.setView([lat, lng], _savedZoom);
initialViewSet = true;
}
updateDisplay();
return;
}
} catch (e) {}
serverOnline = false;
initialViewSet = false;
document.getElementById("statusDot").className = "status-dot error";
document.getElementById("statusText").textContent = "Server offline";
document.getElementById("serverWarning").style.display = "block";
}
updateDisplay();
checkServer();
setInterval(checkServer, 5000);
</script>
</body>
</html>