2026-09-15 18:13:46 +07:00
<!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 : 860 px ; margin : 24 px auto ; padding : 0 16 px ; }
h1 { font-size : 20 px ; }
h2 { font-size : 15 px ; color : #8fb8ff ; border-bottom : 1 px solid #333 ; padding-bottom : 4 px ; }
. card { background : #1d2026 ; border : 1 px solid #333 ; border-radius : 8 px ; padding : 12 px 16 px ; margin : 12 px 0 ; }
. dot { display : inline-block ; width : 10 px ; height : 10 px ; border-radius : 50 % ; background : #666 ; margin-right : 6 px ; }
. dot . on { background : #4caf50 ; }
button { background : #2b6cb0 ; color : #fff ; border : 0 ; border-radius : 6 px ; padding : 8 px 14 px ; margin : 4 px 4 px 4 px 0 ; cursor : pointer ; font-family : inherit ; }
button : hover { background : #3884d6 ; }
button . ghost { background : #3a3f47 ; }
input , select { background : #101215 ; color : #d7dce2 ; border : 1 px solid #444 ; border-radius : 6 px ; padding : 7 px 9 px ; margin : 2 px ; font-family : inherit ; }
input [ type = number ] { width : 110 px ; }
input [ type = range ] { width : 100 % ; margin : 10 px 0 2 px 0 ; accent-color : #ffb74d ; cursor : pointer ; }
# clock { font-size : 30 px ; letter-spacing : 2 px ; color : #ffd88a ; }
table { border-collapse : collapse ; width : 100 % ; margin-top : 8 px ; }
td , th { border : 1 px solid #333 ; padding : 4 px 8 px ; text-align : left ; font-size : 13 px ; }
. muted { color : #888 ; font-size : 12 px ; }
# log { white-space : pre-wrap ; font-size : 12 px ; max-height : 160 px ; overflow-y : auto ; }
2026-09-17 22:58:11 +07:00
. npc-tab { background : #1e293b ; color : #94a3b8 ; border : 1 px solid #334155 ; border-radius : 4 px ; padding : 5 px 12 px ; font-size : 12 px ; cursor : pointer ; transition : 0.15 s ; }
. 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 : 4 px ; padding : 3 px 8 px ; font-size : 11 px ; cursor : pointer ; font-weight : bold ; }
. btn-add-npc : hover { background : #388e3c ; }
. badge-cat { font-size : 10 px ; padding : 2 px 6 px ; border-radius : 3 px ; font-weight : bold ; display : inline-block ; }
. badge-monster { background : #5c1d1d ; color : #ff8a80 ; border : 1 px solid #b71c1c ; }
. badge-citizen { background : #1a365d ; color : #90cdf4 ; border : 1 px solid #2b6cb0 ; }
. badge-police { background : #3e2723 ; color : #ffcc80 ; border : 1 px solid #795548 ; }
. badge-animal { background : #1b4332 ; color : #95d5b2 ; border : 1 px solid #2d6a4f ; }
. badge-ally { background : #4a148c ; color : #e1bee7 ; border : 1 px solid #8e24aa ; }
2026-09-15 18:13:46 +07:00
</ 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 >
2026-09-17 00:59:06 +07:00
< 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 >
2026-09-15 18:13:46 +07:00
</ div >
2026-09-16 18:35:25 +07:00
< div class = "card" >
< h2 > Weather / Atmosphere (สภาพอากาศ)</ h2 >
< div >
2026-09-17 00:59:06 +07:00
< 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 >
2026-09-16 18:35:25 +07:00
</ div >
< div style = "margin-top:8px" >
2026-09-17 00:59:06 +07:00
Custom Weather ID: < input id = "customWeatherId" type = "number" style = "width:90px" value = "1" min = "1" max = "5" >
2026-09-16 18:35:25 +07:00
Transition (sec): < input id = "weatherTransition" type = "number" style = "width:70px" value = "5" >
< button class = "ghost" onclick = "setCustomWeather()" > Apply Custom Weather</ button >
</ div >
2026-09-17 00:59:06 +07:00
< div class = "muted" style = "margin-top:6px" > ซิงค์สภาพอากาศของโลก (WeatherConfig 1..5) และบรรยากาศ client ทันที</ div >
2026-09-16 18:35:25 +07:00
</ div >
2026-09-15 18:13:46 +07:00
< 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 >
2026-09-15 20:29:33 +07:00
< button style = "background:#e91e63" onclick = "spawnSpecificVehicle(81007104)" > 🏍️ Shikage Freeman800 (Honda NT1100) [81007104]</ button >
2026-09-15 18:13:46 +07:00
< button style = "background:#00897b" onclick = "spawnSpecificVehicle(81007007)" > 🚁 Reed Ranger Helicopter [81007007]</ button >
< button style = "background:#f4511e" onclick = "spawnSpecificVehicle(81006011)" > 🏍️ Sunset M125 Superbike [81006011]</ button >
2026-09-15 20:29:33 +07:00
< div style = "font-size:11px;color:#81c784;margin-top:6px" > 💡 หมายเหตุ: รถล้อเดียวของ Taffy (塔菲独轮车) เป็นสกิลกดขี่ของตัวละคร Taffy (กด E / Active Skill) ให้สลับเป็นตัวละคร Taffy แล้วกด E เพื่อแปลงค้อนเป็นรถล้อเดียวขี่ได้ทันที</ div >
2026-09-15 18:13:46 +07:00
</ 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 >
2026-09-17 22:38:58 +07:00
< div class = "card" style = "border-color:#00bcd4" >
2026-09-17 22:58:11 +07:00
< 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 >
2026-09-17 22:38:58 +07:00
</ div >
2026-09-17 22:58:11 +07:00
< 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 >
2026-09-17 22:38:58 +07:00
</ div >
2026-09-15 18:13:46 +07:00
< div class = "card" >
< h2 > Scene ("raid") switch</ h2 >
< div style = "margin-bottom:8px" >
Preset:
< select id = "scenepreset" onchange = "onPresetChange()" style = "min-width:380px" >
2026-09-17 22:38:58 +07:00
< option value = "loading" > Loading scene presets...</ option >
2026-09-15 18:13:46 +07:00
</ 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 >
2026-09-18 16:11:34 +07:00
< div class = "card" style = "border-color:#3b82f6" >
2026-09-18 17:38:46 +07:00
< 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 >
< button id = "tabBtn_cinematic" class = "npc-tab" onclick = "switchTransitTab('cinematic')" > 🎬 ฉากคัทซีนรถไฟใต้ดิน (Metro Cutscenes)</ button >
2026-09-18 00:54:40 +07:00
</ div >
2026-09-18 17:38:46 +07:00
<!-- 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" > 💡 คลิก < b > ⚡ วาร์ปด่วน</ b > เพื่อไปที่ชานชาลาทันที หรือคลิก < b > 🎬 ขึ้นรถไฟ</ b > เพื่อเล่นคัทซีนนั่งในตู้โดยสารก่อนวาร์ปไปถึง</ 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</ button >
< button onclick = "warpSubway(898.4, -1.1, 1558.2, 51)" > Freshville</ button >
< button onclick = "warpSubway(1878.8, -1.1, 2250.5, 0)" > Glimmergold</ button >
< button onclick = "warpSubway(2748.3, -1.1, 2046.6, 0)" > Memorial Park</ button >
< button onclick = "warpSubway(3159.0, -1.1, 2643.0, 0)" > Azure North</ button >
< button onclick = "warpSubway(3661.7, -1.0, 1286.9, 0)" > Future Port</ button >
< button onclick = "warpSubway(2243.6, -1.1, 1616.9, 0)" > Coastal Station</ button >
< button onclick = "warpSubway(3941.7, -1.1, 1974.1, 0)" > Vista Station</ button >
< button onclick = "warpSubway(1232.9, -1.1, 2213.1, 0)" > Veluxity Station</ button >
< button onclick = "warpSubway(620.2, -1.0, 2072.1, 0)" > Paradox Plaza</ button >
< button onclick = "warpSubway(1383.1, -1.1, 1267.7, 0)" > Marine MechWorks</ button >
< button onclick = "warpSubway(2947.4, -1.1, 1030.3, 0)" > 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</ button >
< button class = "ghost" onclick = "warpSubway(-1492.5, 17.6, 1107.9, 0)" > Zhaohua Road</ button >
< button class = "ghost" onclick = "warpSubway(-1431.7, 18.0, 565.0, 0)" > Dinglan Road</ button >
< button class = "ghost" onclick = "warpSubway(-1725.5, 17.5, 12.1, 0)" > Lakeside Pedestrian</ button >
< button class = "ghost" onclick = "warpSubway(-1002.5, 17.7, 115.3, 0)" > Zenith Center</ button >
< button class = "ghost" onclick = "warpSubway(-1788.4, 17.5, -574.6, 0)" > Lakeside South</ button >
< button class = "ghost" onclick = "warpSubway(-844.0, 17.5, 1133.7, 0)" > Great Ferry Port</ button >
< button class = "ghost" onclick = "warpSubway(-624.4, 17.6, 660.4, 0)" > Xinfeng Road</ button >
< button class = "ghost" onclick = "warpSubway(61.6, 17.5, 778.3, 0)" > Yaojin Road</ button >
< button class = "ghost" onclick = "warpSubway(375.8, 17.5, 98.4, 0)" > Yaojin Pedestrian</ button >
< button class = "ghost" onclick = "warpSubway(-427.6, 17.5, 354.0, 0)" > Mingchun North</ button >
< button class = "ghost" onclick = "warpSubway(-2442.7, 17.5, 1015.6, 0)" > Lakefront Institute</ button >
< button class = "ghost" onclick = "warpSubway(-1138.2, 17.7, -602.3, 0)" > 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 >
2026-09-18 16:11:34 +07:00
</ div >
2026-09-18 00:54:40 +07:00
</ 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" >
2026-09-18 16:11:34 +07:00
< 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 >
2026-09-18 00:54:40 +07:00
</ 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" >
2026-09-18 13:45:27 +07:00
< 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)" >
2026-09-18 16:11:34 +07:00
< option value = "PIANO" > 🎹 Grand Piano (เล่นเปียโนสด - เสกตู้เปียโนตรงหน้า)</ option >
2026-09-18 13:45:27 +07:00
< 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 >
2026-09-18 16:11:34 +07:00
< button style = "background:#059669;color:white;font-weight:600;padding:8px 14px" onclick = "teleportToBowling()" > 🎳 Warp to Bowling Alley (วาร์ปไปลานโบว์ลิ่ง)</ button >
2026-09-18 13:45:27 +07:00
</ 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 >
2026-09-18 16:11:34 +07:00
< button style = "background:#059669;color:white;font-size:12px;padding:4px 8px" onclick = "teleportToBowling()" > 🎳 Bowling Alley</ button >
2026-09-18 13:45:27 +07:00
< button style = "background:#059669;color:white;font-size:12px;padding:4px 8px" onclick = "launchMinigameQuick('BASKETBALL')" > 🏀 Basketball</ button >
2026-09-16 02:19:44 +07:00
</ div >
2026-09-18 16:11:34 +07:00
< div class = "muted" style = "margin-top:6px" > เปิดตู้เกมอาร์เคด (KOF '97, Metal Slug), มินิเกมดนตรีสด (เสกโมเดลเปียโนตรงหน้า), หรือวาร์ปไปลานโบว์ลิ่งจริงใน Nova City</ div >
2026-09-16 02:19:44 +07:00
</ div >
2026-09-18 14:19:52 +07:00
< 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 >
2026-09-15 18:13:46 +07:00
< 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 }
};
2026-09-17 22:38:58 +07:00
let npcBusy = false ;
2026-09-17 22:58:11 +07:00
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' ;
}
}
2026-09-17 22:38:58 +07:00
async function spawnNpcs () {
if ( npcBusy ) return ;
2026-09-17 22:58:11 +07:00
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,;]+/ );
2026-09-17 22:38:58 +07:00
return { npcFormworkId : Number ( parts [ 0 ]), poiActionId : Number ( parts [ 1 ] || 0 ) };
2026-09-17 22:58:11 +07:00
}). filter ( item => Number . isInteger ( item . npcFormworkId ) && item . npcFormworkId > 0 );
2026-09-17 22:38:58 +07:00
const output = document . getElementById ( 'npcResult' );
2026-09-17 22:58:11 +07:00
output . style . display = 'block' ;
2026-09-17 22:38:58 +07:00
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 ;
2026-09-17 22:58:11 +07:00
output . textContent = 'Spawning NPCs/Monsters into world...' ;
2026-09-17 22:38:58 +07:00
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 );
2026-09-17 22:58:11 +07:00
log ( 'Spawned ' + items . length + ' entities: ' + ( r . ok ? 'SUCCESS' : r . message ));
2026-09-17 22:38:58 +07:00
} 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 );
}
}
2026-09-15 18:13:46 +07:00
function onPresetChange () {
2026-09-17 22:38:58 +07:00
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 );
2026-09-15 18:13:46 +07:00
}
2026-09-17 22:38:58 +07:00
2026-09-15 18:13:46 +07:00
async function switchToPreset () {
2026-09-17 22:38:58 +07:00
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 ();
}
2026-09-15 18:13:46 +07:00
}
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 ();
}
2026-09-16 02:19:44 +07:00
async function toggleClothes () {
const r = await api ( '/api/player/toggle-clothes' , { method : 'POST' });
log ( 'toggle clothes: ' + JSON . stringify ( r ));
}
2026-09-18 01:22:10 +07:00
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 );
}
}
2026-09-18 00:54:40 +07:00
async function playSelectedCutscene () {
const sel = document . getElementById ( 'cutsceneSelect' );
2026-09-18 01:22:10 +07:00
const opt = sel ? sel . options [ sel . selectedIndex ] : null ;
2026-09-18 00:54:40 +07:00
const name = sel ? sel . value : 'TL_SEYM_010_Q010_S01' ;
2026-09-18 01:22:10 +07:00
const configId = opt ? parseInt ( opt . getAttribute ( 'data-config-id' ) || '0' , 10 ) : 0 ;
2026-09-18 00:54:40 +07:00
const r = await api ( '/api/timeline/play' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
2026-09-18 01:22:10 +07:00
body : JSON . stringify ({ name , configId })
2026-09-18 00:54:40 +07:00
});
log ( 'play cutscene: ' + JSON . stringify ( r ));
}
2026-09-18 13:45:27 +07:00
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 ));
}
2026-09-18 00:54:40 +07:00
async function launchArcadeMinigame () {
2026-09-18 13:45:27 +07:00
await launchSelectedMinigame ();
2026-09-18 00:54:40 +07:00
}
2026-09-18 16:11:34 +07:00
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)' );
}
2026-09-18 17:38:46 +07:00
// --- 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 ;
}
2026-09-17 00:59:06 +07:00
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 ));
}
2026-09-16 18:35:25 +07:00
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 ));
}
2026-09-17 00:59:06 +07:00
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 ));
}
2026-09-16 18:35:25 +07:00
async function setCustomWeather () {
const id = parseInt ( document . getElementById ( 'customWeatherId' ). value || '1' , 10 );
await setWeather ( id );
}
2026-09-18 14:19:52 +07:00
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 ) { }
}
2026-09-15 18:13:46 +07:00
loadFleet ();
2026-09-17 22:38:58 +07:00
loadScenes ();
2026-09-17 22:58:11 +07:00
loadNpcCatalog ();
2026-09-18 01:22:10 +07:00
loadCutsceneCatalog ();
2026-09-15 18:13:46 +07:00
refresh ();
setInterval ( refresh , 2000 );
2026-09-18 14:19:52 +07:00
refreshTrafficStatus ();
setInterval ( refreshTrafficStatus , 2500 );
2026-09-18 17:38:46 +07:00
onSelectRailLine ();
setInterval ( refreshTransitTracker , 1000 );
2026-09-15 18:13:46 +07:00
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 >