Files
Ananta-ps/Ananta.Server/Ananta.App/DebugPanel/index.html
T

1375 lines
74 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<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 &nbsp;·&nbsp; 06:00 — morning &nbsp;·&nbsp; 12:00 — noon &nbsp;·&nbsp; 18:00 — evening &nbsp;·&nbsp; 23:59 — night</div>
<div style="margin-top:8px">
<button class="ghost" onclick="setTimePreset(6, 0)">🌅 Morning (06:00)</button>
<button class="ghost" onclick="setTimePreset(12, 0)">☀️ Noon (12:00)</button>
<button class="ghost" onclick="setTimePreset(18, 30)">🌇 Evening (18:30)</button>
<button class="ghost" onclick="setTimePreset(22, 0)">🌙 Night (22:00)</button>
<button class="ghost" onclick="setTimePreset(0, 0)">🌌 Midnight (00:00)</button>
</div>
</div>
<div class="card">
<h2>Weather / Atmosphere (สภาพอากาศ)</h2>
<div>
<button style="background:#fbc02d;color:#222;font-weight:bold" onclick="setWeather(1)">☀️ Sunny (แดดออก) [1]</button>
<button style="background:#78909c" onclick="setWeather(2)">⛅ Cloudy (มีเมฆมาก) [2]</button>
<button style="background:#1976d2" onclick="setWeather(3)">🌧️ Rain (ฝนตก) [3]</button>
<button style="background:#512da8" onclick="setWeather(4)">⛈️ Storm (พายุฝนฟ้าคะนอง) [4]</button>
<button style="background:#00897b" onclick="setWeather(5)">🌤️ Afterrain (หลังฝนตก / ใสเคลียร์) [5]</button>
</div>
<div style="margin-top:10px;padding:8px;background:#252932;border-radius:6px">
<div style="font-weight:bold;margin-bottom:6px;color:#80deea">🌫️ Volumetric Exponential Fog (หมอกหนา / ทัศนวิสัย):</div>
<button style="background:#00695c" onclick="setFog(0.08)">🌫️ Thick Fog (เปิดหมอกหนาจัด 0.08)</button>
<button style="background:#00838f" onclick="setFog(0.02)">🌁 Light Fog (หมอกบาง 0.02)</button>
<button class="ghost" onclick="setFog(0)">☀️ Clear Fog (ปิดหมอก)</button>
<div class="muted" style="margin-top:4px">ปรับความหนาแน่นหมอกแบบเรียลไทม์ผ่าน WeatherBridge.EnableExternalExpFogDensity</div>
</div>
<div style="margin-top:8px">
Custom Weather ID: <input id="customWeatherId" type="number" style="width:90px" value="1" min="1" max="5">
Transition (sec): <input id="weatherTransition" type="number" style="width:70px" value="5">
<button class="ghost" onclick="setCustomWeather()">Apply Custom Weather</button>
</div>
<div class="muted" style="margin-top:6px">ซิงค์สภาพอากาศของโลก (WeatherConfig 1..5) และบรรยากาศ client ทันที</div>
</div>
<div class="card">
<h2>Garage (server vehicles)</h2>
<div style="margin-bottom:10px;padding:8px;background:#252932;border-radius:6px">
<div style="font-weight:bold;margin-bottom:6px;color:#ffb74d">⚡ Quick Spawn Special Vehicles:</div>
<button style="background:#8e24aa" onclick="spawnSpecificVehicle(81006020)">🚀 FG Vision Aric (Flying Car) [81006020]</button>
<button style="background:#e91e63" onclick="spawnSpecificVehicle(81007104)">🏍️ Shikage Freeman800 (Honda NT1100) [81007104]</button>
<button style="background:#00897b" onclick="spawnSpecificVehicle(81007007)">🚁 Reed Ranger Helicopter [81007007]</button>
<button style="background:#f4511e" onclick="spawnSpecificVehicle(81006011)">🏍️ Sunset M125 Superbike [81006011]</button>
<div style="font-size:11px;color:#81c784;margin-top:6px">💡 หมายเหตุ: รถล้อเดียวของ Taffy (塔菲独轮车) เป็นสกิลกดขี่ของตัวละคร Taffy (กด E / Active Skill) ให้สลับเป็นตัวละคร Taffy แล้วกด E เพื่อแปลงค้อนเป็นรถล้อเดียวขี่ได้ทันที</div>
</div>
<div>Category: <select id="fleetcat" onchange="filterFleet()"></select></div>
<div style="margin-top:6px">Vehicle: <select id="fleet"></select></div>
<div style="margin-top:8px">
<button onclick="spawnNear()">1. Spawn selected nearby</button>
<button onclick="enterLast()">2. Seat into latest</button>
</div>
<div style="margin-top:8px">
<button class="ghost" onclick="resync()">Verified (39)</button>
<button class="ghost" onclick="unlockAll()">SyncAllUnlockedVehicles (all 150)</button>
</div>
<div class="muted">By default only the 39 verified vehicles load into the game. All 150 (incl. broken/unverified) — only via SyncAllUnlockedVehicles.</div>
<div style="margin-top:8px">
Custom ID: <input id="rawId" type="number" style="width:130px" placeholder="81007068">
<button class="ghost" onclick="spawnRaw()">Spawn by ID</button>
</div>
<div class="muted">Custom ID — for testing excluded/broken ones (Broken Model, Undrivable): the server lets it through with a warning, the client has the final say.</div>
<div class="muted">Pushes SyncAllUnlockedVehicles — the vehicle list in the phone/garage updates without relogging.</div>
</div>
<div class="card">
<h2>Summoned vehicles</h2>
<div id="summoned">—</div>
</div>
<div class="card" style="border-color:#00bcd4">
<h2 style="color:#4dd0e1">👥 NPC & Monster Spawner (เสก NPC / มอนสเตอร์ / สัตว์ / ตัวละคร)</h2>
<div class="muted">มอนสเตอร์ สัตว์ และชาวเมืองในเกมใช้โครงสร้าง Agent เดียวกัน ค้นหาหรือเลือกหมวดหมู่ แล้วกด <b>➕ Add</b> เพื่อเพิ่ม ID ลงในคิวเสกได้ทันที</div>
<!-- Category Filter Tabs -->
<div style="display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 8px 0" id="npcCatTabs">
<button class="npc-tab active" data-cat="all" onclick="setNpcCategory('all')">🌟 ทั้งหมด (All)</button>
<button class="npc-tab" data-cat="monster" onclick="setNpcCategory('monster')">⚔️ มอนสเตอร์ & บอส</button>
<button class="npc-tab" data-cat="citizen" onclick="setNpcCategory('citizen')">🚶 ชาวเมือง (Citizens)</button>
<button class="npc-tab" data-cat="police" onclick="setNpcCategory('police')">👮 ตำรวจ & ยาม (Police)</button>
<button class="npc-tab" data-cat="animal" onclick="setNpcCategory('animal')">🐱 สัตว์ & มาสคอต</button>
<button class="npc-tab" data-cat="ally" onclick="setNpcCategory('ally')">🤝 พันธมิตร & ตัวละคร</button>
</div>
<!-- Search & Pose Toolbar -->
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:8px">
<div style="flex:1;min-width:220px">
<input id="npcSearchInput" type="text" placeholder="🔍 ค้นหา (พิมพ์ชื่อ เช่น 谜面, 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>
</div>
<!-- TAB 1: Live Train Lines & Schedule -->
<div id="transitPane_live">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
<span style="font-size:13px;font-weight:bold;color:#93c5fd">เลือกสายรถไฟ:</span>
<select id="railLineSelect" onchange="onSelectRailLine()" style="flex:1;min-width:240px;background:#1e293b;color:#f8fafc;border:1px solid #3b82f6;padding:6px;border-radius:4px">
<option value="1">🔵 Line 1: Bayshore Line (Inner Ring / ตามเข็มนาฬิกา)</option>
<option value="2">🔷 Line 2: Bayshore Line (Outer Ring / ทวนเข็มนาฬิกา)</option>
<option value="3">🟡 Line 3: Central Loop Line (สายวนรอบใจกลางเมือง)</option>
<option value="4">🟢 Line 4: East Loop Line (สายวนรอบเขตธุรกิจตะวันออก)</option>
<option value="9">🟣 Longqi Metro Line 9 (รถไฟใต้ดินเขตหลงฉี สาย 9)</option>
</select>
<button style="background:#0284c7;color:white;padding:6px 12px" onclick="refreshTransitTracker()">🔄 Refresh Live Trains</button>
</div>
<!-- Active Line Info Banner -->
<div id="lineInfoBanner" style="background:#0f172a;border-left:4px solid #38bdf8;padding:8px 12px;border-radius:4px;margin-bottom:10px;font-size:12px;color:#cbd5e1">
</div>
<!-- Live Trains on Line -->
<div style="font-weight:bold;margin-bottom:6px;color:#38bdf8;font-size:13px">🚆 ขบวนรถไฟที่กำลังวิ่งบนสายนี้ (Live Active Trains):</div>
<div id="activeTrainsContainer" style="display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:8px;margin-bottom:12px">
</div>
<!-- Route Station Nodes -->
<div style="font-weight:bold;margin-bottom:6px;color:#60a5fa;font-size:13px">🚉 เส้นทางสถานี & จุดขึ้นขบวนรถ (Station Route & Boarding):</div>
<div id="routeStationsContainer" style="background:#1e222b;border:1px solid #334155;border-radius:6px;padding:8px;max-height:300px;overflow-y:auto">
</div>
<div class="muted" style="margin-top:6px">💡 เมื่อเปิดเช็คบ็อกซ์ด้านบน ระบบจะเล่นคัทซีนนั่งรถไฟลอดอุโมงค์และไปโผล่ออกที่อุโมงค์สถานีปลายทางให้อัตโนมัติ</div>
</div>
<!-- TAB 2: All Stations Grid -->
<div id="transitPane_stations" style="display:none">
<div style="font-weight:bold;margin-bottom:6px;color:#93c5fd">🏙️ Nova City Metro (12 สถานี):</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px">
<button onclick="warpSubway(2376.0, -1.1, 2729.6, 104.5, 'Sunny Bay Station', 'loading_metro_end_1')">Sunny Bay</button>
<button onclick="warpSubway(898.4, -1.1, 1558.2, 51, 'Freshville Station', 'loading_metro_end_1')">Freshville</button>
<button onclick="warpSubway(1878.8, -1.1, 2250.5, 0, 'Glimmergold Station', 'loading_metro_end_1')">Glimmergold</button>
<button onclick="warpSubway(2748.3, -1.1, 2046.6, 0, 'Memorial Park Station', 'loading_metro_end_1')">Memorial Park</button>
<button onclick="warpSubway(3159.0, -1.1, 2643.0, 0, 'Azure North Station', 'loading_metro_end_1')">Azure North</button>
<button onclick="warpSubway(3661.7, -1.0, 1286.9, 0, 'Future Port Station', 'loading_metro_end_1')">Future Port</button>
<button onclick="warpSubway(2243.6, -1.1, 1616.9, 0, 'Coastal Station', 'loading_metro_end_1')">Coastal Station</button>
<button onclick="warpSubway(3941.7, -1.1, 1974.1, 0, 'Vista Station', 'loading_metro_end_1')">Vista Station</button>
<button onclick="warpSubway(1232.9, -1.1, 2213.1, 0, 'Veluxity Station', 'loading_metro_end_1')">Veluxity Station</button>
<button onclick="warpSubway(620.2, -1.0, 2072.1, 0, 'Paradox Plaza Station', 'loading_metro_end_1')">Paradox Plaza</button>
<button onclick="warpSubway(1383.1, -1.1, 1267.7, 0, 'Marine MechWorks Station', 'loading_metro_end_1')">Marine MechWorks</button>
<button onclick="warpSubway(2947.4, -1.1, 1030.3, 0, 'Rixxy Wonderland Station', 'loading_metro_end_1')">Rixxy Wonderland</button>
</div>
<div style="font-weight:bold;margin-bottom:6px;color:#a78bfa">🏮 Longqi Metro (13 สถานี):</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="ghost" onclick="warpSubway(-1638.0, 18.0, 1540.5, 0, 'Dinglan North', 'loading_general_end')">Dinglan North</button>
<button class="ghost" onclick="warpSubway(-1492.5, 17.6, 1107.9, 0, 'Zhaohua Road', 'loading_metro_end_1')">Zhaohua Road</button>
<button class="ghost" onclick="warpSubway(-1431.7, 18.0, 565.0, 0, 'Dinglan Road', 'loading_general_end')">Dinglan Road</button>
<button class="ghost" onclick="warpSubway(-1725.5, 17.5, 12.1, 0, 'Lakeside Pedestrian', 'loading_metro_end_1')">Lakeside Pedestrian</button>
<button class="ghost" onclick="warpSubway(-1002.5, 17.7, 115.3, 0, 'Zenith Center', 'loading_metro_end_1')">Zenith Center</button>
<button class="ghost" onclick="warpSubway(-1788.4, 17.5, -574.6, 0, 'Lakeside South', 'loading_metro_end_1')">Lakeside South</button>
<button class="ghost" onclick="warpSubway(-844.0, 17.5, 1133.7, 0, 'Great Ferry Port', 'loading_metro_end_1')">Great Ferry Port</button>
<button class="ghost" onclick="warpSubway(-624.4, 17.6, 660.4, 0, 'Xinfeng Road', 'loading_metro_end_1')">Xinfeng Road</button>
<button class="ghost" onclick="warpSubway(61.6, 17.5, 778.3, 0, 'Yaojin Road', 'loading_metro_end_1')">Yaojin Road</button>
<button class="ghost" onclick="warpSubway(375.8, 17.5, 98.4, 0, 'Yaojin Pedestrian', 'loading_metro_end_1')">Yaojin Pedestrian</button>
<button class="ghost" onclick="warpSubway(-427.6, 17.5, 354.0, 0, 'Mingchun North', 'loading_metro_end_1')">Mingchun North</button>
<button class="ghost" onclick="warpSubway(-2442.7, 17.5, 1015.6, 0, 'Lakefront Institute', 'loading_metro_end_1')">Lakefront Institute</button>
<button class="ghost" onclick="warpSubway(-1138.2, 17.7, -602.3, 0, 'Cloudstream West', 'loading_metro_end_1')">Cloudstream West</button>
</div>
</div>
</div>
<div class="card" style="border-color:#ff9800">
<h2 style="color:#ffb74d">🕹️ Arcade & Interactive Minigames (ตู้เกมอาร์เคด & มินิเกมในโลก)</h2>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select id="minigameSelect" style="flex:1;min-width:240px;background:#2d3748;color:#e2e8f0;border:1px solid #4a5568;padding:8px 12px;border-radius:6px;font-size:14px">
<optgroup label="🕹️ ตู้เกมอาร์เคด (Arcade Cabinets)">
<option value="KOF97" selected>🕹️ Arcade: The King of Fighters '97 (ตู้เกม KOF '97)</option>
<option value="METALSLUG">🕹️ Arcade: Metal Slug (ตู้เกม Metal Slug ลุยด่าน)</option>
</optgroup>
<optgroup label="🎵 ดนตรีสด (Live Musical Instruments)">
<option value="PIANO">🎹 Grand Piano (เล่นเปียโนสด - เสกตู้เปียโนตรงหน้า)</option>
<option value="DRUMKIT">🥁 Drumkit Set (ตีกลองชุดสด)</option>
</optgroup>
<optgroup label="🏆 กิจกรรมกีฬา (Sports & Leisure)">
<option value="BASKETBALL">🏀 Street Basketball (ชูตบาสเกตบอล)</option>
</optgroup>
</select>
<button style="background:#e65100;color:white;font-weight:600;padding:8px 16px" onclick="launchSelectedMinigame()">Launch / เปิดมินิเกม</button>
<button style="background:#4a5568;color:#cbd5e0;padding:8px 14px" onclick="closeActiveMinigame()">Close / ปิดมินิเกม</button>
<button style="background:#059669;color:white;font-weight:600;padding:8px 14px" onclick="teleportToBowling()">🎳 Warp to Bowling Alley (วาร์ปไปลานโบว์ลิ่ง)</button>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
<button style="background:#d97706;color:white;font-size:12px;padding:4px 8px" onclick="launchMinigameQuick('KOF97')">🕹️ KOF '97</button>
<button style="background:#d97706;color:white;font-size:12px;padding:4px 8px" onclick="launchMinigameQuick('METALSLUG')">🕹️ Metal Slug</button>
<button style="background:#2563eb;color:white;font-size:12px;padding:4px 8px" onclick="launchMinigameQuick('PIANO')">🎹 Piano</button>
<button style="background:#2563eb;color:white;font-size:12px;padding:4px 8px" onclick="launchMinigameQuick('DRUMKIT')">🥁 Drumkit</button>
<button style="background:#059669;color:white;font-size:12px;padding:4px 8px" onclick="teleportToBowling()">🎳 Bowling Alley</button>
<button style="background:#059669;color:white;font-size:12px;padding:4px 8px" onclick="launchMinigameQuick('BASKETBALL')">🏀 Basketball</button>
</div>
<div class="muted" style="margin-top:6px">เปิดตู้เกมอาร์เคด (KOF '97, Metal Slug), มินิเกมดนตรีสด (เสกโมเดลเปียโนตรงหน้า), หรือวาร์ปไปลานโบว์ลิ่งจริงใน Nova City</div>
</div>
<div class="card" style="border-color:#10b981">
<h2 style="color:#34d399">🚗 City Traffic & Urban Crowd Controller (ระบบจราจรและชาวเมือง)</h2>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div style="background:#1e293b;padding:8px 14px;border-radius:6px;border:1px solid #334155;flex:1;min-width:140px">
<div style="font-size:12px;color:#94a3b8">Active Traffic Cars</div>
<div style="font-size:20px;font-weight:bold;color:#38bdf8" id="trafficActiveCount">0</div>
<div style="font-size:11px;color:#64748b" id="trafficRoadCount">42,128 road lanes</div>
</div>
<div style="background:#1e293b;padding:8px 14px;border-radius:6px;border:1px solid #334155;flex:1;min-width:140px">
<div style="font-size:12px;color:#94a3b8">Living Citizens / Crowd</div>
<div style="font-size:20px;font-weight:bold;color:#a78bfa" id="crowdActiveCount">0</div>
<div style="font-size:11px;color:#64748b" id="crowdShopsCount">1,207 shops registered</div>
</div>
<div style="background:#1e293b;padding:8px 14px;border-radius:6px;border:1px solid #334155;flex:1;min-width:140px">
<div style="font-size:12px;color:#94a3b8">Engine Status</div>
<div style="font-size:16px;font-weight:bold;color:#4ade80" id="trafficEngineStatus">Running 🟢</div>
<div style="font-size:11px;color:#64748b">10 ticks/sec real-time</div>
</div>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:10px">
<div style="flex:1;min-width:220px">
<label style="font-weight:600;font-size:13px;display:flex;justify-content:space-between">
<span>🚗 Traffic Density (ความหนาแน่นรถยนต์):</span>
<span id="trafficDensityLabel" style="color:#38bdf8;font-weight:bold">20 cars</span>
</label>
<input type="range" id="trafficDensitySlider" min="0" max="50" value="20" style="width:100%" oninput="onTrafficDensitySlider(this.value)" onchange="applyTrafficConfig()">
<div style="display:flex;gap:6px;margin-top:4px">
<button style="background:#334155;color:#e2e8f0;font-size:11px;padding:3px 8px" onclick="setTrafficDensityPreset(0)">Off (0)</button>
<button style="background:#334155;color:#e2e8f0;font-size:11px;padding:3px 8px" onclick="setTrafficDensityPreset(10)">Light (10)</button>
<button style="background:#0284c7;color:white;font-size:11px;padding:3px 8px" onclick="setTrafficDensityPreset(20)">Normal (20)</button>
<button style="background:#b91c1c;color:white;font-size:11px;padding:3px 8px" onclick="setTrafficDensityPreset(40)">Rush Hour (40)</button>
</div>
</div>
<div style="flex:1;min-width:220px">
<label style="font-weight:600;font-size:13px;display:flex;justify-content:space-between">
<span>🚶 Pedestrian Density (คนเดินถนนและร้านค้า):</span>
<span id="crowdDensityLabel" style="color:#a78bfa;font-weight:bold">15 npcs</span>
</label>
<input type="range" id="crowdDensitySlider" min="0" max="40" value="15" style="width:100%" oninput="onCrowdDensitySlider(this.value)" onchange="applyCrowdConfig()">
<div style="display:flex;gap:6px;margin-top:4px">
<button style="background:#334155;color:#e2e8f0;font-size:11px;padding:3px 8px" onclick="setCrowdDensityPreset(0)">Off (0)</button>
<button style="background:#334155;color:#e2e8f0;font-size:11px;padding:3px 8px" onclick="setCrowdDensityPreset(10)">Quiet (10)</button>
<button style="background:#7c3aed;color:white;font-size:11px;padding:3px 8px" onclick="setCrowdDensityPreset(20)">Lively (20)</button>
<label style="display:inline-flex;align-items:center;gap:4px;font-size:11px;color:#cbd5e1;margin-left:auto">
<input type="checkbox" id="populateShopsCheck" checked onchange="applyCrowdConfig()"> Populate Shops
</label>
</div>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px">
<button style="background:#059669;color:white;font-weight:600;padding:8px 14px" onclick="spawnTrafficWave()">⚡ Spawn Wave Ahead (ปล่อยรถ 5 คันข้างหน้าทันที)</button>
<button style="background:#dc2626;color:white;padding:8px 14px" onclick="clearAllTraffic()">🧹 Clear All Traffic (ล้างรถทั้งหมด)</button>
<button style="background:#475569;color:#e2e8f0;padding:8px 14px" onclick="refreshTrafficStatus()">🔄 Refresh Status</button>
</div>
<div class="muted" style="margin-top:6px">
จำลองการจราจรอัจฉริยะแบบเรียลไทม์บนเส้นทาง 42,128 เลนตามแผนผังเมืองจริง รถจะวิ่งตามเลน เลี้ยวตามทางแยก และผู้เล่นสามารถกด <strong>[F]</strong> เพื่อเปิดประตูขึ้นไปขับได้ทันทีแบบ GTA
</div>
</div>
<div class="card" id="card-weapon" style="border-left: 4px solid #00bcd4;">
<h2 style="color:#4dd0e1">⚔️ Armory Wheel Customizer (ปรับแต่งอาวุธในวงล้อช่อง 1–15)</h2>
<div class="muted">ติดตั้งอาวุธ 634 ชิ้นในเกมลงในช่องวงล้ออาวุธ 1–15 ของตัวละครใดก็ได้ (ช่อง 0 คือหมัด) ซิงค์สไตล์การต่อสู้ แอนิเมชัน และกระสุนเข้าเกมทันที</div>
<div style="margin-top:8px">
ตัวละคร: <select id="wpnSpirit" style="min-width:200px"></select>
ช่องวงล้อ (Slot 1–15): <input id="wpnSlot" type="number" min="1" max="15" value="1" style="width:70px">
</div>
<div style="margin-top:6px">
ค้นหาอาวุธ: <input id="wpnFilter" placeholder="พิมพ์ชื่อ หรือ templateId เช่น 98003001" style="width:250px" onkeydown="if(event.key==='Enter')renderWeapons()">
<button class="ghost" onclick="renderWeapons()">ค้นหา</button>
</div>
<div class="picklist" id="wpnList">กำลังโหลดรายชื่ออาวุธ...</div>
<div style="margin-top:8px">
<button style="background:#00838f;color:#fff;font-weight:bold" onclick="equipSelected()">⚔️ สวมใส่อาวุธลงในช่อง (Equip Weapon)</button>
</div>
</div>
<div class="card" id="card-give-item" style="border-left: 4px solid #ffca28;">
<h2 style="color:#ffd54f">🎒 Universal Item Spawner (ระบบเสกไอเทมเข้ากระเป๋าเป้)</h2>
<div class="muted">ส่งไอเทมตรงเข้ากระเป๋าตัวละครผู้เล่นแบบ Real-time ผ่าน MethodId.SyncBackpackItemChanged โดยไม่ต้องเปิด GM คอนโซลหรือรีเซ็ตเกม</div>
<div style="margin-top:8px">
Item Template ID: <input id="giveId" type="number" placeholder="36012003" style="width:140px">
จำนวน (Count): <input id="giveCount" type="number" value="1" min="1" max="99999" style="width:90px">
<label class="muted" style="margin-left:6px"><input id="giveBind" type="checkbox"> ผูกมัด (Bind)</label>
<button style="background:#f57f17;color:#fff;font-weight:bold" onclick="giveItem()">🎁 เสกไอเทม / Grant</button>
</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 launchSelectedMinigame() {
const sel = document.getElementById('minigameSelect');
const game = sel ? sel.value : 'KOF97';
const r = await api('/api/minigame/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ game })
});
log('launch minigame (' + game + '): ' + JSON.stringify(r));
}
async function launchMinigameQuick(game) {
const r = await api('/api/minigame/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ game })
});
log('quick launch minigame (' + game + '): ' + JSON.stringify(r));
}
async function closeActiveMinigame() {
const r = await api('/api/minigame/close', { method: 'POST' });
log('close minigame: ' + JSON.stringify(r));
}
async function launchArcadeMinigame() {
await launchSelectedMinigame();
}
async function warpSubway(x, y, z, facing, stationName, endTimeline) {
const autoCutscene = document.getElementById('autoSubwayCutscene')?.checked ?? true;
if (autoCutscene) {
const sName = stationName || 'สถานีปลายทาง';
log(`🚇 กำลังเดินทางด้วยรถไฟไปยัง ${sName}... (ระบบกำลังเล่นคัทซีนรถไฟอัตโนมัติ)`);
await api('/api/timeline/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'loading_metro_normal01_m' })
});
setTimeout(async () => {
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) })
});
await api('/api/timeline/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: endTimeline || 'loading_metro_end_1' })
});
log(`🚇 รถไฟเทียบชานชาลาสถานี ${sName} และเดินออกจากอุโมงค์เรียบร้อยแล้ว`);
refresh();
}, 1800);
return;
}
const r = await api('/api/player/teleport', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: parseFloat(x), y: parseFloat(y), z: parseFloat(z), facing: parseFloat(facing || 0) })
});
log(`🚇 วาร์ปไปยังสถานี (${x}, ${y}, ${z}): ` + JSON.stringify(r));
refresh();
}
async function teleportToBowling() {
await warpSubway(1090.8, 6.5, 2083.7, 0, 'Bowling Alley');
log('🎳 Warped to Bowling Alley at (1090.8, 6.5, 2083.7)');
}
// --- TRANSIT & METRO TRACKER ---
function switchTransitTab(tab) {
const tabs = ['live', 'stations'];
tabs.forEach(t => {
const pane = document.getElementById('transitPane_' + t);
const btn = document.getElementById('tabBtn_' + t);
if (pane) pane.style.display = (t === tab) ? 'block' : 'none';
if (btn) {
if (t === tab) btn.classList.add('active');
else btn.classList.remove('active');
}
});
}
const RAIL_LINES = [
{
id: 1,
name: "Line 1: Bayshore Line (Inner Ring / ตามเข็มนาฬิกา)",
color: "#38bdf8",
interval: 90,
speed: 12,
trainCount: 6,
totalDuration: 1120,
stations: [
{ id: 1, name: "Ivory Cove", cn: "白帆湾", x: 539.6, y: 20.5, z: 1363.8, t: 0 },
{ id: 2, name: "Paradox Plaza", cn: "狂悖街区", x: 777.0, y: 14.7, z: 2427.2, t: 150 },
{ id: 3, name: "Sunny Bay", cn: "晴海", x: 1988.8, y: 19.9, z: 2779.2, t: 320 },
{ id: 4, name: "City Link (Hub)", cn: "城关 (ชุมทาง)", x: 2968.2, y: 42.1, z: 2467.9, t: 490 },
{ id: 5, name: "Legacy Hub", cn: "纪事区", x: 3469.2, y: -17.8, z: 1724.5, t: 660 },
{ id: 6, name: "Floramoon Park", cn: "花月公园", x: 2560.1, y: 33.7, z: 1675.7, t: 820 },
{ id: 7, name: "Freshville East", cn: "东新筑", x: 1088.4, y: 18.5, z: 1875.8, t: 980 }
]
},
{
id: 2,
name: "Line 2: Bayshore Line (Outer Ring / ทวนเข็มนาฬิกา)",
color: "#06b6d4",
interval: 90,
speed: 12,
trainCount: 6,
totalDuration: 1120,
stations: [
{ id: 1, name: "Ivory Cove", cn: "白帆湾", x: 539.6, y: 20.5, z: 1363.8, t: 0 },
{ id: 7, name: "Freshville East", cn: "东新筑", x: 1088.4, y: 18.5, z: 1875.8, t: 140 },
{ id: 6, name: "Floramoon Park", cn: "花月公园", x: 2560.1, y: 33.7, z: 1675.7, t: 300 },
{ id: 5, name: "Legacy Hub", cn: "纪事区", x: 3469.2, y: -17.8, z: 1724.5, t: 460 },
{ id: 4, name: "City Link (Hub)", cn: "城关 (ชุมทาง)", x: 2968.2, y: 42.1, z: 2467.9, t: 630 },
{ id: 3, name: "Sunny Bay", cn: "晴海", x: 1988.8, y: 19.9, z: 2779.2, t: 800 },
{ id: 2, name: "Paradox Plaza", cn: "狂悖街区", x: 777.0, y: 14.7, z: 2427.2, t: 970 }
]
},
{
id: 3,
name: "Line 3: Central Loop Line (สายวนรอบใจกลางเมือง)",
color: "#eab308",
interval: 90,
speed: 12,
trainCount: 4,
totalDuration: 740,
stations: [
{ id: 6, name: "Floramoon Park", cn: "花月公园", x: 2560.1, y: 33.7, z: 1675.7, t: 0 },
{ id: 11, name: "Marine MechWorks", cn: "岸泊工业", x: 1739.0, y: 29.3, z: 1379.2, t: 130 },
{ id: 12, name: "Union Square", cn: "统理院", x: 1397.0, y: -8.5, z: 2151.9, t: 280 },
{ id: 13, name: "Sunny Bay Central", cn: "晴海中央", x: 2003.8, y: 10.3, z: 2712.8, t: 430 },
{ id: 4, name: "City Link (Hub)", cn: "城关 (ชุมทาง)", x: 2968.2, y: 42.1, z: 2467.9, t: 590 }
]
},
{
id: 4,
name: "Line 4: East Loop Line (สายวนรอบเขตธุรกิจตะวันออก)",
color: "#22c55e",
interval: 90,
speed: 10,
trainCount: 4,
totalDuration: 640,
stations: [
{ id: 4, name: "City Link (Hub)", cn: "城关 (ชุมทาง)", x: 2968.2, y: 42.1, z: 2467.9, t: 0 },
{ id: 21, name: "Aerie Bank", cn: "鹰庭银行", x: 3412.4, y: 25.0, z: 2244.6, t: 120 },
{ id: 22, name: "Saturn Tower", cn: "土星塔", x: 3865.9, y: 45.2, z: 1738.3, t: 250 },
{ id: 23, name: "Pop Cool City", cn: "砰酷中心", x: 3629.9, y: 47.6, z: 1302.7, t: 380 },
{ id: 24, name: "Crystal Place", cn: "璃色", x: 3134.9, y: 42.0, z: 1460.4, t: 510 }
]
},
{
id: 9,
name: "Longqi Metro Line 9 (รถไฟใต้ดินเขตหลงฉี สาย 9)",
color: "#a855f7",
interval: 100,
speed: 12,
trainCount: 4,
totalDuration: 700,
stations: [
{ id: 41, name: "Mingchun North", cn: "明春北", x: -503.1, y: -9.1, z: -528.8, t: 0 },
{ id: 42, name: "Cloudstream West", cn: "云河西路", x: -1302.9, y: -23.8, z: -642.3, t: 140 },
{ id: 43, name: "Lakefront Promenade", cn: "临湖步行街", x: -1711.3, y: -24.0, z: -50.5, t: 280 },
{ id: 44, name: "Dinglan Road", cn: "定澜路", x: -1474.2, y: -24.1, z: 785.7, t: 420 },
{ id: 45, name: "Wulin Road", cn: "武林路", x: -1512.1, y: -14.2, z: 1095.9, t: 560 }
]
}
];
function getSelectedRailLine() {
const sel = document.getElementById('railLineSelect');
const lineId = parseInt(sel?.value || '1', 10);
return RAIL_LINES.find(l => l.id === lineId) || RAIL_LINES[0];
}
function onSelectRailLine() {
const line = getSelectedRailLine();
const banner = document.getElementById('lineInfoBanner');
if (banner) {
banner.style.borderLeftColor = line.color;
banner.innerHTML = `
<div style="font-weight:bold;color:${line.color};font-size:13px">${line.name}</div>
<div style="margin-top:4px;display:flex;gap:14px;flex-wrap:wrap">
<span>⏱️ ความถี่ขบวนรถ: <b>${line.interval} วินาที</b></span>
<span>🚄 ความเร็วเฉลี่ย: <b>${line.speed} m/s (~${Math.round(line.speed * 3.6)} km/h)</b></span>
<span>🕒 รอบเวลาทั้งสาย: <b>${Math.round(line.totalDuration / 60)} นาที</b></span>
<span>🚆 จำนวนขบวนให้บริการ: <b>${line.trainCount} ขบวน</b></span>
</div>
`;
}
const routeBox = document.getElementById('routeStationsContainer');
if (routeBox) {
let html = '<div style="display:flex;flex-direction:column;gap:6px">';
line.stations.forEach((st, idx) => {
const isHub = st.name.includes('(Hub)');
html += `
<div style="display:flex;align-items:center;justify-content:space-between;background:#15181f;padding:6px 10px;border-radius:4px;border-left:3px solid ${isHub ? '#f59e0b' : line.color}">
<div style="display:flex;align-items:center;gap:8px">
<span style="background:${line.color};color:#000;font-size:11px;font-weight:bold;padding:2px 6px;border-radius:3px">#${idx + 1}</span>
<span style="font-weight:600;color:#f1f5f9">${st.name}</span>
<span style="color:#94a3b8;font-size:12px">[${st.cn}]</span>
${isHub ? '<span style="background:#b45309;color:#fef3c7;font-size:10px;padding:1px 5px;border-radius:3px;font-weight:bold">GRAND INTERCHANGE</span>' : ''}
</div>
<div style="display:flex;gap:4px">
<button style="background:#0284c7;color:white;font-size:11px;padding:3px 8px" onclick="warpSubway(${st.x}, ${st.y}, ${st.z}, 0, '${st.name}', 'loading_metro_end_1')">🚇 ไปยังสถานีนี้</button>
</div>
</div>
`;
});
html += '</div>';
routeBox.innerHTML = html;
}
refreshTransitTracker();
}
function refreshTransitTracker() {
const line = getSelectedRailLine();
const container = document.getElementById('activeTrainsContainer');
if (!container) return;
const now = Math.floor(Date.now() / 1000);
let html = '';
for (let i = 0; i < line.trainCount; i++) {
const trainNum = (line.id * 100) + (i + 1);
const trainProgress = (now + (i * line.interval)) % line.totalDuration;
let prevSt = line.stations[0];
let nextSt = line.stations[1];
let isAtStation = false;
let dwellLeft = 0;
for (let s = 0; s < line.stations.length; s++) {
const cur = line.stations[s];
const nxt = line.stations[(s + 1) % line.stations.length];
const curT = cur.t;
let nxtT = nxt.t;
if (nxtT <= curT) nxtT += line.totalDuration;
let p = trainProgress;
if (p < curT) p += line.totalDuration;
if (p >= curT && p < nxtT) {
prevSt = cur;
nextSt = nxt;
const dwellTime = 20;
if (p - curT < dwellTime) {
isAtStation = true;
dwellLeft = Math.max(1, Math.round(dwellTime - (p - curT)));
}
break;
}
}
const etaSeconds = Math.max(1, Math.round(
(nextSt.t <= prevSt.t ? nextSt.t + line.totalDuration : nextSt.t) -
(trainProgress < prevSt.t ? trainProgress + line.totalDuration : trainProgress)
));
html += `
<div style="background:#181d26;border:1px solid #334155;border-radius:6px;padding:8px 10px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
<span style="font-weight:bold;color:${line.color}">🚆 ขบวน #${trainNum}</span>
<span style="font-size:11px;color:#94a3b8">ความเร็ว: ${line.speed} m/s</span>
</div>
${isAtStation ? `
<div style="color:#4ade80;font-size:12px;font-weight:600">🟢 จอดเทียบชานชาลาที่ ${prevSt.name}</div>
<div style="color:#cbd5e1;font-size:11px;margin-top:2px">ประตูชานชาลาเปิดอยู่ · ออกเดินทางใน <b>${dwellLeft}s</b></div>
` : `
<div style="color:#38bdf8;font-size:12px;font-weight:600">⏩ กำลังมุ่งหน้าไป ${nextSt.name}</div>
<div style="color:#cbd5e1;font-size:11px;margin-top:2px">ออกจาก ${prevSt.name} · ถึงชานชาลาในอีก <b>${etaSeconds}s</b></div>
`}
</div>
`;
}
container.innerHTML = html;
}
async function setTimePreset(hour, minute) {
const slider = document.getElementById('timeslider');
if (slider) {
slider.value = hour * 60 + minute;
const clock = document.getElementById('clock');
if (clock) clock.textContent = fmtClock(hour * 60 + minute);
}
const body = { hour, minute, fix: true, transition: 5 };
const r = await api('/api/time/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('time preset: ' + JSON.stringify(r));
}
async function setWeather(weatherId) {
const transition = parseInt(document.getElementById('weatherTransition')?.value || '5', 10);
const body = { weatherId: parseInt(weatherId, 10), transition };
const r = await api('/api/weather/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('set weather: ' + JSON.stringify(r));
}
async function setFog(density) {
const body = { density: parseFloat(density) };
const r = await api('/api/weather/fog', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('set fog: ' + JSON.stringify(r));
}
async function setCustomWeather() {
const id = parseInt(document.getElementById('customWeatherId').value || '1', 10);
await setWeather(id);
}
function onTrafficDensitySlider(val) {
document.getElementById('trafficDensityLabel').textContent = val + ' cars';
}
function onCrowdDensitySlider(val) {
document.getElementById('crowdDensityLabel').textContent = val + ' npcs';
}
async function setTrafficDensityPreset(val) {
document.getElementById('trafficDensitySlider').value = val;
onTrafficDensitySlider(val);
await applyTrafficConfig();
}
async function setCrowdDensityPreset(val) {
document.getElementById('crowdDensitySlider').value = val;
onCrowdDensitySlider(val);
await applyCrowdConfig();
}
async function applyTrafficConfig() {
const density = parseInt(document.getElementById('trafficDensitySlider')?.value || '20', 10);
const enabled = density > 0;
const body = { enabled, density, speedScale: 1.0 };
const r = await api('/api/traffic/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('traffic config: ' + JSON.stringify(r));
await refreshTrafficStatus();
}
async function applyCrowdConfig() {
const density = parseInt(document.getElementById('crowdDensitySlider')?.value || '15', 10);
const populateShops = document.getElementById('populateShopsCheck')?.checked ?? true;
const enabled = density > 0 || populateShops;
const body = { enabled, density, populateShops };
const r = await api('/api/crowd/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
log('crowd config: ' + JSON.stringify(r));
await refreshTrafficStatus();
}
async function spawnTrafficWave() {
const r = await api('/api/traffic/spawn-wave', { method: 'POST' });
log('spawn wave: ' + JSON.stringify(r));
await refreshTrafficStatus();
}
async function clearAllTraffic() {
const r = await api('/api/traffic/clear', { method: 'POST' });
log('clear traffic: ' + JSON.stringify(r));
await refreshTrafficStatus();
}
async function refreshTrafficStatus() {
try {
const s = await api('/api/traffic/status');
if (s && s.ok) {
if (s.traffic) {
document.getElementById('trafficActiveCount').textContent = s.traffic.activeVehicles;
document.getElementById('trafficRoadCount').textContent = (s.traffic.totalRoadLanes || 42128).toLocaleString() + ' road lanes';
document.getElementById('trafficEngineStatus').textContent = s.traffic.enabled ? 'Running 🟢' : 'Disabled 🔴';
document.getElementById('trafficEngineStatus').style.color = s.traffic.enabled ? '#4ade80' : '#f87171';
}
if (s.crowd) {
document.getElementById('crowdActiveCount').textContent = s.crowd.activeCrowdCount;
document.getElementById('crowdShopsCount').textContent = s.crowd.populatedShopsCount + ' / 1,207 shops populated';
}
}
} catch (e) { }
}
let weaponData = [];
let itemData = [];
async function loadCatalogs() {
try {
const roster = await api('/api/catalog/spirits');
if (Array.isArray(roster)) {
document.getElementById('wpnSpirit').innerHTML =
roster.map(s => `<option value="${s.templateId}">${s.templateId} - ${s.name}</option>`).join('');
}
weaponData = await api('/api/catalog/weapons');
if (Array.isArray(weaponData)) renderWeapons();
const itemsRes = await api('/api/catalog/items?limit=5000');
itemData = (itemsRes && itemsRes.items) ? itemsRes.items : [];
renderItems();
} catch (e) {
log('catalog load error: ' + e);
}
}
function renderWeapons() {
const q = (document.getElementById('wpnFilter').value || '').toLowerCase();
const list = (weaponData || []).filter(w => !q || (w.name || '').toLowerCase().includes(q) || String(w.templateId).includes(q)).slice(0, 400);
document.getElementById('wpnList').innerHTML = list.length
? list.map(w => `<label><input type="radio" name="wpn" value="${w.templateId}"> ` +
`${w.templateId} - ${w.name}` +
(w.isPrivate ? ' <span class="muted">[Exclusive]</span>' : '') +
(w.isShoot ? ' <span class="muted">[Firearm]</span>' : '') + `</label>`).join('')
: '<span class="muted">ไม่พบอาวุธที่ค้นหา</span>';
}
function itemLabel(i) {
const n = (i.name || '').trim();
return (n.length ? n : '(Unnamed Item)').replace(/</g, '&lt;');
}
function filterItemCache(q) {
const k = (q || '').toLowerCase();
return (itemData || []).filter(i =>
!k || (i.name || '').toLowerCase().includes(k) || String(i.templateId).includes(k)).slice(0, 300);
}
function renderItems() {
const q = (document.getElementById('itemFilter').value || '').trim();
const box = document.getElementById('itemList');
const paint = list => {
box.innerHTML = (list && list.length)
? list.map(i => `<label style="cursor:pointer">` +
`<button class="ghost" style="padding:1px 7px;margin-right:6px" ` +
`onclick="event.preventDefault();giveItem(${i.templateId})">ส่ง</button>` +
`<span onclick="pickItemId(${i.templateId})">${i.templateId} - ${itemLabel(i)}</span></label>`).join('')
: '<span class="muted">ไม่พบไอเทมที่ค้นหา</span>';
};
if (q) {
api('/api/catalog/items?limit=300&q=' + encodeURIComponent(q))
.then(r => paint((r && r.items) ? r.items : []))
.catch(() => paint(filterItemCache(q)));
return;
}
paint(filterItemCache(''));
}
function pickItemId(id) {
document.getElementById('giveId').value = id;
log('เลือกไอเทม ID: ' + id);
}
async function giveItem(id) {
const templateId = id || parseInt(document.getElementById('giveId').value || '0', 10);
if (!templateId) { log('กรุณาระบุ Item Template ID'); return; }
const count = Math.max(1, parseInt(document.getElementById('giveCount').value || '1', 10) || 1);
const bind = document.getElementById('giveBind').checked;
try {
const r = await api('/api/item/give', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templateId, count, bind })
});
log('🎁 เสกไอเทม ' + templateId + ' x' + count + ': ' + (r.ok ? r.message : 'ล้มเหลว - ' + (r.error || '')));
} catch (e) {
log('เสกไอเทมล้มเหลว: ' + e);
}
}
async function equipSelected() {
const pick = document.querySelector('input[name=wpn]:checked');
if (!pick) { log('กรุณาเลือกอาวุธก่อนกดสวมใส่'); return; }
const body = {
spiritId: parseInt(document.getElementById('wpnSpirit').value || '0', 10),
slotIndex: parseInt(document.getElementById('wpnSlot').value || '1', 10),
templateId: parseInt(pick.value, 10)
};
try {
const r = await api('/api/armory/equip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
log('⚔️ สวมใส่อาวุธ: ' + (r.ok ? r.message : 'ล้มเหลว - ' + (r.error || '')));
} catch (e) {
log('สวมใส่อาวุธล้มเหลว: ' + e);
}
}
loadFleet();
loadScenes();
loadNpcCatalog();
loadCatalogs();
refresh();
setInterval(refresh, 2000);
refreshTrafficStatus();
setInterval(refreshTrafficStatus, 2500);
onSelectRailLine();
setInterval(refreshTransitTracker, 1000);
document.getElementById('timeslider').addEventListener('input', e => {
timeDragging = true;
document.getElementById('clock').textContent = fmtClock(parseInt(e.target.value || '720', 10));
});
document.getElementById('timeslider').addEventListener('change', async () => {
timeDragging = false;
await pushTime();
refresh();
});
</script>
</body>
</html>