1471 lines
81 KiB
HTML
1471 lines
81 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Debug Panel — vehicles</title>
|
||
<style>
|
||
body { font-family: Consolas, monospace; background: #14161a; color: #d7dce2; max-width: 860px; margin: 24px auto; padding: 0 16px; }
|
||
h1 { font-size: 20px; }
|
||
h2 { font-size: 15px; color: #8fb8ff; border-bottom: 1px solid #333; padding-bottom: 4px; }
|
||
.card { background: #1d2026; border: 1px solid #333; border-radius: 8px; padding: 12px 16px; margin: 12px 0; }
|
||
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #666; margin-right: 6px; }
|
||
.dot.on { background: #4caf50; }
|
||
button { background: #2b6cb0; color: #fff; border: 0; border-radius: 6px; padding: 8px 14px; margin: 4px 4px 4px 0; cursor: pointer; font-family: inherit; }
|
||
button:hover { background: #3884d6; }
|
||
button.ghost { background: #3a3f47; }
|
||
input, select { background: #101215; color: #d7dce2; border: 1px solid #444; border-radius: 6px; padding: 7px 9px; margin: 2px; font-family: inherit; }
|
||
input[type=number] { width: 110px; }
|
||
input[type=range] { width: 100%; margin: 10px 0 2px 0; accent-color: #ffb74d; cursor: pointer; }
|
||
#clock { font-size: 30px; letter-spacing: 2px; color: #ffd88a; }
|
||
table { border-collapse: collapse; width: 100%; margin-top: 8px; }
|
||
td, th { border: 1px solid #333; padding: 4px 8px; text-align: left; font-size: 13px; }
|
||
.muted { color: #888; font-size: 12px; }
|
||
#log { white-space: pre-wrap; font-size: 12px; max-height: 160px; overflow-y: auto; }
|
||
.npc-tab { background: #1e293b; color: #94a3b8; border: 1px solid #334155; border-radius: 4px; padding: 5px 12px; font-size: 12px; cursor: pointer; transition: 0.15s; }
|
||
.npc-tab:hover { background: #334155; color: #fff; }
|
||
.npc-tab.active { background: #00838f; color: #fff; border-color: #00e5ff; font-weight: bold; }
|
||
.btn-add-npc { background: #2e7d32; color: #fff; border: 0; border-radius: 4px; padding: 3px 8px; font-size: 11px; cursor: pointer; font-weight: bold; }
|
||
.btn-add-npc:hover { background: #388e3c; }
|
||
.badge-cat { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: bold; display: inline-block; }
|
||
.badge-monster { background: #5c1d1d; color: #ff8a80; border: 1px solid #b71c1c; }
|
||
.badge-citizen { background: #1a365d; color: #90cdf4; border: 1px solid #2b6cb0; }
|
||
.badge-police { background: #3e2723; color: #ffcc80; border: 1px solid #795548; }
|
||
.badge-animal { background: #1b4332; color: #95d5b2; border: 1px solid #2d6a4f; }
|
||
.badge-ally { background: #4a148c; color: #e1bee7; border: 1px solid #8e24aa; }
|
||
.picklist { max-height: 180px; overflow-y: auto; border: 1px solid #333; border-radius: 6px; padding: 6px; margin-top: 6px; background: #101215; }
|
||
.picklist label { display: block; padding: 3px 0; font-size: 12px; cursor: pointer; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1><span id="dot" class="dot"></span>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 · 06:00 — morning · 12:00 — noon · 18:00 — evening · 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="🔍 ค้นหา (พิมพ์ชื่อ เช่น 谜面, Nanami, เป็ด, สุนัข หรือเลข 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>
|
||
|
||
<div style="margin-bottom:12px;padding:8px 12px;background:#1e293b;border-radius:6px;display:flex;align-items:center;gap:10px;border:1px solid #3b82f6">
|
||
<input id="autoSubwayCutscene" type="checkbox" checked style="width:18px;height:18px;accent-color:#38bdf8;cursor:pointer">
|
||
<label for="autoSubwayCutscene" style="font-size:13px;color:#f0f9ff;cursor:pointer;font-weight:600">
|
||
🎬 เล่นคัทซีนรถไฟลอดอุโมงค์อัตโนมัติ (เล่นฉากนั่งรถไฟ -> ลอดอุโมงค์ -> โผล่ออกที่อุโมงค์สถานีปลายทาง)
|
||
</label>
|
||
</div>
|
||
|
||
<!-- 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>
|
||
<button id="tabBtn_cinematic" class="npc-tab" onclick="switchTransitTab('cinematic')">🎬 ฉากคัทซีนรถไฟใต้ดิน (Metro Cutscenes)</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>
|
||
|
||
<!-- TAB 3: Metro Transit Cutscenes -->
|
||
<div id="transitPane_cinematic" style="display:none">
|
||
<div style="font-size:13px;color:#e2e8f0;margin-bottom:8px">
|
||
ฉากคัทซีนเหล่านี้คือคัทซีนจริงของตัวเกม (Timeline Sequence) ที่เล่นระหว่างนั่งรถไฟข้ามเขตเมือง หรือตอนเทียบชานชาลาสถานี:
|
||
</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||
<button style="background:#2563eb;color:white" onclick="playCutsceneDirect('loading_metro_normal01_m')">🎬 Male MC Riding Metro (พระเอกนั่งในรถไฟ)</button>
|
||
<button style="background:#db2777;color:white" onclick="playCutsceneDirect('loading_metro_normal01_f')">🎬 Female MC Riding Metro (นางเอกนั่งในรถไฟ)</button>
|
||
<button style="background:#475569;color:white" onclick="playCutsceneDirect('loading_metro_normal02')">🎬 Passenger View (บรรยากาศตู้โดยสาร 2)</button>
|
||
<button style="background:#0284c7;color:white" onclick="playCutsceneDirect('loading_metro_high01')">🎬 High-Angle View (วิวมุมสูงหน้าต่างรถไฟ)</button>
|
||
<button style="background:#0284c7;color:white" onclick="playCutsceneDirect('loading_metro_middle01')">🎬 Middle-Angle View (วิวมุมระดับสายตา)</button>
|
||
<button style="background:#0284c7;color:white" onclick="playCutsceneDirect('loading_metro_low01')">🎬 Low-Angle Track View (วิวมุมต่ำรางรถไฟ)</button>
|
||
<button style="background:#16a34a;color:white" onclick="playCutsceneDirect('loading_metro_end_1')">🎬 Station Arrival & Exit (รถไฟเทียบชานชาลา & เดินออก)</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="border-color:#00bcd4">
|
||
<h2 style="color:#4dd0e1">🎬 3D Cinematics & Story Timelines (ฉากคัทซีนเนื้อเรื่อง)</h2>
|
||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||
<select id="cutsceneSelect" style="flex:1;min-width:260px;background:#263238;color:#eee;border:1px solid #37474f;border-radius:4px;padding:6px">
|
||
<option value="tl_seymour01">Seymour Story Chapter 1 (赛默 - 计划外响应 01)</option>
|
||
<option value="tl_seymour02">Seymour Story Chapter 2 (赛默 - 计划外响应 02)</option>
|
||
<option value="tl_seymour03">Seymour Story Chapter 3 (赛默 - 计划外响应 03)</option>
|
||
<option value="tl_seym_010_q010_s01">Seymour Story Quest S01 (面具之下,监视之中)</option>
|
||
<option value="tl_taffy01">Taffy Story: High-Speed Moment 01 (塔菲 - 极速时刻 01)</option>
|
||
<option value="tl_taffy02">Taffy Story: High-Speed Moment 02 (塔菲 - 极速时刻 02)</option>
|
||
<option value="tl_richiet01">Richie Story: Holiday Syndrome 01 (里希 - 假期综合征 01)</option>
|
||
<option value="switchchar_tafei_01">Taffy Cinematic Arrival (ทาฟี่ โดดจากตึก)</option>
|
||
<option value="switchchar_dila_03">Dila Combat Cinematic (ดิลา แอกชันสตรีท)</option>
|
||
<option value="switchchar_saimo_05">Seymour Street Arrival (เซย์มัวร์ สไตล์คูล)</option>
|
||
<option value="switchchar_lixi_04">Richie Motorcycle Rush (ริชชี่ ซิ่งมอเตอร์ไซค์)</option>
|
||
<option value="switchchar_nanzhujue_02">Male MC Arrival A (พระเอก สลับตัว A)</option>
|
||
<option value="switchchar_nanzhujue_03">Male MC Arrival B (พระเอก สลับตัว B)</option>
|
||
<option value="switchchar_common_01">Metropolis Skyline Overview (มุมกล้องเมือง 1)</option>
|
||
<option value="switchchar_common_04">Street Parkour Traverse (มุมกล้องฟรีรันนิง 4)</option>
|
||
<option value="switchchar_common_10">Night Neon Lights (มุมกล้องวิวกลางคืน 10)</option>
|
||
<option value="ananta_openning">Ananta Main Opening Cinematic (ฉากเปิดตัว Ananta โลกใหม่)</option>
|
||
<option value="alienfight">Alien Combat Cinematic (ฉากต่อสู้เอเลี่ยนสุดอลังการ)</option>
|
||
</select>
|
||
<button style="background:#00838f;color:white" onclick="playSelectedCutscene()">Play Cinematic / เล่นคัทซีน</button>
|
||
</div>
|
||
<div class="muted" style="margin-top:6px">สั่งเล่นฉาก Cinematic Timeline 3D ภายใน Engine ของเกมทันทีแบบเรียลไทม์</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>
|
||
</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 && document.getElementById('npcPoiSelect').children.length <= 1) {
|
||
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 loadCutsceneCatalog() {
|
||
const sel = document.getElementById('cutsceneSelect');
|
||
if (!sel) return;
|
||
try {
|
||
const res = await api('/api/cutscene/catalog');
|
||
if (res && res.items && res.items.length > 0) {
|
||
const groups = {
|
||
story: { label: '🎬 เนื้อเรื่องหลัก & เควสต์ (Main Story & Quests)', items: [] },
|
||
character: { label: '🌟 แอนิเมชันเปิดตัวตัวละคร (Character Entrances & Specials)', items: [] },
|
||
urban: { label: '🏙️ ฉากชีวิตเมือง & กิจกรรม (Urban Life & Activities)', items: [] },
|
||
transit: { label: '✈️ ทรานซิชันโลก & ยานพาหนะ (World & Vehicle Transitions)', items: [] }
|
||
};
|
||
res.items.forEach(it => {
|
||
const cat = groups[it.category] ? it.category : 'character';
|
||
groups[cat].items.push(it);
|
||
});
|
||
let html = '';
|
||
for (const k in groups) {
|
||
const g = groups[k];
|
||
if (g.items.length > 0) {
|
||
html += `<optgroup label="${g.label}">`;
|
||
g.items.forEach(it => {
|
||
html += `<option value="${it.id}" data-config-id="${it.configId || 0}">${it.name}</option>`;
|
||
});
|
||
html += `</optgroup>`;
|
||
}
|
||
}
|
||
sel.innerHTML = html;
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load cutscene catalog:', err);
|
||
}
|
||
}
|
||
|
||
async function playSelectedCutscene() {
|
||
const sel = document.getElementById('cutsceneSelect');
|
||
const opt = sel ? sel.options[sel.selectedIndex] : null;
|
||
const name = sel ? sel.value : 'TL_SEYM_010_Q010_S01';
|
||
const configId = opt ? parseInt(opt.getAttribute('data-config-id') || '0', 10) : 0;
|
||
const r = await api('/api/timeline/play', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, configId })
|
||
});
|
||
log('play cutscene: ' + 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) {
|
||
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(`🚇 Warp to Subway (${x}, ${y}, ${z}): ` + JSON.stringify(r));
|
||
refresh();
|
||
}
|
||
|
||
async function teleportToBowling() {
|
||
await warpSubway(1090.8, 6.5, 2083.7, 0);
|
||
log('🎳 Warped to Bowling Alley at (1090.8, 6.5, 2083.7)');
|
||
}
|
||
|
||
// --- TRANSIT & METRO TRACKER ---
|
||
function switchTransitTab(tab) {
|
||
const tabs = ['live', 'stations', 'cinematic'];
|
||
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');
|
||
}
|
||
});
|
||
}
|
||
|
||
async function playCutsceneDirect(tlName) {
|
||
const r = await api('/api/timeline/play', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: tlName })
|
||
});
|
||
log(`🎬 Played Metro Cutscene (${tlName}): ` + JSON.stringify(r));
|
||
}
|
||
|
||
async function rideMetroWithCinematic(x, y, z, stationName) {
|
||
log(`🚇 กำลังขึ้นรถไฟเพื่อมุ่งหน้าไปยังสถานี ${stationName}... เริ่มเล่นคัทซีนเดินทาง`);
|
||
await api('/api/timeline/play', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'loading_metro_normal01_m' })
|
||
});
|
||
setTimeout(async () => {
|
||
await warpSubway(x, y, z, 0);
|
||
await api('/api/timeline/play', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'loading_metro_end_1' })
|
||
});
|
||
log(`🚇 เดินทางถึงสถานี ${stationName} เรียบร้อยแล้ว! (ชานชาลาเปิดประตู)`);
|
||
}, 2500);
|
||
}
|
||
|
||
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:#2563eb;color:white;font-size:11px;padding:3px 8px" onclick="warpSubway(${st.x}, ${st.y}, ${st.z}, 0)">⚡ วาร์ปด่วน</button>
|
||
<button style="background:#059669;color:white;font-size:11px;padding:3px 8px" onclick="rideMetroWithCinematic(${st.x}, ${st.y}, ${st.z}, '${st.name}')">🎬 ขึ้นรถไฟ</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 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, '<');
|
||
}
|
||
|
||
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 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();
|
||
loadCutsceneCatalog();
|
||
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>
|
||
</body>
|
||
</html>
|