360 lines
17 KiB
C#
360 lines
17 KiB
C#
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) 返回**全部**场地(半径 > 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 > 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} 件没发出去)"));
|
||
}
|
||
}
|
||
}
|