using Ananta.SDK.Network; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// Party-camp furniture the server places itself — 露营的烤炉 / 帐篷 / 篝火 / 野餐垫 / 灯 / 野餐车. /// /// WHY (2026-09-26, round 44). 用户站在露营点 2.8 m 处实测「家具全无」:烤炉、帐篷一件都没有, /// 而我们**确实**把营地那一格(32,59)通过 grid-AOI 下发过了。原因和健身房器材一模一样: /// 这批 `Gadgets_Party_*` 只活在客户端的关卡 blob(LX6.Item.GadgetBlob)里,而 /// SyncGadgetGridAOIDecrease(68956797) 这条下发**不驱动** /// GadgetBlob.OnAoiAddBlobCfgByGridIndex ⇒ 烘焙里的道具一件都不会自己出现。 /// 用户那句「是不是和健身器材一样」是对的。见 。 /// /// ★ 判据(回答「是不是开派对才刷」):**不是**。家具烘焙在 raid 23300888 = 主城 free-roam, /// 而露营/舞会的 PartyConfig.RaidId 也是 23300888 ⇒ 派对就地举行、不切副本 ⇒ 场地家具只能是 /// 预先常驻的世界装点。旁证:同一批 prefab 还被当作天台夜市(-378.28,130.50,-497.87)的 /// 常驻装点摆着;营地 400 m 圈内全是改车店/公厕/时装店/象棋摊等主城常驻机关。 /// ⇒ 所以这里是**常驻摆放**(玩家走进场地半径就摆),而不是「开派对时才摆」。 /// /// ⛔ PackedGadgetInfo.iScale 必须是 0(100 ⇒ transform scale (0,0,1) 零尺寸空壳)。 /// /// Rollback: world.content.partyCamp.enabled = false. /// internal sealed partial class GameRouter { private const string PartyCampKey = "client4229938-party-camp"; internal sealed class PartyCampSessionState { internal object SyncRoot { get; } = new(); /// Camp sites already stocked this session. One shot per site, like the gym. internal HashSet Placed { get; } = new(StringComparer.Ordinal); internal int PlacedCount; internal bool WarnedEmpty; // ★ 2026-09-30 就地开局:把整片场地临时搬到玩家脚下(0 = 未启用,走配表锚点)。 internal uint VenueAtPlayerPartyId; internal float VenueAtPlayerX; internal float VenueAtPlayerY; internal float VenueAtPlayerZ; /// /// 就地开局那一刻玩家的朝向(度)。家具整片靠它旋转到玩家正前方 /// (见 )。0 = 保持官方世界方位。 /// internal float VenueAtPlayerFacingDeg; } /// /// ★★★ 2026-09-30:**就地开局** —— 把指定 partyId 的整片场地搬到玩家脚下。 /// /// /// 官方把舞会 13030003 摆在 y ≈ 80 的悬空层(那层没有地板,实机够不到), /// 所以开派对时由 的 StartSingleParty 分支调本方法, /// 把家具整片平移到玩家坐标;下一次 grid tick 就会按新坐标摆出来。 /// /// ⛑⛑ 必须同时把场地从 Placed 里摘掉: /// Placed 是「每会话每场地只摆一次」的去重 HashSet,而一次误判 /// (玩家临时站在矩形外 ⇒ INDOOR-GATE 全挡)就会把场地永久毒死 /// (2026-09-30 踩过,见 2026-09-30.md §45.2)。 /// 同理,同一会话重开派对时也必须解掉,否则第二场一件家具都没有。 /// /// 传入的 必须是地面基准(玩家上报 y − 眼高 ≈ 1.07 m), /// 不是玩家上报值 —— 角色上报的是胶囊中心,直接用会整体浮空 1 m。 /// /// 是开派对那一刻玩家的朝向(度)。partyVenuePropFacePlayer /// 打开时,家具整片会按它旋转,让最远那件(舞会 = 灯光控制台)落在玩家正前方; /// 传 0(或开关关闭)则保持官方的世界方位。 /// internal static void SetPartyVenueAtPlayer( TcpSession session, uint partyId, float x, float y, float z, float facingDeg) { var owner = PartyCampStateOf(session); lock (owner.SyncRoot) { owner.VenueAtPlayerPartyId = partyId; owner.VenueAtPlayerX = x; owner.VenueAtPlayerY = y; owner.VenueAtPlayerZ = z; owner.VenueAtPlayerFacingDeg = facingDeg; foreach (var s in PartyCampCatalog4229938.Sites) { if (s.PartyId == partyId) owner.Placed.Remove(s.Name); } } } private static PartyCampSessionState PartyCampStateOf(TcpSession session) { if (session.Items.TryGetValue(PartyCampKey, out var existing) && existing is PartyCampSessionState typed) return typed; var created = new PartyCampSessionState(); session.Items[PartyCampKey] = created; return created; } /// Reconnect / scene switch: forget what was stocked so the next visit re-places it. internal static void ResetPartyCamp(TcpSession session) { var owner = PartyCampStateOf(session); lock (owner.SyncRoot) { owner.Placed.Clear(); owner.PlacedCount = 0; owner.WarnedEmpty = false; } } /// /// Called from the grid-AOI tick (which already knows the player position). Places every camp /// the player has walked into, once per camp per session. /// internal static async Task TickPartyCampAsync( TcpSession session, WorldEntryState state, float px, float pz) { var cfg = PrivateServerConfigStore.Current.World.Content.PartyCamp; if (!cfg.Enabled) return; var owner = PartyCampStateOf(session); if (PartyCampCatalog4229938.Count == 0) { // One line per session instead of per tick — a missing table is a setup step. lock (owner.SyncRoot) { if (owner.WarnedEmpty) return; owner.WarnedEmpty = true; } session.Log.Info($"[PARTYCAMP] 没有可用场地:{PartyCampCatalog4229938.LoadError}"); return; } var channel = cfg.Channel switch { 1 => GadgetSendChannel.Aoi, 2 => GadgetSendChannel.Both, 3 => GadgetSendChannel.AddList, _ => GadgetSendChannel.Add, }; foreach (var site in PartyCampCatalog4229938.WithinRadius(px, pz, 0)) { // ★★★ 2026-09-30 就地开局:整片场地搬到玩家脚下(见 SetPartyVenueAtPlayer)。 // WithinRadius(...,0) 返回**全部**场地(半径 > 0 才过滤),所以远处开局也照样遍历得到。 float anchorX = site.X, anchorY = site.Y, anchorZ = site.Z; var venueAtPlayer = false; var venueFacingDeg = 0f; lock (owner.SyncRoot) { if (owner.VenueAtPlayerPartyId != 0 && owner.VenueAtPlayerPartyId == site.PartyId) { anchorX = owner.VenueAtPlayerX; anchorY = owner.VenueAtPlayerY; anchorZ = owner.VenueAtPlayerZ; venueFacingDeg = owner.VenueAtPlayerFacingDeg; venueAtPlayer = true; } } var radius = site.Radius + cfg.RadiusBonus; if (radius > 0f) { var dx = anchorX - px; var dz = anchorZ - pz; if (dx * dx + dz * dz > radius * radius) continue; } lock (owner.SyncRoot) { if (!owner.Placed.Add(site.Name)) continue; } // 家具跟着锚点整体平移(相对摆位照抄官方,只是换了个落脚点)。 // ⛑ 这里先算出列表,真正赋给 `items` 要等它声明之后(闸门那段会 reassign)。 IReadOnlyList? shiftedItems = null; if (venueAtPlayer) { // ★ 就地开局:以**动态锚**为原点重放官方的相对摆位。 // `partyVenuePropMaxDistance > 0` 时再把水平距离钳到该上限(只收距离、不改方向), // 否则官方那 20 m 外的灯光控制台搬到脚下依然够不到。 var clamp = PrivateServerConfigStore.Current.Gameplay.PartyVenuePropMaxDistance; // ★★ 朝向对齐(2026-09-30):官方布局的方位是**写死的世界方向** // (舞会灯光控制台在锚点的 +x/+z、约 76°)。整片平移到玩家脚下后方位不变 // ⇒ 玩家很可能背对着它,实测反馈「看不到控制台」(它在 6 m 外,却在身后)。 // 这里以「离锚点最远那件」当枢轴,把整片相对摆位**旋转**到玩家正前方。 // ⛑ 方位角约定与 NPC 散布一致:方向向量 = (sin θ, cos θ) ⇒ θ = atan2(x, z)。 var yaw = 0f; if (PrivateServerConfigStore.Current.Gameplay.PartyVenuePropFacePlayer) { float pivotBearing = 0f, pivotDist2 = -1f; var pivotName = string.Empty; foreach (var it in site.Items) { var ox0 = it.X - site.X; var oz0 = it.Z - site.Z; var d2 = ox0 * ox0 + oz0 * oz0; if (d2 > pivotDist2) { pivotDist2 = d2; pivotBearing = MathF.Atan2(ox0, oz0) * 180f / MathF.PI; pivotName = it.Name; } } if (pivotDist2 > 0f) { var delta = venueFacingDeg - pivotBearing; yaw = delta * MathF.PI / 180f; session.Log.Info($"[PARTYCAMP] 就地开局朝向对齐:枢轴={pivotName} " + $"官方方位={pivotBearing:F1}°、玩家朝向={venueFacingDeg:F1}° " + $"⇒ 整片旋转 {delta:F1}°(最远那件落到玩家正前方)"); } } var cosY = MathF.Cos(yaw); var sinY = MathF.Sin(yaw); var moved = new List(site.Items.Count); foreach (var it in site.Items) { var ox = it.X - site.X; var oy = it.Y - site.Y; var oz = it.Z - site.Z; // 旋转(保长度,只改方位):(sinθ,cosθ)·d → (sin(θ+yaw),cos(θ+yaw))·d if (yaw != 0f) { var rx = ox * cosY + oz * sinY; var rz = -ox * sinY + oz * cosY; ox = rx; oz = rz; } if (clamp > 0f) { var d = MathF.Sqrt(ox * ox + oz * oz); if (d > clamp && d > 0f) { var k = clamp / d; ox *= k; oz *= k; } } moved.Add(it with { X = anchorX + ox, Y = anchorY + oy, Z = anchorZ + oz }); } shiftedItems = moved; } var items = shiftedItems ?? site.Items; // ★ 2026-09-30:按 pathId 跳过不想摆的家具(例:舞会那道「离开舞会的门」)。 // `world.content.partyCamp.skipPathIds`,默认空 = 一件不少。 if (cfg.SkipPathIds.Length > 0) { var kept0 = new List(items.Count); foreach (var it in items) { // ⛑ PathId 是 int、配置是 uint[] ⇒ 必须显式转换,否则 Enumerable.Contains // 匹配不上(CS1929:只会去找 MemoryExtensions.Contains)。 if (cfg.SkipPathIds.Contains((uint)it.PathId)) { session.Log.Info($"[PARTYCAMP] {site.Name} 家具 {it.Name}({it.PathId}) " + $"在 skipPathIds 里 ⇒ 跳过"); continue; } kept0.Add(it); } items = kept0; } // ★★★ 第 50 轮(室内层判定):场地家具逐件过门。 // 落在「VisibleOutside = false」的室内占地里、而玩家不在其中的那几件, // **不摆**。标本:舞会场地(danceOnly)的两件常驻机关 // (Gadgets_Party_DanceDoor 514708797 / Gadgets_Party_LightControl 1341214683) // 在 IndoorConfig#150 "Spiko Hotel" 的占地里,我们却把它们摆到露天 y≈80 // ⇒ 现场「道具在半空、地板不在」。 if (PrivateServerConfigStore.Current.Gameplay.IndoorVisibilityGate) { var kept = new List(items.Count); var held = 0; foreach (var it in items) { if (IndoorRoomCatalog4229938.TryFindHiddenAt(it.X, it.Z, site.Raid, out var hiddenItemRoom) && !hiddenItemRoom.Contains(px, pz)) { held++; session.Log.Warn($"[INDOOR-GATE] {site.Name} 家具 {it.Name}({it.PathId}) " + $"@({it.X:F1},{it.Y:F1},{it.Z:F1}) 位于不可见室内 " + $"{hiddenItemRoom.Name}(IndoorConfig#{hiddenItemRoom.Id})且玩家不在其中" + $" ⇒ 本件不摆。"); continue; } kept.Add(it); } items = kept; if (held > 0 && items.Count == 0) { session.Log.Warn($"[INDOOR-GATE] {site.Name} 整个场地都在不可见室内 ⇒ 本批一件都不摆" + $"(不再按露天刷)。想恢复:gameplay.indoorVisibilityGate=false。"); } } session.Log.Info($"[PARTYCAMP] 进入 {site.Name}(partyId={site.PartyId} raid={site.Raid} " + $"锚点 {anchorX:F1},{anchorY:F1},{anchorZ:F1} 半径 {radius:F0}m" + (venueAtPlayer ? " ★就地开局(玩家坐标)" : "") + ")," + $"放置 {items.Count} 件家具 channel={channel} logOnly={cfg.LogOnly}" + (items.Count == site.Items.Count ? "" : $"(原始 {site.Items.Count} 件,室内层判定挡掉 {site.Items.Count - items.Count} 件)")); if (cfg.LogOnly) continue; var placed = 0; if (channel == GadgetSendChannel.AddList) { var specs = items .Select(i => new GadgetSpawnSpec(i.PathId, i.X, i.Y, i.Z, i.Yaw, i.UniqueId, i.Name)) .ToList(); var batch = await SpawnGadgetListAsync(session, state, specs, cfg.IScale); placed = batch.Ok ? batch.Count : 0; if (!batch.Ok) session.Log.Warn($"[PARTYCAMP] {site.Name} 批量放置失败:{batch.Message}"); } else { foreach (var item in items) { // uniqueId 0 ⇒ 服务端自己 mint 一个(PartyCamp.json 里那些 88 B 记录没有 uid)。 var result = await SpawnGadgetAsync( session, state, item.PathId, item.X, item.Y, item.Z, item.Yaw, channel, item.UniqueId, cfg.IScale); if (result.Ok) placed++; else session.Log.Warn($"[PARTYCAMP] {site.Name} {item.Name}({item.PathId}) 放置失败:{result.Message}"); } } lock (owner.SyncRoot) owner.PlacedCount += placed; session.Log.Info($"[PARTYCAMP] {site.Name} 放置完成 {placed}/{items.Count}" + (placed == items.Count ? "" : $"({items.Count - placed} 件没发出去)")); } } }