using System.Runtime.CompilerServices; using System.Text.Json; using Ananta.SDK.Network; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// 赛车检查点门(R247 · 2026-10-01)—— 服务端显式摆放。 /// /// 症状:整条赛道上一道绿框门都没有(用户:「开始赛车了,没有看到绿框门」)。 /// /// 为什么没有:门是 gadget 对 /// Racing_challenge01_CheckPoint / Racing_challenge01_CheckPointEnd, /// 官方**按检查点位置在运行时 spawn**;而检查点来自 /// RacingDriverTrackInformationConfig.RaidSpoonName 指向的 Spoon 图 —— /// 这张图**从未合并进客户端**(SpoonMd5="" ⇒ SpoonRaidMgr 走 /// #NoCreateIssue),所以官方那条链一次都没执行过,门**从来没人 spawn**。 /// 不是坏了,是没装。 /// /// 为什么必须服务端摆:R244 已经试过「客户端 Lua 自己 /// gResourceManager:LoadAssetWithCallBack + GameObject.Instantiate」, /// 实机证据是 16/16 全部实例化成功、go0.active=true、scale=1/1/1、 /// children=2、全程**零** Load asset FAILED —— 但**肉眼一道都看不见**。 /// 这与「家具全无 / 健身器材 / 摊位补给」是同一个病根:**客户端不会自己实例化这类物件**, /// 必须由服务端经 gadget 通道下发。 /// /// 通道:SpawnGadgetAsync(健身器材 / PropRestock / 摊位补给都走它,实测可见)。 /// ⛔ iScale 必须传 0:100(GM 面板默认)出来是 transform scale (0,0,1) /// 的**零缩放空壳、没有 prefab** —— 正是保龄球球瓶「有物理有音效但看不见」的根因。 /// /// 坐标:客户端 R225 已用 LoadRaceTrackData 采出 16 个检查点(沿弧长等分), /// R247 把它们与**服务端那套 pathId** 一起落到 config/racing-gates.json。 /// 服务端读表摆放 —— 与健身器材 _gym_capture.py 的「离线采样 → 落配置 → 服务端摆」完全同构。 /// /// 拿不到表时:在玩家四周摆 4 道「验证信标」。它的价值是把 /// 「通道对不对」和「坐标对不对」两个变量解耦:看得见信标 ⇒ 通道没问题,只差坐标。 /// /// 回滚:world.content.racingGates.enabled = false(不用重编译)。 /// internal sealed partial class GameRouter { /// /// /// Racing_challenge01_CheckPoint(官方赛道检查点门)+ ..._CheckPointEnd(终点门)。 /// 服务端 GadgetCatalog 那套 id 空间。 /// /// ★★★ R256 更正:R252 写的「**这两个 prefab 的资源根本没随这个客户端包** /// ⇒ 摆上去也是隐形的」**是错的**。硬证据(gym_tools/_r256_gate_instances.py, /// 全库 1682 块 + 88B 记录头校准):关卡块 vfc_1479 / vfc_859 里官方 /// **静态摆了 16 个** Racing_challenge01_CheckPoint(如 /// (1914.60, 0, 2234.30) yaw=255.71)+ 2 个 ...End /// + SelfRacing_PortalGuide + SelfRacing_EffectGuideLine01 /// ⇒ 官方自己就在关卡里摆它们 ⇒ 客户端必然能渲染 ⇒ **资源一定随包**。 /// 当轮「看不见」的真因见 说明 /// (yaw 被算成 atan(dx) 恒 ±90 + pairMirror 同坐标重叠)。 /// /// 这两个常量同时用于:① 默认摆位(pathIdOverride = 0 时); /// ② 清理旧批次(SpawnRacingGatesAsync ①)。 /// internal const int RacingGateCheckPointPathId = 884611825; /// ★ R256:Racing_challenge01_CheckPointEnd(终点门)。 private const int RacingGateEndPathId = 271608686; /// /// R252:SelfRacing_PortalGuide(单人赛车·传送门指引)—— /// 从客户端自己的注册表块 `vfc_907` 逐字读出(`name`/`GraphId`/`path`/`pathHash`/`pathId` /// 五元组,全表 2932 条已导出为 gym_tools/_r252_vfc907_registry.json)。 /// 它是「单人赛车」族里**唯一**有资源块的物件。 /// internal const int SelfRacingPortalGuidePathId = 2017204172; /// /// R253:Jobrace_gongmen(竞速·拱门)—— 本轮锁定的目标物件。 /// /// 为什么是它(三条独立证据): /// /// 名字直译就是拱门:Jobrace(Job Race,竞速)+ gongmen(拱门)。 /// 全库唯一一个名字里带「拱门」的 gadget。 /// 它的美术结构就是拱门:资源块 vfc_1583 / vfc_1582 里 dump 出 /// gp_arch_gongmen_blue_left / _right / _up 三件套 /// (arch = 拱)—— **左柱 + 右柱 + 顶拱**,与参考视频里 /// 「路两侧各一根光柱、顶端向内弯折成拱」完全同构; /// 另有 gp_arch_gongmen_yellow(黄门)与 fx_gp_arch_gongmen_blue_lod1/2。 /// 它**明确绑定本玩法**:vfc_904 里 Jobrace_ 族与 /// 23300888 同现 —— Jobrace_Spurt01_23300888 / 23300888.Jobrace_F01 / /// 23300888.Racing_challenge02 / ClientActionRacing_challenge01_23300888。 /// 而 23300888 正是本赛道玩法 id(RacingDriverTrackInformationConfig.RaidSpoonName /// = "23300888_RacingDriver")。 /// /// /// 与 gp_wangpaicheshen_men_blue/yelllow 的区别:后者名字里有 men, /// 但只在 vfc_1582/vfc_1583 里作为**特效资源**出现,且没有 left/right/up 的三段结构; /// arch_gongmen 才是拱形门本体。 /// internal const int JobraceGongmenPathId = 64493331; /// /// ★ R273:S6_Racing_CheckPoint(`440353630`)—— R270 那轮误把它当成门摆了上去。 /// 只要它没进 purgeIds,一旦把 pathIdOverride 换回官方默认(0), /// 上一批 S6 门就不会被清 ⇒ 赛道上叠一层残留。故此处登记,纳入每局清理。 /// internal const int S6RacingCheckPointPathId = 440353630; /// /// /// ★★★★★ R255(2026-10-02)—— **官方权威检查点表**:track 3 = Nova(`23300888`), /// `RacingDriverTrackInformationConfig.RacingPathName = "RT_888_001"`, /// **含终点共 26 个**。 /// /// 数据来源(离线可复现,零注入):关卡块 /// Ananta_Data/StreamingAssets/Blocks/vfc_819 里躺着一份**明文 JSON** /// (偏移 ≈ 58662107),exportName = /// "Assets/Res/Prefab/ZoneShapeView/ZoneShapes_WorldMap_Release_Saidao.prefab": /// /// {"exportName":"...Saidao.prefab","simplifyTolerance":0.1, /// "tracks":[{"trackIndex":0,"pointCount":259,"isClosed":false, /// "points":[{"x":3112.446,"y":-0.11,"z":899.959, /// "checkpointIndex":-1,"distanceToNextCheckpoint":141.98}, ...]}]} /// /// ⇒ 官方把检查点划分烘焙在每个赛道点的 `checkpointIndex` 里 /// (同族类 MassAI.ZoneGraph.RacingTrackPoint 的字段 = /// x,y,z,checkpointIndex,distanceToNextCheckpoint)。 /// checkpointIndex 值域 = -1 与 0..25;-1 是起点前的引导段 /// ⇒ **真正的检查点是 0..25 共 26 个**(与用户「跟着别人的视频一个一个数出 26 个」完全一致)。 /// /// 检查点 k 的位置 = 第 k 组的第一个点(已由 distanceToNextCheckpoint 自证: /// cp0 组末点 s=215.07, d_last=5.93 ⇒ 221.00,而 cp1 组起点 s=220.99,吻合; /// -1 组末点 s=120.77, d_last=21.21 ⇒ 141.98 = cp0 起点 s=141.98,吻合)。 /// /// 为什么必须内置、不能用 R225 那套采样: /// ① R225 走 LX6.Utils.LuaUtils.LoadRaceTrackData → /// RaceTrackExportData.ExtractFilteredTrackPoints(),那条链**只取 x/y/z、把 /// `checkpointIndex` 削掉了**; /// ② 它还做了**不等距重采样** —— 官方 259 点 / 总长 3382.44 m,探针只拿到 /// 256 点 / 3225 m ⇒ 在它上面做「弧长等分」**必然错位**; /// ③ R225 的数量本身就是猜的(R225_CP_COUNT = 16 -- 原版 Spoon 图数量未知,取保守中值); /// ④ 那张采集表还是**客户端探针每局重写**的(见 PathIdOverride),坐标同样不可托付。 /// /// 交叉验证:R225 采出的 16 个点在官方 259 点列上**逐点命中、距离 0.00 m** /// ⇒ 同一个几何路径,只是点序列/弧长不同 —— 这既证明「赛道认对了」, /// 也证明「用探针曲线等分」的方案从根上就不可能对齐官方检查点。 /// /// yaw(R293 起)= **该检查点处的「道路方向」(局部切向)**,取官方 259 点折线里 /// **含该点那一段**的方向(atan2(Δx, Δz))。语义 = 门面法向沿路 ⇒ 门横跨路面。 /// /// ★★★ R255d 的现象真因(R256 定案):**不是**朝向差 90°,而是两个独立问题叠加 —— /// ① 成对镜像:当时 pairMirror=true 在**同一坐标**又摆了一扇, /// 于是「两扇门叠在一块 / 都在中间」(服务端日志实证那局 n=26,但视觉上重叠); /// ② 朝向从来没算对过:客户端探针 racing_guard.lua 的 yawOf() 写的是 /// math.atan(dx, dz),而 LuaJIT 的 math.atan **只有单参形式、第二参被静默丢弃** /// ⇒ 实际算的是 atan(dx),恒在 ±90 附近。逐条对上(gym_tools/_r256_yawsemantics.py): /// cp00→cp01 Δx=-69.83 ⇒ atan(-69.83) = -89.18°,表里实测 **-89.20**; /// cp04→cp05 Δx=+0.25 ⇒ atan(0.25) = 14.00°,表里实测 **14.00**。 /// 外面包着 pcall 不报错 ⇒ 注释里写的 atan2 兜底**从未执行**。R256 已改为显式 atan2。 /// /// ⇒ yawOffset 保持 0,不要用 ±90 去「掰」。R255d 那条「必须 +90」是 /// 被上面两个假象误导后加的**猜测**,已被官方数据推翻。 /// /// 生成脚本:gym_tools/_r255_extract_tracks.py + /// _r255_reduce_cp.py + _r255_gen_official_gates.py; /// 报告 gym_tools/2.1诊断_赛车门官方权威位置_R255.md。 /// internal static readonly (float X, float Y, float Z, float Yaw)[] OfficialTrack3Checkpoints = [ // ★★★★★ R293(2026-10-02)定案:yaw = **该检查点处的「道路方向」(局部切向)** // —— ⛔ **R290 那版「指向下一个检查点」作废**。 // 用户原话(21:0x):「检查点可以不用朝向下一个点,就和道路平行就行了」。 // // ✅ **为什么「指向下一个点」是错的**(复算脚本 `gym_tools/_r293_road_tangent.py`): // 「道路方向」= 含该点的**那一段**官方折线方向; // 「指向下一个检查点」= 取的是**下一段路的弦**。直道上二者等价,**弯道上差得离谱** —— // 26 个点里 **9 个偏 > 20°**: // cp07 偏 **114.8°**(入向 -91.6° vs 出向 +23.2°,急弯) // cp22 偏 71.1° · cp10 偏 70.9° · cp14 偏 62.8° · cp05 偏 51.5° // cp19 偏 50.5° · cp02 偏 39.1° · cp21 偏 34.9° · cp15 偏 34.5° // `Jobrace_gongmen` 是 **X-Y 平面薄板**(`Box Volume ls=(21.56,14.46,2.42)` ⇒ 法向 = local Z) // ⇒ 只有 **local Z 沿路** 门才「横跨路面」。用弦向就把门拧成「顺路躺」; // 更糟的是 `ExpandGatePairs` 的 ±8 m 侧移是沿 `right(yaw)` 的 ⇒ 也被拧成 // **沿路前后串**(cp07 实测 A 在前、B 在后,相距 15.6 m)⇒ 两半根本合不成一扇门。 // // ★ **数据来源(官方、可复算、零猜测)**:关卡块 `vfc_819` 明文 JSON // `Assets/Res/Prefab/ZoneShapeView/ZoneShapes_WorldMap_Release_Saidao.prefab` // track0 = **259 点 / 3382.44 m / isClosed=false**。 // 26 个检查点**逐点落在折线上,最大偏差 0.009 m** ⇒ 直接取「含该点那一段」的方向。 // ⚠ 与本仓 `_r255_official_cp.txt` 里那列 yaw 最大差 **0.61°**(同源互证)。 // // ⛔ **别拿箭头公式来套门**:官方 `CarRaceManager.lua:UpdateArrowPosAndRot` 走 // `LookRotation(nextTargetPos - targetPos)` ⇒ 那确实是「指向下一个检查点」, // 但那是**指示牌 / 箭头**(`CheckPointArrow`,客户端的 `csToLua` 小地图箭头)的语义。 // 门(`Jobrace_gongmen`)是另一件东西。**R290 翻车就是拿箭头公式去套门**。 // // ⚠ 官方箭头还做 `math.floor(y/10 + 0.5) * 10` 的 **10° 吸附**;本表用原始切向,不吸附。 // // ★ A/B 开关:`racingGates.yawMode` = `road`(默认,本表)/ `next`(= 下面 // `OfficialTrack3YawNext`,R290 那套弦向)。改它**不用重编译、不用重启服务端** // (`private-server.json` 热重载 + 每局开赛重读),下一局即生效。 ( 2988.36f, -0.11f, 968.96f, -61.54f), // cp00 ( 2918.53f, -0.11f, 1005.92f, -63.52f), // cp01 ( 2758.34f, -0.11f, 1065.55f, -73.69f), // cp02 ← 弦向 -35.23(偏 39.1°) ( 2711.20f, -0.11f, 1132.29f, 31.30f), // cp03 ( 2759.20f, -0.11f, 1228.22f, 0.18f), // cp04 ( 2759.45f, -0.11f, 1308.83f, 0.18f), // cp05 ← 弦向 -51.27(偏 51.5°) ( 2691.94f, -0.11f, 1362.97f, -91.57f), // cp06 ( 2540.19f, -0.11f, 1358.80f, -91.57f), // cp07 ← 弦向 +23.22(偏 114.8° 最狠) ( 2601.51f, -0.11f, 1501.76f, 27.93f), // cp08 ( 2607.47f, -0.11f, 1639.18f, -0.78f), // cp09 ( 2608.00f, -0.11f, 1717.72f, 0.56f), // cp10 ← 弦向 -70.32(偏 70.9°) ( 2518.33f, -0.11f, 1749.80f, -88.35f), // cp11 ( 2442.01f, -0.11f, 1751.01f, -89.15f), // cp12 ← 弦向 -119.32(偏 30.1°) ( 2252.23f, -0.11f, 1644.42f, -124.37f), // cp13 ( 2174.30f, -0.10f, 1626.09f, -89.92f), // cp14 ← 弦向 -27.12(偏 62.8°) ( 2147.08f, -0.10f, 1679.23f, 4.82f), // cp15 ← 弦向 -29.68(偏 34.5°) ( 2106.91f, -0.11f, 1749.70f, -88.48f), // cp16 ( 2039.88f, -0.11f, 1765.89f, -87.05f), // cp17 ( 1907.99f, -0.11f, 1756.08f, -88.33f), // cp18 ( 1716.35f, -0.11f, 1765.58f, -90.16f), // cp19 ← 弦向 -140.65(偏 50.5°) ( 1671.20f, -0.04f, 1710.52f, -175.27f), // cp20 ( 1681.39f, -0.10f, 1655.91f, 161.19f), // cp21 ← 弦向 +126.34(偏 34.9°) ( 1875.95f, -0.10f, 1512.77f, 113.35f), // cp22 ← 弦向 -175.56(偏 71.1°) ( 1868.70f, -0.11f, 1419.32f, -169.76f), // cp23 ( 1805.49f, -0.11f, 1301.48f, -92.36f), // cp24 ( 1582.79f, -0.11f, 1266.68f, -99.18f), // cp25 = 终点(含终点第 26 个) ]; /// /// R290 遗留:**「指向下一个检查点」的弦向 yaw**(atan2(Δx, Δz),末点回退用前一点)。 /// 只在 racingGates.yawMode = "next" 时使用,留作 A/B 对照与回滚。 /// ⚠ **默认不要用**:弯道上 9 个点偏 > 20°(cp07 偏 114.8°),见 /// 的说明。 /// 生成脚本 gym_tools/_r290_official_yaw_table.py。 /// internal static readonly float[] OfficialTrack3YawNext = [ -62.11f, -69.58f, -35.23f, 26.58f, 0.18f, -51.27f, -91.57f, 23.22f, 2.48f, 0.39f, -70.32f, -89.09f, -119.32f, -103.24f, -27.12f, -29.68f, -76.42f, -94.25f, -87.16f, -140.65f, 169.43f, 126.34f, -175.56f, -151.79f, -98.88f, -98.88f, ]; /// R255:把内置官方表变成摆位计划(含 YawOffset 修正)。 /// /// ★★★ R256 —— **默认改用官方自己的检查点 prefab**(), /// 不再用 R253 选的 Jobrace_gongmen(拱门)。 /// /// 为什么换回去:config/racing-gates.json 里(客户端探针写的) /// 每个检查点本来就是 pathId 884611825 / 终点 271608686 —— /// 也就是 Res/Prefab/Gadget/Racing_challenge01_CheckPoint.prefab。 /// 是 R253 起用 PathIdOverride=64493331 把它**覆盖**成了拱门。 /// /// R254 的注释写过「884611825 资源从未随包 ⇒ 隐形」,**该判断已被推翻**: /// 关卡块 vfc_1479 里官方**实实在在地摆了 16 个该 prefab** /// (+2 个 ...End,见 _r256_gate_instances.py / _r190_racing_checkpoints.json) /// ⇒ 客户端一定能渲染它,资源必然随包。 /// 当轮"看不见"的真因是 **yaw 算错**(见下)把门转到了错误方向/位置。 /// /// 切换开关:racingGates.pathIdOverride。 /// 0(默认)= 官方检查点 prefab(终点自动用 ...End); /// 64493331 = 回退到 R253~R255 的拱门(此时终点同 prefab)。 /// internal static List BuildOfficialTrackGates(RacingGateSettings cfg) { var list = new List(OfficialTrack3Checkpoints.Length); var last = OfficialTrack3Checkpoints.Length - 1; var overridden = cfg.PathIdOverride != 0; // ★★★★★ R293 —— 朝向来源二选一(`racingGates.yawMode`): // "road"(默认)= 表里的 c.Yaw = **该点道路局部切向** ⇒ 门横跨路面、两半跨路并排; // "next" = R290 的弦向(OfficialTrack3YawNext)⇒ 只在弯道会偏,留作 A/B。 // 改这个键**不用重编译、不用重启服务端**(private-server.json 热重载,每局开赛重读)。 var useNext = string.Equals(cfg.YawMode, "next", StringComparison.OrdinalIgnoreCase); for (var i = 0; i < OfficialTrack3Checkpoints.Length; i++) { var c = OfficialTrack3Checkpoints[i]; var isEnd = i == last; var pid = overridden ? cfg.PathIdOverride : (isEnd ? RacingGateEndPathId : RacingGateCheckPointPathId); var baseYaw = useNext && i < OfficialTrack3YawNext.Length ? OfficialTrack3YawNext[i] : c.Yaw; list.Add(new RacingGatePlacement( pid, c.X, c.Y, c.Z, baseYaw + cfg.YawOffset, isEnd ? $"cp{i:D2}-END" : $"cp{i:D2}")); } return list; } private const string RacingGateLogKey = "client4229938-racing-gates"; /// 一条待摆放的门。 internal sealed record RacingGatePlacement( int PathId, float X, float Y, float Z, float Yaw, string Label); /// /// ⛔⛔ R258 定案:这个开关 = 「我们自己造的门宽」,不是官方数据 ⇒ 默认必须关。 /// /// 用户的两条纠正(R258,决定了本开关的命运): /// 「路宽不一定代表门宽啊,不要查错方向了」+「赛道的宽度也是根据检查点的宽度来计算的」 /// ⇒ 门宽是「门」这件物件自带的,赛道按它建;所以**没有**需要我们填的宽度, /// 也就不该有 pairMirror / pairOffset 这两个旋钮。 /// R257 把它们打开,等于「我们自己在给门调宽」—— 已被明确叫停。 /// /// R258 的官方摆位判据(这才是「一个实例够不够」的正确证法): /// gym_tools/_r190_racing_checkpoints.json 里官方静态摆了 **14 个** /// Racing_challenge01_CheckPoint,两两**最近距离 131.69 m、完全不成对** /// ⇒ 官方就是「**一个检查点摆一个实例**」。 /// 再加上 gadget 注册表(vfc_907)里带「拱门」语义的物件**只有一个** /// (Jobrace_gongmen 64493331,其 prefab 自带 _left/_right/_up) /// ⇒ **一扇门 = 一个实例**,宽度由 prefab 自身决定,我们没有可填的数。 /// /// R254 的假设(已推翻):看到画面只有一根立柱 + 顶端向内弯折 ⇒ 认为 /// 一个 Jobrace_gongmen 实例只是**半扇拱门**,要同坐标摆两个(yaw 差 180°)才成整扇。 /// /// R255d 的实测证据(推翻它): /// ① 资产扫描(`gym_tools/_r255c_gate_prefab_scan.py`,全库 1682 块)—— /// vfc_1583 里 **同一个 prefab** 自带三件套: /// gp_arch_gongmen_blue_left(+`_left_lod2`)、 /// gp_arch_gongmen_blue_right(+`_right_lod12`)、 /// gp_arch_gongmen_blue_up(+`_up_lod`)⇒ **一个实例就是完整一扇门**。 /// R254 当时看到的「只有右半」,最可能是**左半被棕榈树 / 那栋黑楼挡住**。 /// ② 打开本开关后用户实测原话:「**门叠在一块了**」—— /// 因为两个完整门被摆在**同一坐标**,只能互相 z-fighting 重叠。 /// /// 结论:`pairMirror` 保持 **false**(配置 world.content.racingGates.pairMirror)。 /// /// ⛔ R258 更正本条:R257 把 A、B 拆到对侧(-ox/-oz)本身没错, /// 但**整个「成对」模型是自造的**(见上)。官方每检查点只摆 1 个实例 /// ⇒ 一扇门 = 一个 prefab,不需要第二个实例,也就没有「柱到中线的距离」要调。 /// 所以 pairOffset 的真实语义只是「把整道门横向平移」,与「门宽」无关。 /// private static List ExpandGatePairs( List plan, RacingGateSettings cfg) { if (!cfg.PairMirror || plan.Count == 0) return plan; var shift = cfg.PairOffset; var outp = new List(plan.Count * 2); for (var i = 0; i < plan.Count; i++) { var p = plan[i]; // ★★★★★ R291 定案(这是 `yawOffset` 从「空操作」变成「真旋钮」的那一行): // **侧移方向必须用「未加 `yawOffset` 的基准 yaw」,朝向才用含 offset 的 `p.Yaw`。** // // 为什么必须解耦(代数,可直接验算): // `yawOffset` 加在 `BuildOfficialTrackGates:254`(本函数之前)⇒ `p.Yaw` 已含 offset。 // 若侧移也用它:`right(yaw+180) = −right(yaw)` ⇒ A 拿到旧 B 的位置 + 旧 B 的朝向, // B 拿到旧 A 的位置 + 旧 A 的朝向 ⇒ **{A,B} 集合完全不变** ⇒ `yawOffset` 是**空操作**。 // (R289 据此白烧一局;R290 立铁律 529。) // 解耦后:位置由 `baseYaw` 定(与 offset 无关 ⇒ 位置永不随 offset 动), // 朝向 = `p.Yaw` = `baseYaw + offset` ⇒ **每一半绕自己的根部原地转 `offset` 度** // ⇒ `yawOffset = 180` 真正把每一半**原地翻 180°**。 // // ★ R291 为什么要翻这一下:**官方 prefab 自己给出了答案(实例级 dump,不是像素反推)**: // 客户端运行时对象树 `Jobrace_gongmen_` → `fx_gp_arch_gongmen_blue_lod2(Clone)` // → `..Box Volume` 的局部位置 = **x = −8.94**、尺寸 **21.56 × 14.46 × 2.42** // (薄边在 local Z ⇒ 门面是 local X‑Y 平面、法向是 local Z)。 // ⇒ **门体整体偏在 local −X 侧**(不是以根部为中心)。 // 我们给 `yaw = 该点指向下一检查点`(= 官方公式,R290 已与客户端 `arRot=(0,300,10)` 对上), // Unity 约定 local +X = 赛道右侧 ⇒ **local −X = 赛道左侧** ⇒ 现门体朝**路外**长。 // ⇒ 与「两柱贴路两侧、顶端**朝路中央**收拢」正好相反 ⇒ **这就是用户说的「反了」**。 // // Unity 约定:`forward = (sin yaw, 0, cos yaw)` ⇒ `right = (cos yaw, 0, −sin yaw)`。 // 直线段上本式与旧的中心差分**完全等价**(旧 n = (−cos yaw, +sin yaw) = −right) // ⇒ R288 的 bug 修复性质不变。 var baseYaw = p.Yaw - cfg.YawOffset; // ★ R291:侧移基准(去掉朝向偏置) var ox = 0f; var oz = 0f; if (shift != 0f) { var rad = baseYaw * (float)System.Math.PI / 180f; ox = (float)System.Math.Cos(rad) * shift; oz = -(float)System.Math.Sin(rad) * shift; } // A 摆 −right 侧、朝向 `p.Yaw`;B 摆 +right 侧、朝向 `p.Yaw+180°` // ⇒ 两半的连线**严格垂直于路面**(同横截面、零前后错位); // 两半各自绕根部转 `yawOffset` ⇒ `180` = 两臂朝路中央(R291 默认态)。 outp.Add(new RacingGatePlacement( p.PathId, p.X - ox, p.Y, p.Z - oz, p.Yaw, p.Label + "-A")); outp.Add(new RacingGatePlacement( p.PathId, p.X + ox, p.Y, p.Z + oz, p.Yaw + 180f, p.Label + "-B")); } return outp; } private static readonly object RacingGateTableLock = new(); /// 同一个 session 只提醒一次「表还没有」——缺采集文件是设置步骤,不是运行错误。 private static bool RacingGateNoticed(TcpSession session) { lock (session.Items) { if (session.Items.ContainsKey(RacingGateLogKey)) return false; session.Items[RacingGateLogKey] = true; return true; } } /// /// 在一局赛车开始时摆门。由 RunRacingAutoStartAsync 在 DISPLAY 推出去之后调用 /// (倒计时 3 秒期间门就该在了)。 /// internal static async Task SpawnRacingGatesAsync( TcpSession session, WorldEntryState state, uint trackId, uint taskId) { var cfg = PrivateServerConfigStore.Current.World.Content.RacingGates; if (!cfg.Enabled) { session.Log.Info("[RACEGATE] ABLATION racingGates.enabled=false — no gates placed"); return; } var table = LoadRacingGateTable(session, cfg); // ① 先清上一局的残留 —— 按 pathId 过滤,绝不动健身器材 / 摊位 / 补给 // (R234 教训:守卫拿 taskId 做「一局一次」键,两局同 taskId 会原地早退; // 所以这里不复用「已摆过」标记,而是**每局先清再摆**,天然幂等。) // ★ R252:清哪些 pathId **不能只写死那两个常量** —— 表里可以换成别的物件 // (本轮就把检查点门从 `Racing_challenge01_CheckPoint` 换成 `SelfRacing_PortalGuide`), // 写死常量会漏清 ⇒ 每局在原地叠一批、越跑越多。 // 所以 = {常量} ∪ {表里出现过的所有 pathId}。 try { var purgeIds = new List { RacingGateCheckPointPathId, RacingGateEndPathId, SelfRacingPortalGuidePathId, // ★ R253:R252 那轮摆的 16 个「指示牌」必须一起清 JobraceGongmenPathId, // ★ R253:本轮摆的拱门(自清,防换物件后残留) S6RacingCheckPointPathId, // ★ R273:R270 误用的 S6 门(自清,否则换回官方件后叠一层) }; foreach (var p in table) { if (!purgeIds.Contains(p.PathId)) purgeIds.Add(p.PathId); } var removed = 0; foreach (var pid in purgeIds) { var (_, _, n) = await RemoveGadgetsAsync(session, state, null, pid); removed += n; } if (removed > 0) { session.Log.Info($"[RACEGATE] cleared previous batch n={removed} " + $"ids=[{string.Join(",", purgeIds)}]"); } } catch (Exception ex) { session.Log.Warn($"[RACEGATE] clear previous batch failed: {ex.Message}"); } // ② 决定摆什么 // ★★★ R255:**默认走服务端内置的官方表**(26 个检查点,含终点)—— // 客户端那张采集表既丢 `checkpointIndex` 又做了不等距重采样,还被探针**每局重写**, // 坐标不能托付给它。`UseOfficialTrack=false` 才回落到「表 / 信标」的 R254 行为。 List plan; string source; if (cfg.UseOfficialTrack && OfficialTrack3Checkpoints.Length > 0) { plan = BuildOfficialTrackGates(cfg); source = "official-R255"; } else if (table.Count > 0) { plan = table; source = "table"; } else if (cfg.BeaconEnabled) { plan = BuildRacingGateBeacons(state, cfg); source = "beacon"; } else { if (RacingGateNoticed(session)) session.Log.Info($"[RACEGATE] no table at {cfg.TablePath} and beacons off — " + "nothing to place (run one lap so the client writes the checkpoint table)"); return; } // ⛔ R258:本块默认**不执行**(`pairMirror: false`)。理由见 `ExpandGatePairs` 的说明 —— // 「一个实例只是半扇、要摆两个」是 R254 起的**自造模型**,已被官方摆位数据推翻 // (官方 14 个静态检查点最近相距 131.69 m,不成对 ⇒ 一个检查点一个实例)。 // 用户明确要求「门宽由门自带宽,不要我们去调」⇒ 保留代码仅为应急,别默认打开。 if (cfg.PairMirror) { var before = plan.Count; plan = ExpandGatePairs(plan, cfg); session.Log.Info($"[RACEGATE] pair-mirror on: {before} -> {plan.Count} " + $"halfSpan={cfg.PairOffset:F1} (A=center-h, B=center+h)"); } session.Log.Info($"[RACEGATE] spawn track={trackId} task={taskId} source={source} " + $"n={plan.Count} channel={(GadgetSendChannel)cfg.Channel} iScale={cfg.IScale} " + $"logOnly={cfg.LogOnly}"); if (cfg.LogOnly) { foreach (var p in plan) session.Log.Info($"[RACEGATE] (dry) {p.Label} pathId={p.PathId} " + $"at ({p.X:F1},{p.Y:F1},{p.Z:F1}) yaw={p.Yaw:F1}"); return; } // ③ 摆 var placed = 0; foreach (var p in plan) { try { var result = await SpawnGadgetAsync( session, state, p.PathId, p.X, p.Y, p.Z, p.Yaw, (GadgetSendChannel)cfg.Channel, 0UL, cfg.IScale); if (result.Ok) { placed++; } else { session.Log.Warn($"[RACEGATE] place FAILED {p.Label}({p.PathId}) " + $"at ({p.X:F1},{p.Y:F1},{p.Z:F1}): {result.Message}"); } } catch (Exception ex) { session.Log.Warn($"[RACEGATE] place threw {p.Label}({p.PathId}): {ex.Message}"); } } // ★ R252:这里打印**实际用到的** pathId(去重),别再只打那两个常量 —— // 否则换表之后日志会骗人(「done 16/16 cpPathId=884611825」而实际摆的是 2017204172)。 var usedIds = new SortedSet(); foreach (var p in plan) usedIds.Add(p.PathId); session.Log.Info($"[RACEGATE] done {placed}/{plan.Count} source={source} " + $"pathIds=[{string.Join(",", usedIds)}]"); // ★ R311:记下「这批门是什么时候摆出去的」—— 撤门宽限期(见 `RemoveCheckpointGatesAsync`) // 要靠它算「客户端到底有没有时间把 prefab 加载出来」。 GateSpawnTick.Remove(session); GateSpawnTick.Add(session, [Environment.TickCount64]); } // ──────────────────────────────────────────────────────────────────────────────── // ★★★★★ R294(2026-10-02)—— **过点即撤门 / 结束清场** // // 用户原话:「检查点开过去不会消失,而且比赛结束了所有检查点还在」。 // // **真因**:`SpawnRacingGatesAsync` 一局只摆一次(26 点 × 2 半 = 52 个),摆完**从不回收**。 // 而官方的检查点标记是**跟着当前目标走的**:客户端 `CarRaceManager.lua` 只有一个 // `self.arrowGO`(`AddArrow` 加载、`RemoveArrow`/`Destroy` 销毁),过点后它被重建到下一个点, // 跑完连它一起销毁 ⇒ 玩家看到的是「过点即消失」。我们 spawn 的是**服务端 gadget**, // 客户端不知道它们的存在,所以永远不会自己消失。 // // **撤哪几个**:客户端每过一个检查点就上报一条 // `AskSetTaskCounterValue(task=60000147, index=k, value=1)`(见 `GameRouter.RacingVehicle`), // 而 **index 恰好等于我们的检查点下标** —— 客户端 `taskCounterList` 就是我们 Lua 喂的 // `R255_OFFICIAL_CP`(`racing_guard.lua:1077-1080, 1211`), // 过 `list[k+1]`(= 我们的 cp k)时报 `index = (k+1) - 1 = k`。 // // ⛔ **不能按 pathId 撤**:一局的 52 个门**同一个 pathId**(`Jobrace_gongmen`), // 按 pathId 撤会把还没跑到的一起抹掉。所以按**坐标就近**挑出那一对: // 官方 259 点折线上相邻检查点最近相距 **79 m**(cp00→cp01),而 A/B 只差 ±8 m, // ⇒ 20 m 半径既能捞出完整的 2 个,又绝不会误伤隔壁。 // ──────────────────────────────────────────────────────────────────────────────── /// 过点撤门时的坐标匹配半径(米)。见上方 R294 段:相邻检查点最小间距 79 m。 private const float RacingGateRemoveRadiusM = 20f; /// /// R311(2026-10-03)· 本会话**最近一次摆门**的时刻()。 /// 只服务「撤门宽限期」一件事;`ConditionalWeakTable` ⇒ 会话结束自动回收,不留引用。 /// private static readonly ConditionalWeakTable GateSpawnTick = new(); /// /// R312(2026-10-03)· 被「**前 N 个门延迟撤**」规则挂起的检查点下标(每会话一份)。 /// 用户原话:「前两个门设置为**到第三个门之后再消失**」。这里就是那个「挂起名单」—— /// `cp00`/`cp01` 上报通过时**不撤**,只登记;等 `cp02` 通过时连同 `cp02` 一起撤。 /// 元素类型用 `List<int>`(而非 `HashSet`)是为了日志里能按「通过的先后」原样打出来。 /// `ConditionalWeakTable` ⇒ 会话结束自动回收,不跨局/跨人泄漏。 /// private static readonly ConditionalWeakTable> DeferredGatePass = new(); /// R294:撤这一局可能摆过的**所有**赛车门 pathId。 internal static int[] RacingGatePathIdSet(RacingGateSettings cfg) { var set = new SortedSet { RacingGateCheckPointPathId, RacingGateEndPathId, SelfRacingPortalGuidePathId, JobraceGongmenPathId, S6RacingCheckPointPathId, }; if (cfg.PathIdOverride != 0) set.Add(cfg.PathIdOverride); return [.. set]; } /// /// R294:撤掉**第 个检查点**那对门(A/B 两半)。 /// 返回撤掉的个数(0 = 这个点本来就没摆 / 已经撤过)。 /// internal static async Task RemoveCheckpointGatesAsync( TcpSession session, WorldEntryState state, int cpIndex) { if (cpIndex < 0 || cpIndex >= OfficialTrack3Checkpoints.Length) return 0; // ★★★★★ R311(2026-10-03)· **撤门宽限期** —— 用户原话「**不是所有门都没有了啊,还是前两个**」。 // // `console-20261003-015034.log` 时序(一局 52 个门在 `01:53:50` 同一秒全摆完): // 摆门 +0 s → cp00 上报通过 +5 s → cp01 +9 s → cp02 +13 s → cp03 +16 s … // 客户端按 `BabyDriver_CheckPointRange=22`(m) 上报「已过点」,服务端**上报即撤** // ⇒ **前两个检查点的门在「客户端把 `Jobrace_gongmen` prefab 加载完并实例化」之前 // 就被 remove 抵消掉了** —— 这就是「前两个看不到、第三个起正常」的全部原因 // (第三个那对活过了首次加载的冷启动窗口)。 // // ⛔ 不在这里 `await Task.Delay`:本方法的调用者 `GameRouter.RacingVehicle` // 紧接着要 `await conn.ReturnEmptyOkAsync(msg)` 回执,拖十几秒客户端会判超时。 // ⇒ **丢给后台任务到点补撤**(`session.Active` 当守卫;`RemoveCheckpointGatesAsync` // 本身按坐标重算 id 且幂等 ⇒ 重复调用安全)。 var gateCfg = PrivateServerConfigStore.Current.World.Content.RacingGates; // ★★★★★ R312 · **用户指定规则**(2026-10-03 02:5x 原话): // 「把前两个门的消失规则改一下,前两个门设置为**到第三个门之后再消失**, // 第三个门后的所有门都维持现状」 // // 【为什么不再靠「猜 prefab 加载耗时」】R311 把宽限期从「上报即撤」抬到 12 s, // 用户实测仍是「没有任何变化」;而 `R311 kids` 诊断已证明门 GameObject // **确实在客户端里**(`findGate(this cp)=11`,cp1..cp5 全命中)。 // ⇒ 与其继续调常量,不如把前两个门的生存期**锚到玩家的真实进度**上: // 活到「第三个门被通过」为止 —— 这比任何秒数都稳。 // // 【实现】前 `gateKeepFirstNCount` 个检查点:**登记挂起、不撤**; // 走到 `cpIndex >= gateKeepFirstNCount`(= 第三个门)时,把**挂起的 + 当前的** // 一次性撤掉。`gateKeepFirstNCount = 0` ⇒ 本条完全关闭,回到 R311 纯宽限期行为。 // ⛔ 撤挂起时走 `...CoreAsync` **绕过本分支** —— 否则那两个点会被再次挂起 ⇒ 永远撤不掉。 // ⚠ 挂起表按 session 存(`ConditionalWeakTable`)⇒ 换局/换人自动隔离,不跨局泄漏。 if (gateCfg.GateKeepFirstNCount > 0) { var held = DeferredGatePass.GetOrCreateValue(session); if (cpIndex < gateCfg.GateKeepFirstNCount) { lock (held) { if (!held.Contains(cpIndex)) held.Add(cpIndex); } session.Log.Info($"[RACEGATE] cp{cpIndex:D2} pass HELD (R312: first " + $"{gateCfg.GateKeepFirstNCount} gates survive until cp" + $"{gateCfg.GateKeepFirstNCount:D2} is passed)"); return 0; } List release; lock (held) { release = [.. held]; held.Clear(); } if (release.Count > 0) { session.Log.Info($"[RACEGATE] cp{cpIndex:D2} reached -> releasing held gates " + $"[{string.Join(",", release)}] (R312)"); foreach (var h in release) { try { await RemoveCheckpointGatesCoreAsync(session, state, h, gateCfg); } catch (Exception ex) { session.Log.Warn($"[RACEGATE] cp{h:D2} held-release failed: {ex.Message}"); } } } } if (gateCfg.GateMinLifetimeMs > 0 && GateSpawnTick.TryGetValue(session, out var spawnBox)) { var gateAge = Environment.TickCount64 - spawnBox[0]; if (gateAge < gateCfg.GateMinLifetimeMs) { var waitMs = (int)(gateCfg.GateMinLifetimeMs - gateAge); var dsess = session; var dstate = state; var dIndex = cpIndex; session.Log.Info($"[RACEGATE] cp{cpIndex:D2} pass deferred {waitMs}ms " + $"(gate age {gateAge}ms < gateMinLifetimeMs={gateCfg.GateMinLifetimeMs}) " + "— 客户端可能还没把 Jobrace_gongmen 加载出来(R311 宽限期)"); _ = Task.Run(async () => { try { await Task.Delay(waitMs); // ⚠ `TcpSession` 没有 `Active` 成员(`Balloon.cs` 里的 `session.Active` // 是另一套运行时对象的字段)⇒ 这里用最廉价的连通性判据 + try/catch 兜底。 if (dsess.Client is null || !dsess.Client.Connected) { dsess.Log.Info($"[RACEGATE] cp{dIndex:D2} deferred remove skipped (session gone)"); return; } // ⛔ R312:这里**必须**走 `...CoreAsync` —— 走公共方法会重新命中 // 「前 N 个挂起」分支,把自己又挂回去(永不撤除)。 var n = await RemoveCheckpointGatesCoreAsync(dsess, dstate, dIndex, gateCfg); dsess.Log.Info($"[RACEGATE] cp{dIndex:D2} deferred remove done n={n}"); } catch (Exception ex) { dsess.Log.Warn($"[RACEGATE] cp{dIndex:D2} deferred remove failed: {ex.Message}"); } }); return 0; } } return await RemoveCheckpointGatesCoreAsync(session, state, cpIndex, gateCfg); } /// /// R312:**真正的按坐标撤门**(纯执行,不含任何策略/宽限期/挂起逻辑)。 /// 抽出来是为了让「R311 撤门宽限期」和「R312 前 N 个门延迟到第 N+1 个通过再撤」 /// 两条策略都能调它,而**不会互相重入** —— 否则 `cpIndex < keepFirstN` 的那两个点 /// 会在递归里被**再次挂起** ⇒ 永远撤不掉(自己给自己下套)。 /// private static async Task RemoveCheckpointGatesCoreAsync( TcpSession session, WorldEntryState state, int cpIndex, RacingGateSettings gateCfg) { var c = OfficialTrack3Checkpoints[cpIndex]; var keep = RacingGatePathIdSet(gateCfg); var r2 = RacingGateRemoveRadiusM * RacingGateRemoveRadiusM; List ids; lock (state.SyncRoot) { ids = state.SpawnedGadgets.Values .Where(r => Array.IndexOf(keep, r.PathId) >= 0) .Where(r => { var dx = r.X - c.X; var dz = r.Z - c.Z; return dx * dx + dz * dz <= r2; }) .Select(r => r.InstanceId) .ToList(); } if (ids.Count == 0) return 0; var (_, _, n) = await RemoveGadgetsAsync(session, state, null, 0, ids); session.Log.Info($"[RACEGATE] cp{cpIndex:D2} passed -> removed n={n} " + $"ids=[{string.Join(",", ids)}] (at {c.X:F1},{c.Z:F1})"); return n; } /// R294:撤掉这一局摆过的**全部**赛车门(比赛结束 / 完成上报时调)。 internal static async Task RemoveAllRacingGatesAsync(TcpSession session, WorldEntryState state) { var keep = RacingGatePathIdSet(PrivateServerConfigStore.Current.World.Content.RacingGates); var total = 0; foreach (var pid in keep) { var (_, _, n) = await RemoveGadgetsAsync(session, state, null, pid); total += n; } if (total > 0) session.Log.Info($"[RACEGATE] cleared ALL racing gates n={total} " + $"pathIds=[{string.Join(",", keep)}]"); return total; } /// /// 读客户端 R247 落下的检查点表。文件不存在 / 解析失败都只是「还没有坐标」, /// 退回信标模式,绝不抛。 /// private static List LoadRacingGateTable(TcpSession session, RacingGateSettings cfg) { var result = new List(); try { string path; if (Path.IsPathRooted(cfg.TablePath)) { path = cfg.TablePath; } else { var root = PrivateServerConfigStore.ResolveProjectPath("."); path = Path.Combine(root, cfg.TablePath); } if (!File.Exists(path)) { if (RacingGateNoticed(session)) session.Log.Info($"[RACEGATE] checkpoint table not found at {path}"); return result; } using var doc = JsonDocument.Parse(File.ReadAllText(path)); if (!doc.RootElement.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) { if (RacingGateNoticed(session)) session.Log.Warn($"[RACEGATE] {path} has no 'items' array"); return result; } foreach (var node in items.EnumerateArray()) { if (!node.TryGetProperty("pathId", out var pidNode) || pidNode.ValueKind != JsonValueKind.Number || !pidNode.TryGetInt32(out var pathId) || pathId == 0) continue; // ★★ R254:钉死 pathId —— 这张表是**客户端 R247 探针每局重写**的, // 写进去的是客户端那套 id(884611825 / 271608686,资源从未随包 ⇒ 隐形)。 // R253 手工改成 64493331 后,用户跑一局就被覆盖回去了 ⇒ 必须服务端强制。 if (cfg.PathIdOverride != 0) pathId = cfg.PathIdOverride; if (!node.TryGetProperty("pos", out var posNode) || posNode.ValueKind != JsonValueKind.Array || posNode.GetArrayLength() < 3) continue; var xyz = new float[3]; var i = 0; foreach (var c in posNode.EnumerateArray()) { if (i >= 3) break; xyz[i++] = c.ValueKind == JsonValueKind.Number ? c.GetSingle() : 0f; } var yaw = 0f; if (node.TryGetProperty("yaw", out var yawNode) && yawNode.ValueKind == JsonValueKind.Number) yaw = yawNode.GetSingle(); var label = node.TryGetProperty("name", out var nameNode) && nameNode.ValueKind == JsonValueKind.String ? nameNode.GetString() ?? "" : $"gate{result.Count + 1}"; result.Add(new RacingGatePlacement(pathId, xyz[0], xyz[1], xyz[2], yaw, label)); } if (result.Count > 0) session.Log.Info($"[RACEGATE] table loaded n={result.Count} from {path}"); else if (RacingGateNoticed(session)) session.Log.Info($"[RACEGATE] table at {path} parsed to 0 usable rows"); } catch (Exception ex) { if (RacingGateNoticed(session)) session.Log.Warn($"[RACEGATE] table read failed: {ex.Message}"); } return result; } /// /// 「验证信标」:在玩家四周各摆一道门。用途只有一个 —— /// 把「通道对不对」与「坐标对不对」解耦。看得见 ⇒ 通道没问题,只差那张坐标表。 /// private static List BuildRacingGateBeacons( WorldEntryState state, RacingGateSettings cfg) { var list = new List(); float px, py, pz; lock (state.SyncRoot) { var p = state.LastReportedPlayerPosition; px = p.X; py = p.Y; pz = p.Z; } var ahead = cfg.BeaconAhead <= 0f ? 40f : cfg.BeaconAhead; (float Dx, float Dz, string Tag)[] dirs = [ (1f, 0f, "E"), (-1f, 0f, "W"), (0f, 1f, "N"), (0f, -1f, "S"), ]; foreach (var d in dirs) { list.Add(new RacingGatePlacement( SelfRacingPortalGuidePathId, px + d.Dx * ahead, py, pz + d.Dz * ahead, 0f, "beacon-" + d.Tag)); } return list; } }