Files

360 lines
17 KiB
C#
Raw Permalink 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.
using Ananta.SDK.Network;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Party-camp furniture the server places itself — 露营的烤炉 / 帐篷 / 篝火 / 野餐垫 / 灯 / 野餐车.
///
/// WHY (2026-09-26, round 44). 用户站在露营点 2.8 m 处实测「家具全无」:烤炉、帐篷一件都没有,
/// 而我们**确实**把营地那一格(32,59)通过 grid-AOI 下发过了。原因和健身房器材一模一样:
/// 这批 `Gadgets_Party_*` 只活在客户端的关卡 blob(<c>LX6.Item.GadgetBlob</c>)里,而
/// <c>SyncGadgetGridAOIDecrease(68956797)</c> 这条下发**不驱动**
/// <c>GadgetBlob.OnAoiAddBlobCfgByGridIndex</c> ⇒ 烘焙里的道具一件都不会自己出现。
/// 用户那句「是不是和健身器材一样」是对的。见 <see cref="TickGymEquipmentAsync"/>。
///
/// ★ 判据(回答「是不是开派对才刷」):**不是**。家具烘焙在 raid 23300888 = 主城 free-roam,
/// 而露营/舞会的 PartyConfig.RaidId 也是 23300888 ⇒ 派对就地举行、不切副本 ⇒ 场地家具只能是
/// 预先常驻的世界装点。旁证:同一批 prefab 还被当作天台夜市(-378.28,130.50,-497.87)的
/// 常驻装点摆着;营地 400 m 圈内全是改车店/公厕/时装店/象棋摊等主城常驻机关。
/// ⇒ 所以这里是**常驻摆放**(玩家走进场地半径就摆),而不是「开派对时才摆」。
///
/// ⛔ <c>PackedGadgetInfo.iScale</c> 必须是 0(100 ⇒ transform scale (0,0,1) 零尺寸空壳)。
///
/// Rollback: <c>world.content.partyCamp.enabled = false</c>.
/// </summary>
internal sealed partial class GameRouter
{
private const string PartyCampKey = "client4229938-party-camp";
internal sealed class PartyCampSessionState
{
internal object SyncRoot { get; } = new();
/// <summary>Camp sites already stocked this session. One shot per site, like the gym.</summary>
internal HashSet<string> 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;
/// <summary>
/// 就地开局那一刻玩家的朝向(度)。家具整片靠它旋转到玩家正前方
/// (见 <see cref="SetPartyVenueAtPlayer"/>)。0 = 保持官方世界方位。
/// </summary>
internal float VenueAtPlayerFacingDeg;
}
/// <summary>
/// ★★★ 2026-09-30:**就地开局** —— 把指定 partyId 的整片场地搬到玩家脚下。
/// </summary>
/// <remarks>
/// 官方把舞会 13030003 摆在 <c>y ≈ 80</c> 的悬空层(那层没有地板,实机够不到),
/// 所以开派对时由 <see cref="GameRouter"/> 的 StartSingleParty 分支调本方法,
/// 把家具整片平移到玩家坐标;下一次 grid tick 就会按新坐标摆出来。
///
/// ⛑⛑ <b>必须同时把场地从 <c>Placed</c> 里摘掉</b>:
/// <c>Placed</c> 是「每会话每场地只摆一次」的去重 HashSet,而一次误判
/// (玩家临时站在矩形外 ⇒ INDOOR-GATE 全挡)就会把场地永久毒死
/// (2026-09-30 踩过,见 <c>2026-09-30.md</c> §45.2)。
/// 同理,同一会话<b>重开</b>派对时也必须解掉,否则第二场一件家具都没有。
///
/// 传入的 <paramref name="y"/> 必须是<b>地面基准</b>(玩家上报 y − 眼高 ≈ 1.07 m),
/// 不是玩家上报值 —— 角色上报的是胶囊中心,直接用会整体浮空 1 m。
///
/// <paramref name="facingDeg"/> 是开派对那一刻玩家的朝向(度)。<c>partyVenuePropFacePlayer</c>
/// 打开时,家具整片会按它旋转,让<b>最远那件(舞会 = 灯光控制台)落在玩家正前方</b>;
/// 传 0(或开关关闭)则保持官方的世界方位。
/// </remarks>
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;
}
/// <summary>Reconnect / scene switch: forget what was stocked so the next visit re-places it.</summary>
internal static void ResetPartyCamp(TcpSession session)
{
var owner = PartyCampStateOf(session);
lock (owner.SyncRoot)
{
owner.Placed.Clear();
owner.PlacedCount = 0;
owner.WarnedEmpty = false;
}
}
/// <summary>
/// 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.
/// </summary>
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) 返回**全部**场地(半径 &gt; 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<PartyCampItem4229938>? shiftedItems = null;
if (venueAtPlayer)
{
// ★ 就地开局:以**动态锚**为原点重放官方的相对摆位。
// `partyVenuePropMaxDistance &gt; 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<PartyCampItem4229938>(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<PartyCampItem4229938>(items.Count);
foreach (var it in items)
{
// ⛑ PathId 是 int、配置是 uint[] ⇒ 必须显式转换,否则 Enumerable.Contains
// 匹配不上(CS1929:只会去找 MemoryExtensions.Contains<int>)。
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<PartyCampItem4229938>(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} 件没发出去)"));
}
}
}