diff --git a/Ananta.Proxy/proxy/public/fastpatch_4229938.zip b/Ananta.Proxy/proxy/public/fastpatch_4229938.zip index 743f06a..002fa8e 100644 Binary files a/Ananta.Proxy/proxy/public/fastpatch_4229938.zip and b/Ananta.Proxy/proxy/public/fastpatch_4229938.zip differ diff --git a/Ananta.Server/Ananta.App/DebugApiServer.cs b/Ananta.Server/Ananta.App/DebugApiServer.cs index a5480db..8dde16f 100644 --- a/Ananta.Server/Ananta.App/DebugApiServer.cs +++ b/Ananta.Server/Ananta.App/DebugApiServer.cs @@ -141,6 +141,8 @@ internal sealed class DebugApiServer(PrivateServerConfig config, GameSessionHub await WriteJsonAsync(ctx, await NpcSpawnAsync(await ReadBodyAsync(ctx.Request, token), token), token); else if (method == "GET" && path == "/api/scenes") await WriteJsonAsync(ctx, Scenes(), token); + else if (method == "GET" && path == "/api/npc/catalog") + await WriteJsonAsync(ctx, NpcCatalog(ctx.Request), token); else await WriteJsonAsync(ctx, new { ok = false, error = "unknown route" }, token, 404); } @@ -575,6 +577,55 @@ internal sealed class DebugApiServer(PrivateServerConfig config, GameSessionHub }; } + private static object NpcCatalog(HttpListenerRequest req) + { + var q = req.QueryString["q"]; + var cat = req.QueryString["cat"]; + int limit = 150; + if (int.TryParse(req.QueryString["limit"], out var l) && l > 0) + limit = Math.Clamp(l, 1, 300); + + var entries = NpcCatalog4229938.Search(q, cat, limit); + + return new + { + categories = new[] + { + new { id = "all", label = "🌟 All (ทั้งหมด)" }, + new { id = "monster", label = "⚔️ Monsters & Bosses (มอนสเตอร์/บอส)" }, + new { id = "citizen", label = "🚶 Citizens (ชาวเมือง/ประชาชน)" }, + new { id = "police", label = "👮 Police & Security (ตำรวจ/ยาม)" }, + new { id = "animal", label = "🐱 Animals & Pets (สัตว์/เป็ด/แมว)" }, + new { id = "ally", label = "🤝 Allies & Story (พันธมิตร/ตัวละคร)" }, + }, + poiActions = new[] + { + new { id = 0, name = "🧍 Auto / None (ไม่ระบุท่า)" }, + new { id = 2, name = "🧍 Idle (ยืนนิ่ง)" }, + new { id = 1, name = "🚶 Walk (เดิน)" }, + new { id = 4, name = "🏃 Run (วิ่ง)" }, + new { id = 11, name = "📱 Phone / Camera (คุยโทรศัพท์/ถ่ายรูป)" }, + new { id = 10, name = "🧱 Lean on Wall (พิงกำแพง)" }, + new { id = 12, name = "🪑 Formal Sit (นั่งเก้าอี้เรียบร้อย)" }, + new { id = 13, name = "🪑 Relaxed Sit (นั่งเก้าอี้ผ่อนคลาย)" }, + new { id = 15, name = "💬 Sit & Talk (นั่งคุย)" }, + new { id = 6, name = "👏 Clapping (ปรบมือ)" }, + new { id = 5, name = "👀 Spectating (ยืนมุงดู)" }, + new { id = 3, name = "😱 Scared / Panicking (ตกใจกลัว)" }, + new { id = 8, name = "🥤 Vending Machine (กดตู้ขายน้ำ)" } + }, + items = entries.Select(e => new + { + id = e.Id, + name = string.IsNullOrWhiteSpace(e.Name) ? $"Agent_{e.Id}" : e.Name, + category = e.Category, + model = e.GeneralModelId, + camp = e.Camp, + defaultPoi = e.Category == "monster" ? 0 : 2 + }).ToList() + }; + } + private sealed class NpcSpawnRequest { public List? Items { get; set; } diff --git a/Ananta.Server/Ananta.App/DebugPanel/index.html b/Ananta.Server/Ananta.App/DebugPanel/index.html index f5e9596..b6a4de3 100644 --- a/Ananta.Server/Ananta.App/DebugPanel/index.html +++ b/Ananta.Server/Ananta.App/DebugPanel/index.html @@ -21,6 +21,17 @@ 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; } @@ -105,17 +116,82 @@
-

👥 NPC Spawner (เสก NPC / ตัวละครเดินดิน)

-
One NPC per line: AgentConfig ID, optionally followed by a POI action ID (เช่น 40130709, 2).
- -
- - - - +

👥 NPC & Monster Spawner (เสก NPC / มอนสเตอร์ / สัตว์ / ตัวละคร)

+
มอนสเตอร์ สัตว์ และชาวเมืองในเกมใช้โครงสร้าง Agent เดียวกัน ค้นหาหรือเลือกหมวดหมู่ แล้วกด ➕ Add เพื่อเพิ่ม ID ลงในคิวเสกได้ทันที
+ + +
+ + + + + +
-
NPCs are placed in a player-relative grid in front of you.
-

+
+  
+  
+
+ +
+
+ + +
+
+ + +
+ + + + + + + + + + + + + +
หมวดIDชื่อ (Name)Model IDAction
กำลังโหลดรายชื่อ Agent...
+
+ + +
+ 📋 รายการที่จะเสก (คั่นด้วยบรรทัดใหม่: ID, POI_ID): +
+ + + + +
+
+ + +
+ + + + +
+
NPC / มอนสเตอร์จะถูกวางเป็นแถวกริดด้านหน้าตัวละครของคุณโดยอิงจากตำแหน่งและมุมหันในปัจจุบัน
+
@@ -174,36 +250,6 @@
Immediately forces client transition reset, restores user input, and rolls back coordinates to before the glitch.
-
-

Combat & Monster Spawner

-
- Monster: - - ID: -
-
- Camp: - - Count: - -
-
Spawns enemies near the player for live combat testing. Use with active weapon & combat sandbox.
-
-

🎬 Realtime 3D Cutscenes & Timelines (In-Engine 3D)

@@ -418,14 +464,129 @@ const scenePresets = { 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 = 'ไม่พบ Agent ในเงื่อนไขนี้'; + return; + } + + const catBadges = { + monster: '⚔️ Monster', + citizen: '🚶 Citizen', + police: '👮 Guard', + animal: '🐱 Animal', + ally: '🤝 Ally' + }; + + tbody.innerHTML = items.map(item => { + const badge = catBadges[item.category] || 'Agent'; + const escapedName = (item.name || '').replace(/'/g, "\\'"); + return ` + ${badge} + ${item.id} + ${item.name} + ${item.model || '-'} + + + + `; + }).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 lines = document.getElementById('npcItems').value.split(/\r?\n/).map(line => line.trim()).filter(Boolean); - const items = lines.map(line => { - const parts = line.split(/[\s,;]+/); + 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; @@ -435,7 +596,7 @@ async function spawnNpcs() { const maxPerRow = Number(document.getElementById('npcMaxPerRow').value); npcBusy = true; document.getElementById('npcSpawn').disabled = true; - output.textContent = 'Spawning NPCs...'; + output.textContent = 'Spawning NPCs/Monsters into world...'; try { const r = await api('/api/npc/spawn', { method: 'POST', @@ -443,6 +604,7 @@ async function spawnNpcs() { 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 { @@ -560,23 +722,6 @@ async function setCustomWeather() { await setWeather(id); } -function onEnemyPresetChange() { - const sel = document.getElementById('enemypreset').value; - if (sel !== 'custom') { - document.getElementById('enemyId').value = sel; - } -} - -async function spawnEnemy() { - const body = { - enemyId: parseInt(document.getElementById('enemyId').value || '40900579', 10), - camp: parseInt(document.getElementById('enemyCamp').value || '0', 10), - count: parseInt(document.getElementById('enemyCount').value || '1', 10) - }; - const r = await api('/api/enemy/spawn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - log('spawn enemy: ' + JSON.stringify(r)); -} - function onTimelinePresetChange() { const sel = document.getElementById('timelinepreset').value; if (sel !== 'custom') { @@ -595,6 +740,7 @@ async function playTimeline() { loadFleet(); loadScenes(); +loadNpcCatalog(); refresh(); setInterval(refresh, 2000); document.getElementById('timeslider').addEventListener('input', e => { diff --git a/Ananta.Server/Ananta.Core/ClientData/Client4229938/NpcCatalog4229938.cs b/Ananta.Server/Ananta.Core/ClientData/Client4229938/NpcCatalog4229938.cs index 3a7f648..32ef2ea 100644 --- a/Ananta.Server/Ananta.Core/ClientData/Client4229938/NpcCatalog4229938.cs +++ b/Ananta.Server/Ananta.Core/ClientData/Client4229938/NpcCatalog4229938.cs @@ -12,7 +12,9 @@ internal sealed record NpcCatalogEntry4229938( uint BehaviorActionConfigId, uint ActionGroupId, uint AiSettingId, - uint InteractSettingId); + uint InteractSettingId, + uint Camp = 0, + string Category = "citizen"); internal static class NpcCatalog4229938 { @@ -21,6 +23,43 @@ internal static class NpcCatalog4229938 internal static bool TryGet(uint id, out NpcCatalogEntry4229938 entry) => Cache.Value.TryGetValue(id, out entry!); + internal static IReadOnlyCollection All => Cache.Value.Values; + + internal static List Search(string? query, string? category, int limit = 150) + { + var q = query?.Trim(); + var cat = category?.Trim().ToLowerInvariant(); + var hasQuery = !string.IsNullOrWhiteSpace(q); + var hasCat = !string.IsNullOrWhiteSpace(cat) && cat != "all"; + + uint queryId = 0; + if (hasQuery) uint.TryParse(q, out queryId); + + var list = new List(); + foreach (var entry in Cache.Value.Values) + { + if (hasCat && !string.Equals(entry.Category, cat, StringComparison.OrdinalIgnoreCase)) + continue; + + if (hasQuery) + { + if (queryId != 0 && entry.Id == queryId) + { + list.Add(entry); + if (list.Count >= limit) break; + continue; + } + if (!entry.Name.Contains(q!, StringComparison.OrdinalIgnoreCase) && + !entry.Id.ToString().Contains(q!)) + continue; + } + + list.Add(entry); + if (list.Count >= limit) break; + } + return list; + } + private static Dictionary Load() { var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs); @@ -59,9 +98,27 @@ internal static class NpcCatalog4229938 var animSetTag = UInt32(row, "AnimStereotype"); if (animSetTag == 0) animSetTag = UInt32(row, "BattleAnimType"); + + var camp = UInt32(row, "Camp"); + var enemyClass = UInt32(row, "EnemyClassType"); + var isAnimal = (row.TryGetProperty("AnimalGamePlay", out var ag) && ag.ValueKind == JsonValueKind.True) || + name.Contains("鸭") || name.Contains("电台") || name.Contains("猫") || name.Contains("鸽") || name.Contains("鸟") || name.Contains("犬"); + string category; + if (isAnimal || camp == 11) + category = "animal"; + else if (camp is 0 or 26 || enemyClass > 0 || name.Contains("帮") || name.Contains("怪") || name.Contains("Boss") || name.Contains("哨兵") || name.Contains("斗士") || name.Contains("无赖") || name.Contains("狂飙") || name.Contains("狂战士") || name.Contains("史莱姆")) + category = "monster"; + else if (camp is 16 or 17 or 18 or 19 or 24 || name.Contains("警") || name.Contains("守卫") || name.Contains("保镖") || name.Contains("巡卫") || name.Contains("安保")) + category = "police"; + else if (camp == 1 || name.Contains("Seymour") || name.Contains("Taffy") || name.Contains("Bansy") || name.Contains("Garm") || name.Contains("Rin") || name.Contains("HotDog")) + category = "ally"; + else + category = "citizen"; + result[id] = new(id, name, model, persona, animSetTag, UInt32(row, "Action"), UInt32(row, "ActionGroupId"), - UInt32(row, "AI"), UInt32(row, "InteractSetting")); + UInt32(row, "AI"), UInt32(row, "InteractSetting"), + camp, category); } return result; }