454 lines
20 KiB
C#
454 lines
20 KiB
C#
using Ananta.SDK.Network;
|
||||
|
|
using Ananta.SDK.Serialization;
|
|||
|
|
using Ananta.Server.ClientData.Client4229938;
|
|||
|
|
using Ananta.Server.Configuration;
|
|||
|
|
using Ananta.Server.Protocol.Client4229938;
|
|||
|
|
using Ananta.Server.RpcTypes.Client4229938;
|
|||
|
|
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
|||
|
|
|
|||
|
|
namespace Ananta.Server.Handlers.Game;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Fifth entity channel: functional NPCs at fixed city spots (shop clerks, ticket sellers, ...).
|
|||
|
|
///
|
|||
|
|
/// Same four-step announce sequence as the citizen channels — this file does not invent a spawn
|
|||
|
|
/// path, it only supplies different coordinates. Instead of "a plot's pavement spots, how many
|
|||
|
|
/// decided by the hour" the positions are固定的: one per venue, already snapped onto pavement by
|
|||
|
|
/// the generator, so a shop clerk ends up where the shop actually is.
|
|||
|
|
///
|
|||
|
|
/// What the NPC *does* is not our problem here: F-key behaviour (open a shop panel, buy a ticket,
|
|||
|
|
/// start a mahjong match) is driven entirely by the client's own lua through
|
|||
|
|
/// AgentConfig.InteractSetting. The server just has to put a body there. That is deliberate — it
|
|||
|
|
/// is the whole reason this card is cheap.
|
|||
|
|
///
|
|||
|
|
/// Two ways in, sharing one placement helper so they can never disagree:
|
|||
|
|
/// • the follow loop (<see cref="EnsureFunctionalNpcs"/>) — how they normally appear: walk up to
|
|||
|
|
/// a shop and its clerk is standing there;
|
|||
|
|
/// • the panel button (<see cref="SpawnFunctionalNpcsAsync"/>) — "put them all down now".
|
|||
|
|
/// </summary>
|
|||
|
|
internal sealed partial class GameRouter
|
|||
|
|
{
|
|||
|
|
private const string FunctionalNpcKey = "client4229938-functional-npcs";
|
|||
|
|
|
|||
|
|
internal sealed class FunctionalNpcSessionState
|
|||
|
|
{
|
|||
|
|
internal object SyncRoot { get; } = new();
|
|||
|
|
internal CancellationTokenSource? Cts;
|
|||
|
|
internal Task? Loop;
|
|||
|
|
|
|||
|
|
/// <summary>entityId -> the spot it was placed for. Doubles as the "already placed" set.</summary>
|
|||
|
|
internal Dictionary<ulong, FunctionalNpcSpot4229938> Active { get; } = new();
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Formwork ids whose placement has been started but not finished yet.
|
|||
|
|
/// ⛑ Placement awaits several network writes and happens OUTSIDE the lock, so a tick that
|
|||
|
|
/// starts while the previous batch is still in flight cannot see it in <see cref="Active"/>
|
|||
|
|
/// and would place the same clerk again — two identical NPCs stacked on one spot.
|
|||
|
|
/// </summary>
|
|||
|
|
internal HashSet<uint> Pending { get; } = new();
|
|||
|
|
|
|||
|
|
/// <summary>Scene the <see cref="Active"/> entries belong to; a change wipes them.</summary>
|
|||
|
|
internal uint Raid;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static FunctionalNpcSessionState FunctionalNpcStateOf(TcpSession session)
|
|||
|
|
{
|
|||
|
|
if (session.Items.TryGetValue(FunctionalNpcKey, out var existing)
|
|||
|
|
&& existing is FunctionalNpcSessionState typed)
|
|||
|
|
return typed;
|
|||
|
|
|
|||
|
|
var created = new FunctionalNpcSessionState();
|
|||
|
|
session.Items[FunctionalNpcKey] = created;
|
|||
|
|
return created;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>World-ready hook: start the follow loop. Idempotent; no-op when disabled.</summary>
|
|||
|
|
internal static void EnsureFunctionalNpcs(TcpSession session)
|
|||
|
|
{
|
|||
|
|
if (!PrivateServerConfigStore.Current.Gameplay.Aether.FunctionalNpcsEnabled)
|
|||
|
|
return;
|
|||
|
|
|
|||
|
|
var owner = FunctionalNpcStateOf(session);
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (owner.Loop is { IsCompleted: false })
|
|||
|
|
return;
|
|||
|
|
owner.Cts?.Dispose();
|
|||
|
|
owner.Cts = new CancellationTokenSource();
|
|||
|
|
owner.Loop = Task.Run(() => RunFunctionalNpcLoopAsync(session, owner, owner.Cts.Token));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>Disconnect / scene switch: stop the loop. The client clears the entities itself.</summary>
|
|||
|
|
internal static void StopFunctionalNpcs(TcpSession session)
|
|||
|
|
{
|
|||
|
|
if (!session.Items.TryGetValue(FunctionalNpcKey, out var existing)
|
|||
|
|
|| existing is not FunctionalNpcSessionState owner)
|
|||
|
|
return;
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
owner.Cts?.Cancel();
|
|||
|
|
owner.Cts?.Dispose();
|
|||
|
|
owner.Cts = null;
|
|||
|
|
owner.Active.Clear();
|
|||
|
|
owner.Pending.Clear();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>How many the follow loop currently has standing. Panel display only.</summary>
|
|||
|
|
internal static int FunctionalNpcActiveCount(TcpSession session)
|
|||
|
|
{
|
|||
|
|
var owner = FunctionalNpcStateOf(session);
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
return owner.Active.Count;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task RunFunctionalNpcLoopAsync(
|
|||
|
|
TcpSession session, FunctionalNpcSessionState owner, CancellationToken token)
|
|||
|
|
{
|
|||
|
|
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
|
|||
|
|
var tick = Math.Max(500, cfg.FunctionalNpcTickMs);
|
|||
|
|
session.Log.Info($"[FUNCNPC] follow loop started enter={cfg.FunctionalNpcEnterMeters:0}m " +
|
|||
|
|
$"exit={cfg.FunctionalNpcExitMeters:0}m tick={tick}ms per-tick={cfg.FunctionalNpcPerTick}");
|
|||
|
|
|
|||
|
|
while (!token.IsCancellationRequested)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
// 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
|
|||
|
|
if (!IsBackgroundPushPaused(session))
|
|||
|
|
await TickFunctionalNpcsAsync(session, owner, cfg);
|
|||
|
|
}
|
|||
|
|
catch (OperationCanceledException)
|
|||
|
|
{
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
catch (ObjectDisposedException)
|
|||
|
|
{
|
|||
|
|
session.Log.Info("[FUNCNPC] loop stopped: session disposed");
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
session.Log.Info($"[FUNCNPC] tick failed: {ex.Message}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
await Task.Delay(tick, token);
|
|||
|
|
}
|
|||
|
|
catch (OperationCanceledException)
|
|||
|
|
{
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task TickFunctionalNpcsAsync(
|
|||
|
|
TcpSession session, FunctionalNpcSessionState owner, AetherSettings cfg)
|
|||
|
|
{
|
|||
|
|
var state = GetStateIfExists(session);
|
|||
|
|
if (state is null)
|
|||
|
|
return;
|
|||
|
|
|
|||
|
|
uint raid;
|
|||
|
|
float px, pz;
|
|||
|
|
lock (state.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
|
|||
|
|
return;
|
|||
|
|
raid = state.ActiveRaidId;
|
|||
|
|
px = state.LastReportedPlayerPosition.X;
|
|||
|
|
pz = state.LastReportedPlayerPosition.Z;
|
|||
|
|
}
|
|||
|
|
if (!float.IsFinite(px) || !float.IsFinite(pz))
|
|||
|
|
return;
|
|||
|
|
if (FunctionalNpcSpotCatalog4229938.LoadError is not null)
|
|||
|
|
return;
|
|||
|
|
|
|||
|
|
// A scene switch wiped every entity on the client, so our bookkeeping goes with it.
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (owner.Raid != raid)
|
|||
|
|
{
|
|||
|
|
owner.Active.Clear();
|
|||
|
|
owner.Pending.Clear();
|
|||
|
|
owner.Raid = raid;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 1. Retire what the player walked away from. Hysteresis (exit > enter) is the whole point:
|
|||
|
|
// a single threshold would spawn and despawn on every tick at the boundary.
|
|||
|
|
//
|
|||
|
|
// ⛑⛑ 默认不撤(Aether.FunctionalNpcRetireOnExit = false,见配置里的说明):
|
|||
|
|
// SyncAetherAINpcRemove 删不掉 8300 段那具身体(2026-09-25 实机),撤不掉却在玩家折回时
|
|||
|
|
// 重新发一具 ⇒ 同一坐标叠 2~3 具(2026-10-01「健身房里 3 个卢卡斯·高桥」)。
|
|||
|
|
// 只铺不撤时 Active 就是「本局已铺过」的账本,第 2 步的 busy 判断自然不会重铺。
|
|||
|
|
if (cfg.FunctionalNpcRetireOnExit)
|
|||
|
|
{
|
|||
|
|
var exit2 = cfg.FunctionalNpcExitMeters * cfg.FunctionalNpcExitMeters;
|
|||
|
|
var gone = new List<(ulong Id, FunctionalNpcSpot4229938 Spot)>();
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
foreach (var (id, spot) in owner.Active)
|
|||
|
|
if (spot.Raid != raid || Distance2(spot, px, pz) > exit2)
|
|||
|
|
gone.Add((id, spot));
|
|||
|
|
|
|||
|
|
foreach (var (id, spot) in gone)
|
|||
|
|
{
|
|||
|
|
await RemoveFunctionalNpcAsync(session, state, owner, id, spot);
|
|||
|
|
session.Log.Info($"[FUNCNPC] retired entity={id}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Place what the player just walked into. Two per tick at most: the client drops the tail
|
|||
|
|
// of a large batch, and these are meant to appear as you stroll past, not all at once.
|
|||
|
|
var enter2 = cfg.FunctionalNpcEnterMeters * cfg.FunctionalNpcEnterMeters;
|
|||
|
|
var wanted = new List<FunctionalNpcSpot4229938>();
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
var busy = new HashSet<uint>(owner.Active.Values.Select(s => s.NpcFormworkId));
|
|||
|
|
busy.UnionWith(owner.Pending); // ⛑ in-flight placements count as busy too
|
|||
|
|
wanted = FunctionalNpcSpotCatalog4229938.ForRaid(raid, true)
|
|||
|
|
.Where(s => !busy.Contains(s.NpcFormworkId) && Distance2(s, px, pz) <= enter2)
|
|||
|
|
.OrderBy(s => Distance2(s, px, pz))
|
|||
|
|
.Take(Math.Max(1, cfg.FunctionalNpcPerTick))
|
|||
|
|
.ToList();
|
|||
|
|
foreach (var s in wanted)
|
|||
|
|
owner.Pending.Add(s.NpcFormworkId);
|
|||
|
|
}
|
|||
|
|
if (wanted.Count == 0)
|
|||
|
|
return;
|
|||
|
|
|
|||
|
|
await SendDonorAetherInitAsync(session);
|
|||
|
|
foreach (var spot in wanted)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
await PlaceFunctionalNpcAsync(session, state, owner, spot);
|
|||
|
|
}
|
|||
|
|
finally
|
|||
|
|
{
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
owner.Pending.Remove(spot.NpcFormworkId);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task RemoveFunctionalNpcAsync(
|
|||
|
|
TcpSession session, WorldEntryState state, FunctionalNpcSessionState owner, ulong id,
|
|||
|
|
FunctionalNpcSpot4229938? spot = null)
|
|||
|
|
{
|
|||
|
|
// ★ 捐赠端 GameRouter.FunctionalNpc.cs:218:NPC 退役时要从警察登记表里摘掉,
|
|||
|
|
// 否则盘查链会一直以为这个 NPC 还在场上(控制台列表 / 押送目标都会残留)。
|
|||
|
|
await UnregisterPoliceNpc4229938(session, id);
|
|||
|
|
|
|||
|
|
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
|
|||
|
|
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = id }),
|
|||
|
|
CancellationToken.None);
|
|||
|
|
|
|||
|
|
// ⛑ Second shot under the id we actually wrote into ClientStaticNpcInitData.NpcPid.
|
|||
|
|
// The spawn packet carries three different ids (entity id / StaticNpcInfoId / NpcPid) and
|
|||
|
|
// we do not know which one the client keys its remove on; a remove that silently misses
|
|||
|
|
// leaves the old body standing and the next visit stacks a second copy on the same spot.
|
|||
|
|
// Every formwork id is used by exactly one spot per raid, so this cannot over-delete.
|
|||
|
|
if (spot is not null && spot.NpcFormworkId != 0)
|
|||
|
|
{
|
|||
|
|
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
|
|||
|
|
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938
|
|||
|
|
{
|
|||
|
|
pid = spot.NpcFormworkId,
|
|||
|
|
}),
|
|||
|
|
CancellationToken.None);
|
|||
|
|
session.Log.Info($"[FUNCNPC] retired also by formwork={spot.NpcFormworkId} ({spot.Name})");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
owner.Active.Remove(id);
|
|||
|
|
lock (state.SyncRoot)
|
|||
|
|
{
|
|||
|
|
state.StaticNpcPoses.Remove(id);
|
|||
|
|
state.StaticNpcPreparedPlotEvents.Remove(id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// ★★★ R93(2026-09-27)—— **落脚高度吸附**:把点位声明的 Y 换成「该 XZ 上真实地面/物件的高度」。
|
|||
|
|
///
|
|||
|
|
/// 为什么必须做:<c>FunctionalNpcSpots.json</c> 的 Y 是用 <b>CivilianDistricts 人行道点</b>
|
|||
|
|
/// 反向找的(见该文件 note)。那套人行道点只在**主城平地**有数据,一旦点位的 XZ 落在
|
|||
|
|
/// **高台/庙会/室内**,找出来的就是别处(常常是 y≈0/2 那一层)的 Y。
|
|||
|
|
/// 实测标本(2026-09-27):Tea Shop Owner(40954014)/Tea Stall Owner(40954022) 声明 Y=2.00,
|
|||
|
|
/// 而同 XZ 上 2528 件场景物一律 **y≈166~172** ⇒ NPC 被生成在**地下 167 m**,
|
|||
|
|
/// 现象 = 用户所说的「摊位的npc呢」(看着像根本没刷,其实刷了、埋了)。
|
|||
|
|
///
|
|||
|
|
/// 数据源 = <c>config/scene_item_placements.json</c>(mtime 热重载,4877 件带真实 y)。
|
|||
|
|
/// 取点位 XZ **半径 <paramref name="radius"/> 内最近的若干件**的 y 中位数当落脚高度:
|
|||
|
|
/// 中位数对「同一点位旁边既有台面又有地面」的情况比最近邻稳。
|
|||
|
|
/// 命中不到(荒地/新点位)⇒ **原样返回**,绝不猜。
|
|||
|
|
/// </summary>
|
|||
|
|
private static float SnapGroundY(float x, float y, float z,
|
|||
|
|
out int samples, out float snapped, float radius = 8f)
|
|||
|
|
{
|
|||
|
|
samples = 0;
|
|||
|
|
snapped = y;
|
|||
|
|
List<SceneItemPlacement> items;
|
|||
|
|
try { items = LoadSceneItemPlacements(); }
|
|||
|
|
catch { return y; }
|
|||
|
|
if (items.Count == 0)
|
|||
|
|
return y;
|
|||
|
|
|
|||
|
|
var r2 = radius * radius;
|
|||
|
|
var ys = new List<float>(16);
|
|||
|
|
foreach (var it in items)
|
|||
|
|
{
|
|||
|
|
var dx = it.X - x;
|
|||
|
|
var dz = it.Z - z;
|
|||
|
|
if (dx * dx + dz * dz > r2)
|
|||
|
|
continue;
|
|||
|
|
ys.Add(it.Y);
|
|||
|
|
}
|
|||
|
|
if (ys.Count == 0)
|
|||
|
|
return y;
|
|||
|
|
|
|||
|
|
ys.Sort();
|
|||
|
|
// 只保留「高于声明值」的候选:声明值被打错成低层(y≈2)时,低层的散件会把中位数拖回去。
|
|||
|
|
var above = ys.Where(v => v > y + 1f).ToList();
|
|||
|
|
var pool = above.Count > 0 ? above : ys;
|
|||
|
|
snapped = pool[pool.Count / 2];
|
|||
|
|
samples = pool.Count;
|
|||
|
|
return snapped;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// One placement: announce + remember. Returns the entity id, or 0 when the spot's ids are not
|
|||
|
|
/// in the catalogs (a bad row in the json must not kill the batch).
|
|||
|
|
/// </summary>
|
|||
|
|
private static async Task<ulong> PlaceFunctionalNpcAsync(
|
|||
|
|
TcpSession session, WorldEntryState state, FunctionalNpcSessionState owner,
|
|||
|
|
FunctionalNpcSpot4229938 spot)
|
|||
|
|
{
|
|||
|
|
if (!DonorNpcCatalog4229938.TryGet(spot.NpcFormworkId, out _))
|
|||
|
|
return 0;
|
|||
|
|
if (spot.PoiActionId != 0 && !DonorPoiActionCatalog4229938.TryGet(spot.PoiActionId, out _))
|
|||
|
|
return 0;
|
|||
|
|
|
|||
|
|
// ★ R93:落脚高度吸附(见 SnapGroundY 说明)。吸附不动时完全等价于原行为。
|
|||
|
|
var y = SnapGroundY(spot.X, spot.Y, spot.Z, out var samples, out var snapped);
|
|||
|
|
var yNote = samples > 0 && MathF.Abs(snapped - spot.Y) > 1f
|
|||
|
|
? $" ySnap={spot.Y:F2}->{snapped:F2}(n={samples})"
|
|||
|
|
: "";
|
|||
|
|
|
|||
|
|
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
|
|||
|
|
await SendCivilianAsync(session, new CivilianSpawnItem(spot.NpcFormworkId, spot.PoiActionId),
|
|||
|
|
id, spot.X, spot.Y, spot.Z, spot.Facing);
|
|||
|
|
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
owner.Active[id] = spot;
|
|||
|
|
lock (state.SyncRoot)
|
|||
|
|
{
|
|||
|
|
state.StaticNpcPreparedPlotEvents[id] = 0;
|
|||
|
|
state.StaticNpcPoses[id] = new StaticNpcPose(spot.X, spot.Y, spot.Z, spot.Facing);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ★ 捐赠端 GameRouter.FunctionalNpc.cs:265-266:功能 NPC 也要进警察账本,
|
|||
|
|
// 否则里希对它按原生 F 会走到 "untracked/unsupported target"。
|
|||
|
|
await RegisterPoliceNpc4229938(session, id, spot.NpcFormworkId, new Vec3(spot.X, spot.Y, spot.Z));
|
|||
|
|
_ = RefreshSpawnedPoliceNpcs4229938(session, [id], state.WorldEntryControlGeneration);
|
|||
|
|
session.Log.Info($"[FUNCNPC] placed {spot.Name}({spot.NpcFormworkId}) poi={spot.PoiActionId} " +
|
|||
|
|
$"at=({spot.X:F2},{spot.Y:F2},{spot.Z:F2}) facing={spot.Facing:F0} " +
|
|||
|
|
$"entity={id} anchor={spot.Anchor}");
|
|||
|
|
return id;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Panel button: place every functional NPC of the current scene (nearest first when a radius
|
|||
|
|
/// is given, radius <= 0 = the whole scene). Anything already standing is skipped.
|
|||
|
|
/// </summary>
|
|||
|
|
internal static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnFunctionalNpcsAsync(
|
|||
|
|
TcpSession session, float radius, bool includeSemantic)
|
|||
|
|
{
|
|||
|
|
var state = GetStateIfExists(session);
|
|||
|
|
if (state is null)
|
|||
|
|
return (false, "没有玩家世界状态", [], "");
|
|||
|
|
|
|||
|
|
uint raidId;
|
|||
|
|
Vec3 near;
|
|||
|
|
lock (state.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
|
|||
|
|
return (false, "等玩家在场景里站稳(已知道坐标)再生成", [], "");
|
|||
|
|
raidId = state.ActiveRaidId;
|
|||
|
|
near = state.LastReportedPlayerPosition;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var loadError = FunctionalNpcSpotCatalog4229938.LoadError;
|
|||
|
|
if (loadError is not null)
|
|||
|
|
return (false, $"功能 NPC 点位没加载起来:{loadError}", [], "");
|
|||
|
|
|
|||
|
|
var spots = FunctionalNpcSpotCatalog4229938.WithinRadius(
|
|||
|
|
raidId, near.X, near.Z, radius, includeSemantic);
|
|||
|
|
if (spots.Count == 0)
|
|||
|
|
return (false,
|
|||
|
|
$"当前场景(raid {raidId})没有可用的功能 NPC 点位。" +
|
|||
|
|
$"(点位文件共 {FunctionalNpcSpotCatalog4229938.Count} 个;只在主城 23300888 / 23300999 有数据)" +
|
|||
|
|
(FunctionalNpcSpotCatalog4229938.NonPersonSkipped > 0
|
|||
|
|
? $";规则 199 拦下 {FunctionalNpcSpotCatalog4229938.NonPersonSkipped} 个非人模板(物件/敌人/临时通用模型)"
|
|||
|
|
: ""), [], "");
|
|||
|
|
|
|||
|
|
var owner = FunctionalNpcStateOf(session);
|
|||
|
|
var ids = new List<ulong>();
|
|||
|
|
var lines = new List<string>();
|
|||
|
|
var skipped = new List<string>();
|
|||
|
|
string? failure = null;
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
await SendDonorAetherInitAsync(session);
|
|||
|
|
var sent = 0;
|
|||
|
|
foreach (var spot in spots)
|
|||
|
|
{
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (owner.Active.Values.Any(a => a.NpcFormworkId == spot.NpcFormworkId))
|
|||
|
|
{
|
|||
|
|
skipped.Add($"{spot.Name}(已经在场)");
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var id = await PlaceFunctionalNpcAsync(session, state, owner, spot);
|
|||
|
|
if (id == 0)
|
|||
|
|
{
|
|||
|
|
skipped.Add($"{spot.Name}(模板或 POI 不在目录里)");
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ids.Add(id);
|
|||
|
|
var function = string.Join("/", spot.Labels.Count > 0 ? spot.Labels : spot.Actions);
|
|||
|
|
lines.Add($" {spot.Name}({spot.NpcFormworkId})· {function} · {spot.Anchor}");
|
|||
|
|
|
|||
|
|
if (++sent % 20 == 0)
|
|||
|
|
await Task.Delay(10);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
failure = ex.Message;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var head = $"场景 {raidId} · 半径 {(radius <= 0 ? "不限" : radius.ToString("0") + "m")} · " +
|
|||
|
|
$"命中 {spots.Count} 个点位,实际生成 {ids.Count} 人\n";
|
|||
|
|
if (skipped.Count > 0)
|
|||
|
|
head += "跳过 " + string.Join("; ", skipped) + "\n";
|
|||
|
|
var detail = head + string.Join("\n", lines);
|
|||
|
|
return failure is null
|
|||
|
|
? (true, $"已铺 {ids.Count} 个功能 NPC", [.. ids], detail)
|
|||
|
|
: (false, $"已送出 {ids.Count} 个后失败:{failure}", [.. ids], detail);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static float Distance2(FunctionalNpcSpot4229938 spot, float x, float z)
|
|||
|
|
{
|
|||
|
|
var dx = spot.X - x;
|
|||
|
|
var dz = spot.Z - z;
|
|||
|
|
return dx * dx + dz * dz;
|
|||
|
|
}
|
|||
|
|
}
|