Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.MiniGameStall.cs

322 lines
16 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;
using Ananta.Server.RpcTypes.Client4229938; // ★ 刮刮乐:ScratchWire.SubTypeLevelOne
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 龙栖村(新祈村)庙会摊位「布景」—— 服务端主动把摊位机关摆到官方世界坐标上。
///
/// ★ 用户的原话:「现在能像别人服务那样把龙栖村所有的摊位布景都一起刷出来吗」
///
/// ── 与对照包的关系(第 89 轮已核实,别再多花时间)────────────────────────────
/// 对照包 `DRMK-PS_08.05_Lyco-v0.2.1` 里**没有任何摊位摆位代码**:
/// grep SyncGadgetAdd|GadgetEntityInfo|MiniGameStall 只命中它自己的
/// GameRouter.cs / GameRouter.SceneDeviceState.cs / MethodId.cs。
/// 它只做了**打气球一个**小游戏的完整链路,其余摊位(套圈/刮刮乐/五子棋…)
/// 连 wire 契约都没有(它的 Ananta.RpcTypes/Methods/Game/ 只有 BalloonZone + RacingZone)。
/// ⇒ 「像别人那样把摊位刷出来」这件事,**我方是走在前面的一方**,不是补课。
/// 对照包它自己也只有一句「入口空 ACK」,摊位本体照样看不见。
///
/// ── 为什么必须服务端摆(与健身房器材 / 派对家具 / 补货物件完全同因)────────────
/// 这批摊位机关(Gadget_DaQiQiu 912387408 / Gadget_RingToss 1937674505 /
/// ScratchGame 165071345 / Gadget_WuZiQi 2066229997 …)的真身分两半:
/// ① 客户端 Spoon 图里有一个 GADGET 节点(StartBalloonNode / StartRingTossNode …)
/// ② 关卡块(vfc_1479 / vfc_859)里有一份**官方世界摆位**
/// 而它**不在**客户端会自己实例化的清单里 —— grid-AOI 下发(68956797)**不驱动**
/// <c>LX6.Item.GadgetBlob.OnAoiAddBlobCfgByGridIndex</c> ⇒ 一件都不会自己出现。
/// 唯一可行路径 = 服务端拿 pathId + 官方坐标显式走 SyncGadgetAdd。
///
/// ── 数据来源(别再搞混两套 id 空间)────────────────────────────────────────
/// pathId = <c>ClientData/4229938/Configs/GadgetCatalog.json</c>(2935 条,服务端用)
/// ⛔ 不是客户端 <c>GadgetConfig.json</c> 的 1367 条(另一套;
/// 同一件 Gadget_DaQiQiu 在那边是 79304020,在服务端 catalog 是 912387408)
/// 世界坐标 = <c>Configs/MiniGameStalls.json</c>(解自关卡块,工具
/// gym_tools/_r87_minigame_gadget.py,记录结构 = 命中点-28 起头,
/// +0/+4/+8 = f32 x/y/z,+16 = yaw,+28 = pathId)
///
/// ── 判据(回答「是不是要按 F 才开始摆」)──────────────────────────────────
/// **不是。** 摆位是「玩家走进锚点半径」触发的常驻世界装点,一次会话每片区只摆一次。
/// 与玩家有没有交互、摊位开没开、Lua 有没有跑到那里**都无关** —— 这是纯服务端行为。
/// 反过来说:**看得见摊位 ≠ 摊位能玩**。能玩要各自的小游戏入口 RPC
/// (打气球 EnterBalloonZone 已实现;套圈 EnterRingTossZone 67108425 见下)。
///
/// ⛔ <c>PackedGadgetInfo.iScale</c> 必须是 **0**(100 ⇒ transform scale (0,0,1) 零尺寸空壳,
/// 看得见实体、看不见模型)。与气球的 <c>DestructibleInfo.IScale = 1000</c> 是
/// **同名不同尺度的两个字段**(铁律 39;本条在 R89 上真踩过一次,别再来)。
///
/// Rollback: <c>world.content.miniGameStalls.enabled = false</c>(config 热重载,不需重编译)。
/// </summary>
internal sealed partial class GameRouter
{
private const string MiniGameStallKey = "client4229938-minigame-stall";
/// <summary>
/// ★ 刮刮乐桌的 pathId —— 走**服务端** catalog 空间
/// (<c>ClientData/4229938/Configs/GadgetCatalog.json</c>),不是客户端
/// <c>GadgetConfig.json</c> 的 1367 条那套(铁律:两个 id 空间别混)。
/// 数据侧:<c>Configs/MiniGameStalls.json</c> 的 <c>{ "pathId": 165071345, "name": "ScratchGame" }</c>。
/// </summary>
private const int ScratchTablePathId = 165071345;
internal sealed class MiniGameStallSessionState
{
internal object SyncRoot { get; } = new();
/// <summary>摊位片区本轮已摆过的名字。一次会话每片区一次,和健身房 / 营地一样。</summary>
internal HashSet<string> Placed { get; } = new(StringComparer.Ordinal);
internal int PlacedCount;
/// <summary>空表只抱怨一次,不每 tick 刷屏。</summary>
internal bool WarnedEmpty;
/// <summary>只记一次「跳过了几件」的明细,避免每片区重复。</summary>
internal HashSet<int> SkipLogged { get; } = new();
// ── ★ 刮刮乐「走近自动叫面板」的状态 ──────────────────────────────
/// <summary>服务端自己 mint 出来的 ScratchGame 桌 instanceId(`[GADGET] add id=…`)。</summary>
internal ulong ScratchGadgetUid;
internal float ScratchX = float.NaN;
internal float ScratchZ = float.NaN;
/// <summary>上一刻玩家是否在圈内(迟滞用)。</summary>
internal bool ScratchNear;
/// <summary>本轮是否已推过(进圈只推一次)。</summary>
internal bool ScratchOffered;
}
private static MiniGameStallSessionState MiniGameStallStateOf(TcpSession session)
{
if (session.Items.TryGetValue(MiniGameStallKey, out var existing)
&& existing is MiniGameStallSessionState typed)
return typed;
var created = new MiniGameStallSessionState();
session.Items[MiniGameStallKey] = created;
return created;
}
/// <summary>重连 / 切场景:忘掉已摆记录,下次进图重摆(客户端那边实例已随会话消失)。</summary>
internal static void ResetMiniGameStall(TcpSession session)
{
var owner = MiniGameStallStateOf(session);
lock (owner.SyncRoot)
{
owner.Placed.Clear();
owner.PlacedCount = 0;
owner.WarnedEmpty = false;
owner.SkipLogged.Clear();
owner.ScratchGadgetUid = 0;
owner.ScratchX = float.NaN;
owner.ScratchZ = float.NaN;
owner.ScratchNear = false;
owner.ScratchOffered = false;
}
}
/// <summary>
/// 由网格 AOI tick 调用(它已经知道玩家位置)。把玩家走进的每个摊位片区的机关摆一次。
/// </summary>
internal static async Task TickMiniGameStallAsync(
TcpSession session, WorldEntryState state, float px, float pz)
{
var cfg = PrivateServerConfigStore.Current.World.Content.MiniGameStalls;
if (!cfg.Enabled)
return;
var owner = MiniGameStallStateOf(session);
if (MiniGameStallCatalog4229938.Count == 0)
{
// 每会话一行即可 —— 缺数据文件是「还没抓点位」这个准备步骤,不是运行时故障。
lock (owner.SyncRoot)
{
if (owner.WarnedEmpty)
return;
owner.WarnedEmpty = true;
}
session.Log.Info($"[STALL] 没有可用摊位:{MiniGameStallCatalog4229938.LoadError}");
return;
}
var channel = cfg.Channel switch
{
1 => GadgetSendChannel.Aoi,
2 => GadgetSendChannel.Both,
3 => GadgetSendChannel.AddList,
_ => GadgetSendChannel.Add,
};
foreach (var spot in MiniGameStallCatalog4229938.WithinRadius(px, pz, 0))
{
var radius = spot.Radius + cfg.RadiusBonus;
if (radius > 0f)
{
var dx = spot.X - px;
var dz = spot.Z - pz;
if (dx * dx + dz * dz > radius * radius)
continue;
}
lock (owner.SyncRoot)
{
if (!owner.Placed.Add(spot.Name))
continue;
}
if (spot.Items.Count == 0)
{
session.Log.Info($"[STALL] 片区 {spot.Name} 还没有摊位坐标(先跑 _r87_minigame_gadget.py)");
continue;
}
session.Log.Info($"[STALL] 进入 {spot.Name}(raid={spot.Raid} 锚点 {spot.X:F1},{spot.Z:F1} " +
$"半径 {radius:F0}m),放置 {spot.Items.Count} 件摊位 channel={channel} " +
$"logOnly={cfg.LogOnly}");
if (cfg.LogOnly)
continue;
var placed = 0;
if (channel == GadgetSendChannel.AddList)
{
// 批量:一次推完整个片区。uniqueId=0 ⇒ 服务端自己 mint。
var specs = spot.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($"[STALL] {spot.Name} 批量放置失败:{batch.Message}");
}
else
{
foreach (var item in spot.Items)
{
// iScale 传 0 —— ⛔ 这是 PackedGadgetInfo.iScale 的语义,
// 与气球的 DestructibleInfo.IScale=1000 不是一个字段(铁律 39)。
var result = await SpawnGadgetAsync(
session, state, item.PathId, item.X, item.Y, item.Z, item.Yaw, channel,
item.UniqueId, 0);
if (result.Ok)
{
placed++;
// ★ 刮刮乐桌:记住服务端 mint 出来的 instanceId —— 推面板要用它
// (配表里 UniqueId = 0 ⇒ 只有 SpawnGadgetAsync 的返回值可信)。
if (item.PathId == ScratchTablePathId)
{
lock (owner.SyncRoot)
{
owner.ScratchGadgetUid = result.Id;
owner.ScratchX = item.X;
owner.ScratchZ = item.Z;
}
}
session.Log.Info($"[STALL] + {item.Name}({item.PathId}) " +
$"@({item.X:F2},{item.Y:F2},{item.Z:F2}) yaw={item.Yaw:F1} " +
$"uid={item.UniqueId}");
}
else
{
session.Log.Warn($"[STALL] {spot.Name} {item.Name}({item.PathId}) 放置失败:{result.Message}");
}
}
}
lock (owner.SyncRoot)
owner.PlacedCount += placed;
session.Log.Info($"[STALL] {spot.Name} 放置完成 {placed}/{spot.Items.Count}" +
(placed == spot.Items.Count ? "" : $"({spot.Items.Count - placed} 件没发出去)"));
// ★ 摆完之后看一眼玩家是不是正站在刮刮乐桌前(见 MaybeOfferScratchAsync)。
await MaybeOfferScratchAsync(session, owner, px, pz);
}
}
/// <summary>
/// ★★★ 玩家走近刮刮乐桌 ⇒ 推一条 <c>SyncStartScratchGameZone</c>(68649573),把面板叫出来。
///
/// ── 为什么必须服务端「主动」推 ────────────────────────────────────────
/// 客户端**不发** <c>AskStartScratchGame</c>:它在 2349 个 Lua 文件里 0 命中,
/// 9 份 <c>packets-*.log</c> 里也 0 命中(服务端 handler 已登记白名单、从未被调用过)。
/// 而刮刮乐面板的**唯一**入口是服务端推 <c>SyncStartScratchGameZone</c>:
/// <c>GameSceneToClientImpl.lua:1131</c> → <c>gScratchGameManager:Show(gadgetUid, subType, result)</c>
/// <c>ScratchGameManager.lua:7</c>:<c>result == nil</c> ⇒ return;否则
/// <c>GetEntitySearchByInstanceId(gadgetUid)</c> → <c>RegisterWaitLoad</c> →
/// <c>Find("ItemViewNode/UIPose")</c> → <c>CheckShow(SCRATCHCARD_PANEL, …)</c>。
/// ⇒ 服务端不推,「通用交互提示」永远只是提示、没有选项。
/// 详细 wire 见 <c>GameRouter.MiniGameEntry.PushScratchStartAsync</c>。
///
/// ── 迟滞(别在边界上连推)────────────────────────────────────────────
/// 进圈(dist ≤ <c>ScratchOfferRadius</c>)推**一次**;出圈超过 <c>ScratchRearmRadius</c>
/// 才复位,允许再进来再推。玩家一直站在圈里 ⇒ 只推一条。
///
/// ── 前提 ─────────────────────────────────────────────────────────
/// 桌子的 <c>gadgetUid</c> 必须是**服务端自己 mint 的那个**(<c>[GADGET] add id=…</c>)——
/// 配表里 <c>UniqueId = 0</c>,所以只有 <c>SpawnGadgetAsync</c> 的返回值可信。
/// 没摆到(uid==0)或走 <c>AddList</c> 批量通道(那条路目前不回收 id)⇒ 直接返回,什么都不做。
///
/// Rollback: <c>world.content.miniGameStalls.scratchAutoOpen = false</c>(配置热重载,不用重编译)。
/// </summary>
private static async Task MaybeOfferScratchAsync(
TcpSession session, MiniGameStallSessionState owner, float px, float pz)
{
var cfg = PrivateServerConfigStore.Current.World.Content.MiniGameStalls;
if (!cfg.ScratchAutoOpen)
return;
ulong gadgetUid;
float tx, tz;
lock (owner.SyncRoot)
{
gadgetUid = owner.ScratchGadgetUid;
tx = owner.ScratchX;
tz = owner.ScratchZ;
}
if (gadgetUid == 0 || float.IsNaN(tx) || float.IsNaN(tz))
return;
var dx = tx - px;
var dz = tz - pz;
var dist2 = dx * dx + dz * dz;
var offerR = MathF.Max(0.5f, cfg.ScratchOfferRadius);
var rearmR = MathF.Max(offerR + 0.5f, cfg.ScratchRearmRadius); // 迟滞必须 > 触发半径
var shouldPush = false;
lock (owner.SyncRoot)
{
if (dist2 <= offerR * offerR)
{
if (!owner.ScratchNear)
{
owner.ScratchNear = true;
if (!owner.ScratchOffered)
{
owner.ScratchOffered = true;
shouldPush = true;
}
}
}
else if (dist2 > rearmR * rearmR)
{
owner.ScratchNear = false;
owner.ScratchOffered = false;
}
}
if (!shouldPush)
return;
await PushScratchStartAsync(session, gadgetUid, ScratchWire.SubTypeLevelOne);
}
}