Files

1431 lines
81 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<title>Debug Panel — vehicles</title>
<style>
/* ===== design tokens ported from PrivateServer1 (Drmk) WebUI ===== */
:root {
--bg-page: #dbe0e7; --bg-canvas: #dde2e9; --bg-header: rgba(238,242,247,0.92);
--bg-card: rgba(248,250,253,0.82); --bg-card-elevated: #ffffff; --bg-card-sub: rgba(230,236,243,0.6);
--bg-input: rgba(255,255,255,0.9);
--pill-active-bg: #23272e; --pill-active-text: #fff;
--pill-inactive-bg: rgba(168,179,195,0.28); --pill-inactive-text: #3e4756; --pill-hover-bg: rgba(168,179,195,0.42);
--btn-primary-bg: #22262e; --btn-primary-hover: #111419; --btn-primary-text: #fff;
--btn-secondary-bg: rgba(160,173,190,0.28); --btn-secondary-hover: rgba(160,173,190,0.42); --btn-secondary-text: #22262e;
--text-main: #181c23; --text-2: #3a4352; --text-3: #5f6b7c; --text-4: #8a96a6;
--border-subtle: rgba(160,175,195,0.35); --border-card: rgba(255,255,255,0.8); --border-input: rgba(160,175,195,0.5);
--accent-blue: #2b66ff; --accent-glow: rgba(43,102,255,0.35);
--red: #ef4444; --green: #10b981;
--font-game: 'Bahnschrift','PingFang SC','Microsoft YaHei','Segoe UI',-apple-system,BlinkMacSystemFont,Roboto,sans-serif;
}
html[data-theme="dark"] {
--bg-page: #101319; --bg-canvas: #12161f; --bg-header: rgba(20,25,34,0.92);
--bg-card: rgba(26,32,43,0.85); --bg-card-elevated: #1b212c; --bg-card-sub: rgba(38,46,60,0.5);
--bg-input: rgba(16,20,28,0.9);
--pill-active-bg: #e8ecf2; --pill-active-text: #161a21;
--pill-inactive-bg: rgba(120,134,156,0.22); --pill-inactive-text: #aab6c6; --pill-hover-bg: rgba(120,134,156,0.34);
--btn-primary-bg: #e8ecf2; --btn-primary-hover: #ffffff; --btn-primary-text: #161a21;
--btn-secondary-bg: rgba(120,134,156,0.22); --btn-secondary-hover: rgba(120,134,156,0.34); --btn-secondary-text: #d5dde8;
--text-main: #e8ecf2; --text-2: #c3ccd9; --text-3: #94a1b3; --text-4: #6b7889;
--border-subtle: rgba(120,134,156,0.30); --border-card: rgba(255,255,255,0.06); --border-input: rgba(120,134,156,0.45);
}
body { font-family: var(--font-game); background: var(--bg-canvas); color: var(--text-main);
max-width: 1180px; margin: 0 auto; padding: 78px 18px 40px; }
.game-header { position: fixed; top: 0; left: 0; right: 0; z-index: 100; height: 50px;
display: flex; align-items: center; justify-content: space-between; padding: 0 16px;
background: var(--bg-header); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border-bottom: 1px solid var(--border-subtle); }
.ananta-svg-wrap svg, .ananta-svg-wrap img { height: 26px; display: block; color: var(--text-main); }
.theme-toggle { height: 30px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--border-subtle);
background: var(--btn-secondary-bg); color: var(--btn-secondary-text); cursor: pointer; font-size: 14px; margin-left: auto; }
h1 { font-size: 16px; font-weight: 700; color: var(--text-main); margin: 6px 0 12px; letter-spacing: .3px; }
h2 { font-size: 13.5px; font-weight: 700; color: var(--text-2); text-transform: uppercase; letter-spacing: .8px;
border-bottom: 1px solid var(--border-subtle); padding-bottom: 6px; margin: 4px 0 12px; }
.card { background: var(--bg-card); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-card); border-radius: 10px; padding: 16px 18px; margin: 14px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
button { height: 34px; padding: 0 15px; border-radius: 6px; border: 1px solid var(--border-subtle);
background: var(--btn-secondary-bg); color: var(--btn-secondary-text); font-size: 13px; font-weight: 600;
font-family: var(--font-game); cursor: pointer; margin: 3px 4px 3px 0; transition: background .15s ease; }
button:hover { background: var(--btn-secondary-hover); }
button.ghost { background: var(--btn-primary-bg); color: var(--btn-primary-text); border-color: rgba(0,0,0,0.12); }
button.ghost:hover { background: var(--btn-primary-hover); }
.btn-add-npc { height: 24px; padding: 0 10px; font-size: 11px; border-radius: 5px;
background: var(--btn-primary-bg); color: var(--btn-primary-text); }
.btn-add-npc:hover { background: var(--btn-primary-hover); }
input, select, textarea { background: var(--bg-input); color: var(--text-main); border: 1px solid var(--border-input);
border-radius: 6px; padding: 7px 10px; margin: 2px; font-family: var(--font-game); font-size: 13px; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent-blue);
box-shadow: 0 0 0 3px var(--accent-glow); }
input[type=number] { width: 110px; }
input[type=range] { width: 100%; margin: 10px 0 2px; accent-color: var(--accent-blue); cursor: pointer; height: auto; border: none; padding: 0; background: transparent; }
input[type=checkbox] { accent-color: var(--accent-blue); }
#clock { font-size: 30px; letter-spacing: 2px; color: var(--text-main); font-weight: 700; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #888; margin-right: 6px; }
.dot.on { background: var(--green); box-shadow: 0 0 8px rgba(16,185,129,0.6); }
table { border-collapse: collapse; width: 100%; margin-top: 8px; }
td, th { border: none; border-bottom: 1px solid var(--border-subtle); padding: 6px 8px; text-align: left; font-size: 12.5px; }
tr:hover td { background: var(--bg-card-sub); }
.muted { color: var(--text-3); font-size: 12px; }
#log { white-space: pre-wrap; font-size: 12px; max-height: 160px; overflow-y: auto; font-family: 'Cascadia Mono', Consolas, monospace; }
.npc-tab { height: 28px; padding: 0 12px; border-radius: 999px; border: 1px solid transparent;
background: var(--pill-inactive-bg); color: var(--pill-inactive-text); font-size: 12px; font-weight: 600;
font-family: var(--font-game); cursor: pointer; transition: .15s; }
.npc-tab:hover { background: var(--pill-hover-bg); }
.npc-tab.active { background: var(--pill-active-bg); color: var(--pill-active-text); font-weight: bold; }
.badge-cat { font-size: 10px; padding: 2px 7px; border-radius: 999px; font-weight: bold; display: inline-block; }
.badge-monster { background: rgba(239,68,68,0.14); color: #ff8a80; border: 1px solid rgba(239,68,68,0.4); }
.badge-citizen { background: rgba(43,102,255,0.12); color: #90cdf4; border: 1px solid rgba(43,102,255,0.4); }
.badge-police { background: rgba(183,149,11,0.14); color: #ffcc80; border: 1px solid rgba(183,149,11,0.4); }
.badge-animal { background: rgba(16,185,129,0.12); color: #95d5b2; border: 1px solid rgba(16,185,129,0.4); }
.badge-ally { background: rgba(142,36,170,0.14); color: #e1bee7; border: 1px solid rgba(142,36,170,0.4); }
.picklist { max-height: 180px; overflow-y: auto; border: 1px solid var(--border-subtle); border-radius: 8px;
padding: 6px; margin-top: 6px; background: var(--bg-input); }
.picklist label { display: block; padding: 3px 0; font-size: 12px; cursor: pointer; }
</style>
</head>
<body>
<header class="game-header">
<div style="display:flex;align-items:center"><div class="ananta-svg-wrap" title="ANANTA">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 118" fill="currentColor" fill-rule="evenodd" class="ananta-svg-logo">
<path d="M 386 2 L 388 11 L 390 13 L 390 16 L 393 25 L 395 27 L 398 39 L 400 41 L 401 47 L 405 54 L 406 60 L 411 71 L 411 74 L 413 76 L 416 88 L 418 90 L 422 104 L 426 114 L 428 116 L 455 115 L 445 94 L 445 92 L 447 90 L 457 90 L 461 94 L 475 116 L 510 116 L 510 114 L 502 104 L 501 101 L 473 64 L 472 61 L 439 17 L 433 7 L 428 2 Z M 416 26 L 418 25 L 423 26 L 432 42 L 437 48 L 437 50 L 440 53 L 440 55 L 443 58 L 444 61 L 442 63 L 434 63 L 432 61 Z M 333 2 L 335 7 L 336 17 L 338 20 L 338 24 L 340 29 L 349 29 L 353 33 L 354 40 L 356 42 L 357 50 L 361 59 L 362 68 L 364 70 L 367 85 L 369 87 L 370 95 L 372 98 L 372 102 L 374 104 L 375 112 L 377 116 L 406 116 L 405 108 L 403 106 L 403 102 L 400 93 L 398 91 L 395 79 L 393 76 L 387 58 L 387 55 L 385 52 L 385 49 L 379 34 L 379 32 L 382 29 L 388 29 L 387 23 L 385 21 L 382 8 L 380 6 L 379 2 Z M 260 2 L 260 16 L 262 24 L 263 46 L 265 51 L 266 77 L 268 80 L 269 105 L 271 109 L 272 116 L 299 116 L 298 100 L 296 96 L 296 88 L 294 84 L 293 69 L 291 65 L 292 62 L 294 61 L 297 64 L 297 66 L 299 68 L 299 70 L 307 84 L 307 86 L 310 90 L 310 92 L 312 94 L 312 96 L 315 100 L 321 114 L 323 116 L 356 116 L 353 101 L 351 98 L 350 89 L 346 79 L 345 70 L 341 60 L 340 51 L 338 49 L 338 44 L 336 41 L 335 32 L 333 30 L 333 25 L 330 18 L 330 13 L 328 11 L 328 7 L 326 2 L 304 2 L 305 10 L 307 13 L 307 18 L 309 21 L 310 31 L 312 34 L 312 39 L 310 41 L 308 40 L 291 10 L 291 8 L 286 2 Z M 210 2 L 208 7 L 208 12 L 206 15 L 205 24 L 203 27 L 203 32 L 201 34 L 200 44 L 198 47 L 198 52 L 196 55 L 195 65 L 193 67 L 193 72 L 190 80 L 190 85 L 188 87 L 187 97 L 185 100 L 185 105 L 183 107 L 182 116 L 211 116 L 213 111 L 213 105 L 215 101 L 215 95 L 217 90 L 232 90 L 234 92 L 234 116 L 264 115 L 262 98 L 262 84 L 260 80 L 260 67 L 258 61 L 257 34 L 255 31 L 255 18 L 253 12 L 252 2 Z M 228 25 L 232 25 L 233 27 L 233 61 L 231 63 L 221 62 L 222 51 L 224 47 L 224 40 L 226 37 L 226 30 Z M 202 2 L 179 3 L 177 8 L 177 12 L 175 14 L 172 30 L 170 32 L 169 40 L 166 42 L 164 40 L 164 22 L 162 2 L 135 2 L 133 4 L 130 15 L 128 17 L 125 28 L 123 30 L 120 39 L 120 42 L 112 61 L 112 64 L 110 66 L 109 72 L 105 79 L 104 85 L 99 96 L 99 99 L 97 101 L 94 113 L 92 115 L 125 115 L 127 111 L 128 104 L 130 102 L 130 98 L 132 95 L 133 88 L 135 86 L 135 82 L 138 76 L 138 72 L 141 64 L 143 62 L 146 63 L 146 78 L 148 86 L 148 109 L 150 116 L 175 116 L 177 110 L 177 105 L 179 103 L 180 93 L 182 90 L 182 85 L 184 82 L 185 72 L 187 69 L 190 52 L 192 49 L 193 39 L 195 36 L 198 19 L 200 16 Z M 126 2 L 84 2 L 2 115 L 38 116 L 42 112 L 42 110 L 47 104 L 47 102 L 56 90 L 66 90 L 68 93 L 60 109 L 59 116 L 86 115 L 94 94 L 94 91 L 96 89 L 97 83 L 101 75 L 102 69 L 104 67 L 105 61 L 109 53 L 110 47 L 112 45 L 115 33 L 117 31 L 118 25 L 120 23 L 120 20 L 125 9 Z M 94 24 L 96 26 L 96 28 L 94 30 L 91 39 L 89 41 L 81 60 L 78 63 L 70 63 L 68 60 L 76 49 L 76 47 L 81 41 L 89 27 L 92 24 Z" />
</svg>
</div><div class="brand-title" style="font-size:13px;font-weight:700;color:var(--text-2);margin-left:10px;white-space:nowrap">Private Server Console</div></div>
<button id="themeToggle" class="theme-toggle" title="Toggle theme">🌙</button>
</header>
<h1 style="display:none">Vehicle debug</h1>
<div class="card">
<h2>Status</h2>
<div id="status">connecting…</div>
<div class="muted">refreshes every 2 sec</div>
</div>
<div class="card">
<h2>Time of day</h2>
<div id="clock">12:00</div>
<input id="timeslider" type="range" min="0" max="1439" step="1" value="720" aria-label="Time of day">
<div class="muted">00:00 — night &nbsp;·&nbsp; 06:00 — morning &nbsp;·&nbsp; 12:00 — noon &nbsp;·&nbsp; 18:00 — evening &nbsp;·&nbsp; 23:59 — night</div>
<div style="margin-top:8px">
<button class="ghost" onclick="setTimePreset(6, 0)">🌅 Morning (06:00)</button>
<button class="ghost" onclick="setTimePreset(12, 0)">☀️ Noon (12:00)</button>
<button class="ghost" onclick="setTimePreset(18, 30)">🌇 Evening (18:30)</button>
<button class="ghost" onclick="setTimePreset(22, 0)">🌙 Night (22:00)</button>
<button class="ghost" onclick="setTimePreset(0, 0)">🌌 Midnight (00:00)</button>
</div>
</div>
<div class="card">
<h2>Weather / Atmosphere (สภาพอากาศ)</h2>
<div>
<button style="background:#fbc02d;color:#222;font-weight:bold" onclick="setWeather(1)">☀️ Sunny (แดดออก) [1]</button>
<button style="background:#78909c" onclick="setWeather(2)">⛅ Cloudy (มีเมฆมาก) [2]</button>
<button style="background:#1976d2" onclick="setWeather(3)">🌧️ Rain (ฝนตก) [3]</button>
<button style="background:#512da8" onclick="setWeather(4)">⛈️ Storm (พายุฝนฟ้าคะนอง) [4]</button>
<button style="background:#00897b" onclick="setWeather(5)">🌤️ Afterrain (หลังฝนตก / ใสเคลียร์) [5]</button>
</div>
<div style="margin-top:10px;padding:8px;background:#252932;border-radius:6px">
<div style="font-weight:bold;margin-bottom:6px;color:#80deea">🌫️ Volumetric Exponential Fog (หมอกหนา / ทัศนวิสัย):</div>
<button style="background:#00695c" onclick="setFog(0.08)">🌫️ Thick Fog (เปิดหมอกหนาจัด 0.08)</button>
<button style="background:#00838f" onclick="setFog(0.02)">🌁 Light Fog (หมอกบาง 0.02)</button>
<button class="ghost" onclick="setFog(0)">☀️ Clear Fog (ปิดหมอก)</button>
<div class="muted" style="margin-top:4px">ปรับความหนาแน่นหมอกแบบเรียลไทม์ผ่าน WeatherBridge.EnableExternalExpFogDensity</div>
</div>
<div style="margin-top:8px">
Custom Weather ID: <input id="customWeatherId" type="number" style="width:90px" value="1" min="1" max="5">
Transition (sec): <input id="weatherTransition" type="number" style="width:70px" value="5">
<button class="ghost" onclick="setCustomWeather()">Apply Custom Weather</button>
</div>
<div class="muted" style="margin-top:6px">ซิงค์สภาพอากาศของโลก (WeatherConfig 1..5) และบรรยากาศ client ทันที</div>
</div>
<div class="card">
<h2>Garage (server vehicles)</h2>
<div style="margin-bottom:10px;padding:8px;background:#252932;border-radius:6px">
<div style="font-weight:bold;margin-bottom:6px;color:#ffb74d">⚡ Quick Spawn Special Vehicles:</div>
<button style="background:#8e24aa" onclick="spawnSpecificVehicle(81006020)">🚀 FG Vision Aric (Flying Car) [81006020]</button>
<button style="background:#e91e63" onclick="spawnSpecificVehicle(81007104)">🏍️ Shikage Freeman800 (Honda NT1100) [81007104]</button>
<button style="background:#00897b" onclick="spawnSpecificVehicle(81007007)">🚁 Reed Ranger Helicopter [81007007]</button>
<button style="background:#f4511e" onclick="spawnSpecificVehicle(81006011)">🏍️ Sunset M125 Superbike [81006011]</button>
<div style="font-size:11px;color:#81c784;margin-top:6px">💡 หมายเหตุ: รถล้อเดียวของ Taffy (塔菲独轮车) เป็นสกิลกดขี่ของตัวละคร Taffy (กด E / Active Skill) ให้สลับเป็นตัวละคร Taffy แล้วกด E เพื่อแปลงค้อนเป็นรถล้อเดียวขี่ได้ทันที</div>
</div>
<div>Category: <select id="fleetcat" onchange="filterFleet()"></select></div>
<div style="margin-top:6px">Vehicle: <select id="fleet"></select></div>
<div style="margin-top:8px">
<button onclick="spawnNear()">1. Spawn selected nearby</button>
<button onclick="enterLast()">2. Seat into latest</button>
</div>
<div style="margin-top:8px">
<button class="ghost" onclick="resync()">Verified (39)</button>
<button class="ghost" onclick="unlockAll()">SyncAllUnlockedVehicles (all 150)</button>
</div>
<div class="muted">By default only the 39 verified vehicles load into the game. All 150 (incl. broken/unverified) — only via SyncAllUnlockedVehicles.</div>
<div style="margin-top:8px">
Custom ID: <input id="rawId" type="number" style="width:130px" placeholder="81007068">
<button class="ghost" onclick="spawnRaw()">Spawn by ID</button>
</div>
<div class="muted">Custom ID — for testing excluded/broken ones (Broken Model, Undrivable): the server lets it through with a warning, the client has the final say.</div>
<div class="muted">Pushes SyncAllUnlockedVehicles — the vehicle list in the phone/garage updates without relogging.</div>
</div>
<div class="card">
<h2>Summoned vehicles</h2>
<div id="summoned">—</div>
</div>
<div class="card" style="border-color:#00bcd4">
<h2 style="color:#4dd0e1">👥 NPC & Monster Spawner (เสก NPC / มอนสเตอร์ / สัตว์ / ตัวละคร)</h2>
<div class="muted">มอนสเตอร์ สัตว์ และชาวเมืองในเกมใช้โครงสร้าง Agent เดียวกัน ค้นหาหรือเลือกหมวดหมู่ แล้วกด <b>➕ Add</b> เพื่อเพิ่ม ID ลงในคิวเสกได้ทันที</div>
<!-- Category Filter Tabs -->
<div style="display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 8px 0" id="npcCatTabs">
<button class="npc-tab active" data-cat="all" onclick="setNpcCategory('all')">🌟 ทั้งหมด (All)</button>
<button class="npc-tab" data-cat="monster" onclick="setNpcCategory('monster')">⚔️ มอนสเตอร์ & บอส</button>
<button class="npc-tab" data-cat="citizen" onclick="setNpcCategory('citizen')">🚶 ชาวเมือง (Citizens)</button>
<button class="npc-tab" data-cat="police" onclick="setNpcCategory('police')">👮 ตำรวจ & ยาม (Police)</button>
<button class="npc-tab" data-cat="animal" onclick="setNpcCategory('animal')">🐱 สัตว์ & มาสคอต</button>
<button class="npc-tab" data-cat="ally" onclick="setNpcCategory('ally')">🤝 พันธมิตร & ตัวละคร</button>
</div>
<!-- Search & Pose Toolbar -->
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:8px">
<div style="flex:1;min-width:220px">
<input id="npcSearchInput" type="text" placeholder="🔍 ค้นหา (พิมพ์ EN/จีน เช่น Security, Placeholder, 占位, Duck หรือเลข ID)..." style="width:100%;box-sizing:border-box" oninput="onNpcSearchInput()">
</div>
<div style="display:flex;align-items:center;gap:6px">
<label style="font-size:12px;color:#aaa">ท่าทาง (Pose):</label>
<select id="npcPoiSelect" style="min-width:180px">
<option value="0">🧍 Auto / None (0)</option>
<option value="2" selected>🧍 Idle ยืนนิ่ง (2)</option>
<option value="1">🚶 Walk เดิน (1)</option>
<option value="4">🏃 Run วิ่ง (4)</option>
<option value="11">📱 Phone คุยโทรศัพท์/ถ่ายรูป (11)</option>
<option value="10">🧱 Lean Wall พิงกำแพง (10)</option>
<option value="12">🪑 Formal Sit นั่งเรียบร้อย (12)</option>
<option value="13">🪑 Relax Sit นั่งผ่อนคลาย (13)</option>
<option value="15">💬 Sit & Talk นั่งคุย (15)</option>
<option value="6">👏 Clap ปรบมือ (6)</option>
<option value="5">👀 Spectate ยืนมุง (5)</option>
<option value="3">😱 Scared ตกใจกลัว (3)</option>
<option value="8">🥤 Vending กดตู้ขายน้ำ (8)</option>
</select>
</div>
</div>
<!-- Catalog Table -->
<div style="max-height:240px;overflow-y:auto;border:1px solid #333;border-radius:6px;background:#0d1117;margin-bottom:10px">
<table style="width:100%;border-collapse:collapse;font-size:12px;text-align:left">
<thead>
<tr style="background:#161b22;border-bottom:1px solid #30363d;position:sticky;top:0;z-index:1">
<th style="padding:6px 8px;width:75px">หมวด</th>
<th style="padding:6px 8px;width:95px">ID</th>
<th style="padding:6px 8px">ชื่อ (Name)</th>
<th style="padding:6px 8px;width:80px">Model ID</th>
<th style="padding:6px 8px;width:70px;text-align:center">Action</th>
</tr>
</thead>
<tbody id="npcTableBody">
<tr><td colspan="5" style="padding:12px;text-align:center;color:#888">กำลังโหลดรายชื่อ Agent...</td></tr>
</tbody>
</table>
</div>
<!-- Textarea & Spawn Controls -->
<div style="margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px">
<span style="font-size:12px;font-weight:bold;color:#4dd0e1">📋 รายการที่จะเสก (คั่นด้วยบรรทัดใหม่: ID, POI_ID):</span>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="ghost" style="padding:2px 8px;font-size:11px" onclick="clearNpcList()">🧹 ล้างรายการ</button>
<button class="ghost" style="padding:2px 8px;font-size:11px" onclick="setNpcPreset('gang')">⚔️ แก๊ง 4 ตัว</button>
<button class="ghost" style="padding:2px 8px;font-size:11px" onclick="setNpcPreset('citizens')">🚶 ชาวเมือง 3 คน</button>
<button class="ghost" style="padding:2px 8px;font-size:11px" onclick="setNpcPreset('animals')">🦆 สัตว์ 3 ตัว</button>
</div>
</div>
<textarea id="npcItems" rows="4" style="width:100%;box-sizing:border-box;background:#101215;color:#d7dce2;border:1px solid #444;border-radius:6px;padding:6px;font-family:monospace;margin:4px 0 8px 0;resize:vertical" placeholder="40900579, 0" spellcheck="false">40130709, 2</textarea>
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center">
<label>X spacing <input id="npcXSpacing" type="number" min="0" step="any" value="1.5" style="width:55px"></label>
<label>Z spacing <input id="npcZSpacing" type="number" min="0" step="any" value="1.5" style="width:55px"></label>
<label>Max / row <input id="npcMaxPerRow" type="number" min="1" step="1" value="10" style="width:55px"></label>
<button id="npcSpawn" style="background:#0097a7;padding:8px 16px;font-weight:bold" onclick="spawnNpcs()">🚀 เสกเข้าเกม (Spawn)</button>
</div>
<div class="muted" style="margin-top:4px">NPC / มอนสเตอร์จะถูกวางเป็นแถวกริดด้านหน้าตัวละครของคุณโดยอิงจากตำแหน่งและมุมหันในปัจจุบัน</div>
<pre id="npcResult" style="background:#101215;border:1px solid #333;border-radius:6px;padding:8px;max-height:120px;overflow-y:auto;white-space:pre-wrap;margin-top:6px;font-size:12px;display:none"></pre>
</div>
<div class="card">
<h2>Scene ("raid") switch</h2>
<div style="margin-bottom:8px">
Preset:
<select id="scenepreset" onchange="onPresetChange()" style="min-width:380px">
<option value="loading">Loading scene presets...</option>
</select>
<button style="background:#2e7d32;margin-left:8px" onclick="switchToPreset()">Switch to preset</button>
</div>
<div style="margin-bottom:8px">
Raid <input id="s_raid" type="number" style="width:130px" value="23300999">
Instance <input id="s_instance" type="number" style="width:130px" value="20001223">
Universe <input id="s_universe" type="number" style="width:130px" value="76000888">
</div>
<div>
X <input id="s_x" type="number" step="0.5" style="width:90px" value="-4719.8">
Y <input id="s_y" type="number" step="0.5" style="width:90px" value="168.5">
Z <input id="s_z" type="number" step="0.5" style="width:90px" value="-2844.7">
Facing <input id="s_f" type="number" step="1" style="width:80px" value="0">
<button onclick="switchSceneCustom()" style="margin-left:8px">Switch scene</button>
</div>
<div class="muted" style="margin-top:6px">Switches world region and initiates client-side scene load. Preset or custom coordinates can be specified.</div>
</div>
<div class="card">
<h2>Player teleport</h2>
<div>
X <input id="tx" type="number" step="0.5" value="0">
Y <input id="ty" type="number" step="0.5" value="0">
Z <input id="tz" type="number" step="0.5" value="0">
Facing <input id="tf" type="number" step="1" value="0">
</div>
<div style="margin-top:8px">
<button onclick="teleport()">Teleport</button>
<button class="ghost" onclick="fillCurrent()">Use current</button>
<button class="ghost" onclick="fillSpawn()">Spawn point</button>
</div>
</div>
<div class="card" style="border-color:#3b82f6">
<h2 style="color:#60a5fa">🚇 ระบบขนส่งมวลชน & รถไฟใต้ดิน (Metro Transit & Live Train Tracker)</h2>
<!-- Sub-Tabs Navigation -->
<div style="display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap">
<button id="tabBtn_live" class="npc-tab active" onclick="switchTransitTab('live')">🚊 ติดตามขบวนรถไฟสด & สายรถไฟ (Live Train Lines)</button>
<button id="tabBtn_stations" class="npc-tab" onclick="switchTransitTab('stations')">📍 แผนผังจุดวาร์ปสถานีทั้งหมด (All Stations Grid)</button>
</div>
<!-- TAB 1: Live Train Lines & Schedule -->
<div id="transitPane_live">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
<span style="font-size:13px;font-weight:bold;color:#93c5fd">เลือกสายรถไฟ:</span>
<select id="railLineSelect" onchange="onSelectRailLine()" style="flex:1;min-width:240px;background:#1e293b;color:#f8fafc;border:1px solid #3b82f6;padding:6px;border-radius:4px">
<option value="1">🔵 Line 1: Bayshore Line (Inner Ring / ตามเข็มนาฬิกา)</option>
<option value="2">🔷 Line 2: Bayshore Line (Outer Ring / ทวนเข็มนาฬิกา)</option>
<option value="3">🟡 Line 3: Central Loop Line (สายวนรอบใจกลางเมือง)</option>
<option value="4">🟢 Line 4: East Loop Line (สายวนรอบเขตธุรกิจตะวันออก)</option>
<option value="9">🟣 Longqi Metro Line 9 (รถไฟใต้ดินเขตหลงฉี สาย 9)</option>
</select>
<button style="background:#0284c7;color:white;padding:6px 12px" onclick="refreshTransitTracker()">🔄 Refresh Live Trains</button>
</div>
<!-- Active Line Info Banner -->
<div id="lineInfoBanner" style="background:#0f172a;border-left:4px solid #38bdf8;padding:8px 12px;border-radius:4px;margin-bottom:10px;font-size:12px;color:#cbd5e1">
</div>
<!-- Live Trains on Line -->
<div style="font-weight:bold;margin-bottom:6px;color:#38bdf8;font-size:13px">🚆 ขบวนรถไฟที่กำลังวิ่งบนสายนี้ (Live Active Trains):</div>
<div id="activeTrainsContainer" style="display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:8px;margin-bottom:12px">
</div>
<!-- Route Station Nodes -->
<div style="font-weight:bold;margin-bottom:6px;color:#60a5fa;font-size:13px">🚉 เส้นทางสถานี & จุดขึ้นขบวนรถ (Station Route & Boarding):</div>
<div id="routeStationsContainer" style="background:#1e222b;border:1px solid #334155;border-radius:6px;padding:8px;max-height:300px;overflow-y:auto">
</div>
<div class="muted" style="margin-top:6px">💡 เมื่อเปิดเช็คบ็อกซ์ด้านบน ระบบจะเล่นคัทซีนนั่งรถไฟลอดอุโมงค์และไปโผล่ออกที่อุโมงค์สถานีปลายทางให้อัตโนมัติ</div>
</div>
<!-- TAB 2: All Stations Grid -->
<div id="transitPane_stations" style="display:none">
<div style="font-weight:bold;margin-bottom:6px;color:#93c5fd">🏙️ Nova City Metro (12 สถานี):</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px">
<button onclick="warpSubway(2376.0, -1.1, 2729.6, 104.5, 'Sunny Bay Station', 'loading_metro_end_1')">Sunny Bay</button>
<button onclick="warpSubway(898.4, -1.1, 1558.2, 51, 'Freshville Station', 'loading_metro_end_1')">Freshville</button>
<button onclick="warpSubway(1878.8, -1.1, 2250.5, 0, 'Glimmergold Station', 'loading_metro_end_1')">Glimmergold</button>
<button onclick="warpSubway(2748.3, -1.1, 2046.6, 0, 'Memorial Park Station', 'loading_metro_end_1')">Memorial Park</button>
<button onclick="warpSubway(3159.0, -1.1, 2643.0, 0, 'Azure North Station', 'loading_metro_end_1')">Azure North</button>
<button onclick="warpSubway(3661.7, -1.0, 1286.9, 0, 'Future Port Station', 'loading_metro_end_1')">Future Port</button>
<button onclick="warpSubway(2243.6, -1.1, 1616.9, 0, 'Coastal Station', 'loading_metro_end_1')">Coastal Station</button>
<button onclick="warpSubway(3941.7, -1.1, 1974.1, 0, 'Vista Station', 'loading_metro_end_1')">Vista Station</button>
<button onclick="warpSubway(1232.9, -1.1, 2213.1, 0, 'Veluxity Station', 'loading_metro_end_1')">Veluxity Station</button>
<button onclick="warpSubway(620.2, -1.0, 2072.1, 0, 'Paradox Plaza Station', 'loading_metro_end_1')">Paradox Plaza</button>
<button onclick="warpSubway(1383.1, -1.1, 1267.7, 0, 'Marine MechWorks Station', 'loading_metro_end_1')">Marine MechWorks</button>
<button onclick="warpSubway(2947.4, -1.1, 1030.3, 0, 'Rixxy Wonderland Station', 'loading_metro_end_1')">Rixxy Wonderland</button>
</div>
<div style="font-weight:bold;margin-bottom:6px;color:#a78bfa">🏮 Longqi Metro (13 สถานี):</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="ghost" onclick="warpSubway(-1638.0, 18.0, 1540.5, 0, 'Dinglan North', 'loading_general_end')">Dinglan North</button>
<button class="ghost" onclick="warpSubway(-1492.5, 17.6, 1107.9, 0, 'Zhaohua Road', 'loading_metro_end_1')">Zhaohua Road</button>
<button class="ghost" onclick="warpSubway(-1431.7, 18.0, 565.0, 0, 'Dinglan Road', 'loading_general_end')">Dinglan Road</button>
<button class="ghost" onclick="warpSubway(-1725.5, 17.5, 12.1, 0, 'Lakeside Pedestrian', 'loading_metro_end_1')">Lakeside Pedestrian</button>
<button class="ghost" onclick="warpSubway(-1002.5, 17.7, 115.3, 0, 'Zenith Center', 'loading_metro_end_1')">Zenith Center</button>
<button class="ghost" onclick="warpSubway(-1788.4, 17.5, -574.6, 0, 'Lakeside South', 'loading_metro_end_1')">Lakeside South</button>
<button class="ghost" onclick="warpSubway(-844.0, 17.5, 1133.7, 0, 'Great Ferry Port', 'loading_metro_end_1')">Great Ferry Port</button>
<button class="ghost" onclick="warpSubway(-624.4, 17.6, 660.4, 0, 'Xinfeng Road', 'loading_metro_end_1')">Xinfeng Road</button>
<button class="ghost" onclick="warpSubway(61.6, 17.5, 778.3, 0, 'Yaojin Road', 'loading_metro_end_1')">Yaojin Road</button>
<button class="ghost" onclick="warpSubway(375.8, 17.5, 98.4, 0, 'Yaojin Pedestrian', 'loading_metro_end_1')">Yaojin Pedestrian</button>
<button class="ghost" onclick="warpSubway(-427.6, 17.5, 354.0, 0, 'Mingchun North', 'loading_metro_end_1')">Mingchun North</button>
<button class="ghost" onclick="warpSubway(-2442.7, 17.5, 1015.6, 0, 'Lakefront Institute', 'loading_metro_end_1')">Lakefront Institute</button>
<button class="ghost" onclick="warpSubway(-1138.2, 17.7, -602.3, 0, 'Cloudstream West', 'loading_metro_end_1')">Cloudstream West</button>
</div>
</div>
</div>
<div class="card" style="border-color:#ff9800">
<h2 style="color:#ffb74d">🕹️ Arcade & Interactive Minigames (ตู้เกมอาร์เคด & มินิเกมในโลก)</h2>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select id="minigameSelect" style="flex:1;min-width:240px;background:#2d3748;color:#e2e8f0;border:1px solid #4a5568;padding:8px 12px;border-radius:6px;font-size:14px">
<optgroup label="🕹️ ตู้เกมอาร์เคด (Arcade Cabinets)">
<option value="KOF97" selected>🕹️ Arcade: The King of Fighters '97 (ตู้เกม KOF '97)</option>
<option value="METALSLUG">🕹️ Arcade: Metal Slug (ตู้เกม Metal Slug ลุยด่าน)</option>
</optgroup>
<optgroup label="🎵 ดนตรีสด (Live Musical Instruments)">
<option value="PIANO">🎹 Grand Piano (เล่นเปียโนสด - เสกตู้เปียโนตรงหน้า)</option>
<option value="DRUMKIT">🥁 Drumkit Set (ตีกลองชุดสด)</option>
</optgroup>
<optgroup label="🏆 กิจกรรมกีฬา (Sports & Leisure)">
<option value="BASKETBALL">🏀 Street Basketball (ชูตบาสเกตบอล)</option>
</optgroup>
</select>
<button style="background:#e65100;color:white;font-weight:600;padding:8px 16px" onclick="launchSelectedMinigame()">Launch / เปิดมินิเกม</button>
<button style="background:#4a5568;color:#cbd5e0;padding:8px 14px" onclick="closeActiveMinigame()">Close / ปิดมินิเกม</button>
<button style="background:#059669;color:white;font-weight:600;padding:8px 14px" onclick="teleportToBowling()">🎳 Warp to Bowling Alley (วาร์ปไปลานโบว์ลิ่ง)</button>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
<button style="background:#d97706;color:white;font-size:12px;padding:4px 8px" onclick="launchMinigameQuick('KOF97')">🕹️ KOF '97</button>
<button style="background:#d97706;color:white;font-size:12px;padding:4px 8px" onclick="launchMinigameQuick('METALSLUG')">🕹️ Metal Slug</button>
<button style="background:#2563eb;color:white;font-size:12px;padding:4px 8px" onclick="launchMinigameQuick('PIANO')">🎹 Piano</button>
<button style="background:#2563eb;color:white;font-size:12px;padding:4px 8px" onclick="launchMinigameQuick('DRUMKIT')">🥁 Drumkit</button>
<button style="background:#059669;color:white;font-size:12px;padding:4px 8px" onclick="teleportToBowling()">🎳 Bowling Alley</button>
<button style="background:#059669;color:white;font-size:12px;padding:4px 8px" onclick="launchMinigameQuick('BASKETBALL')">🏀 Basketball</button>
</div>
<div class="muted" style="margin-top:6px">เปิดตู้เกมอาร์เคด (KOF '97, Metal Slug), มินิเกมดนตรีสด (เสกโมเดลเปียโนตรงหน้า), หรือวาร์ปไปลานโบว์ลิ่งจริงใน Nova City</div>
</div>
<div class="card" style="border-color:#10b981">
<h2 style="color:#34d399">🚗 City Traffic & Urban Crowd Controller (ระบบจราจรและชาวเมือง)</h2>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div style="background:#1e293b;padding:8px 14px;border-radius:6px;border:1px solid #334155;flex:1;min-width:140px">
<div style="font-size:12px;color:#94a3b8">Active Traffic Cars</div>
<div style="font-size:20px;font-weight:bold;color:#38bdf8" id="trafficActiveCount">0</div>
<div style="font-size:11px;color:#64748b" id="trafficRoadCount">42,128 road lanes</div>
</div>
<div style="background:#1e293b;padding:8px 14px;border-radius:6px;border:1px solid #334155;flex:1;min-width:140px">
<div style="font-size:12px;color:#94a3b8">Living Citizens / Crowd</div>
<div style="font-size:20px;font-weight:bold;color:#a78bfa" id="crowdActiveCount">0</div>
<div style="font-size:11px;color:#64748b" id="crowdShopsCount">1,207 shops registered</div>
</div>
<div style="background:#1e293b;padding:8px 14px;border-radius:6px;border:1px solid #334155;flex:1;min-width:140px">
<div style="font-size:12px;color:#94a3b8">Engine Status</div>
<div style="font-size:16px;font-weight:bold;color:#4ade80" id="trafficEngineStatus">Running 🟢</div>
<div style="font-size:11px;color:#64748b">10 ticks/sec real-time</div>
</div>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:10px">
<div style="flex:1;min-width:220px">
<label style="font-weight:600;font-size:13px;display:flex;justify-content:space-between">
<span>🚗 Traffic Density (ความหนาแน่นรถยนต์):</span>
<span id="trafficDensityLabel" style="color:#38bdf8;font-weight:bold">20 cars</span>
</label>
<input type="range" id="trafficDensitySlider" min="0" max="50" value="20" style="width:100%" oninput="onTrafficDensitySlider(this.value)" onchange="applyTrafficConfig()">
<div style="display:flex;gap:6px;margin-top:4px">
<button style="background:#334155;color:#e2e8f0;font-size:11px;padding:3px 8px" onclick="setTrafficDensityPreset(0)">Off (0)</button>
<button style="background:#334155;color:#e2e8f0;font-size:11px;padding:3px 8px" onclick="setTrafficDensityPreset(10)">Light (10)</button>
<button style="background:#0284c7;color:white;font-size:11px;padding:3px 8px" onclick="setTrafficDensityPreset(20)">Normal (20)</button>
<button style="background:#b91c1c;color:white;font-size:11px;padding:3px 8px" onclick="setTrafficDensityPreset(40)">Rush Hour (40)</button>
</div>
</div>
<div style="flex:1;min-width:220px">
<label style="font-weight:600;font-size:13px;display:flex;justify-content:space-between">
<span>🚶 Pedestrian Density (คนเดินถนนและร้านค้า):</span>
<span id="crowdDensityLabel" style="color:#a78bfa;font-weight:bold">15 npcs</span>
</label>
<input type="range" id="crowdDensitySlider" min="0" max="40" value="15" style="width:100%" oninput="onCrowdDensitySlider(this.value)" onchange="applyCrowdConfig()">
<div style="display:flex;gap:6px;margin-top:4px">
<button style="background:#334155;color:#e2e8f0;font-size:11px;padding:3px 8px" onclick="setCrowdDensityPreset(0)">Off (0)</button>
<button style="background:#334155;color:#e2e8f0;font-size:11px;padding:3px 8px" onclick="setCrowdDensityPreset(10)">Quiet (10)</button>
<button style="background:#7c3aed;color:white;font-size:11px;padding:3px 8px" onclick="setCrowdDensityPreset(20)">Lively (20)</button>
<label style="display:inline-flex;align-items:center;gap:4px;font-size:11px;color:#cbd5e1;margin-left:auto">
<input type="checkbox" id="populateShopsCheck" checked onchange="applyCrowdConfig()"> Populate Shops
</label>
</div>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px">
<button style="background:#059669;color:white;font-weight:600;padding:8px 14px" onclick="spawnTrafficWave()">⚡ Spawn Wave Ahead (ปล่อยรถ 5 คันข้างหน้าทันที)</button>
<button style="background:#dc2626;color:white;padding:8px 14px" onclick="clearAllTraffic()">🧹 Clear All Traffic (ล้างรถทั้งหมด)</button>
<button style="background:#475569;color:#e2e8f0;padding:8px 14px" onclick="refreshTrafficStatus()">🔄 Refresh Status</button>
</div>
<div class="muted" style="margin-top:6px">
จำลองการจราจรอัจฉริยะแบบเรียลไทม์บนเส้นทาง 42,128 เลนตามแผนผังเมืองจริง รถจะวิ่งตามเลน เลี้ยวตามทางแยก และผู้เล่นสามารถกด <strong>[F]</strong> เพื่อเปิดประตูขึ้นไปขับได้ทันทีแบบ GTA
</div>
</div>
<div class="card" id="card-weapon" style="border-left: 4px solid #00bcd4;">
<h2 style="color:#4dd0e1">⚔️ Armory Wheel Customizer (ปรับแต่งอาวุธในวงล้อช่อง 1–15)</h2>
<div class="muted">ติดตั้งอาวุธ 634 ชิ้นในเกมลงในช่องวงล้ออาวุธ 1–15 ของตัวละครใดก็ได้ (ช่อง 0 คือหมัด) ซิงค์สไตล์การต่อสู้ แอนิเมชัน และกระสุนเข้าเกมทันที</div>
<div style="margin-top:8px">
ตัวละคร: <select id="wpnSpirit" style="min-width:200px"></select>
ช่องวงล้อ (Slot 1–15): <input id="wpnSlot" type="number" min="1" max="15" value="1" style="width:70px">
</div>
<div style="margin-top:6px">
ค้นหาอาวุธ: <input id="wpnFilter" placeholder="พิมพ์ชื่อ หรือ templateId เช่น 98003001" style="width:250px" onkeydown="if(event.key==='Enter')renderWeapons()">
<button class="ghost" onclick="renderWeapons()">ค้นหา</button>
</div>
<div class="picklist" id="wpnList">กำลังโหลดรายชื่ออาวุธ...</div>
<div style="margin-top:8px">
<button style="background:#00838f;color:#fff;font-weight:bold" onclick="equipSelected()">⚔️ สวมใส่อาวุธลงในช่อง (Equip Weapon)</button>
</div>
</div>
<div class="card" id="card-give-item" style="border-left: 4px solid #ffca28;">
<h2 style="color:#ffd54f">🎒 Universal Item Spawner (ระบบเสกไอเทมเข้ากระเป๋าเป้)</h2>
<div class="muted">ส่งไอเทมตรงเข้ากระเป๋าตัวละครผู้เล่นแบบ Real-time ผ่าน MethodId.SyncBackpackItemChanged โดยไม่ต้องเปิด GM คอนโซลหรือรีเซ็ตเกม</div>
<div style="margin-top:8px">
Item Template ID: <input id="giveId" type="number" placeholder="36012003" style="width:140px">
จำนวน (Count): <input id="giveCount" type="number" value="1" min="1" max="99999" style="width:90px">
<label class="muted" style="margin-left:6px"><input id="giveBind" type="checkbox"> ผูกมัด (Bind)</label>
<button style="background:#f57f17;color:#fff;font-weight:bold" onclick="giveItem()">🎁 เสกไอเทม / Grant</button>
<button style="background:#00838f;color:#fff;font-weight:bold" onclick="grantGachaTickets()">🎟️ เสกตั๋วกาชาทั้งหมด</button>
</div>
<div style="margin-top:6px">
ค้นหาไอเทม: <input id="itemFilter" placeholder="ชื่อไอเทม หรือ ID" style="width:250px" onkeydown="if(event.key==='Enter')renderItems()">
<button class="ghost" onclick="renderItems()">ค้นหา</button>
</div>
<div class="picklist" id="itemList">กำลังโหลดรายการไอเทม...</div>
<div class="muted" style="margin-top:4px">คลิกที่รายการเพื่อใส่ ID ลงในช่อง หรือคลิกปุ่ม "ส่ง" เพื่อเสกไอเทมทันที 1 ชิ้น</div>
</div>
<div class="card" style="border-color:#e53935">
<h2 style="color:#ff8a80">Emergency Unstuck & Black Screen Recovery</h2>
<div>
<button style="background:#d32f2f" onclick="unstuckBlackScreen()">Fix / Recover Black Screen</button>
<button style="background:#f57c00" onclick="rollback10s()">Rollback Position (10s Ago)</button>
</div>
<div class="muted" style="margin-top:6px">Immediately forces client transition reset, restores user input, and rolls back coordinates to before the glitch.</div>
</div>
<div class="card">
<h2>Panel log</h2>
<div id="log"></div>
</div>
<script>
let lastStatus = null;
let fleetData = [];
const catNames = { sport: 'Sport / Luxury / Unique', regular: 'Regular Cars', suv: 'SUV / Pickups', van: 'Vans / Bus', truck: 'Trucks / Heavy Duty', moto: 'Motorcycles / Scooters', water: 'Watercraft', air: 'Aircraft / Flying Car', special: 'Special / Test / Props' };
let timeDragging = false;
function fmtClock(totalMin) {
const h = Math.floor(totalMin / 60) % 24;
const m = totalMin % 60;
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
}
function sliderMinutes() { return parseInt(document.getElementById('timeslider').value || '720', 10); }
async function pushTime() {
const total = sliderMinutes();
const body = {
hour: Math.floor(total / 60) % 24,
minute: total % 60,
fix: true,
transition: 5
};
const r = await api('/api/time/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('time: ' + JSON.stringify(r));
}
function syncTimeUI(s) {
const slider = document.getElementById('timeslider');
const clock = document.getElementById('clock');
if (!s.online) {
slider.disabled = true;
return;
}
slider.disabled = false;
if (typeof s.hour === 'number' && typeof s.minute === 'number' && s.hasTime && !timeDragging) {
slider.value = s.hour * 60 + s.minute;
clock.textContent = fmtClock(s.hour * 60 + s.minute);
} else if (!timeDragging) {
clock.textContent = fmtClock(sliderMinutes());
}
}
function log(t) {
const el = document.getElementById('log');
el.textContent = new Date().toLocaleTimeString() + ' ' + t + '\n' + el.textContent;
}
async function api(path, opts) {
const r = await fetch(path, opts);
return await r.json();
}
async function refresh() {
try {
const s = await api('/api/status');
lastStatus = s;
document.getElementById('dot').className = 'dot' + (s.online ? ' on' : '');
if (!s.online) {
document.getElementById('status').textContent = 'client not in game (no live session)';
document.getElementById('summoned').textContent = '—';
syncTimeUI(s);
return;
}
const p = s.player;
const w = s.world || { raidId: '—', instanceId: '—' };
document.getElementById('status').innerHTML =
`pid=${s.pid} ready=${s.ready} unit=${s.activeUnit} raid=${w.raidId} instance=${w.instanceId}<br>` +
`pos=(${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)}) yaw=${p.yaw.toFixed(1)} fix=${p.hasFix}<br>` +
`fleet=${s.fleetCount} summoned=${s.summoned.length}` +
(s.inVehicle ? `<br>in vehicle: entity=${s.inVehicle} seat=${s.inSeat} <button onclick="vehicleExit()">Exit</button>` : '');
document.getElementById('summoned').innerHTML = s.summoned.length
? '<table><tr><th>entityId</th><th>configId</th><th>coordinates</th><th></th></tr>' +
s.summoned.map(v => `<tr><td>${v.entityId}</td><td>${v.configId}</td>` +
`<td>${v.hasFix ? `${v.x.toFixed(1)}, ${v.y.toFixed(1)}, ${v.z.toFixed(1)}` : '—'}</td>` +
`<td><button onclick="vehicleEnter(${v.entityId})">Enter</button> ` +
`<button onclick="vehicleToMe(${v.entityId})">To me</button> ` +
`<button onclick="vehicleGoto(${v.entityId})">To it</button> ` +
`<button class="ghost" onclick="vehicleRemove(${v.entityId})">Remove</button></td></tr>`).join('') + '</table>'
: 'empty — click "Spawn selected vehicle nearby" above';
syncTimeUI(s);
} catch (e) { log('status error: ' + e); }
}
async function loadFleet() {
try {
fleetData = await api('/api/fleet');
const cats = [...new Set(fleetData.map(v => v.cat))];
document.getElementById('fleetcat').innerHTML =
cats.map(c => `<option value="${c}">${catNames[c] || c}</option>`).join('');
filterFleet();
} catch (e) { log('fleet error: ' + e); }
}
function filterFleet() {
const c = document.getElementById('fleetcat').value;
const list = fleetData.filter(v => v.cat === c);
document.getElementById('fleet').innerHTML =
list.map(v => `<option value="${v.id}">${v.id} — ${v.name}</option>`).join('');
}
async function enterLast() {
const r = await api('/api/vehicle/enter', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
log('enter: ' + JSON.stringify(r));
refresh();
}
async function resync() {
const r = await api('/api/garage/resync', { method: 'POST' });
log('resync verified: ' + JSON.stringify(r));
}
async function unlockAll() {
const r = await api('/api/garage/unlock-all', { method: 'POST' });
log('unlock-all: ' + JSON.stringify(r));
}
async function spawnSpecificVehicle(id) {
const r = await api('/api/vehicle/spawn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ configId: id, distance: 6 }) });
log('spawn ' + id + ': ' + JSON.stringify(r));
refresh();
}
async function spawnNear() {
const sel = document.getElementById('fleet');
const body = { configId: parseInt(sel.value || '0', 10), distance: 6 };
const r = await api('/api/vehicle/spawn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('spawn: ' + JSON.stringify(r));
refresh();
}
async function spawnRaw() {
const id = parseInt(document.getElementById('rawId').value || '0', 10);
if (!id) { log('spawnRaw: enter an ID'); return; }
const r = await api('/api/vehicle/spawn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ configId: id, distance: 6 }) });
log('spawnRaw: ' + JSON.stringify(r));
refresh();
}
async function vehicleEnter(entityId) {
const r = await api('/api/vehicle/enter', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entityId }) });
log('enter: ' + JSON.stringify(r));
refresh();
}
async function vehicleExit() {
const r = await api('/api/vehicle/exit', { method: 'POST' });
log('exit: ' + JSON.stringify(r));
refresh();
}
async function vehicleToMe(entityId) {
const r = await api('/api/vehicle/to-me', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entityId }) });
log('to-me: ' + JSON.stringify(r));
}
async function vehicleGoto(entityId) {
const r = await api('/api/vehicle/goto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entityId }) });
log('goto: ' + JSON.stringify(r));
}
async function vehicleRemove(entityId) {
const r = await api('/api/vehicle/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entityId }) });
log('remove: ' + JSON.stringify(r));
refresh();
}
async function teleport() {
const body = {
x: parseFloat(document.getElementById('tx').value),
y: parseFloat(document.getElementById('ty').value),
z: parseFloat(document.getElementById('tz').value),
facing: parseFloat(document.getElementById('tf').value)
};
const r = await api('/api/player/teleport', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('teleport: ' + JSON.stringify(r));
}
function fillCurrent() {
if (!lastStatus || !lastStatus.online) return;
const p = lastStatus.player;
document.getElementById('tx').value = p.x.toFixed(1);
document.getElementById('ty').value = p.y.toFixed(1);
document.getElementById('tz').value = p.z.toFixed(1);
document.getElementById('tf').value = p.yaw.toFixed(1);
}
function fillSpawn() {
if (!lastStatus || !lastStatus.online || !lastStatus.spawn) return;
document.getElementById('tx').value = lastStatus.spawn.x;
document.getElementById('ty').value = lastStatus.spawn.y;
document.getElementById('tz').value = lastStatus.spawn.z;
}
const scenePresets = {
longqi: { raid: 23300999, instance: 20001223, universe: 76000888, x: -4719.8, y: 168.5, z: -2844.7, facing: 0 },
longqi_tea: { raid: 23300999, instance: 20001223, universe: 76000888, x: -4818.4, y: 192.3, z: -2690.1, facing: 117 },
longqi_temple: { raid: 23300999, instance: 20001223, universe: 76000888, x: -4306.0, y: 156.8, z: -3240.8, facing: 133 },
longqi_wukang: { raid: 23300999, instance: 20001223, universe: 76000888, x: 158.7, y: 18.0, z: 1057.2, facing: -48.8 },
maincity: { raid: 23300888, instance: 20001222, universe: 76000888, x: 3142.67, y: 0, z: 2522.78, facing: 0 },
airport: { raid: 23300888, instance: 20001222, universe: 76000888, x: -4468.3, y: -6.15, z: -2597.5, facing: 85.5 }
};
let npcBusy = false;
let currentNpcCategory = 'all';
let npcCatalogData = [];
let npcSearchTimeout = null;
async function loadNpcCatalog(category = 'all', query = '') {
try {
const q = encodeURIComponent(query || '');
const cat = encodeURIComponent(category || 'all');
const r = await api(`/api/npc/catalog?cat=${cat}&q=${q}&limit=150`);
if (r && r.items) {
npcCatalogData = r.items;
renderNpcTable(npcCatalogData);
if (r.poiActions) {
renderPoiSelect(r.poiActions);
}
}
} catch (err) {
console.error('Failed to load NPC catalog:', err);
}
}
function renderPoiSelect(poiList) {
const sel = document.getElementById('npcPoiSelect');
if (!sel) return;
sel.innerHTML = '';
poiList.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `${p.name} [${p.id}]`;
if (p.id === 2) opt.selected = true;
sel.appendChild(opt);
});
}
function renderNpcTable(items) {
const tbody = document.getElementById('npcTableBody');
if (!tbody) return;
if (!items || items.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="padding:12px;text-align:center;color:#888">ไม่พบ Agent ในเงื่อนไขนี้</td></tr>';
return;
}
const catBadges = {
monster: '<span class="badge-cat badge-monster">⚔️ Monster</span>',
citizen: '<span class="badge-cat badge-citizen">🚶 Citizen</span>',
police: '<span class="badge-cat badge-police">👮 Guard</span>',
animal: '<span class="badge-cat badge-animal">🐱 Animal</span>',
ally: '<span class="badge-cat badge-ally">🤝 Ally</span>'
};
tbody.innerHTML = items.map(item => {
const badge = catBadges[item.category] || '<span class="badge-cat">Agent</span>';
const escapedName = (item.name || '').replace(/'/g, "\\'");
return `<tr style="border-bottom:1px solid #222">
<td style="padding:4px 8px">${badge}</td>
<td style="padding:4px 8px;font-family:monospace;color:#4dd0e1;font-weight:bold">${item.id}</td>
<td style="padding:4px 8px">${item.name}</td>
<td style="padding:4px 8px;color:#888;font-family:monospace">${item.model || '-'}</td>
<td style="padding:4px 8px;text-align:center">
<button class="btn-add-npc" onclick="addNpcToInput(${item.id}, '${escapedName}')">➕ Add</button>
</td>
</tr>`;
}).join('');
}
function setNpcCategory(cat) {
currentNpcCategory = cat;
const tabs = document.querySelectorAll('.npc-tab');
tabs.forEach(t => t.classList.remove('active'));
const activeTab = document.querySelector(`.npc-tab[data-cat="${cat}"]`);
if (activeTab) activeTab.classList.add('active');
const q = document.getElementById('npcSearchInput').value.trim();
loadNpcCatalog(currentNpcCategory, q);
}
function onNpcSearchInput() {
if (npcSearchTimeout) clearTimeout(npcSearchTimeout);
npcSearchTimeout = setTimeout(() => {
const q = document.getElementById('npcSearchInput').value.trim();
loadNpcCatalog(currentNpcCategory, q);
}, 250);
}
function addNpcToInput(id, name) {
const textarea = document.getElementById('npcItems');
const poi = document.getElementById('npcPoiSelect').value || '0';
const newLine = `${id}, ${poi}`;
const cur = textarea.value.trim();
if (!cur) {
textarea.value = newLine;
} else {
textarea.value = cur + '\n' + newLine;
}
textarea.scrollTop = textarea.scrollHeight;
log(`Added to spawn queue: ${name} [${id}] (POI: ${poi})`);
}
function clearNpcList() {
document.getElementById('npcItems').value = '';
}
function setNpcPreset(preset) {
const textarea = document.getElementById('npcItems');
if (preset === 'gang') {
textarea.value = '40900579, 0\n40100004, 0\n40100011, 0\n40100034, 0';
} else if (preset === 'citizens') {
textarea.value = '40130709, 2\n40130001, 1\n4065053, 11';
} else if (preset === 'animals') {
textarea.value = '4065051, 0\n40120100, 2\n40120325, 0';
}
}
async function spawnNpcs() {
if (npcBusy) return;
const rawLines = document.getElementById('npcItems').value.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
const items = rawLines.map(line => {
const clean = line.split('//')[0].split('#')[0].trim();
const parts = clean.split(/[\s,;]+/);
return { npcFormworkId: Number(parts[0]), poiActionId: Number(parts[1] || 0) };
}).filter(item => Number.isInteger(item.npcFormworkId) && item.npcFormworkId > 0);
const output = document.getElementById('npcResult');
output.style.display = 'block';
if (!items.length || items.some(item => !Number.isInteger(item.npcFormworkId) || item.npcFormworkId < 1 || item.npcFormworkId > 4294967295 || !Number.isInteger(item.poiActionId) || item.poiActionId < 0 || item.poiActionId > 4294967295)) {
output.textContent = 'Enter valid numeric IDs, one NPC per line.';
return;
}
const xSp = Number(document.getElementById('npcXSpacing').value);
const zSp = Number(document.getElementById('npcZSpacing').value);
const maxPerRow = Number(document.getElementById('npcMaxPerRow').value);
npcBusy = true;
document.getElementById('npcSpawn').disabled = true;
output.textContent = 'Spawning NPCs/Monsters into world...';
try {
const r = await api('/api/npc/spawn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, xSpacing: xSp, zSpacing: zSp, maxPerRow })
});
output.textContent = JSON.stringify(r, null, 2);
log('Spawned ' + items.length + ' entities: ' + (r.ok ? 'SUCCESS' : r.message));
} catch (err) {
output.textContent = 'Spawn request failed: ' + err.message;
} finally {
npcBusy = false;
document.getElementById('npcSpawn').disabled = false;
}
}
async function loadScenes() {
try {
const r = await api('/api/scenes');
const sel = document.getElementById('scenepreset');
if (!sel || !r.presets) return;
sel.innerHTML = '';
r.presets.forEach(p => {
const opt = document.createElement('option');
opt.value = p.key;
opt.text = p.label;
opt.dataset.p = JSON.stringify(p);
sel.appendChild(opt);
});
sel.onchange = onPresetChange;
if (sel.options.length) onPresetChange();
} catch (err) {
console.warn('loadScenes failed:', err);
}
}
function onPresetChange() {
const sel = document.getElementById('scenepreset');
if (!sel || !sel.selectedOptions[0] || !sel.selectedOptions[0].dataset.p) return;
const p = JSON.parse(sel.selectedOptions[0].dataset.p);
document.getElementById('s_raid').value = p.raidId;
document.getElementById('s_instance').value = p.instanceId;
document.getElementById('s_universe').value = p.universeId;
document.getElementById('s_x').value = p.x.toFixed(1);
document.getElementById('s_y').value = p.y.toFixed(1);
document.getElementById('s_z').value = p.z.toFixed(1);
document.getElementById('s_f').value = p.facing.toFixed(1);
}
async function switchToPreset() {
const sel = document.getElementById('scenepreset');
if (sel && sel.value) {
const r = await api('/api/world/switch-scene', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preset: sel.value })
});
log('switch scene preset: ' + JSON.stringify(r));
refresh();
} else {
await switchSceneCustom();
}
}
async function switchSceneCustom() {
const body = {
raidId: parseInt(document.getElementById('s_raid').value || '23300999', 10),
instanceId: parseInt(document.getElementById('s_instance').value || '20001223', 10),
universeId: parseInt(document.getElementById('s_universe').value || '76000888', 10),
x: parseFloat(document.getElementById('s_x').value || '0'),
y: parseFloat(document.getElementById('s_y').value || '0'),
z: parseFloat(document.getElementById('s_z').value || '0'),
facing: parseFloat(document.getElementById('s_f').value || '0')
};
const r = await api('/api/world/switch-scene', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('switch scene: ' + JSON.stringify(r));
refresh();
}
async function unstuckBlackScreen() {
const r = await api('/api/unstuck/blackscreen', { method: 'POST' });
log('unstuck black screen: ' + JSON.stringify(r));
refresh();
}
async function rollback10s() {
const r = await api('/api/player/rollback-10s', { method: 'POST' });
log('rollback 10s: ' + JSON.stringify(r));
refresh();
}
async function toggleClothes() {
const r = await api('/api/player/toggle-clothes', { method: 'POST' });
log('toggle clothes: ' + JSON.stringify(r));
}
async function launchSelectedMinigame() {
const sel = document.getElementById('minigameSelect');
const game = sel ? sel.value : 'KOF97';
const r = await api('/api/minigame/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ game })
});
log('launch minigame (' + game + '): ' + JSON.stringify(r));
}
async function launchMinigameQuick(game) {
const r = await api('/api/minigame/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ game })
});
log('quick launch minigame (' + game + '): ' + JSON.stringify(r));
}
async function closeActiveMinigame() {
const r = await api('/api/minigame/close', { method: 'POST' });
log('close minigame: ' + JSON.stringify(r));
}
async function launchArcadeMinigame() {
await launchSelectedMinigame();
}
async function warpSubway(x, y, z, facing, stationName, endTimeline) {
const r = await api('/api/player/teleport', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: parseFloat(x), y: parseFloat(y), z: parseFloat(z), facing: parseFloat(facing || 0) })
});
log(`🚇 วาร์ปไปยังสถานี (${x}, ${y}, ${z}): ` + JSON.stringify(r));
refresh();
}
async function teleportToBowling() {
await warpSubway(1090.8, 6.5, 2083.7, 0, 'Bowling Alley');
log('🎳 Warped to Bowling Alley at (1090.8, 6.5, 2083.7)');
}
// --- TRANSIT & METRO TRACKER ---
function switchTransitTab(tab) {
const tabs = ['live', 'stations'];
tabs.forEach(t => {
const pane = document.getElementById('transitPane_' + t);
const btn = document.getElementById('tabBtn_' + t);
if (pane) pane.style.display = (t === tab) ? 'block' : 'none';
if (btn) {
if (t === tab) btn.classList.add('active');
else btn.classList.remove('active');
}
});
}
const RAIL_LINES = [
{
id: 1,
name: "Line 1: Bayshore Line (Inner Ring / ตามเข็มนาฬิกา)",
color: "#38bdf8",
interval: 90,
speed: 12,
trainCount: 6,
totalDuration: 1120,
stations: [
{ id: 1, name: "Ivory Cove", cn: "白帆湾", x: 539.6, y: 20.5, z: 1363.8, t: 0 },
{ id: 2, name: "Paradox Plaza", cn: "狂悖街区", x: 777.0, y: 14.7, z: 2427.2, t: 150 },
{ id: 3, name: "Sunny Bay", cn: "晴海", x: 1988.8, y: 19.9, z: 2779.2, t: 320 },
{ id: 4, name: "City Link (Hub)", cn: "城关 (ชุมทาง)", x: 2968.2, y: 42.1, z: 2467.9, t: 490 },
{ id: 5, name: "Legacy Hub", cn: "纪事区", x: 3469.2, y: -17.8, z: 1724.5, t: 660 },
{ id: 6, name: "Floramoon Park", cn: "花月公园", x: 2560.1, y: 33.7, z: 1675.7, t: 820 },
{ id: 7, name: "Freshville East", cn: "东新筑", x: 1088.4, y: 18.5, z: 1875.8, t: 980 }
]
},
{
id: 2,
name: "Line 2: Bayshore Line (Outer Ring / ทวนเข็มนาฬิกา)",
color: "#06b6d4",
interval: 90,
speed: 12,
trainCount: 6,
totalDuration: 1120,
stations: [
{ id: 1, name: "Ivory Cove", cn: "白帆湾", x: 539.6, y: 20.5, z: 1363.8, t: 0 },
{ id: 7, name: "Freshville East", cn: "东新筑", x: 1088.4, y: 18.5, z: 1875.8, t: 140 },
{ id: 6, name: "Floramoon Park", cn: "花月公园", x: 2560.1, y: 33.7, z: 1675.7, t: 300 },
{ id: 5, name: "Legacy Hub", cn: "纪事区", x: 3469.2, y: -17.8, z: 1724.5, t: 460 },
{ id: 4, name: "City Link (Hub)", cn: "城关 (ชุมทาง)", x: 2968.2, y: 42.1, z: 2467.9, t: 630 },
{ id: 3, name: "Sunny Bay", cn: "晴海", x: 1988.8, y: 19.9, z: 2779.2, t: 800 },
{ id: 2, name: "Paradox Plaza", cn: "狂悖街区", x: 777.0, y: 14.7, z: 2427.2, t: 970 }
]
},
{
id: 3,
name: "Line 3: Central Loop Line (สายวนรอบใจกลางเมือง)",
color: "#eab308",
interval: 90,
speed: 12,
trainCount: 4,
totalDuration: 740,
stations: [
{ id: 6, name: "Floramoon Park", cn: "花月公园", x: 2560.1, y: 33.7, z: 1675.7, t: 0 },
{ id: 11, name: "Marine MechWorks", cn: "岸泊工业", x: 1739.0, y: 29.3, z: 1379.2, t: 130 },
{ id: 12, name: "Union Square", cn: "统理院", x: 1397.0, y: -8.5, z: 2151.9, t: 280 },
{ id: 13, name: "Sunny Bay Central", cn: "晴海中央", x: 2003.8, y: 10.3, z: 2712.8, t: 430 },
{ id: 4, name: "City Link (Hub)", cn: "城关 (ชุมทาง)", x: 2968.2, y: 42.1, z: 2467.9, t: 590 }
]
},
{
id: 4,
name: "Line 4: East Loop Line (สายวนรอบเขตธุรกิจตะวันออก)",
color: "#22c55e",
interval: 90,
speed: 10,
trainCount: 4,
totalDuration: 640,
stations: [
{ id: 4, name: "City Link (Hub)", cn: "城关 (ชุมทาง)", x: 2968.2, y: 42.1, z: 2467.9, t: 0 },
{ id: 21, name: "Aerie Bank", cn: "鹰庭银行", x: 3412.4, y: 25.0, z: 2244.6, t: 120 },
{ id: 22, name: "Saturn Tower", cn: "土星塔", x: 3865.9, y: 45.2, z: 1738.3, t: 250 },
{ id: 23, name: "Pop Cool City", cn: "砰酷中心", x: 3629.9, y: 47.6, z: 1302.7, t: 380 },
{ id: 24, name: "Crystal Place", cn: "璃色", x: 3134.9, y: 42.0, z: 1460.4, t: 510 }
]
},
{
id: 9,
name: "Longqi Metro Line 9 (รถไฟใต้ดินเขตหลงฉี สาย 9)",
color: "#a855f7",
interval: 100,
speed: 12,
trainCount: 4,
totalDuration: 700,
stations: [
{ id: 41, name: "Mingchun North", cn: "明春北", x: -503.1, y: -9.1, z: -528.8, t: 0 },
{ id: 42, name: "Cloudstream West", cn: "云河西路", x: -1302.9, y: -23.8, z: -642.3, t: 140 },
{ id: 43, name: "Lakefront Promenade", cn: "临湖步行街", x: -1711.3, y: -24.0, z: -50.5, t: 280 },
{ id: 44, name: "Dinglan Road", cn: "定澜路", x: -1474.2, y: -24.1, z: 785.7, t: 420 },
{ id: 45, name: "Wulin Road", cn: "武林路", x: -1512.1, y: -14.2, z: 1095.9, t: 560 }
]
}
];
function getSelectedRailLine() {
const sel = document.getElementById('railLineSelect');
const lineId = parseInt(sel?.value || '1', 10);
return RAIL_LINES.find(l => l.id === lineId) || RAIL_LINES[0];
}
function onSelectRailLine() {
const line = getSelectedRailLine();
const banner = document.getElementById('lineInfoBanner');
if (banner) {
banner.style.borderLeftColor = line.color;
banner.innerHTML = `
<div style="font-weight:bold;color:${line.color};font-size:13px">${line.name}</div>
<div style="margin-top:4px;display:flex;gap:14px;flex-wrap:wrap">
<span>⏱️ ความถี่ขบวนรถ: <b>${line.interval} วินาที</b></span>
<span>🚄 ความเร็วเฉลี่ย: <b>${line.speed} m/s (~${Math.round(line.speed * 3.6)} km/h)</b></span>
<span>🕒 รอบเวลาทั้งสาย: <b>${Math.round(line.totalDuration / 60)} นาที</b></span>
<span>🚆 จำนวนขบวนให้บริการ: <b>${line.trainCount} ขบวน</b></span>
</div>
`;
}
const routeBox = document.getElementById('routeStationsContainer');
if (routeBox) {
let html = '<div style="display:flex;flex-direction:column;gap:6px">';
line.stations.forEach((st, idx) => {
const isHub = st.name.includes('(Hub)');
html += `
<div style="display:flex;align-items:center;justify-content:space-between;background:#15181f;padding:6px 10px;border-radius:4px;border-left:3px solid ${isHub ? '#f59e0b' : line.color}">
<div style="display:flex;align-items:center;gap:8px">
<span style="background:${line.color};color:#000;font-size:11px;font-weight:bold;padding:2px 6px;border-radius:3px">#${idx + 1}</span>
<span style="font-weight:600;color:#f1f5f9">${st.name}</span>
<span style="color:#94a3b8;font-size:12px">[${st.cn}]</span>
${isHub ? '<span style="background:#b45309;color:#fef3c7;font-size:10px;padding:1px 5px;border-radius:3px;font-weight:bold">GRAND INTERCHANGE</span>' : ''}
</div>
<div style="display:flex;gap:4px">
<button style="background:#0284c7;color:white;font-size:11px;padding:3px 8px" onclick="warpSubway(${st.x}, ${st.y}, ${st.z}, 0, '${st.name}', 'loading_metro_end_1')">🚇 ไปยังสถานีนี้</button>
</div>
</div>
`;
});
html += '</div>';
routeBox.innerHTML = html;
}
refreshTransitTracker();
}
function refreshTransitTracker() {
const line = getSelectedRailLine();
const container = document.getElementById('activeTrainsContainer');
if (!container) return;
const now = Math.floor(Date.now() / 1000);
let html = '';
for (let i = 0; i < line.trainCount; i++) {
const trainNum = (line.id * 100) + (i + 1);
const trainProgress = (now + (i * line.interval)) % line.totalDuration;
let prevSt = line.stations[0];
let nextSt = line.stations[1];
let isAtStation = false;
let dwellLeft = 0;
for (let s = 0; s < line.stations.length; s++) {
const cur = line.stations[s];
const nxt = line.stations[(s + 1) % line.stations.length];
const curT = cur.t;
let nxtT = nxt.t;
if (nxtT <= curT) nxtT += line.totalDuration;
let p = trainProgress;
if (p < curT) p += line.totalDuration;
if (p >= curT && p < nxtT) {
prevSt = cur;
nextSt = nxt;
const dwellTime = 20;
if (p - curT < dwellTime) {
isAtStation = true;
dwellLeft = Math.max(1, Math.round(dwellTime - (p - curT)));
}
break;
}
}
const etaSeconds = Math.max(1, Math.round(
(nextSt.t <= prevSt.t ? nextSt.t + line.totalDuration : nextSt.t) -
(trainProgress < prevSt.t ? trainProgress + line.totalDuration : trainProgress)
));
html += `
<div style="background:#181d26;border:1px solid #334155;border-radius:6px;padding:8px 10px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
<span style="font-weight:bold;color:${line.color}">🚆 ขบวน #${trainNum}</span>
<span style="font-size:11px;color:#94a3b8">ความเร็ว: ${line.speed} m/s</span>
</div>
${isAtStation ? `
<div style="color:#4ade80;font-size:12px;font-weight:600">🟢 จอดเทียบชานชาลาที่ ${prevSt.name}</div>
<div style="color:#cbd5e1;font-size:11px;margin-top:2px">ประตูชานชาลาเปิดอยู่ · ออกเดินทางใน <b>${dwellLeft}s</b></div>
` : `
<div style="color:#38bdf8;font-size:12px;font-weight:600">⏩ กำลังมุ่งหน้าไป ${nextSt.name}</div>
<div style="color:#cbd5e1;font-size:11px;margin-top:2px">ออกจาก ${prevSt.name} · ถึงชานชาลาในอีก <b>${etaSeconds}s</b></div>
`}
</div>
`;
}
container.innerHTML = html;
}
async function setTimePreset(hour, minute) {
const slider = document.getElementById('timeslider');
if (slider) {
slider.value = hour * 60 + minute;
const clock = document.getElementById('clock');
if (clock) clock.textContent = fmtClock(hour * 60 + minute);
}
const body = { hour, minute, fix: true, transition: 5 };
const r = await api('/api/time/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('time preset: ' + JSON.stringify(r));
}
async function setWeather(weatherId) {
const transition = parseInt(document.getElementById('weatherTransition')?.value || '5', 10);
const body = { weatherId: parseInt(weatherId, 10), transition };
const r = await api('/api/weather/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('set weather: ' + JSON.stringify(r));
}
async function setFog(density) {
const body = { density: parseFloat(density) };
const r = await api('/api/weather/fog', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('set fog: ' + JSON.stringify(r));
}
async function setCustomWeather() {
const id = parseInt(document.getElementById('customWeatherId').value || '1', 10);
await setWeather(id);
}
function onTrafficDensitySlider(val) {
document.getElementById('trafficDensityLabel').textContent = val + ' cars';
}
function onCrowdDensitySlider(val) {
document.getElementById('crowdDensityLabel').textContent = val + ' npcs';
}
async function setTrafficDensityPreset(val) {
document.getElementById('trafficDensitySlider').value = val;
onTrafficDensitySlider(val);
await applyTrafficConfig();
}
async function setCrowdDensityPreset(val) {
document.getElementById('crowdDensitySlider').value = val;
onCrowdDensitySlider(val);
await applyCrowdConfig();
}
async function applyTrafficConfig() {
const density = parseInt(document.getElementById('trafficDensitySlider')?.value || '20', 10);
const enabled = density > 0;
const body = { enabled, density, speedScale: 1.0 };
const r = await api('/api/traffic/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('traffic config: ' + JSON.stringify(r));
await refreshTrafficStatus();
}
async function applyCrowdConfig() {
const density = parseInt(document.getElementById('crowdDensitySlider')?.value || '15', 10);
const populateShops = document.getElementById('populateShopsCheck')?.checked ?? true;
const enabled = density > 0 || populateShops;
const body = { enabled, density, populateShops };
const r = await api('/api/crowd/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('crowd config: ' + JSON.stringify(r));
await refreshTrafficStatus();
}
async function trafficLab(key, value) {
const body = {};
body[key] = value;
const r = await api('/api/traffic/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('🧪 traffic ' + key + ' = ' + value + ': ' + JSON.stringify(r));
await refreshTrafficStatus();
}
async function spawnTrafficWave() {
const r = await api('/api/traffic/spawn-wave', { method: 'POST' });
log('spawn wave: ' + JSON.stringify(r));
await refreshTrafficStatus();
}
async function clearAllTraffic() {
const r = await api('/api/traffic/clear', { method: 'POST' });
log('clear traffic: ' + JSON.stringify(r));
await refreshTrafficStatus();
}
async function refreshTrafficStatus() {
try {
const s = await api('/api/traffic/status');
if (s && s.ok) {
if (s.traffic) {
document.getElementById('trafficActiveCount').textContent = s.traffic.activeVehicles;
document.getElementById('trafficRoadCount').textContent = (s.traffic.totalRoadLanes || 42128).toLocaleString() + ' road lanes';
document.getElementById('trafficEngineStatus').textContent = s.traffic.enabled ? 'Running 🟢' : 'Disabled 🔴';
document.getElementById('trafficEngineStatus').style.color = s.traffic.enabled ? '#4ade80' : '#f87171';
}
if (s.crowd) {
document.getElementById('crowdActiveCount').textContent = s.crowd.activeCrowdCount;
document.getElementById('crowdShopsCount').textContent = s.crowd.populatedShopsCount + ' / 1,207 shops populated';
}
}
} catch (e) { }
}
let weaponData = [];
let itemData = [];
async function loadCatalogs() {
try {
const roster = await api('/api/catalog/spirits');
if (Array.isArray(roster)) {
document.getElementById('wpnSpirit').innerHTML =
roster.map(s => `<option value="${s.templateId}">${s.templateId} - ${s.name}</option>`).join('');
}
weaponData = await api('/api/catalog/weapons');
if (Array.isArray(weaponData)) renderWeapons();
const itemsRes = await api('/api/catalog/items?limit=5000');
itemData = (itemsRes && itemsRes.items) ? itemsRes.items : [];
renderItems();
} catch (e) {
log('catalog load error: ' + e);
}
}
function renderWeapons() {
const q = (document.getElementById('wpnFilter').value || '').toLowerCase();
const list = (weaponData || []).filter(w => !q || (w.name || '').toLowerCase().includes(q) || String(w.templateId).includes(q)).slice(0, 400);
document.getElementById('wpnList').innerHTML = list.length
? list.map(w => `<label><input type="radio" name="wpn" value="${w.templateId}"> ` +
`${w.templateId} - ${w.name}` +
(w.isPrivate ? ' <span class="muted">[Exclusive]</span>' : '') +
(w.isShoot ? ' <span class="muted">[Firearm]</span>' : '') + `</label>`).join('')
: '<span class="muted">ไม่พบอาวุธที่ค้นหา</span>';
}
function itemLabel(i) {
const n = (i.name || '').trim();
return (n.length ? n : '(Unnamed Item)').replace(/</g, '&lt;');
}
function filterItemCache(q) {
const k = (q || '').toLowerCase();
return (itemData || []).filter(i =>
!k || (i.name || '').toLowerCase().includes(k) || String(i.templateId).includes(k)).slice(0, 300);
}
function renderItems() {
const q = (document.getElementById('itemFilter').value || '').trim();
const box = document.getElementById('itemList');
const paint = list => {
box.innerHTML = (list && list.length)
? list.map(i => `<label style="cursor:pointer">` +
`<button class="ghost" style="padding:1px 7px;margin-right:6px" ` +
`onclick="event.preventDefault();giveItem(${i.templateId})">ส่ง</button>` +
`<span onclick="pickItemId(${i.templateId})">${i.templateId} - ${itemLabel(i)}</span></label>`).join('')
: '<span class="muted">ไม่พบไอเทมที่ค้นหา</span>';
};
if (q) {
api('/api/catalog/items?limit=300&q=' + encodeURIComponent(q))
.then(r => paint((r && r.items) ? r.items : []))
.catch(() => paint(filterItemCache(q)));
return;
}
paint(filterItemCache(''));
}
function pickItemId(id) {
document.getElementById('giveId').value = id;
log('เลือกไอเทม ID: ' + id);
}
async function giveItem(id) {
const templateId = id || parseInt(document.getElementById('giveId').value || '0', 10);
if (!templateId) { log('กรุณาระบุ Item Template ID'); return; }
const count = Math.max(1, parseInt(document.getElementById('giveCount').value || '1', 10) || 1);
const bind = document.getElementById('giveBind').checked;
try {
const r = await api('/api/item/give', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templateId, count, bind })
});
log('🎁 เสกไอเทม ' + templateId + ' x' + count + ': ' + (r.ok ? r.message : 'ล้มเหลว - ' + (r.error || '')));
} catch (e) {
log('เสกไอเทมล้มเหลว: ' + e);
}
}
async function grantGachaTickets() {
const r = await api('/api/gacha/grant-tickets', { method: 'POST' });
log('🎟️ ตั๋วกาชา: ' + JSON.stringify(r));
}
async function equipSelected() {
const pick = document.querySelector('input[name=wpn]:checked');
if (!pick) { log('กรุณาเลือกอาวุธก่อนกดสวมใส่'); return; }
const body = {
spiritId: parseInt(document.getElementById('wpnSpirit').value || '0', 10),
slotIndex: parseInt(document.getElementById('wpnSlot').value || '1', 10),
templateId: parseInt(pick.value, 10)
};
try {
const r = await api('/api/armory/equip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
log('⚔️ สวมใส่อาวุธ: ' + (r.ok ? r.message : 'ล้มเหลว - ' + (r.error || '')));
} catch (e) {
log('สวมใส่อาวุธล้มเหลว: ' + e);
}
}
loadFleet();
loadScenes();
loadNpcCatalog();
loadCatalogs();
refresh();
setInterval(refresh, 2000);
refreshTrafficStatus();
setInterval(refreshTrafficStatus, 2500);
onSelectRailLine();
setInterval(refreshTransitTracker, 1000);
document.getElementById('timeslider').addEventListener('input', e => {
timeDragging = true;
document.getElementById('clock').textContent = fmtClock(parseInt(e.target.value || '720', 10));
});
document.getElementById('timeslider').addEventListener('change', async () => {
timeDragging = false;
await pushTime();
refresh();
});
</script>
<script>
(function () {
var btn = document.getElementById('themeToggle');
if (!btn) return;
var saved = localStorage.getItem('panel-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
btn.textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☽';
btn.addEventListener('click', function () {
var el = document.documentElement;
var next = el.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
el.setAttribute('data-theme', next);
localStorage.setItem('panel-theme', next);
btn.textContent = next === 'dark' ? '☀' : '☽';
});
})();
</script>
</body>
</html>