1700 lines
98 KiB
C#
1700 lines
98 KiB
C#
// Balloon ("打气球") zone handlers for client build 4229938.
|
||
//
|
||
// WHY THIS FILE EXISTS
|
||
// The client's balloon mini-game is complete and self-contained
|
||
// (lua\LX6\SGUI\StoreDefine\BalloonGameStore.lua + Manager\MiniGame\MiniGameDataManager.lua),
|
||
// but the server side had NO implementation at all. Both RPCs were being answered by
|
||
// DefaultReturnCatalog4229938's typed-default shells:
|
||
//
|
||
// [RPC4229938] unimplemented EnterBalloonZone [0x0404B74F] -> typed-default complex(...) 51b
|
||
// [RPC4229938] unimplemented LeaveBalloon [0x03FF7BF6] -> typed-default void 0b
|
||
//
|
||
// Live evidence (logs\packets-20260927-152501.log) — the player entered the stall and then
|
||
// pressed "返回" SIX times over ~70 s, every one of them answered error=0:
|
||
//
|
||
// 15:26:28.344 C2S INVOKE EnterBalloonZone [0x0404B74F] invoke=109695828 body=9b
|
||
// 15:26:28.347 S2C RETURN EnterBalloonZone [0x0404B74F] invoke=109695828 error=0 body=51b
|
||
// 15:26:32.194 C2S INVOKE LeaveBalloon [0x03FF7BF6] invoke=109695831 body=8b
|
||
// 15:26:32.197 S2C RETURN LeaveBalloon [0x03FF7BF6] invoke=109695831 error=0 body=0b
|
||
// ... x6 ...
|
||
//
|
||
// WHY error=0 STILL MEANT "退不出来"
|
||
// BalloonGameStore.OnClickBackBtn (lua, decompiled):
|
||
// if gMiniGameDataManager.curBalloonGadgetId == 0 then
|
||
// gClientToGameSceneDelegate:LeaveBalloon(gMiniGameDataManager.curBalloonGadgetId)
|
||
// .Callback = function (err, data)
|
||
// if err == LTConfig.MessageConfig.Ok then return end -- ★ Ok -> do nothing
|
||
// L50.L50App.Scene.ZoneGamePlayManager:EndContext()
|
||
// end
|
||
// end
|
||
// The decompiled branch direction is suspect (JMPF/JMPT), but the load-bearing fact is that
|
||
// replying Ok to LeaveBalloon does NOT close the panel either way.
|
||
//
|
||
// THE PATH THAT DOES CLOSE THE PANEL —— ★ R88m CORRECTION, READ THIS FIRST
|
||
//
|
||
// R88l claimed the close path was SyncGameGroundZoneInfo + StopBalloon. That was WRONG on the
|
||
// channel, right on the symptom. The zone gameplay context (which is what the balloon panel
|
||
// hangs off) is owned by C#, not by Lua:
|
||
//
|
||
// C# L50.Gameplay.ZoneGamePlay.ZoneGamePlayManager
|
||
// StartContext / EndContext / SyncGameGroundZoneState / SyncBalloonGameScore
|
||
// C# L50.Gameplay.ZoneGamePlay.BalloonGamePlayContext
|
||
// OnStarted / OnGameEnd / OnEnded / SyncBalloonGameScore / OnSceneItemBreak
|
||
// (Assets\ScriptNew\L50Game\GamePlay\ZoneGamePlay\BalloonGamePlayContext.cs;
|
||
// the Lua-visible arm is the ToLua wrapper
|
||
// L50_Gameplay_ZoneGamePlay_ZoneGamePlayManagerWrap — which is why a Lua-only search
|
||
// for balloon callers finds exactly one hit: BalloonGameStore.OnClickBackBtn's EndContext().)
|
||
//
|
||
// So the server must own TWO channels, not one:
|
||
//
|
||
// 1) EnterBalloonZone (67417935, Invoke): return a REAL BalloonZoneInfo object.
|
||
// ★ Unlike EnterBowlingZone — whose return is a bare u64 — this RPC's return reader is
|
||
// midToReturnMessageReader[67417935] = Base.ReadComplex(reader, Auto.Reader[680]).
|
||
// Returning the 51-byte all-zero shell left ZoneSessionId empty, so no session ever
|
||
// formed and nothing downstream was reachable.
|
||
// 2) Push SyncGameGroundZoneInfo (68004609, NOTIFY) with the same BalloonZoneInfo (tag 3).
|
||
// NOTE: this one is consumed by an OLDER Lua dispatcher whose branch table has NO balloon
|
||
// arm at all (Dart/Bowling/Racing/BBQ/ChineseChess/RingToss/Scratch). Kept for fidelity;
|
||
// it is NOT what opens or closes anything for balloon.
|
||
// 3) ★★★ Push SyncGameGroundZoneState (68383444, NOTIFY) — "started" on enter,
|
||
// GameOver + Dispose on leave. THIS is the channel ZoneGamePlayManager listens on.
|
||
// Omitting it means the zone context is never entered, so EndContext() has nothing to
|
||
// tear down and the panel cannot close — i.e. exactly the "退不出来" the player reported.
|
||
//
|
||
// SCORE PUSH: SyncBalloonGameScore (64893406) payload is a BARE Int32
|
||
// (midToReader[64893406] = function(reader) local score = reader.ReadInt32(reader) return score end).
|
||
// We push 0 at zone start and again at leave, which is what makes OnGameEnd rank "endType = 0"
|
||
// (score below Balloon_NormalEndCredit). The client computes the actual hits locally.
|
||
//
|
||
// SERIALIZATION: see BalloonWireContracts.cs — the byte layouts come from the client's own Lua
|
||
// serializer/reader (RPCSerializeAuto.Auto.WriteBalloonZoneInfo / RPCDeserializeAuto.Auto.Reader[680]),
|
||
// NOT from Drmk.Proxy/proxy/rpc_schema.json (stale older build).
|
||
using System.Collections.Concurrent;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938; // R110: MiniGameStallCatalog4229938(官方摊位位姿)
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.RpcTypes.Client4229938;
|
||
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>Per-connection balloon session, parked in Session.Items like the bowling one.</summary>
|
||
private const string BalloonStateKey = "client4229938-balloon";
|
||
|
||
// 局内 session 后缀(只进日志与 ZoneSessionId 字符串,不是实体 id,但同样落盘保持单调)。
|
||
private const string BalloonZoneSeqCounter = "Balloon.ZoneSeq";
|
||
|
||
/// <summary>
|
||
/// ★★★ R89 — 进气球区前的手持武器实例 ID,退出时还原(key = spirit unitId)。
|
||
/// 用 TryAdd:重复进入同一局不会把「原来的枪」覆盖成玩具枪。
|
||
/// </summary>
|
||
private static readonly ConcurrentDictionary<ulong, ulong> BalloonSavedWeapon = new();
|
||
|
||
/// <summary>
|
||
/// ★★★ R89 — 当前一轮气球靶:key = spirit unitId,value =(气球实例 ID → 气球 cfgId)。
|
||
/// 只在 EnterBalloonZone 建立、LeaveBalloon 清空,用来判定「打爆的是不是本轮的气球」。
|
||
/// </summary>
|
||
private static readonly ConcurrentDictionary<ulong, Dictionary<ulong, uint>> BalloonTargets = new();
|
||
|
||
/// <summary>
|
||
/// ★ R141(2026-09-28)—— side-channel 落盘路径的「报一次」标志。
|
||
///
|
||
/// WHY:side-channel(把本局气球的真值落盘给客户端注入器读)原先写死
|
||
/// <c>D:\Ananta\gym_tools\_balloon_push.json</c> ⇒ **整包发给别人 / 换盘符 / 换个目录就断**:
|
||
/// 服务端在写 A、注入器在读 B ⇒ <c>pushN</c> 恒 0 ⇒ 气球一个都不出现。
|
||
/// 修法见 <c>PushBalloonWallAsync</c>:改成写「与安装位置无关」的共享目录
|
||
/// <c>%LOCALAPPDATA%\AnantaBalloon\balloon_push.json</c>,
|
||
/// 注入器 <c>gym_tools\AnantaBalloonLoader.exe</c> 按**同一规则**算同一路径。
|
||
/// 这个标志只用来把「到底用了哪个路径」打一次日志(判活用),不影响主流程。
|
||
/// </summary>
|
||
private static bool _balloonPushPathLogged;
|
||
|
||
private sealed class BalloonSession
|
||
{
|
||
internal ulong GadgetUId;
|
||
internal string ZoneSessionId = "";
|
||
internal long StartedUtcTicks;
|
||
internal int Score;
|
||
internal bool Active;
|
||
/// <summary>The REAL scene-item instance ids pushed at zone entry (R88o).</summary>
|
||
internal readonly List<ulong> BalloonIds = new();
|
||
/// <summary>
|
||
/// ★★★ R112 — 本轮每颗气球**实际下发**的 cfg(id → cfgId)。
|
||
/// 用于「按颜色算分」(BalloonWire.CreditForCfg)。旧代码的账本用的是
|
||
/// BalloonWire.BalloonSceneItemCfgIds 这个**固定 5 元组**,cfgSweep 生效时与实际不符。
|
||
/// </summary>
|
||
internal readonly Dictionary<ulong, uint> BalloonCfgById = new();
|
||
|
||
/// <summary>
|
||
/// ★★★ R123 — 上一局**没打掉**的那几颗(局末、撤保护之前抓下来)。
|
||
///
|
||
/// <para><b>为什么必须存它:</b>局末 <c>FinishBalloonRoundAsync</c> 会
|
||
/// ① <see cref="UnprotectDestructibles"/> 把本局 16 个 id 全摘出 <c>SceneItemGrid.Protected</c>、
|
||
/// ② <see cref="BalloonIds"/>.Clear()。⇒ 下一局 spawn 时**两条线索都没了**,
|
||
/// 服务端根本不知道上一局剩了哪几颗,而客户端 hold 里那几颗**还活着**
|
||
/// (整格重置的心跳只停止"保活",不会主动删客户端的 hold)。
|
||
/// 新球又落在**同一批 balloonWallSpots** 上 ⇒ 子弹先命中残留球的碰撞体、
|
||
/// 报的是旧 id ⇒ 服务端 <c>ignore break</c>,新球既打不掉也不加分。</para>
|
||
///
|
||
/// <para><b>实测铁证(R122,2026-09-28 08:19~08:20):</b>
|
||
/// R1 spawn <c>id 0x11..0x20</c> 只打爆 12 颗,未打掉 = <c>{0x15,0x17,0x1C,0x20}</c>;
|
||
/// R2(<c>0x21..0x30</c>)在 <c>08:20:19</c> 收到
|
||
/// <c>[BALLOON] ignore break n=1 first=BA1100000017</c> —— <c>0x17</c> **正是** R1 的残留之一。</para>
|
||
///
|
||
/// <para><b>用法:</b>下一局 spawn 时把这批 id 填进 AOI 报文的
|
||
/// <c>aoiInfo.RemoveIds</c>(<c>DestructibleGridAOIIncrease.removeIds</c>,
|
||
/// 与 <c>addUniqueIds</c> 成对;通道说明见 <c>GameRouter.GameGround.cs:655</c>),
|
||
/// 客户端据此删掉对应 hold;下发成功后清空。</para>
|
||
/// </summary>
|
||
internal readonly List<ulong> RetiredIds = new();
|
||
|
||
/// <summary>
|
||
/// ★★★★★ R126(2026-09-28)—— **局代**:每开一局 +1。
|
||
///
|
||
/// <para><b>为什么必须有它(用户实测:「第二轮结算时间非常快,没法正常测试」):</b>
|
||
/// 本类**按 session 复用、不每局新建**,而 <see cref="ArmBalloonRoundTimeout"/> 每局都
|
||
/// <c>Task.Run</c> 起一个 <c>Task.Delay(40s)</c>,醒来后只用 <c>session.Active</c> 当守卫。
|
||
/// 玩家在 40 s 内手动 <c>LeaveBalloon</c> 再马上重进时:上一局的任务醒来时
|
||
/// **看到的 Active 已经是下一局的 true** ⇒ 直接把新局结算掉。</para>
|
||
///
|
||
/// <para><b>实测铁证(R126,2026-09-28 09:01:29~09:03:15,5 局)——</b>
|
||
/// 每局的结束时刻**恰好等于上一局开始时刻 + 40 s**:
|
||
/// <code>
|
||
/// R2 结束 09:02:09 = R1 开始 09:01:29 + 40 (R2 只跑了 22 s)
|
||
/// R3 结束 09:02:27 = R2 开始 09:01:47 + 40 (R3 只跑了 9 s)
|
||
/// R4 结束 09:02:58 = R3 开始 09:02:18 + 40
|
||
/// R5 结束 09:03:15 = R4 开始 09:02:34 + 41
|
||
/// </code>
|
||
/// ⇒ 越往后局越短(玩家 9~25 s 就退再进),完全对得上。</para>
|
||
///
|
||
/// <para><b>用法:</b><c>SpawnBalloonsAsync</c> 开头 <c>++</c>,把当次的值闭包给定时任务;
|
||
/// 任务醒来与 <see cref="FinishBalloonRoundAsync"/> 都校验「发起者是不是当前局」。</para>
|
||
/// </summary>
|
||
internal int RoundGeneration;
|
||
}
|
||
|
||
private BalloonSession GetBalloonSession(RpcContext ctx)
|
||
{
|
||
if (ctx.Session.Items.TryGetValue(BalloonStateKey, out var raw) && raw is BalloonSession existing)
|
||
return existing;
|
||
|
||
var created = new BalloonSession();
|
||
ctx.Session.Items[BalloonStateKey] = created;
|
||
return created;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// EnterBalloonZone (67417935) — the gameplay door.
|
||
//
|
||
// Arg layout is ClientToGameSceneDelegate.lua (SerializerHelper.EnterBalloonZone_Serializer):
|
||
// gadgetuid : u64 (WriteUInt64)
|
||
// gametype : byte (CheckEnum(gametype, 66, 0), i.e. an enum with 66 members)
|
||
// -> 9 bytes. Confirmed live: "C2S INVOKE EnterBalloonZone [0x0404B74F] body=9b"
|
||
// with payload 01 18 20 E4 A3 08 00 00 00 :
|
||
// u64 LE = 0x08A3E4201801 (the stall gadget's instanceId)
|
||
// byte = 0x00 (gametype 0)
|
||
//
|
||
// The return is a COMPLEX BalloonZoneInfo (see the file header) — this is deliberately
|
||
// different from EnterBowlingZone's bare u64.
|
||
// ---------------------------------------------------------------------------------------
|
||
[Handler(MethodId.EnterBalloonZone, HandlerPacketKind.Invoke)]
|
||
private async Task EnterBalloonZone4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
ulong gadgetUId = 0;
|
||
byte gameType = 0;
|
||
if (msg.Body is { Length: >= 9 })
|
||
{
|
||
gadgetUId = BitConverter.ToUInt64(msg.Body, 0);
|
||
gameType = msg.Body[8];
|
||
}
|
||
|
||
// ★★★ R89 — 先发枪,再推 zone。
|
||
// 客户端拿到 BalloonZoneInfo 会**立刻**开面板并读 currentWeapon
|
||
// (BalloonGameStore.lua:341 → gPlayerManager.infoSpirit.bindData.currentWeapon);
|
||
// 为 nil 就抛 `attempt to index local 'weapon'` ⇒ 面板起不来、气枪模型不出。
|
||
// 所以这一步必须在 StartBalloonZoneAsync(内含 zone-info 推送)之前完成。
|
||
if (PrivateServerConfigStore.Current.World.Content.MiniGameStalls.BalloonGrantWeapon)
|
||
await GrantBalloonWeaponAsync(conn, msg.Context);
|
||
|
||
var zoneInfo = await StartBalloonZoneAsync(conn, msg.Context, gadgetUId, gameType, "EnterBalloonZone");
|
||
|
||
// ★ Return the REAL object. The client reads Dispatch[680] straight out of the reply.
|
||
await conn.ReturnAsync(msg, zoneInfo);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★★ R89 — 按官方武器发布管线的形状,把气枪(80300134)发布为当前手持武器。
|
||
/// 与 <c>GameRouter.Profiles.PublishWeaponSnapshot</c> 同一条链路:
|
||
/// SyncSpiritWeaponDetail(槽位视图)→ SyncSpiritSwitchWeaponAction(选中实例)。
|
||
///
|
||
/// ⛔ 不能用 <c>CombatCodec.SpiritWeaponSnapshot</c> —— 它会把 currentWeaponInstanceId
|
||
/// **强行塞进槽位列表**,而玩具枪不在玩家的常规配装上,必须手搓 SpiritWeaponDetail。
|
||
///
|
||
/// ⛔ 也**不要**补 <c>SyncAgentWeaponWheel</c>(68121167):对照包 2026-09-26 实测客户端解不开
|
||
/// (Player.log: deserialize failed mode=3 mid=68121167 len=24),且它只写
|
||
/// agentCurrentWeaponDict,仅 RobotDog/RobotFlyer/RobotSpider 三个机器人控件读它 ——
|
||
/// 气球面板要的 currentWeapon 来自 SyncSpiritWeaponDetail。
|
||
/// </summary>
|
||
private async Task GrantBalloonWeaponAsync(Connection conn, RpcContext ctx)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
var unitId = state.ActiveSpiritUnitId;
|
||
if (unitId == 0)
|
||
return;
|
||
|
||
// 只在首次进入时记录原武器;重复进入不会被玩具枪覆盖记录。
|
||
BalloonSavedWeapon.TryAdd(unitId, state.ActiveWeaponInstanceId);
|
||
|
||
var instanceId = BalloonWire.BalloonWeaponInstanceBase | (unitId & 0xFFFF_FFFFUL);
|
||
|
||
var weapon = new SceneMethods.WeaponDetail
|
||
{
|
||
SourceAgentSpoonId = 0,
|
||
SourceAgentId = 0,
|
||
SourceSceneItemId = 0,
|
||
IsLocked = false,
|
||
SourceSceneItemTaskUId = 0,
|
||
TemplateId = BalloonWire.BalloonWeaponTemplateId, // 80300134
|
||
Durability = -1, // 与 cfg.Durability = -1 一致(无限)
|
||
InstanceId = instanceId,
|
||
EventId = 0,
|
||
ReceivedTimeStamp = 0d,
|
||
OperatorFlags = 0,
|
||
SpecialLabel = null,
|
||
SceneItemHp = 1f,
|
||
StackCount = 1,
|
||
FightStyleId = 0, // 玩具枪不参与战斗流派
|
||
MagazineAmmo = PrivateServerConfigStore.Current.World.Content.MiniGameStalls.BalloonWeaponAmmo,
|
||
BulletDatas = new Auto.WeaponBulletDatas { BulletId = 0 },
|
||
BindPid = PrivateServerConfigStore.Current.Player.Pid,
|
||
IsPlayerLocked = false,
|
||
};
|
||
|
||
var snapshot = new SceneMethods.SpiritWeaponDetail
|
||
{
|
||
SpiritTid = state.ActiveSpiritTemplateId,
|
||
SpiritUid = unitId,
|
||
CurrentWeaponUid = instanceId,
|
||
WeaponSlots = new List<SceneMethods.WeaponDetail?> { weapon },
|
||
CurrentTempWeapon = null,
|
||
TempWeaponSlots = null,
|
||
VirtualWeaponSlots = new Dictionary<int, SceneMethods.WeaponDetail>(),
|
||
};
|
||
|
||
await conn.NotifyAsync(MethodId.SyncSpiritWeaponDetail, snapshot);
|
||
await conn.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction,
|
||
CombatCodec.SpiritSwitchWeapon(unitId, instanceId));
|
||
|
||
conn.Log.Info(
|
||
$"[BALLOON] weapon granted unit={unitId} template={BalloonWire.BalloonWeaponTemplateId} " +
|
||
$"instance={instanceId} ammo={PrivateServerConfigStore.Current.World.Content.MiniGameStalls.BalloonWeaponAmmo}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★★ R89 — 退出气球区时还原进气前的手持武器。
|
||
/// 这里**可以**用官方快照(原武器本来就在配装里),避免被玩具枪顶掉。
|
||
/// </summary>
|
||
private async Task RestoreBalloonWeaponAsync(Connection conn, RpcContext ctx)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
var unitId = state.ActiveSpiritUnitId;
|
||
if (unitId == 0)
|
||
return;
|
||
|
||
if (!BalloonSavedWeapon.TryRemove(unitId, out var previous) || previous == 0)
|
||
return;
|
||
|
||
var snapshot = CombatCodec.SpiritWeaponSnapshot(unitId, state.ActiveSpiritTemplateId, previous);
|
||
await conn.NotifyAsync(MethodId.SyncSpiritWeaponDetail, snapshot);
|
||
await conn.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction,
|
||
CombatCodec.SpiritSwitchWeapon(unitId, previous));
|
||
|
||
conn.Log.Info($"[BALLOON] weapon restored unit={unitId} instance={previous}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Shared body: mint the per-connection zone session, build the BalloonZoneInfo, push the
|
||
/// SyncGameGroundZoneInfo the client's manager gates on, and hand a copy back for the Invoke
|
||
/// reply of EnterBalloonZone. Returns the zone info so both doors (EnterBalloonZone, and a
|
||
/// future GmEnterBalloonZone) can shape their own reply.
|
||
/// </summary>
|
||
private async Task<BalloonZoneInfo> StartBalloonZoneAsync(
|
||
Connection conn, RpcContext ctx, ulong gadgetUId, byte gameType, string source)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
var session = GetBalloonSession(ctx);
|
||
|
||
session.GadgetUId = gadgetUId;
|
||
session.ZoneSessionId = $"{BalloonWire.ZoneSessionPrefix}-{ctx.Session.Id}-{IdCounterStore.Next(BalloonZoneSeqCounter, 1)}";
|
||
session.StartedUtcTicks = DateTime.UtcNow.Ticks;
|
||
session.Score = 0;
|
||
session.Active = true;
|
||
|
||
var playerPos = state.LastReportedPlayerPosition;
|
||
conn.Log.Info(
|
||
$"[BALLOON] {source} gadgetUId={gadgetUId} gameType={gameType} " +
|
||
$"zone={session.ZoneSessionId} " +
|
||
$"player=({playerPos.X:F1},{playerPos.Y:F1},{playerPos.Z:F1})");
|
||
|
||
// ★★ R88n — the session ids and the target cfg. R88o completed this: see below.
|
||
// Leaving TargetSceneItemCfgId at 0 told the panel "no target" (BalloonGameStore.OnShow
|
||
// reads data.targetSceneItemId), and an empty BalloonInstanceIdList gave the C#
|
||
// BalloonGamePlayContext nothing to key its balloonInstanceIdSet on.
|
||
var zoneInfo = new BalloonZoneInfo
|
||
{
|
||
// ★★★ R93 — TargetModelId 不再填 0:客户端 BalloonGameStore.OnShow 读 data.targetId 赋给
|
||
// self.targetModelId,0 会让面板拿不到目标气球模型。对照包 Lyco 实测用 BalloonGameSceneItemCfgIds[0]
|
||
// (当时=80005796 Special),TargetModelId 与 TargetSceneItemCfgId 客户端语义不严格区分,填 cfg id 即可。
|
||
// ★ R100:数组已换成「派对气球」,故此处自动变为 80001468;TargetSceneItemCfgId 同步换。
|
||
TargetModelId = BalloonWire.BalloonSceneItemCfgIds[0], // R100: 80001468(派对气球 a)
|
||
TargetSceneItemCfgId = BalloonWire.TargetSceneItemCfgId, // R100: 80001468
|
||
Score = 0,
|
||
BalloonInstanceIdList = new List<ulong>(), // filled below (real ids)
|
||
ZoneSessionId = session.ZoneSessionId, // ★ MUST be non-empty
|
||
GadgetUId = gadgetUId,
|
||
TaskId = 0,
|
||
StartReason = BalloonWire.ZoneStartReasonSingle,
|
||
GameTypeRandom = false,
|
||
SyncReason = BalloonWire.ZoneSyncReasonEnter, // MUST be Enter(0): the manager branches on it
|
||
ZoneType = BalloonWire.ZoneTypeBalloon,
|
||
// ★★★ R93 — ZoneState 用 GameStart(2) 而非 Display(1):对照包 Lyco 实测 GameStart,
|
||
// 客户端 ZoneGamePlayManager 靠 GameStart 才 StartContext(BalloonGamePlayContext)。
|
||
// Display(1) 是 Idle(0) 与 GameStart(2) 之间的过渡态,直接回包给客户端会导致 context 起不来。
|
||
ZoneState = BalloonWire.ZoneStateGameStart,
|
||
ParticipantInfos = new List<BalloonParticipantInfo>(), // empty, never null
|
||
CountDownInfo = new GameGroundZoneCountDownInfo(),
|
||
};
|
||
|
||
// ★★★ R88o — ACTUALLY SPAWN THE BALLOONS, then hand their REAL instance ids to the client.
|
||
//
|
||
// Why the R88n version (which put *cfg* ids in this list) still showed an empty wall:
|
||
// the C# consumer keys on instance ids, not cfg ids —
|
||
// L50.Gameplay.ZoneGamePlay.BalloonGamePlayContext
|
||
// TargetModelId | TargetSceneItemId | balloonInstanceIdSet | playerScoreDic
|
||
// | ListenSceneItemBreak | OnSceneItemBreak
|
||
// and the scene-item hold is only created by the explicit destructible push
|
||
// (SyncDestructibleGridAOIDecrease.aoiInfo.addInfos), exactly as bowling does.
|
||
// Doing it here — before the zone-info push — means the ids in the list are ids the client
|
||
// has already been told about, so the wait never times out.
|
||
var balloonIds = await SpawnBalloonsAsync(conn, ctx, session, source);
|
||
zoneInfo.BalloonInstanceIdList = balloonIds;
|
||
session.BalloonIds.Clear();
|
||
session.BalloonIds.AddRange(balloonIds);
|
||
|
||
// ★★★ R89 — 登记本轮靶账本,AskBreakDestructibleObject(s) 靠它判定「打爆的是不是气球」。
|
||
// ★★★ R112 修正:账本里的 cfg 必须取 **spawn 实际下发**的那个。
|
||
// 旧代码写 `cfgs[i % cfgs.Length]`(BalloonWire 的固定 5 元组),cfgSweep 生效时
|
||
// 与实际不符 ⇒ 计分会按错颜色。
|
||
var targets = new Dictionary<ulong, uint>();
|
||
foreach (var bid in balloonIds)
|
||
targets[bid] = session.BalloonCfgById.TryGetValue(bid, out var bidCfg)
|
||
? bidCfg
|
||
: BalloonWire.BalloonSceneItemCfgIds[0];
|
||
|
||
var spiritUnitId = state.ActiveSpiritUnitId;
|
||
if (spiritUnitId != 0)
|
||
BalloonTargets[spiritUnitId] = targets;
|
||
|
||
conn.Log.Info(
|
||
$"[BALLOON] zone objects targetCfgId={zoneInfo.TargetSceneItemCfgId} " +
|
||
$"balloons={zoneInfo.BalloonInstanceIdList.Count} targets={targets.Count} " +
|
||
$"[{string.Join(",", zoneInfo.BalloonInstanceIdList)}]");
|
||
|
||
await conn.NotifyAsync(MethodId.SyncGameGroundZoneInfo, zoneInfo);
|
||
conn.Log.Info($"[BALLOON] pushed SyncGameGroundZoneInfo tag=3 zone={session.ZoneSessionId} score=0 source={source}");
|
||
|
||
// ★★ SyncGameGroundZoneState (68383444) is the channel ZoneGamePlayManager actually
|
||
// listens on (C#: L50.Gameplay.ZoneGamePlay.ZoneGamePlayManager.SyncGameGroundZoneState
|
||
// -> BalloonGamePlayContext.OnStarted). Without it the zone gameplay context is never
|
||
// "entered", so EndContext() on leave has nothing to tear down and the panel stays.
|
||
//
|
||
// ★★★ R93 — 只推 GameStart(2) 一次,不再推 Display(1)。对照包 Lyco 实测只推 GameStart;
|
||
// 多推一次 Display 会让 ZoneGamePlayManager 先把 context 置为 Display(未开始),
|
||
// 紧接着 GameStart 又切一次,客户端状态机在两次切换之间可能直接判定结束 → OnEnded
|
||
// → BalloonGameStore.OnGameEnd 读 resultActiveCtrlEnum(nil) 抛异常 → 面板关不掉("退出不了")。
|
||
await PushZoneStateAsync(conn, gadgetUId, BalloonWire.ZoneStateGameStart);
|
||
conn.Log.Info($"[BALLOON] pushed SyncGameGroundZoneState GameStart zone={session.ZoneSessionId} type={BalloonWire.ZoneTypeBalloon}");
|
||
|
||
// Push the initial score so the client's panel starts from a known value. The payload is a
|
||
// bare Int32 — see the file header.
|
||
await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, 0);
|
||
conn.Log.Info("[BALLOON] pushed SyncBalloonGameScore score=0");
|
||
|
||
// Hand the Invoke reply a COPY (the notifier already consumed this instance).
|
||
return zoneInfo;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Spawn the breakable balloons as real scene items and return their instance ids.
|
||
///
|
||
/// ★★★ R88o — THIS is the piece that makes "墙上有气球" actually true, and it is the exact
|
||
/// same three-step dance bowling already does (GameRouter.GameGround.cs:683-930):
|
||
/// 1) mint real instance ids BalloonInstanceIdBase + ordinal(1..count)
|
||
/// 2) push them as DestructibleInfo
|
||
/// SyncDestructibleGridAOIDecrease (68969593) .aoiInfo.addInfos ← creates the hold
|
||
/// SyncDestructibleObjectAddList (68963429) ← the object add
|
||
/// with CfgId = the balloon cfg id, PathId = 五色各一 (R89/R100),
|
||
/// ForceLod0 = false, IScale = 100 (R109, 原 1000 作废), State = Active
|
||
/// ★★★ R100:CfgId 与 PathId 已**成对**换成资源确实随包的「派对气球」
|
||
/// (旧 80005796..80005800 的美术资源不存在 —— 四条证据链见 BalloonWireContracts.cs)。
|
||
/// 3) hand those SAME ids back to the caller for BalloonZoneInfo.BalloonInstanceIdList
|
||
///
|
||
/// WHY THIS IS NOT OPTIONAL — the C# consumer keys on INSTANCE ids, not cfg ids:
|
||
/// L50.Gameplay.ZoneGamePlay.BalloonGamePlayContext holds
|
||
/// TargetModelId | TargetSceneItemId | balloonInstanceIdSet | playerScoreDic
|
||
/// and implements OnSceneItemBreak. A break only scores if the broken item's instance id is
|
||
/// in balloonInstanceIdSet, and nothing can be broken at all unless the item was spawned.
|
||
///
|
||
/// ★★★ R89 LAYOUT — 已改为对照包(Lyco-v0.2.1,真机跑通)的摆法:玩家**前方 6m** 的 4×3 墙。
|
||
/// ⛔ 旧的「摊位固定坐标 10×2」不再用。ClientConfig 的 Balloon_RightNum = 10 是**棋盘上限**,
|
||
/// 不是本局数量;对照包实测本局 = 12(4 列 × 3 行)。
|
||
/// </summary>
|
||
/// <summary>
|
||
/// ★★★★★ R122(2026-09-28)—— 把 <c>SceneItemGrid.Protected</c> 里**不属于本局**的气球 id
|
||
/// 全部摘掉并返回(供 AOI 报文的 <c>aoiInfo.RemoveIds</c> 使用)。
|
||
///
|
||
/// <para>为什么必须有这一步:<c>Protected</c> 是 <c>Dictionary<ulong,…></c>、**只增不减**,
|
||
/// 而 5 s 心跳会把表里每个 id 都塞回 <c>AddInfos</c> + <c>ExceptIds</c>
|
||
/// (<c>SceneItemGrid.cs:344-382</c>)⇒ 上一局没打掉的球会被**永远保活**。</para>
|
||
///
|
||
/// <para>判定只用高位前缀 <see cref="BalloonWire.IsBalloonInstanceId"/>(<c>0xBA11</c>),
|
||
/// 所以不会误伤保龄球(<c>0x9C7B</c> 段)或普通场景物。
|
||
/// <paramref name="currentRoundIds"/> = 本局刚分配的那 16 个(它们此刻还没进 Protected,
|
||
/// 传进来只是兜底:任何在本局名单里的 id 一律不删)。</para>
|
||
/// </summary>
|
||
private static List<ulong> PurgeStaleBalloonProtection(
|
||
TcpSession session, IEnumerable<ulong> currentRoundIds)
|
||
{
|
||
var owner = SceneItemGridStateOf(session);
|
||
var keep = new HashSet<ulong>(currentRoundIds);
|
||
var stale = new List<ulong>();
|
||
lock (owner.SyncRoot)
|
||
{
|
||
foreach (var id in owner.Protected.Keys)
|
||
if (BalloonWire.IsBalloonInstanceId(id) && !keep.Contains(id))
|
||
stale.Add(id);
|
||
foreach (var id in stale)
|
||
owner.Protected.Remove(id);
|
||
}
|
||
stale.Sort();
|
||
return stale;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★★ R123(2026-09-28)—— 本局 16 个 id 里,**此刻仍在 <c>Protected</c> 的**(= 本局没打掉的)。
|
||
///
|
||
/// <para><b>为什么在局末抓、而不是下一局抓:</b><c>FinishBalloonRoundAsync</c> 会紧接着调
|
||
/// <see cref="UnprotectDestructibles"/> 把本局 id 全摘出 <c>Protected</c>,
|
||
/// 摘完就再也查不到"哪几颗没打掉"了(R122 的 <c>PurgeStaleBalloonProtection</c>
|
||
/// 在下一局 spawn 时才查 ⇒ 表已空 ⇒ 日志 <c>purge stale</c> 恒 0、修了个寂寞)。</para>
|
||
///
|
||
/// <para>结果存进 <see cref="BalloonSession.RetiredIds"/>,由下一局 spawn 作为
|
||
/// <c>aoiInfo.RemoveIds</c> 下发。</para>
|
||
/// </summary>
|
||
private static List<ulong> SurvivingBalloonIds(TcpSession session, IEnumerable<ulong> roundIds)
|
||
{
|
||
var owner = SceneItemGridStateOf(session);
|
||
var alive = new List<ulong>();
|
||
lock (owner.SyncRoot)
|
||
foreach (var id in roundIds)
|
||
if (owner.Protected.ContainsKey(id))
|
||
alive.Add(id);
|
||
alive.Sort();
|
||
return alive;
|
||
}
|
||
|
||
private async Task<List<ulong>> SpawnBalloonsAsync(
|
||
Connection conn, RpcContext ctx, BalloonSession session, string source)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
var anchor = state.LastReportedPlayerPosition;
|
||
|
||
var cfgStall = PrivateServerConfigStore.Current.World.Content.MiniGameStalls;
|
||
|
||
// ★★★ R89 — 改为对照包(Lyco-v0.2.1,真机跑通)的摆放法:
|
||
// 不再用摊位固定坐标摆一面 10×2 的「棋盘」,而是在**玩家当前位姿的前方**摆 4×3 的墙。
|
||
// 理由:私服没有关卡烘焙的摊位摆位(铁律 8「自指锚点」),玩家站位才是权威;
|
||
// 且 ClientConfig 的 Balloon_RightNum=10 / CameraMinX.. 是**棋盘上限**,不是本局数量。
|
||
// Unity 朝向约定:绕 Y 轴 yaw 度 ⇒ forward = (sin,0,cos),right = (cos,0,-sin)。
|
||
float yaw = state.LastReportedPlayerRotation.Y;
|
||
double rad = yaw * Math.PI / 180.0;
|
||
float fx = (float)Math.Sin(rad), fz = (float)Math.Cos(rad);
|
||
float rx = (float)Math.Cos(rad), rz = -(float)Math.Sin(rad);
|
||
|
||
var count = cfgStall.BalloonCount;
|
||
if (count < 1) count = BalloonWire.BalloonCount;
|
||
// ★★★★★ R111 —— 摆位参数改为**配置驱动**(private-server.json 热重载 ⇒ 改完重新进区即生效,
|
||
// **不用重启服务端**)。旧版是 const(对照包 Lyco 的值),已全部作废。
|
||
var rows = Math.Max(1, cfgStall.BalloonWallRows); // 对照包 BalloonSpawnRows = 3
|
||
var cols = Math.Max(1, count / rows); // 12 / 3 = 4 列
|
||
var lateralStep = cfgStall.BalloonWallLateralStep; // R111: 1.2 → 0.9
|
||
var forward = cfgStall.BalloonWallForward; // player 锚点的前移量(默认 6)
|
||
var baseHeight = cfgStall.BalloonWallBaseHeight; // R111: 1.2 → 1.25
|
||
var heightStep = cfgStall.BalloonWallHeightStep; // R111: 0.7 → 0.5
|
||
|
||
// ★★★ R112 — 每局重新生成 ⇒ 账本先清空,避免上一局的 cfg 残留。
|
||
session.BalloonCfgById.Clear();
|
||
|
||
// ★★★★★ R126 — **开一局就换一个「局代」**。本局末布防的 40 s 看门狗会把它闭包进去,
|
||
// 醒来时若发现当前局代已变(= 已经是下一局了)⇒ 自我作废,绝不结算下一局。
|
||
// 病因与实测铁证见 BalloonSession.RoundGeneration 的注释。
|
||
var roundGen = ++session.RoundGeneration;
|
||
|
||
var cfgs = BalloonWire.BalloonSceneItemCfgIds; // 5 colours
|
||
var paths = BalloonWire.BalloonSceneItemPathIds; // 5 colours (R89)
|
||
|
||
// ★★★★★ R111 —— 按列换 (cfg, pathId) 组(美术 A/B)。空 = 内置 5 色派对气球。
|
||
var cfgSweep = cfgStall.BalloonCfgSweep ?? System.Array.Empty<string>();
|
||
var colCfg = new uint[cols];
|
||
var colPath = new int[cols];
|
||
for (var col = 0; col < cols; col++)
|
||
{
|
||
var ok = false;
|
||
if (cfgSweep.Length > 0)
|
||
{
|
||
var spec = cfgSweep[Math.Min(col, cfgSweep.Length - 1)] ?? string.Empty;
|
||
var pair = spec.Split(':');
|
||
if (pair.Length >= 2 &&
|
||
uint.TryParse(pair[0].Trim(), out var c0) &&
|
||
int.TryParse(pair[1].Trim(), out var p0))
|
||
{ colCfg[col] = c0; colPath[col] = p0; ok = true; }
|
||
}
|
||
if (!ok) { colCfg[col] = cfgs[col % cfgs.Length]; colPath[col] = paths[col % paths.Length]; }
|
||
}
|
||
if (cfgSweep.Length > 0)
|
||
conn.Log.Info("[BALLOON] cfg sweep on: " + string.Join(" | ",
|
||
System.Linq.Enumerable.Range(0, cols).Select(i => $"col{i}=cfg{colCfg[i]}/path{colPath[i]}")));
|
||
|
||
var infos = new List<DestructibleInfo>(count);
|
||
var ids = new List<ulong>(count);
|
||
var cells = new Dictionary<(int, int), bool>();
|
||
|
||
// ★★★★★ R109 — IScale 默认值 1000 → 100(详见 BalloonWire.DefaultIScale 的更正段)。
|
||
// 保龄球(唯一被实测确认看得见的同族)用 100;气球用 1000 且从来没人看见过。
|
||
// ⛔ 0 仍是 Gadget 通道的语义,别混(同名不同尺度)。
|
||
var iScale = cfgStall.BalloonIScale;
|
||
|
||
// ★★★★★ R109 — 诊断扫描:非空时**按列**给不同 IScale,一次重启同时试 4 个候选值。
|
||
// 列号 = ordinal/rows(cols 外层、rows 内层)⇒ 用户只要报告「哪一列看得见」。
|
||
var scaleSweep = cfgStall.BalloonIScaleSweep;
|
||
if (scaleSweep is { Length: > 0 })
|
||
conn.Log.Info($"[BALLOON] IScale SWEEP on: col0={scaleSweep[0]} " +
|
||
$"col1={(scaleSweep.Length > 1 ? scaleSweep[1] : scaleSweep[0])} " +
|
||
$"col2={(scaleSweep.Length > 2 ? scaleSweep[2] : scaleSweep[0])} " +
|
||
$"col3={(scaleSweep.Length > 3 ? scaleSweep[3] : scaleSweep[0])} " +
|
||
$"(rows={rows}, 列序从玩家左手边起)");
|
||
|
||
// ★★★ R89 — 实例 id:固定 48 位区间 base + 序号(1..count)。
|
||
// 绝不用 gadget 散列(低 32 位大时 id 到 49 位,经 Lua double 回传丢精度 ⇒ found=0)。
|
||
const int gridCellSize = 40; // 与 WorldContent 的 40m cell 一致
|
||
var useLegacyIds = cfgStall.BalloonUseLegacyInstanceIds;
|
||
// ★★★★★ R118(2026-09-27)—— **实例 id 必须「每局换一段」**。
|
||
//
|
||
// 原来非 legacy 分支是常数 `BalloonInstanceIdBase` ⇒ **每一局都是同样那 16 个 id**。
|
||
// 铁证(`logs/console-20260927-231258.log` 一局 16 颗 id 固定 0xBA11…0001–0010)+
|
||
// 用户实测两条相互印证的现象:
|
||
// ① 「金色球打完再下一次重新游戏时不会再刷新出来」——客户端对 uniqueId 有**跨局持久**的
|
||
// "已销毁" 记账,第二局收到同一批 id 直接**拒绝重建**(金色正是 `destructible_*` 家族,
|
||
// 见 R117 §5:它才会被客户端永久记住);
|
||
// ② 「第二轮打球直接不会掉下去了,钉死在墙上」——非金色那族虽然会被重建,
|
||
// 但带着上一局的残留状态 ⇒ 站着不动、打不动。
|
||
// ⇒ 每局分配新一段 id,客户端每次看到的都是**新物件**,两条现象一起消掉。
|
||
// ⚠ 三条硬约束(踩了就会"只生成 1 个"或"查不到"):
|
||
// 1) 偏移必须是注入侧 `MAX_BATCH = 32` 的**整数倍**,否则 idx=(id-base)%32 会串位;
|
||
// 2) 全程 ≤48 位(Lua number 只有 53 位尾数 —— 铁律 39);
|
||
// 3) 高位必须仍以 `ba11` 开头(注入侧 `isBalloon()` 靠前缀认领)。
|
||
// ★★★★★ R119 —— 计数器**换成独立的新键**,让**第一局的 id 段等于**
|
||
// 「唯一被实测证明能计分的那一局」(`0xBA11_0000_0001..0010`)。
|
||
// 理由与步长由 128 压到 16 的取证过程见 BalloonInstanceIdRoundStride 的注释。
|
||
// ⇒ 第一局 = A/B 的对照组;如果第一局恢复正常,就**确证** id 段是根因。
|
||
var batch = useLegacyIds
|
||
? (ulong)IdCounterStore.Next(BalloonWire.BalloonSceneItemIdBatchCounter, 0)
|
||
: (ulong)IdCounterStore.Next(BalloonWire.BalloonInstanceRoundCounter, 0);
|
||
var batchBase = useLegacyIds
|
||
? BalloonWire.BalloonSceneItemIdBase + batch * (ulong)count
|
||
: BalloonWire.BalloonInstanceIdBase + batch * BalloonWire.BalloonInstanceIdRoundStride;
|
||
|
||
// ★★★★★ R110 —— 锚点:**优先用官方摊位机关自己的位姿**,而不是「玩家前方 6 m」。
|
||
//
|
||
// 病因(R110 三维几何实证,龙栖村气球摊;详见 PrivateServerConfig.BalloonWallAnchor):
|
||
// 官方摊位 Gadget_DaQiQiu (-4750.50,168.60,-2827.60) yaw=315.0
|
||
// 玩家上报位姿 (-4749.00,168.50,-2830.30) yaw=313.3 ⇒ 摊位在正前方 2.97 m
|
||
// 旧算法玩家前方 6 m ⇒ 墙中心 (-4753.36,-2826.18) = 比摊位再往前 3.19 m
|
||
// ⇒ 整面墙落在摊位/背景板之后、只从地面 1.2 m 起 ⇒ 被结构遮住 = 什么都看不见。
|
||
// ⇒ 服务端载荷 / 注入 / 资源 / hold / transform 全对、物件确实存在,只是几何上被挡住。
|
||
//
|
||
// 扫描:BalloonWallAnchorSweep 按列给「锚点:前移量」,一次重启同时试多个摆位。
|
||
var sweep = cfgStall.BalloonWallAnchorSweep ?? System.Array.Empty<string>();
|
||
var wantStall = string.Equals(cfgStall.BalloonWallAnchor, "stall", StringComparison.OrdinalIgnoreCase)
|
||
|| Array.Exists(sweep, s => (s ?? string.Empty)
|
||
.TrimStart().StartsWith("stall", StringComparison.OrdinalIgnoreCase));
|
||
|
||
MiniGameStallItem4229938? stallHit = null;
|
||
var stallDist = -1f;
|
||
if (wantStall)
|
||
{
|
||
var match = string.IsNullOrEmpty(cfgStall.BalloonWallStallMatch) ? "DaQiQiu" : cfgStall.BalloonWallStallMatch;
|
||
var best = float.MaxValue;
|
||
foreach (var it in MiniGameStallCatalog4229938.Spots.SelectMany(s => s.Items))
|
||
{
|
||
if (it.Name is null || it.Name.IndexOf(match, StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||
var dx = it.X - anchor.X;
|
||
var dz = it.Z - anchor.Z;
|
||
var d2 = dx * dx + dz * dz;
|
||
if (d2 < best) { best = d2; stallHit = it; }
|
||
}
|
||
if (stallHit is not null) stallDist = MathF.Sqrt(best);
|
||
conn.Log.Info(stallHit is null
|
||
? $"[BALLOON] 锚点查找:摊位表里没有名字含 '{match}' 的机关 ⇒ 全部退回玩家锚点"
|
||
: $"[BALLOON] 锚点查找:命中 {stallHit.Name}({stallHit.PathId}) " +
|
||
$"@({stallHit.X:F2},{stallHit.Y:F2},{stallHit.Z:F2}) yaw={stallHit.Yaw:F1} " +
|
||
$"距玩家上报位置 {stallDist:F2}m(告警半径 {cfgStall.BalloonWallStallSearchRadius:F1}m)");
|
||
}
|
||
|
||
// 每列的锚点规格:基底(bx,by,bz) + 该锚点的 forward/right + 沿 forward 的前移量
|
||
//
|
||
// ★★★★★ R111 —— **sweep 为空时所有列共用同一个 fwd ⇒ 平墙**。
|
||
// 旧代码在 `rawSpec is null` 时写的是 `forward`(player 锚点的 const 6.0),
|
||
// 却忽略了 ballotWallStallForward ⇒ stall 模式下前移量恒为 0(把墙压在摊位原点上)。
|
||
// 现在按 mode 取:stall ⇒ BalloonWallStallForward / player ⇒ BalloonWallForward。
|
||
var stallForward = cfgStall.BalloonWallStallForward;
|
||
var colSpec = new (float bx, float by, float bz, float cfx, float cfz,
|
||
float crx, float crz, float fwd, string tag)[cols];
|
||
for (var col = 0; col < cols; col++)
|
||
{
|
||
var rawSpec = sweep.Length > 0 ? (sweep[Math.Min(col, sweep.Length - 1)] ?? string.Empty) : null;
|
||
var mode = string.Equals(cfgStall.BalloonWallAnchor, "stall", StringComparison.OrdinalIgnoreCase)
|
||
? "stall" : "player";
|
||
var fwd = mode == "stall" ? stallForward : forward;
|
||
if (rawSpec is not null)
|
||
{
|
||
var parts = rawSpec.Split(':');
|
||
mode = parts[0].Trim().ToLowerInvariant();
|
||
fwd = (parts.Length > 1 && float.TryParse(parts[1], System.Globalization.NumberStyles.Float,
|
||
System.Globalization.CultureInfo.InvariantCulture, out var f2)) ? f2 : 0f;
|
||
}
|
||
|
||
if (mode == "stall" && stallHit is not null)
|
||
{
|
||
var srad = stallHit.Yaw * Math.PI / 180.0;
|
||
colSpec[col] = (stallHit.X, stallHit.Y, stallHit.Z,
|
||
(float)Math.Sin(srad), (float)Math.Cos(srad),
|
||
(float)Math.Cos(srad), -(float)Math.Sin(srad), fwd, "stall");
|
||
}
|
||
else
|
||
{
|
||
colSpec[col] = (anchor.X, anchor.Y, anchor.Z, fx, fz, rx, rz,
|
||
fwd, mode == "stall" ? "player(fallback)" : "player");
|
||
}
|
||
}
|
||
conn.Log.Info("[BALLOON] 锚点按列: " + string.Join(" | ", colSpec.Select((c, i) =>
|
||
$"col{i}={c.tag}@({c.bx:F2},{c.by:F2},{c.bz:F2}) fwd={c.fwd:F2}m")));
|
||
|
||
// ★★★★★ R115 —— **官方球位表**(`balloonWallSpots`,逐点世界坐标)。
|
||
//
|
||
// 病因(R115 运行时探针实锤,见 _r114k_out.txt):
|
||
// 摊位 prefab 里 `QiQiuBan`(气球板)挂着官方那面墙 —— 节点 `BalloonBoard_Square2`
|
||
// (wp -4752.203,168.6,-2827.108, active=true) 有 **16 个 `BalloonPointXY` 子节点**,
|
||
// 它们的**世界坐标就是官方球位**(4 列 × 4 行,列距 0.4 m、行距 ≈0.348 m,
|
||
// 整面墙仅 1.2 m × 1.05 m、离地 0.775–1.82 m)。
|
||
// 而 R110/R111 那套「锚点 + forward/lateral/baseHeight/heightStep」是**我们自己编的**,
|
||
// 算出来是 4.6 m 宽 × 2.6 m 高的散布 ⇒ 看着「不在官方位置」。
|
||
// ⇒ 本表非空且全部可解析时,几何算法整段停用,位置一律取本表。
|
||
var spots = cfgStall.BalloonWallSpots ?? System.Array.Empty<string>();
|
||
var spotXyz = new (float x, float y, float z)[spots.Length];
|
||
var spotOk = 0;
|
||
for (var i = 0; i < spots.Length; i++)
|
||
{
|
||
var parts = (spots[i] ?? string.Empty).Split(',');
|
||
if (parts.Length >= 3
|
||
&& float.TryParse(parts[0].Trim(), System.Globalization.NumberStyles.Float,
|
||
System.Globalization.CultureInfo.InvariantCulture, out var sx)
|
||
&& float.TryParse(parts[1].Trim(), System.Globalization.NumberStyles.Float,
|
||
System.Globalization.CultureInfo.InvariantCulture, out var sy)
|
||
&& float.TryParse(parts[2].Trim(), System.Globalization.NumberStyles.Float,
|
||
System.Globalization.CultureInfo.InvariantCulture, out var sz))
|
||
{
|
||
spotXyz[i] = (sx, sy, sz);
|
||
spotOk++;
|
||
}
|
||
}
|
||
var useSpots = spots.Length > 0 && spotOk == spots.Length;
|
||
conn.Log.Info(useSpots
|
||
? $"[BALLOON] 官方球位表 on: {spots.Length} 点(BalloonBoard_Square2 烘焙坐标)⇒ 几何算法停用"
|
||
: $"[BALLOON] 官方球位表 off(配置 {spots.Length} 项 / 可解析 {spotOk} 项)⇒ 退回几何算法");
|
||
|
||
// ★★★★★ R118(2026-09-27)—— **把官方那 16 个点「拉开间距」**。
|
||
//
|
||
// 用户实测(R117→R118 两轮):「球变成一面墙,都凑在一起」→「球的位置差不多了,但是要分散开」。
|
||
// 官方 `BalloonBoard_Square2` 的 4×4 点确实很挤:**列距 0.400 m / 行距 0.348 m,
|
||
// 整面墙只有 1.2 m × 1.045 m**,而球本身的包围盒是 0.185 × **0.3566** × 0.185
|
||
// (`_r109_authoring.json`)⇒ 上下**几乎贴住**,看起来就是"一坨"。
|
||
// ⇒ 以质心为轴按倍率放大(1.0 = 官方原样)。热重载键 `balloonWallSpotSpread`。
|
||
// ⚠ 只是"拉开",不改中心 —— 用户说"位置差不多了"。
|
||
var spotSpread = cfgStall.BalloonWallSpotSpread;
|
||
if (useSpots && spotSpread > 0f && MathF.Abs(spotSpread - 1f) > 0.001f)
|
||
{
|
||
var sscx = 0f;
|
||
var sscy = 0f;
|
||
var sscz = 0f;
|
||
for (var i = 0; i < spotXyz.Length; i++)
|
||
{
|
||
sscx += spotXyz[i].x;
|
||
sscy += spotXyz[i].y;
|
||
sscz += spotXyz[i].z;
|
||
}
|
||
sscx /= spotXyz.Length;
|
||
sscy /= spotXyz.Length;
|
||
sscz /= spotXyz.Length;
|
||
for (var i = 0; i < spotXyz.Length; i++)
|
||
spotXyz[i] = (sscx + (spotXyz[i].x - sscx) * spotSpread,
|
||
sscy + (spotXyz[i].y - sscy) * spotSpread,
|
||
sscz + (spotXyz[i].z - sscz) * spotSpread);
|
||
conn.Log.Info(
|
||
$"[BALLOON] 球位间距 ×{spotSpread:F2}(质心=({sscx:F2},{sscy:F2},{sscz:F2}))" +
|
||
$"⇒ 官方 4×4 的 0.400/0.348 列行距已拉开");
|
||
}
|
||
|
||
// ★★★★★ R116(2026-09-27)—— **球位整体朝玩家一侧外移,避开摊位那块「会动的布」**。
|
||
//
|
||
// 用户实测:「气球墙有一块会动的布,这块布经常会把球挡住一点」+「球变成一面墙,都凑在一起」。
|
||
// 官方那 16 个点是**贴在板上**的(板正面就是摊位装饰:`p_map01_level_sister_tile_board`
|
||
// 招牌帘布 / Lianzi 帘子,探针实测它们与球位在同一 y 区间 169.4–170.4)。
|
||
// ⇒ 把整组点沿「板法线 ≈ 球位质心 → 玩家」的水平方向外移 balloonWallSpotPull 米,
|
||
// 球就浮在帘布**前面**,既不穿模也不会被布挡。
|
||
//
|
||
// 方向是量出来的:板局部 +Z ≈ 世界 (0.707, 0, -0.707)(由 BalloonPoint 的局部 X 步进
|
||
// (-0.282,-0.282)/0.4 反解出 yaw=135°),而「质心→玩家」实测 ≈ (0.68, 0, -0.73)——
|
||
// 两者一致 ⇒ 「朝玩家」就是「沿板面法线往外」。热重载可调,0 = 官方原始位。
|
||
var spotPull = cfgStall.BalloonWallSpotPull;
|
||
if (useSpots && spotPull != 0f)
|
||
{
|
||
var spcx = 0f;
|
||
var spcz = 0f;
|
||
for (var i = 0; i < spotXyz.Length; i++) { spcx += spotXyz[i].x; spcz += spotXyz[i].z; }
|
||
spcx /= spotXyz.Length;
|
||
spcz /= spotXyz.Length;
|
||
var dxp = anchor.X - spcx;
|
||
var dzp = anchor.Z - spcz;
|
||
var dl = MathF.Sqrt(dxp * dxp + dzp * dzp);
|
||
if (dl > 0.05f)
|
||
{
|
||
var ux = dxp / dl;
|
||
var uz = dzp / dl;
|
||
for (var i = 0; i < spotXyz.Length; i++)
|
||
spotXyz[i] = (spotXyz[i].x + ux * spotPull, spotXyz[i].y,
|
||
spotXyz[i].z + uz * spotPull);
|
||
conn.Log.Info(
|
||
$"[BALLOON] 球位外移 {spotPull:F2}m 朝玩家 dir=({ux:F2},{uz:F2}) " +
|
||
$"质心=({spcx:F2},{spcz:F2}) ⇒ 避开摊位帘布/招牌");
|
||
}
|
||
}
|
||
|
||
// ★★★★★ R127(2026-09-28)—— **每局重排球位**(打散"4×4 方阵"的规律感)。
|
||
//
|
||
// 用户实测:「16 个球全部按规律排好,没有挑战性」「16 个球可以不按规则出现吗」。
|
||
// 官方 16 点是 4×4 网格 ⇒ 即使 spotSpread 把它们拉开,排列**仍然有规律**(一眼看穿行/列)。
|
||
// 开启 balloonWallRandomize 后,每局在本表点集所定义的**同一片区域**内重新随机撒点:
|
||
// 区域 = 「主轴(2D PCA) × Y 范围」;深度沿主轴法线**保持不变**
|
||
// ⇒ 仍是"上下 + 左右铺开、不往前后排"(用户明确要求);
|
||
// 拒绝采样保证 (主轴位移, Y) 平面内两两距离 ≥ balloonWallMinGap(球包围盒 0.185×0.357×0.185)。
|
||
// 颜色仍由 ordinal 决定(col = (ordinal-1)/rows)⇒ 4 色各 4 颗,只是位置被打散。
|
||
// ⚠ 新增配置键 ⇒ 必须重编 + 重启服务端(铁律 61)。
|
||
if (useSpots && cfgStall.BalloonWallRandomize && spotXyz.Length >= 2)
|
||
{
|
||
var rn = spotXyz.Length;
|
||
var rcx = 0f;
|
||
var rcz = 0f;
|
||
var ryMin = float.MaxValue;
|
||
var ryMax = float.MinValue;
|
||
for (var i = 0; i < rn; i++)
|
||
{
|
||
rcx += spotXyz[i].x;
|
||
rcz += spotXyz[i].z;
|
||
if (spotXyz[i].y < ryMin) ryMin = spotXyz[i].y;
|
||
if (spotXyz[i].y > ryMax) ryMax = spotXyz[i].y;
|
||
}
|
||
rcx /= rn;
|
||
rcz /= rn;
|
||
|
||
var sxx = 0f;
|
||
var sxz = 0f;
|
||
var szz = 0f;
|
||
for (var i = 0; i < rn; i++)
|
||
{
|
||
var dx = spotXyz[i].x - rcx;
|
||
var dz = spotXyz[i].z - rcz;
|
||
sxx += dx * dx;
|
||
sxz += dx * dz;
|
||
szz += dz * dz;
|
||
}
|
||
var axisTheta = 0.5f * MathF.Atan2(2f * sxz, sxx - szz);
|
||
var dirX = MathF.Cos(axisTheta);
|
||
var dirZ = MathF.Sin(axisTheta);
|
||
if (sxx + szz < 1e-6f) { dirX = 1f; dirZ = 0f; }
|
||
|
||
var halfW = 0f;
|
||
for (var i = 0; i < rn; i++)
|
||
halfW = MathF.Max(halfW,
|
||
MathF.Abs((spotXyz[i].x - rcx) * dirX + (spotXyz[i].z - rcz) * dirZ));
|
||
|
||
var gap = cfgStall.BalloonWallMinGap;
|
||
if (gap <= 0f) gap = 0.42f;
|
||
var spanY = ryMax - ryMin;
|
||
|
||
// ★★★★★ R140(2026-09-28)—— **帘子接缝禁带**(球不许落在三块帘子之间的木隔断上)。
|
||
//
|
||
// 用户实测(截图):「位置可以了,但是有球会被三块板子中间的隔断挡住」。
|
||
// 探针实测:三块帘子 `Lianzi`×3 的 local x = −2.423 / −0.805 / +0.811(间距 1.618 m)
|
||
// ⇒ 接缝在 local x ≈ −1.614 与 +0.003;而 R139 写进 `balloonWallSpots` 的四个「列」
|
||
// 正好是**帘子边界**(local x = −3.232 / −1.615 / +0.002 / +1.619)
|
||
// ⇒ 第 2、3 列**整列压在接缝上**,随机撒点后同样会落到那里。
|
||
// ⇒ 把「接缝两侧 balloonWallForbiddenHalfWidth 米」当禁带,采到就拒绝。
|
||
// `balloonWallForbiddenSpans` = 禁带**中心**的世界坐标("x,z"),沿主轴投影成 t 区间。
|
||
var forbidT = new System.Collections.Generic.List<(float lo, float hi)>();
|
||
var forbSpans = cfgStall.BalloonWallForbiddenSpans ?? System.Array.Empty<string>();
|
||
var forbHalf = cfgStall.BalloonWallForbiddenHalfWidth;
|
||
if (forbHalf <= 0f) forbHalf = 0.27f;
|
||
for (var i = 0; i < forbSpans.Length; i++)
|
||
{
|
||
var fp = (forbSpans[i] ?? string.Empty).Split(',');
|
||
if (fp.Length >= 2
|
||
&& float.TryParse(fp[0].Trim(), System.Globalization.NumberStyles.Float,
|
||
System.Globalization.CultureInfo.InvariantCulture, out var seamX)
|
||
&& float.TryParse(fp[1].Trim(), System.Globalization.NumberStyles.Float,
|
||
System.Globalization.CultureInfo.InvariantCulture, out var seamZ))
|
||
{
|
||
var ft = (seamX - rcx) * dirX + (seamZ - rcz) * dirZ;
|
||
forbidT.Add((ft - forbHalf, ft + forbHalf));
|
||
}
|
||
}
|
||
if (forbidT.Count > 0)
|
||
{
|
||
var sbF = new System.Text.StringBuilder();
|
||
for (var i = 0; i < forbidT.Count; i++)
|
||
sbF.Append(i == 0 ? $"({forbidT[i].lo:F3},{forbidT[i].hi:F3})"
|
||
: $" ({forbidT[i].lo:F3},{forbidT[i].hi:F3})");
|
||
conn.Log.Info(
|
||
$"[BALLOON] 球位禁带 ×{forbidT.Count}(半宽 {forbHalf:F2}m)t={sbF} " +
|
||
$"⇒ 避开三块帘子之间的木隔断");
|
||
}
|
||
|
||
var rng = new Random();
|
||
var placedT = new float[rn];
|
||
var placedY = new float[rn];
|
||
var made = 0;
|
||
for (var attempt = 0; attempt < 30000 && made < rn; attempt++)
|
||
{
|
||
var t = (float)(rng.NextDouble() * 2.0 - 1.0) * halfW;
|
||
var yy = ryMin + (float)rng.NextDouble() * spanY;
|
||
var inForbid = false;
|
||
for (var k = 0; k < forbidT.Count; k++)
|
||
{
|
||
if (t > forbidT[k].lo && t < forbidT[k].hi) { inForbid = true; break; }
|
||
}
|
||
if (inForbid) continue;
|
||
var ok = true;
|
||
for (var k = 0; k < made; k++)
|
||
{
|
||
var dl = t - placedT[k];
|
||
var dy = yy - placedY[k];
|
||
if (dl * dl + dy * dy < gap * gap) { ok = false; break; }
|
||
}
|
||
if (!ok) continue;
|
||
placedT[made] = t;
|
||
placedY[made] = yy;
|
||
made++;
|
||
}
|
||
|
||
if (made == rn)
|
||
{
|
||
for (var i = 0; i < rn; i++)
|
||
spotXyz[i] = (rcx + dirX * placedT[i], placedY[i], rcz + dirZ * placedT[i]);
|
||
conn.Log.Info(
|
||
$"[BALLOON] 球位重排(每局随机) on: {rn} 点 | 轴=({dirX:F2},{dirZ:F2}) " +
|
||
$"左右半宽 {halfW:F2}m | Y {ryMin:F2}~{ryMax:F2} | 最小间距 {gap:F2}m " +
|
||
$"| 禁带 {forbidT.Count}");
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Info(
|
||
$"[BALLOON] 球位重排(每局随机) 放弃:只排出 {made}/{rn} 点" +
|
||
$"(间距 {gap:F2}m 过紧 / 禁带 {forbidT.Count} 段)⇒ 用固定球位表");
|
||
}
|
||
}
|
||
|
||
var ordinal = 0;
|
||
for (var col = 0; col < cols && ordinal < count; col++)
|
||
{
|
||
var cs = colSpec[col];
|
||
var lateral = (col - (cols - 1) * 0.5f) * lateralStep;
|
||
for (var row = 0; row < rows && ordinal < count; row++)
|
||
{
|
||
ordinal++;
|
||
var id = batchBase + (ulong)ordinal;
|
||
var color = (ordinal - 1) % cfgs.Length;
|
||
|
||
float x, y, z;
|
||
if (useSpots && ordinal - 1 < spots.Length)
|
||
{
|
||
var sp = spotXyz[ordinal - 1];
|
||
x = sp.x; y = sp.y; z = sp.z;
|
||
}
|
||
else
|
||
{
|
||
x = cs.bx + cs.cfx * cs.fwd + cs.crx * lateral;
|
||
y = cs.by + baseHeight + row * heightStep;
|
||
z = cs.bz + cs.cfz * cs.fwd + cs.crz * lateral;
|
||
}
|
||
|
||
var pos = new UXVector3 { X = x, Y = y, Z = z };
|
||
// ★★★ R112 — 提取「这一颗实际用的 cfg」:既写进 DestructibleInfo,也记进账本。
|
||
// 旧代码账本(BalloonTargets)用的是 BalloonWire.BalloonSceneItemCfgIds 固定数组,
|
||
// 一旦 cfgSweep 生效就与实际下发的不一致 ⇒ 会按**错误颜色**算分。
|
||
var useCfg = cfgSweep.Length > 0 ? colCfg[col] : cfgs[color];
|
||
infos.Add(new DestructibleInfo
|
||
{
|
||
InstanceId = id,
|
||
UniqueId = id,
|
||
CfgId = useCfg,
|
||
PathId = cfgSweep.Length > 0
|
||
? colPath[col]
|
||
: (cfgStall.BalloonUseSharedPathId
|
||
? BalloonWire.BalloonPathId // R100: 895168017 = 派对气球 a(cfg 80001468)
|
||
: paths[color]), // R100 五色路径(与 cfgs[] 逐项对应,见 BalloonWireContracts.cs)
|
||
Position = pos,
|
||
Facing = new UXVector3(),
|
||
// ★ R109 — 扫描开启时按**列**取值(col 0..3),否则统一用 iScale。
|
||
IScale = (scaleSweep is { Length: > 0 })
|
||
? scaleSweep[Math.Min(col, scaleSweep.Length - 1)]
|
||
: iScale,
|
||
NavId = 0,
|
||
// ★★★ R100 定案 —— **ForceLod0 已被证明不是「看不见」的原因,故回退为 false。**
|
||
// R100 一度改成 true(理由:GameRouter.Gadget.cs:137 的 gym 道具用 true 且可见)。
|
||
// 但同一批代码里 `GameRouter.GameGround.cs:714 / :737`(**保龄球的球和瓶**,
|
||
// 是唯一被实测证明能渲染出 SceneItem 的兄弟玩法)用的是 **false** —— 它也看得见。
|
||
// ⇒ ForceLod0 与可见性无关。真实原因是 R100 的新定案:
|
||
// 旧 cfg 80005796..80005800 的**美术资源从未随包发布**(见 BalloonWireContracts.cs
|
||
// 顶部那段四条证据链)。本轮只改「cfg + pathId」这一对变量,其余保持原值。
|
||
ForceLod0 = false,
|
||
BindPid = 0,
|
||
Hp = 1f, // 一枪一个
|
||
State = BalloonWire.DestructibleStateActive, // 0 = Active
|
||
BreakStage = 0,
|
||
OccupantInfos = new List<SceneDeviceOccupantInfo>(),
|
||
DropWeaponId = 0,
|
||
StackCount = 1,
|
||
EffectIds = new List<int>(),
|
||
NoSleep = true,
|
||
ReleaserId = 0,
|
||
SkillInfo = null,
|
||
});
|
||
ids.Add(id);
|
||
session.BalloonCfgById[id] = useCfg; // ★★★ R112 按颜色算分的账本
|
||
cells[((int)MathF.Floor(x / gridCellSize), (int)MathF.Floor(z / gridCellSize))] = true;
|
||
}
|
||
}
|
||
|
||
var px = anchor.X;
|
||
var pz = anchor.Z;
|
||
var cx = (int)MathF.Floor(px / gridCellSize);
|
||
var cz = (int)MathF.Floor(pz / gridCellSize);
|
||
cells[(cx, cz)] = true;
|
||
|
||
var indexList = new List<SceneMethods.GridIndex>();
|
||
foreach (var key in cells.Keys)
|
||
indexList.Add(new SceneMethods.GridIndex { X = key.Item1, Z = key.Item2 });
|
||
|
||
// ★★★★★ R122(2026-09-28)—— **开新一局前,先把上一局没打掉的球删掉。**
|
||
//
|
||
// 用户实测原话:「如果我在第一轮没有把气球打完,那么那些气球并不会被销毁,
|
||
// 所以在第二轮的时候还是会出问题」。
|
||
// 实测铁证(console-latest.log,同一会话):
|
||
// R1 07:56:08 spawn(…0011..0020)→ 只打爆 14 颗 → 剩 …001D/001E
|
||
// R1 07:56:48 Timeout(40s) 局末 → 撤保护 → 07:56:50 心跳 bytes 回到 1642(整格重置)
|
||
// R2 07:57:15 / 07:57:22 客户端**仍然**为 …001D/…001E 发破坏报文
|
||
// ⇒ `[BALLOON] ignore break n=1 … first=BA110000001D/001E`
|
||
// ⇒ 「整格重置」**并不会销毁客户端已经建好的 hold**,残留球跨局活着,
|
||
// 且与 R2 的新球落在**同一个球位**(同一面 16 个 balloonWallSpots)
|
||
// ⇒ 子弹先命中残留球的碰撞体、报的是旧 id ⇒ 服务端忽略、新球打不掉也不加分。
|
||
//
|
||
// 两条正交的修法(配置项 BalloonRemoveStaleIds 统一开关):
|
||
// ① 从 `SceneItemGrid.Protected` 摘掉这批历史 id(否则 5 s 心跳一直保活它们);
|
||
// ② 把它们的 id 填进本次 AOI 报文的 `aoiInfo.RemoveIds` —— 这是
|
||
// `DestructibleGridAOIIncrease.removeIds` 的本职(与 `addUniqueIds` 成对;
|
||
// 通道说明见 GameRouter.GameGround.cs:655),客户端据此删除对应 hold。
|
||
//
|
||
// ★★★★★ R123 更正 —— R122 版在这里改成 `PurgeStaleBalloonProtection`,**结构上不可能命中**:
|
||
// 局末 `FinishBalloonRoundAsync` 已经 ① 撤保护 ② `BalloonIds.Clear()`,
|
||
// 所以下一局 spawn 时 `Protected` 里**一个气球 id 都没有** ⇒ 返回空
|
||
// ⇒ 日志 `purge stale` 恒 0、`RemoveIds` 恒空。实测(2026-09-28 08:19~08:20,两局)
|
||
// 正是如此:0 条 `purge stale`、`ignore break` 仍在(first=BA1100000017 ∈ R1 未打掉集合)。
|
||
// ⇒ 真正要知道的那批 id 必须**在局末、撤保护之前**抓下来
|
||
// (`SurvivingBalloonIds` → `BalloonSession.RetiredIds`)。
|
||
// 本条仍保留 `PurgeStaleBalloonProtection` 作兜底(覆盖「中途退场没走局末」的残目)。
|
||
var staleIds = new List<ulong>();
|
||
if (cfgStall.BalloonRemoveStaleIds)
|
||
{
|
||
staleIds.AddRange(session.RetiredIds); // 上一局正经结算后的残留(主路径)
|
||
staleIds.AddRange(PurgeStaleBalloonProtection(ctx.Session, ids)); // 兜底:未走局末的残目
|
||
staleIds = staleIds.Distinct().OrderBy(x => x).ToList();
|
||
}
|
||
if (staleIds.Count > 0)
|
||
conn.Log.Info($"[BALLOON] retire stale n={staleIds.Count} " +
|
||
$"first={staleIds[0]:X} last={staleIds[^1]:X} " +
|
||
"⇒ 上一局残留:随本次 AOI 报文 RemoveIds 下发删除(客户端应删除对应 hold)");
|
||
|
||
try
|
||
{
|
||
// 1) the hold-creating channel (identical shape to bowling's).
|
||
var gridArgs = new SyncDestructibleGridAOIDecreaseArgs4229938
|
||
{
|
||
aoiInfo = new DestructibleGridAOIIncrease
|
||
{
|
||
PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz },
|
||
AddInfos = infos,
|
||
IndexList = indexList,
|
||
AddUniqueIds = new List<ulong>(ids),
|
||
// ★ R123 — 上一局残留下发删除(空 = 上一局打干净了,正常)。
|
||
RemoveIds = staleIds,
|
||
Reason = BowlingWire.AoiReasonInit,
|
||
},
|
||
addGridInfo = new SceneMethods.GridAOIDecrease
|
||
{
|
||
SectorIdList = new List<int> { 0 },
|
||
BuildingId = 0,
|
||
StandardIndexList = indexList
|
||
.Select(g => new SceneMethods.GridIndex { X = g.X, Z = g.Z }).ToList(),
|
||
ExceptIds = new List<ulong>(),
|
||
},
|
||
removeGridInfo = new SceneMethods.GridAOIDecrease(),
|
||
};
|
||
var gridBytes = UxSerializer.Serialize(gridArgs);
|
||
await conn.NotifyRawAsync(MethodId.SyncDestructibleGridAOIDecrease, gridBytes);
|
||
// ★ R123 — 残留已随上面的报文下发,清账(放在 send 之后:send 抛异常则保留,下次重试)。
|
||
session.RetiredIds.Clear();
|
||
|
||
// 2) the object-add channel (NotifyRaw, NOT Notify: bytes are already a UX body).
|
||
var addBytes = UxSerializer.Serialize(new DestructibleObjectAddList
|
||
{
|
||
AddInfos = infos,
|
||
Reason = BalloonWire.DestructibleCreateReasonBalloon, // Balloon = 18
|
||
});
|
||
|
||
// ★★★ R98 — SIDE-CHANNEL(照抄 GameRouter.GameGround.cs:877-928 的保龄球版)。
|
||
//
|
||
// WHY: 客户端的场景物层**只有一道门** ——
|
||
// SceneItemBlob.TryGetSceneItemPlaceInfoDataByPlaceId(uniqueId, out MA_Ref, out blobType)
|
||
// 它是对「关卡烘焙摆位表」的查表,而官方关卡**没有烘焙任何气球**
|
||
// (80005796..80005800 的 placeInfo 不在摊位区任何 vfc 块里)。
|
||
// ★ R100 补充(这两件事**互相独立**,别混为一谈):
|
||
// ①「查表查不到」= 客户端不建 hold ⇒ 修法是 R98 这道 side-channel + balloon_fix.js;
|
||
// ②「美术资源没随包」= hold 建出来了、就是画不出 ⇒ 修法是换 cfg+pathId(R100)。
|
||
// R99 扩了 isOurs 后 ① 已解决(实测 hold 12/12、GameObject 非空、位姿正确),
|
||
// 剩下的现象由 ② 解释 ⇒ **本轮不需要再动注入侧**。
|
||
// 本服合成的 0xBA11_xxxx_xxxx id 查不到 ⇒ 返回 false ⇒ 不建 hold ⇒
|
||
// 「面板开了、枪拿得起来、就是看不见气球」(R97 定案,R94「不需要注入」已作废)。
|
||
// ⇒ 客户端补丁 balloon_fix.js 必须伪造这次查表,而伪造要的就是这里的真值
|
||
// (尤其位置:气球墙在玩家前方 4×3,依赖当时的 yaw,客户端自己算会错一帧)。
|
||
//
|
||
// 落盘时机 = Notify **之前**,保证客户端收到 push 时文件已经在盘上。
|
||
// 失败绝不影响主流程(整段吞异常)。
|
||
try
|
||
{
|
||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.Append("{\"t\":").Append(DateTimeOffset.UtcNow.ToUnixTimeSeconds())
|
||
.Append(",\"kind\":\"balloon\",\"count\":").Append(infos.Count)
|
||
.Append(",\"yaw\":").Append(yaw.ToString("0.###", inv))
|
||
.Append(",\"items\":[");
|
||
for (int i = 0; i < infos.Count; i++)
|
||
{
|
||
var it = infos[i];
|
||
if (i > 0) sb.Append(',');
|
||
sb.Append("{\"id\":").Append(it.UniqueId)
|
||
.Append(",\"cfg\":").Append(it.CfgId)
|
||
.Append(",\"path\":").Append(it.PathId)
|
||
.Append(",\"kind\":\"balloon\"")
|
||
.Append(",\"x\":").Append(it.Position.X.ToString("0.#####", inv))
|
||
.Append(",\"y\":").Append(it.Position.Y.ToString("0.#####", inv))
|
||
.Append(",\"z\":").Append(it.Position.Z.ToString("0.#####", inv))
|
||
.Append(",\"scale\":").Append(it.IScale)
|
||
.Append(",\"i\":").Append(i)
|
||
.Append('}');
|
||
}
|
||
sb.Append("],\"player\":{\"x\":").Append(anchor.X.ToString("0.####", inv))
|
||
.Append(",\"z\":").Append(anchor.Z.ToString("0.####", inv))
|
||
.Append("},\"source\":\"").Append(source).Append("\"}");
|
||
|
||
var json = sb.ToString();
|
||
// ★ R141(2026-09-28)—— 落盘点必须与「服务端装在哪个目录」无关。
|
||
// 原先只有下面第 2 条那个写死的 D:\Ananta 路径 ⇒ 整包发给朋友后
|
||
// 服务端在写 A、注入器在读 B ⇒ pushN 恒 0 ⇒「气球一个都不出现」。
|
||
// 第 1 条是**机器相关但安装位置无关**的共享目录,注入器按同一规则算同一路径
|
||
// (gym_tools\bf_loader.py 的 push_path(),两侧必须保持一致,改一处要改两处)。
|
||
var sharedPush = Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||
"AnantaBalloon",
|
||
"balloon_push.json");
|
||
foreach (var p in new[]
|
||
{
|
||
sharedPush,
|
||
@"D:\Ananta\gym_tools\_balloon_push.json",
|
||
Path.Combine(AppContext.BaseDirectory, "config", "balloon_push.json"),
|
||
})
|
||
{
|
||
try
|
||
{
|
||
var dir = Path.GetDirectoryName(p);
|
||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||
File.WriteAllText(p, json);
|
||
}
|
||
catch { }
|
||
}
|
||
if (!_balloonPushPathLogged)
|
||
{
|
||
_balloonPushPathLogged = true;
|
||
conn.Log.Info($"[BALLOON] side-channel → {sharedPush}(与安装位置无关;注入器读同一路径)");
|
||
}
|
||
}
|
||
catch { }
|
||
|
||
await conn.NotifyRawAsync(MethodId.SyncDestructibleObjectAddList, addBytes);
|
||
|
||
// ★★★ R93 — 关键补丁:把 12 个气球登记为「受保护的可破坏物」。
|
||
//
|
||
// 病因(v1.9 真机实测,2026-09-27 18:18):气球 18:18:19 生成后,
|
||
// 场景物网格心跳(heartbeatMs=5000)在 18:18:23 就重发了同一格 (-119,-71) 的
|
||
// SyncDestructibleGridAOIDecrease,其 aoiInfo.AddInfos 为空 ⇒ 客户端按自己的
|
||
// 烘焙 SceneItemBlob 重建该格 ⇒ 我服合成出来的气球(blob 里没有)被整片抹掉。
|
||
// 结果 = 气球打不着、分数恒 0、客户端从不发 AskBreakDestructibleObjects。
|
||
//
|
||
// 登记后,心跳会把它们的 id 放进 addGridInfo.ExceptIds,并把 DestructibleInfo
|
||
// 重新塞回 aoiInfo.AddInfos ⇒ 气球在整局内稳定存在。
|
||
ProtectDestructibles(ctx.Session, infos.Select(i => (i.InstanceId, i.Position.X, i.Position.Z, (object)i)));
|
||
|
||
// ★★★ 2026-09-30 —— 一次性延迟重推(替补被关掉的 5 s 心跳重试通道)。
|
||
//
|
||
// 【背景】sceneItems.heartbeatMs=0(物件闪烁修复,§40)把「心跳把 Protected 的
|
||
// 球重新塞回 aoiInfo.addInfos ⇒ 客户端重建 hold 再查一次 placeInfo」这条
|
||
// 恢复路径也带走了。而注入器(balloon_fix.js r147 起)的 QUERY hook 是
|
||
// 「250 ms 定时器看到新鲜 push 文件才挂」,比客户端收到 AddList 后**当场**
|
||
// 查表晚 ~0.3–1 s ⇒ 首批 N 个 id 全部 MISS ⇒ 没有重推就永远不显示
|
||
// (实测 2026-09-30 15:0x:气球一个都看不见)。
|
||
//
|
||
// 【做法】spawn 后 +1.8 s / +6 s 各重发一次**同一条** grid 报文
|
||
// (与 :1079 逐字节相同)。客户端按 AOI 快照语义重建这些 hold ⇒ 第二次
|
||
// 查表时 hook 已挂上、PUSH 已读入、TPL 已有(常驻种子挂载,见修复 ⑬)
|
||
// ⇒ FAKED 成功,气球显示。+6 s 那发是「注入器恰好慢半拍」的保险。
|
||
// ⛑ 内容与首发完全相同 ⇒ 幂等(重建同一批 hold),不会抹窗口里其他物件
|
||
// (首发同款报文实测不抹)。会话断了就静默停(catch return)。
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(1800);
|
||
await conn.NotifyRawAsync(MethodId.SyncDestructibleGridAOIDecrease, gridBytes);
|
||
conn.Log.Info("[BALLOON] 延迟重推 gridAOIDecrease (+1.8s) —— 替补 heartbeatMs=0 后丢失的重试通道");
|
||
await Task.Delay(4200);
|
||
await conn.NotifyRawAsync(MethodId.SyncDestructibleGridAOIDecrease, gridBytes);
|
||
conn.Log.Info("[BALLOON] 延迟重推 gridAOIDecrease (+6s)");
|
||
}
|
||
catch { }
|
||
});
|
||
|
||
var firstPath = cfgStall.BalloonUseSharedPathId ? BalloonWire.BalloonPathId : paths[0];
|
||
conn.Log.Info(
|
||
$"[BALLOON] spawn n={ids.Count} cells={indexList.Count} " +
|
||
$"gridAOIDecrease(68969593)={gridBytes.Length}b objectAddList(68963429)={addBytes.Length}b " +
|
||
$"reason=Balloon(18) itemTag=1 anchor=({anchor.X:F1},{anchor.Y:F1},{anchor.Z:F1}) " +
|
||
$"yaw={yaw:F1} first={ids[0]:X} last={ids[^1]:X} cfg0={cfgs[0]} path0={firstPath} " +
|
||
$"legacyIds={useLegacyIds} roundBatch={batch} " +
|
||
$"sharedPathId={cfgStall.BalloonUseSharedPathId} " +
|
||
$"iScale={iScale} lod0=false source={source} protected={ids.Count}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
conn.Log.Warn($"[BALLOON] balloon spawn failed ({source}): {ex.Message}");
|
||
}
|
||
|
||
// ★★★★★ R116 — 一局限时(官方 Balloon_CountDown = 40 s),到点服务端主动结算。
|
||
// ★ R126:把本局的「局代」一起交给看门狗(见 BalloonSession.RoundGeneration)。
|
||
ArmBalloonRoundTimeout(conn, ctx, session, cfgStall.BalloonRoundSeconds, roundGen);
|
||
|
||
return ids;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★★★★ R116(2026-09-27)—— **一局限时看门狗**。
|
||
///
|
||
/// <para>用户实测/提问:「打完了不应该是结算游戏吗,现在得自己手动离开」
|
||
/// 「这种小游戏肯定会限制只能打几枪吧,不然不限制谁都能打完,没有挑战性」。</para>
|
||
///
|
||
/// <para>官方口径(<c>ConfigDump_v3/Static/PoiGameConfig.static.json</c>):
|
||
/// <c>Balloon_CountDown = 40</c>(一局 40 秒)+ 分值 绿1/蓝3/紫5/金10
|
||
/// + 结算线 <c>Balloon_GoodEndCredit = 50</c> / <c>Balloon_NormalEndCredit = 25</c>。
|
||
/// 客户端 <c>BalloonGameStore.OnGameEnd</c> 就是按这两条线排 endType,并在 1.5 s 后
|
||
/// 自己 <c>gPanelManager:Close(BALLOON_PANEL)</c>。</para>
|
||
///
|
||
/// <para>到点后走与「打光最后一颗」完全相同的结束链路
|
||
/// (<see cref="FinishBalloonRoundAsync"/>:final score → GameOver → Dispose)。
|
||
/// 客户端提前手动退出时 <c>session.Active</c> 已为 false,这里直接返回(幂等)。</para>
|
||
/// </summary>
|
||
/// <param name="roundGen">
|
||
/// ★★★★★ R126 — 布防那一刻的 <see cref="BalloonSession.RoundGeneration"/>。
|
||
/// 醒来时若当前局代已变,说明**本局早被玩家手动退出、这是上一局留下的残骸**
|
||
/// ⇒ 直接丢弃(改前它会把下一局结算掉,用户症状:「第二轮结算时间非常快」)。
|
||
/// </param>
|
||
private void ArmBalloonRoundTimeout(
|
||
Connection conn, RpcContext ctx, BalloonSession session, int seconds, int roundGen)
|
||
{
|
||
if (seconds <= 0)
|
||
{
|
||
conn.Log.Info("[BALLOON] 限时关闭(balloonRoundSeconds<=0)⇒ 本局无时限");
|
||
return;
|
||
}
|
||
|
||
conn.Log.Info($"[BALLOON] 限时 {seconds}s 已布防(gen={roundGen},到点自动结算)");
|
||
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(TimeSpan.FromSeconds(seconds));
|
||
|
||
// ★★★★★ R126 — 双守卫:① 本局是否还活跃;② **本局还是"我这局"吗**。
|
||
// 旧代码只有 ①,而 session 是跨局复用的对象 ⇒ 上一局的任务醒来时读到的
|
||
// Active 是下一局的 true,于是把新局掐掉(实测:每局结束 = 上一局开始 + 40 s)。
|
||
if (!session.Active || session.RoundGeneration != roundGen)
|
||
{
|
||
conn.Log.Info(
|
||
$"[BALLOON] 限时任务作废 gen={roundGen} 当前gen={session.RoundGeneration} " +
|
||
$"active={session.Active} ⇒ 上一局留下的看门狗,丢弃、**不结算**");
|
||
return;
|
||
}
|
||
|
||
conn.Log.Info(
|
||
$"[BALLOON] 限时到({seconds}s)⇒ 主动结算 score={session.Score} " +
|
||
$"(GoodEnd>{BalloonWire.BalloonGoodEndCredit} / NormalEnd>{BalloonWire.BalloonNormalEndCredit})");
|
||
await FinishBalloonRoundAsync(conn, ctx, session, $"Timeout({seconds}s)", roundGen);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
conn.Log.Warn($"[BALLOON] round timeout failed: {ex.Message}");
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Push SyncGameGroundZoneState (68383444). Wire = 30 bytes, no markers:
|
||
/// GameGroundZoneIdentifier (inline struct) GadgetUId u64 | TaskId u32 | ZoneType u8
|
||
/// state u8
|
||
/// GameGroundZoneCountDownInfo (inline) 4× u32
|
||
/// See BalloonWireContracts.cs for the derivation and for why this — not
|
||
/// SyncGameGroundZoneInfo — is the signal that opens/closes the balloon zone context.
|
||
/// </summary>
|
||
private static Task PushZoneStateAsync(Connection conn, ulong gadgetUId, byte state)
|
||
=> conn.NotifyAsync(MethodId.SyncGameGroundZoneState, new GameGroundZoneStatePush
|
||
{
|
||
ZoneIdentifier = new GameGroundZoneIdentifier
|
||
{
|
||
GadgetUId = gadgetUId,
|
||
TaskId = 0,
|
||
ZoneType = BalloonWire.ZoneTypeBalloon,
|
||
},
|
||
State = state,
|
||
CountDownInfo = new GameGroundZoneCountDownInfo(),
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// LeaveBalloon (67075062) — the gameplay exit door.
|
||
//
|
||
// ClientToGameSceneDelegate.LeaveBalloon(gadgetuid : u64) -> 8 bytes, void return
|
||
// ("C2S INVOKE LeaveBalloon [0x03FF7BF6] body=8b").
|
||
//
|
||
// ★ Replying Ok is NOT enough to close the panel (see the file header). The close is driven by
|
||
// MiniGameDataManager.StopBalloon -> store.OnGameEnd -> Timer(1.5s) -> Close(BALLOON_PANEL).
|
||
// So before we reply, we push the END signal: a final SyncBalloonGameScore plus a
|
||
// SyncGameGroundZoneInfo carrying ZoneState = GameOver(3). Either of those is what the
|
||
// client's manager watches for, and pushing both makes the intent unambiguous.
|
||
// ---------------------------------------------------------------------------------------
|
||
[Handler(MethodId.LeaveBalloon, HandlerPacketKind.Both)]
|
||
private async Task LeaveBalloon4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
ulong gadgetUId = 0;
|
||
if (msg.Body is { Length: >= 8 })
|
||
gadgetUId = BitConverter.ToUInt64(msg.Body, 0);
|
||
|
||
var session = GetBalloonSession(msg.Context);
|
||
var wasActive = session.Active;
|
||
|
||
// ★★★★★ R115 —— 结束链路抽成 FinishBalloonRoundAsync,供「手动退出」与「清光自动结算」共用。
|
||
// 旧代码把整段收尾写在 if (wasActive) 里,自动结算无从复用。
|
||
if (wasActive)
|
||
{
|
||
await FinishBalloonRoundAsync(conn, msg.Context, session, "LeaveBalloon");
|
||
}
|
||
else if (msg.Context is not null)
|
||
{
|
||
// ★★★ R89 — 客户端可能在 zone 还没建立时就点了退出,那把枪同样要收回去。
|
||
await RestoreBalloonWeaponAsync(conn, msg.Context);
|
||
}
|
||
|
||
conn.Log.Info($"[BALLOON] LeaveBalloon gadgetUId={gadgetUId} wasActive={wasActive} -> Ok");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★★★★ R115 —— **结束一局**(唯一实现;LeaveBalloon 与「清光自动结算」都走这里)。
|
||
///
|
||
/// <para>为什么要抽出来:「打完了不结算、得自己手动离开」(R115 用户实测)——
|
||
/// 旧实现把结束链路绑死在 <c>LeaveBalloon</c> 上,而客户端**不会**在打光最后一颗球时
|
||
/// 自己发这个包(packets 实测:12/12 打完后 5 秒内 C2S 无任何包,直到玩家点退出)
|
||
/// ⇒ 面板永远不关。现在 <c>ScoreBalloonBreaksAsync</c> 在 <c>targets.Count == 0</c>
|
||
/// 时直接调本方法。</para>
|
||
///
|
||
/// <para>链路(三者缺一不可,见本文件头的 contract):
|
||
/// ① final <c>SyncBalloonGameScore</c>(客户端按总分排 endType);
|
||
/// ② <c>SyncGameGroundZoneInfo</c> + <c>ZoneState = GameOver(3)</c>(带**与开局完全相同**的
|
||
/// balloon id 列表 —— 客户端拿它对账,空列表会被忽略);
|
||
/// ③ <c>SyncGameGroundZoneState = Dispose(4)</c> —— ★ 唯一能真正撕掉 zone context 的通道;
|
||
/// 只推 ② 关不掉面板;推 GameOver 会走客户端有 bug 的 <c>OnGameEnd</c>(R93 定案)。</para>
|
||
/// </summary>
|
||
/// <param name="expectedGen">
|
||
/// ★★★★★ R126 — 发起者的「局代」(<see cref="BalloonSession.RoundGeneration"/>);
|
||
/// <c>-1</c> = 不校验(当前局自身的流程:手动 LeaveBalloon、清光自动结算)。
|
||
/// 只有 40 s 看门狗会传具体值 —— 它是唯一的**异步延迟**发起者,也是唯一可能
|
||
/// 「跨局醒来」的调用点。
|
||
/// </param>
|
||
private async Task FinishBalloonRoundAsync(
|
||
Connection conn, RpcContext? ctx, BalloonSession session, string reason, int expectedGen = -1)
|
||
{
|
||
if (!session.Active)
|
||
return;
|
||
|
||
// ★★★★★ R126 — 只允许「本局自己的发起者」结算本局。
|
||
if (expectedGen >= 0 && expectedGen != session.RoundGeneration)
|
||
{
|
||
conn.Log.Info(
|
||
$"[BALLOON] 结算请求作废 发起gen={expectedGen} 当前gen={session.RoundGeneration} " +
|
||
$"reason={reason} ⇒ 绝不让上一局的看门狗掐掉本局");
|
||
return;
|
||
}
|
||
|
||
// 0) 收枪(避免玩家揣着玩具枪离开摊位)。
|
||
if (ctx is not null)
|
||
await RestoreBalloonWeaponAsync(conn, ctx);
|
||
|
||
// 1) final score
|
||
await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, session.Score);
|
||
|
||
// 2) zone over: ZoneState = GameOver(3)。id 列表必须与开局一致。
|
||
var endBalloonIds = new List<ulong>(session.BalloonIds);
|
||
var endInfo = new BalloonZoneInfo
|
||
{
|
||
TargetModelId = BalloonWire.TargetModelIdNone,
|
||
TargetSceneItemCfgId = BalloonWire.TargetSceneItemCfgId,
|
||
Score = session.Score,
|
||
BalloonInstanceIdList = endBalloonIds,
|
||
ZoneSessionId = session.ZoneSessionId,
|
||
GadgetUId = session.GadgetUId,
|
||
TaskId = 0,
|
||
StartReason = BalloonWire.ZoneStartReasonSingle,
|
||
GameTypeRandom = false,
|
||
SyncReason = BalloonWire.ZoneSyncReasonEnter,
|
||
ZoneType = BalloonWire.ZoneTypeBalloon,
|
||
ZoneState = BalloonWire.ZoneStateGameOver,
|
||
ParticipantInfos = new List<BalloonParticipantInfo>(),
|
||
CountDownInfo = new GameGroundZoneCountDownInfo(),
|
||
};
|
||
await conn.NotifyAsync(MethodId.SyncGameGroundZoneInfo, endInfo);
|
||
|
||
// 3) ★ 决定性的一推:Dispose 是终态,直接撕 context(不经过有 bug 的 OnGameEnd)。
|
||
await PushZoneStateAsync(conn, session.GadgetUId, BalloonWire.ZoneStateDispose);
|
||
|
||
conn.Log.Info(
|
||
$"[BALLOON] {reason} pushed END score={session.Score} zone={session.ZoneSessionId} " +
|
||
$"state=Dispose type={BalloonWire.ZoneTypeBalloon} balloons={endBalloonIds.Count}");
|
||
|
||
session.GadgetUId = 0;
|
||
session.Active = false;
|
||
session.Score = 0;
|
||
|
||
// ★★★★★ R123 — **先抓残留、再撤保护**(顺序反了这一步就白做)。
|
||
//
|
||
// 撤保护(下一行)会把本局 16 个 id 全摘出 `SceneItemGrid.Protected`,
|
||
// 之后再也无法区分「打掉的」与「没打掉的」;而客户端 hold 里没打掉的那几颗
|
||
// 并不会因为「整格重置」心跳而消失(实测 R122:R1 未打掉 {0x15,0x17,0x1C,0x20},
|
||
// R2 仍收到 ignore break first=BA1100000017)。
|
||
// ⇒ 此刻仍在 Protected 的就是「没打掉的」,存进 RetiredIds 让下一局 spawn 下发 RemoveIds。
|
||
// ⚠ 必须带 `BalloonIds.Count > 0` 守卫:本方法可能被调用两次(Timeout 之后又 Leave),
|
||
// 第二次 `BalloonIds` 已空,若无守卫会把刚记下的残留**冲成空**。
|
||
if (ctx is not null && session.BalloonIds.Count > 0)
|
||
{
|
||
session.RetiredIds.Clear();
|
||
session.RetiredIds.AddRange(SurvivingBalloonIds(ctx.Session, session.BalloonIds));
|
||
if (session.RetiredIds.Count > 0)
|
||
conn.Log.Info($"[BALLOON] retire record n={session.RetiredIds.Count} " +
|
||
$"first={session.RetiredIds[0]:X} last={session.RetiredIds[^1]:X} " +
|
||
"⇒ 本局未打掉的球,已记入 RetiredIds,下一局 spawn 时下发 RemoveIds");
|
||
}
|
||
|
||
// ★★★ R93 — 撤下气球保护(本局结束,气球已被客户端清掉,再保护就是幽灵条目)。
|
||
if (ctx is not null && session.BalloonIds.Count > 0)
|
||
UnprotectDestructibles(ctx.Session, session.BalloonIds);
|
||
session.BalloonIds.Clear();
|
||
session.BalloonCfgById.Clear();
|
||
|
||
// ★★★ R89 — 清账本(用 placements 前不能清,这里已经撤完实体了)。
|
||
if (ctx is not null)
|
||
{
|
||
var unitId = GetWorldState(ctx).ActiveSpiritUnitId;
|
||
if (unitId != 0)
|
||
BalloonTargets.TryRemove(unitId, out _);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// AskBreakDestructibleObject (67463592) — ★★★ R89 计分闭环,R88o 完全缺失的那一环。
|
||
//
|
||
// 不打这个 handler 的后果:客户端打碎气球后上报 → 走到 DefaultUnknownInvoke ⇒
|
||
// 一条 Warn + 空应答。客户端 err=0 以为受理,**永远等不到分数推送** ⇒
|
||
// 不打结分、面板分数恒 0、也没有爆破碎屑的分数气泡。
|
||
//
|
||
// 线格式(客户端 AskBreakDestructibleObject_Serializer,**权威** = AutoGen):
|
||
// u64 breaker
|
||
// Complex <DestructibleBrokenInfo> brokeninfos: // ★ 先写 1 字节标记 0xFF
|
||
// u64 InstanceId | u32 Stage | u8 BrokenType | UXVector3 Position | UXVector3 Facing
|
||
//
|
||
// ★★★ 2026-09-27 修正(R93,原判定有误,此处为更正后结论):
|
||
// 旧代码把第 8 字节起直接当 u64 读(BitConverter.ToUInt64(body, 8)),**是错的**。
|
||
// 真实第 8 字节是 SerializeBase.WriteComplex 写的**对象标记 0xFF**
|
||
// (RPCSerializeBase.lua:77-104:WriteComplex 只写 1 字节 mark 再写内联字段,
|
||
// **不带长度前缀**),真正的 InstanceId 在**偏移 9**。
|
||
// 读偏 1 字节 ⇒ 取到 0xFF + InstanceId 前 7 字节 拼成的垃圾值 ⇒
|
||
// targets.Remove 永不命中 ⇒ 每次都走 else「ignore break」⇒
|
||
// **分数永远 0、气球打了没反应**(真机日志 31 处 score=0 + 零处 broken 即此)。
|
||
//
|
||
// 非气球破坏(普通箱子/罐子)⇒ 与本 handler 注册前行为一致:回空 ACK,不改任何状态。
|
||
// ---------------------------------------------------------------------------------------
|
||
[Handler(MethodId.AskBreakDestructibleObject, HandlerPacketKind.Invoke)]
|
||
private async Task AskBreakDestructibleObject4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var body = msg.Body;
|
||
if (body is null || body.Length < 17 || msg.Context is null)
|
||
{
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var breaker = BitConverter.ToUInt64(body, 0);
|
||
|
||
// 跳过 Complex 对象标记(0xFF;null 时为 0x00)。带 0x00 ⇒ 无 brokeninfos,直接空 ACK。
|
||
int off = 8;
|
||
byte mark = body[off];
|
||
if (mark == 0x00)
|
||
{
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
off++; // 吃掉 0xFF 标记
|
||
if (body.Length < off + 8)
|
||
{
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
var brokenId = BitConverter.ToUInt64(body, off);
|
||
|
||
var state = GetWorldState(msg.Context);
|
||
var unitId = state.ActiveSpiritUnitId;
|
||
|
||
// ★ 诊断:整包 hex + 解析出的 id —— 首次真机若仍不命中,直接看 off=9 是否就是气球 InstanceId。
|
||
conn.Log.Info(
|
||
$"[BALLOON] break-raw len={body.Length} mark=0x{mark:X2} off={off} breaker={breaker} " +
|
||
$"id={brokenId} unit={unitId} @9={BitConverter.ToUInt64(body, 9):X} hex={Convert.ToHexString(body)}");
|
||
|
||
if (unitId != 0 &&
|
||
BalloonTargets.TryGetValue(unitId, out var targets) &&
|
||
targets.TryGetValue(brokenId, out var brokenCfg))
|
||
{
|
||
targets.Remove(brokenId);
|
||
// ★ R116 — 同复数版:立刻从保护名单摘掉,否则心跳(5s)会把它重建出来(打了又刷)。
|
||
UnprotectDestructibles(msg.Context.Session, new[] { brokenId });
|
||
var session = GetBalloonSession(msg.Context);
|
||
// ★★★★★ R112 — 官方「按颜色算分」(PoiGameConfig.static.json):
|
||
// 绿 1 / 蓝 3 / 紫 5 / 金 10。旧代码固定 +10(BalloonScorePerBalloon)已作废。
|
||
var credit = BalloonWire.CreditForCfg(brokenCfg);
|
||
session.Score += credit;
|
||
var score = session.Score;
|
||
|
||
conn.Log.Info(
|
||
$"[BALLOON] broken id={brokenId} cfg={brokenCfg} credit={credit} " +
|
||
$"breaker={breaker} score={score} left={targets.Count}");
|
||
|
||
// SyncBalloonGameScore(64893406) 的载荷就是一个 int32
|
||
// (客户端 reader:midToReader[64893406] = function(reader) return reader.ReadInt32(reader) end)。
|
||
await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, score);
|
||
}
|
||
else
|
||
{
|
||
// 不是本轮气球:保持与注册前一致的空 ACK。
|
||
conn.Log.Info($"[BALLOON] ignore break id={brokenId} breaker={breaker} unit={unitId}");
|
||
}
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★★★★ R112(2026-09-27)—— **复数版破坏计分:客户端真正发的是这一条。**
|
||
///
|
||
/// 实机取证(logs/packets-latest.log 22:18:29):
|
||
/// C2S NOTIFY AskBreakDestructibleObjects [0x040BF2FC] body=48b
|
||
/// DECODED {"breaker":0,"brokenInfos":[{"InstanceId":204582177210376(=0xBA1100000008),
|
||
/// "Stage":0,"BrokenType":1,…}]}
|
||
/// ⇒ InstanceId 正是服务端登记、注入伪造的那颗气球,解码正确。
|
||
///
|
||
/// ⛔ 病因:单数版 `AskBreakDestructibleObject`(67463592) 挂着一整套计分逻辑,但
|
||
/// 客户端**从不发那一条**;而复数版过去只打一行日志 ⇒ **打爆气球永远不加分**。
|
||
/// (GameRouter.Balloon.cs:721 的老注释说「客户端从不发 AskBreakDestructibleObjects」是**反的**。)
|
||
///
|
||
/// 本方法与单数版共用同一套账本(BalloonTargets)与同一套「按颜色算分」(CreditForCfg)。
|
||
/// </summary>
|
||
internal async Task<int> ScoreBalloonBreaksAsync(
|
||
Connection conn, RpcContext ctx, ulong breaker, IReadOnlyList<ulong> brokenIds)
|
||
{
|
||
if (ctx is null || brokenIds.Count == 0)
|
||
return 0;
|
||
|
||
var state = GetWorldState(ctx);
|
||
var unitId = state.ActiveSpiritUnitId;
|
||
if (unitId == 0 || !BalloonTargets.TryGetValue(unitId, out var targets))
|
||
{
|
||
conn.Log.Info($"[BALLOON] break n={brokenIds.Count} no active balloon round (unit={unitId}) => ignored");
|
||
return 0;
|
||
}
|
||
|
||
var session = GetBalloonSession(ctx);
|
||
var gained = 0;
|
||
var hits = 0;
|
||
// ★★★★★ R116 — 被打爆的 id 收集起来,循环后从「受保护」名单里摘掉(见下方注释)。
|
||
List<ulong>? justBroken = null;
|
||
foreach (var brokenId in brokenIds)
|
||
{
|
||
if (!targets.TryGetValue(brokenId, out var cfg))
|
||
continue;
|
||
|
||
targets.Remove(brokenId);
|
||
(justBroken ??= new List<ulong>()).Add(brokenId);
|
||
var credit = BalloonWire.CreditForCfg(cfg);
|
||
session.Score += credit;
|
||
gained += credit;
|
||
hits++;
|
||
conn.Log.Info(
|
||
$"[BALLOON] broken id={brokenId} cfg={cfg} credit={credit} breaker={breaker} " +
|
||
$"score={session.Score} left={targets.Count}");
|
||
}
|
||
|
||
// ★★★★★ R116(2026-09-27)—— **止住「打了又刷出来」**。
|
||
//
|
||
// 用户实测:「紫,蓝,绿球在不停的出现,掉落,出现,掉落」「打了还会再刷新」
|
||
// 「只有金色球正常,所以永远结束不了」。服务端日志给出了硬证据:同一批 id 被打爆
|
||
// 之后又收到 11 次 `ignore break`(同一个 InstanceId 再爆一遍,账本里已经没有它了)。
|
||
//
|
||
// 病因是**我们自己**:spawn 时 16 颗球被 `ProtectDestructibles` 登记进
|
||
// SceneItemGrid 的 `Protected`,而网格心跳(heartbeatMs=5000)会把 Protected 里的条目
|
||
// 塞回 `addGridInfo.AddInfos` 一起重发(SceneItemGrid.cs:358,R93 加的机制)⇒
|
||
// 客户端每 5 秒按服务端重发的 DestructibleInfo **重建**已打爆的球(本地销毁状态被覆盖)。
|
||
// 结果:永远打不完 ⇒ 永远不进结算;而且重生的球再挨一枪时服务端只能 ignore。
|
||
//
|
||
// ⇒ 打爆一颗,就立刻把它从 Protected 里摘掉:心跳只会重发「还活着」的球。
|
||
// (整局结束时 LeaveBalloon 依旧会做一次全量 Unprotect,重复摘除是幂等的。)
|
||
if (justBroken is not null)
|
||
{
|
||
UnprotectDestructibles(ctx.Session, justBroken);
|
||
conn.Log.Info(
|
||
$"[BALLOON] unprotect n={justBroken.Count} ⇒ 心跳(5s)不再重建已打爆的球 " +
|
||
$"(left={targets.Count})");
|
||
}
|
||
|
||
if (hits == 0)
|
||
{
|
||
// ★★★★★ R119 —— **兜底摘除**(与计分无关,纯防御)。
|
||
// 客户端有可能报一个「id 在我们段内,但服务端账本里已经没有了」的球
|
||
// (跨局残留 / 重发的球再挨一枪)。这种情况下如果什么都不做,那颗球会一直留在
|
||
// `SceneItemGrid.Protected` 里,被 5 s 心跳反复重发 ⇒ 客户端重建 ⇒ **打完又刷新**。
|
||
// ⇒ 只要 id 落在本局段内(高 32 位 = 0xBA11)就摘掉;重复摘除是幂等的。
|
||
var segIds = new List<ulong>();
|
||
foreach (var brokenId in brokenIds)
|
||
if (BalloonWire.IsBalloonInstanceId(brokenId))
|
||
segIds.Add(brokenId);
|
||
if (segIds.Count > 0)
|
||
{
|
||
UnprotectDestructibles(ctx.Session, segIds);
|
||
conn.Log.Info($"[BALLOON] unprotect(seg-fallback) n={segIds.Count} " +
|
||
$"first={segIds[0]:X} ⇒ 心跳不再重建这些段内 id");
|
||
}
|
||
|
||
conn.Log.Info($"[BALLOON] ignore break n={brokenIds.Count} breaker={breaker} unit={unitId} " +
|
||
$"first={brokenIds[0]:X}");
|
||
return 0;
|
||
}
|
||
|
||
// SyncBalloonGameScore(64893406) 载荷 = 裸 int32(客户端 reader: reader.ReadInt32)。
|
||
await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, session.Score);
|
||
conn.Log.Info($"[BALLOON] pushed SyncBalloonGameScore score={session.Score} " +
|
||
$"(+{gained} from {hits} balloon(s), breaker={breaker})");
|
||
|
||
// ★★★★★ R115 — **清光即自动结算**。
|
||
// 用户实测(R115):「打完了不应该是结算游戏吗,现在得自己手动离开」。
|
||
// packets 铁证:12/12 打完后 5 秒内 C2S 一个包都没有(下一次 C2S 就是玩家点退出的
|
||
// LeaveBalloon,23:06:03)⇒ 客户端**不会**自己发结束包 ⇒ 结束链路必须由服务端在
|
||
// 最后一颗被判定时主动触发,否则面板永远不关。
|
||
if (targets.Count == 0)
|
||
await FinishBalloonRoundAsync(conn, ctx, session, $"AutoFinish(last broken, hits={hits})");
|
||
|
||
return gained;
|
||
}
|
||
|
||
/// <summary>
|
||
/// GmEnterBalloonZone (69363970) — the GM-console door into the same zone entry.
|
||
///
|
||
/// Like its bowling twin (GmEnterBowlingZone 69135013) this is a DIFFERENT RPC from
|
||
/// EnterBalloonZone, and neither implies the other. Arg layout is
|
||
/// ClientToGameSceneGMDelegate.lua (SerializerHelper.GmEnterBalloonZone_Serializer):
|
||
/// gadgetuid : u64 (WriteUInt64)
|
||
/// gametype : u32 (WriteUInt32) <- u32 here, u8 in the gameplay door
|
||
/// -> 12 bytes. This one is void-returning, unlike EnterBalloonZone which returns the object.
|
||
///
|
||
/// The in-world GM console (what /api/gm/send drives via SyncDoGmCommand) only ever sends this
|
||
/// id, so with EnterBalloonZone whitelisted and this one missing, the console command would
|
||
/// fall through to the typed-default body and the client would do nothing.
|
||
/// </summary>
|
||
[Handler(MethodId.GmEnterBalloonZone, HandlerPacketKind.Invoke)]
|
||
private async Task GmEnterBalloonZone4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
ulong gadgetUId = 0;
|
||
uint gameTypeRaw = 0;
|
||
if (msg.Body is { Length: >= 12 })
|
||
{
|
||
gadgetUId = BitConverter.ToUInt64(msg.Body, 0);
|
||
gameTypeRaw = BitConverter.ToUInt32(msg.Body, 8);
|
||
}
|
||
|
||
// gameType is a u32 on this wire and a byte everywhere downstream; clamp is safe because
|
||
// the client's own gametype enum only defines small values.
|
||
var gameType = gameTypeRaw <= byte.MaxValue ? (byte)gameTypeRaw : (byte)0;
|
||
|
||
conn.Log.Info($"[BALLOON] GmEnterBalloonZone gadgetUId={gadgetUId} gameType={gameTypeRaw} bytes={msg.Body.Length}");
|
||
|
||
await StartBalloonZoneAsync(conn, msg.Context, gadgetUId, gameType, "GmEnterBalloonZone");
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
}
|