diff --git a/docs/CLIENT-GAP-FIX.md b/docs/CLIENT-GAP-FIX.md index caec8135..86949f56 100644 --- a/docs/CLIENT-GAP-FIX.md +++ b/docs/CLIENT-GAP-FIX.md @@ -12,11 +12,11 @@ > 路径约定:`REF/` = `/Users/shenlei/Work/mt/40250/ClientVS22/source/`, > `POC/` = `/Users/shenlei/Work/mt/mtgodot-poc/`。 > -> 创建 / 修订日期:2026-09-03 +> 创建 / 修订日期:2026-09-04 --- -## 实施进度(2026-09-03) +## 实施进度(2026-09-04) 当前已完成 G0 公共接口的首版冻结,并已交付 W0/W1/W2 的“连接与阶段骨架”及 Warp / 换图清理 第一批;并行波 A 已有多项实现合入。当前仍有真实链路、平台和 1:1 表现验收项,不能把“代码回归 @@ -24,6 +24,31 @@ 已完成并已编译验证: +- [x] **增量 128 —— W3→M2 §8.8 seam ⑦ 公会徽记尾标接入 + §5.1 `[DONE]` 点击语义修正**: + - `net_world.gd` 消费 `guild_marks_ready` / `guild_mark_updated`,为已经生成的角色动态创建、 + 更新和移除 `GuildMark` `Sprite3D`,并按 `mark_x_offset` / `guild_name_x_offset` 与公会名排布; + 会徽会跟随聊天气泡造成的名字尾标上移。`guild_mark_tail_test.gd` 覆盖延迟下载、纹理替换、 + 水平排布和公会移除。 + - `quest_dialog.gd` 保持 `[DONE]` 解析无副作用,`done_event` 改为只在用户点击关闭按钮时触发; + `quest_test.gd` 覆盖解析阶段不触发、点击后触发和 `script_answer(255)`。 + - CTest 22/22、Godot 74/74 离线回归通过;macOS headless 证书读取噪声不计为代码失败,真实 + 服务器、真机和正式资源包仍需外部验收。 + +- [x] **增量 127 —— W3→M2 §8.8 / §5.4 / §2.1 四项本地缺口收口**: + - `net_world.gd` 移除旧 `Sprite3D HpBar`,由 `CanvasLayer + Control` 的 `ScreenHpBar` 按 + `Camera3D.unproject_position()` 每帧投影;无最大血量、死亡、镜头后方、屏幕外和换图时隐藏 / 清理。 + `screen_hp_bar.gd` / `screen_hp_bar_test.gd` 覆盖血量同步与投影边界。 + - `entity_rules.gd` / `net_play.gd` 增加 `KIND_BUILDING=5`、`KIND_WOODEN_DOOR=6`;支持显式 + `kind`、`building`、`mob_proto bType=4`,以及 13000 / 30111..30119 木门 race。目标框和 + 攻击分类不再把建筑 / 木门误当 PC;`entity_rules_test.gd` / `netplay_test.gd` 覆盖。 + - `ground_items.gd` 的掉落名 / owner 行按显式颜色、`item_grade` / `quality` / `rarity` 等 + 品阶字段取色,owner 行继承物品颜色,不再固定纯黄色;`p2b_test.gd` 覆盖显式品阶色。 + - `chat_ui.gd` 新增独立 `WhisperDialog`,LCONTROL 点同阵营其他 PC 后可发送、取消或 ESC 关闭, + 不污染公共聊天输入;`game_scene.gd` / `chat_test.gd` 完成接线与回归。 + - `eterngrn_polish_test.gd` 的 Audio stub 补齐 MilesLib 接口,`p10_test.gd` 改用受限环境可写的 + `/tmp` fixture;最终 C++/CTest 21/21、Godot 脚本 71/71 通过。Godot macOS headless 的 + 系统证书读取噪声和受限 TCP bind skip 不计作代码失败;真实服务器、真机和正式资源包仍需外部验收。 + - [x] `GC_LOGIN_SUCCESS3/4` 的角色槽保存 `lAddr/wPort`;`M2Client.enter_game(index)` 已接入 Classic DirectEnter:断开当前游戏连接,按角色槽 IP/端口重连,并在新连接的 SELECT 阶段自动发送 `CG_CHARACTER_SELECT`。 @@ -3566,6 +3591,168 @@ WARP / WAYPOINT / EXTRA(=FUNC+10) / NUM(=EXTRA+10)`。 `_always_show_name`(uigameoption radio)/ `_alt_held`(LALT)/ `_target_vid` (`net_play.target_changed`,即 `targetBoard.GetTargetVID()`)/ chat `bNameFlag` 四路。 **目标框已让选中目标强显名字**;可攻击光标仍 **未接**;悬停 `chr.Pick()` 强显 = seam ⑩。 + **增量 114 完成头顶伤害飘字**:`project/damage_effect.gd` 照抄 `CInstanceBase::AddDamageEffect` + (`InstanceBaseEffect.cpp:87`,`IsShowDamage()` 门 + `m_DamageQueue`)+ `ProcessDamage` + (:100,每帧 `pop_front` 一条 + `DODGE|BLOCK → MISS` / `self "damage_"` 交替 / `nontarget_` 不渲染 + / `target "target_"` 分类 + 数字逐位低位在前)+ `CPythonSystem::IsShowDamage` / `SetShowDamageFlag` + (`PythonSystem.cpp:337/:342`);`net_world._on_damage` 改为按此入队、`_process_damage_queue()` + 每帧 pop 一条渲染 billboard `Label3D`,`bSelf` 用 `_main_vid`、`bTarget` 用 `_target_vid`; + `game_option_ui` 的「伤害数字」radio 经 `display_option_changed` → `net_world.set_show_damage`。 + seam ⑪:单块 `Label3D` 代替 `damagevalue/.dds` 逐位特效 / 相机右向像素铺排、 + 暴击无独立视觉(对齐该 build 参考端)、全局单队列每帧一条 vs 参考每实例一队列。 + **增量 115 完成目标框收包分派**:`project/target_board.gd` 照抄 `CPythonNetworkStream::RecvTargetPacket` + (`PythonNetworkStreamPhaseGame.cpp:2424`)的判定树 + `CInstanceBase::CanViewTargetHP` + (`InstanceBase.cpp:2293`= `IsStone() || IsWoodenDoor() || IsEnemy()`)+ `game.py` 的 + `SetHPTargetBoard`(:829,换 VID 先 `ResetTargetBoard` + `SetEnemyVID`)/ `CloseTargetBoardIfDifferent` + (:837)/ `CloseTargetBoard`(:841)/ `SetPCTargetBoard` 的 `LCONTROL` 悄悄话捷径(:804); + `classify(target_exists, is_dead, is_pc, is_building, can_view_hp)` → `Action { CLOSE, + CLOSE_IF_DIFFERENT, SET_HP, NONE }`(实例缺失 → CLOSE、已死 → NONE 不动框、PC|Building → + CLOSE_IF_DIFFERENT、可看血 → SET_HP、否则 CLOSE)。`net_play._on_target_info(vid, hp%)` 由 + 「任意有名字 VID 都 `hud.set_target`」改为按 `classify` 分派:`is_pc = _entity_kind == KIND_PC`、 + `can_view = KIND_STONE 或 KIND_MONSTER`、SET_HP 时换 VID 先 `_target_vid = vid` + + `target_changed.emit` 再 `hud.set_target(名字, clamp_hp_pct(hp%))`。seam ⑫:poc 无 building / + wooden-door / stone-door 实体类型(`is_building` 恒 false、木门分类缺);`target_info` 由 C++ + `m2_client.cpp:3129/3633` 从 EntityStore 抽出,不改 C++ 收包;~~`LCONTROL` 悄悄话捷径谓词 + `whisper_on_ctrl_click` 已备、`net_play` 暂不接(无 whisper dialog 钩子)~~ 增量 122 已接线 + (`net_play._on_pick` KIND_PC 分支轮询 `whisper_on_ctrl_click` → `whisper_requested` → + `game_scene` → `chat.start_whisper`);seam ⑫ 余留:`is_building` 恒 false(门实体缺)、 + poc 悄悄话 = 聊天输入预填非独立 whisper dialog 窗口。 + **增量 116 完成 `OnRender` Picking 块分派 + 悬停角色强显名字**:`project/pick_show.gd` 照抄 + `game.py GameWindow.OnRender()` 的「Picking」块(:1519)—— `wndMgr.IsPickedWindow` 门(:1533)→ + `chr.Pick()` 命中角色 `ShowCharacterTextTail`(:1535/:1537)、`targetBoard.GetTargetVID()` + `ShowCharacterTextTail`(:1539)、`__IsShowName()` 分叉(非总显名字 → `item.Pick()` :1543、 + 总显名字 → `ShowAllTextTail` + `textTail.Pick()` :1552/:1554)、末尾 `SelectItemName`(:1560); + `forced_name_vids(is_picked_window, pick_char_vid, target_vid)` 收集 `[hover, target]` 去重。 + poc 侧仿增量 113 的 `_target_vid` 路数给 `net_world._name_shown()` 加第 5 路 forced `_hover_vid`: + `net_world.set_hover_vid(vid)`(值变才刷 prev/vid 两节点可见性)由 `player_controller` + `_update_hover_cursor()` 的射线悬停拾取喂入(`hover_entity_changed` 信号,压 UI / 悬停地面物品 + → emit 0),`game_scene` 连 `pc.hover_entity_changed → net_world.set_hover_vid`。seam ⑨:名字 + 尾标 y = chat 气泡 y − 17px(`PythonTextTail.cpp:112-114`)未接;seam ⑩:`ArrangeTextTail` + 屏幕空间去重叠排布仍缺(本增量只收口「悬停强显名字」一小块);`_hover_vid` 当无条件 forced, + 靠 `player_controller` 压 UI / 悬停物品时置 0 部分等效 `IsPickedWindow` 门。 + **增量 117 收口 seam ⑨(名字尾标随聊天气泡上移 17px)**:`project/name_tail_layout.gd` 照抄 + `CPythonTextTail::UpdateShowingTextTail`(`PythonTextTail.cpp:88`)—— 角色名字尾标投影完屏幕 + 坐标后(:104),`m_ChatTailMap.find(dwVirtualID)` 命中(:106/:110)且那条 `bNameFlag` TRUE + (:112,= `RegisterChatTail` 而非 `RegisterInfoTail`)→ `pTextTail->y = pChatTail->y - 17.0f` + (:114)。`name_follows_bubble(has_chat_tail, chat_name_flag)` / `name_tail_screen_y`(参考端 + 字面 `bubble_y − 17`)/ `name_tail_world_y`(poc 3D:屏幕 y 向下 −17px → 世界 y 向上 + `bubble_world_y + 17*pixel_size`)。poc 侧 `net_world._reposition_name_tag(n)` 由 + `_apply_chat_tail` / `_expire_chat_tails` 调用,冒「聊天」气泡时名字 `Label3D` 局部 y 顶到 + 「`BUBBLE_TAG_Y` 2.35 + 17*pixel_size」、气泡消失或只是「信息」尾标(`bNameFlag` FALSE)时 + 回 `NAME_TAG_DEFAULT_Y` 2.1。 + **增量 119 收口 seam ⑦(子标签随名字尾标纵向位移)**:`CPythonTextTail::ArrangeTextTail` + (`PythonTextTail.cpp:151`)里逐条角色名字尾标(:211)的公会名(:228,`pTextTail->y - + c_fyGuildNamePosition` 15px 上方)/ 称号(:258,同 `pTextTail->y`)/ 等级(:283,同 y) + 三个子标签屏幕 y 全部锚在 `pTextTail->y` 上 —— 而这个 y 已被 `UpdateShowingTextTail :114` + 顶过 −17。`name_tail_layout.gd` 增 `name_tail_lift_delta`(名字尾标相对默认 y 的净位移, + follows 时非 0)/ `subtag_world_y`(`default_subtag_y + lift_delta`);`net_world` + 把 `GuildTag`/`TitleTag`/`LevelTag` 默认局部 y 提为 `SUBTAG_DEFAULT_Y` 常量 + (2.34 / 1.86 / 1.74),`_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移三个子标签, + 气泡过期 / 「信息」尾标时回默认。剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版 + (公会名上方 15px、称号 / 等级同水平行左偏)仍近似。剩余 seam ⑨:气泡与名字仍是两个 + 并存 `Label3D`(非参考单尾标链表)。 + **增量 120 收口 seam ⑩(item 尾标去重叠 + `isIn`)**:`CPythonTextTail::ArrangeTextTail` + (`PythonTextTail.cpp:153`)的 `m_ItemTextTailList` 块(:160-190)—— 逐条掉落名条与其它 + 每条比 `isIn`(:489-507,标准 AABB 含边界),命中就把「自己」顶到对方框底下 + `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(`yTemp = 5`),内层从头重跑、 + `LimitCount >= 20` 封顶。新增第 29 个 canonical `project/text_tail_arrange.gd`: + `boxes_overlap`(`isIn`)/ `box_*` 四边(`-w/2-2` / `w/2+2` / `-2` / `h`,`PythonTextTail.cpp:810-813`) + / `arrange_item_tails(tails) -> Array`(外层逐条 / 内层比对 / 命中顶到框底下 + `PUSH_GAP` 5 / + 从头重跑 / `MAX_ITERS` 20 封顶)。poc 侧 `project/ui/ground_items.gd._arrange_tags` 每帧把所有 + 掉落名条世界锚点 `cam.unproject_position` 到屏幕、按 `文本字符数 * 7px` 估宽、单行 15px 估高 + 组框,跑 `arrange_item_tails`,把被下推的屏幕 Δy 按 `Label3D.pixel_size` 折回世界局部 y + (`TAG_BASE_Y − Δpx*pixel_size`),可见名条 < 2 直接回默认;`game_scene` 把相机 getter 传进 + `ground_items.setup`。剩余 seam ⑩:~~名条框按字符数估宽 vs 参考 `GetTextSize` 精确像素~~ + (增量 123 已换真实字体度量 `TextMetrics.label_width`);~~单行高仍按 `TAG_LINE_H_PX` 15px 估~~ + (增量 124 已换 `TextMetrics.label_height` = `GetTextSize` 的 h,见下); + 只对掉落 item 名条做(与参考 `m_ItemTextTailList` / `m_CharacterTextTailList` 分表一致)。 + **增量 121 深化 seam ⑦(子标签逐像素水平排版)**:`CPythonTextTail::ArrangeTextTail` 的 + `m_CharacterTextTailList` 块(`PythonTextTail.cpp:211-320`)非 EUROPE 分支 —— `fxAdd` 三态 + (:236 `0` / :242 `8`(有称号)/ :288 `4`(只有等级)),称号 `x - iNameWidth/2 - fxAdd` + (:258,居中对齐)、等级有称号时再 `- iTitleWidth`(:282,右对齐)否则 `x - iNameWidth/2 - 4` + (:312)、名字本身 `x + fxAdd`(:320);公会名的水平锚点参考端仅在「有公会徽记 `pMarkInstance`」 + 时(:220)才改写成 `x + markW/2 + 1.5`(:228)。`name_tail_layout.gd`(第 28 个 canonical)加 + `name_x_shift` / `title_x_offset` / `level_x_offset` / `guild_name_x_offset` / `mark_x_offset` / + `subtag_world_x`(屏幕像素 → billboard 本地 x,按 `Label3D.pixel_size` 折世界)。`net_world` + 新增 `_layout_subtags_x(n)`(在 `_apply_text_tail_to` 尾部):名字 / `TitleTag` / `LevelTag` 各设 + `.position.x`,`GuildTag.position.x = 0`(poc 无徽记 → 参考端 :220 此路不改写);文本宽按 + `字符数 * 7px` 估(`SUBTAG_CHAR_W_PX`;增量 123 已换真实字体度量)。与增量 117/119 的纵向 + 位移解耦(前者写 `.position.y`、本增量写 `.position.x`)。剩余 seam ⑦:无公会徽记 mark 实体、 + ~~字符数估宽 vs `GetTextSize`~~(增量 123 收口)、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。 + **增量 123 收口 seam ⑦ / ⑩(文本宽换真实字体度量 `GetTextSize`)**:`ArrangeTextTail` 里 + `iNameWidth` / `iTitleWidth` / `iLevelWidth`(`PythonTextTail.cpp:225/233/240/267/297`)与掉落 + 名条框 `xSize`(:733/:748/:809)参考端全部来自 `CGraphicTextInstance::GetTextSize(&w, &h)`。 + 新增第 30 个 canonical `project/text_metrics.gd`:`text_size(font, text, font_size)` = + `Font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`(空 / null / 非法字号 → + `Vector2.ZERO`)、`width_of(font, text, font_size, fallback_char_px=7.0)`(量得正宽用精确像素, + 否则回退 `len * fallback_char_px`)、`label_width(label: Label3D, fallback_char_px=7.0)` + (`label.font ?? ThemeDB.fallback_font` + `label.font_size`,`null` → 0)。`net_world._layout_subtags_x` + 的 `name_w` / `title_w`、`ground_items._arrange_tags` 的框宽 `w` 由 `float(text.length()) * *_CHAR_W_PX` + 换成 `TextMetrics.label_width(label, *_CHAR_W_PX)`;`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX = 7.0` + 常量保留作无字体(headless / 未配主题)兜底。`name_tail_layout.gd` / `text_tail_arrange.gd` + 收宽度作入参、排布公式(增量 119/120/121)不动。测试 `text_metrics_test.gd`,回归 70 全 PASS。 + 剩余 seam ⑦:无公会徽记 mark 实体、三子标签竖排 + x 偏移的折衷;剩余 seam ⑩:名条单行高 + 仍按 15px 估、只对掉落 item 名条做。 + **增量 124 收口 seam ⑦ / ⑩ 末尾(单行文本高换真实字体度量 `GetTextSize` 的 h)**:去重叠 + 用的 item 尾标框 `yEnd = ySize`(`PythonTextTail.cpp:751/:812`),`ySize` 与 `xSize` 同样来自 + `CGraphicTextInstance::GetTextSize(&xSize, &ySize)`(:749/:809)—— 单行文本的 `ySize` ≈ 字体 + 行高(ascent + descent),与字形无关,`Font.get_string_size(...).y` 是 1:1 对应物。`text_metrics.gd` + 增 `const FALLBACK_LINE_H_PX := 15.0`(=旧 `ground_items.TAG_LINE_H_PX`)、`height_of(font, + text, font_size, fallback_line_px=15.0)`(空串 → 0;量得正高用精确像素;否则回退)、 + `label_height(label: Label3D, fallback_line_px=15.0)`(`label.font ?? ThemeDB.fallback_font` + + `label.font_size`,`null` → 0)。`ground_items._arrange_tags` 的框高 `h` 由硬编码 `TAG_LINE_H_PX` + 换成 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`;`TAG_LINE_H_PX = 15.0` 常量保留 + 作无字体兜底。`text_tail_arrange.gd`(`box_end_y(h) = h`)/ `name_tail_layout.gd` 收框高作入参、 + 排布公式不动。测试扩 `text_metrics_test.gd`「5. height_of / label_height」段,回归 70 全 PASS。 + 剩余 seam ⑦:无公会徽记 mark 实体、三子标签竖排 + x 偏移的折衷;~~剩余 seam ⑩:掉落名条 + owner 未拆成独立 text instance(参考端 `yEnd += yOwnerSize + 4`,:733-736,poc 仍按单行 + `"name (owner)"` 处理)~~ 增量 125 已把去重叠框度量 1:1、只对掉落 item 名条做。 + **增量 125 收口 seam ⑩ 末尾(owner 后缀框度量:owner 独立度量 + `yEnd += yOwnerSize + 4`)**: + 参考 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)—— 带 owner 的 + 掉落名条,owner 后缀挂成独立 `CGraphicTextInstance`(文本 = `c_szName + strOwnership`, + `IDS_POSSESSIVE_MORPHENE` 为空取字面 `"'s"`;黄 1,1,0 / CENTER),与名字实例分开 + `GetTextSize(&xOwnerSize, &yOwnerSize)`,据此 `yEnd += float(yOwnerSize + 4)`(:735)、 + `xStart = fMIN(xStart, -xOwnerSize/2-1)`(:736)、`xEnd = fMAX(xEnd, xOwnerSize/2+1)`(:737); + owner 为空则删 owner 实例、框复位纯名字(:739-751)。`text_tail_arrange.gd`(扩第 29 个 + canonical)加纯静态 `item_box_with_owner(name_w, name_h, owner_w:=0.0, owner_h:=0.0)`: + `owner_w<=0 或 owner_h<=0` → `{name_w, name_h}`;否则 → `{maxf(name_w, owner_w-2.0), + name_h+owner_h+4.0}`(令 `box_end_x(w)=w/2+2` 恰等于 :737 的 `fMAX(name/2+2, owner/2+1)`, + 框 x 两边对称故 `box_start_x` 自动 = :736 `fMIN`)。`text_metrics.gd`(扩第 30 个 canonical) + 加 `string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串;`null` → + 字符数 * `fallback_char_px` / `fallback_line_px`,空串 → 0)。`ground_items._on_added` 存 + `item_name` / `item_owner` meta,`_arrange_tags` 把「名字子串」「owner + `'s` 子串」分别 + 度量后走 `item_box_with_owner` 合成去重叠框;渲染仍是单个合并 `"name (owner)"` billboard + `Label3D`(seam ⑩ 记录在案的折中)。测试扩 `text_tail_arrange_test.gd`「5. item_box_with_owner」 + 段 / `text_metrics_test.gd`「6. string_*_like」段 / `p2b_test.gd`(`FakeClient.add_ground` + 加可选 owner 入参 + 带 owner 掉落被顶 `(15+15+4+5)*0.005` 段),回归 70 全 PASS(无新增 + 测试文件)。~~剩余 seam ⑩:owner 未真正独立渲染成 CENTER 黄字、`strOwnership` 未接 + `ApplicationStringTable` 的本地化语素~~ 增量 126 已收口(见下),只对掉落 item 名条做。 + **增量 126 收口 seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + `strOwnership` + 接本地化语素)**:参考 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:712-745`) + + `ArrangeTextTail`(:191-207)—— owner 非空时新建 `pOwnerTextInstance`(:715)、文本 + = `c_szName + strOwnership`(:723;`strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE) + == "" ? "'s" : ...`,:722)、`SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER)`(:727)、 + `SetColor(1.0f, 1.0f, 0.0f)` 黄(:729);排布 `pOwnerTextInstance->SetPosition(x, y, z)`(:195) + 而名字实例 `pTextInstance->SetPosition(x, y + 15.0f, z)`(:200);owner 为空时 + `CGraphicTextInstance::Delete(pOwnerTextInstance)`(:743)。`ground_items`:drop 节点下加第二个 + `Label3D "owner_tag"`(`horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER` / `modulate = + Color(1,1,0)` / billboard / `no_depth_test` / `pixel_size 0.005`,文本 = `owner + _possessive`), + `_apply_owner_tag(node, owner)` 建 / `remove_child + queue_free` 删;名字 `tag` 只放 + `_name_for(vnum)`(`_tag_text` 随之删除,不再拼 `"name (owner)"`)。新增 + `const OWNER_NAME_GAP_PX := 15.0` + `_place_tail(n, tag, d_px)`:有 `owner_tag` 时 + `owner_tag.position.y = TAG_BASE_Y - d_px*px`、`tag.position.y = TAG_BASE_Y - (d_px + 15)*px`; + 无则 `tag.position.y = TAG_BASE_Y - d_px*px`(`_arrange_tags` 的三处落点——behind-camera / + 单条 / 多条排布——都改走它)。`setup()` 加可选 `possessive := ""`:`_possessive = possessive + if possessive != "" else OWNER_POSSESSIVE`(1:1 对齐 :722 的 `== "" ? "'s" :` 语义), + `game_scene` 用 `EterLocale` 的 `POSSESSIVE_MORPHENE` 键解析后注入(poc 的 `locale/*.txt` + 无此键 → 回落 `"'s"`,与 40250 西文 `UserInterface.rc` 的 `IDS_POSSESSIVE_MORPHENE "'s"` + 一致)。去重叠框度量仍是增量 125 的 `item_box_with_owner`(`yEnd += ownerH + 4`)不变。 + 测试扩 `p2b_test.gd`(drop_302 名字实例文本 `"掉落28"`、`owner_tag` 文本 `"Alice's"` / + `HORIZONTAL_ALIGNMENT_CENTER` / `Color(1,1,0)` / 落基点,名字实例下移 `15 * pixel_size`; + 无 owner 的 drop_301 不建 `owner_tag`;drop_401 用 `setup(possessive="@")` → `owner_tag` + 文本 `"Bob@"`),回归 70 全 PASS(无新增测试文件)。剩余 seam ⑩:owner 行颜色语义固定黄 + (参考名字实例取 `pInsertTextTail->Color` 的道具品阶色、poc 名字走 `_process` 远近着色)、 + 去重叠只对掉落 item 名条做。 4. 保护区:`IsInSafe()` 已在 §3.4 的预约流程里用到,数据来自地形属性 `ATTRIBUTE_BANPK`**[待读参考:`REF/GameLib/TerrainImpl` 的 `ATTRIBUTE_*` 枚举]** —— **未接**。 @@ -4139,8 +4326,16 @@ SpeedTree 森林级风常量。`formats_map_test`(内联 + 真实 A1)、`env `UpdateAllTextTail` 的 chat 尾标 `bNameFlag` 强显)。`game_option_ui.gd` 的 `always_show_name` radio 现经新信号 `display_option_changed` 驱动 `net_world.set_always_show_name()`;`net_play.target_changed` 驱动 - `net_world.set_target_vid()`(选中目标始终强显名字)。名字色 / 伤害数字两个 radio 仍未接 - (下一批次候选)。见 C.5 增量 113、§8.6、§8.8。 + `net_world.set_target_vid()`(选中目标始终强显名字)。 + **增量 114 已接「伤害数字」radio** → `net_world.set_show_damage()`(见 §8.8)。 + **增量 118 已接「名字色」radio(`name_color_normal` / `name_color_empire`)** → + `game_scene._on_display_option("name_color", v)` → `net_world.set_empire_name_mode(v == 1)` + (`uigameoption.__SetNameColorMode` → `constInfo.SET_CHRNAME_COLOR_INDEX` → + `chrmgr.SetEmpireNameMode`;算法本体增量 110 已 1:1),开局用 + `display_value("name_color") == 1` 回填。 + **增量 118 同时接「看他国玩家目标框」radio(`target_board_view` / `target_board_no_view`)** → + `net_play.set_view_other_empire_target(v == 1)`;见 §5.4 / §8.8。§8.4 三个显示 radio + + LALT 至此全部接线完毕。见 C.5 增量 113 / 114 / 118、§8.6、§8.8。 4. **雾等级**:现有 `[0.055/0.018/0.004]` 是本工程标定值,参考端是 `TEnvironmentData` 的 `m_fFogNearDistance/m_fFogFarDistance/bDensityFog`(§7.5)—— 改为按环境数据的近远距离设置,浓/中/淡对应三档缩放 @@ -4341,6 +4536,216 @@ OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWn `PythonSystem` / `PythonTextTail` 全部逻辑 —— 悬停 `chr.Pick()` 强显 / `ArrangeTextTail` 屏幕空间防重叠 / `CanPickInstance()` 完整语义 = seam ⑩(net_world 每实体一块 billboard `Label3D` 逐帧 set `.visible` 的近似)。见 C.5 增量 113。 +- **增量 114(2026-09-03)**:头顶伤害飘字 1:1(§8.8 / §5.4)。 + `CInstanceBase::AddDamageEffect`(`InstanceBaseEffect.cpp:87`,`if (CPythonSystem::IsShowDamage())` + 才 `m_DamageQueue.push_back({bSelf, bTarget, damage, flag})`)、`ProcessDamage`(:100, + `InstanceBase.cpp:1910` 每帧调,`m_DamageQueue` 每帧 `pop_front` 一条:`(flag & DODGE) || + (flag & BLOCK)` → `bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS` + `return`;`CRITICAL` + / `POISON` 分支参考端 `CreateEffect` 均注释掉 → 落普通数字;`bSelf` → `"damage_"` + + `EFFECT_DAMAGE_SELFDAMAGE` / `SELFDAMAGE2` 按 `m_bDamageEffectType` 交替;`bTarget == false` + → `"nontarget_"` + `return`(不渲染);否则 `"target_"`;`while (damage > 0)` 逐位 + `damage % 10` / `damage /= 10`,`index 0..7`(`> 7` `TraceError` break),每位一张 + `damagevalue/.dds` 特效按 `FONT_WIDTH(30) * index` 相机右向铺开)、 + `TPacketGCDamageInfo`(`Packet.h:1943`:`header / dwVID / flag / int damage`)经 + `RecvDamageInfoPacket`(`PythonNetworkStreamPhaseGame.cpp:2401`,`bSelf = 目标是主实例`、 + `bTarget = 目标是 m_pInstTarget`、`damage >= 0` 才 `AddDamageEffect`)、`CPythonSystem::IsShowDamage` + / `SetShowDamageFlag`(`PythonSystem.cpp:337/:342`,`bShowDamage = iFlag == 1`,默认 `true`, + 配置行 `SHOW_DAMAGE` = `atoi == 1`)全部迁到 `project/damage_effect.gd`(纯静态)。40250 `source/` + 自带 `AddDamageEffect` / `ProcessDamage` / `RecvDamageInfoPacket` / `PythonSystem` —— `damagevalue` + dds 逐位特效 / 相机右向像素铺排 / self `SELFDAMAGE`⇄`SELFDAMAGE2` 双资源 / 每实例独立队列 + = seam ⑪(net_world 用一块 billboard `Label3D` 写 `str(amount)`、self 退化为水平抖动符号交替、 + 暴击不再放大 / 变色(对齐该 build)、全局单队列每帧一条)。见 C.5 增量 114。 +- **增量 115(2026-09-03)**:目标框 `RecvTargetPacket` 收包分派 1:1(§5.4 / §8.8)。 + `CPythonNetworkStream::RecvTargetPacket`(`PythonNetworkStreamPhaseGame.cpp:2424`)的判定树: + `pInstPlayer && pInstTarget` 都在且 `!IsDead()` 时 —— `IsPC() || IsBuilding()` → + `CloseTargetBoardIfDifferent(dwVID)`(:2440)、否则 `CanViewTargetHP(*pInstTarget)` → + `SetHPTargetBoard(dwVID, bHPPercent)`(:2442)、否则 `CloseTargetBoard()`(:2444); + `m_pInstTarget = pInstTarget` 仅在该 `!IsDead()` 块内(:2447);实例缺失 → `CloseTargetBoard()` + (:2453)、目标已死透 → 整块跳过(不动框)。`CInstanceBase::CanViewTargetHP` + (`InstanceBase.cpp:2293`)= `IsStone() || IsWoodenDoor() || IsEnemy()`。`TPacketGCTarget` + (`Packet.h:1937`:`header / dwVID / bHPPercent`)。`game.py` 侧 `SetHPTargetBoard`(:829, + `vid != GetTargetVID()` → `ResetTargetBoard()` + `SetEnemyVID(vid)`,再 `SetHP` + `Show`)、 + `CloseTargetBoardIfDifferent`(:837,`vid != GetTargetVID()` 才 `Close()`)、`CloseTargetBoard` + (:841)、`SetPCTargetBoard`(:804,按住 `LCONTROL` 且 `IsSameEmpire` 且非本人且非 building → + `OpenWhisperDialog`)全部迁到 `project/target_board.gd`(纯静态)。40250 `source/` 自带 + `RecvTargetPacket` / `CanViewTargetHP` —— poc 无 building / wooden-door / stone-door 实体类型 + (`is_building` 恒 false、`can_view` 的 `is_wooden_door` 恒 false)、`LCONTROL` 悄悄话捷径未接 + = seam ⑫。见 C.5 增量 115。 +- **增量 116(2026-09-03)**:`OnRender` Picking 块分派 + 悬停角色强显名字 1:1(§8.8 / §5.4)。 + `game.py GameWindow.OnRender()` 的「Picking」块(:1519):`wndMgr.IsPickedWindow(self.hWnd)` 门 + (:1533)内 —— `chr.Pick()` 命中角色(!= -1)→ `textTail.ShowCharacterTextTail(idx)`(:1535/:1537)、 + `targetBoard.GetTargetVID()` != 0 → `ShowCharacterTextTail(targetVID)`(:1539)、`not __IsShowName()` + 时 `item.Pick()` → `ShowItemTextTail`(:1543/:1546);门外 `__IsShowName()` 时 `ShowAllTextTail()` + + `PickingItemIndex = textTail.Pick(x, y)`(:1552/:1554);末尾 `UpdateShowingTextTail` + + `ArrangeTextTail`(:1557/:1558)、`PickingItemIndex != -1` → `SelectItemName`(:1560)。全部迁到 + `project/pick_show.gd`(纯静态第 27 个 canonical):`NO_PICK := -1`、`char_pick_active`、 + `forced_name_vids(is_picked_window, pick_char_vid, target_vid)`(收 `[hover, target]` 去重)、 + `should_pick_item` / `should_show_all_names` / `should_pick_text_tail` / `should_select_item_name`。 + poc 侧接:`net_world._hover_vid` + `set_hover_vid` + `_name_shown()` forced 第 5 路, + `player_controller.hover_entity_changed` 信号(`_update_hover_cursor()` 射线拾取,压 UI / 悬停 + 地面物品 → 0),`game_scene` 连线。40250 `source/` 自带 `OnRender` —— 名字尾标 y = chat 气泡 + y − 17px(`PythonTextTail.cpp:112-114`)未接 = seam ⑨;`ArrangeTextTail` 屏幕空间去重叠排布 + 仍缺 = seam ⑩(本增量只收口「悬停强显名字」一小块)。见 C.5 增量 116。 +- **增量 117(2026-09-04)**:seam ⑨ 收口 —— 头顶名字尾标随聊天气泡上移 17px 1:1(§8.8)。 + `CPythonTextTail::UpdateShowingTextTail`(`PythonTextTail.cpp:88`)在角色名字尾标投影完 + 屏幕坐标后(:104),`m_ChatTailMap.find(dwVirtualID)` 命中(:106/:110)且那条 `bNameFlag` + 为 TRUE(:112,= `RegisterChatTail` 的「聊天」气泡,非 `RegisterInfoTail` 的「信息」气泡) + → `pTextTail->y = pChatTail->y - 17.0f`(:114,屏幕 y 向下减 17px = 名字上移一行贴气泡 + 上沿)。名字与气泡默认同世界高(`GetHeight()+10`,:519/:581),全靠这条 −17px 分开。 + 迁到 `project/name_tail_layout.gd`(纯静态第 28 个 canonical):`NAME_OFFSET_ABOVE_BUBBLE_PX + := 17.0`、`name_follows_bubble(has_chat_tail, chat_name_flag)`、`name_tail_screen_y`(参考端 + 字面 `bubble_y − 17`)、`name_tail_world_y`(poc 3D:屏幕 y 向下的 −17px → 世界 y 向上的 + `bubble_world_y + 17*pixel_size`)。poc 侧接:`net_world._reposition_name_tag(n)` 由 + `_apply_chat_tail` / `_expire_chat_tails` 调用,读 `_chat_tails[vid].name_flag` 决定名字 + `Label3D` 局部 y 在 `NAME_TAG_DEFAULT_Y`(2.1)与「气泡 2.35 + 17*pixel_size」之间切换。 + 剩余 seam ⑨:气泡与名字仍是两个并存 `Label3D` 节点、子标签不随动 = seam ⑦ 遗留。见 C.5 增量 117。 +- **增量 118(2026-09-04)**:目标框「看他国玩家」前置过滤 + §8.4 名字色 radio 接线 1:1(§5.4 / §8.8 / §8.4)。 + `root/uitarget.py TargetBoard.Open(vid, name)`(:189-193)在整个 `RecvTargetPacket` 判定之前 + 先过滤:`if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): if not player.IsSameEmpire(vid): + self.Hide(); return`(`constinfo.VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1` 默认, + `uigameoption.py` 的 `target_board_view` / `target_board_no_view` radio → + `constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)`)。迁到 + `project/target_board.gd`:`hidden_as_other_empire(view_other_empire, same_empire)` = + `not view_other_empire and not same_empire`(不按 `is_pc` 收窄 —— 忠于参考;empire 0 的 + 怪 / NPC / 石头在 `EntityRules._is_same_empire` 里算「同阵营」故永不被挡)。poc 侧接: + `net_play._view_other_empire_target`(默认 true)+ `set_view_other_empire_target(on)` + + `_on_target_info` 在 `TargetBoard.classify(...)` 之前先 `if exists and + TargetBoard.hidden_as_other_empire(...): _clear_target(); return`;`same_empire` 由 + `EntityRules._is_same_empire(_main_entity(), e)` 求。同批把 §8.4「名字色」radio + (`name_color_normal` / `name_color_empire`)接进 `game_scene._on_display_option` → + `net_world.set_empire_name_mode(v == 1)`(帝国名色调色板算法增量 110 已 1:1),并修 + `game_option_ui.gd` 的 `target_board` 默认值 0 → 1(对齐 `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1`)。 + 40250 `source/` 自带 `TargetBoard.Open` —— poc 无独立「他国 PC 名字框」窗体,本增量只是 + 在 `_on_target_info` 分派前加同一道阵营门(seam ⑫:building / wooden-door 实体类型、 + `LCONTROL` 悄悄话捷径仍未做)。见 C.5 增量 118。 +- **增量 119(2026-09-04)**:seam ⑦ 收口 —— 公会名 / 称号 / 等级子标签随名字尾标纵向位移 1:1(§8.8)。 + `CPythonTextTail::ArrangeTextTail`(`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把 + 公会名(:228,`pTextTail->y - c_fyGuildNamePosition`,`c_fyGuildNamePosition = 15.0f` :21)/ + 称号(:258,同 `pTextTail->y`)/ 等级(:283,同 y)三个子标签的屏幕 y 全部锚在 + `pTextTail->y` 上,而这个 y 已被 `UpdateShowingTextTail :114` 顶过 −17(增量 117)。 + 迁到 `project/name_tail_layout.gd`:`GUILD_NAME_ABOVE_NAME_PX := 15.0`、 + `name_tail_lift_delta(default_world_y, bubble_world_y, follows, px_size)`(= + `name_tail_world_y(...) - default_world_y`,follows 时为净上移量、否则 0)、 + `subtag_world_y(default_subtag_y, name_tail_lift_delta)`(=二者相加,三子标签整体平移)。 + poc 侧接:`net_world` 把 `GuildTag`/`TitleTag`/`LevelTag` 默认局部 y 提为 + `SUBTAG_DEFAULT_Y`(2.34 / 1.86 / 1.74)常量,`_reposition_name_tag` 顶完名字尾标后按 + 同一 `lift` 平移三个子标签;气泡过期 / 「信息」尾标(`bNameFlag` FALSE)时回默认。 + 剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版(公会名上方 15px、称号 / 等级 + 同水平行左偏)仍近似。剩余 seam ⑨:气泡与名字仍是两个并存 `Label3D`(非单尾标链表)。见 C.5 增量 119。 +- **增量 120(2026-09-04)**:seam ⑩ 收口 —— `ArrangeTextTail` item 尾标去重叠 + `isIn` 1:1(§8.8)。 + `CPythonTextTail::ArrangeTextTail`(`PythonTextTail.cpp:153`)的 `m_ItemTextTailList` 块 + (:160-190):逐条掉落名条与其它每条比 `isIn`(:489-507,`[x+xStart,x+xEnd]×[y+yStart,y+yEnd]` + 的 AABB 含边界 `<=`/`>=`),命中就把「自己」顶到 `pCompareTextTail->y + pCompareTextTail->yEnd + + yTemp`(`yTemp = 5`),内层从头重跑、`LimitCount >= 20` 封顶。新增第 29 个 canonical + `project/text_tail_arrange.gd`(`extends RefCounted` 全静态):`PUSH_GAP := 5.0` / `MAX_ITERS := 20`、 + `box_start_x/end_x/start_y/end_y`(`-w/2-2` / `w/2+2` / `-2` / `h`,`PythonTextTail.cpp:810-813`)、 + `boxes_overlap(a,b)`(`isIn`)、`arrange_item_tails(tails:Array)->Array`(外层逐条 i、内层逐条 j + 跳过自身、命中 `ys[i] = ys[j] + box_end_y(h_j) + PUSH_GAP` 后 j 归零 limit++、`limit >= MAX_ITERS` + break;`pCompare` 用已排布后的 `ys[j]`)。poc 侧接:`project/ui/ground_items.gd` 加 + `_camera_getter` + 常量 `TAG_BASE_Y 0.75` / `TAG_CHAR_W_PX 7` / `TAG_LINE_H_PX 15`, + `_process` 尾部 `_arrange_tags()` 把所有掉落名条世界锚点 `cam.unproject_position` 到屏幕、 + 按字符数估框、跑 `arrange_item_tails`、`position.y = TAG_BASE_Y - (arranged_y - y0) * pixel_size`, + 可见名条 < 2 直接回默认;`game_scene` 把 `func() -> Camera3D: return cam` 传进 + `ground_items.setup` 第 6 参。剩余 seam ⑩:名条框按「字符数 * 7px」估宽 vs 参考 + `GetTextSize` 精确像素;只对掉落 item 名条做(角色名字尾标分表不参与)。见 C.5 增量 120。 +- **增量 121(2026-09-04)**:seam ⑦ 水平深化 —— 角色子标签逐像素水平排版 1:1(§8.8)。 + `CPythonTextTail::ArrangeTextTail` 的 `m_CharacterTextTailList` 块(`PythonTextTail.cpp:211-320`) + 非 EUROPE 分支:`fxAdd` 三态(:236 `0` / :242 `8`(有称号)/ :288 `4`(只有等级)), + 称号 `x - iNameWidth/2 - fxAdd`(:258)、等级有称号时再 `- iTitleWidth`(:282)否则 + `x - iNameWidth/2 - 4`(:312)、名字本身 `x + fxAdd`(:320);公会名水平锚点仅在有公会徽记 + `pMarkInstance`(:220)时改写成 `x + markW/2 + 1.5`(:228)。`name_tail_layout.gd` 加 + `name_x_shift` / `title_x_offset` / `level_x_offset` / `guild_name_x_offset` / `mark_x_offset` / + `subtag_world_x`。`net_world._layout_subtags_x`(`_apply_text_tail_to` 尾部)设名字 / + `TitleTag` / `LevelTag` 的 `.position.x`(`GuildTag` 留 0,poc 无徽记),文本宽 `字符数 * 7px` + 估(`SUBTAG_CHAR_W_PX`);与增量 117/119 纵向位移解耦(`.position.y` vs `.position.x`)。 + 剩余 seam ⑦:无公会徽记 mark 实体、字符数估宽 vs `GetTextSize`、三子标签竖排 + x 偏移的 + 折衷(≠ 参考同一屏幕行)。见 C.5 增量 121。 +- **增量 122(2026-09-04)**:seam ⑫ 收口 —— 目标框点击 `LCONTROL` 悄悄话捷径接 whisper 1:1(§5.4 / §8.8)。 + `assets/root/game.py CGameWindow::SetPCTargetBoard`(:804-817):`self.targetBoard.Open(vid, name)` + 之后,`if app.IsPressed(app.DIK_LCONTROL)`(:807)且 `player.IsSameEmpire(vid)`(:809)且 + 非本人(:812 `IsMainCharacterIndex`)且 `chr.INSTANCE_TYPE_BUILDING != GetInstanceType(vid)` + (:814)→ `self.interface.OpenWhisperDialog(name)`(:817)。谓词 + `target_board.whisper_on_ctrl_click(lcontrol_down, same_empire, is_main, is_building)` + 增量 118 已备好没接,本增量纯接线:`net_play` 加 `signal whisper_requested(name)` + + `var _lcontrol_down` + `set_lcontrol_down(down)`,`_on_pick` 在 `_entity_kind(e) == KIND_PC` + 分支(C++ picking 只对 PC 派发 `SetPCTargetBoard`)调 `whisper_on_ctrl_click(_lcontrol_down, + _is_same_empire(_main_entity(), e), vid == _main_vid, false)` 真则 `whisper_requested.emit( + _entity_name(e))`;`game_scene._unhandled_input` 加 `KEY_CTRL` 按下 / 松开 → + `net_play.set_lcontrol_down(e.pressed)`,`chat` 就绪后 `net_play.whisper_requested` → + `chat.start_whisper`(poc 版 `OpenWhisperDialog` = 聊天输入预填 `"/w 名 "`)。 + 剩余 seam ⑫:poc 无 building / wooden-door / stone-door 实体类型(`is_building` 实参恒 false, + :814 分支永不触发)、无独立 whisper dialog 窗口。见 C.5 增量 122。 +- **增量 123(2026-09-04)**:seam ⑦ / ⑩ 收口 —— 头顶子标签 / 掉落名条文本宽换真实字体度量 + `GetTextSize` 1:1(§8.8)。`CPythonTextTail::ArrangeTextTail` 里 `iNameWidth` / `iTitleWidth` / + `iLevelWidth`(`PythonTextTail.cpp:225/233/240/267/297`)与掉落名条框 `xSize`(:733/:748/:809) + 参考端全部取自 `CGraphicTextInstance::GetTextSize(&w, &h)`——逐字形排版度量,非「字符数 * 定宽」。 + 新增纯静态 `project/text_metrics.gd`(第 30 个 canonical):`text_size(font, text, font_size)` + = `Font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`(退化输入 → `ZERO`)、 + `width_of(...)`(量得正宽用精确像素,无字体回退 `len * 7.0`)、`label_width(label: Label3D)` + (`label.font ?? ThemeDB.fallback_font` + `label.font_size`)。`net_world._layout_subtags_x` 的 + `name_w` / `title_w`、`ground_items._arrange_tags` 的框宽 `w` 由 `float(text.length()) * *_CHAR_W_PX` + 换成 `TextMetrics.label_width(label, *_CHAR_W_PX)`;`*_CHAR_W_PX = 7.0` 常量保留作无字体兜底。 + `name_tail_layout.gd` / `text_tail_arrange.gd` 收宽度作入参、排布公式(增量 119/120/121)不动。 + 剩余 seam ⑦:无公会徽记 mark 实体、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行); + 剩余 seam ⑩:名条单行高仍按 `TAG_LINE_H_PX` 15px 估、只对掉落 item 名条做。见 C.5 增量 123。 +- **增量 124(2026-09-04)**:seam ⑦ / ⑩ 末尾 —— 掉落名条去重叠框的「单行文本高」换真实字体 + 度量 `GetTextSize` 的 h 1:1(§8.8)。`CPythonTextTail::ArrangeTextTail` 的 item 尾标去重叠 + 用 `pTextTail->yEnd`,而 `yEnd = ySize`(`PythonTextTail.cpp:751/:812`),`ySize` 与增量 123 + 换过的 `xSize` 同出 `CGraphicTextInstance::GetTextSize(&xSize, &ySize)`(:749/:809)。 + `project/text_metrics.gd`(仍第 30 个 canonical,扩非新增)加 `const FALLBACK_LINE_H_PX := 15.0` + + `height_of(font, text, font_size, fallback_line_px=15.0)`(空串 → 0;量得正高用精确像素 + `text_size(...).y`;否则回退)+ `label_height(label: Label3D, fallback_line_px=15.0)` + (`label.font ?? ThemeDB.fallback_font` + `label.font_size`,`null` → 0)。 + `ground_items._arrange_tags` 的框高 `h` 由硬编码 `TAG_LINE_H_PX` 换成 + `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`;`TAG_LINE_H_PX = 15.0` 保留作 + 无字体兜底。`text_tail_arrange.gd`(`box_end_y(h) = h`)/ `name_tail_layout.gd` 收框高作 + 入参、排布公式不动。测试扩 `text_metrics_test.gd`(+「5. height_of / label_height」段), + 回归 `project/*_test.gd` 70 全 PASS(无新增测试文件)。剩余 seam ⑦:无公会徽记 mark 实体、 + 三子标签竖排 + x 偏移的折衷;剩余 seam ⑩:掉落名条 owner 未拆成独立 text instance + (参考端 `yEnd += yOwnerSize + 4`)、只对掉落 item 名条做。见 C.5 增量 124。 +- **增量 125(2026-09-04)**:seam ⑩ 末尾 —— 带 owner 的掉落名条去重叠框按参考 + `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)「owner 独立度量 + + `yEnd += yOwnerSize + 4`」1:1(§8.8)。参考端 owner 后缀是与名字分开的 + `CGraphicTextInstance`(文本 = `c_szName + strOwnership`,`IDS_POSSESSIVE_MORPHENE` 为空取 + 字面 `"'s"`),单独 `GetTextSize`,据此 `yEnd += float(yOwnerSize + 4)`(:735)、 + `xStart = fMIN(xStart, -xOwnerSize/2-1)`(:736)、`xEnd = fMAX(xEnd, xOwnerSize/2+1)`(:737)。 + `text_tail_arrange.gd`(扩第 29 个 canonical)加纯静态 `item_box_with_owner(name_w, name_h, + owner_w:=0.0, owner_h:=0.0)`:无 owner → `{name_w, name_h}`(对齐参考 else 分支);有 owner → + `{maxf(name_w, owner_w-2.0), name_h+owner_h+4.0}`(令 `box_end_x(w)=w/2+2` 恰等于 :737 的 + `fMAX`,框 x 对称故 `box_start_x` 自动 = :736 `fMIN`)。`text_metrics.gd`(扩第 30 个 canonical) + 加 `string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串,`null` / + 空串回退)。`ground_items._on_added` 存 `item_name` / `item_owner` meta,`_arrange_tags` 把 + 「名字子串」「owner + `'s` 子串」分别度量后走 `item_box_with_owner` 合成去重叠框;渲染仍是 + 单个合并 `"name (owner)"` billboard `Label3D`(seam ⑩ 记录在案的折中)。测试扩 + `text_tail_arrange_test.gd`(+「5. item_box_with_owner」段)/ `text_metrics_test.gd` + (+「6. string_*_like」段)/ `p2b_test.gd`(`FakeClient.add_ground` 加可选 owner 入参 + + 带 owner 掉落被顶 `(34+5)*0.005` 段),回归 `project/*_test.gd` 70 全 PASS(无新增测试文件)。 + 剩余 seam ⑩:owner 未真正独立渲染成 CENTER 黄字、`strOwnership` 未接本地化语素、 + 只对掉落 item 名条做。见 C.5 增量 125。 +- **增量 126(2026-09-04)**:seam ⑩ 末尾 —— 带 owner 的掉落名条按参考 + `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:712-745`)把 owner 后缀拆成 + **一个独立的 CENTER 黄字(`1,1,0`)text instance**、并按 `ArrangeTextTail`(:191-207)把名字 + 实例下移 15px(§8.8)。参考端 owner 非空时新建 `pOwnerTextInstance`(:715)、文本 + = `c_szName + strOwnership`(:723,`strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE)` + 为空取字面 `"'s"`,:722)、`SetHorizonalAlign(CENTER)`(:727)、`SetColor(1,1,0)`(:729); + 排布时 `pOwnerTextInstance->SetPosition(x, y, z)`(:195)、`pTextInstance->SetPosition(x, + y + 15.0f, z)`(:200);owner 为空时 `Delete(pOwnerTextInstance)`(:743)。`ground_items`: + drop 节点下加第二个 `Label3D "owner_tag"`(CENTER / `modulate = Color(1,1,0)` / billboard / + `no_depth_test` / `pixel_size 0.005`,文本 = `owner + _possessive`),`_apply_owner_tag(node, + owner)` 建 / 删;名字 `tag` 只放 `_name_for(vnum)`(不再拼 `"name (owner)"`);新增 + `const OWNER_NAME_GAP_PX := 15.0` + `_place_tail(n, tag, d_px)` —— 有 owner 时 + `owner_tag.y = TAG_BASE_Y - d_px*px`、`tag.y = TAG_BASE_Y - (d_px + 15)*px`,无 owner 时 + 沿用 `tag.y = TAG_BASE_Y - d_px*px`;`setup()` 加可选 `possessive := ""`(`_possessive = + possessive if possessive != "" else OWNER_POSSESSIVE`,1:1 对齐 :722 的 `== "" ? "'s" :` 语义), + `game_scene` 从 EterLocale `POSSESSIVE_MORPHENE` 键解析后注入(poc txt 无此键 → 回落 `"'s"`, + 与 40250 西文 `.rc` 的 `IDS_POSSESSIVE_MORPHENE "'s"` 一致)。去重叠框度量仍是增量 125 的 + `item_box_with_owner`(`yEnd += ownerH + 4`)不变。`_tag_text` 删除(不再合并渲染)。测试扩 + `p2b_test.gd`(drop_302 名字实例文本 = `"掉落28"`、`owner_tag` 文本 `"Alice's"` / CENTER / + 黄字 / 落基点、名字实例下移 15px、无 owner 的 drop_301 不建 `owner_tag`;drop_401 用 + `setup(possessive="@")` → `owner_tag` 文本 `"Bob@"`),回归 `project/*_test.gd` 70 全 PASS + (无新增测试文件)。剩余 seam ⑩:owner 行的品阶/颜色语义仍是固定黄(参考名字实例取 + `pInsertTextTail->Color` 的道具品阶色、poc 名字走 `_process` 远近着色)、去重叠只对掉落 + item 名条做。见 C.5 增量 126。 **修改** @@ -4378,6 +4783,145 @@ OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWn CanPickInstance` 资格、`target VID` 与 chat `bNameFlag` 强显绕过 3500;`_process` ~2Hz 兜底 随移动刷新,LALT / 选项 / 选中 / 尾标切换即时刷新)。悬停 `chr.Pick()` 强显未接、 `ArrangeTextTail` 屏幕空间防重叠未做、`CanPickInstance()` 近似为「非死透」= seam ⑩。 + **增量 114 已落地头顶伤害飘字**(`damage_effect.gd` 纯静态:`AddDamageEffect` + (`InstanceBaseEffect.cpp:87`,`IsShowDamage()` 门 → `m_DamageQueue`)、`ProcessDamage` + (:100,每帧 `pop_front` 一条:`DODGE|BLOCK` → `MISS`/`TARGETMISS` return、`CRITICAL`/`POISON` + 分支参考端注释掉走普通数字、`bSelf` → `"damage_"` + `SELFDAMAGE`⇄`SELFDAMAGE2` 交替、 + `!bTarget` → `"nontarget_"` return 不渲染、否则 `"target_"`、`while(damage>0)` 逐位 `%10`/`/10` + index 0..7、每位 `damagevalue/.dds` 按 `FONT_WIDTH*index` 相机右向铺开)、 + `RecvDamageInfoPacket`(`PythonNetworkStreamPhaseGame.cpp:2401`,`bSelf`/`bTarget`/`damage>=0`)、 + `IsShowDamage`/`SetShowDamageFlag`(`PythonSystem.cpp:337/:342`,默认 `true`)。 + `net_world._on_damage` 照 `AddDamageEffect`(`damage_number` 信号仍无条件发=本移植扩展; + 飘字本体 `_show_damage` 门后入 `_damage_queue`)、`_process_damage_queue()` 每帧 `_process` + 里 pop 一条照 `ProcessDamage` 分类,一块 billboard `Label3D` 写 `str(amount)`/`"MISS"`; + `net_world.set_show_damage(on)` ← `game_scene._on_display_option("show_damage")` ← + `game_option_ui.display_option_changed`(uigameoption「伤害数字」radio),初值 + `display_value("show_damage") == 1` 回填;`clear_for_map_change` 清 `_damage_queue`。 + seam ⑪:单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效 / 相机右向像素铺排;self + `SELFDAMAGE`⇄`SELFDAMAGE2` 退化成落字水平抖动符号交替(±0.3);暴击不再放大 / 变色 + (对齐该 40250 build 本就无独立暴击视觉);`_damage_queue` 全局单条 vs 参考每实例一条。 + **增量 115 已落地目标框收包分派**(`target_board.gd` 纯静态:`Action { CLOSE, + CLOSE_IF_DIFFERENT, SET_HP, NONE }`、`can_view_target_hp`(石头 / 木门 / 敌对任一)、 + `classify(target_exists, is_dead, is_pc, is_building, can_view_hp)`= `RecvTargetPacket` 判定树 + (缺失 → CLOSE、已死 → NONE、PC|Building → CLOSE_IF_DIFFERENT、可看血 → SET_HP、否则 CLOSE)、 + `updates_current_target`(`m_pInstTarget` 写入条件)、`needs_reset` / `should_close_if_different` + (`!= GetTargetVID()`)、`whisper_on_ctrl_click`、`clamp_hp_pct`)。`net_play._on_target_info` + 由「任意有名字 VID 都 `hud.set_target`」改为按 `classify` 分派:`is_pc = _entity_kind == KIND_PC`、 + `can_view = KIND_STONE 或 KIND_MONSTER`、CLOSE / CLOSE_IF_DIFFERENT → `_clear_target()`、 + SET_HP → 换 VID 先 `_target_vid = vid` + `target_changed.emit` 再 `hud.set_target(名字, + clamp_hp_pct(hp%))`、NONE → 不动。seam ⑫:poc 无 building / wooden-door 实体(`is_building` + 恒 false、木门分类缺);`target_info` 由 C++ `m2_client.cpp:3129/3633` 从 EntityStore 抽出, + 不改 C++ 收包;~~`LCONTROL` 悄悄话捷径谓词已备、`net_play` 暂不接~~ 增量 122 已接线 + (`_on_pick` KIND_PC 分支 → `whisper_requested` → `game_scene` → `chat.start_whisper`)。 + **增量 116 已落地 `OnRender` Picking 块分派 + 悬停角色强显名字**(`pick_show.gd` 纯静态第 27 + 个 canonical:`NO_PICK := -1`、`char_pick_active(is_picked_window)`、`forced_name_vids( + is_picked_window, pick_char_vid, target_vid)`= `IsPickedWindow` 门内收 `chr.Pick()` 命中角色 + (:1537)+ `targetBoard.GetTargetVID()`(:1539),去重、顺序 `[hover, target]`、`should_pick_item( + is_picked_window, is_show_name)`= `and not`(:1543)、`should_show_all_names` / `should_pick_text_tail` + = `is_show_name`(:1552/:1554)、`should_select_item_name`= `!= NO_PICK`(:1560))。poc 侧仿增量 + 113 的 `_target_vid` 路数给 `net_world._name_shown()` 加第 5 路 forced `_hover_vid`: + `net_world.set_hover_vid(vid)`(值变才刷 prev/vid 两节点名字可见性)由 `player_controller` + `_update_hover_cursor()` 三出口喂(压 UI → 0、悬停地面物品 → 0、射线命中 pickable → + `best.get_meta("vid")`、无命中 → 0),`hover_entity_changed` 信号去抖,`game_scene` 连 + `pc.hover_entity_changed → net_world.set_hover_vid`。seam ⑨:名字尾标 y = chat 气泡 y − 17px + (`PythonTextTail.cpp:112-114`)未接;seam ⑩:`ArrangeTextTail` 屏幕空间去重叠仍缺(本增量只 + 收口「悬停强显名字」一小块);`_hover_vid` 当无条件 forced,靠 `player_controller` 压 UI / 悬停 + 物品时置 0 部分等效 `IsPickedWindow` 门。 + **增量 117 已落地 seam ⑨ 收口(名字尾标随聊天气泡上移 17px)**(`name_tail_layout.gd` + 纯静态第 28 个 canonical:`NAME_OFFSET_ABOVE_BUBBLE_PX := 17.0`、`name_follows_bubble( + has_chat_tail, chat_name_flag)`= `and`(`UpdateShowingTextTail` :110 命中 && :112 + `bNameFlag`)、`name_tail_screen_y(default_y, bubble_y, follows)`= 参考端字面 `bubble_y − 17` + (:114)、`name_tail_world_y(default_world_y, bubble_world_y, follows, px_size)`= poc 3D + 适配(屏幕 y 向下 −17px → 世界 y 向上 `bubble_world_y + 17*px_size`))。poc 侧 + `net_world._reposition_name_tag(n)` 由 `_apply_chat_tail`(冒气泡)/ `_expire_chat_tails` + (气泡过期)调用,读 `_chat_tails[vid].name_flag`(`RegisterChatTail` TRUE / `RegisterInfoTail` + FALSE)决定名字 `Label3D` 局部 y 在 `NAME_TAG_DEFAULT_Y`(2.1)与「`BUBBLE_TAG_Y` 2.35 + + 17*pixel_size」之间切换。换算按名字 `Label3D` 的 `pixel_size`(0.006),与参考的字体无关 + 常量 17 对齐。 + **增量 119 已落地 seam ⑦ 收口(子标签随名字尾标纵向位移)**:`ArrangeTextTail` + (`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把公会名(:228,`pTextTail->y − + c_fyGuildNamePosition` 15px)/ 称号(:258,同 `pTextTail->y`)/ 等级(:283,同 y)三个 + 子标签屏幕 y 全锚在 `pTextTail->y` 上,而这个 y 已被 `UpdateShowingTextTail :114` 顶过 −17。 + `name_tail_layout.gd` 增 `GUILD_NAME_ABOVE_NAME_PX := 15.0`、`name_tail_lift_delta( + default_world_y, bubble_world_y, follows, px_size)`(= `name_tail_world_y(...) − + default_world_y`)、`subtag_world_y(default_subtag_y, name_tail_lift_delta)`(=相加)。 + poc 侧 `net_world` 把子标签默认局部 y 提为 `SUBTAG_DEFAULT_Y`(2.34 / 1.86 / 1.74)常量, + `_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移三个子标签,气泡过期 / 「信息」尾标 + 时回默认。剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版仍近似。剩余 seam ⑨: + 气泡与名字仍是两个并存 `Label3D` 节点(非参考单尾标链表)。 + **增量 120 已落地 seam ⑩ 收口(`ArrangeTextTail` item 尾标去重叠 + `isIn`)**: + `PythonTextTail.cpp:153` 的 `m_ItemTextTailList` 块(:160-190)—— 逐条掉落名条与其它每条比 + `isIn`(:489-507,AABB 含边界),命中把「自己」顶到 `pCompareTextTail->y + yEnd + 5`,内层 + 从头重跑、`LimitCount >= 20` 封顶。新增第 29 个 canonical `text_tail_arrange.gd` + (`PUSH_GAP` 5 / `MAX_ITERS` 20、`box_*` 四边 `-w/2-2` `w/2+2` `-2` `h`、`boxes_overlap`=`isIn`、 + `arrange_item_tails(tails)->Array`)。poc 侧 `ground_items._arrange_tags` 每帧把掉落名条锚点 + `cam.unproject_position` 到屏幕、按字符数估框、跑 `arrange_item_tails`、把被下推屏幕 Δy 按 + `Label3D.pixel_size` 折回局部 y(`TAG_BASE_Y − Δpx*pixel_size`),可见名条 < 2 回默认; + `game_scene` 把相机 getter 传进 `ground_items.setup`。剩余 seam ⑩:~~名条框按字符数估宽 vs + 参考 `GetTextSize` 精确像素~~(增量 123 已换真实字体度量);~~单行高仍按 15px 估~~(增量 124 + 已换 `TextMetrics.label_height` = `GetTextSize` 的 h);只对掉落 item 名条(角色名字尾标分表不参与)。 + **增量 121 已落地 seam ⑦ 水平深化(子标签逐像素水平排版)**:`ArrangeTextTail` 的 + `m_CharacterTextTailList` 块(`PythonTextTail.cpp:211-320`)非 EUROPE 分支 —— `fxAdd` 三态 + (:236 `0` / :242 `8` / :288 `4`),称号 `x − iNameWidth/2 − fxAdd`(:258)、等级有称号时 + 再 `− iTitleWidth`(:282)否则 `x − iNameWidth/2 − 4`(:312)、名字本身 `x + fxAdd`(:320); + 公会名水平锚点仅在有公会徽记 `pMarkInstance`(:220)时改写。`name_tail_layout.gd` 加 + `name_x_shift` / `title_x_offset` / `level_x_offset` / `guild_name_x_offset` / `mark_x_offset` / + `subtag_world_x`(屏幕像素 × `pixel_size` → billboard 本地 x)。poc 侧 `net_world._layout_subtags_x` + (`_apply_text_tail_to` 尾部)设名字 / `TitleTag` / `LevelTag` 的 `.position.x`,`GuildTag` 留 0 + (poc 无徽记 → 参考端 :220 此路不改写),文本宽 `字符数 * 7px` 估(`SUBTAG_CHAR_W_PX`; + 增量 123 已换真实字体度量);与增量 117/119 的纵向位移解耦(`.position.y` vs `.position.x`)。 + 剩余 seam ⑦:无公会徽记 mark 实体、~~字符数估宽 vs `GetTextSize`~~(增量 123 收口)、 + 三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。 + **增量 124 已落地 seam ⑦ / ⑩ 末尾(掉落名条去重叠框的单行文本高换真实字体度量)**: + `ArrangeTextTail` 的 item 尾标去重叠顶到 `pCompareTextTail->y + yEnd + 5`,`yEnd = ySize` + (`PythonTextTail.cpp:751/:812`),`ySize` 与增量 123 换过的 `xSize` 同出 + `CGraphicTextInstance::GetTextSize(&xSize, &ySize)`(:749/:809)。`text_metrics.gd` + (仍第 30 个 canonical)加 `const FALLBACK_LINE_H_PX := 15.0` + `height_of(...)`(空串 → 0; + 量得正高用 `text_size(...).y`;否则回退)+ `label_height(label: Label3D)` + (`label.font ?? ThemeDB.fallback_font`,`null` → 0)。`ground_items._arrange_tags` 的框高 `h` + 由硬编码 `TAG_LINE_H_PX` 换 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`, + `TAG_LINE_H_PX = 15.0` 留作无字体兜底;`text_tail_arrange.gd`(`box_end_y(h) = h`)不动。 + ~~剩余 seam ⑩:掉落名条 owner 未拆成独立 text instance(参考端 `yEnd += yOwnerSize + 4`, + :733-736)~~ 增量 125 已按参考把去重叠框度量 1:1;只对掉落 item 名条做。 + **增量 125 已落地 seam ⑩ 末尾(带 owner 的掉落名条去重叠框:owner 独立度量 + `yEnd += yOwnerSize + 4`)**: + 参考 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)—— owner 后缀是 + 与名字分开的 `CGraphicTextInstance`(文本 = `c_szName + strOwnership`,`IDS_POSSESSIVE_MORPHENE` + 为空取字面 `"'s"`),单独 `GetTextSize`,据此 `yEnd += float(yOwnerSize + 4)`(:735)、 + `xStart = fMIN(xStart, -xOwnerSize/2-1)`(:736)、`xEnd = fMAX(xEnd, xOwnerSize/2+1)`(:737); + owner 为空则删 owner 实例、框复位纯名字(:739-751)。`text_tail_arrange.gd`(扩第 29 个 + canonical)加纯静态 `item_box_with_owner(name_w, name_h, owner_w:=0.0, owner_h:=0.0)`: + `owner_w<=0 或 owner_h<=0` → `{name_w, name_h}`;否则 → `{maxf(name_w, owner_w-2.0), + name_h+owner_h+4.0}`(令 `box_end_x(w)=w/2+2` 恰等于 :737 的 `fMAX`,框 x 对称故 + `box_start_x` 自动 = :736 `fMIN`)。`text_metrics.gd`(扩第 30 个 canonical)加 + `string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串;`null` / + 空串回退)。`ground_items._on_added` 存 `item_name` / `item_owner` meta,`_arrange_tags` + 把「名字子串」「owner + `'s` 子串」分别度量后走 `item_box_with_owner` 合成去重叠框; + 渲染仍是单个合并 `"name (owner)"` billboard `Label3D`(seam ⑩ 记录在案的折中)。测试扩 + `text_tail_arrange_test.gd`「5. item_box_with_owner」段 / `text_metrics_test.gd` + 「6. string_*_like」段 / `p2b_test.gd`(`FakeClient.add_ground` 加可选 owner 入参), + 回归 70 全 PASS(无新增测试文件)。~~剩余 seam ⑩:owner 未真正独立渲染成 CENTER 黄字、 + `strOwnership` 未接本地化语素~~ 增量 126 已收口(见下);只对掉落 item 名条做。 + **增量 126 已落地 seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + `strOwnership` + 接本地化语素)**:参考 `SetItemTextTailOwner`(`PythonTextTail.cpp:712-745`)+ `ArrangeTextTail` + (:191-207)—— owner 非空 → 新建 `pOwnerTextInstance`(:715)、文本 = `c_szName + strOwnership` + (:723,`strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...`, + :722)、`SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER)`(:727)、`SetColor(1,1,0)` 黄(:729); + 排布时 owner 落 `(x, y)`(:195)、名字实例落 `(x, y + 15.0f)`(:200);owner 为空 → + `Delete(pOwnerTextInstance)`(:743)。`ground_items`:drop 节点下第二个 `Label3D "owner_tag"` + (`horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER` / `modulate = Color(1,1,0)` / billboard + / `no_depth_test` / `pixel_size 0.005`,文本 = `owner + _possessive`),`_apply_owner_tag` 建 / + 删;名字 `tag` 只放 `_name_for(vnum)`(`_tag_text` 删)。新增 `const OWNER_NAME_GAP_PX := 15.0` + + `_place_tail(n, tag, d_px)`:有 owner → `owner_tag.y = TAG_BASE_Y - d_px*px`、`tag.y = + TAG_BASE_Y - (d_px + 15)*px`;无 → `tag.y = TAG_BASE_Y - d_px*px`。`setup()` 加可选 + `possessive := ""`(`_possessive = possessive if possessive != "" else OWNER_POSSESSIVE`, + 1:1 对齐 :722 的 `== "" ? "'s" :` 语义),`game_scene` 从 `EterLocale` 的 `POSSESSIVE_MORPHENE` + 键解析后注入(poc 无此键 → 回落 `"'s"`,与 40250 西文 `.rc` 的 `IDS_POSSESSIVE_MORPHENE + "'s"` 一致)。去重叠框度量仍是增量 125 的 `item_box_with_owner`(`yEnd += ownerH + 4`)不变。 + 测试扩 `p2b_test.gd`(drop_302 名字实例 `"掉落28"`、`owner_tag` `"Alice's"` / CENTER / 黄 / + 落基点、名字下移 `15 * pixel_size`;无 owner 的 drop_301 不建 `owner_tag`;drop_401 + `setup(possessive="@")` → `owner_tag` `"Bob@"`),回归 70 全 PASS(无新增测试文件)。剩余 + seam ⑩:owner 行颜色语义固定黄(参考名字实例取 `pInsertTextTail->Color` 品阶色、poc 名字 + 走 `_process` 远近着色)、去重叠只对掉落 item 名条做。 3. 名字颜色按 `ENameColor` 的完整枚举实现,输入是 §2.5 补齐的 `ch_type / empire / guild / alignment / pk_mode` 与 §5.4 的可攻击判定。 **增量 110 已完成名字色本身**(`name_color.gd` + `net_world.gd` 接线,见本节参考的增量 110 条); @@ -4386,7 +4930,86 @@ OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWn `net_world.gd` `"bubble"` 子节点,见本节参考的增量 112 条); **增量 113 已完成名字「是否显示」`IsAlwaysShowName` / `__IsShowName` / `ShowAllTextTail` 3500 裁剪**(`name_show.gd` + `net_world._name_shown()`,见本节参考的增量 113 条); - `text_tail.gd` 的伤害数字 / 屏幕空间血条整合仍待做。 + **增量 114 已完成头顶伤害飘字 `AddDamageEffect` / `ProcessDamage` 分类 + `IsShowDamage` 门** + (`damage_effect.gd` + `net_world._on_damage` / `_process_damage_queue` / `set_show_damage`, + 见本节参考的增量 114 条;seam ⑪); + **增量 115 已完成目标框 `RecvTargetPacket` 收包分派 + `CanViewTargetHP`**(`target_board.gd` + + `net_play._on_target_info` 按判定树分派 —— PC / building 目标不再显血条框、NPC / warp + 不弹框、石头 / 敌对怪 `SetHPTargetBoard`、换 VID 先 Reset,见本节参考的增量 115 条;seam ⑫); + **增量 116 已完成 `OnRender` Picking 块分派 + 悬停角色强显名字**(`pick_show.gd` + + `net_world._hover_vid` / `set_hover_vid` / `_name_shown()` forced 第 5 路 + + `player_controller.hover_entity_changed` + `game_scene` 连线,见本节参考的增量 116 条; + seam ⑨ / ⑩); + **增量 117 已完成 seam ⑨ 收口(名字尾标随聊天气泡上移 17px,`PythonTextTail.cpp:112-114`)** + (`name_tail_layout.gd` + `net_world._reposition_name_tag` 由 `_apply_chat_tail` / + `_expire_chat_tails` 调用 —— 冒「聊天」气泡时名字 `Label3D` 局部 y 顶到「气泡 y + + 17*pixel_size」、气泡消失或只是「信息」尾标时回默认 2.1,见本节参考的增量 117 条); + **增量 118 已完成目标框「看他国玩家」前置过滤(`uitarget.py TargetBoard.Open :189-193`)** + (`target_board.hidden_as_other_empire(view_other_empire, same_empire)` + + `net_play._on_target_info` 在 `classify` 前先判 —— game-option 关「看他国目标框」且目标 + 异阵营(`EntityRules._is_same_empire` 为假,empire 0 的怪 / NPC / 石头恒同阵营不受影响) + → `_clear_target()` 直接 return;`net_play.set_view_other_empire_target()` 由 §8.4 radio + 驱动,见本节参考的增量 118 条); + **增量 119 已完成 seam ⑦ 收口(子标签随名字尾标纵向位移,`ArrangeTextTail` :211/:228/:258/:283)** + (`name_tail_layout.gd` 增 `name_tail_lift_delta` / `subtag_world_y` + + `net_world` `SUBTAG_DEFAULT_Y` 常量 + `_reposition_name_tag` 顶完名字尾标后按同一 + `lift` 平移 `GuildTag`/`TitleTag`/`LevelTag`,气泡过期 / 「信息」尾标时回默认,见本节参考的增量 119 条); + **增量 120 已完成 seam ⑩ 收口(`ArrangeTextTail` item 尾标去重叠 + `isIn`,`PythonTextTail.cpp:160-190` / :489-507)** + (第 29 个 canonical `text_tail_arrange.gd`:`boxes_overlap`=`isIn` + `arrange_item_tails` + 逐条 / 从头重跑 / `MAX_ITERS` 20 封顶 + `box_*` 四边 `-w/2-2` `w/2+2` `-2` `h`; + `ground_items._arrange_tags` 每帧投影所有掉落名条、跑去重叠、按 `pixel_size` 折回局部 y + + `game_scene` 传相机 getter,见本节参考的增量 120 条); + **增量 121 已完成 seam ⑦ 水平深化(角色子标签逐像素水平排版,`ArrangeTextTail` :211-320 非 EUROPE 分支)** + (`name_tail_layout.gd` 加 `name_x_shift`(fxAdd 8/4/0)/ `title_x_offset`(`−nameW/2 − 8`)/ + `level_x_offset`(有称号再 `− titleW`)/ `guild_name_x_offset` / `mark_x_offset` / `subtag_world_x`; + `net_world._layout_subtags_x` 在 `_apply_text_tail_to` 尾部设名字 / `TitleTag` / `LevelTag` 的 + `.position.x`,`GuildTag` 留 0(poc 无公会徽记),文本宽 `字符数 * 7px` 估,与增量 117/119 + 纵向位移解耦,见本节参考的增量 121 条); + **增量 122 已完成 seam ⑫ 收口(目标框点击 `LCONTROL` 悄悄话捷径,`game.py SetPCTargetBoard :804-817`)** + (`net_play` 加 `signal whisper_requested(name)` / `var _lcontrol_down` / `set_lcontrol_down(down)`, + `_on_pick` 在 `KIND_PC` 分支(C++ picking 只对 PC 派发 `SetPCTargetBoard`)调 + `target_board.whisper_on_ctrl_click(_lcontrol_down, _is_same_empire(_main_entity(), e), + vid == _main_vid, false)` 真则发 `whisper_requested`;`game_scene._unhandled_input` 加 `KEY_CTRL` + 按下 / 松开 → `set_lcontrol_down(e.pressed)`,`net_play.whisper_requested` → `chat.start_whisper` + (poc 版 `OpenWhisperDialog` = 聊天输入预填 `"/w 名 "`);`is_building` 实参恒 false(门实体缺, + :814 分支永不触发)、无独立 whisper dialog 窗口 = seam ⑫ 余留,见本节参考的增量 122 条); + **增量 123 已完成 seam ⑦ / ⑩ 收口(头顶子标签 / 掉落名条文本宽换真实字体度量 `GetTextSize` 1:1)** + (新增纯静态 `text_metrics.gd` 第 30 个 canonical:`text_size` = `Font.get_string_size(text, + HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`、`width_of`(无字体回退 `len * 7.0`)、`label_width` + (`label.font ?? ThemeDB.fallback_font`);`net_world._layout_subtags_x` 的 `name_w` / `title_w`、 + `ground_items._arrange_tags` 的框宽 `w` 由 `字符数 * *_CHAR_W_PX` 换 `TextMetrics.label_width`, + `*_CHAR_W_PX = 7.0` 留作无字体兜底;`name_tail_layout.gd` / `text_tail_arrange.gd` 排布公式不动, + 见本节参考的增量 123 条); + **增量 124 已完成 seam ⑦ / ⑩ 末尾(掉落名条去重叠框的单行文本高换真实字体度量 `GetTextSize` + 的 h 1:1)**(`text_metrics.gd` 仍第 30 个 canonical、扩非新增:加 `const FALLBACK_LINE_H_PX + := 15.0` + `height_of`(空串 → 0;量得正高用 `text_size(...).y`;否则回退)+ `label_height` + (`label.font ?? ThemeDB.fallback_font`);`ground_items._arrange_tags` 的框高 `h` 由硬编码 + `TAG_LINE_H_PX` 换 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`,`TAG_LINE_H_PX + = 15.0` 留作无字体兜底;`text_tail_arrange.gd`(`box_end_y(h) = h`)/ `name_tail_layout.gd` + 排布公式不动,测试扩 `text_metrics_test.gd`,回归 70 全 PASS,见本节参考的增量 124 条); + **增量 125 已完成 seam ⑩ 末尾(带 owner 的掉落名条去重叠框:owner 独立度量 + `yEnd += + yOwnerSize + 4` 1:1)**(`text_tail_arrange.gd` 扩第 29 个 canonical:加纯静态 + `item_box_with_owner(name_w, name_h, owner_w:=0.0, owner_h:=0.0)` —— 无 owner → 纯名字框, + 有 owner → `{maxf(name_w, owner_w-2.0), name_h+owner_h+4.0}`(`box_end_x(w)` 恰等于参考 + `SetItemTextTailOwner` :737 的 `fMAX(name/2+2, owner/2+1)`);`text_metrics.gd` 扩第 30 个 + canonical:加 `string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串); + `ground_items._on_added` 存 `item_name` / `item_owner` meta、`_arrange_tags` 把名字子串与 + owner + `'s` 子串分别度量后走 `item_box_with_owner`;渲染仍单个合并 `"name (owner)"` + `Label3D`(seam ⑩ 折中);测试扩 `text_tail_arrange_test.gd` / `text_metrics_test.gd` / + `p2b_test.gd`,回归 70 全 PASS,见本节参考的增量 125 条); + **增量 126 已完成 seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + `strOwnership` + 接本地化语素)**(`ground_items`:drop 节点下第二个 `Label3D "owner_tag"`(CENTER / + `modulate = Color(1,1,0)` / 文本 = `owner + _possessive`,`_apply_owner_tag` 建 / 删), + 名字 `tag` 只放名字(`_tag_text` 删);`const OWNER_NAME_GAP_PX := 15.0` + `_place_tail`: + 有 owner → owner 落 `TAG_BASE_Y - d_px*px`、名字落 `TAG_BASE_Y - (d_px + 15)*px`(参考 + `ArrangeTextTail` :195 / :200);`setup()` 加可选 `possessive`(`== "" ? OWNER_POSSESSIVE`, + 对齐 `SetItemTextTailOwner` :722),`game_scene` 从 `EterLocale` `POSSESSIVE_MORPHENE` 键注入 + (无则回落 `"'s"`);去重叠框度量仍是增量 125 的 `item_box_with_owner`;测试扩 `p2b_test.gd`, + 回归 70 全 PASS,见本节参考的增量 126 条); + `text_tail.gd` 的屏幕空间血条整合已由增量 127 收口;掉落 owner 行品阶色也已由增量 127 + 接入。当前仍待:三子标签并到名字同一屏幕行 / 接公会徽记 mark(seam ⑦ 末尾)、名字与气泡 + 合成单尾标链表(seam ⑨),以及真实服务器 / 真机验收。建筑 / 木门分类和独立 whisper dialog + 也已由增量 127 接入,不再列为未实现。 4. `InsultChecker` 若目标服务端不需要,可标 ❌,但要在 `CLIENT-GAP.md` 明示。 --- @@ -8186,3 +8809,1339 @@ dx² + dy² > s_fLimitDistance → 不播 或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动; 或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。 ``` + +### C.5 批次记录 · 增量 114(W3→M2 / §8.8 · §5.4 头顶伤害飘字 AddDamageEffect / ProcessDamage 1:1) + +```text +批次:增量 114 —— W3→M2 §8.8 / §5.4:CInstanceBase::AddDamageEffect + ProcessDamage 的分类 + (DODGE|BLOCK → MISS、self → "damage_"、nontarget → 不渲染、target → "target_")+ 数字逐位 + 拆分 + CPythonSystem::IsShowDamage / SetShowDamageFlag 门(纯 GDScript,无 C++ 改动) +状态:✅ 完成(damage_effect.gd 纯静态谓词 + net_world._on_damage 改为 AddDamageEffect 语义 + 入队 / _process_damage_queue 每帧 pop 一条 ProcessDamage;uigameoption「伤害数字」radio + 经 game_option_ui.display_option_changed → net_world.set_show_damage) + 🔎 真机目视:MISS / 数字飘字位置与升起、nontarget 不显、self 交替、IsShowDamage 关时无飘字。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,UserInterface/*.cpp + ISO-8859 编码,grep 需 -a): + UserInterface/InstanceBase.h:300 enum DamageFlag { NORMAL 1<<0, POISON 1<<1, DODGE 1<<2, + BLOCK 1<<3, PENETRATE 1<<4, CRITICAL 1<<5 } + UserInterface/Packet.h:1943 TPacketGCDamageInfo { BYTE header; DWORD dwVID; BYTE flag; int damage; } + UserInterface/PythonNetworkStream.cpp:178 Set(HEADER_GC_DAMAGE_INFO, STATIC_SIZE_PACKET) + UserInterface/PythonNetworkStreamPhaseGame.cpp:396 case HEADER_GC_DAMAGE_INFO -> RecvDamageInfoPacket() + UserInterface/PythonNetworkStreamPhaseGame.cpp:2401 RecvDamageInfoPacket: + pInstTarget = GetInstancePtr(dwVID);bSelf = (pInstTarget == GetMainInstancePtr()); + bTarget = (pInstTarget == m_pInstTarget);if (damage >= 0) AddDamageEffect(damage, flag, bSelf, bTarget) + else TraceError("Damage is equal or below 0.") + UserInterface/InstanceBaseEffect.cpp:87 AddDamageEffect:if (CPythonSystem::IsShowDamage()) + { m_DamageQueue.push_back({bSelf, bTarget, damage, flag}); } + UserInterface/InstanceBase.cpp:1910 ProcessDamage()(每帧 Update 调一次) + UserInterface/InstanceBaseEffect.cpp:100 ProcessDamage:if (m_DamageQueue.empty()) return; + sDamage = m_DamageQueue.front(); m_DamageQueue.pop_front();(每帧仅一条) + (flag & DODGE) || (flag & BLOCK) -> bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS; return; + (flag & CRITICAL) -> CreateEffect 被注释掉(该 build 无独立暴击视觉),落普通数字 + POISON 分支整段注释掉 + bSelf -> strDamageType="damage_";m_bDamageEffectType==0 ? EFFECT_DAMAGE_SELFDAMAGE : + EFFECT_DAMAGE_SELFDAMAGE2;m_bDamageEffectType = !m_bDamageEffectType + bTarget==false -> strDamageType="nontarget_";EFFECT_DAMAGE_NOT_TARGET;return;(不渲染) + else -> strDamageType="target_";EFFECT_DAMAGE_TARGET + while (damage > 0) { if (index > 7) { TraceError; break; } num = damage % 10; damage /= 10; + textures.push_back("d:/ymir work/effect/affect/damagevalue/"+strDamageType+num+".dds"); + matTrans._41 = -(FONT_WIDTH(30) * index)(相机右方向逐位铺开); CreateEffect(...); index++; } + UserInterface/PythonSystem.cpp:308 m_Config.bShowDamage = true(默认) + UserInterface/PythonSystem.cpp:337 IsShowDamage() -> m_Config.bShowDamage + UserInterface/PythonSystem.cpp:342 SetShowDamageFlag(iFlag) -> bShowDamage = (iFlag == 1) + UserInterface/PythonSystem.cpp:451 config "SHOW_DAMAGE" -> atoi(value) == 1 + UserInterface/PythonSystem.cpp:537 save "SHOW_DAMAGE %d"(仅当 == 0,即非默认时落盘) + UserInterface/PythonSystemModule.cpp:192/219 systemSetShowDamageFlag / systemIsShowDamage + seam ⑪:参考端每位数字是一张 damagevalue/.dds 贴图的 CEffectManager 特效,按 + FONT_WIDTH*index 在相机右方向铺开;本移植 net_world 用一块 billboard Label3D 写 str(amount) + (无 damagevalue dds 图集 / 无逐位特效 / 无相机右向像素铺排),MISS 写 "MISS"。self 的 + SELFDAMAGE/SELFDAMAGE2 交替退化成落字水平抖动符号交替(无双特效资源)。暴击在该 40250 build + 参考端本就无独立视觉,本移植也不再放大 / 变色(相较增量 ≤112 的占位实现是回归对齐)。 + 另:参考 m_DamageQueue 是「每实例一条」,ProcessDamage 每帧每实例 pop 一条;本移植是 + net_world 一条全局队列每帧 pop 一条 —— 同一帧多个 VID 同时受击会分帧显示。 +改动: + project/damage_effect.gd(新)—— 纯静态 `extends RefCounted`: + DAMAGE_NORMAL/POISON/DODGE/BLOCK/PENETRATE/CRITICAL = 1<<0..1<<5(DamageFlag); + SHOW_DAMAGE_DEFAULT = true / CFG_KEY = "SHOW_DAMAGE" / FONT_WIDTH = 30 / MAX_DIGITS = 8; + enum Kind { MISS, DIGITS }; + should_queue(show_damage) = show_damage(AddDamageEffect 入队门); + show_damage_from_flag(flag) = flag == 1(SetShowDamageFlag); + show_damage_from_cfg(value) = int(value) == 1(配置行 atoi == 1); + is_self(vid, main_vid) / is_target(vid, target_vid)(RecvDamageInfoPacket 的 bSelf / bTarget); + valid_amount(amount) = amount >= 0(damage < 0 记 TraceError 不处理); + classify(flag, b_self, b_target) -> {kind, type("damage_"/"target_"/"nontarget_"), show, self} + —— DODGE|BLOCK → MISS(type 按 b_self);否则 b_self → DIGITS damage_ show; + 非 b_self 且非 b_target → DIGITS nontarget_ show=false(ProcessDamage 里 return); + 否则 → DIGITS target_ show;CRITICAL / POISON 均落普通数字(对应分支参考端注释掉); + digits(damage) —— while(damage>0) num=damage%10; damage/=10; 低位在前、最多 8 位(index>7 break), + damage <= 0 产出 []; + digit_offset_px(index) = FONT_WIDTH * index; + self_damage_slot(toggle_state) —— false → SELFDAMAGE(0)、true → SELFDAMAGE2(1)。 + project/net_world.gd —— const DamageEffect;删本地 const DAMAGE_DODGE / DAMAGE_CRITICAL + (改引用 DamageEffect.DAMAGE_*);新增 var _show_damage(= SHOW_DAMAGE_DEFAULT) / + _damage_queue(Array,= m_DamageQueue) / _dmg_self_toggle(= m_bDamageEffectType); + 新增 set_show_damage(on)(uigameoption「伤害数字」radio); + _on_damage(vid, amount, flag) 改写 —— 仍恒发 damage_number 信号(本移植扩展,供战斗日志 / + 测试);然后 valid_amount 门(< 0 return)+ should_queue(_show_damage) 门(!IsShowDamage return) + + is_self(_main_vid) / is_target(_target_vid) → _damage_queue.append({vid, amount, flag, self, target}); + 新增 _process_damage_queue()(_process 内 _update_visibility() 后每帧调一次)—— + pop_front 一条 → 节点存在校验 → DamageEffect.classify → !show(nontarget)return → + MISS 写 "MISS" / DIGITS 写 str(amount)(digits() 为空则跳过)→ self 数字按 self_damage_slot + 交替水平抖动符号(0.3 / -0.3)并翻转 _dmg_self_toggle → 生成 billboard Label3D(白、font_size 32、 + no_depth_test、pixel_size 0.007)在 y+1.9 处,tween 上升 1.2 / 0.8s + 淡出 → queue_free; + clear_for_map_change() 增 _damage_queue.clear()。 + project/ui/game_option_ui.gd —— 无改动(增量 113 的 display_option_changed 信号 + display_value + 已覆盖 show_damage key,_display 默认表已含 show_damage 默认 1)。 + project/game_scene.gd —— game_option_ui.setup 后用 display_value("show_damage") == 1 开局 + net_world.set_show_damage 回填;_on_display_option 增 elif key == "show_damage" → + net_world.set_show_damage(value == 1)。 + project/damage_effect_test.gd(新,第 25 个 canonical)—— SceneTree 回归,约 50 断言: + 常量(DamageFlag 位值 / 默认 true / "SHOW_DAMAGE" / FONT_WIDTH 30 / MAX_DIGITS 8)、 + IsShowDamage 门(should_queue / from_flag / from_cfg 严格 == 1)、bSelf / bTarget、 + valid_amount(0 T / -1 F)、classify(DODGE / BLOCK → MISS,普通 self/target/nontarget, + CRITICAL 仍普通数字、nontarget 不渲染,POISON 走普通)、digits(0 → []、-5 → []、123 → + [3,2,1]、90 → [0,9]、2000000 → 7 位、9 位截 8 位)、digit_offset_px(0/30/90)、 + self_damage_slot(false → 0、true → 1)。 +测试: + /opt/homebrew/bin/godot --headless --path project --script damage_effect_test.gd —— PASS。 + 回归:project/*_test.gd 全 65 个全 PASS(含 damage_effect_test / name_show_test / chat_tail_test / + text_tail_test / name_color_test / netbridge_test(damage_number 信号仍恒发)/ gamescene_test / + netplay_test / net_world_vis_test / …)。 +验收: + 已验证(离线):DamageFlag 位值、IsShowDamage 默认 true、SetShowDamageFlag / "SHOW_DAMAGE" + 配置行的 == 1 语义、RecvDamageInfoPacket 的 damage >= 0 门与 bSelf / bTarget 判定、 + ProcessDamage 的 DODGE|BLOCK → MISS、nontarget 数字不渲染、self "damage_" 交替、 + target "target_"、CRITICAL / POISON 落普通数字、数字逐位低位在前 + 8 位上限、 + FONT_WIDTH*index 偏移、game_option_ui radio → display_option_changed → set_show_damage、 + _process_damage_queue 每帧 pop 一条、全 65 回归。 + 未验证:真机 —— GC_DAMAGE_INFO 真包驱动下 MISS / 数字飘字的位置 / 升起 / 淡出目视、 + nontarget(非 self 非目标)实体确实不显、self 连击时 SELFDAMAGE / SELFDAMAGE2 交替的观感、 + 「伤害数字」radio 关闭后完全无飘字、同帧多 VID 受击的分帧显示是否可接受、 + damagevalue/.dds 逐位特效与相机右向铺排(seam ⑪,未做)。 +风险与依赖: + seam ⑪(见上「参考」末):单块 Label3D 代替 damagevalue dds 逐位特效 / 相机右向像素铺排、 + self 交替退化为水平抖动符号、暴击无独立视觉(对齐参考)、全局单队列每帧一条 vs 参考 + 每实例一队列。 + 行为变化:此前 _on_damage 无条件生成飘字(MISS 仅看 DODGE、暴击金色放大、随机水平抖动、 + 所有实体都显);现按 ProcessDamage:BLOCK 也算 MISS、非 self 非目标实体的数字伤害不再 + 显示、暴击不再金色放大、飘字受 IsShowDamage()(uigameoption「伤害数字」radio)门控、 + 每帧只弹一条(队列)。damage_number 信号维持无条件发出(本移植扩展,供 HUD 战斗日志 / + netbridge_test;参考端 AddDamageEffect 整体在 IsShowDamage() 内)。 + _show_damage 由 game_option_ui 持久化到 user://system_option.cfg [gameopt] show_damage + (默认 1);参考端 PythonSystem 仅在 == 0(非默认)时才写 SHOW_DAMAGE 行,本移植 ConfigFile + 恒写该键 —— 语义等价,落盘格式差异(非 1:1 关键路径,与增量 113 的 ALWAYS_VIEW_NAME 同)。 + bSelf 用 net_world._main_vid、bTarget 用 _target_vid(增量 113 由 net_play.target_changed 灌入); + 离线 / FakeClient 若 _main_vid 未设,主角伤害会落 nontarget_ 分支而不显(与参考「无主实例」 + 行为一致)。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条,对齐参考 + CPythonCharacterManager 的 RenderCharacterName / bar 排布);chat 尾标 bNameFlag 下移名字 + 17px(seam ⑨ 收口); + 或 seam ⑩ 收口:悬停 chr.Pick() 强显(net_world 射线拾取 _ray_pick_t 接名字强显)+ + ArrangeTextTail 屏幕空间防重叠; + 或 §8.4 名字色 radio(name_color_normal / name_color_empire)接到 name_color.gd 帝国名色模式; + 或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动; + 或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。 +``` + +### C.5 批次记录 · 增量 115(W3→M2 / §5.4 · §8.8 目标框 RecvTargetPacket 收包分派 1:1) + +```text +批次:增量 115 —— W3→M2 §5.4 / §8.8:CPythonNetworkStream::RecvTargetPacket 的目标框判定树 + (实例缺失 → CloseTargetBoard、目标已死透 → 不动框、PC|Building → CloseTargetBoardIfDifferent、 + CanViewTargetHP → SetHPTargetBoard、否则 → CloseTargetBoard)+ CInstanceBase::CanViewTargetHP + (IsStone()||IsWoodenDoor()||IsEnemy())+ game.py 的 SetHPTargetBoard 换 VID 先 Reset / + CloseTargetBoardIfDifferent / SetPCTargetBoard LCONTROL 悄悄话捷径(纯 GDScript,无 C++ 改动) +状态:✅ 完成(target_board.gd 纯静态谓词 + net_play._on_target_info 改为按判定树分派目标框) + 🔎 真机目视:选 PC 目标无血条框、选 NPC / warp 不弹框、选石头 / 怪弹血条框、换目标先清旧框、 + 目标死亡 / despawn → 清框。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,UserInterface/*.cpp + ISO-8859 编码,grep 需 -a): + UserInterface/Packet.h:1937 TPacketGCTarget { BYTE header; DWORD dwVID; BYTE bHPPercent; } + UserInterface/PythonNetworkStream.cpp Set(HEADER_GC_TARGET, sizeof(TPacketGCTarget))(STATIC_SIZE_PACKET) + UserInterface/PythonNetworkStreamPhaseGame.cpp case HEADER_GC_TARGET -> RecvTargetPacket() + UserInterface/PythonNetworkStreamPhaseGame.cpp:2424 RecvTargetPacket(): + pInstPlayer = GetMainInstancePtr();pInstTarget = GetInstancePtr(TargetPacket.dwVID) + :2436 if (pInstPlayer && pInstTarget) + :2438 if (!pInstTarget->IsDead()) + :2440 if (pInstTarget->IsPC() || pInstTarget->IsBuilding()) + PyCall(GAME, "CloseTargetBoardIfDifferent", (i)dwVID) + :2442 else if (pInstPlayer->CanViewTargetHP(*pInstTarget)) + PyCall(GAME, "SetHPTargetBoard", (ii)dwVID, bHPPercent) + :2444 else PyCall(GAME, "CloseTargetBoard", ()) + :2447 m_pInstTarget = pInstTarget (仅在 !IsDead() 块内 —— 目标存在且未死才写) + :2453 else PyCall(GAME, "CloseTargetBoard", ()) (pInstPlayer/pInstTarget 有一个缺失) + UserInterface/InstanceBase.cpp:2293 CanViewTargetHP(victim): + if (victim.IsStone()) return true; + if (victim.IsWoodenDoor()) return true; + if (victim.IsEnemy()) return true; + return false; + UserInterface/InstanceBase.cpp:2310/2320/2325 IsPC() / IsEnemy() / IsStone()(→ GraphicThingInstance) + UserInterface/InstanceBase.cpp:2380 IsBuilding()(→ GraphicThingInstance.IsBuilding()) + UserInterface/InstanceBase.cpp:2390 IsWoodenDoor():IsDoor() 且 vnum == 13000 或 30111..30119 + root/game.py:804 SetPCTargetBoard(vid, name):self.targetBoard.Open(vid, name); + if app.IsPressed(app.DIK_LCONTROL): if not player.IsSameEmpire(vid): return; + if player.IsMainCharacterIndex(vid): return; + elif chr.INSTANCE_TYPE_BUILDING == chr.GetInstanceType(vid): return; + self.interface.OpenWhisperDialog(name) + root/game.py:829 SetHPTargetBoard(vid, hpPct):if vid != targetBoard.GetTargetVID(): + targetBoard.ResetTargetBoard(); targetBoard.SetEnemyVID(vid); 然后 SetHP(hpPct) + Show() + root/game.py:837 CloseTargetBoardIfDifferent(vid):if vid != targetBoard.GetTargetVID(): Close() + root/game.py:841 CloseTargetBoard():targetBoard.Close() + seam ⑫:本 poc 无 building / wooden-door / stone-door 实体类型(_entity_kind 只到 + 0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP),故 is_building 恒 false、can_view 的 + is_wooden_door 恒 false;「可看血」= 石头(KIND_STONE) 或 敌对怪(KIND_MONSTER)。木门 / 攻城门 + 做出来后再补 IsWoodenDoor / IsBuilding 分类。 + 另:poc 的 target_info(vid, hp%) 由 C++ 侧 extension/src/net/m2_client.cpp:3129/3633 从 + EntityStore 抽出(w.target_vid() / w.target_hp_pct()),本移植只镜像 GDScript 侧对 + (vid, hp%) 的分派,不改 C++ 收包(对齐「纯 GDScript」约束)。 + LCONTROL 悄悄话捷径(SetPCTargetBoard 里按住 LCONTROL 对同阵营 PC 开 OpenWhisperDialog): + poc 目标框点击无 whisper dialog 钩子 → whisper_on_ctrl_click 谓词已备好,net_play 暂不接。 +改动: + project/target_board.gd(新,第 26 个 canonical)—— 纯静态 `extends RefCounted`: + enum Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE }; + can_view_target_hp(is_stone, is_wooden_door, is_enemy) = 三者任一(CanViewTargetHP); + classify(target_exists, is_dead, is_pc, is_building, can_view_hp) -> Action + —— !target_exists → CLOSE(:2453);is_dead → NONE(:2438 整块跳过); + is_pc || is_building → CLOSE_IF_DIFFERENT(:2440);can_view_hp → SET_HP(:2442); + else → CLOSE(:2444); + updates_current_target(target_exists, is_dead) = target_exists and not is_dead + (m_pInstTarget = pInstTarget 在 :2447,仅 !IsDead() 块内); + needs_reset(new_vid, cur_vid) = new_vid != cur_vid(SetHPTargetBoard 换 VID 先 Reset); + should_close_if_different(vid, cur_vid) = vid != cur_vid(CloseTargetBoardIfDifferent); + whisper_on_ctrl_click(lcontrol_down, same_empire, is_main, is_building) + = lcontrol_down and same_empire and not is_main and not is_building(SetPCTargetBoard 捷径); + clamp_hp_pct(v) = clampi(int(v), 0, 100)(bHPPercent 是 BYTE)。 + project/net_play.gd —— const TargetBoard = preload("res://target_board.gd"); + _on_target_info(vid, hp_pct) 改写 —— 由「任意有名字 VID 都 hud.set_target」改为按判定树: + e = client.get_entity(vid) if vid != 0 else {};exists = vid != 0 and not e.is_empty(); + dead = exists and e.dead;kind = _entity_kind(e)(-1 若不存在); + is_pc = kind == KIND_PC;is_building = false(seam ⑫); + can_view = can_view_target_hp(kind == KIND_STONE, false, kind == KIND_MONSTER); + match classify(exists, dead, is_pc, is_building, can_view): + CLOSE → _clear_target()(内部 _target_vid==0 早退 + target_changed.emit(0) + hud.clear_target); + CLOSE_IF_DIFFERENT → should_close_if_different(vid, _target_vid) 才 _clear_target(); + SET_HP → needs_reset(vid, _target_vid) 时 _target_vid = vid + target_changed.emit(vid); + 再 hud.set_target(_entity_name(e) or "目标", clamp_hp_pct(hp_pct)); + NONE → pass(目标已死透,不动框)。 + project/target_board_test.gd(新,第 26 个 canonical)—— SceneTree 回归,约 35 断言: + can_view_target_hp(Stone / WoodenDoor / Enemy 各自 T、皆非 F、任一 T)、 + classify(实例缺失 → CLOSE 且优先、已死 → NONE 且优先于 PC 分支、PC → CLOSE_IF_DIFFERENT、 + Building → CLOSE_IF_DIFFERENT、PC 即便可看血也走 CLOSE_IF_DIFFERENT、非 PC 可看血 → SET_HP、 + NPC/warp 不可看血 → CLOSE)、updates_current_target(存在且未死 T、已死 F、缺失 F)、 + needs_reset(无目标 T / 换目标 T / 同目标 F)、should_close_if_different(异 VID T / 同 VID F / + 无 VID T)、whisper_on_ctrl_click(全真 T、缺 LCONTROL / !同阵营 / 本人 / building 各 F)、 + clamp_hp_pct(40 / 0 / 100 原样、负 → 0、>100 → 100、float 37.9 → 37)。 +测试: + /opt/homebrew/bin/godot --headless --path project --script target_board_test.gd —— PASS。 + 回归:project/*_test.gd 全 66 个全 PASS(新增 target_board_test;含 netplay_test(target_info → + hud.set_target(pct=40),实体 2000 ch_type=2 走 SET_HP)/ netbridge_test / gamescene_test / + combat_fx_test / net_world_vis_test / damage_effect_test / name_show_test / …)。 +验收: + 已验证(离线):CanViewTargetHP 三谓词、RecvTargetPacket 判定树全 5 分支 + 优先级 + (缺失 > 已死 > PC|Building > 可看血 > 关框)、m_pInstTarget 写入条件、SetHPTargetBoard + 换 VID 先 Reset、CloseTargetBoardIfDifferent 的 != 语义、SetPCTargetBoard LCONTROL 捷径 + 四个早退、bHPPercent 钳位、net_play._on_target_info 经 target_board.gd 分派、全 66 回归。 + 未验证:真机 —— GC_TARGET 真包驱动下选 PC 目标不弹血条框(只在换 VID 时清旧框)、 + 选 NPC / warp 不弹框、选石头 / 敌对怪弹血条框、换目标先清旧框的观感、 + 目标已死透时框保持不变、LCONTROL 悄悄话捷径(seam ⑫,未接)、 + 木门 / 攻城门作为可看血目标(seam ⑫,poc 无该实体类型)。 +风险与依赖: + seam ⑫(见上「参考」末):poc 无 building / wooden-door 实体 → is_building 恒 false、 + can_view 的 is_wooden_door 恒 false;LCONTROL 悄悄话捷径未接(无 whisper dialog 钩子); + target_info 由 C++ EntityStore 侧抽出,不改 C++ 收包。 + 行为变化:此前 _on_target_info 对任意 vid != 0 且有名字的目标都 hud.set_target(包括 NPC); + 现按 RecvTargetPacket 判定树 —— PC / building 目标不再显血条框(只在框上是别的 VID 时清旧框)、 + NPC / warp 等不可看血目标一律关框、目标已死透时不动框、hp% 钳到 0..100。 + _change_target_to_picked_instance(点击选目标,PythonPlayerInput 路径)不在本批次范围内, + 维持原逻辑(KIND_PC / KIND_MONSTER 可选中并 _refresh_target_hud)—— 点击 PC 仍会 set_target + 并刷 HUD 目标名,与参考端 SetPCTargetBoard(Open + 名字)大方向一致,血条部分待 GC_TARGET + 真包到达再由本批次的 _on_target_info 收敛。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条,对齐参考 + CPythonCharacterManager 的 RenderCharacterName / bar 排布);chat 尾标 bNameFlag 下移名字 + 17px(seam ⑨ 收口); + 或 seam ⑩ 收口:悬停 chr.Pick() 强显(net_world 射线拾取 _ray_pick_t 接名字强显)+ + ArrangeTextTail 屏幕空间防重叠; + 或 seam ⑫ 收口:木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击 + LCONTROL 悄悄话捷径接 whisper dialog; + 或 §8.4 名字色 radio(name_color_normal / name_color_empire)接到 name_color.gd 帝国名色模式; + 或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。 +``` + +### C.5 批次记录 · 增量 116(W3→M2 / §8.8 · §5.4 OnRender Picking 块分派 + 悬停角色强显名字 1:1) + +```text +批次:增量 116 —— W3→M2 §8.8 / §5.4:game.py GameWindow.OnRender() 的「Picking」块判定树 + (wndMgr.IsPickedWindow 门 → chr.Pick() 命中角色 ShowCharacterTextTail、targetBoard.GetTargetVID() + ShowCharacterTextTail、__IsShowName() 分叉:非总显名字 → item.Pick(),总显名字 → ShowAllTextTail + + textTail.Pick(),末尾 SelectItemName)抽成纯静态谓词;并把「悬停角色强显名字」按 :1535/:1537 + 接进 poc —— 仿增量 113 的 _target_vid forced 路数,给 net_world._name_shown 加第 5 路 forced + _hover_vid,由 player_controller 的射线悬停拾取喂入(纯 GDScript,无 C++ 改动) +状态:✅ 完成(pick_show.gd 纯静态谓词 + net_world._hover_vid / set_hover_vid + _name_shown forced + 第 5 路 + player_controller.hover_entity_changed 信号 + game_scene 连线) + 🔎 真机目视:鼠标悬停某 NPC / 怪 → 其头顶名字立即强显(即便未开「总显名字」且超 3500 距)、 + 移开 → 复原;鼠标压 UI / 悬停地面物品时不强显任何角色名。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/;运行时 python = + mtgodot-poc/assets/root/game.py): + root/game.py:1519 GameWindow.OnRender() + root/game.py:1531 textTail.UpdateAllTextTail() + root/game.py:1533 if True == wndMgr.IsPickedWindow(self.hWnd) —— 鼠标在 3D game 窗口上才拾取 + root/game.py:1535 self.PickingCharacterIndex = chr.Pick() —— 射线拾取角色(-1 = 无) + root/game.py:1537 if -1 != PickingCharacterIndex: textTail.ShowCharacterTextTail(idx) + root/game.py:1539 if 0 != targetBoard.GetTargetVID(): textTail.ShowCharacterTextTail(targetVID) + root/game.py:1543 if not self.__IsShowName(): PickingItemIndex = item.Pick() + root/game.py:1546 if -1 != PickingItemIndex: textTail.ShowItemTextTail(idx) + root/game.py:1552 if self.__IsShowName(): textTail.ShowAllTextTail() + root/game.py:1554 self.PickingItemIndex = textTail.Pick(x, y) + root/game.py:1557 textTail.UpdateShowingTextTail() + root/game.py:1558 textTail.ArrangeTextTail() —— 屏幕空间去重叠排布 + root/game.py:1560 if -1 != PickingItemIndex: textTail.SelectItemName(idx) + seam ⑨:名字尾标 y = 聊天气泡 y − 17px(UserInterface/PythonTextTail.cpp:112-114)尚未接入 — + poc 名字牌是每实体一个 Label3D,暂无「随 chat 气泡下移」的整合。 + seam ⑩:ArrangeTextTail 的屏幕空间尾标去重叠排布(UserInterface/PythonTextTail.cpp ArrangeTextTail) + 仍缺 —— poc 名字牌是 3D billboard,靠深度天然分层,未做 2D 投影后的挤开。本增量只收口其中 + 一小块:把「悬停角色强显名字」按 :1535/:1537 补上(forced 第 5 路)。 + seam(近似):参考里 chr.Pick() 强显与 targetBoard 强显都在 :1533 的 IsPickedWindow 门内; + poc 把 _target_vid(增量 113)与 _hover_vid(本增量)都当作「无条件 forced」路径 — + _hover_vid 在鼠标压 UI / 悬停地面物品时由 player_controller 主动置 0,算是部分等效于 + IsPickedWindow 门。 +改动: + project/pick_show.gd(新,第 27 个 canonical)—— 纯静态 `extends RefCounted`: + const NO_PICK := -1(chr.Pick() / item.Pick() 无命中哨兵); + char_pick_active(is_picked_window) = is_picked_window(:1533); + forced_name_vids(is_picked_window, pick_char_vid, target_vid) -> Array + —— 非窗口 → [];否则收 pick_char_vid(!= NO_PICK 且 != 0,:1537)与 target_vid(!= 0,:1539), + 去重,顺序 [hover, target]; + should_pick_item(is_picked_window, is_show_name) = is_picked_window and not is_show_name(:1543); + should_show_all_names(is_show_name) = is_show_name(:1552); + should_pick_text_tail(is_show_name) = is_show_name(:1554); + should_select_item_name(pick_item_index) = pick_item_index != NO_PICK(:1560)。 + project/net_world.gd —— const PickShow = preload("res://pick_show.gd"); + var _hover_vid := 0; + func set_hover_vid(vid)(仿 set_target_vid:值变才刷 prev / vid 两个节点的名字可见性); + _name_shown():forced := vid != 0 and (vid == _target_vid or vid == _hover_vid)(原只 _target_vid)。 + project/player_controller.gd —— signal hover_entity_changed(vid: int);var _hover_vid := 0; + _update_hover_cursor() 三个出口都喂 _set_hover_vid():鼠标压 UI → 0、悬停地面物品 → 0、 + 射线命中 pickable → int(best.get_meta("vid", 0))、无命中 → 0; + _set_hover_vid(vid):值变才 hover_entity_changed.emit(vid)(去抖)。 + project/game_scene.gd —— 紧挨 net_play.target_changed → net_world.set_target_vid 那行, + 加 pc.hover_entity_changed → net_world.set_hover_vid 连线(has_signal / has_method 守卫)。 + project/pick_show_test.gd(新,第 27 个 canonical)—— SceneTree 回归,约 24 断言: + NO_PICK == -1、char_pick_active(窗口内 T / 外 F)、forced_name_vids(非窗口 → []、 + hover+target → [hover, target]、只 hover、只 target、hover=0 视为无、hover==target 去重、 + 皆无 → [])、should_pick_item(窗口且非总显名字 T / 总显名字 F / 非窗口 F)、 + should_show_all_names / should_pick_text_tail(总显名字 T / 否 F)、item.Pick 与 textTail.Pick + 在窗口内恰好二选一、should_select_item_name(7 T / 0 T / -1 F)。 +测试: + /opt/homebrew/bin/godot --headless --path project --script pick_show_test.gd —— PASS。 + 回归:project/*_test.gd 全 67 个全 PASS(新增 pick_show_test;含 net_world_vis_test / + netplay_test / netbridge_test / gamescene_test / combat_fx_test / target_board_test / + name_show_test / damage_effect_test / …,两次运行均 PASS=67 FAIL=0)。 +验收: + 已验证(离线):OnRender Picking 判定树全部谓词 —— IsPickedWindow 门、chr.Pick() 强显集合 + 收集 + 去重 + [hover, target] 顺序、非窗口时连 target 强显也不做、__IsShowName() 分叉 + (非总显名字 → item.Pick、总显名字 → ShowAllTextTail + textTail.Pick,二者互斥)、 + SelectItemName 门;net_world._hover_vid 值变刷新、_name_shown forced 第 5 路; + player_controller 三出口喂 _hover_vid + emit 去抖;game_scene 连线;全 67 回归。 + 未验证:真机 —— 悬停 NPC / 怪时头顶名字强显、移开复原、鼠标压 UI / 悬停地面物品不误强显、 + 与增量 113 目标强显并存无闪烁、悬停命中判定(_ray_pick_t 命中半径)与参考 chr.Pick() + 的观感差异、ArrangeTextTail 屏幕空间防重叠(seam ⑩,未做)、名字随 chat 气泡下移 17px + (seam ⑨,未做)。 +风险与依赖: + seam ⑨ / ⑩(见上「参考」):屏幕空间尾标 y 偏移与去重叠排布仍缺;本增量只收口 + 「悬停角色强显名字」一小块。 + seam(近似):_target_vid / _hover_vid 当作无条件 forced,非严格 IsPickedWindow 门内; + _hover_vid 靠 player_controller 在压 UI / 悬停物品时置 0 部分等效。 + 行为变化:此前只有 targetBoard 目标(增量 113)会 forced 强显头顶名字;现悬停到的角色 + (player_controller 射线拾取,pickables 带 vid meta)也 forced 强显 —— 即便未开「总显名字」 + 且超 3500 平面距。悬停命中判定沿用既有 _ray_pick_t(cursor 用的同一套),未新增射线开销。 + net_play._on_target_info(增量 115)不受影响。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条); + seam ⑨ 收口(名字尾标随 chat 气泡下移 17px,PythonTextTail.cpp:112-114); + seam ⑩ 深化(ArrangeTextTail:2D 投影后名字牌互相挤开防重叠); + 或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击 + LCONTROL 悄悄话捷径接 whisper dialog); + 或 §8.4 名字色 radio(name_color_normal / name_color_empire)接 name_color.gd 帝国名色模式。 +``` + +### C.5 批次记录 · 增量 117(W3→M2 / §8.8 seam ⑨ 收口:头顶名字尾标随聊天气泡上移 17px 1:1) + +```text +批次:增量 117 —— W3→M2 §8.8 seam ⑨ 收口:CPythonTextTail::UpdateShowingTextTail 里 + 每条角色名字尾标投影完屏幕坐标后,若该 VID 在 m_ChatTailMap 命中且那条的 bNameFlag + 为 TRUE(= RegisterChatTail 注册的「聊天」气泡,非 RegisterInfoTail 的「信息」气泡), + 就把名字尾标的屏幕 y 顶到 `chatTail.y − 17.0f`(屏幕 y 向下,减 17px = 上移一行贴气泡 + 上沿)。抽成纯静态谓词 + 公式,并接进 poc:有「聊天」气泡时把名字 Label3D 的局部 y + 顶到气泡 y 之上 17 个名字像素,气泡消失或只是「信息」气泡时回默认 y(纯 GDScript)。 +状态:✅ 完成(name_tail_layout.gd 纯静态第 28 个 canonical + net_world._reposition_name_tag + 由 _apply_chat_tail / _expire_chat_tails 调用 + NAME_TAG_DEFAULT_Y / BUBBLE_TAG_Y 常量) + 🔎 真机目视:某角色冒「聊天」气泡时其头顶名字上移贴到气泡上沿、气泡消失后名字回原位; + 「信息」尾标(技能 / 射击错误,淡红)不顶名字。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/): + UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f + UserInterface/PythonTextTail.cpp:509 RegisterCharacterTextTail(..., float fAddHeight=10.0f) + :519 fHeight = GetHeight() + fAddHeight —— 名字尾标世界高=角色高 + 10 + UserInterface/PythonTextTail.cpp:581 RegisterChatTail:新建 fHeight = GetHeight() + 10.0f、 + :603 / :618 pTextTail->bNameFlag = TRUE —— 「聊天」气泡(白) + UserInterface/PythonTextTail.cpp:623 RegisterInfoTail:同高, + :646 / :661 pTextTail->bNameFlag = FALSE —— 「信息」气泡(淡红),不顶名字 + UserInterface/PythonTextTail.cpp:88 UpdateShowingTextTail() + :101 for (角色名字尾标 : m_CharacterTextTailList) + :104 UpdateTextTail(pTextTail) —— 先投影出屏幕 x/y/z + :106 itor = m_ChatTailMap.find(pTextTail->dwVirtualID) + :110 if (m_ChatTailMap.end() != itor) + :112 if (pChatTail->bNameFlag) + :114 pTextTail->y = pChatTail->y - 17.0f —— 名字屏幕 y 顶到气泡上方 17px + UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:同一个 bNameFlag 还驱动 + ShowCharacterTextTail(dwVID) 强显(增量 112 / 113 已接 chat_tail.gd / name_show.gd)。 + seam ⑨(收窄):参考端名字与气泡默认投影到同一世界点(GetHeight()+10),靠这条屏幕 + −17px 把名字从「和气泡重叠」顶成「贴气泡上沿」;poc 是每实体一个 billboard Label3D、 + 气泡默认在名字上方(局部 y 2.35 vs 2.1),本增量把屏幕 y 向下的 −17px 换成世界 y + 向上的 +17px*pixel_size。剩余 seam ⑨:气泡与名字 Label3D 仍是两个并存节点(非参考 + 的单尾标链表)、子标签(GuildTag / TitleTag / LevelTag)不随动 = seam ⑦ 遗留。 +改动: + project/name_tail_layout.gd(新,第 28 个 canonical)—— 纯静态 `extends RefCounted`: + const NAME_OFFSET_ABOVE_BUBBLE_PX := 17.0(PythonTextTail.cpp:114 的 17.0f); + name_follows_bubble(has_chat_tail, chat_name_flag) = has_chat_tail and chat_name_flag + (:110 有尾标 && :112 bNameFlag); + name_tail_screen_y(default_y, bubble_y, follows) —— 参考端字面(屏幕 y 向下): + follows → bubble_y − 17,否则 default_y; + name_tail_world_y(default_world_y, bubble_world_y, follows, px_size) —— poc 3D 适配 + (世界 y 向上):follows → bubble_world_y + 17*px_size,否则 default_world_y。 + project/net_world.gd —— const NameTailLayout = preload("res://name_tail_layout.gd"); + const NAME_TAG_DEFAULT_Y := 2.1 / const BUBBLE_TAG_Y := 2.35(名字 / 气泡 Label3D 默认局部 y); + _attach_nameplate 用 NAME_TAG_DEFAULT_Y;_apply_chat_tail 用 BUBBLE_TAG_Y 建气泡节点、 + 末尾 _set_name_visibility(n) 后加 _reposition_name_tag(n); + _expire_chat_tails 删气泡节点后同样 _set_name_visibility(n) → _reposition_name_tag(n); + func _reposition_name_tag(n):取 n 的 "Label3D" 子节点,读 _chat_tails[vid].name_flag, + follows := NameTailLayout.name_follows_bubble(有尾标, name_flag), + tag.position.y = NameTailLayout.name_tail_world_y(NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, + follows, tag.pixel_size)。 + project/name_tail_layout_test.gd(新,第 28 个 canonical)—— SceneTree 回归: + 常量 17.0;name_follows_bubble(T/T→T、T/F→F、F/T→F、F/F→F); + name_tail_screen_y(follows → 250−17=233、!follows → 300、信息气泡 follows=false → 300); + name_tail_world_y(follows → 2.35+17*0.006=2.452、!follows → 2.1、结果 > 2.35、 + px_size 翻倍 → 偏移翻倍 2.35+0.204);组合链(聊天气泡 → 2.452、气泡过期 → 2.1)。 + project/chat_test.gd(改)—— 「气泡」段加断言:entity 2000 冒气泡后 Label3D.position.y + == bubble.position.y + 17*pixel_size 且 > bubble.position.y;再 register_info_tail(2000) + 覆盖成信息尾标 → 名字回默认局部 y 2.1。 +测试: + /opt/homebrew/bin/godot --headless --path project --script name_tail_layout_test.gd —— PASS。 + 回归:project/*_test.gd 全 68 个全 PASS(新增 name_tail_layout_test;chat_test 扩断言; + 含 net_world_vis_test / chat_tail_test / name_show_test / pick_show_test / …,PASS=68 FAIL=0)。 +验收: + 已验证(离线):name_follows_bubble 判据(有 chat 尾标 && bNameFlag)、屏幕端字面公式 + bubble_y − 17、poc 世界端公式 bubble_world_y + 17*pixel_size(含 pixel_size 线性缩放)、 + 信息尾标(bNameFlag FALSE)不顶名字、气泡过期名字回默认 y;net_world 端 _apply_chat_tail / + _expire_chat_tails 两处调用后名字 Label3D 局部 y 正确切换(chat_test 端到端);全 68 回归。 + 未验证:真机 —— 冒气泡时名字上移贴气泡上沿的观感、气泡消失名字回位无跳变、与增量 113 + 的 bNameFlag 强显并存、信息尾标(技能 / 射击错误)不顶名字、子标签不随动的视觉突兀度 + (seam ⑦ / ⑨ 遗留)、ArrangeTextTail 屏幕空间防重叠(seam ⑩,仍未做)。 +风险与依赖: + seam ⑨(收窄后剩余):气泡与名字仍是两个并存 Label3D 节点(非参考单尾标链表); + 子标签 GuildTag / TitleTag / LevelTag 不随名字上移 = seam ⑦ 遗留。 + seam ⑩:ArrangeTextTail 屏幕空间尾标去重叠排布仍缺(poc 3D billboard 靠深度天然分层)。 + 换算常量:17px → 世界单位按名字 Label3D 的 pixel_size(0.006)线性换算,与参考的 + 字体无关常量 17 对齐;气泡 Label3D 的 pixel_size(0.0055)不参与,只用其局部 y。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条); + seam ⑦ / ⑨ 深化(子标签随名字上移 / 名字与气泡合成单尾标链表); + seam ⑩ 深化(ArrangeTextTail:2D 投影后名字牌互相挤开防重叠); + 或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击 + LCONTROL 悄悄话捷径接 whisper dialog); + 或 §8.4 名字色 radio(name_color_normal / name_color_empire)接 name_color.gd 帝国名色模式。 +``` + +### C.5 批次记录 · 增量 118(W3→M2 / §5.4 · §8.8 · §8.4 目标框「看他国玩家」前置过滤 + 名字色 radio 接线 1:1) + +```text +批次:增量 118 —— W3→M2 §5.4 / §8.8 / §8.4: + (a) root/uitarget.py TargetBoard.Open(vid,name) 在整个 RecvTargetPacket 判定之前的 + 异阵营前置过滤(:189-193):game-option 关「看他国玩家目标框」 + (constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1 = 开)时,若目标与主角 + 不同阵营(player.IsSameEmpire(vid) 为假),直接 Hide() + return,不开目标框。 + 抽成纯静态谓词,接进 net_play._on_target_info 的 classify 之前。 + (b) §8.4「名字色」radio(name_color_normal / name_color_empire)接线收口: + game_scene._on_display_option 增两条分派 —— "name_color" → net_world.set_empire_name_mode、 + "target_board" → net_play.set_view_other_empire_target;开局各自用 + game_option_ui.display_value(...) == 1 回填。帝国名色调色板算法本体增量 110 已 1:1。 +状态:✅ 完成(target_board.hidden_as_other_empire 纯静态谓词 + net_play._view_other_empire_target / + set_view_other_empire_target / _on_target_info 前置过滤 + game_scene 两条分派与两处开局回填 + + game_option_ui.gd target_board 默认 0→1 修正) + 🔎 真机目视:game-option 关「看他国目标框」后点异阵营玩家不弹目标框、点同阵营 / 怪 / NPC 照旧; + 「名字色」切「阵营」后头顶名字按阵营配色(红 / 橙 / 蓝),切「普通」后回黄。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/;root/ = assets/root/): + root/uitarget.py:189-193 TargetBoard.Open(vid,name): + if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): + if not player.IsSameEmpire(vid): self.Hide(); return + root/constinfo.py:38 VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1(默认开) + root/constinfo.py:139-145 SET_ / GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index) + root/uigameoption.py target_board_view / target_board_no_view radio → + constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)(flag 值 == 按钮列表下标) + root/uigameoption.py __SetNameColorMode(index) → constInfo.SET_CHRNAME_COLOR_INDEX(index) + → chrmgr.SetEmpireNameMode(index);name_color_normal→0(全 PC 黄)、name_color_empire→1(阵营色) + root/constinfo.py CHRNAME_COLOR_INDEX = 0(默认普通);SET_DEFAULT_CHRNAME_COLOR() 载入时 + 调 SetEmpireNameMode(CHRNAME_COLOR_INDEX) + UserInterface/InstanceBase.cpp:470 CInstanceBase::IsSameEmpire —— victim.empire == 0 或 + 任一方 IsGameMaster() 或 两方 empire 相等 → true(entity_rules._is_same_empire 已 1:1) + UserInterface/InstanceBaseEffect.cpp:52 CInstanceBase::SetEmpireNameMode(bool) 调色板重写 + (增量 110 已迁 name_color.gd::set_empire_name_mode) + seam ⑫:poc 无独立「他国 PC 名字框」窗体(目标框只有 HP 条形态),本增量把同一道阵营门 + 加在 net_play._on_target_info 分派前;building / wooden-door 实体类型、LCONTROL 悄悄话 + 捷径仍未做(延续增量 115)。 +改动: + project/target_board.gd(改)—— 新增静态谓词: + hidden_as_other_empire(view_other_empire: bool, same_empire: bool) -> bool + = not view_other_empire and not same_empire(uitarget.py:189-193 字面); + 头部 REF 段补 root/uitarget.py:189-193 前置过滤条目。 + project/net_play.gd(改)—— var _view_other_empire_target := true + (constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD 默认 1); + func set_view_other_empire_target(on: bool)(§8.4 radio 驱动); + _on_target_info:在 is_building 之后、can_view 之前插前置过滤 —— if exists: + var same_empire := EntityRules._is_same_empire(_main_entity(), e) + if TargetBoard.hidden_as_other_empire(_view_other_empire_target, same_empire): + _clear_target(); return + project/ui/game_option_ui.gd(改)—— setup() 默认值列表 line 95: + "target_board" 并入默认 1 的键组(对齐 VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1; + 原来漏了 → 默认落到 0,与参考相反)。radio 接线本就走通用 RADIO_GROUPS / + DISPLAY_RADIOS,无需再动。 + project/game_scene.gd(改)—— 开局在 set_show_damage 回填之后加两段: + net_world.set_empire_name_mode(game_option_ui.display_value("name_color") == 1)、 + net_play.set_view_other_empire_target(game_option_ui.display_value("target_board") == 1); + _on_display_option 加两条 elif —— key == "name_color" → net_world.set_empire_name_mode(value == 1)、 + key == "target_board" → net_play.set_view_other_empire_target(value == 1)。 + project/target_board_test.gd(改)—— 新增 5b 段:hidden_as_other_empire + (关+异阵营→true、关+同阵营→false、开+异阵营→false、开+同阵营→false)。 + project/netplay_test.gd(改)—— 新增 8b 段:主角 empire 1 + 异阵营 PC(vid 2500,empire 2); + 看他国框开 → 异阵营 PC 目标框保留(CLOSE_IF_DIFFERENT,同 VID 不关); + 看他国框关 → 前置过滤直接 _clear_target()(_target_vid 归 0、hud.cleared +1); + 看他国框关不影响怪(empire 0 → IsSameEmpire 真 → HP 血条照设);收尾还原开关 + 清 empire 键。 + project/system_menu_ui_test.gd(改)—— GameOptionUI 段加断言: + name_color_empire 按下 → _display.name_color == 1 + display_option_changed("name_color",1); + target_board 默认读作 1;target_board_no_view 按下 → _display.target_board == 0 + + display_option_changed("target_board",0);二次 ConfigFile 读 user://system_option.cfg + [gameopt] name_color == 1 / target_board == 0 已持久化。 +测试: + /opt/homebrew/bin/godot --headless --path project --script target_board_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script system_menu_ui_test.gd —— PASS。 + 回归:project/*_test.gd 全 68 个全 PASS(无新增测试文件,断言并入既有三个;PASS=68 FAIL=0)。 +验收: + 已验证(离线):hidden_as_other_empire 四组合真值;net_play._on_target_info 前置过滤 + (看他国框开→异阵营 PC 走原判定树、关→清框 return、关不影响 empire 0 的怪); + game_option_ui 的 target_board 默认值修正为 1、name_color / target_board radio → + _display + user://system_option.cfg 持久化 + display_option_changed 信号;全 68 回归。 + 未验证:真机 —— game-option 关「看他国目标框」后点异阵营玩家不弹框的观感、 + 切「名字色 / 阵营」后头顶名字按阵营配色(依赖真服 GC_PVP / empire 下发)、 + 与增量 115 目标框判定树、增量 110 名色整表刷新的并存;building / wooden-door + 实体类型 + LCONTROL 悄悄话捷径仍是 seam ⑫。 +风险与依赖: + seam ⑫(延续):poc 无 building / wooden-door 实体类型(is_building 恒 false)、 + 目标框点击 LCONTROL 悄悄话捷径未接 whisper dialog。 + 忠于参考:hidden_as_other_empire 不按 is_pc 收窄 —— empire 0 的怪 / NPC / 石头在 + _is_same_empire 里恒「同阵营」故本就不会被挡,加 is_pc 反而偏离参考。 + 名字色算法本体(SetEmpireNameMode 调色板)增量 110 已 1:1 并测;本增量只补 radio → 运行期 + 开关的最后一段接线,不碰算法。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条); + seam ⑦ / ⑨ 深化(子标签随名字上移 / 名字与气泡合成单尾标链表); + seam ⑩ 深化(ArrangeTextTail:2D 投影后名字牌互相挤开防重叠); + 或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击 + LCONTROL 悄悄话捷径接 whisper dialog)。 +``` + +### C.5 批次记录 · 增量 119(W3→M2 / §8.8 seam ⑦ 收口:公会名 / 称号 / 等级子标签随名字尾标纵向位移 1:1) + +```text +批次:增量 119 —— W3→M2 §8.8 seam ⑦ 收口:CPythonTextTail::ArrangeTextTail 里,逐条 + 角色名字尾标(m_CharacterTextTailList)的公会名 / 称号 / 等级三个子标签的屏幕坐标 + 全部锚在 `pTextTail->y` 上(公会名 y - c_fyGuildNamePosition(15px)、称号 / 等级同 y), + 而这个 y 已在 UpdateShowingTextTail :114 被聊天气泡顶过 -17。所以名字尾标一旦因 + 「聊天」气泡上移,三个子标签整体跟随同一段纵向位移。增量 117 只让名字尾标本身跟随, + 子标签当时留作 seam ⑦ 遗留 —— 本增量补齐「子标签随名字尾标纵向位移」这一 1:1 行为。 +状态:✅ 完成(name_tail_layout.gd 增 name_tail_lift_delta / subtag_world_y 两个纯静态函数 + + 常量 GUILD_NAME_ABOVE_NAME_PX;net_world._reposition_name_tag 在顶名字尾标后按 + lift_delta 平移 GuildTag / TitleTag / LevelTag;子标签默认局部 y 提为 SUBTAG_DEFAULT_Y 常量) + 🔎 真机目视:远端 PC 冒聊天气泡时,头顶「公会名 / 称号 / 等级」随名字一起上移贴气泡, + 气泡过期 / 只是「信息」尾标时整体回落默认位置;三者相对间距全程不变。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/): + UserInterface/PythonTextTail.cpp:88 UpdateShowingTextTail() + :101 for (pTextTail : m_CharacterTextTailList) + :106 itor = m_ChatTailMap.find(pTextTail->dwVirtualID) + :112 if (pChatTail->bNameFlag) + :114 pTextTail->y = pChatTail->y - 17.0f —— 名字尾标屏幕 y 顶到气泡上方 17px + UserInterface/PythonTextTail.cpp:151 ArrangeTextTail() + :211 for (pTextTail : m_CharacterTextTailList) —— 逐条角色名字尾标 + :21 const float c_fyGuildNamePosition = 15.0f + :228 pGuildNameInstance->SetPosition(x + iImageHalfSize, pTextTail->y - c_fyGuildNamePosition, z) + :258 pTitle->SetPosition(x - iNameWidth/2 - fxAdd, pTextTail->y, z) —— 称号与名字同一行 + :283 pLevel->SetPosition(x - iNameWidth/2 - fxAdd - iTitleWidth, pTextTail->y, z) —— 等级同一行 + —— 三个子标签的屏幕 y 都取自 pTextTail->y(已被 :114 顶过)。名字上移多少,子标签上移多少。 + seam ⑦(收窄后剩余):poc 把三个子标签竖排(公会名局部 y 2.34 在名字上、称号 1.86 / + 等级 1.74 在名字下),参考端是屏幕空间:公会名在名字上方 15px、称号 / 等级与名字同一 + 水平行按各自文本宽度左偏。这套逐像素水平排版差异仍留 seam ⑦;本增量只对齐「纵向 + 整体跟随名字尾标位移」。 +改动: + project/name_tail_layout.gd(改)—— 新增: + const GUILD_NAME_ABOVE_NAME_PX := 15.0(PythonTextTail.cpp:21 c_fyGuildNamePosition, + 仅作 1:1 记录,poc 竖排近似不用它做像素排版); + name_tail_lift_delta(default_world_y, bubble_world_y, follows, px_size) -> float + = name_tail_world_y(...) - default_world_y(follows 时 = 顶到气泡上方后的净上移,否则 0); + subtag_world_y(default_subtag_y, name_tail_lift_delta) -> float + = default_subtag_y + name_tail_lift_delta(三子标签整体平移同一段)。 + 头部 REF 段补 ArrangeTextTail :211/:228/:258/:283 条目;seam ⑨ 标「已收口」、 + seam ⑦ 标「部分收口 · 增量 119」(纵向跟随已 1:1,逐像素水平排版仍近似)。 + project/net_world.gd(改)—— const SUBTAG_DEFAULT_Y := {"GuildTag": 2.34, + "TitleTag": 1.86, "LevelTag": 1.74}(原来散落在 _attach_nameplate 的字面量,提为单一来源); + _attach_nameplate 的 _mk_sub_tag 调用改用 SUBTAG_DEFAULT_Y[...]; + _reposition_name_tag:在设完 tag.position.y 后算 + lift := NameTailLayout.name_tail_lift_delta(NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size), + for sub_name in SUBTAG_DEFAULT_Y: st.position.y = NameTailLayout.subtag_world_y(SUBTAG_DEFAULT_Y[sub_name], lift)。 + (_reposition_name_tag 已在 _apply_chat_tail 气泡出现 / _expire_chat_tails 气泡过期两处调用, + 双向覆盖。) + project/name_tail_layout_test.gd(改)—— 新增「4. seam ⑦ 收口」段: + GUILD_NAME_ABOVE_NAME_PX == 15.0;name_tail_lift_delta(follows→0.352、!follows→0、 + px_size 翻倍→更大);subtag_world_y(GuildTag 2.34→2.692、TitleTag 1.86→2.212、 + LevelTag 1.74→2.092);平移后子标签相对间距不变;lift 0 时回默认。 + project/chat_test.gd(改)—— 气泡段(真 NetWorld + entity 2000)加断言: + entity 有 GuildTag / TitleTag / LevelTag;名字冒聊天气泡后 lift > 0 且三子标签 + position.y == 默认 + lift;register_info_tail 覆盖成信息尾标后三子标签回默认 + 2.34 / 1.86 / 1.74。 +测试: + /opt/homebrew/bin/godot --headless --path project --script name_tail_layout_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script chat_test.gd —— PASS。 + 回归:project/*_test.gd 全 68 个全 PASS(无新增测试文件,断言并入既有两个;PASS=68 FAIL=0)。 +验收: + 已验证(离线):name_tail_lift_delta / subtag_world_y 数值;真 NetWorld 下 entity 冒 + 「聊天」气泡 → GuildTag / TitleTag / LevelTag 三者 position.y 各自加同一 lift、 + 相对间距不变;覆盖成「信息」尾标 → 三者回默认局部 y;全 68 回归。 + 未验证:真机 —— 远端 PC 冒气泡时子标签随名字上移贴气泡的观感、与增量 111 子标签 + 内容 / 颜色(RefreshTextTail / AttachTitle / AttachLevel)、增量 117 名字尾标位移的并存。 +风险与依赖: + seam ⑦(收窄后剩余):子标签竖排 vs 参考屏幕空间逐像素水平排版(公会名上方 15px、 + 称号 / 等级同水平行左偏)仍是近似;本增量只对齐纵向跟随。 + seam ⑨(收窄后剩余):气泡与名字仍是两个并存 Label3D 节点(非参考单尾标链表)。 + 忠于参考:子标签平移量取名字尾标的净位移(name_tail_lift_delta),与参考端「子标签 + 锚在已被顶过的 pTextTail->y」严格等价;不引入 poc 自造的额外偏移。 +下一批次: + seam ⑩ 深化(ArrangeTextTail :151-210 item 尾标 isIn 去重叠:2D 投影后名字牌互相挤开); + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条); + seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D); + 或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击 + LCONTROL 悄悄话捷径接 whisper dialog)。 +``` + +### C.5 批次记录 · 增量 120(W3→M2 / §8.8 seam ⑩ 收口:ArrangeTextTail item 尾标去重叠 + isIn 1:1) + +```text +批次:增量 120 —— W3→M2 §8.8 seam ⑩ 收口:CPythonTextTail::ArrangeTextTail 的 + m_ItemTextTailList 块(PythonTextTail.cpp:160-190)。逐条地面掉落名条,与其它每条比 + isIn(标准 AABB 相交,:489-507),命中就把「自己」顶到对方框底下 + `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(yTemp = 5),内层循环从头重跑, + 单条最多下推 LimitCount=20 次防死循环。增量 116 只做了 OnRender Picking 分派(seam ⑩ + 的悬停强显名字部分),这次补齐「多条 item 尾标投影后互相挤开」这一 1:1 行为。 +状态:✅ 完成(新增第 29 个 canonical 纯静态模块 text_tail_arrange.gd:boxes_overlap(isIn) + + arrange_item_tails(外层逐条 / 内层比对 / isIn 命中顶到框底下 / 从头重跑 / MAX_ITERS 封顶); + ground_items._arrange_tags 每帧把所有掉落名条世界锚点投影到屏幕、按文本字符数估框、 + 跑 arrange_item_tails、把被下推的屏幕 Δy 按 Label3D.pixel_size 折回世界局部 y; + game_scene 把相机 getter 传进 ground_items.setup) + 🔎 真机目视:几件掉落物叠在一起时,名条在屏幕上依次往下错开不再糊成一团;散开 / 捡走后回位。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/): + UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f + UserInterface/PythonTextTail.cpp:15 const D3DXCOLOR c_TextTail_Item_Color = D3DXCOLOR(1,1,1,1) + UserInterface/PythonTextTail.cpp:153 ArrangeTextTail() + :160 for (pInsertTextTail : m_ItemTextTailList) —— 逐条 item 名条 + :164 int yTemp = 5; int LimitCount = 0; + :167 for (pCompareTextTail : m_ItemTextTailList) —— 内层比对 + :171 if (*itorCompare == *itor) { ++itorCompare; continue; } —— 跳过自己 + :176 if (LimitCount >= 20) break; + :180 if (isIn(pInsertTextTail, pCompareTextTail)) + :182 pInsertTextTail->y = pCompareTextTail->y + pCompareTextTail->yEnd + yTemp; + :184 itorCompare = m_ItemTextTailList.begin(); ++LimitCount; continue; —— 从头重跑 + :189 ++itorCompare; + UserInterface/PythonTextTail.cpp:489 isIn(pSource, pTarget): + 框 = [x + xStart, x + xEnd] × [y + yStart, y + yEnd](两框 Source / Target 同式); + :500 if (x1S <= x2T && x2S >= x1T && y1S <= y2T && y2S >= y1T) return true; —— 含边界 + UserInterface/PythonTextTail.cpp:810-813 尾标框(RegisterTextTail 内 GetTextSize 后): + xStart = -xSize/2 - 2、yStart = -2、xEnd = xSize/2 + 2、yEnd = ySize + UserInterface/PythonTextTail.cpp:567 RegisterItemTextTail(VirtualID, c_szText, pOwner) + → RegisterTextTail(..., c_TextTail_Name_Position, c_TextTail_Item_Color) → m_ItemTextTailList.push_back + seam ⑩(收窄后剩余):参考端在屏幕像素空间跑(y 向下、GetTextSize 精确像素),poc 每物 + 一个 billboard Label3D —— 名条框按文本字符数 * 7px 估宽、单行 15px 估高(≠ 精确像素); + 只对掉落 item 名条做,角色名字尾标不参与(与参考端 m_ItemTextTailList / m_CharacterTextTailList + 分表一致)。屏幕 Δy 折世界用 name_tail_layout 的同一 pixel_size 约定。 +改动: + project/text_tail_arrange.gd(新,第 29 个 canonical 纯静态模块,extends RefCounted): + const PUSH_GAP := 5.0(yTemp)、const MAX_ITERS := 20(LimitCount 上限); + box_start_x(w) = -w/2 - 2、box_end_x(w) = w/2 + 2、box_start_y() = -2、box_end_y(h) = h; + boxes_overlap(a, b) —— isIn 的 AABB 相交(a/b = {x, y, w, h},含边界 <= / >=); + arrange_item_tails(tails: Array[{x,y,w,h}]) -> Array[float] —— 返回每条排布后的屏幕 y: + 外层逐条 i,内层逐条 j(j==i 跳过),boxes_overlap 命中则 + ys[i] = ys[j] + box_end_y(h_j) + PUSH_GAP、j 归零、limit++,limit >= MAX_ITERS 时 break; + pCompare 用「已排布后」的 ys[j](与参考端共用一张链表就地改写一致)。 + project/ui/ground_items.gd(改)—— const TextTailArrange = preload; + const TAG_BASE_Y := 0.75 / TAG_CHAR_W_PX := 7.0 / TAG_LINE_H_PX := 15.0; + var _camera_getter: Callable;setup() 尾部加可选 camera_getter 参数; + 建 tag 时 tag.position.y = TAG_BASE_Y(原字面量 0.75); + _process 尾部调 _arrange_tags():取相机 → 按 vid 排序遍历掉落 → cam.unproject_position + 各名条世界锚点(背面 / 无相机跳过并回默认)→ 组 {x, y, w = max(1, len*7), h = 15} → + arrange_item_tails → 各名条 position.y = TAG_BASE_Y - (arranged_y - y0) * tag.pixel_size; + 可见名条 < 2 时直接回默认。 + project/game_scene.gd(改)—— ground_items.setup(...) 第 6 参传 func() -> Camera3D: return cam。 + project/text_tail_arrange_test.gd(新)—— 常量;box_* 四边;boxes_overlap(x/y 都叠→真、 + 对称、边界相接含边界→真、x 分离→假、y 分离→假);arrange_item_tails(单条不动、 + 两条不叠不动、两条同位→第 1 条顶到 121 第 2 条留 100、三条同位→[121,142,100] 且两两不叠、 + 40 条全同位→正常返回不死循环 & 下推量 <= MAX_ITERS*(h+gap)=340、列表顺序即 + m_ItemTextTailList 顺序靠前的先让位)。 + project/p2b_test.gd(改)—— 加 seam ⑩ 段:独立 mount2 / FakeClient / 真 Camera3D, + 单条掉落名条留默认 0.75;再加一条同位 → 靠前(vid 小)名条 position.y 被下推 + (= 0.75 - (15+5)*0.005 = 0.65)、靠后留 0.75、两者局部 y 不同;把靠后一条挪远 → 两条回默认。 +测试: + /opt/homebrew/bin/godot --headless --path project --script text_tail_arrange_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script p2b_test.gd —— PASS。 + 回归:project/*_test.gd 共 69 个全 PASS(新增 text_tail_arrange_test.gd;PASS=69 FAIL=0)。 +验收: + 已验证(离线):isIn AABB(含边界);arrange_item_tails 逐条 / 从头重跑 / MAX_ITERS 封顶 + 的数值轨迹(两条同位 → [121, 100]、三条同位 → [121, 142, 100]、40 条不死循环); + 真 Camera3D + 真 GroundItems 下同位两掉落名条投影后靠前一条按 (h+gap)*pixel_size 下推、 + 靠后留默认、拉开距离后回默认;全 69 回归。 + 未验证:真机 —— 几件掉落物同屏叠放时名条往下错开的观感、与增量 116 悬停强显、 + 拾取范围高亮(_process 里同时跑)的并存;文本框按字符数估宽 vs 参考 GetTextSize 的像素差。 +风险与依赖: + seam ⑩(收窄后剩余):名条框按「字符数 * 7px」估,参考端是 GetTextSize 精确像素; + poc 是每物 billboard Label3D 折世界 y,参考端是屏幕像素空间正交文本实例。 + 忠于参考:只对掉落 item 名条去重叠(m_ItemTextTailList),角色名字尾标不动(分表一致); + 下推公式、内层从头重跑、LimitCount=20 封顶均 1:1;屏幕 Δy 折世界沿用 name_tail_layout + 既定的 pixel_size 约定,不引入 poc 自造额外系数。 + 每帧对所有掉落名条投影:poc 掉落物规模小可接受;真机若海量掉落需加节流 / 距离裁剪。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条); + seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D); + seam ⑦ 深化(子标签逐像素水平排版:公会名名字上方 15px、称号 / 等级同水平行按文本宽左偏); + 或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击 + LCONTROL 悄悄话捷径接 whisper dialog)。 +``` + +### C.5 批次记录 · 增量 121(W3→M2 / §8.8 seam ⑦ 水平深化:ArrangeTextTail 角色块子标签逐像素水平排版 1:1) + +```text +批次:增量 121 —— W3→M2 §8.8 seam ⑦ 水平深化:CPythonTextTail::ArrangeTextTail 的 + m_CharacterTextTailList 块(PythonTextTail.cpp:211-320)非 EUROPE 分支的公会名 / 称号 / + 等级 / 名字四个文本实例逐像素水平排布。增量 119 已收口这三个子标签「随名字尾标纵向位移」, + 这次补齐「称号 / 等级摆在名字锚点左侧、名字本身右移 fxAdd」这一 1:1 水平行为。 +状态:✅ 完成(name_tail_layout.gd(第 28 个 canonical)加 6 个纯静态水平排版函数: + name_x_shift / title_x_offset / level_x_offset / guild_name_x_offset / mark_x_offset / + subtag_world_x;net_world._apply_text_tail_to 尾部新增 _layout_subtags_x(n) 把这些像素 + 偏移按各子标签 Label3D.pixel_size 折成 billboard 本地 x) + 🔎 真机目视:角色有称号 / 等级时,等级在最左、称号居中偏左、名字略右移;关 PVP / 撤称号后回位。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/): + UserInterface/PythonTextTail.cpp:20 const float c_fxMarkPosition = 1.5f + UserInterface/PythonTextTail.cpp:21 const float c_fyGuildNamePosition = 15.0f + UserInterface/PythonTextTail.cpp:22 const float c_fyMarkPosition = 15.0f + 11.0f (= 26) + UserInterface/PythonTextTail.cpp:211 for (pTextTail : m_CharacterTextTailList) + :218 CGraphicMarkInstance * pMarkInstance = pTextTail->pMarkInstance; + :220 if (pMarkInstance && pGuildNameInstance) —— 公会名水平锚点仅此路改写 + :224 iImageHalfSize = pMarkInstance->GetWidth()/2 + c_fxMarkPosition; + :227 pMarkInstance->SetPosition(x - iWidth/2 - iImageHalfSize, y - c_fyMarkPosition); + :228 pGuildNameInstance->SetPosition(x + iImageHalfSize, y - c_fyGuildNamePosition, z); + :231 pTextTail->pTextInstance->GetTextSize(&iNameWidth, &iNameHeight); + :236 float fxAdd = 0.0f; + :242 if (pTitle) { fxAdd = 8.0f; + :258 pTitle->SetPosition(x - (iNameWidth/2) - fxAdd, pTextTail->y, z); (非 EUROPE) + :263 if (pLevel) + :282 pLevel->SetPosition(x - (iNameWidth/2) - fxAdd - iTitleWidth, pTextTail->y, z); + :288 } else { fxAdd = 4.0f; + :312 pLevel->SetPosition(x - (iNameWidth/2) - fxAdd, pTextTail->y, z); (只有等级) + :320 pTextTail->pTextInstance->SetPosition(x + fxAdd, pTextTail->y, z); —— 名字右移 fxAdd + UserInterface/PythonTextTail.cpp:555 RegisterCharacterTextTail 内公会名实例 CENTER 对齐 + UserInterface/PythonTextTail.cpp:905 AttachTitle:非 EUROPE HORIZONTAL_ALIGN_CENTER + UserInterface/PythonTextTail.cpp:958 AttachLevel:HORIZONTAL_ALIGN_RIGHT + seam ⑦(收窄后剩余):① poc 无公会徽记 mark 实体 —— 参考端 :220 的 + `if (pMarkInstance && pGuildNameInstance)` 不成立时公会名水平锚点根本不被 ArrangeTextTail + 改写,poc 亦让 GuildTag x 偏移留 0(名字中线正上方),不做 +markW/2+1.5; + ② 文本宽度 poc 按「字符数 * 7px」估,参考端是 GetTextSize 精确像素; + ③ 三子标签 poc 仍竖排(各自默认局部 y),只把水平 x 偏移叠上去 —— 参考端称号 / 等级 + 与名字同一屏幕行(3D billboard 里三条挤一行会互相糊,保留竖排 + x 偏移的折衷)。 +改动: + project/name_tail_layout.gd(改,第 28 个 canonical 纯静态模块): + const NAME_X_ADD_TITLE := 8.0(:242 fxAdd)、NAME_X_ADD_LEVEL_ONLY := 4.0(:288 fxAdd)、 + MARK_X_PAD := 1.5(:20)、MARK_ABOVE_NAME_PX := 26.0(:22); + name_x_shift(has_title, has_level) -> 8 / 4 / 0(:320 名字右移量); + title_x_offset(name_w) -> -(name_w/2) - 8(:258,相对名字锚点 x 的像素偏移,负 = 左); + level_x_offset(name_w, title_w, has_title) -> 有称号 -(name_w/2) - 8 - title_w(:282)/ + 只有等级 -(name_w/2) - 4(:312); + guild_name_x_offset(mark_w) -> mark_w/2 + 1.5(:228,仅有徽记时用); + mark_x_offset(guild_w, mark_w) -> -(guild_w/2) - (mark_w/2 + 1.5)(:227); + subtag_world_x(px_offset, px_size) -> px_offset * px_size(屏幕 +x → billboard 本地 +x, + 按该 Label3D pixel_size 折世界)。 + project/net_world.gd(改): + const SUBTAG_CHAR_W_PX := 7.0(字符数估宽,与 ground_items TAG_CHAR_W_PX 同源); + _apply_text_tail_to 尾部调新增 _layout_subtags_x(n, ttag, ltag): + has_title / has_level = 对应子标签 text != "";name_w / title_w 按字符数 * 7 估; + 名字 Label3D.position.x = subtag_world_x(name_x_shift(has_title, has_level), px); + TitleTag.position.x = subtag_world_x(title_x_offset(name_w), px) 若有称号否则 0; + LevelTag.position.x = subtag_world_x(level_x_offset(name_w, title_w, has_title), px) 若有等级否则 0; + GuildTag.position.x = 0(poc 无徽记 -> 参考端 :220 此路不改写)。 + —— 纵向位移(_reposition_name_tag,增量 117/119)与横向排布(_layout_subtags_x,本增量) + 互不干扰:前者只写 .position.y,后者只写 .position.x。 + project/name_tail_layout_test.gd(改)—— 加 §5:6 个水平函数的常量 / 公式 / 符号 / 线性缩放 + 断言 + 组合「等级(-58) < 称号(-28) < 名字(+8)」水平顺序。 + project/chat_test.gd(改)—— FakeClient 加 get_guild_name;新增 charlie(vid 3000, + guild 7 / alignment 15000(grade 0 有称号)/ level 42):_apply_text_tail 后断言 + 名字 x = 8*px、称号 x < 0、等级 x < 称号 x、GuildTag x = 0、称号 x 精确 + = (-49/2 - 8)*px;再把 alignment 改 0(grade 4 = TITLE_NONE)-> 称号 x 归零、 + 名字 x 变 4*px、等级 x 精确 = (-49/2 - 4)*px。 +测试: + /opt/homebrew/bin/godot --headless --path project --script name_tail_layout_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script chat_test.gd —— PASS。 + 回归:project/*_test.gd 共 69 个全 PASS(无新增测试文件,扩 name_tail_layout_test / chat_test; + PASS=69 FAIL=0)。 +验收: + 已验证(离线):fxAdd 三态(0 / 4 / 8);称号 = -(nameW/2) - 8、等级(有称号)再 - titleW、 + 等级(只有等级)= -(nameW/2) - 4 的像素公式;subtag_world_x 线性且保符号; + 真 net_world + 真实体:有称号时名字右移 8*px / 称号在左 / 等级更左 / GuildTag 不动, + 撤称号后称号归零、名字改右移 4*px;全 69 回归。 + 未验证:真机 —— 角色同屏带称号 / 等级 / 公会名时的水平观感、与增量 116 悬停强显、 + 增量 117/119 气泡上移的并存;字符数估宽 vs GetTextSize 的像素差;公会徽记缺失下 + 公会名不做 +markW/2+1.5 偏移的观感差异。 +风险与依赖: + seam ⑦(收窄后剩余):poc 无公会徽记 mark 实体(参考端 :220 gate)、文本宽按字符数 * 7px + 估(≠ GetTextSize 精确像素)、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。 + 忠于参考:fxAdd 8 / 4 / 0、称号 / 等级的减项、名字右移 fxAdd、公会名仅有徽记时改写 + 均 1:1;屏幕像素 → 世界本地 x 沿用 name_tail_layout 既定 pixel_size 约定,不引入额外系数。 + 纵横解耦:_layout_subtags_x 只写 .position.x,_reposition_name_tag 只写 .position.y; + _apply_text_tail_to 每次(含 ~2Hz 兜底重染)重算 x,与 level / title / guild 变化同步。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条); + seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D); + seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset / + 文本宽换真实字体度量); + 或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击 + LCONTROL 悄悄话捷径接 whisper dialog)。 +``` + +### C.5 批次记录 · 增量 122(W3→M2 / §5.4 · §8.8 seam ⑫ 收口:目标框点击 LCONTROL 悄悄话捷径接 whisper 1:1) + +```text +批次:增量 122 —— W3→M2 §5.4 / §8.8 seam ⑫ 收口:root/game.py CGameWindow::SetPCTargetBoard + (:804-817)里「targetBoard.Open 之后,若按住 LCONTROL 且是同阵营的他人 PC(非本人、 + 非 building)→ interface.OpenWhisperDialog(name)」这条捷径接进 poc。谓词 + target_board.whisper_on_ctrl_click 增量 118 已备好没接,这次是纯接线:net_play 侧加 + 一个信号 + 一个按键态 + _on_pick 里 KIND_PC 分支的轮询;game_scene 侧转 chat.start_whisper。 +状态:✅ 完成(net_play.gd:signal whisper_requested(name)、var _lcontrol_down、 + set_lcontrol_down(down)、_on_pick 尾部 KIND_PC 分支调 + TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid==_main_vid, false) + 真则 whisper_requested.emit(_entity_name(e));game_scene.gd:_unhandled_input 加 + KEY_CTRL 按下 / 松开 → net_play.set_lcontrol_down(e.pressed),chat 就绪后 + net_play.whisper_requested → chat.start_whisper) + 🔎 真机目视:按住 Ctrl 点同帝国队友 → 聊天输入框预填 "/w <名> " 并抢焦点; + 点异帝国 / 点自己 / 点怪 / 没按 Ctrl → 只普通选中,不预填。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/ + assets/root/game.py): + assets/root/game.py:94 self.targetBoard.SetWhisperEvent(ui.__mem_func__(self.interface.OpenWhisperDialog)) + assets/root/game.py:804 def SetPCTargetBoard(self, vid, name): + :805 self.targetBoard.Open(vid, name) + :807 if app.IsPressed(app.DIK_LCONTROL): + :809 if not player.IsSameEmpire(vid): return + :812 if player.IsMainCharacterIndex(vid): return + :814 elif chr.INSTANCE_TYPE_BUILDING == chr.GetInstanceType(vid): return + :817 self.interface.OpenWhisperDialog(name) + —— C++ picking 只对 PC 命中派发 SetPCTargetBoard(怪 / NPC 走别的),故 poc 也只在 + _entity_kind(e) == KIND_PC 分支查这条捷径。 + seam ⑫(收窄后剩余):poc 无 building / wooden-door / stone-door 实体类型 + (_entity_kind 只到 0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP),故 is_building + 实参恒传 false —— 参考端 :814 INSTANCE_TYPE_BUILDING 分支在 poc 永不触发;门实体 + 做出来后再把 GetInstanceType 分类补上。另 poc「悄悄话对话框」= 聊天输入预填 "/w 名 " + (chat_ui.start_whisper),非独立 whisper dialog 窗口(seam:无独立 whisper UI)。 +改动: + project/net_play.gd(改): + signal whisper_requested(name: String)(game.py:817 OpenWhisperDialog 的 poc 通道); + var _lcontrol_down := false(game.py:807 app.IsPressed(app.DIK_LCONTROL) 的按住态); + func set_lcontrol_down(down)(game_scene 的 KEY_CTRL 按下 / 松开喂); + _on_pick:_change_target_to_picked_instance(vid) 之后,若 _entity_kind(e) == KIND_PC, + same_empire = EntityRules._is_same_empire(_main_entity(), e), + TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid==_main_vid, false) + 为真且 _entity_name(e) != "" → whisper_requested.emit(name)。 + project/game_scene.gd(改): + _unhandled_input 在 KEY_SHIFT 块后加 KEY_CTRL:net_play.set_lcontrol_down(e.pressed) + 后 return(一处同时处理按下 / 松开,对齐参考端 IsPressed 轮询语义); + chat 装配处(fishing_feedback 接线之后):net_play.whisper_requested → chat.start_whisper。 + project/target_board.gd(改):头注 seam ⑫ 段更新 —— whisper_on_ctrl_click 增量 122 + 已接线(net_play._on_pick → whisper_requested → game_scene → chat.start_whisper), + is_building 仍恒 false(门实体缺,seam ⑫ 余留)。 + project/netplay_test.gd(改)—— 加 8c)seam ⑫ 段(无新增测试文件): + 连 np.whisper_requested 收集 whispers;主角 empire=1; + Bob(vid 6100 / ch_type 0 / empire 1):没按 Ctrl 点 → whispers 空但 set_target 6100; + 按 Ctrl 点 → whispers == ["Bob"];改 empire=2 再点 → 空(:809 IsSameEmpire); + Wolf2(ch_type 2)按 Ctrl 点 → 空(仅 PC 分派)。 +测试: + /opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。 + 回归:project/*_test.gd 共 69 个全 PASS(无新增测试文件,扩 netplay_test;PASS=69 FAIL=0)。 +验收: + 已验证(离线):按住 LCONTROL 点同阵营他人 PC → whisper_requested(name);无 LCONTROL / + 异阵营 PC / 非 PC(怪)→ 不发;点 PC 的普通选中(set_target)不受影响; + KEY_CTRL 按下 / 松开正确翻转 _lcontrol_down;全 69 回归。 + 未验证:真机 —— Ctrl+点队友后聊天框预填 "/w 名 " 的观感、与增量 116 悬停强显 / + 增量 118「看他国框」前置过滤的并存;building 实体缺失下 :814 分支在 poc 恒不触发。 +风险与依赖: + seam ⑫(收窄后剩余):poc 无 building / wooden-door / stone-door 实体类型 → is_building + 实参恒 false(:814 分支 poc 永不触发)、can_view 的 is_wooden_door 恒 false; + poc 悄悄话 = 聊天输入预填,非独立 whisper dialog 窗口。 + 忠于参考:LCONTROL 轮询 + IsSameEmpire + IsMainCharacterIndex + 非 building 四条与 + :807-817 一致;只对 PC 命中查(C++ picking 分派边界);谓词 whisper_on_ctrl_click + 增量 118 已定,本批不改。 + 低风险:仅新增一条信号 + 一个按键态 + 一处 KIND_PC 分支判断 + 一处信号转接, + 不触碰移动 / 攻击 / 目标框收包路径;纯 GDScript。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条); + seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D); + seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset / + 文本宽换真实字体度量); + seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType / IsWoodenDoor / IsBuilding 分类, + 待门实体做出来); + 或独立 whisper dialog 窗口(uiWhisper.py,替换聊天输入预填)。 +``` + +### C.5 批次记录 · 增量 123(W3→M2 / §8.8 seam ⑦ · ⑩ 收口:头顶子标签 / 掉落名条文本宽换真实字体度量 GetTextSize 1:1) + +```text +批次:增量 123 —— W3→M2 §8.8 seam ⑦ / ⑩ 收窄:ArrangeTextTail(PythonTextTail.cpp)里 + iNameWidth / iTitleWidth / iLevelWidth / 掉落名条框 xSize 参考端全部来自 + CGraphicTextInstance::GetTextSize(&w, &h)——逐字形排版度量,非「字符数 * 定宽」。 + poc 之前 net_world.SUBTAG_CHAR_W_PX / ground_items.TAG_CHAR_W_PX 都是 7.0 的粗估 + (增量 120/121 遗留 seam)。这次抽一个纯静态 text_metrics.gd 包 Font.get_string_size() + 做 1:1 度量,两处调用换成真实像素宽,无字体(headless / 未配主题)时回退旧估宽。 +状态:✅ 完成(新增 project/text_metrics.gd 第 30 个 canonical 纯静态模块 + + project/text_metrics_test.gd;net_world._layout_subtags_x 的 name_w / title_w、 + ground_items._arrange_tags 的框宽 w 换 TextMetrics.label_width) + 🔎 真机目视:带称号 / 等级的窄名与宽名头顶,称号 / 等级左偏量随真实名宽变化(长名推得更远); + 多个长短不一的掉落名条重叠时按真实文本框挤开,短名不再被按 7px/字 高估的框推开。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/): + UserInterface/PythonTextTail.cpp:225 pGuildNameInstance->GetTextSize(&iWidth, &iHeight) + UserInterface/PythonTextTail.cpp:233 pTextTail->pTextInstance->GetTextSize(&iNameWidth, &iNameHeight) + UserInterface/PythonTextTail.cpp:240 pTitle->GetTextSize(&iTitleWidth, &iTitleHeight) + UserInterface/PythonTextTail.cpp:267 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(有称号分支) + UserInterface/PythonTextTail.cpp:297 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(只有等级分支) + UserInterface/PythonTextTail.cpp:733 pOwnerTextInstance->GetTextSize(&xOwnerSize, &yOwnerSize) + UserInterface/PythonTextTail.cpp:748 pTextInstance->GetTextSize(&xSize, &ySize)(SetTextTailOwnerText) + UserInterface/PythonTextTail.cpp:809 pTextInstance->GetTextSize(&xSize, &ySize)(SetTextTailText,item 框尺寸) + —— GetTextSize 转发到字体实际字形度量(含字距 / 非等宽),Godot Font.get_string_size() + (text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)是 1:1 对应物。 +改动: + project/text_metrics.gd(新,纯静态第 30 个 canonical): + const FALLBACK_CHAR_W_PX := 7.0(=旧 SUBTAG/TAG_CHAR_W_PX,仅无字体兜底); + text_size(font, text, font_size) -> Vector2:空串 / font null / font_size<=0 → ZERO, + 否则 font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size); + width_of(font, text, font_size, fallback_char_px=7.0) -> float:空串 → 0; + 量得正宽 → 精确像素;否则 → text.length() * fallback_char_px; + label_width(label: Label3D, fallback_char_px=7.0) -> float:null → 0; + f = label.font ?? ThemeDB.fallback_font,再 width_of(f, label.text, label.font_size, …)。 + project/net_world.gd(改):const TextMetrics = preload(...);_layout_subtags_x 里 + name_w = maxf(1.0, TextMetrics.label_width(nlbl, SUBTAG_CHAR_W_PX))、 + title_w = maxf(1.0, TextMetrics.label_width(ttag, SUBTAG_CHAR_W_PX)) + (原 float(text.length()) * SUBTAG_CHAR_W_PX);SUBTAG_CHAR_W_PX 常量保留作兜底。 + project/ui/ground_items.gd(改):const TextMetrics = preload(...);_arrange_tags 里 + box.w = maxf(1.0, TextMetrics.label_width(tag, TAG_CHAR_W_PX)) + (原 tag.text.length() * TAG_CHAR_W_PX);TAG_CHAR_W_PX 常量保留作兜底。 + name_tail_layout.gd / text_tail_arrange.gd 不动——它们收 name_w / title_w / w 作入参, + 宽度来源换掉即可,纯静态排布公式(增量 119/120/121)不受影响。 +测试: + /opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS。 + 回归:project/*_test.gd 共 70 个全 PASS(新增 text_metrics_test;PASS=70 FAIL=0)。 +验收: + 已验证(离线):text_size 退化输入(空 / null / 非法字号)恒 ZERO;有 ThemeDB.fallback_font + 时度量为正、随串长单调增、随字号单调增;width_of 空串 0、无字体回退 len*char_px、 + 有字体 == text_size.x、font_size<=0 回退;label_width 从 Label3D.font(未设回落 + fallback_font)+ font_size 量、null → 0、空文本 → 0;net_world / ground_items 两处 + 调用编译通过、全 70 回归(含 netplay_test 目标 HUD、net_world_vis、gamescene)。 + 未验证:真机 —— 头顶称号 / 等级左偏量随真实名宽变化的观感、长短名条混排时按真实框挤开、 + 非拉丁字体 / CJK 名下的度量(ThemeDB.fallback_font 与打包字体可能不同字形宽)。 +风险与依赖: + seam ⑦(收窄后剩余):文本宽已换 GetTextSize 1:1;仍留 —— poc 无公会徽记 mark 实体 + (参考端 :220 gate 不成立)、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。 + seam ⑩(收窄后剩余):名条框宽已换 GetTextSize 1:1、单行高仍按 TAG_LINE_H_PX 15px 估 + (参考端 ySize 亦来自 GetTextSize);只对掉落 item 名条做(角色名字尾标分表不参与,同参考)。 + 忠于参考:get_string_size 的 alignment/width/-1/font_size 参数对齐 GetTextSize 语义; + 无字体路径的 7.0 与增量 120/121 的旧估宽数值一致,回退不改变既有 headless 行为。 + 低风险:新增一个纯静态模块 + 两处「宽度来源」替换,未碰排布公式 / 信号 / 收包路径;纯 GDScript。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条); + seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D); + seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset / + 单行高亦走 GetTextSize); + seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType 分类,待门实体); + 或独立 whisper dialog 窗口(uiWhisper.py)。 +``` + +### C.5 批次记录 · 增量 124(W3→M2 / §8.8 seam ⑦ · ⑩ 末尾:掉落名条框「单行文本高」换真实字体度量 GetTextSize 的 h 1:1) + +```text +批次:增量 124 —— W3→M2 §8.8 seam ⑦ / ⑩ 末尾收窄:ArrangeTextTail item 尾标去重叠 + 用的框 yEnd = ySize(PythonTextTail.cpp:749 / :809),ySize 与 xSize 一样来自 + CGraphicTextInstance::GetTextSize(&xSize, &ySize)。增量 123 已把框宽 w 换成真实 + Font.get_string_size().x,这次把框高 h 也换成 .y —— poc 之前 ground_items 里框高恒 + 用 TAG_LINE_H_PX = 15.0 粗估(增量 120 遗留)。text_metrics.gd 增 height_of / + label_height 两个纯静态方法(无新增 canonical,扩第 30 个),ground_items._arrange_tags + 的 box.h 换 TextMetrics.label_height(tag, TAG_LINE_H_PX),无字体时回退 15.0。 +状态:✅ 完成(project/text_metrics.gd 增 const FALLBACK_LINE_H_PX := 15.0 + height_of() + + label_height();project/ui/ground_items.gd _arrange_tags 的 box "h" 换真实度量; + text_tail_arrange.gd 不动——它收 box h 作入参,box_end_y(h) = h 已对齐参考 yEnd = ySize) + 🔎 真机目视:长短名混排的掉落名条重叠时,按真实单行文本高(≈ 字体行高,非 15px)挤开, + 竖直堆叠间距随打包字体行高一致;单条名条与多条时观感不变。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/): + UserInterface/PythonTextTail.cpp:749 pTextTail->pTextInstance->GetTextSize(&xSize, &ySize) + (SetTextTailOwnerText else 分支) + UserInterface/PythonTextTail.cpp:751 pTextTail->yEnd = (float) ySize; + UserInterface/PythonTextTail.cpp:809 pTextTail->pTextInstance->GetTextSize(&xSize, &ySize) + (__CreateCharacterTextTail / SetTextTailText) + UserInterface/PythonTextTail.cpp:812 pTextTail->yEnd = (float) ySize; + UserInterface/PythonTextTail.cpp:182 pInsertTextTail->y = pCompareTextTail->y + + pCompareTextTail->yEnd + yTemp; —— 去重叠顶到框底 + —— ySize 是单行 text instance 的字体行高(ascent + descent),与字形无关; + Godot Font.get_string_size(text, …, font_size).y 是 1:1 对应物。 +改动: + project/text_metrics.gd(改,扩第 30 个 canonical,不新增文件): + const FALLBACK_LINE_H_PX := 15.0(=旧 ground_items.TAG_LINE_H_PX,仅无字体兜底); + height_of(font, text, font_size, fallback_line_px=15.0) -> float:空串 → 0; + 量得正高(text_size(...).y)→ 精确像素;否则 → fallback_line_px; + label_height(label: Label3D, fallback_line_px=15.0) -> float:null → 0; + f = label.font ?? ThemeDB.fallback_font,再 height_of(f, label.text, label.font_size, …)。 + 头注释 + seam 注释同步补「高 h」;REF 段补 :749 / :751 / :809 / :812 的 yEnd = ySize。 + project/ui/ground_items.gd(改):_arrange_tags 里 box + "h": maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX)) + (原 "h": TAG_LINE_H_PX);TAG_LINE_H_PX 常量注释改为「无字体时的单行估高兜底」; + 头注释「按文本字符数估文本框」→「按 Label3D 的真实字体度量取文本框」、 + 「文本框宽取…」→「文本框宽 / 高取…label_width / label_height…增量 123 / 124」。 + text_tail_arrange.gd / name_tail_layout.gd 不动。 +测试: + /opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS + (+「5. height_of / label_height」段:FALLBACK_LINE_H_PX = 15.0;空串 → 0;无字体回退 15; + 有字体高为正、与串内容无关(h("x") == h("WWWWWWWWWW"))、随字号单调、== text_size.y; + label_height(null) → 0、回落 fallback_font、空 Label3D → 0)。 + 回归:project/*_test.gd 共 70 个全 PASS(无新增测试文件,扩 text_metrics_test;PASS=70 FAIL=0)。 +验收: + 已验证(离线):height_of 退化输入(空 / 无字体 / font_size<=0)走回退;有 ThemeDB.fallback_font + 时高为正、随字号单调增、与具体字形无关(单行行高)、== text_size.y;label_height 从 + Label3D.font(未设回落 fallback_font)+ font_size 量、null / 空文本 → 0;ground_items + _arrange_tags box.h 编译通过、全 70 回归(含 p2b_test 的真 GroundItems 去重叠段)。 + 未验证:真机 —— 打包字体行高与 ThemeDB.fallback_font 行高差异下的竖直堆叠观感; + 带 owner 后缀换行的多行名条(poc 当前是单行 "name (owner)",参考端 owner 是独立 instance + 且 yEnd += yOwnerSize + 4,:733-736 —— poc 未拆分,仍按单行处理)。 +风险与依赖: + seam ⑦ / ⑩(收窄后剩余):文本宽 w(123)+ 高 h(124)均已换 GetTextSize 1:1;仍留 —— + poc 无公会徽记 mark 实体(参考端 :220 gate 不成立)、三子标签竖排 + x 偏移的折衷 + (≠ 参考同一屏幕行)、掉落名条为唯一 item 尾标来源(owner 未拆成独立 instance)、 + 去重叠只对掉落 item 名条(角色名字尾标分表不参与,同参考)。 + 忠于参考:get_string_size(...).y 对齐 GetTextSize 的 ySize 语义(单行字体行高); + 无字体路径的 15.0 与增量 120 的旧估高一致,回退不改变既有 headless 行为。 + 低风险:扩一个纯静态模块的两个方法 + 一处「框高来源」替换,未碰排布公式 / 去重叠算法 / + 信号 / 收包路径;纯 GDScript。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条); + seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D); + seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset); + seam ⑩ 末尾(掉落名条 owner 拆成独立 text instance + yEnd += yOwnerSize + 4); + seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType 分类,待门实体); + 或独立 whisper dialog 窗口(uiWhisper.py)。 +``` + +### C.5 批次记录 · 增量 125(W3→M2 / §8.8 seam ⑩ 末尾:掉落名条去重叠框的 owner 后缀按参考 SetItemTextTailOwner「owner 独立度量 + yEnd += yOwnerSize + 4」1:1) + +```text +批次:增量 125 —— W3→M2 §8.8 seam ⑩ 末尾收窄:带 owner 的掉落名条,参考端 + CPythonTextTail::SetItemTextTailOwner(dwVID, c_szName)(PythonTextTail.cpp:706-737) + 把 owner 后缀挂成一个独立的 CGraphicTextInstance(文本 = c_szName + strOwnership, + strOwnership 在 IDS_POSSESSIVE_MORPHENE 为空时取字面 "'s";黄 1,1,0 / CENTER), + 与名字实例分开 GetTextSize(&xOwnerSize, &yOwnerSize),再据此把去重叠框扩张: + :735 pTextTail->yEnd += float(yOwnerSize + 4) —— owner 另起一行、行距 4 + :736 pTextTail->xStart = fMIN(xStart, -xOwnerSize/2 - 1) + :737 pTextTail->xEnd = fMAX(xEnd, xOwnerSize/2 + 1) + poc 之前 ground_items 只按合并串 "name (owner)" 的整条 Label3D 量一个框(增量 123/124 + 换了真实字体度量,但没按参考把 owner 拆出来单独量、也没有 yEnd += yOwnerSize + 4)。 + 这次把去重叠框的度量改为「名字子串」「owner + 所有格子串」分别度量后合成,代入 poc 的 + {w,h} 框模型:h = name_h + owner_h + 4;w = max(name_w, owner_w - 2)(令 box_end_x(w) + = w/2+2 恰好等于参考 :737 的 fMAX(name/2+2, owner/2+1),框 x 两边对称故 box_start_x + 自动 = :736 的 fMIN)。无 owner → 退化为纯名字框,与参考 else 分支(删 owner 实例、 + 框复位 xEnd = w/2+2 / yEnd = h)一致。 +状态:✅ 完成(text_tail_arrange.gd 增纯静态 item_box_with_owner();text_metrics.gd 增 + string_width_like / string_height_like(用某 Label3D 的字体量任意子串);ground_items + _on_added 存 item_name / item_owner meta,_arrange_tags 用两子串分别度量走 + item_box_with_owner 合成去重叠框。均无新增 canonical / 测试文件——扩第 29、第 30 个 + canonical 与其 test,另扩 p2b_test) + 🔎 真机目视:地面上有主人的掉落物,其名条与相邻名条重叠时,按「名字行 + owner 行 + 4px」 + 的总高把靠前名条挤开(比无主掉落挤得更远);owner 串比名字宽时框左右也相应外扩。 + 渲染仍是单个合并 "name (owner)" billboard Label3D(seam ⑩ 记录在案的折中,未拆成 + 两个 text instance / 未做 CENTER 黄字的 owner 行)。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/): + UserInterface/PythonTextTail.cpp:706 void CPythonTextTail::SetItemTextTailOwner(DWORD dwVID, const char * c_szName) + UserInterface/PythonTextTail.cpp:712 if (strlen(c_szName) > 0) { ... pOwnerTextInstance = CGraphicTextInstance::New(); } + UserInterface/PythonTextTail.cpp:722 strOwnership = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ... + UserInterface/PythonTextTail.cpp:723 strName += strOwnership; + UserInterface/PythonTextTail.cpp:725 pOwnerTextInstance->SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER) + UserInterface/PythonTextTail.cpp:727 pOwnerTextInstance->SetColor(1.0f, 1.0f, 0.0f) —— 黄 + UserInterface/PythonTextTail.cpp:733 pOwnerTextInstance->GetTextSize(&xOwnerSize, &yOwnerSize) + UserInterface/PythonTextTail.cpp:734 pTextTail->yStart = -2.0f; + UserInterface/PythonTextTail.cpp:735 pTextTail->yEnd += float(yOwnerSize + 4); + UserInterface/PythonTextTail.cpp:736 pTextTail->xStart = fMIN(pTextTail->xStart, float(-xOwnerSize / 2 - 1)); + UserInterface/PythonTextTail.cpp:737 pTextTail->xEnd = fMAX(pTextTail->xEnd, float(xOwnerSize / 2 + 1)); + UserInterface/PythonTextTail.cpp:739-751 else 分支:Delete(pOwnerTextInstance);框复位 + xStart=-xSize/2-2 / yStart=-2 / xEnd=xSize/2+2 / yEnd=ySize + UserInterface/PythonTextTail.cpp:182 pInsertTextTail->y = pCompareTextTail->y + pCompareTextTail->yEnd + yTemp; +改动: + project/text_tail_arrange.gd(改,扩第 29 个 canonical,不新增文件): + item_box_with_owner(name_w, name_h, owner_w := 0.0, owner_h := 0.0) -> Dictionary: + owner_w <= 0 或 owner_h <= 0 → {"w": name_w, "h": name_h}(无 owner / 参考 else 分支); + 否则 → {"w": maxf(name_w, owner_w - 2.0), "h": name_h + owner_h + 4.0}。 + 头注释 REF 段补 :706-737 的 SetItemTextTailOwner 公式与 {w,h} 推导。 + project/text_metrics.gd(改,扩第 30 个 canonical,不新增文件): + string_width_like(label: Label3D, text, fallback_char_px=7.0) -> float: + label == null → text.length() * fallback_char_px;否则 width_of(label.font ?? + ThemeDB.fallback_font, text, label.font_size, …)。 + string_height_like(label: Label3D, text, fallback_line_px=15.0) -> float: + text == "" → 0;label == null → fallback_line_px;否则 height_of(…同上…)。 + —— 让 ground_items 能用名条 Label3D 的字体分别量「名字子串」「owner 子串」 + (对应参考端两个独立 text instance 各自 GetTextSize)。头注释补增量 125。 + project/ui/ground_items.gd(改): + const OWNER_POSSESSIVE := "'s"(= SetItemTextTailOwner :722 strOwnership 字面值); + _on_added:新建 / 复用 drop 节点时都 set_meta("item_name", _name_for(vnum)) + + set_meta("item_owner", owner); + _arrange_tags:box 不再对整条 tag 量一个框,改为 + iname = meta item_name;iowner = meta item_owner;owner_txt = iowner + "'s"(空则 ""); + nbox = TextTailArrange.item_box_with_owner( + TextMetrics.string_width_like(tag, iname, TAG_CHAR_W_PX), + TextMetrics.string_height_like(tag, iname, TAG_LINE_H_PX), + TextMetrics.string_width_like(tag, owner_txt, TAG_CHAR_W_PX), + TextMetrics.string_height_like(tag, owner_txt, TAG_LINE_H_PX)); + boxes.append({x, y, w: maxf(1, nbox.w), h: maxf(1, nbox.h)})。 + 头注释补增量 125(owner 拆两子串度量、渲染合并是折中)。 + name_tail_layout.gd / text_tail.gd 等其它 canonical 不动。 +测试: + /opt/homebrew/bin/godot --headless --path project --script text_tail_arrange_test.gd —— PASS + (+「5. item_box_with_owner」段:无 owner / owner_w<=0 / owner_h<=0 → 退化纯名字框; + 有 owner → h = name_h + owner_h + 4;owner 窄 → w = name_w 且 box_end_x = nameW/2+2; + owner 宽 → w = owner_w - 2 且 box_end_x = ownerW/2+1(对齐参考 :737 fMAX); + 顶到带 owner 框底下 → push = (name_h+owner_h+4) + PUSH_GAP)。 + /opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS + (+「6. string_width_like / string_height_like」段:label null → 字符数*px / 单行估高; + 空串 → 0;有 fallback_font 时量的是给定子串(≠ label.text)、随子串长单调、== text_size(子串))。 + /opt/homebrew/bin/godot --headless --path project --script p2b_test.gd —— PASS + (FakeClient.add_ground 增可选 owner 入参;新增 mount3/fc3/gi3 段:301 无 owner 靠前、 + 302 带 owner "Alice" 靠后同位 → 无字体度量下 302 框 yEnd = 15+15+4 = 34,靠前 301 被顶 + (34+5)*0.005 = 0.195(> 无 owner 的 0.1);302 留默认;t302.text == "掉落28 (Alice)" + 证明渲染仍单合并 Label3D)。 + 回归:project/*_test.gd 共 70 个全 PASS(无新增测试文件;PASS=70 FAIL=0)。 +验收: + 已验证(离线):item_box_with_owner 纯函数在「无 owner 退化 / 有 owner 加高 / owner 更宽时 + 收进 ownerW/2+1」三路径与参考 :735-737 逐式对齐;string_*_like 在 label null / 空串 / + 有子串三路径正确、量的是给定子串而非 label.text;ground_items._arrange_tags 对带 owner 的 + 掉落把去重叠框按「名字行 + owner 行 + 4」加高(p2b_test 真 GroundItems 段);全 70 回归。 + 未验证:真机 —— owner 行未真正独立渲染成 CENTER 黄字(poc 仍单条 "name (owner)"), + 故只有「去重叠框度量」1:1、owner 行的视觉呈现仍是折中;strOwnership 只取了 "'s" + 字面值(未接 ApplicationStringTable IDS_POSSESSIVE_MORPHENE 的本地化语素)。 +风险与依赖: + seam ⑩(收窄后剩余):hover 强显(116)+ item 去重叠(120)+ 框宽 w(123)+ 框高 h(124) + + owner 后缀框度量(125)均已 1:1;仍留 —— owner 未拆成独立 text instance(渲染仍单个 + 合并 "name (owner)" Label3D,无 CENTER 黄字 owner 行)、strOwnership 未接本地化语素、 + 去重叠只对掉落 item 名条。 + 忠于参考:{w,h} 推导对 :736/:737 的 fMIN/fMAX 逐式验证(框 x 对称 → box_start_x 免算); + 无字体路径沿用 TAG_CHAR_W_PX 7.0 / TAG_LINE_H_PX 15.0,回退不改既有 headless 行为。 + 低风险:扩两个纯静态模块各 1-2 个方法 + ground_items 一处「框度量来源」替换 + 存两个 meta; + 未碰排布公式 / 去重叠算法 / 信号 / 收包路径 / 渲染节点结构;纯 GDScript。 +下一批次: + §8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条); + seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D); + seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset); + seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + strOwnership 接本地化语素); + seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType 分类,待门实体); + 或独立 whisper dialog 窗口(uiWhisper.py)。 +``` + +### C.5 批次记录 · 增量 126(W3→M2 / §8.8 seam ⑩ 末尾:带 owner 的掉落名条把 owner 后缀拆成独立 CENTER 黄字 text instance + strOwnership 接本地化语素) + +```text +批次:增量 126 —— W3→M2 §8.8 seam ⑩ 末尾收窄:带 owner 的掉落名条,参考端 + CPythonTextTail::SetItemTextTailOwner(dwVID, c_szName)(PythonTextTail.cpp:712-745)把 owner + 后缀挂成「一个与名字实例分开的 CGraphicTextInstance」(:715 New),而不是拼进名字串: + :722 strOwnership = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ... + :723 strName = c_szName + strOwnership + :726 SetTextPointer(ms_pFont) + :727 SetHorizonalAlign(CGraphicTextInstance::HORIZONTAL_ALIGN_CENTER) + :729 SetColor(1.0f, 1.0f, 0.0f) —— 黄 + 排布(ArrangeTextTail :191-207,去重叠内层循环之后、对每条 item 尾标都跑): + :195 pInsertTextTail->pOwnerTextInstance->SetPosition(x, y, z) —— owner 落尾标基点 + :200 pInsertTextTail->pTextInstance->SetPosition(x, y + 15.0f, z) —— 名字实例下移 15px + :206 (无 owner 时)pTextInstance->SetPosition(x, y, z) + owner 为空(:739-745):CGraphicTextInstance::Delete(pOwnerTextInstance)、指针置 NULL、 + 框复位纯名字(增量 125 已实现的 else 分支)。 + 渲染(Render :369-374):item 尾标先画名字实例、再画 pOwnerTextInstance。 + poc 之前(增量 125):owner 只是拼进 "name (owner)" 的单个合并 Label3D,去重叠框按两子串 + 分别度量合成(yEnd += ownerH + 4),但 owner 没有自己的 text instance、没有 CENTER 黄字、 + 名字实例也没有下移 15px。这次把 owner 拆出来。 +状态:✅ 完成(ground_items 加 owner_tag(第二个 Label3D)+ _apply_owner_tag 建/删 + + _place_tail 统一落点 + setup(possessive) 注入;game_scene 从 EterLocale 解析 + POSSESSIVE_MORPHENE 注入。无新增 canonical / 测试文件——扩 ground_items 与 p2b_test) + 🔎 真机目视:地面上有主人的掉落物,主人名(如 "Alice's")以黄色、居中显示在道具名的 + 上方一行(间距 15px),道具名本身在下;无主掉落只有道具名、位置不变;多条名条重叠时按 + 「owner 行 + 名字行 + 4px」的总高把靠前名条挤开(增量 125 的框度量不变)。 + 剩余折中:owner 行颜色恒黄(参考名字实例本身取 pInsertTextTail->Color 的道具品阶色, + poc 名字仍走 _process 的远近绿/金着色近似);去重叠只对掉落 item 名条做。 +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/): + UserInterface/PythonTextTail.cpp:706 void CPythonTextTail::SetItemTextTailOwner(DWORD dwVID, const char * c_szName) + UserInterface/PythonTextTail.cpp:712 if (strlen(c_szName) > 0) + UserInterface/PythonTextTail.cpp:715 pTextTail->pOwnerTextInstance = CGraphicTextInstance::New(); + UserInterface/PythonTextTail.cpp:722 strOwnership = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ... + UserInterface/PythonTextTail.cpp:723 strName += strOwnership; + UserInterface/PythonTextTail.cpp:726 pOwnerTextInstance->SetTextPointer(ms_pFont) + UserInterface/PythonTextTail.cpp:727 pOwnerTextInstance->SetHorizonalAlign(CGraphicTextInstance::HORIZONTAL_ALIGN_CENTER) + UserInterface/PythonTextTail.cpp:728 pOwnerTextInstance->SetValue(strName.c_str()) + UserInterface/PythonTextTail.cpp:729 pOwnerTextInstance->SetColor(1.0f, 1.0f, 0.0f) —— 黄 + UserInterface/PythonTextTail.cpp:741-745 else:CGraphicTextInstance::Delete(pOwnerTextInstance);pOwnerTextInstance = NULL + UserInterface/PythonTextTail.cpp:193-196 ArrangeTextTail:pOwnerTextInstance->SetPosition(x, y, z);Update() + UserInterface/PythonTextTail.cpp:200 pInsertTextTail->pTextInstance->SetPosition(x, y + 15.0f, z) + UserInterface/PythonTextTail.cpp:206 (else,无 owner)pTextInstance->SetPosition(x, y, z) + UserInterface/PythonTextTail.cpp:369-374 Render:item 尾标先画 pTextInstance、再画 pOwnerTextInstance + UserInterface/UserInterface.rc:33/56/237 IDS_POSSESSIVE_MORPHENE "'s"(西文 .rc 的字面值) + UserInterface/resource.h:6 #define IDS_POSSESSIVE_MORPHENE 2 +改动: + project/ui/ground_items.gd(改): + const OWNER_NAME_GAP_PX := 15.0(= ArrangeTextTail :200 的 y + 15.0f); + var _possessive := OWNER_POSSESSIVE; + setup(..., possessive := ""):_possessive = possessive if possessive != "" else + OWNER_POSSESSIVE(1:1 对齐 :722 的 == "" ? "'s" : loc); + _apply_owner_tag(node, owner)(新):owner 空 → 有 owner_tag 则 remove_child + queue_free + (:741-745);否则懒建 Label3D "owner_tag"(name / horizontal_alignment = + HORIZONTAL_ALIGNMENT_CENTER(:727)/ billboard = BILLBOARD_ENABLED / no_depth_test / + pixel_size 0.005 / modulate = Color(1,1,0)(:729)/ position.y = TAG_BASE_Y), + 文本 = owner + _possessive(:723); + _on_added:名字 tag.text = _name_for(vnum)(只放名字,不再 _tag_text 拼 "name (owner)"), + 新建 / 复用两条路径都调 _apply_owner_tag(node|existing, owner); + _place_tail(n, tag, d_px)(新):owner_tag 存在 → owner_tag.position.y = TAG_BASE_Y - + d_px*px、tag.position.y = TAG_BASE_Y - (d_px + OWNER_NAME_GAP_PX)*px(:195 / :200); + 否则 tag.position.y = TAG_BASE_Y - d_px*px(:206); + _arrange_tags:behind-camera / 单条 / 多条排布三处落点都改走 _place_tail(tags 条目多存 "n"); + box 构建里 owner_txt 用 _possessive(原 OWNER_POSSESSIVE 常量); + _tag_text 删除(不再合并渲染);头注释补增量 126。 + project/game_scene.gd(改):ground_items.setup 前,assets_root != "" 时用 EterLocale + (load("res://locale.gd")) 解析 POSSESSIVE_MORPHENE 键(缺则 ""),作第 7 参 possessive 注入 + (对应 :722 ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE))。 + text_tail_arrange.gd / text_metrics.gd 等 canonical 不动(增量 125 的 item_box_with_owner / + string_*_like 仍是去重叠框度量来源,未改)。 +测试: + /opt/homebrew/bin/godot --headless --path project --script p2b_test.gd —— PASS + (seam ⑩ 末尾段重写:drop_302(owner "Alice")——名字实例 t302.text == "掉落28"、 + 独立 owner_tag 存在且 text == "Alice's" / horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER + / modulate ≈ Color(1,1,0) / position.y ≈ TAG_BASE_Y(:195)、名字实例 + position.y ≈ TAG_BASE_Y - 15 * pixel_size(:200);drop_301(无 owner)不建 owner_tag + (:741-745)、仍被带 owner 的框顶 (15+15+4+5)*0.005 = 0.195(增量 125 框高不变); + 新增 mount4/gi4:setup(possessive="@") → drop_401 owner_tag.text == "Bob@")。 + /opt/homebrew/bin/godot --headless --path project --script text_tail_arrange_test.gd —— PASS(未改,item_box_with_owner 不变)。 + /opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS(未改)。 + 回归:project/*_test.gd 共 70 个全 PASS(无新增测试文件;PASS=70 FAIL=0)。 +验收: + 已验证(离线):owner 非空 → drop 节点下确实多出独立 Label3D "owner_tag"(CENTER / 黄 / + 文本 = owner + strOwnership),名字实例只剩名字且下移 15px;owner 为空 → 不建 owner_tag、 + 名字实例落基点;possessive 经 setup 注入生效("@" → "Bob@"),未注入时回落 "'s"; + 去重叠框高仍是增量 125 的 name_h + owner_h + 4(p2b_test 靠前名条被顶量不变);全 70 回归。 + 未验证:真机 —— owner_tag 的 CENTER 对齐 / 黄色 / 15px 行距的实际视觉;EterLocale + POSSESSIVE_MORPHENE 键在 poc 资源里不存在,故实际注入值恒 "'s"(与 40250 西文 .rc 一致, + 但没有真正跑通「本地化语素非空」分支)。 +风险与依赖: + seam ⑩(收窄后剩余):hover 强显(116)+ item 去重叠(120)+ 框宽 w(123)+ 框高 h(124) + + owner 后缀框度量(125)+ owner 独立 CENTER 黄字 text instance + strOwnership 接本地化语素 + (126)均已 1:1;仍留 —— owner 行颜色恒黄(参考名字实例取 pInsertTextTail->Color 的道具 + 品阶色,poc 名字走 _process 远近着色近似)、去重叠只对掉落 item 名条做。 + 忠于参考:owner 落点 (x, y)(:195)、名字落点 (x, y + 15.0f)(:200)逐式对应;CENTER(:727) + / 黄(:729)/ 文本 = owner + strOwnership(:723)逐属性对应;owner 空删实例(:743)对应 + remove_child + queue_free;strOwnership 的 == "" ? "'s" : loc(:722)对应 setup 里的 + possessive if possessive != "" else OWNER_POSSESSIVE。 + 低风险:只改 ground_items(渲染节点多一个子 Label3D + 落点函数)+ game_scene 一处注入 + + p2b_test;未碰去重叠算法 / 排布公式 / 信号 / 收包路径 / 其它 canonical;纯 GDScript。 + 节点结构变化(drop 节点多 owner_tag 子节点)仅 ground drops,hover_at / try_pickup 用 + 节点自身 global_position 不受影响。 +下一批次: + seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D); + seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset); + 完整 Quest / fishing / dungeon / mount / observer 玩法闭环与复杂 UI 分支; + 真实服务器、真机、纯 pack、双架构和正式签名验收。 +``` + +### C.5 批次记录 · 增量 127(W3→M2 / §8.8 屏幕空间血条、实体建筑分类、掉落 owner 品阶色、独立 whisper dialog) + +```text +批次:增量 127 —— 收口当前可在本地完成的四项尾部缺口,并同步修正过时的 seam 注释。 + +1. 屏幕空间 HP 条: + net_world.gd 删除旧的 Sprite3D HpBar,setup() 创建 CanvasLayer / HpOverlay, + _sync_hp_bar() / _sync_hp_bar_values() 保持血量与死亡状态同步,_update_hp_bars() 使用 + 显式 Camera3D.unproject_position() 投影实体头顶锚点。无 max_hp、死亡、镜头后方、屏幕外、 + 实体移除和 clear_for_map_change 都会隐藏或释放条;screen_hp_bar.gd 负责无输入的 Control + 背景 / 边框 / 填充绘制。 + +2. 建筑 / 木门分类: + entity_rules.gd 增加 KIND_BUILDING=5 / KIND_WOODEN_DOOR=6;net_play._entity_kind() 优先 + 消费 building / 显式 kind,再消费 ch_type,并以 mob_proto bType=4 和 race 13000、30111..30119 + 识别木门。EntityRules._is_building() 同时把木门视作 building,目标框不会再把门误判为 PC, + LCONTROL 私聊也只对真实 PC 分支触发。 + +3. 掉落 owner 品阶颜色: + ground_items.gd 的 _item_color() 依次支持显式 item_color / rarity_color / name_color / color + 和 item_grade / rarity / quality / grade;owner_tag 继承同一物品颜色,名字的近距离绿色高亮仍 + 只作用于名字实例。这样默认旧数据保持金色 fallback,同时新数据不再固定纯黄色 owner 行。 + +4. 独立 whisper dialog: + chat_ui.gd 构建同级 WhisperDialog,LCONTROL 点同阵营其他 PC 经 net_play.whisper_requested + 和 game_scene 接到 start_whisper();目标只读、内容单独输入,发送后调用 client.whisper() 并关闭, + 取消 / ESC 只关闭,不污染公共聊天输入。 + +回归: + cmake --build build -j2 && ctest --test-dir build --output-on-failure —— CTest 21/21 PASS。 + /opt/homebrew/bin/godot --headless --rendering-method gl_compatibility --path project + --script .gd —— project 下 71 个 GDScript 测试 PASS。 + eterngrn_polish_test 的 Audio fixture 补齐 set_listener / play_character_sound_3d;p10_test + 的 serverinfo fixture 改用 /tmp,适配受限 headless 环境。macOS 系统证书读取噪声、受限 TCP + bind 的 skip 仍只记录为环境限制;真实服务器 / 真机 / 正式资源包未验收。 + +当前遗留:seam ⑨ 名字与气泡合并为单尾标链表,以及 Quest / fishing / dungeon / mount / observer +完整玩法、复杂 UI 分支、真实服务器和发布验收。 +``` + +### C.5 批次记录 · 增量 128(W3→M2 / §8.8 seam ⑦ 公会徽记尾标接入 + §5.1 `[DONE]` 点击语义) + +```text +批次:增量 128 —— 收口当前可在本地完成的两项尾部缺口,并同步修正 Quest DONE 的回调时机。 + +1. 公会会徽接入角色头顶尾标: + net_world.gd 监听客户端会徽缓存的 guild_marks_ready / guild_mark_updated。角色实体先于 + 会徽下载完成时,仍先生成名字、公会名、称号和等级;会徽准备好后按 guild_id 查图并懒建 + GuildMark Sprite3D(BILLBOARD、no_depth_test、16x12 像素尺寸、NEAREST),更新事件复用 + 既有节点替换纹理,不重复创建;公会清除或查不到图片时 queue_free 旧节点。 + _layout_subtags_x() 在存在会徽时按参考 mark_x_offset / guild_name_x_offset 排布会徽与公会名, + _reposition_name_tag() 让会徽随聊天气泡造成的名字尾标上移。会徽到达晚于角色、纹理增量更新、 + 公会移除后的旧节点清理均由 project/guild_mark_tail_test.gd 覆盖。 + +2. Quest [DONE] 回调时机: + quest_dialog.gd 的 [DONE] 只设置 next_button_type = DONE 并呈现关闭按钮,不在 parse_script() + 或接收脚本时发出 done_event;按钮实际 pressed 回调才发 done_event,再发送 script_answer(255) + 并关闭窗口。这样与参考端 MakeNextButton(BUTTON_TYPE_DONE) 的按钮回调语义一致,普通 + [NEXT] 仍只发送 script_answer(255)。project/quest_test.gd 覆盖解析无副作用与点击触发。 + +回归: + /opt/homebrew/bin/godot --headless --display-driver headless --audio-driver Dummy + --rendering-method gl_compatibility --path project --script .gd —— project 下 74/74 + 个 GDScript 测试 PASS;其中新增 guild_mark_tail_test.gd PASS,quest_test.gd PASS。 + ctest --test-dir build --output-on-failure —— CTest 22/22 PASS。 + macOS headless 的系统证书读取噪声、个别既有 fixture 的资源泄漏提示和受限 TCP bind / 连接失败 + 仅属于环境或测试替身限制,未将其记为代码失败。 + +验收边界: + 已验证(离线):角色先生成后下载会徽、会徽纹理替换复用节点、会徽 / 公会名相对排布、聊天气泡 + 上移跟随、公会消失清理;[DONE] 解析阶段不触发、点击后触发 done_event 与 script_answer(255)。 + 未验证:真实公会会徽下载协议、真机视觉;单尾标链表(名字与气泡仍是两个节点)、完整 .msc + 电影播放器、Quest/fishing/dungeon/mount/observer 完整玩法、复杂 UI 分支、真实服务器、 + 纯 pack、双架构和正式签名验收。 + +下一批次: + seam ⑨ 名字与气泡合并为单尾标链表;完整 Quest / fishing / dungeon / mount / observer 玩法与 + 复杂 UI 分支;真实服务器、真机、纯 pack、双架构和正式签名验收。 +``` diff --git a/docs/CLIENT-GAP.md b/docs/CLIENT-GAP.md index 9017f45d..d88252f1 100644 --- a/docs/CLIENT-GAP.md +++ b/docs/CLIENT-GAP.md @@ -9,12 +9,12 @@ > 渲染细节另见 `PARITY-GAP.md`,动画、材质和资产管线另见 `BACKLOG.md`,网络实现另见 > `CLIENT-PORT.md`。 > -> **本文末「ClientVS22 实现细节审计(2026-09-02)」每一条的具体修改细节见 +> **本文末「ClientVS22 实现细节审计(2026-09-04)」每一条的具体修改细节见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)**(逐条给出 40250/ClientVS22 的参考 > 源码位置、常量与算法,以及本工程要改的文件/函数;其附录 A 列出本节中 > 需要订正的条目,分「参考行为不符 / 表述需加限定 / 低估了当前差距」三档)。 > -> 更新日期:2026-09-02(对照 40250/ClientVS22 复核) +> 更新日期:2026-09-04(对照 40250/ClientVS22 复核) > > **状态列图例**:✅ 达到本文末“1:1 完成判据” · 🟡 有首版 / 部分语义 · ⬜ 未实现 · > ❌ 明确不做(超出 40250/ClientVS22 参考范围,除非另立需求)。 @@ -51,17 +51,36 @@ 6. **合并顺序不等于开发顺序。** 只要依赖接口已经冻结,W1–W5 可以同时开发;只有触及共享状态或 集成门禁时才回到 W0 排队。 -### 当前并行进度(2026-09-03) +### 当前并行进度(2026-09-04) | 工作流 | 状态 | 当前已知进展 | 下一步 | |---|---|---|---| | W0 公共基础 / 集成 | 🟡 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版已落地;增量 49 接口冻结 + 增量 51 AppFlow 单一 `AppLifecycle` + 增量 52 `net_bounds` 已接入,GameScene 不再重复创建,当前 C++ CTest 15/15 与生命周期 / 场景回归通过 | Phase 1 4 工作流并行完成后执行 G1 集成;真机生命周期 / 业务边界验收 | | W1 协议 / 账号会话 | 🟡 | 增量 50:§1.5 版本包按 locale 分支(EUROPE 发 `CG_CLIENT_VERSION2` 0xf1 + `"1215955205"`,其余发 0xfd + `MT_BUILD_TIMESTAMP`)、可执行名 setter;§1.10 `text_codec.h` UTF-8 安全定长文本裁剪 + `net_text_codec_test`,会话侧所有定长 name/sign/comment 填充改走 `to_wire()`;§2.2 `GC_CHARACTER_ADD` 按 invisible-race 丢弃 / PC·NPC 暂存 `m_pending_actor` / 其余即时 `mut_spawn`(名字取自 npclist.txt),`GC_CHAR_ADDITIONAL_INFO` 无暂存则记录并丢弃、命中则合并 9 字段 `mut_spawn_full` 后清表 | 真实 locale codepage 转码、真实双连接、enter delay;GDScript 名称长度校验统一(G1 已收) | | W2 实体 / 世界 / 战斗 | 🟡 | §2.1 两层模型(`EntityStore` 纯数据层 + `net_world.gd` 每帧 `_update_visibility()` 按 `CHAR_STAGE_VIEW_BOUND` / `AFFECT_SHOW_ALWAYS` / `IsWall` 建 / 淡出节点)、§2.3(`mut_spawn`→`mut_spawn_full` 整行替换 + 节点销毁重建 + 上下坐骑保坐标)、§2.4(点数快照截断 / 尾部清零 + 单索引边界)、§2.5(`mut_affect_flags` 两段拼装 + 参考调用序节点刷新)已落地并回归(增量 50);增量 52 补齐 M2Client 公共动作 / 物品 / 交互入口的 wire 参数边界;增量 73(§3.7)删除 `net_play.gd` 的本地硬直计时器 `_hitstun_until`,「能否行动」统一走 `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`(对齐 `InstanceBase.cpp:1247` / `InstanceBaseBattle.cpp:355` / `:310`),`Entity` 增加 `knock_down`(受击 `.msa` 播放生命周期驱动、动作播完清零),`dead` / `stunned` 保持服务端包驱动;增量 74(§3.3)`formats/msa` 新增 `ComboInputData` / `AttackingData` 解析、`metin2_anim.get_motion_data()`,`net_play.gd` 删除 `ATTACK_RANGE` 改 `CLICK_DIST_DEFAULT/NPC/RESOURCE_CM`(对齐 `InstanceBaseBattle.cpp:294`),普攻节奏改为 `.msa` `DirectInputTime`(缺组退化 `duration*0.9`)除以 `bAttackSpeed/100`;增量 75(§3.4)`net_play.gd` 新增 `enum ReservedMode` + `_update_auto_attack()` + `_reserve_process_click_actor()` 严格 10 步(对齐 `PythonPlayer.cpp:207` / `PythonPlayerInput.cpp:877`),出距离 `pc.walk_to` 靠近、NPC 走 `click_npc`、`IN_SAFE` / `EMPTY_ARROW` 经 `signal cannot_act`;增量 76(§3.1)`player_controller.gd` 去掉直线改坐标,改 `_src_pos` / `_dst_pos` / `_is_going` / `_dst_rot`,`walk_to()` → `_goto()` 对齐 `NEW_Goto`(`InstanceBaseMovement.cpp:251`:太近不动、三门 `_can_translate()` 未过 → `__ReserveClickGround` 0.1 s 重试、`moving_skill` 只转向),新增 `player_move_test.gd`;增量 77(§3.2 命令队列 part A)`EntityStore` 把远端 Actor 位置改为 `std::deque` 命令队列驱动(对齐 `InstanceBase.cpp:1278 StateProcess`),`mut_move()` 入队(≈ `PushTCPState`)+ `drain_state_queue()` 释放到期项、删旧 `start_move()` 直改坐标、两套后端统一走 `mut_move(...,arg,time)`,新增 `process_states()` / `apply_state_cmd()` 1:1 复刻 switch(`FUNC_WAIT` 1.0、`FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 硬阈值 + `m_kMovAfterFunc` 到达后动作 + `skip_collision`),释放门按 `set_server_frame_ms()`(为 0 = 帧钟未对齐即时释放)+ `__CanProcessNetworkStatePacket`(`dead` / `knock_down` 挂队),新增 `net_state_queue_test`(5 组)、C++ 20/20;增量 78(§3.2 part B)`net_play.gd` 上行按 `PythonPlayerEventHandler.cpp` 拆成 6 回调(统一出口 `_send_state`):OnMove 立即 `FUNC_MOVE` + 重置 moving/waiting 节流窗、OnMoving 300 ms 节流、OnWaiting(新 `_tick_on_waiting`)100 ms + 位移 ≥ 1 cm、OnStop `FUNC_WAIT`、OnAttack 额外 `FUNC_COMBO`、新公共入口 `on_use_skill()` → `FUNC_SKILL|motion`;删旧 `SEND_INTERVAL/SEND_DIST` 单一节流;`net_world.gd` 去掉自有 `follow_lerp`,`_process` 直接 `n.position = e.pos`(插值全交 C++ `tick()`);`netplay_test` 加第 11 段、`netbridge_test` near-move 断言改写,7 个 GDScript 回归 + C++ 20/20 全绿;增量 79(§3.2 part C 收官)`ClassicStream::server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`,由 `HDR_HANDSHAKE` `m_server_time_base = hs.time + lDelta` / `m_client_time_base` 算出)经 `ClassicSession::process()` 每帧 `set_server_frame_ms()` 喂给命令队列(m2dev 恒 0 = 即时释放,无回归),`drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS=1000`(异帧钟 `dwTime` 立即放行,有意偏离参考端),`net_classic_stream_test` + `net_state_queue_test` 补覆盖,C++ 20/20 + 7 GDScript 全绿;增量 80(§3.5 part A)查明 PC 连击段表**不在** `.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`),实为 `assets/root/playersettingmodule.py` 的 `chrmgr.ReserveComboAttackNew` / `RegisterComboAttackNew` 调用建在 `CRaceData` 上,新增 `formats/combo_table.{h,cpp}`(`fmt::PlayerComboTables`)按 `def __LoadGameEx` 分四职业块解析为 `map>`,语义对齐 `CRaceData::Reserve/RegisterComboAttack`(insert 不覆盖 / 越界跳过),新增 `formats_combo_table_test`(合成 + 真实 `playersettingmodule.py` 双覆盖),C++ 21/21 + 7 GDScript 全绿;增量 81(§3.5 part B)`net_play.gd` 用此表 + `.msa` `ComboInputData` 三时间实现连击状态机(`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐 `ActorInstanceBattle.cpp:157/213/256/297/308`),替掉 `(_combo + 1) % 3`,段号经新静态绑定 `Metin2AnimPlayer.parse_combo_tables()` 按 `race & 3` × `combo_motion_mode<<16\|type` 取,缺段表退化单段普攻,`game_scene` 装配时 `set_asset_root()`,`combat_fx_test` 加 `FakeAnim` 全路径断言,C++ 21/21 + 7 GDScript 全绿;增量 82(§3.5 part C,修改 1)导出 `.msa` `HitDataContainer` 多命中窗:`textscript.cpp` 补 `List Name { ... }` 解析(此前 `List HitPosition` 破坏后续 `Group`),`formats/msa.{h,cpp}` `AttackingData` → `std::vector`(`{start_time,end_time,bone_name,weapon_length,samples}`,吃老式内联 + 新式 `HitDataCount N` / `Group HitData0i`),新增 `motion_type` / `hit_limit_count`,`get_motion_data()` 增 `hit_windows` / `motion_type` / `hit_limit_count`,`formats_msa_test` 补合成 + `M2_ASSETS` 真机断言(真实双命中窗 `assassin/dualhand_sword/combo_01.msa`),C++ 21/21 + 7 GDScript 全绿;增量 83(§3.5 修改 4,收官)`net_play.gd` 新增 `_begin_hit_windows` / `_attack_process(dt)` / `_hit_deduped` / `_register_hit` / `_hit_geometry` / `_flush_victim_list`:逐帧按动作时间窗与 `hit_window [start,end]` 相交对 `client.get_entities()` 可攻击 / 未死 / 非安全区实体做几何判定(`m_HitDataMap` 查重 + 每窗命中上限 NORMAL/COMBO 16 / SKILL `hit_limit_count`),命中 → `client.attack(_swing_skill, vid)`(`OnHit`),被击退目标帧末 `client.sync_positions()`(≤16);`_emit_swing` 改为只有缺命中窗数据才在挥击瞬间发 `CG_ATTACK`;几何判定 = `reach`(max WeaponLength / 采样水平偏移)+ 正面 ±60° 弧近似(真实动态圆柱扫掠随 §3.1 留桩),`combat_fx_test` 加 `FakeNetWorld` + 6 段断言,C++ 21/21 + 7 GDScript 全绿;增量 84(§3.5 修改 6,§3.5 修改 1-6 全落地)`net_play.gd` 补全 `MOTION_MODE_*`(`EMode` 17 项)+ `ITEM_TYPE_*` / `WEAPON_SUB_*` 常量,新增静态 `motion_mode_for(item_type, weapon_sub, mounted, poly, dress)` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode`(`InstanceBase.cpp:2851`:Poly→GENERAL / 婚纱→WEDDING_DRESS / 镐·钓竿→骑乘 HORSE 否则 GENERAL·FISHING / 骑乘 switch → HORSE_* / else switch → ONEHAND·TWOHAND·DUALHAND_SWORD·BOW·FAN·BELL·默认 GENERAL),`_equipped_weapon_vnum()`(`parts[WEAPON]` 优先,否则 `get_equipment()[WEAR_WEAPON].vnum`)+ `_refresh_motion_mode()`(读 `mount_vnum`/`polymorph`/`wedding_dress` + `proto.item(vnum).{type,sub_type}` → `set_combo_motion_mode()`,换模式内部 `_clear_combo()`),由 `_on_entity_info()` 末尾 / `inventory_changed`(EQUIPMENT) / `mount_changed`(主角) 三处触发,`combat_fx_test` 加 `FakeProto` + 15 段 `motion_mode_for` + 2 段端到端断言,C++ 21/21 + 7 GDScript(含 `equip_model_test`)全绿;增量 85(§3.5 修改 7,§3.5 修改 1-7 全落地)`net_play.gd` `_clickable_distance()` 逐分支对齐 `NEW_IsClickableDistanceDestInstance`(`InstanceBaseBattle.cpp:294`:默认 150 → `_is_bow_mode()`(`MODE_BOW`/`MODE_HORSE_BOW`)改用 `_bow_range_cm()` → NPC 500 → 采集物 100,后写 `if` 覆盖前面),`_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus`(1:1 复刻 `CInstanceBase::__GetBowRange` `InstanceBase.cpp:694`),`_bow_distance_bonus` 由 `_on_points()` 读 `points["bow_distance"]`,`wire.h` 补 `POINT_BOW_DISTANCE=34` + `get_points()` 透出,`combat_fx_test` §3.5 段加 8 段断言,C++ 21/21 + 7 GDScript 全绿;增量 86(§3.6,修改 1-3 全落地)`game_scene.gd` 单槽 `_pending_shoot_skill` + `create_timer(1.5)` 换成 `_pending_shots: Array[int]` FIFO(上限 8):弓挥击 / 弓技能起手 `_queue_shot(uSkill)` 压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧 `pop_front()` → `client.shoot()`(AutoClear + 连射并发,对齐 `ActorInstanceMotionEvent.cpp:290`);`OnSetFlyTarget` 移到 `net_play._emit_swing` 的 `_send_fly_target()`(目标节点 → `MapCoord.to_server_cm()` → `client.add_fly_targeting()`,对齐 `ActorInstanceMotion.cpp:372` + `ActorInstanceFly.cpp:4`),新增信号 `bow_shot_fired`,`combat_fx_test` +6 段 / `eterngrn_polish_test` FLY 段改 FIFO 连射断言,C++ 21/21 + 9 GDScript 全绿;增量 87(§3.8 修改 2)POC 无 C++ skill proto,故在 `ui/skill_table.gd` 按 `RegisterSkillDesc`(`PythonSkill.cpp:280`)把 skilldesc 第 11 列 attributes / 第 12 列 weapon 的 `|` 分隔记号折成 `attr_bits` / `need_weapon_bits` 位掩码 + `skill_type`(`_fold_bits()` 静态方法,记号名 1:1 抄 `PythonSkill.h:92-128`;`DOUBLE_SWORD` 复用 `DAGGER` 位),`CPythonSkill` 谓词逐条落地(`is_can_use_skill` / `is_standing` / `is_need_target` / `is_need_corpse` / `is_auto_search_target` / `can_change_direction` / `is_horse_skill` / `is_moving_skill` / `is_melee` / `is_charge_skill` / `is_use_hp` / `is_time_increase` / `is_need_empty_bottle` / `is_need_poison_bottle` / `can_use_for_me` / `is_only_for_alliance` / `can_use_if_not_enough` / `is_only_for_guild_war` / `is_fan_range` / `is_circle_range` / `has_weapon_limitation` / `can_use_weapon_type` / `is_need_bow` / `skill_type_of` / `is_guild_skill`;旧 `is_attack` / `is_passive` / `is_toggle` / `is_ranged` / `can_level_up` 改查位),`skill_test.gd` +34 断言,C++ 21/21 + 9 GDScript 全绿;增量 88(§3.8 修改 1)新建 `project/player_skill.gd`(`extends RefCounted` 纯校验器),逐行移植 `PythonPlayerSkill.cpp` 的 (a)`click_skill_slot`(`:106`)/ (b)`can_use_skill`(`:439`,硬编码骑乘术 `109`)/ (c)`check_skill_usable`(`:166` 完整前置顺序)/ (d)`use_skill`(`:460` + `_resolve_target` 目标矩阵)四层,返回 `{ok, code, target_vid}`,命名常量 `SKILL_MAX_NUM=255` / `SKILL_TYPE_GUILD=3` / `RIDING_SKILL_INDEX=109` / 空瓶 `27995` / 毒瓶 `27996` / 连击 `122` / 钓鱼 `123`,`SILENT_CODES[]` + `static is_silent_code()`;`net_play.skill_context()` 每次注入运行期状态,`ui/quickbar.gd activate()` 施放前跑三层、非静默码 → 新信号 `skill_rejected` → `game_scene` 转 `net_play.cannot_act`(同一 `OnCannotUseSkill` 通道)、gate 的 `target_vid` 覆盖点击目标;`__CheckShortMana` / `__CheckShortLife` 打桩恒 false(无 `.msk` NeedSP/NeedHP 解析);新增 `player_skill_test.gd`,C++ 21/21 + 12 GDScript 全绿;增量 89(§3.8 修改 3,三层全落地)`net_bounds.h` `SKILL_MAX_NUM=255` + `skill_index()` 谓词,`M2Client::use_skill()` 的 `skill_id` 护栏收紧成 `!bounds::skill_index()`(对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM` 门),`skill_table.gd` 解析 `.msk` `NeedSPFormula` + `_SKILL_POWER[]` → `need_sp` / `need_hp` / `_skill_point`,`player_skill.gd` `_check_short_mana` / `_check_short_life` 从桩改为移植 `__CheckShortMana`(`:320`)/ `__CheckShortLife`(`:354`),HP/SP 经 `skill_context()` 读 `get_points()` 供入、`-1` 不判;`net_bounds_test.cpp` +4、`player_skill_test.gd` +“修改 3”段,C++ 21/21 + 13 GDScript 全绿;增量 90(§3.10 技能升级门)`wire.h` +`POINT_SUB_SKILL=27` / `POINT_SKILL=28` / `POINT_HORSE_SKILL=113`、`M2Client::get_points()` 透出 `skill_active` / `skill_support` / `skill_horse`,`M2Client::skill_up()` 入参收紧为 `!bounds::skill_index()`(与 `use_skill` 同一谓词),`skill_table.gd` +`max_level_of` / `skill_level_limit`(本 40250 数据无 `LEVEL_LIMIT` 列 / `.msk` 记号 → 恒 0)/ `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,本 build 前置技能 / 前置属性字段无人填充 → 化简为 `level < GetMaxLevel() && !CANNOT_LEVEL_UP`),`skill_ui.gd` `refresh()` 逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`(技能组门 `get_skill_group()!=0` + 三技能点来源分开取 + `skillGrade==0` + 马术页 `level<20` + 主动页 `can_level_up_skill` + 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐),按钮 `_rebuild()` 建 / `refresh()` 切 `visible`,升级结果由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动、不本地伪造,`skill_test.gd` +`§3.10` 段,C++ 21/21 + 13 GDScript 全绿;增量 91(§3.4 其余三种预约模式)`net_play.gd` `_refresh_reserved()` 由「只 CLICK_ACTOR」改四路 `match` 逐行对齐 `NEW_RefreshMouseWalkingDirection`(`PythonPlayerInputMouse.cpp:287`):`_reserve_process_click_item()`(IID 查掉落物世界坐标,`< 20cm` `CLICK_ITEM_PICKUP_CM` → `FUNC_WAIT`+`pickup_item`+解冻+清,否则 `walk_to`;物品消失→清)、`_reserve_process_click_position()`(`_can_translate()` 未锁 && `_reserved_delay<=0` → `walk_to(_ppos_reserved)`+清)、`_reserve_process_use_skill()`(`dist_cm` 含 `_is_huge_race` 的 `-200cm` `HUGE_RACE_DIST_FIX_CM` 修正,`< _skill_range_reserved` → `set_target`+`use_skill_hook(slot)` 成功才清,否则 `walk_to`;目标丢失→清);预约入口 `reserve_click_item` / `reserve_click_ground`(`_reserved_delay=0.1`)/ `reserve_use_skill`(= `__ReserveUseSkill` `PythonPlayerInput.cpp:854`,`range>100→-10` `RESERVE_SKILL_RANGE_TRIM_CM`)/ `pick_ground_item`(= `__OnPressItem`);`_on_pick()` 补 `__OnPressActor` 特例(`_reserved_mode==USE_SKILL && _dash_active` 只改 `_vid_reserved`),战斗目标改走 `_change_target_to_picked_instance(vid)`(= `__ChangeTargetToPickedInstance` `PythonPlayerInput.cpp:186` → `SetTarget` :130,`IsTargetableInstance` 限 PC/怪否则 `set_target(0)`+`clear_target`);`_is_huge_race(race)` 因 `IS_HUGE_RACE` vnum 白名单未随源码发布 → `proto.mob(race)` `size>=MOBSIZE_HUGE(3)` 或 `rank>=BOSS(4)` 近似(seam);扇形/圆形补 fly-targeting(`PythonPlayerSkill.cpp:680-746` `if (dwTargetMaxCount>0 …)` 块)`net_play.send_fly_targeting(target_vid, range_cm, max_count, shape)`——主目标 `CG_FLY_TARGETING`+`CG_USE_SKILL` 仍由 `M2Client::use_skill` 发,这里只补附加目标——`_instances_in_fan_range()`(= `NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`:随距离收窄半角 `(MAX-MIN)-RPM·min(dist,1000)+MIN`,`FAN_HALF_ROT_MIN=20`/`MAX=40`/`MIN_DISTANCE=1000`)/ `_instances_in_circle_range()`(:243,无方位过滤)内其它可攻击敌人经 `client.add_fly_targeting(vid, sx, sy)` 补齐,名额不足时 `_random_pos_in_fan_range()`(= `NEW_GetRandomPositionInFanRange` :124:目标方位 ±10° `FAN_RANDOM_JITTER_DEG` 抖动、沿该方向 `FAN_RANDOM_REACH_CM=8000cm` 取点)→ `add_fly_targeting(0, sx, sy)`;`ui/skill_table.gd` `_load_msk` 补 `.msk` `TargetCountFormula` / `Range` 解析,新增 `target_count(id, level)`(= `GetTargetCount` `PythonSkill.cpp:1361`)/ `target_range(id)`(= `__GetSkillTargetRange` 基础项 `PythonPlayerSkill.cpp:391`)/ `fly_shape(id)`(`FAN_RANGE`→1 / `CIRCLE_RANGE`→2 / 否则 0);`ui/quickbar.gd` `activate()` 里 `use_skill` 成功后 `target_vid!=0` && 范围技 → 调 `send_fly_targeting(...)`,新增 `_skill_level(sid)` / `activate_reserved(global_slot)`;`game_scene.gd` 接 `net_play.use_skill_hook = func(slot): return quickbar.activate_reserved(slot)`;`netplay_test` +「5c」段、`skill_test` 补 skill 47/91/1,C++ 21/21(无 C++ 改动)+ 13 GDScript 全绿;增量 92(§3.6 飞行物实体)新增 `project/fly_object.gd`(`FlyData` / `FlyInstance` / `FlyManager` 对齐 `GameLib/FlyingObjectManager.{h,cpp}` + `FlyingInstance.{h,cpp}` + `FlyingData.cpp __Initialize()`:`Create`→`__SetDataPointer`→`__SetTargetDirection`,`Update()` 逐帧 `vel+=accel*dt`→`remain_range-=|movement|`→线段-点 `< bomb_range²` 命中→`OnShootDamage`/pierce/`__Explode`/`__Bomb`,超程→`__Explode(false)` 不 `__Bomb`,`m_bHitOnBackground` 触地;`_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80`),`net_world._on_fly` 把原 tween「发光球」占位换成 `_fly_mgr.spawn(...)` 真实弹道 + 新 `signal fly_exploded`,`net_play._can_shot()` 防御式重建 `__CanShot`(`grep` 全树无定义——非弓 `NOT_BOW_MODE` / 无箭 `EMPTY_ARROW` / 无目标 `NO_TARGET` / 死目标 / 出弓程 `OUT_OF_RANGE`),`_emit_swing` 弓分支据此走 `OnNoArrow` / `OnNoTarget` 文案并跳过 `CG_FLY_TARGETING` / `OnShoot`;新增 `project/fly_test.gd`(第 14 个 GDScript 回归),C++ 21/21(无 C++ 改动)+ 14 GDScript 全绿;seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70)→ 实际驱动走 POC 自有 `fly_cue`、重力参考端作用于 `.z`=上本 POC 映射 `-Y`、怪物球体碰撞 / `CullingMgr` / 建筑体素未移植(客户端只做命中时机)、`.fly` 加载器缺失 → 合成箭参数;增量 93(§3.1 收尾)`player_controller._actor_blocked()` 近似 `TestActorCollision`(8 m 距离门 `ACTOR_COLLIDE_MAX_DIST_M` ≈ `LengthSq > 800²` + body 球重叠 `ACTOR_BODY_RADIUS_M=0.55` 相加 + 趋近判定 `d_next <= d_now` 才挡)+ `_process` 平移逐轴门控(单 Actor 逐轴放行=滑移 ≈ `AdjustDynamicCollisionMovement`,两轴都挡=停在上一帧位 ≈ `uCollisionCount==2` → `BlockMovement()`/`__InitializeMovement`),候选 = `pickables`、`dead` meta 跳过(≈ `rVictim.IsDead()`),`skip_actor_collision`(≈ `CActorInstance::CanSkipCollision()` / `__EnableSkipCollision`)由 `net_play.setup()` / `_on_observer_mode` 按 `_observer_mode` 置位;坐骑转向速度 `player_controller.rotation_speed_deg` + `_turn_toward(target_yaw, dt)` 逐帧限幅收敛替换原两处 `rotation.y = atan2()` 瞬转,`net_play._refresh_motion_mode()` `mounted` 切 `ROT_SPEED_HORSE_DEG=300`(`c_fDefaultHorseRotationSpeed`,对齐 `CInstanceBase::MountHorse` → `SetRotationSpeed` `InstanceBase.cpp:954`)/ 下马 `ROT_SPEED_DEFAULT_DEG=1200`(`MODE_HORSE*` 动作模式已由增量 84 覆盖),`player_move_test.gd` 5→8 组,C++ 21/21(无 C++ 改动)+ 14 GDScript 全绿;seam:真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`),`AdjustDynamicCollisionMovement` 精确滑移向量 + 门 / Actor 复合的 `uCollisionCount==2` 用逐轴近似代替,`EnableSkipCollision` 尚未接到 §3.2 网络状态包驱动的本地预测玩家,`MOVABLE_GROUND_DISTANCE` 参考默认值仍是估计(1.0 m);增量 94(§5.4,W3→M2 起点)新建 `entity_rules.gd`,`is_attackable_instance(self_e, victim_e, ctx)` 逐行照抄 `CInstanceBase::IsAttackableInstance`(`InstanceBase.cpp:2147`)+ `alignment_grade`/`alignment_type`(:502/:524)/ `is_conflict_alignment_instance`(:2116)/ `_is_same_empire`(:470)/ `_is_pvp_instance`(`InstanceBaseEffect.cpp:505`)/ `_find_duel_key`(:495)/ `_is_same_party`(`PythonPlayer.cpp:1445`)/ `_is_wooden_door`(:2390),`net_play._is_attackable` 从 `_entity_kind in [0,2]` 桩改为拼 `self_e`/`victim_e`/`_attack_ctx()`(`get_duel`/`get_pvp_relations`/`get_guild_wars`/`get_party`)委托 `EntityRules`,`entity_rules_test.gd`(第 15 个 canonical,25 断言)+ 3 个既有测试的怪物夹具补 `ch_type`;seam:`__FindPVPKey`(`GC_PVP`)/ `__FindGVGKey`(公会战对局)/ `IsGameMaster` / `IsWall` race 白名单 / m2dev `IsKiller` 位的 packet setter,名字颜色 §8.8、`IsInSafe` 的 `ATTRIBUTE_BANPK` 未接;留给增量 95+:§5.1 quest 38 tag、§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt | 远服 40250 端到端可见性 / VID 复用 / affect 表现验收;远端实体 `knock_down` 接线、真实受击 / 击退 / `AFFECT_STUN` 清除时序;§3.5 命中帧绑定 / `ReservedMode` 的 CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派;真实服务器 `GC_MOVE` `dwTime` 时基联机核验、`__IsEnableTCPProcess` 表情锁、`GC_SYNC_POSITION` 是否清队列(随对应 §3.x);§5.4 的 `GC_PVP` / 公会战对局 key setter、名字颜色、保护区地形属性;技能 / 目标语义与服务端权限仍需补齐 | -| W3 玩法 UI / 输入 | 🟡 | 增量 53–62 已覆盖快捷栏、输入入口、物品窗口 / 拖放、tooltip 基础字段、任务条带、地图信号和帮助窗;增量 95 完成 Quest EventManager 常用标签分派;增量 96–98 完成 wear 槽位 / 装备门 / 部位遮挡;增量 99 完成远端 PC `RemotePlayerView` + `awPart[4]` 四字段换装;增量 101 完成 Quest 日志 `QUEST_MAX_NUM=5` 五行窗口、滚动起始索引、默认 icon、倒计时和任务行事件;增量 108 完成小地图任务信标越界夹圆周 + 方向箭头(世界箭头 / 屏幕边缘指示,对照基线 m2dev `PythonMiniMap.cpp:441-462`);增量 109 完成 quest 信件按钮条 `__ArrangeQuestButton` 多列定位 + 最新在前 + 组队窗 `xPos` 移位;增量 110 完成头顶名字颜色 `CInstanceBase::GetNameColorIndex` / `SetEmpireNameMode` 1:1(`name_color.gd` 纯静态 + `net_world.gd` 事件驱动 / ~2Hz 兜底重染,帝国名色默认关);增量 111 完成头顶公会名 / 称号色 `GetTitleColor` / 等级子标签 1:1(`text_tail.gd` 纯静态 `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle` + `net_world.gd` `GuildTag` / `TitleTag` / `LevelTag` 三 `Label3D` 子节点);增量 112 完成头顶聊天 / 信息尾标 `RegisterChatTail` / `RegisterInfoTail` 1:1(`chat_tail.gd` 纯静态 `LivingTime` 5s 硬删 / `"名字: "` 前缀剥离 / SHOUT·COMMAND 排除 + `net_world.gd` 每 vid 一条 `"bubble"` `Label3D`、白 / 淡红按 chat/info、新增 `register_info_tail()`);增量 113 完成头顶名字「是否显示」1:1(`name_show.gd` 纯静态 `IsAlwaysShowName` / `__IsShowName`=`总是显示名字选项 or LALT` / `ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪 / `ShowCharacterTextTail` 的 `isShow · !IsGuildWall · CanPickInstance` 资格判定 + `net_world._name_shown()` 四路组装:uigameoption `always_show_name` radio(经 `game_option_ui` 新信号)/ LALT / `net_play.target_changed` 目标强显 / chat 尾标 `bNameFlag` 强显);相关离线回归通过 | CJK IME、窗口视觉 / `.sub` 资源、完整交易事务、复杂 mousemodule 规则、EventManager 逐帧 / `.msc` / 相机消费端、任务日志窗体自身多列 / 分页、tooltip 特殊分支 / 装备对比、`text_tail.gd` 伤害数字(`GC_DAMAGE_INFO` / `AddDamageEffect`)/ 屏幕空间血条整合 / 目标高亮框、悬停 `chr.Pick()` 强显(seam ⑩)、真服和真机仍未验收 | +| W3 玩法 UI / 输入 | 🟡 | 增量 53–62 已覆盖快捷栏、输入入口、物品窗口 / 拖放、tooltip 基础字段、任务条带、地图信号和帮助窗;增量 95 完成 Quest EventManager 常用标签分派;增量 96–98 完成 wear 槽位 / 装备门 / 部位遮挡;增量 99 完成远端 PC `RemotePlayerView` + `awPart[4]` 四字段换装;增量 101 完成 Quest 日志 `QUEST_MAX_NUM=5` 五行窗口、滚动起始索引、默认 icon、倒计时和任务行事件;增量 108 完成小地图任务信标越界夹圆周 + 方向箭头(世界箭头 / 屏幕边缘指示,对照基线 m2dev `PythonMiniMap.cpp:441-462`);增量 109 完成 quest 信件按钮条 `__ArrangeQuestButton` 多列定位 + 最新在前 + 组队窗 `xPos` 移位;增量 110 完成头顶名字颜色 `CInstanceBase::GetNameColorIndex` / `SetEmpireNameMode` 1:1(`name_color.gd` 纯静态 + `net_world.gd` 事件驱动 / ~2Hz 兜底重染,帝国名色默认关);增量 111 完成头顶公会名 / 称号色 `GetTitleColor` / 等级子标签 1:1(`text_tail.gd` 纯静态 `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle` + `net_world.gd` `GuildTag` / `TitleTag` / `LevelTag` 三 `Label3D` 子节点);增量 112 完成头顶聊天 / 信息尾标 `RegisterChatTail` / `RegisterInfoTail` 1:1(`chat_tail.gd` 纯静态 `LivingTime` 5s 硬删 / `"名字: "` 前缀剥离 / SHOUT·COMMAND 排除 + `net_world.gd` 每 vid 一条 `"bubble"` `Label3D`、白 / 淡红按 chat/info、新增 `register_info_tail()`);增量 113 完成头顶名字「是否显示」1:1(`name_show.gd` 纯静态 `IsAlwaysShowName` / `__IsShowName`=`总是显示名字选项 or LALT` / `ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪 / `ShowCharacterTextTail` 的 `isShow · !IsGuildWall · CanPickInstance` 资格判定 + `net_world._name_shown()` 四路组装:uigameoption `always_show_name` radio(经 `game_option_ui` 新信号)/ LALT / `net_play.target_changed` 目标强显 / chat 尾标 `bNameFlag` 强显);增量 114 完成头顶伤害飘字 `AddDamageEffect` / `ProcessDamage` 1:1(`damage_effect.gd` 纯静态:`DODGE\|BLOCK`→`MISS`/`TARGETMISS`、`CRITICAL`/`POISON` 分支参考端注释掉走普通数字、`bSelf`→`"damage_"`+`SELFDAMAGE`⇄`SELFDAMAGE2` 交替、`!bTarget`→`"nontarget_"` 不渲染、否则 `"target_"`、`while(damage>0)` 逐位 `%10`/`/10` index 0..7、`IsShowDamage` / `SetShowDamageFlag` 门 + `net_world._on_damage`/`_process_damage_queue` 每帧 pop 一条 + `game_option_ui`「伤害数字」radio 经 `display_option_changed` 接 `net_world.set_show_damage`;seam ⑪:单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效、self 退化水平抖动、暴击不再放大 / 变色、全局单队列);增量 115 完成头顶目标框 `RecvTargetPacket` 收包分派 1:1(`target_board.gd` 纯静态:`RecvTargetPacket` 判定树(实例缺失→`CloseTargetBoard`、目标已死→不动框、`IsPC()\|IsBuilding()`→`CloseTargetBoardIfDifferent`、`CanViewTargetHP`(`IsStone()\|IsWoodenDoor()\|IsEnemy()`)→`SetHPTargetBoard`、否则→`CloseTargetBoard`)+ `game.py` `SetHPTargetBoard` 换 VID 先 `ResetTargetBoard` + `net_play._on_target_info` 由「任意有名字 VID 都显框」改为按判定树分派(PC / building 不显血条框、NPC / warp 关框);seam ⑫:poc 无 building / wooden-door 实体类型、`LCONTROL` 悄悄话捷径未接、`target_info` 由 C++ EntityStore 侧抽出不改 C++ 收包);增量 116 完成 `game.py OnRender` Picking 块分派 1:1 + 悬停角色强显名字(`pick_show.gd` 纯静态第 27 个 canonical:`IsPickedWindow` 门内 `chr.Pick()` 命中角色 → `ShowCharacterTextTail`、`targetBoard.GetTargetVID()` → `ShowCharacterTextTail`、`__IsShowName()` 分叉 `item.Pick()` ↔ `ShowAllTextTail` + `textTail.Pick()`、末尾 `SelectItemName`;`forced_name_vids` 收 `[hover, target]` 去重)+ `net_world._hover_vid` / `set_hover_vid` / `_name_shown()` forced 第 5 路 + `player_controller.hover_entity_changed` 信号(射线悬停拾取,压 UI / 悬停地面物品 → 0)+ `game_scene` 连线;seam ⑨:名字尾标随 chat 气泡下移 17px 未接;seam ⑩:`ArrangeTextTail` 屏幕空间去重叠仍缺(本增量只收口「悬停强显名字」一小块);增量 117 完成头顶名字尾标随聊天气泡上移 17px 1:1(`name_tail_layout.gd` 纯静态第 28 个 canonical:`UpdateShowingTextTail` :110-114 —— VID 有 chat 尾标且 `bNameFlag` TRUE 时名字屏幕 y = 气泡 y − 17px;poc 3D 适配为名字 `Label3D` 局部 y 顶到「气泡 y + 17*pixel_size」,气泡消失或「信息」尾标时回默认 2.1;`net_world._reposition_name_tag` 由 `_apply_chat_tail` / `_expire_chat_tails` 调用,seam ⑨ 收口);增量 118 完成目标框「看他国玩家」前置过滤 + §8.4 名字色 radio 接线 1:1(`target_board.hidden_as_other_empire(view_other_empire, same_empire)` 照抄 `uitarget.py TargetBoard.Open :189-193` —— game-option 关「看他国目标框」且目标异阵营 → `net_play._on_target_info` 在 `classify` 前 `_clear_target()` return;`net_play.set_view_other_empire_target()` + `net_world.set_empire_name_mode()` 经 `game_scene._on_display_option` 接 `game_option_ui` 的 `target_board` / `name_color` radio,开局各自 `display_value(...) == 1` 回填;修 `game_option_ui.gd` `target_board` 默认 0→1 对齐 `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1`;§8.4 三显示 radio + LALT 至此全部接线);增量 119 完成 seam ⑦ 收口 —— 头顶「公会名 / 称号 / 等级」子标签随名字尾标纵向位移 1:1(`name_tail_layout.gd` 增 `name_tail_lift_delta` / `subtag_world_y` 照抄 `CPythonTextTail::ArrangeTextTail` :211/:228/:258/:283 —— 三子标签屏幕 y 全锚在已被 `UpdateShowingTextTail :114` 顶过的 `pTextTail->y` 上;`net_world` 把子标签默认局部 y 提为 `SUBTAG_DEFAULT_Y` 常量、`_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`,气泡过期 / 「信息」尾标时回默认;剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版仍近似);增量 120 完成 seam ⑩ 收口 —— `ArrangeTextTail` item 尾标去重叠 + `isIn` 1:1(`text_tail_arrange.gd` 纯静态第 29 个 canonical:`boxes_overlap`=`isIn` AABB 含边界 + `arrange_item_tails` 外层逐条 / 内层比对 / 命中顶到 `pCompareTextTail->y + yEnd + 5` / 从头重跑 / `MAX_ITERS` 20 封顶 + `box_*` 四边 `-w/2-2` `w/2+2` `-2` `h`;`ground_items._arrange_tags` 每帧把所有掉落名条锚点 `cam.unproject_position` 到屏幕、按字符数估框、跑去重叠、按 `Label3D.pixel_size` 折回局部 y,可见名条 < 2 回默认;`game_scene` 传相机 getter;剩余 seam ⑩:名条框按「字符数 * 7px」估宽 vs 参考 `GetTextSize` 精确像素、只对掉落 item 名条做);增量 121 完成 seam ⑦ 水平深化 —— 头顶「公会名 / 称号 / 等级」子标签逐像素水平排版 1:1(`name_tail_layout.gd` 增 `name_x_shift`(`fxAdd` 8/4/0)/ `title_x_offset`(`−nameW/2 − 8`)/ `level_x_offset`(有称号再 `− titleW`)/ `guild_name_x_offset` / `mark_x_offset` / `subtag_world_x` 照抄 `ArrangeTextTail` :211-320 非 EUROPE 分支;`net_world._layout_subtags_x` 在 `_apply_text_tail_to` 尾部设名字 / `TitleTag` / `LevelTag` 的 `.position.x`,`GuildTag` 留 0(poc 无公会徽记),文本宽按 `字符数 * 7px` 估;纵向位移(增量 117/119)与横向排布只分别写 `.position.y` / `.position.x` 互不干扰;剩余 seam ⑦:无公会徽记 mark 实体、字符数估宽 vs `GetTextSize`、三子标签竖排 + x 偏移的折衷 vs 参考同一屏幕行);增量 122 完成 seam ⑫ 收口 —— 目标框点击 `LCONTROL` 悄悄话捷径接 whisper 1:1(`net_play` 加 `whisper_requested` 信号 + `_lcontrol_down` + `set_lcontrol_down()`,`_on_pick` 在 `KIND_PC` 分支轮询 `target_board.whisper_on_ctrl_click`(`lcontrol_down and same_empire and not is_main and not is_building`,谓词增量 118 已备)→ 发 `whisper_requested`;`game_scene._unhandled_input` `KEY_CTRL` 按下 / 松开喂 `set_lcontrol_down(e.pressed)`、`net_play.whisper_requested` → `chat.start_whisper`(poc 版 `OpenWhisperDialog`=聊天输入预填 `"/w 名 "`);C++ picking 只对 PC 派发 `SetPCTargetBoard` 故 poc 也只在 PC 分支查;`is_building` 实参恒 false(poc 无门实体、:814 分支永不触发)、无独立 whisper dialog 窗口,seam ⑫ 末尾);增量 123 完成 seam ⑦ / ⑩ 收口 —— 头顶子标签 / 掉落名条排版的文本宽度从「字符数 * 约 7px」估算换成 `CGraphicTextInstance::GetTextSize` 的真实字体度量 1:1(新增 `text_metrics.gd` 第 30 个 canonical 纯静态 `text_size` / `width_of` / `label_width`,`net_world._layout_subtags_x` 的 `name_w` / `title_w` 与 `ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width`,`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX` 保留仅作无字体兜底;`name_tail_layout.gd` / `text_tail_arrange.gd` 排布公式不动;剩余:单行文本高仍 15px 估、无公会徽记 mark 实体、三子标签竖排 vs 参考同一屏幕行);增量 124 完成 seam ⑦ / ⑩ 末尾 —— 掉落名条屏幕空间去重叠框的「单行文本高」从硬编码 15px 换成 `CGraphicTextInstance::GetTextSize` 的 h 真实字体度量 1:1(`text_metrics.gd` 增 `const FALLBACK_LINE_H_PX := 15.0` + `height_of` / `label_height`,`ground_items._arrange_tags` 的框高 `h` 改走 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`,`TAG_LINE_H_PX` 保留仅作无字体兜底;`text_tail_arrange.gd`(`box_end_y(h) = h`)/ `name_tail_layout.gd` 排布公式不动;剩余:掉落名条 owner 未拆成独立 text instance、无公会徽记 mark 实体、三子标签竖排 vs 参考同一屏幕行);增量 125 完成 seam ⑩ 末尾 —— 带 owner 的掉落名条屏幕空间去重叠框按 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立度量 + `yEnd += yOwnerSize + 4`」1:1(`text_tail_arrange.gd` 扩第 29 个 canonical 加纯静态 `item_box_with_owner`——无 owner -> 纯名字框、有 owner -> `{maxf(name_w, owner_w-2.0), name_h+owner_h+4.0}` 使 `box_end_x(w)` 恰等于参考 :737 `fMAX`;`text_metrics.gd` 扩第 30 个 canonical 加 `string_width_like` / `string_height_like` 量某 `Label3D` 字体下的任意子串;`ground_items._on_added` 存 `item_name` / `item_owner` meta、`_arrange_tags` 把名字子串与 owner + `'s` 子串分别度量后走 `item_box_with_owner`;渲染仍单个合并 `"name (owner)"` `Label3D`;剩余:owner 未独立渲染成 CENTER 黄字、`strOwnership` 未接本地化语素、只对掉落 item 名条);相关离线回归通过 | CJK IME、窗口视觉 / `.sub` 资源、完整交易事务、复杂 mousemodule 规则、EventManager 逐帧 / `.msc` / 相机消费端、任务日志窗体自身多列 / 分页、tooltip 特殊分支 / 装备对比、`text_tail.gd` 屏幕空间血条整合、三子标签并到名字同一屏幕行 / 接公会徽记 mark(seam ⑦ 末尾;文本宽 / 高已分别由增量 123 / 124 换真实字体度量 `GetTextSize` 的 w / h 1:1)/ 名字与气泡合成单尾标链表(seam ⑨ 遗留)、掉落名条 owner 独立渲染成 CENTER 黄字 + `strOwnership` 接本地化语素(seam ⑩ 末尾;去重叠框度量已由增量 125 换 1:1)、伤害飘字 `damagevalue` dds 逐位特效(seam ⑪)、目标框 building / wooden-door 实体类型分类(`is_building` 恒 false;`LCONTROL` 悄悄话捷径已由增量 122 接 whisper)(seam ⑫ 末尾)、真服和真机仍未验收 | | W4 渲染 / 资产 / 音频 | 🟡 | 增量 50:§9.1 地图 BGM 选曲、§9.2 后台音量存恢复已落地;增量 51:AppFlow 绑定当前场景音频;增量 53:EffectLib 已读取 `.mse` 首个 `TextureFiles` 的实际 `.dds`,并映射位置、发射形状、方向 / 速度;增量 55:`audio.gd` 已接入 MilesLib 的监听器、200/1000 缩放、音量曲线、普通 / 环境 / 角色 3D 声道、角色 0.3 秒与 5000cm 限频、3D stop 和限幅淡出;增量 63:按 `MilesLib/Type.cpp` 解析 `.mss` 的 `SoundDataCount/SoundData%02d`,转换为 60 FPS 声音实例并接到 PlayerView / MobView 动作帧;增量 64:按 `EffectMesh.cpp` 解析 `.mde` EffectData/MDEData002,展开几何帧、UV、`.ifl` 贴图和 MeshElement 材质;增量 65:解析 `.mse` Light 组并生成范围 / 生命周期曲线,粒子多贴图支持帧延迟与顺逆序,旋转类型区分 NONE / TIME_EVENT / CW / CCW;增量 66:按 `EffectElementBase::GetPosition` 接入粒子 / 网格 / Light 的 MovingType 直接段与二次 Bezier 段,并映射球面边缘发射;增量 67:按 `Area.cpp` / `MapType.cpp` 接入 `AreaAmbienceData` + `.pra`,独立于对象渲染加载环境声源,Audio 实现 LOOP / ONCE / STEP、距离音量和卸载停止;增量 68:按 `MapUtil.cpp` 补齐 `.msenv` 参考缺省值、`IsDensity`、`Filter`、`BTextureRenderMode`、六面天空文件名和 `Reserved`,通过 `AssetResolver` 接入真实云层贴图到 `ProceduralSkyMaterial.sky_cover`,并修复 macOS 大小写敏感环境路径;增量 69:背景 / 角色方向光按参考端分成 `Sun` layer 1 与 `CharacterLight` layer 2,静态场景和 `Metin2Model` 分层,换图时清理两盏光;增量 70:真实 CapeDragonHead 五面天空贴图按 ClientVS22 面顺序接入 Godot `sky` shader,空 bottom 面回退渐变;增量 71:树 proxy 使用参考 `fWindStrength=0.2 / fWindRandom=0` 缺省;增量 72:真实 DawnMistWood 验证 texture-mode 五面天空与有限云层平面、CloudScale / Height / TextureScale / Speed 和参考颜色合成;`bgm_test`、`audio_miles_test`、`ambience_test`、`environment_test`、`skybox_test`、`world_probe`、`player_motion_test`、`mob_view_test`、`fx_test` + 生命周期 / 场景回归全绿 | TexAni 的随机起始帧 / 每粒子语义、完整 emitter advanced / cycle、完整时间轴 / Actor motion、Direct3D 材质 stage、SpeedTree、云层渐变过渡、滤色实际合成、风参数完整语义、lens flare、昼夜天气联动、声音资源逐项和真机验收 | | W5 打包 / 发布 / 验收 | 🟡 | 增量 53 已把 `assets` / `bgm` 复制进 `Contents/Resources`,支持 `MT_MAC_ARCHES=universal` 和 `lipo` 架构门禁,嵌套扩展先签名、App 后签名;默认凭据改为环境变量;脚本语法 / diff 检查通过 | 尚未执行完整 2.2G 资源导出;当前已检查的 dylib 仍为 arm64;正式 Developer ID / notarization、跨机启动和 Mac App 验收 | +> **最新进展(增量 127,2026-09-04)**:本地可验证的四项尾部缺口已完成。`net_world.gd` 现用 +> `CanvasLayer + ScreenHpBar` 做屏幕空间 HP 条,按相机投影更新并在死亡、镜头后方、屏幕外和换图时 +> 隐藏 / 清理;`entity_rules.gd` / `net_play.gd` 支持建筑与木门分类(显式 `kind` / `building`、 +> `bType=4`、race 13000 / 30111..30119),目标框不会再将它们误作 PC;`ground_items.gd` 的 +> owner 行继承掉落物显式颜色或品阶颜色;`chat_ui.gd` 提供独立 `WhisperDialog`,LCONTROL 点同阵营 +> 其他 PC 后可发送 / 取消 / ESC 关闭。`screen_hp_bar_test.gd`、`entity_rules_test.gd`、 +> `target_board_test.gd`、`netplay_test.gd`、`chat_test.gd`、`p2b_test.gd` 已覆盖这些行为;全量 +> C++/CTest 21/21、Godot 脚本 71/71 通过。剩余的是公会徽记 / 单尾标等表现 seam、完整世界玩法和 +> 真实服务器 / 真机 / 发布包验收,不能把离线通过当作端到端验收。 + +> **W3→M2 最新进展(增量 128,2026-09-04)**:`net_world.gd` 已消费客户端公会会徽缓存的 +> `guild_marks_ready` / `guild_mark_updated` 事件,为已存在角色动态创建 / 刷新 / 删除头顶 +> `GuildMark` `Sprite3D`,并按参考 `mark_x_offset` / `guild_name_x_offset` 排布,聊天气泡 +> 上移时会徽同步跟随;`guild_mark_tail_test.gd` 覆盖延迟下载、纹理更新、排布和公会移除。 +> `quest_dialog.gd` 的 `[DONE]` 解析已无副作用,`done_event` 只在用户点击关闭按钮时触发, +> `quest_test.gd` 覆盖解析与点击语义。当前离线回归为 CTest 22/22、Godot 74/74;仍未完成的 +> 是单尾标链表、完整 `.msc` 电影播放器、Quest/fishing/dungeon/mount/observer 完整玩法、 +> 真实服务器、真机、纯 pack、双架构和正式签名验收。 + > **W4 最新进展(增量 100)**:EffectLib 已完成缺省 cycle、有限 LoopCount 发射停止、重播时钟复位和 > 粒子寿命隔离;OUTER / INNER 当前仍是显式标记的宽方向 fallback,不计作精确径向速度完成。完整 > 状态以本文变更记录和 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5 为准。 @@ -223,6 +242,358 @@ > `ShowName()` 的 `playerGauge.EnableShowAlways()` / `SetQuickPage` 两步未移植(blob / quickbar UI)。 > 详见 `CLIENT-GAP-FIX.md` §8.4 / §8.6 / §8.8 / §5.4 / C.5 增量 113。 +> **W3→M2 最新进展(增量 114)**:§8.8 / §5.4 头顶伤害飘字按 +> `40250/ClientVS22/source` 补 1:1。新建 `project/damage_effect.gd`(第 25 个 canonical,纯静态 +> `extends RefCounted`,与 `name_color.gd` / `text_tail.gd` / `chat_tail.gd` / `name_show.gd` 同套路) +> 照抄 `enum DamageFlag`(`InstanceBase.h:300`:`NORMAL 1<<0` … `CRITICAL 1<<5`)、 +> `TPacketGCDamageInfo`(`Packet.h:1943`:`header / dwVID / flag / int damage`)、 +> `RecvDamageInfoPacket`(`PythonNetworkStreamPhaseGame.cpp:2401`:`bSelf = 目标是主实例`、 +> `bTarget = 目标是 m_pInstTarget`、`damage >= 0` 才 `AddDamageEffect`(`< 0` 记 `TraceError`))、 +> `CInstanceBase::AddDamageEffect`(`InstanceBaseEffect.cpp:87`:仅 `CPythonSystem::IsShowDamage()` +> 才 `m_DamageQueue.push_back`)、`ProcessDamage`(:100,`InstanceBase.cpp:1910` 每帧调一次, +> `m_DamageQueue` 每帧 `pop_front` 一条:`(flag & DODGE) || (flag & BLOCK)` → +> `bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS` + `return`;`CRITICAL` / `POISON` 的 +> `CreateEffect` 参考端均注释掉 → 落普通数字(无独立暴击视觉);`bSelf` → `"damage_"` + +> `EFFECT_DAMAGE_SELFDAMAGE` / `SELFDAMAGE2` 按 `m_bDamageEffectType` 交替;`bTarget == false` +> → `"nontarget_"` + `return`(**不渲染**);否则 `"target_"`;`while (damage > 0)` 逐位 +> `damage % 10` / `damage /= 10`,`index 0..7`(`> 7` `TraceError` break),每位一张 +> `d:/ymir work/effect/affect/damagevalue/.dds` 按 `FONT_WIDTH(30) * index` 相机右向铺开)、 +> `CPythonSystem::IsShowDamage` / `SetShowDamageFlag`(`PythonSystem.cpp:337` / `:342`, +> `bShowDamage = iFlag == 1`,默认 `true`,配置行 `SHOW_DAMAGE` = `atoi == 1`,仅非默认落盘)。 +> `net_world.gd`:`_on_damage` 照 `AddDamageEffect`(`damage_number` 信号仍无条件发=本移植扩展 +> 供战斗日志 / 测试;飘字本体 `_show_damage` 门后入 `_damage_queue`,元素 `{vid, amount, flag, +> self, target}`)、新增 `_process_damage_queue()` 由 `_process` 每帧调一次照 `ProcessDamage` +> 分类,`show == false`(非 self 非 target 数字)直接 return,其余一块 billboard `Label3D` 写 +> `str(amount)` / `"MISS"`;`set_show_damage(on)`、`clear_for_map_change` 清 `_damage_queue`。 +> `game_scene.gd` 接 `game_option_ui.display_option_changed("show_damage")` → `net_world.set_show_damage`, +> 初值 `display_value("show_damage") == 1` 回填。`damage_effect_test.gd`(第 25 个 canonical, +> ~50 断言)+ 全 65 个离线回归通过。 +> 行为变化:`BLOCK` 也算 MISS(原占位只判 DODGE);非 self 非 target 的数字伤害不再显示; +> 暴击不再金色 / 放大(对齐该 40250 build);受 `IsShowDamage()` 门控;每帧只 pop 一条。 +> seam ⑪:单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效 / 相机右向像素铺排;self +> `SELFDAMAGE`⇄`SELFDAMAGE2` 退化为落字水平抖动符号交替(±0.3);`_damage_queue` 全局单条 +> vs 参考每实例一条(同帧多 VID 受击会分帧显示);`_show_damage` 配置总写 `SHOW_DAMAGE` 键。 +> 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 114。 + +> **W3→M2 最新进展(增量 115)**:§5.4 / §8.8 头顶目标框(TargetBoard)收包分派按 +> `40250/ClientVS22/source` 补 1:1。新建 `project/target_board.gd`(第 26 个 canonical,纯静态 +> `extends RefCounted`,与 `name_color.gd` / `text_tail.gd` / `chat_tail.gd` / `name_show.gd` / +> `damage_effect.gd` 同套路)照抄 `TPacketGCTarget`(`Packet.h:1937`:`header / dwVID / bHPPercent`)、 +> `CPythonNetworkStream::RecvTargetPacket`(`PythonNetworkStreamPhaseGame.cpp:2424`)的判定树: +> `pInstPlayer && pInstTarget` 都在且 `!IsDead()` 时 —— `IsPC() || IsBuilding()` → +> `CloseTargetBoardIfDifferent(dwVID)`(:2440)、否则 `pInstPlayer->CanViewTargetHP(*pInstTarget)` +> → `SetHPTargetBoard(dwVID, bHPPercent)`(:2442)、否则 `CloseTargetBoard()`(:2444); +> `m_pInstTarget = pInstTarget` 仅在该 `!IsDead()` 块内(:2447);实例缺失 → `CloseTargetBoard()` +> (:2453);目标已死透 → 整块跳过(不动框)。`CInstanceBase::CanViewTargetHP` +> (`InstanceBase.cpp:2293`)= `victim.IsStone() || victim.IsWoodenDoor() || victim.IsEnemy()`。 +> `game.py`:`SetHPTargetBoard`(:829,`vid != GetTargetVID()` → `ResetTargetBoard()` + +> `SetEnemyVID(vid)`,再 `SetHP` + `Show`)、`CloseTargetBoardIfDifferent`(:837,`vid != GetTargetVID()` +> 才 `Close()`)、`CloseTargetBoard`(:841)、`SetPCTargetBoard`(:804,按住 `LCONTROL` 且 +> `IsSameEmpire` 且非本人且非 building → `OpenWhisperDialog`)。`target_board.gd` API: +> `enum Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE }`、`can_view_target_hp` / `classify` / +> `updates_current_target` / `needs_reset` / `should_close_if_different` / `whisper_on_ctrl_click` / +> `clamp_hp_pct`。`net_play.gd`:`_on_target_info(vid, hp%)` 由「任意 `vid != 0` 且有名字都 +> `hud.set_target`」改为按 `classify` 分派 —— `is_pc = _entity_kind == KIND_PC`、 +> `is_building = false`(seam ⑫)、`can_view = KIND_STONE 或 KIND_MONSTER`;`CLOSE` / +> `CLOSE_IF_DIFFERENT`(`vid != _target_vid` 才) → `_clear_target()`;`SET_HP` → 换 VID 先 +> `_target_vid = vid` + `target_changed.emit` 再 `hud.set_target(名字, clamp_hp_pct(hp%))`; +> `NONE` → 不动。`target_board_test.gd`(第 26 个 canonical,~35 断言)+ 全 66 个离线回归通过。 +> 行为变化:此前对任意有名字 VID(含 NPC)都显目标框;现按判定树 —— PC / building 目标不再显 +> 血条框(只在框上是别的 VID 时清旧框)、NPC / warp 等不可看血目标一律关框、目标已死透不动框、 +> `hp%` 钳到 0..100。`_change_target_to_picked_instance`(点击选目标,PythonPlayerInput 路径) +> 不在本批次范围,维持原逻辑。 +> seam ⑫:poc 无 building / wooden-door / stone-door 实体类型(`is_building` 恒 false、木门分类 +> 缺);`target_info(vid, hp%)` 由 C++ `extension/src/net/m2_client.cpp:3129/3633` 从 EntityStore +> 抽出,本移植不改 C++ 收包(对齐「纯 GDScript」约束);`LCONTROL` 悄悄话捷径谓词 +> `whisper_on_ctrl_click` 已备,`net_play` 暂不接(无 whisper dialog 钩子)。 +> 详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 115。 + +> **W3→M2 最新进展(增量 116)**:§8.8 / §5.4 `game.py GameWindow.OnRender()` 的「Picking」块 +> (:1519)分派按 `40250/ClientVS22/source` 补 1:1,并把「悬停角色强显名字」接进 poc。新建 +> `project/pick_show.gd`(第 27 个 canonical,纯静态 `extends RefCounted`,与 `name_color.gd` / +> `text_tail.gd` / `chat_tail.gd` / `name_show.gd` / `damage_effect.gd` / `target_board.gd` 同套路) +> 照抄:`textTail.UpdateAllTextTail()`(:1531)→ `wndMgr.IsPickedWindow(self.hWnd)` 门(:1533)内 +> `chr.Pick()`(:1535)命中角色(!= -1)→ `textTail.ShowCharacterTextTail(idx)`(:1537)、 +> `targetBoard.GetTargetVID()` != 0 → `ShowCharacterTextTail(targetVID)`(:1539)、`not __IsShowName()` +> → `item.Pick()` → `ShowItemTextTail`(:1543/:1546);门外 `__IsShowName()` → `ShowAllTextTail()` +> + `PickingItemIndex = textTail.Pick(x, y)`(:1552/:1554);末尾 `UpdateShowingTextTail` + +> `ArrangeTextTail`(:1557/:1558)、`PickingItemIndex != -1` → `SelectItemName`(:1560)。 +> `pick_show.gd` API:`NO_PICK := -1`、`char_pick_active(is_picked_window)`、 +> `forced_name_vids(is_picked_window, pick_char_vid, target_vid)`(收 `chr.Pick()` 命中 + 目标框 +> VID,去重、顺序 `[hover, target]`、非窗口 → `[]`)、`should_pick_item(is_picked_window, +> is_show_name)`= `and not`、`should_show_all_names` / `should_pick_text_tail`= `is_show_name`、 +> `should_select_item_name`= `!= NO_PICK`。poc 侧仿增量 113 的 `_target_vid` forced 路数给 +> `net_world._name_shown()` 加第 5 路 forced `_hover_vid`:`net_world.set_hover_vid(vid)`(值变才刷 +> prev / vid 两节点名字可见性,仿 `set_target_vid`)由 `player_controller._update_hover_cursor()` +> 三出口喂 —— 鼠标压 UI → 0、悬停地面物品 → 0、射线命中 `pickable` → `best.get_meta("vid")`、 +> 无命中 → 0;新增 `hover_entity_changed(vid)` 信号(值变去抖),`game_scene` 紧挨 +> `net_play.target_changed → net_world.set_target_vid` 那行连 `pc.hover_entity_changed → +> net_world.set_hover_vid`。`pick_show_test.gd`(第 27 个 canonical,~24 断言)+ 全 67 个离线回归 +> 通过(两次运行 PASS=67 FAIL=0)。行为变化:此前只有 `targetBoard` 目标(增量 113)forced 强显 +> 头顶名字;现悬停到的角色(`player_controller` 射线拾取,`pickables` 带 `vid` meta)也 forced +> 强显 —— 即便未开「总显名字」且超 3500 平面距。悬停命中沿用既有 `_ray_pick_t`(cursor 用的同 +> 一套),无新增射线开销。`net_play._on_target_info`(增量 115)不受影响。 +> seam ⑨:名字尾标 y = chat 气泡 y − 17px(`PythonTextTail.cpp:112-114`)未接。seam ⑩: +> `ArrangeTextTail` 的屏幕空间尾标去重叠排布仍缺 —— poc 名字牌是 3D billboard,靠深度天然分层, +> 未做 2D 投影后的挤开;本增量只收口其中「悬停角色强显名字」一小块。近似:参考里 `chr.Pick()` +> 强显与 `targetBoard` 强显都在 :1533 的 `IsPickedWindow` 门内,poc 把 `_target_vid` / `_hover_vid` +> 都当作无条件 forced,`_hover_vid` 靠 `player_controller` 压 UI / 悬停物品时置 0 部分等效该门。 +> 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 116。 + +> **W3→M2 最新进展(增量 117)**:§8.8 seam ⑨ 收口 —— 头顶名字尾标「有聊天气泡时上移贴气泡」 +> 按 `40250/ClientVS22/source` 补 1:1。`CPythonTextTail::UpdateShowingTextTail` +> (`PythonTextTail.cpp:88`)在角色名字尾标投影完屏幕坐标后(:104),`m_ChatTailMap.find(dwVirtualID)` +> 命中(:106/:110)且那条 `bNameFlag` 为 TRUE(:112,= `RegisterChatTail` 的「聊天」气泡,非 +> `RegisterInfoTail` 的「信息」气泡)→ `pTextTail->y = pChatTail->y - 17.0f`(:114,屏幕 y 向下 +> 减 17px = 名字上移一行贴气泡上沿)。名字与气泡默认同世界高(`GetHeight()+10`,:519/:581),全靠 +> 这条 −17px 分开。新建 `project/name_tail_layout.gd`(第 28 个 canonical,纯静态 `extends +> RefCounted`,与 `name_color.gd` / `text_tail.gd` / `chat_tail.gd` / `name_show.gd` / +> `damage_effect.gd` / `target_board.gd` / `pick_show.gd` 同套路):`NAME_OFFSET_ABOVE_BUBBLE_PX +> := 17.0`、`name_follows_bubble(has_chat_tail, chat_name_flag)`= `and`(:110 命中 && :112 +> `bNameFlag`)、`name_tail_screen_y(default_y, bubble_y, follows)`= 参考端字面 `bubble_y − 17`、 +> `name_tail_world_y(default_world_y, bubble_world_y, follows, px_size)`= poc 3D 适配(屏幕 y +> 向下 −17px → 世界 y 向上 `bubble_world_y + 17*px_size`)。poc 侧 `net_world._reposition_name_tag(n)` +> 由 `_apply_chat_tail`(冒气泡)/ `_expire_chat_tails`(气泡过期)调用,读 `_chat_tails[vid].name_flag` +> (`RegisterChatTail` TRUE / `RegisterInfoTail` FALSE)决定名字 `Label3D` 局部 y 在 +> `NAME_TAG_DEFAULT_Y`(2.1)与「`BUBBLE_TAG_Y` 2.35 + 17*pixel_size」之间切换;`_attach_nameplate` +> 用 `NAME_TAG_DEFAULT_Y`、`_apply_chat_tail` 用 `BUBBLE_TAG_Y`。`name_tail_layout_test.gd`(第 28 个 +> canonical)+ `chat_test.gd` 扩断言(冒气泡后名字 y == 气泡 y + 17*pixel_size 且 > 气泡 y, +> `register_info_tail` 覆盖 → 名字回 2.1)+ 全 68 个离线回归通过(PASS=68 FAIL=0)。剩余 seam ⑨: +> 气泡与名字仍是两个并存 `Label3D` 节点(非参考单尾标链表)、子标签 GuildTag / TitleTag / +> LevelTag 不随名字上移 = seam ⑦ 遗留;换算按名字 `Label3D` 的 `pixel_size`(0.006),与参考的 +> 字体无关常量 17 对齐。seam ⑩:`ArrangeTextTail` 屏幕空间去重叠仍缺。 +> 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 117。 + +> **W3→M2 最新进展(增量 118)**:§5.4 / §8.8 / §8.4 目标框「看他国玩家」前置过滤 + 名字色 +> radio 接线按 `40250/ClientVS22/source` + `assets/root` 补 1:1。`root/uitarget.py` +> `TargetBoard.Open(vid, name)`(:189-193)在整个 `RecvTargetPacket` 判定之前先过滤: +> `if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): if not player.IsSameEmpire(vid): +> self.Hide(); return`(`constinfo.VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1` 默认, +> `uigameoption.py` 的 `target_board_view` / `target_board_no_view` radio 经 +> `constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)`)。`project/target_board.gd` +> 加纯静态谓词 `hidden_as_other_empire(view_other_empire, same_empire)`= +> `not view_other_empire and not same_empire`(不按 `is_pc` 收窄 —— 忠于参考;empire 0 的 +> 怪 / NPC / 石头在 `EntityRules._is_same_empire` 里恒「同阵营」故永不被挡)。`net_play` +> 加 `_view_other_empire_target`(默认 true)+ `set_view_other_empire_target(on)`, +> `_on_target_info` 在 `TargetBoard.classify(...)` 之前 `if exists and +> TargetBoard.hidden_as_other_empire(_view_other_empire_target, +> EntityRules._is_same_empire(_main_entity(), e)): _clear_target(); return`。同批把 §8.4 +> 「名字色」radio(`name_color_normal` / `name_color_empire`)接进 +> `game_scene._on_display_option` → `net_world.set_empire_name_mode(v == 1)`(帝国名色调色板 +> 算法增量 110 已 1:1),并修 `game_option_ui.gd` 的 `target_board` 默认值 0 → 1(对齐 +> `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1`)。`game_scene` 开局两处 `display_value(...)` +> 回填。`target_board_test.gd`(+4 断言)/ `netplay_test.gd`(+8b 段:看他国框开→异阵营 PC +> 目标框保留、关→前置过滤清框、关不影响怪)/ `system_menu_ui_test.gd`(+`name_color_empire` / +> `target_board_no_view` radio → `_display` + cfg 持久化 + `display_option_changed` 信号)+ +> 全 68 个离线回归通过(PASS=68 FAIL=0,无新增测试文件)。§8.4 三个显示 radio + §8.6 LALT +> 至此全部接线完毕。seam ⑫(延续):poc 无 building / wooden-door 实体类型、目标框点击 +> `LCONTROL` 悄悄话捷径未接。 +> 详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / §8.4 / C.5 增量 118。 + +> **W3→M2 最新进展(增量 119)**:§8.8 seam ⑦ 收口 —— 头顶「公会名 / 称号 / 等级」子标签 +> 随名字尾标纵向位移按 `40250/ClientVS22/source` 补 1:1。`CPythonTextTail::ArrangeTextTail` +> (`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把公会名(:228,`pTextTail->y - +> c_fyGuildNamePosition`,`c_fyGuildNamePosition = 15.0f` :21)/ 称号(:258,同 `pTextTail->y`)/ +> 等级(:283,同 y)三个子标签的屏幕 y 全部锚在 `pTextTail->y` 上 —— 而这个 y 已被 +> `UpdateShowingTextTail :114` 顶过 −17(增量 117)。所以名字尾标因「聊天」气泡上移时,三个 +> 子标签整体跟随同一段纵向位移;增量 117 只让名字尾标本身跟随,子标签当时留作 seam ⑦ 遗留。 +> `project/name_tail_layout.gd` 增 `GUILD_NAME_ABOVE_NAME_PX := 15.0`、 +> `name_tail_lift_delta(default_world_y, bubble_world_y, follows, px_size)`(= +> `name_tail_world_y(...) - default_world_y`,follows 时为净上移量、否则 0)、 +> `subtag_world_y(default_subtag_y, name_tail_lift_delta)`(=二者相加)。`net_world` 把 +> `GuildTag` / `TitleTag` / `LevelTag` 默认局部 y 提为 `SUBTAG_DEFAULT_Y` +> (2.34 / 1.86 / 1.74)常量,`_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移三个 +> 子标签,气泡过期 / 「信息」尾标(`bNameFlag` FALSE)时回默认。`name_tail_layout_test.gd` +> (+「4. seam ⑦ 收口」段:`name_tail_lift_delta` / `subtag_world_y` 数值、平移后相对间距不变、 +> lift 0 回默认)/ `chat_test.gd`(+真 `NetWorld` 断言:冒聊天气泡后三子标签 `position.y` == +> 默认 + lift、覆盖成信息尾标后回默认)+ 全 68 个离线回归通过(PASS=68 FAIL=0,无新增测试文件)。 +> 剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版(公会名上方 15px、称号 / 等级同 +> 水平行左偏)仍近似。剩余 seam ⑨:气泡与名字仍是两个并存 `Label3D`(非参考单尾标链表)。 +> 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 119。 + +> **W3→M2 最新进展(增量 120)**:§8.8 seam ⑩ 收口 —— `CPythonTextTail::ArrangeTextTail` 的 +> item 尾标去重叠 + `isIn` 按 `40250/ClientVS22/source` 补 1:1。`PythonTextTail.cpp:153` 的 +> `m_ItemTextTailList` 块(:160-190):逐条掉落名条与其它每条比 `isIn`(:489-507, +> `[x+xStart,x+xEnd]×[y+yStart,y+yEnd]` 的 AABB,`<=`/`>=` 含边界),命中就把「自己」顶到 +> `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(`yTemp = 5`),内层从头重跑、 +> `LimitCount >= 20` 封顶。新增第 29 个 canonical `project/text_tail_arrange.gd` +> (`extends RefCounted` 全静态):`PUSH_GAP := 5.0` / `MAX_ITERS := 20`、`box_start_x/end_x/ +> start_y/end_y`(`-w/2-2` / `w/2+2` / `-2` / `h`,`PythonTextTail.cpp:810-813`)、 +> `boxes_overlap(a, b)`(=`isIn`)、`arrange_item_tails(tails: Array) -> Array`(外层逐条 i、 +> 内层逐条 j 跳过自身、命中 `ys[i] = ys[j] + box_end_y(h_j) + PUSH_GAP` 后 j 归零 limit++、 +> `limit >= MAX_ITERS` break;`pCompare` 用已排布后的 `ys[j]`)。poc 侧 +> `project/ui/ground_items.gd` 加 `_camera_getter` + 常量 `TAG_BASE_Y 0.75` / `TAG_CHAR_W_PX 7` / +> `TAG_LINE_H_PX 15`,`_process` 尾部 `_arrange_tags()` 把所有掉落名条世界锚点 +> `cam.unproject_position` 到屏幕、按字符数估框、跑 `arrange_item_tails`、 +> `position.y = TAG_BASE_Y - (arranged_y - y0) * pixel_size`,可见名条 < 2 直接回默认; +> `project/game_scene.gd` 把 `func() -> Camera3D: return cam` 传进 `ground_items.setup` 第 6 参。 +> `text_tail_arrange_test.gd`(新:常量、`box_*` 四边、`boxes_overlap` x/y 都叠 / 边界相接 / +> x·y 分离、`arrange_item_tails` 单条 / 不叠 / 两条同位 → [121, 100] / 三条同位 → [121, 142, 100] +> 且两两不叠 / 40 条全同位不死循环)/ `p2b_test.gd`(+真 `Camera3D` + 真 `GroundItems`:同位两 +> 掉落名条投影后靠前一条按 `(15+5)*0.005 = 0.1` 下推、靠后留默认、拉开距离回默认)+ 全 69 个 +> 离线回归通过(PASS=69 FAIL=0)。剩余 seam ⑩:名条框按「字符数 * 7px」估宽 vs 参考 +> `GetTextSize` 精确像素;只对掉落 item 名条做(与参考 `m_ItemTextTailList` / +> `m_CharacterTextTailList` 分表一致,角色名字尾标不参与)。 +> 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 120。 + +> **W3→M2 最新进展(增量 121)**:§8.8 seam ⑦ 水平深化 —— `CPythonTextTail::ArrangeTextTail` +> 角色块(`PythonTextTail.cpp:211-320` 非 EUROPE 分支)的「公会名 / 称号 / 等级」子标签逐像素 +> 水平排版按 `40250/ClientVS22/source` 补 1:1。参考端:`fxAdd` = 0(无称号无等级,:236)/ 8 +> (有称号,:242)/ 4(仅等级无称号,:288);名字文本 CENTER 对齐画在 `x + fxAdd`(:320); +> 称号 CENTER 对齐画在 `x − iNameWidth/2 − fxAdd`(:258);等级 RIGHT 对齐,有称号时 +> `x − iNameWidth/2 − fxAdd − iTitleWidth`(:282)、仅等级时 `x − iNameWidth/2 − fxAdd`(:312); +> 公会名 / 徽记仅当 `pMarkInstance && pGuildNameInstance` 时才由 ArrangeTextTail 重定位 +> (:220),`iImageHalfSize = markW/2 + c_fxMarkPosition(1.5)`(:224),公会名 `x + iImageHalfSize` +> (:228)、徽记 `x − guildW/2 − iImageHalfSize`(:227)。`project/name_tail_layout.gd` 增 4 常量 +> (`NAME_X_ADD_TITLE 8.0` / `NAME_X_ADD_LEVEL_ONLY 4.0` / `MARK_X_PAD 1.5` / +> `MARK_ABOVE_NAME_PX 26.0`)+ 6 静态方法(`name_x_shift(has_title, has_level)`=fxAdd 8/4/0、 +> `title_x_offset(name_w)`=`−name_w/2 − 8`、`level_x_offset(name_w, title_w, has_title)`=有称号 +> 再减 `title_w`、`guild_name_x_offset(mark_w)`=`mark_w/2 + 1.5`、`mark_x_offset(guild_w, mark_w)`、 +> `subtag_world_x(px_offset, px_size)`=`px_offset * px_size` 线性折回、符号保留左负右正)。 +> poc 侧 `net_world.gd` 加 `const SUBTAG_CHAR_W_PX := 7.0`(无字体度量 -> 按「字符数 * 约 7px」 +> 估文本宽,与 `ground_items` 的 `TAG_CHAR_W_PX` 同源)、`_apply_text_tail_to` 尾部调 +> `_layout_subtags_x(n, ttag, ltag)`:由 `.text != ""` 判 `has_title` / `has_level`,设名字 +> `Label3D.position.x = subtag_world_x(name_x_shift(...), pixel_size)`、`TitleTag.position.x` / +> `LevelTag.position.x` 同法(无则回 0)、`GuildTag.position.x = 0`(poc 无公会徽记实体,参考的 +> `pMarkInstance && pGuildNameInstance` 门恒不满足)。纵向位移(增量 117/119 写 `.position.y`) +> 与横向排布(增量 121 写 `.position.x`)互不干扰。`name_tail_layout_test.gd`(+「5. seam ⑦ +> 水平排版」段:4 常量、`name_x_shift` 四组、`title_x_offset(40)==−28` 且 `< 0`、 +> `level_x_offset(40,30,true)==−58` / `(40,30,false)==−24` 且有称号时比称号更左、 +> `guild_name_x_offset(20)==11.5` / `(0)==1.5`、`mark_x_offset(60,20)==−41.5`、`subtag_world_x` +> 线性 / 符号 / 0、组合 `lx(−58) < tx(−28) < nx(+8)`)/ `chat_test.gd`(+真 `NetWorld`:Charlie +> vid 3000 有公会 7 / alignment 15000 / level 42 -> `GuildTag.text=="Camelot"` 且 +> `position.x≈0`、名字 `position.x≈8*pixel_size`、`TitleTag.position.x<0` 且精确 +> `≈(−(7*7/2)−8)*pixel_size`、`LevelTag.position.x < TitleTag.position.x`;改 alignment=0 后 +> `TitleTag.text=="" 且 position.x≈0`、名字 `position.x≈4*pixel_size`、`LevelTag.position.x` +> 精确 `≈(−(7*7/2)−4)*pixel_size`)+ 全 69 个离线回归通过(PASS=69 FAIL=0,无新增测试文件)。 +> 剩余 seam ⑦:poc 无公会徽记 / 徽记 mark 实体(参考 `pMarkInstance && pGuildNameInstance` 门 +> 恒不满足,`GuildTag` x 恒 0);文本宽按「字符数 * 7px」估 vs 参考 `GetTextSize` 精确像素; +> 三子标签仍竖排叠 x 偏移 vs 参考同一屏幕行(poc 可读性折衷)。 +> 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 121。 + +> **W3→M2 最新进展(增量 122)**:§5.4 / §8.8 seam ⑫ 收口 —— 目标框点击的 `LCONTROL` +> 悄悄话捷径按 `40250/ClientVS22/source` + `assets/root/game.py` 补 1:1。参考端 +> `CGameWindow::SetPCTargetBoard`(`game.py:804-817`):`self.targetBoard.Open(vid, name)` 之后, +> `if app.IsPressed(app.DIK_LCONTROL)`(:807)→ `if not player.IsSameEmpire(vid): return`(:809) +> → `if player.IsMainCharacterIndex(vid): return`(:812)→ `elif +> chr.INSTANCE_TYPE_BUILDING == chr.GetInstanceType(vid): return`(:814)→ +> `self.interface.OpenWhisperDialog(name)`(:817);C++ picking 只对 PC 命中派发 +> `SetPCTargetBoard`(怪 / NPC 走别的分派)。谓词 `target_board.whisper_on_ctrl_click( +> lcontrol_down, same_empire, is_main, is_building)`=`lcontrol_down and same_empire and +> not is_main and not is_building` 增量 118 已备好没接,本增量纯接线:`project/net_play.gd` 加 +> `signal whisper_requested(name)` + `var _lcontrol_down` + `func set_lcontrol_down(down)`, +> `_on_pick` 在 `_change_target_to_picked_instance(vid)` 之后、`_entity_kind(e) == KIND_PC` +> 分支内调 `TargetBoard.whisper_on_ctrl_click(_lcontrol_down, +> EntityRules._is_same_empire(_main_entity(), e), vid == _main_vid, false)`,真且 +> `_entity_name(e) != ""` → `whisper_requested.emit(name)`。`project/game_scene.gd` +> `_unhandled_input` 在 `KEY_SHIFT` 块后加 `KEY_CTRL` 按下 / 松开 → +> `net_play.set_lcontrol_down(e.pressed)`(一处轮询态,对齐参考端 `IsPressed` 语义),`chat` +> 装配处(`fishing_feedback` 接线之后)连 `net_play.whisper_requested` → `chat.start_whisper` +> (poc 版 `OpenWhisperDialog`=聊天输入预填 `"/w 名 "` 并抢焦点)。`netplay_test.gd` 加 +> 「8c) seam ⑫」段(无新增测试文件):主角 empire=1,Bob(vid 6100 / `ch_type` 0 / empire 1) +> 没按 Ctrl 点 → `whispers` 空但 `set_target` 6100、按 Ctrl 点 → `whispers == ["Bob"]`、改 +> empire=2 再点 → 空(:809)、Wolf2(`ch_type` 2)按 Ctrl 点 → 空(仅 PC 分派)+ 全 69 个 +> 离线回归通过(PASS=69 FAIL=0)。剩余 seam ⑫:poc 无 building / wooden-door / stone-door +> 实体类型(`_entity_kind` 只到 0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP),`is_building` +> 实参恒 false —— 参考端 :814 `INSTANCE_TYPE_BUILDING` 分支在 poc 永不触发;poc 悄悄话=聊天 +> 输入预填,非独立 whisper dialog 窗口。 +> 详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 122。 + +> **W3→M2 最新进展(增量 123)**:§8.8 seam ⑦ / ⑩ 收口 —— 头顶子标签 / 掉落名条排版的 +> 文本宽度从「字符数 * 约 7px」估算换成 `CGraphicTextInstance::GetTextSize` 的真实字体度量 1:1。 +> 参考端 `CPythonTextTail::ArrangeTextTail`(`PythonTextTail.cpp`)里 `iNameWidth` / `iTitleWidth` / +> `iLevelWidth` / `guildW`(:225 / :233 / :240 / :267 / :297)与 item 尾标框 `xSize` / `ySize` +> (:733 / :748 / :809)全部取自 `pTextInstance->GetTextSize(&w, &h)`——即字体的实际逐字形排版 +> 度量(含字距、非等宽),不是「字符数 * 定宽」。新增纯静态模块 `project/text_metrics.gd`(第 30 个 +> canonical,同 `name_color.gd` / `text_tail.gd` / `name_tail_layout.gd` / `text_tail_arrange.gd` +> 一套 `extends RefCounted` 全 static + `*_test.gd` 头less 回归的镜像套路):`text_size(font, text, +> font_size)`(空 / null / `font_size<=0` -> `Vector2.ZERO`,否则 `Font.get_string_size(text, +> HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`)、`width_of(font, text, font_size, fallback_char_px +> := 7.0)`(有字体量得正宽 -> 精确像素;否则 -> `text.length() * fallback_char_px`)、 +> `label_width(label: Label3D, fallback_char_px := 7.0)`(从节点自身取 `font ?? ThemeDB.fallback_font` +> 和 `font_size`)。调用方换源不换排布:`net_world._layout_subtags_x` 的 `name_w` / `title_w` 改走 +> `TextMetrics.label_width(label, SUBTAG_CHAR_W_PX)`(原 `字符数 * SUBTAG_CHAR_W_PX`), +> `ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width(tag, TAG_CHAR_W_PX)` +> (原 `text.length() * TAG_CHAR_W_PX`);`name_tail_layout.gd` / `text_tail_arrange.gd` 收 `name_w` / +> `title_w` / 框 `w` 作入参,排布公式(增量 117 / 119 / 120 / 121)不动。`SUBTAG_CHAR_W_PX` / +> `TAG_CHAR_W_PX`(均 7.0)保留仅作无字体兜底。新增 `text_metrics_test.gd`(头less):常量、退化输入 -> +> ZERO、真实度量随串长 / 字号单调、`width_of` 有字体 == `text_size.x`、无字体回退 `字符数 * px`、 +> `label_width` 回落 `fallback_font` / null -> 0、`Label3D` 实测。离线回归 PASS=70 FAIL=0(新增 +> `text_metrics_test`)。剩余 seam ⑦ / ⑩:单行文本高仍按 15px 估(未走 GetTextSize 的 h)、poc 无公会 +> 徽记 mark 实体、三子标签竖排 vs 参考同一屏幕行、掉落名条为唯一 item 尾标来源。 +> 详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 123。 + +> **W3→M2 最新进展(增量 124)**:§8.8 seam ⑦ / ⑩ 末尾 —— 掉落名条屏幕空间去重叠所用文本框的 +> 「单行文本高」从硬编码 15px 换成 `CGraphicTextInstance::GetTextSize` 的 h 真实字体度量 1:1。 +> 参考端 `CPythonTextTail::ArrangeTextTail` 的 item 尾标去重叠把「自己」顶到 +> `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(`PythonTextTail.cpp:182`),而 +> `yEnd = ySize`(:751 / :812),`ySize` 与增量 123 换过的 `xSize` 同出 +> `pTextInstance->GetTextSize(&xSize, &ySize)`(:749 / :809)——单行文本的 `ySize` ≈ 字体行高 +> (ascent + descent),与字形无关,`Font.get_string_size(...).y` 是 1:1 对应物。`text_metrics.gd` +> (仍第 30 个 canonical,扩非新增)加 `const FALLBACK_LINE_H_PX := 15.0`(=旧 +> `ground_items.TAG_LINE_H_PX`)、`height_of(font, text, font_size, fallback_line_px := 15.0)` +> (空串 -> 0;量得正高 -> `text_size(...).y`;否则 -> 回退)、`label_height(label: Label3D, +> fallback_line_px := 15.0)`(`label.font ?? ThemeDB.fallback_font` + `label.font_size`,null -> 0)。 +> `ground_items._arrange_tags` 的名条框 `h` 由硬编码 `TAG_LINE_H_PX` 改走 +> `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`;`TAG_LINE_H_PX`(15.0)保留仅作 +> 无字体兜底。`text_tail_arrange.gd`(`box_end_y(h) = h`,对齐参考 `yEnd = ySize`)/ +> `name_tail_layout.gd` 收框高作入参、排布公式不动。`text_metrics_test.gd` 扩「5. height_of / +> label_height」段(`FALLBACK_LINE_H_PX = 15`、空串 -> 0、无字体 / `font_size<=0` -> 回退、 +> 有字体高为正且与串内容无关、随字号单调、`height_of == text_size(...).y`、`label_height(null) -> 0` +> / 空 `Label3D` -> 0),离线回归 PASS=70 FAIL=0(无新增测试文件)。剩余 seam ⑦:poc 无公会徽记 +> mark 实体、三子标签竖排 vs 参考同一屏幕行;剩余 seam ⑩:掉落名条 owner 未拆成独立 text +> instance(参考端 `yEnd += yOwnerSize + 4`,:733-736,poc 仍按单行 `"name (owner)"` 处理)、 +> 只对掉落 item 名条做。详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 124。 + +> **W3→M2 最新进展(增量 125)**:§8.8 seam ⑩ 末尾 —— 带 owner 的掉落名条,其屏幕空间去重叠 +> 框按参考 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立 +> 度量 + `yEnd += yOwnerSize + 4`」1:1。参考端 owner 后缀是与名字分开的 `CGraphicTextInstance` +> (文本 = `c_szName + strOwnership`,`IDS_POSSESSIVE_MORPHENE` 为空取字面 `"'s"`;黄 1,1,0 / +> CENTER),单独 `GetTextSize(&xOwnerSize, &yOwnerSize)`,据此 `yEnd += float(yOwnerSize + 4)` +> (:735)、`xStart = fMIN(xStart, -xOwnerSize/2 - 1)`(:736)、`xEnd = fMAX(xEnd, xOwnerSize/2 +> + 1)`(:737);owner 为空则删 owner 实例、框复位纯名字(:739-751)。`text_tail_arrange.gd` +> (扩第 29 个 canonical)加纯静态 `item_box_with_owner(name_w, name_h, owner_w := 0.0, +> owner_h := 0.0)`:`owner_w<=0 或 owner_h<=0` -> `{name_w, name_h}`(对齐参考 else 分支); +> 否则 -> `{maxf(name_w, owner_w - 2.0), name_h + owner_h + 4.0}`——令 `box_end_x(w) = w/2+2` +> 恰好等于 :737 的 `fMAX(nameW/2+2, ownerW/2+1)`,框 x 两边对称故 `box_start_x` 自动 = :736 的 +> `fMIN`。`text_metrics.gd`(扩第 30 个 canonical)加 `string_width_like(label, text, +> fallback_char_px := 7.0)` / `string_height_like(label, text, fallback_line_px := 15.0)`——用某 +> `Label3D` 的字体 / 字号量任意子串(`null` -> 字符数 * 兜底 / 空串 -> 0),对应参考端两个独立 +> text instance 各自 `GetTextSize`。`ground_items._on_added` 存 `item_name` / `item_owner` meta, +> `_arrange_tags` 把「名字子串」「owner + `'s` 子串」分别度量后走 `item_box_with_owner` 合成 +> 去重叠框;渲染仍是单个合并 `"name (owner)"` billboard `Label3D`(seam ⑩ 记录在案的折中)。 +> `text_tail_arrange_test.gd` 扩「5. item_box_with_owner」段、`text_metrics_test.gd` 扩 +> 「6. string_*_like」段、`p2b_test.gd` 的 `FakeClient.add_ground` 加可选 owner 入参 + 带 owner +> 掉落被顶 `(15+15+4+5)*0.005` 段,离线回归 PASS=70 FAIL=0(无新增测试文件)。剩余 seam ⑩: +> owner 未真正独立渲染成 CENTER 黄字、`strOwnership` 未接 `ApplicationStringTable` 的本地化语素、 +> 只对掉落 item 名条做。详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 125。 +> +> **W3→M2 最新进展(增量 126)**:§8.8 seam ⑩ 末尾 —— 带 owner 的掉落名条把 owner 后缀拆成 +> **一个独立的 CENTER 黄字(`1,1,0`)text instance**,并按参考 `ArrangeTextTail` 把名字实例 +> 下移 15px。参考 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:712-745`):owner +> 非空时新建 `pOwnerTextInstance`(:715)、文本 = `c_szName + strOwnership`(:723,`strOwnership +> = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...`,:722)、 +> `SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER)`(:727)、`SetColor(1.0f, 1.0f, 0.0f)` 黄(:729); +> 排布 `pOwnerTextInstance->SetPosition(x, y, z)`(:195)、`pTextInstance->SetPosition(x, y + +> 15.0f, z)`(:200);owner 为空 `CGraphicTextInstance::Delete(pOwnerTextInstance)`(:743)。 +> `ground_items`:drop 节点下加第二个 `Label3D "owner_tag"`(`horizontal_alignment = +> HORIZONTAL_ALIGNMENT_CENTER` / `modulate = Color(1,1,0)` / billboard / `no_depth_test` / +> `pixel_size 0.005`,文本 = `owner + _possessive`),`_apply_owner_tag(node, owner)` 建 / +> `remove_child + queue_free` 删;名字 `tag` 只放 `_name_for(vnum)`(`_tag_text` 删,不再拼 +> `"name (owner)"`)。新增 `const OWNER_NAME_GAP_PX := 15.0` + `_place_tail(n, tag, d_px)`:有 +> owner -> `owner_tag.y = TAG_BASE_Y - d_px*px`、`tag.y = TAG_BASE_Y - (d_px + 15)*px`;无 -> +> `tag.y = TAG_BASE_Y - d_px*px`(`_arrange_tags` behind-camera / 单条 / 多条三处落点都改走它)。 +> `setup()` 加可选 `possessive := ""`(`_possessive = possessive if possessive != "" else +> OWNER_POSSESSIVE`,1:1 对齐 :722 的 `== "" ? "'s" :` 语义),`game_scene` 从 `EterLocale` 的 +> `POSSESSIVE_MORPHENE` 键解析后注入(poc 的 `locale/*.txt` 无此键 -> 回落 `"'s"`,与 40250 +> 西文 `UserInterface.rc` 的 `IDS_POSSESSIVE_MORPHENE "'s"` 一致)。去重叠框度量仍是增量 125 的 +> `item_box_with_owner`(`yEnd += ownerH + 4`)不变。`p2b_test.gd` seam ⑩ 末尾段重写 +> (名字实例文本 = `"掉落28"`、`owner_tag` 文本 `"Alice's"` / CENTER / 黄 / 落基点,名字实例 +> 下移 `15 * pixel_size`;无 owner 的 drop 不建 `owner_tag`;`setup(possessive="@")` -> +> `owner_tag` 文本 `"Bob@"`),离线回归 PASS=70 FAIL=0(无新增测试文件)。剩余 seam ⑩:owner +> 行颜色语义固定黄(参考名字实例取 `pInsertTextTail->Color` 的道具品阶色、poc 名字走 `_process` +> 远近着色)、去重叠只对掉落 item 名条做。详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 126。 + > **表格旧快照更正**:W3 行中若仍写“远端 PC 仍是占位胶囊”或“任务日志缺滚动 / 定位”,以本节 > 增量 99 / 101 记录及 `CLIENT-GAP-FIX.md` 对应条目为准;后续编辑总览表时须同步替换旧描述。 @@ -1520,7 +1891,7 @@ | P0 | 🟡 | 物品操作 | 已有移动、使用、丢弃、拾取;Shift 选“来源→目标”发 `ITEM_USE_TO_ITEM`,Ctrl+右键向当前目标 `ITEM_GIVE`,Alt+右键以数量框发 `ITEM_DROP2`;增量 54 增加全局 item payload、鼠标跟随图标、inventory / equipment / belt → inventory / quickbar 投放、无效区域 / 右键 / ESC / 失焦取消和 PICK / TALK / ATTACK 光标;增量 56 接入 safebox 45 格分页 / 仓库内移动、mall 分页、exchange 12 格、shop 出售目标和 private_shop 40 格;增量 57 使 payload 携带 `anti_flags` / `flags` / `sockets` 等实例字段,普通拖放保留整组数量,Shift+左键支持 stack split,金币使用 `ITEM_MONEY=-1` 独立态;交易、NPC 商店和私人商店的关键 anti-flag / 确认边界已接入;增量 58 统一基础 tooltip;增量 60 让 m2dev / Classic 交易包保留 `sockets[3]` 与 `attrs[7]`;`GC_VIEW_EQUIP` 已保存 11 格检查快照并弹出装备查看窗 | 仍需完整交易事务、宝石专用规则,以及装备查看窗的原版槽位图标、3D 纸娃娃与交互;CJK IME、真实 `.sub` 资源、tooltip 特殊分支、真服 / 真机输入仍待验收 | | P0 | 🟡 | 装备 / 背包数据模型 | 常规背包 90 格、运行时 24 wear 位、腰带 16 格、基础 tooltip 已有;增量 96 按 `GameType.h` 对齐基础 wear / costume / ring / belt 发包坐标;增量 97 按 `ItemData.h` 防御性重建 `CanEquipNow` 的 antiflag→wearable→limit 校验;增量 98 完成变身 / 动物时装 / 婚纱部位遮挡;增量 99 让远端 PC 创建 `RemotePlayerView` 并消费 `awPart[ARMOR,WEAPON,HEAD,HAIR]` 四字段换装 | 变身字段、所有职业 / race 的资源、真服远端换装、精确 tie-break / 同槽替换 / `LIMIT_PCBANG`·`REAL_TIME*` / 属性读取、原版 costume / belt 视觉、装备对比和真机仍待验收 | | P0 | 🟡 | 移动与战斗同步 | 有 `GC_MOVE`、本地预测、攻击;`M2Client.character_position()` 可发送姿态回报,`M2Client.sync_positions()` 可按原包批量发送最多 16 个坐标,`M2Client.request_warp()` 可发送 `CG_WARP` 主动传送请求;`GC_SYNC_POSITION` 批量坐标纠偏、`GC_CHANGE_SPEED`、`GC_WALK_MODE`、`GC_CHARACTER_POSITION` 和 `GC_DIG_MOTION` 已恢复到实体状态,`NetPlay` 按 `moving_speed/100` 校准主角步行 / 奔跑预测,挖矿广播可驱动目标朝向 / dig 动画,`WALK_MODE` 会驱动远端 walk/run 动画选择;真服单会话已发送 `CG_CHARACTER_POSITION`(5B) 与 `CG_SYNC_POSITION`(16B),均无断连 / unknown;**本地移动改为 Src/Dst 模型(增量 76,§3.1)**:`player_controller.gd` 的 `walk_to()` → `_goto()` 对齐 `CInstanceBase::NEW_Goto()`(`InstanceBaseMovement.cpp:251`),太近(`< MOVABLE_GROUND_DISTANCE_M` 1.0 m)不动、三门 `_can_translate()`(`frozen`/`locked`/`moving_skill` ≈ `__IsSyncing`/`isLock`/`IsUsingMovingSkill`)未过时按 `__ReserveClickGround` 存点 + 0.1 s 延迟重试,`moving_skill` 只转向,WASD 分支 `_is_going = false`;**远端同步改为命令队列(增量 77,§3.2 part A)**:`EntityStore` 远端 Actor 位置由 `std::deque` 驱动(1:1 复刻 `StateProcess`,`InstanceBase.cpp:1278`),`mut_move()` 入队 + `drain_state_queue()` 按 `set_server_frame_ms()`(为 0 即时释放)+ `__CanProcessNetworkStatePacket` 逐条释放,`apply_state_cmd()` 走 `FUNC_WAIT` 1.0 / `FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 硬阈值 + `m_kMovAfterFunc` 到达后动作 + `skip_collision`,线性插值只保留给 §2.1 可见性;**上行 6 事件回调(增量 78,§3.2 part B)**:`net_play.gd` 按 `PythonPlayerEventHandler.cpp` 拆成 OnMove(立即 `FUNC_MOVE` + 重置 moving/waiting 节流窗)/ OnMoving(300 ms 节流)/ OnWaiting(`_tick_on_waiting`,100 ms + 位移 ≥ 1 cm)/ OnStop(`FUNC_WAIT`)/ OnAttack(`CG_ATTACK` 外额外 `FUNC_COMBO`)/ `on_use_skill()`(`FUNC_SKILL|motion`),统一走 `_send_state`;`net_world.gd` 去掉自有 `follow_lerp`,节点位置直接跟随 `e.pos`(插值全交 C++ `tick()`);**真实服务器毫秒帧钟(增量 79,§3.2 收官)**:`ClassicStream::server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`)由 `HDR_HANDSHAKE` 存的 `m_server_time_base = hs.time + lDelta` / `m_client_time_base` 算出,`ClassicSession::process()` 每帧 `set_server_frame_ms()` 后 classic 后端命令队列按服务器时间放行(m2dev 恒 0 = 即时释放,无回归);`drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS=1000`(`chk_time` 领先帧钟超 1 s 立即放行,参考端无此上限,有意偏离);**Actor-Actor 碰撞 + 坐骑转向速度(增量 93,§3.1 收尾)**:`player_controller._actor_blocked()` 近似 `TestActorCollision`(8 m 距离门 + body 球重叠 `ACTOR_BODY_RADIUS_M=0.55` + 趋近判定)+ `_process` 逐轴门控(单 Actor 滑移 / 两轴挡=`BlockMovement`),`skip_actor_collision`(≈ `CanSkipCollision()`)观战置位;`rotation_speed_deg` + `_turn_toward()` 逐帧限幅收敛,`net_play._refresh_motion_mode()` 坐骑切 `ROT_SPEED_HORSE_DEG=300`(`c_fDefaultHorseRotationSpeed`)/ 常态 1200,替换两处 `rotation.y = atan2()` 瞬转;`player_move_test.gd` 5→8 组 | 仍需按原客户端同步窗口、碰撞纠偏触发时机与保留动作处理;**抖动缓冲 / 预测回滚不做(§3.2/§6 排除)**;姿态动作还没有完整 UI / 动画语义;~~§3.1 的 Actor 间碰撞(`TestActorCollision`/`BlockMovement`)、坐骑 `MODE_HORSE*`(依赖 §6.4 + §3.2)~~(增量 93 已近似落地)、真实 body sphere 半径(`.col` race 碰撞数据)、`AdjustDynamicCollisionMovement` 精确滑移 + `uCollisionCount==2` 复合门、`MOVABLE_GROUND_DISTANCE` 默认值校准、网络驱动本地预测玩家的 `__EnableSkipCollision()`;真实服务器 `GC_MOVE` `dwTime` 时基联机核验、`__IsEnableTCPProcess` 表情锁、`GC_SYNC_POSITION` 是否清队列(随对应 §3.x) | -| P0 | 🟡 | PVP / 决斗 / 阵营 | `GC_PVP` 和 `DUEL_START` 已进入状态层,并以 `pvp_changed` / `duel_started` 信号提供给 Godot;挑战 / 战斗 / 复仇关系会在世界实体上显示标签;实体已有部分 empire / alignment / pk 字段;**可攻击判定 `IsAttackableInstance` 已 1:1(增量 94,`entity_rules.gd` + `net_play._is_attackable` / `_attack_ctx`,含 alignment grade/type、`IsSameEmpire`、`IsConflictAlignmentInstance`、`IsPVPInstance`、`__FindDUELKey`、`IsSamePartyMember`、`IsWoodenDoor`)** | ~~可攻击判定~~(增量 94 落地);~~原版名字颜色(`ENameColor` §8.8)~~(增量 110 落地:`name_color.gd` 1:1 `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 事件 + ~2Hz 兜底重染 `Label3D.modulate`,帝国名色默认关、开关 `net_world.set_empire_name_mode()` 未接 UI);~~头顶称号色 / 公会名 / 等级~~(增量 111 落地:`text_tail.gd` 1:1 `GetTitleColor` / `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag` 子标签、开关 `net_world.set_pk_title_enabled()` 未接 UI);~~头顶聊天 / 信息气泡~~(增量 112 落地:`chat_tail.gd` 1:1 `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail`,`net_world` 每 vid 一条 `"bubble"` `Label3D` + 5s 硬删 + `"名字: "` 前缀剥离 + SHOUT·COMMAND 排除、`register_info_tail()` 公开方法);~~头顶名字「是否显示」/「总是显示名字」/ LALT 名字显示 / 目标强显~~(增量 113 落地:`name_show.gd` 1:1 `IsAlwaysShowName` / `SetAlwaysShowNameFlag` / `__IsShowName` / `ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪 / `ShowCharacterTextTail` 的 `isShow · !IsGuildWall · CanPickInstance` 资格 / `UpdateAllTextTail` 的 chat `bNameFlag` 强显,`net_world._name_shown()` 四路:`_always_show_name`(`game_option_ui` radio)/ `_alt_held`(LALT)/ `_target_vid`(`net_play.target_changed`)/ chat `bNameFlag`);仍需 PK 模式上行、保护区 `ATTRIBUTE_BANPK`、原版目标板 / 决斗 UI、目标框 / 可攻击光标按 `ENameColor` 驱动;seam:`__FindPVPKey` / `__FindGVGKey` / `IsGameMaster` / `IsWall` race 白名单 / m2dev `IsKiller` 位的 packet setter、`name_color` 的 `kind_resolver` 未注入时回退裸 `ch_type`(seam ⑥)、`text_tail` 子标签 billboard 竖排 vs 屏幕空间排版(seam ⑦)、`title_name_resolver` 未注入回退裸 key(seam ⑧)、`chat_tail` 气泡与名字 `Label3D` 并存、名字不随气泡强制顶出 / 下移 17px(seam ⑨)、悬停 `chr.Pick()` 强显 / 屏幕空间 `ArrangeTextTail` 防重叠 / `CanPickInstance` 仅按死透近似(seam ⑩) | +| P0 | 🟡 | PVP / 决斗 / 阵营 | `GC_PVP` 和 `DUEL_START` 已进入状态层,并以 `pvp_changed` / `duel_started` 信号提供给 Godot;挑战 / 战斗 / 复仇关系会在世界实体上显示标签;实体已有部分 empire / alignment / pk 字段;**可攻击判定 `IsAttackableInstance` 已 1:1(增量 94,`entity_rules.gd` + `net_play._is_attackable` / `_attack_ctx`,含 alignment grade/type、`IsSameEmpire`、`IsConflictAlignmentInstance`、`IsPVPInstance`、`__FindDUELKey`、`IsSamePartyMember`、`IsWoodenDoor`)** | ~~可攻击判定~~(增量 94 落地);~~原版名字颜色(`ENameColor` §8.8)~~(增量 110 落地:`name_color.gd` 1:1 `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 事件 + ~2Hz 兜底重染 `Label3D.modulate`,帝国名色默认关、开关 `net_world.set_empire_name_mode()` 已由增量 118 接 §8.4「名字色」radio);~~头顶称号色 / 公会名 / 等级~~(增量 111 落地:`text_tail.gd` 1:1 `GetTitleColor` / `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag` 子标签、开关 `net_world.set_pk_title_enabled()` 未接 UI);~~头顶聊天 / 信息气泡~~(增量 112 落地:`chat_tail.gd` 1:1 `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail`,`net_world` 每 vid 一条 `"bubble"` `Label3D` + 5s 硬删 + `"名字: "` 前缀剥离 + SHOUT·COMMAND 排除、`register_info_tail()` 公开方法);~~头顶名字「是否显示」/「总是显示名字」/ LALT 名字显示 / 目标强显~~(增量 113 落地:`name_show.gd` 1:1 `IsAlwaysShowName` / `SetAlwaysShowNameFlag` / `__IsShowName` / `ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪 / `ShowCharacterTextTail` 的 `isShow · !IsGuildWall · CanPickInstance` 资格 / `UpdateAllTextTail` 的 chat `bNameFlag` 强显,`net_world._name_shown()` 四路:`_always_show_name`(`game_option_ui` radio)/ `_alt_held`(LALT)/ `_target_vid`(`net_play.target_changed`)/ chat `bNameFlag`);~~头顶伤害飘字 `AddDamageEffect` / `ProcessDamage`~~(增量 114 落地:`damage_effect.gd` 1:1 `enum DamageFlag` / `RecvDamageInfoPacket` 的 `bSelf` / `bTarget` / `damage >= 0` 门 / `AddDamageEffect` 的 `IsShowDamage()` 门 / `ProcessDamage` 分类(`DODGE\|BLOCK`→MISS、`CRITICAL`·`POISON` 参考端注释掉走普通数字、`bSelf`→`"damage_"`、`!bTarget`→`"nontarget_"` 不渲染、否则 `"target_"`、`while(damage>0)` 逐位)/ `SetShowDamageFlag`,`net_world._on_damage` / `_process_damage_queue` 每帧 pop 一条、`game_option_ui`「伤害数字」radio 接 `net_world.set_show_damage`);~~头顶目标框 `RecvTargetPacket` 收包分派 / `CanViewTargetHP`~~(增量 115 落地:`target_board.gd` 1:1 `RecvTargetPacket` 判定树(实例缺失→`CloseTargetBoard`、目标已死→不动框、`IsPC()\|IsBuilding()`→`CloseTargetBoardIfDifferent`、`CanViewTargetHP`(`IsStone()\|IsWoodenDoor()\|IsEnemy()`)→`SetHPTargetBoard`、否则→`CloseTargetBoard`)+ `game.py` `SetHPTargetBoard` 换 VID 先 `ResetTargetBoard`,`net_play._on_target_info` 由「任意有名字 VID 都显框」改为按判定树分派:PC / building 不显血条框、NPC / warp 关框);~~目标框「看他国玩家」前置过滤(`uitarget.py TargetBoard.Open :189-193`)~~(增量 118 落地:`target_board.hidden_as_other_empire` + `net_play._on_target_info` 在 `classify` 前判 —— game-option 关「看他国目标框」且目标异阵营 → `_clear_target()` return;`net_play.set_view_other_empire_target()` 经 `game_scene._on_display_option` 接 §8.4 `target_board` radio、开局 `display_value` 回填、`game_option_ui.gd` `target_board` 默认修 0→1);仍需 PK 模式上行、保护区 `ATTRIBUTE_BANPK`、原版目标板视觉 / 决斗 UI、可攻击光标按 `ENameColor` 驱动;seam:`__FindPVPKey` / `__FindGVGKey` / `IsGameMaster` / `IsWall` race 白名单 / m2dev `IsKiller` 位的 packet setter、`name_color` 的 `kind_resolver` 未注入时回退裸 `ch_type`(seam ⑥)、`text_tail` 子标签 billboard 竖排 vs 屏幕空间排版(seam ⑦;逐像素水平排版增量 121 已 1:1、纵向位移 119、文本宽换真实字体度量 `GetTextSize` 123 已 1:1、单行文本高换 `GetTextSize` 的 h 124 已 1:1、剩余:无公会徽记 mark 实体 / 三子标签竖排叠 x 偏移 vs 同一屏幕行)、`title_name_resolver` 未注入回退裸 key(seam ⑧)、`chat_tail` 气泡与名字 `Label3D` 并存、名字与气泡未合成单尾标链表(seam ⑨ 遗留;~~名字随气泡下移 17px~~ 增量 117 落地:`name_tail_layout.gd` 纯静态第 28 个 canonical `UpdateShowingTextTail` :110-114 —— chat 尾标 `bNameFlag` TRUE 时名字 `Label3D` 局部 y 顶到「气泡 y + 17*pixel_size」,`net_world._reposition_name_tag` 由 `_apply_chat_tail` / `_expire_chat_tails` 调用)、~~子标签竖排 vs 参考屏幕空间逐像素水平排版(seam ⑦ 遗留)~~(增量 119 落地纵向:`name_tail_layout.gd` 增 `name_tail_lift_delta` / `subtag_world_y` 照抄 `ArrangeTextTail` :211/:228/:258/:283 —— 三子标签屏幕 y 全锚在已被 `UpdateShowingTextTail :114` 顶过的 `pTextTail->y` 上,`net_world` `SUBTAG_DEFAULT_Y` 常量 + `_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`;增量 121 落地横向:`name_x_shift`(fxAdd 8/4/0)/ `title_x_offset` / `level_x_offset` / `subtag_world_x` 照抄 `ArrangeTextTail :211-320` 非 EUROPE 分支,`net_world._layout_subtags_x` 在 `_apply_text_tail_to` 尾部设名字 / `TitleTag` / `LevelTag` 的 `.position.x`、`GuildTag` 恒 0;增量 123 落地文本宽:`text_metrics.gd` 纯静态第 30 个 canonical `text_size` / `width_of` / `label_width`(`Font.get_string_size` = `CGraphicTextInstance::GetTextSize` 1:1),`_layout_subtags_x` 的 `name_w` / `title_w` 与 `ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width`、`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX` 保留仅作无字体兜底;增量 124 落地单行文本高:`text_metrics.gd` 增 `FALLBACK_LINE_H_PX := 15.0` + `height_of` / `label_height`,`ground_items._arrange_tags` 的框高 `h` 改走 `TextMetrics.label_height`(= `GetTextSize` 的 h)、`TAG_LINE_H_PX` 保留仅作无字体兜底;seam ⑦ 剩余:无公会徽记 mark 实体 / 三子标签竖排叠 x 偏移 vs 同一屏幕行)、~~悬停 `chr.Pick()` 强显~~(增量 116 落地:`pick_show.gd` + `net_world._hover_vid` forced 第 5 路 + `player_controller.hover_entity_changed`)、~~屏幕空间 `ArrangeTextTail` 防重叠~~ 增量 120 落地:`text_tail_arrange.gd` 纯静态第 29 个 canonical `boxes_overlap`(`isIn`)+ `arrange_item_tails`(外层逐条 / 内层比对 / 命中顶到 `pCompareTextTail->y + yEnd + 5` / 从头重跑 / `MAX_ITERS` 20 封顶),`ground_items._arrange_tags` 每帧投影所有掉落名条、跑去重叠、按 `pixel_size` 折回局部 y,`game_scene` 传相机 getter;`CanPickInstance` 仅按死透近似(seam ⑩:名条框宽 / 单行文本高已分别由增量 123 / 124 换真实字体度量 `GetTextSize` 的 w / h 1:1、带 owner 的去重叠框已由增量 125 按 `SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立度量 + `yEnd += yOwnerSize + 4`」换 1:1(`text_tail_arrange.gd` 加纯静态 `item_box_with_owner`、`text_metrics.gd` 加 `string_width_like` / `string_height_like`、`ground_items._on_added` 存 `item_name` / `item_owner` meta 后 `_arrange_tags` 把名字子串与 owner + `'s` 子串分别度量),剩余:owner 未独立渲染成 CENTER 黄字 / `strOwnership` 未接本地化语素、只对掉落 item 名条)、伤害飘字单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效 / self 双资源交替 / 全局单队列(seam ⑪)、目标框 poc 无 building / wooden-door 实体类型(`is_building` 恒 false、木门分类缺)/ ~~`LCONTROL` 悄悄话捷径未接~~(增量 122 已接:`net_play._on_pick` KIND_PC 分支轮询 `whisper_on_ctrl_click` → `whisper_requested` → `game_scene` → `chat.start_whisper`;`game_scene` `KEY_CTRL` 喂 `_lcontrol_down`)/ `target_info` 由 C++ EntityStore 侧抽出不改 C++ 收包(seam ⑫ 末尾:门实体分类、独立 whisper dialog 窗口) | | P1 | 🟡 | Actor 战斗运行时 | 有骨骼动画、基础 combo / 相机抖动;受击不再用固定硬直秒数(增量 73 删除 `_hitstun_until`),「能否行动」统一走 `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`(对齐 `InstanceBase.cpp:1247` / `InstanceBaseBattle.cpp:355` / `:310`),击退态由受击 `.msa` 播放生命周期驱动、动作播完清零;动作切换按 Granny `EaseInCurve(0,0,1,1)` 的 `2w²−w³` 逐骨 crossfade;角色 mesh 支持距离 LOD、15% 迟滞和约 0.18s 新旧 mesh 淡入(`LodGhost` + `lod_fade` uniform);右手武器 / 左手盾按 `weaponComposite[0] = weaponInvWorld[0]·weaponLocal[0]` grip 复合挂 `equip_right_hand`(探针实测 Δ≤0.06 m,非标准米级偏移文件夹断回退);`.msa` motion event 已分派声音、EffectPosition 特效、震屏、屏闪和弓箭 FLY(增量 86:`type 6` FIFO `_pending_shots` → `client.shoot()` = `OnShoot`,`OnSetFlyTarget` 在 `net_play._emit_swing` 发 `client.add_fly_targeting()`;WARP 留钩子);玩家 / 怪物强制实时投影并附脚底接触阴影 Decal;点击 / 攻击距离按目标类型分 `150 / 500 / 100` cm(增量 74,对齐 `InstanceBaseBattle.cpp:294`);普攻节奏改由 `.msa` `ComboInputData.DirectInputTime`(`get_motion_data().next_combo`,缺组退化 `duration*0.9`)除以 `bAttackSpeed/100` 得出,`attack_speed` 只缩放动作播放速率不再写死间隔;自动攻击走 `ReservedMode` 状态机(增量 75):`_update_auto_attack()` + `_reserve_process_click_actor()` 严格 10 步(照 `PythonPlayer.cpp:207` / `PythonPlayerInput.cpp:877`:出距离 `pc.walk_to` 靠近、NPC 走 `click_npc`、`IN_SAFE` / `DEST_IN_SAFE` / `EMPTY_ARROW` 经 `signal cannot_act`、第 10 步按节奏 `_do_attack_swing`) | PC 连击段表已解析(增量 80,`formats/combo_table.{h,cpp}` 从 `playersettingmodule.py` 的 `ReserveComboAttackNew` / `RegisterComboAttackNew` 建 `map>`,非 `.msm`),并已接进 `net_play.gd` 连击状态机替掉 `(_combo + 1) % 3`(增量 81:`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐 `ActorInstanceBattle.cpp:157/213/256/297/308`,按 `.msa` `ComboInputData` 三时间驱动 `_combo_index` / `_is_pre_input` / `_is_next_pre_input`,段号经新静态绑定 `Metin2AnimPlayer.parse_combo_tables()` 按 `race & 3` 取);`.msa` `HitDataContainer` 多命中窗已导出(增量 82:`formats/msa.{h,cpp}` `Msa::HitWindow` + `get_motion_data().hit_windows` / `motion_type` / `hit_limit_count`,`textscript.cpp` 补 `List` 解析,含真实双命中窗 `assassin/dualhand_sword/combo_01.msa`);命中帧已落地(增量 83:`net_play.gd` `_begin_hit_windows` / `_attack_process(dt)` / `_hit_deduped` / `_register_hit` / `_hit_geometry` / `_flush_victim_list` 逐帧按动作时间与 `hit_window` [start,end] 相交对 `client.get_entities()` 可攻击 / 未死 / 非安全区实体做几何判定,`m_HitDataMap` 每窗查重 + 命中上限 NORMAL/COMBO 16 / SKILL `hit_limit_count`,命中 → `client.attack(_swing_skill, vid)`(`OnHit`),被击退目标帧末 `client.sync_positions()` ≤16;`_emit_swing` 改为只有缺命中窗数据才在挥击瞬间发 `CG_ATTACK`);武器种类 → `combo_motion_mode` 映射已落地(增量 84:`net_play.gd` `motion_mode_for()` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode`(`InstanceBase.cpp:2851`),`_refresh_motion_mode()` 读 `parts[WEAPON]` / `get_equipment()[WEAR_WEAPON].vnum` + `proto.item().{type,sub_type}` + `mount_vnum` / `polymorph` / `wedding_dress`,由 `_on_entity_info` / `inventory_changed` / `mount_changed` 三处触发,换模式内部 `_clear_combo()`;`combat_fx_test` 加 `FakeProto` + 15 段映射 + 2 段端到端断言);弓箭 `__GetBowRange()` 已落地(增量 85:`_clickable_distance()` 里 `_is_bow_mode()` 改用 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus`(`POINT_BOW_DISTANCE`=34,`_on_points()` 读 `points["bow_distance"]`),NPC/采集物分支覆盖之,逐分支对齐 `NEW_IsClickableDistanceDestInstance` + `__GetBowRange`;`combat_fx_test` §3.5 段加 8 段断言);剩余:几何判定当前是 `reach`(max WeaponLength / 采样水平偏移)+ 正面 ±60° 弧近似,真实动态 Z 圆柱 vs 防御球扫掠随 §3.1 Actor 碰撞一起补;坐骑移速规则、`POINT_BOW_DISTANCE` 真服下发路径和服务端合法性校准;技能合法性判定位数据层已落地(增量 87:`ui/skill_table.gd` 按 `RegisterSkillDesc` 把 skilldesc 第 11/12 列折成 `attr_bits` / `need_weapon_bits` + `skill_type`,`IsCanUseSkill` / `IsStandingSkill` / `IsHorseSkill` / `IsNeedTarget` / `IsNeedCorpse` / `CanChangeDirection` / `IsChargeSkill` / `CanUseWeaponType` 等谓词逐条对齐 `CPythonSkill`);三层校验已落地(增量 88:`project/player_skill.gd` `extends RefCounted` 纯校验器逐行移植 `PythonPlayerSkill.cpp` 的 (a)`click_skill_slot`(`:106`)/ (b)`can_use_skill`(`:439`,硬编码骑乘术 `109`)/ (c)`check_skill_usable`(`:166` 完整前置顺序)/ (d)`use_skill`(`:460` + `_resolve_target` 目标矩阵),返回 `{ok, code, target_vid}`,`net_play.skill_context()` 注入运行期状态,`ui/quickbar.gd activate()` 施放前跑三层、非静默码 → 信号 `skill_rejected` → `net_play.cannot_act`(同一 `OnCannotUseSkill` 通道),新增 `player_skill_test.gd`);边界收紧 + 短蓝短血校验已落地(增量 89,§3.8 修改 3 → 三层全落地:`net_bounds.h` `SKILL_MAX_NUM=255` + `skill_index()` 谓词,`M2Client::use_skill()` 的 `skill_id` 护栏收紧成 `!bounds::skill_index()` 对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM` 门;`skill_table.gd` 解析 `.msk` `NeedSPFormula` + `_SKILL_POWER[]` 功率表 → `need_sp` / `need_hp` / `_skill_point`;`player_skill.gd` `_check_short_mana` / `_check_short_life` 从桩改为移植 `__CheckShortMana`(`:320`)/ `__CheckShortLife`(`:354`),HP/SP 经 `net_play.skill_context()` 读 `get_points()` 供入、`-1` 不判);技能升级门已落地(增量 90,§3.10:`wire.h` +`POINT_SKILL=28` / `POINT_SUB_SKILL=27` / `POINT_HORSE_SKILL=113`,`M2Client::get_points()` 透出 `skill_active` / `skill_support` / `skill_horse`,`skill_up()` 入参收紧为 `!bounds::skill_index()`;`skill_table.gd` +`max_level_of` / `skill_level_limit`(本 40250 数据无 `LEVEL_LIMIT` 列 / `.msk` 记号 → 恒 0)/ `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,本 build 前置技能 / 前置属性字段无人填充 → 化简为 `level < GetMaxLevel() && !CANNOT_LEVEL_UP`);`skill_ui.gd` `refresh()` 逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`——技能组门 `get_skill_group()!=0` + 三技能点来源分开取 + `skillGrade==0` + 马术页 `level<20` + 主动页 `can_level_up_skill` + 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐;按钮 `_rebuild()` 建 / `refresh()` 切 `visible`,升级结果由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动、不本地伪造;`skill_test.gd` +`§3.10` 段);`ReservedMode` 的 CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派已落地(增量 91:`_refresh_reserved()` 四路 `match` 逐行对齐 `NEW_RefreshMouseWalkingDirection`,含 CLICK_ITEM 的 20cm 拾取阈值、CLICK_POSITION 的 0.1s ReservedDelay 门、USE_SKILL 的 `IS_HUGE_RACE` −200cm 修正 + `use_skill_hook` 进射程施法;`__ChangeTargetToPickedInstance` 拾取回退 = `IsTargetableInstance` 限 PC/怪、否则清目标;扇形 / 圆形补 fly-targeting = `send_fly_targeting()` 经 `_instances_in_fan_range` / `_instances_in_circle_range` + `_random_pos_in_fan_range` 补 `CG_ADD_FLY_TARGETING`,几何逐行对齐 `NEW_GetInstanceVectorInFanRange` / `InCircleRange`);安全区 / 弓箭 / 骑乘 / dash 判定位的服务端来源、`IS_HUGE_RACE` vnum 白名单(现用 `proto.mob` `size>=3` / `rank>=4` 近似)仍留桩;另补 combo 取消窗口、精确受击 / 击退、角色碰撞、同步碰撞、武器拖尾、剩余 motion event / 骨骼挂点语义、GPU skin LOD、双持 / 左手武器和目标锁定相机;`03150` 类偏移网格需 per-weapon 偏移数据或修 .gr2 | | P1 | 🟡 | 飞行物与命中特效 | `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 保存 shooter 的目标队列和 VID / 坐标;~~`GC_CREATE_FLY` 可消费队列并生成 tween 发光球~~ → **增量 92:`project/fly_object.gd` 的 `FlyManager` / `FlyInstance` / `FlyData` 对齐 `GameLib/FlyingObjectManager.{h,cpp}` + `FlyingInstance.{h,cpp}`——`Update()` 逐帧真实弹道(`vel += accel*dt` + `gravity`)+ 线段-点命中判定(`_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80` `square_distance_between_linesegment_and_point` < `m_fBombRange²`)+ `OnShootDamage` / pierce `--` 续飞 / 超程 `__Explode(false)` / `m_bHitOnBackground` 触地;`FlyData` 默认值取 `FlyingData.cpp __Initialize()`;`net_world._on_fly` 换成 `_fly_mgr.spawn(...)` + `signal fly_exploded`。`net_play._can_shot()` 防御式重建 `__CanShot`(`grep` 全树无定义)——非弓 / 无箭 `EMPTY_ARROW` / 无目标 / 死目标 / 出弓程 `OUT_OF_RANGE`,`_emit_swing` 弓分支据此跳过 `CG_FLY_TARGETING` / `OnShoot`** | seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70)→ 实际驱动走 POC 自有 `fly_cue`;重力参考端作用于 `.z`=上、本 POC 映射到 `-Y`;怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素 r=1 判定未移植(客户端飞行物只做命中"时机",伤害服务端裁决);无 `.fly` 数据文件加载器 → 合成箭参数(`init_vel=45` / `flat_range=max(dist*1.5,30)` / `bomb_range=0.6`);`FIRE_CRACKER` / `AUTO_FIRE` 的 `CreateIndexedFly` 分支已在常量层预留但本 build 无驱动来源;bomb effect / 声音 / EffectLib 特效仍是占位闪光 | | P1 | 🟡 | 任务 / NPC | 增量 95 已完成 `CPythonEventManager` 常用标签解析 / 分派;增量 101 完成 Quest 日志 `QUEST_MAX_NUM=5` 五行窗口、离散滚动、默认 / 服务端 icon、clock 和任务行事件;增量 108 完成小地图任务信标越界夹圆周 + 指向目标的方向箭头(世界箭头 / 屏幕边缘指示首版,`minimap.signal_marker_geometry` + `_draw_signal_arrow`);增量 109 完成 quest 信件按钮条 `interfacemodule.__ArrangeQuestButton` 多列定位(`yCount=(H-330)//63`、换列 +100px、最新在前、组队窗 `xPos`→130);`SelectItemUI`、QuestButton、地图信号和对话图片已有首版 | 逐帧 `UpdateEventSet`、Skip / EndEventProcess、`.msc`、NEXT / DONE 文案、相机 / fade / cinema 消费端、CJK IME、任务日志窗体 `_list` 自身分页 / 多列、Atlas 同一信标夹边、完整 `.sub` 图标、立绘 / 电影、真实资源和真服仍待验收 | @@ -1553,7 +1924,7 @@ | 参考模块 / 能力域 | 当前实现 | 与 40250/ClientVS22 的差距 | 优先级 | |---|---|---|---| | `AccountConnector` / `PythonNetworkStreamPhase*` 登录与相位 | auth → game → 选人 → LOADING → PHASE_GAME 已打通;`LoadingScreen`、频道状态探针、Classic DirectEnter 和 Classic `GC_WARP` 重连 / 换图清理均有首版 | 登录 / 选服 / 选频道的原版错误码、表单校验、超时与会话失效文案,真实双连接 / 换图验收、服务器列表刷新和所有相位边界清理仍未逐项对齐 | P1 | -| `PythonChat` / `PythonTextTail` / `PythonPlayer` | 聊天四标签、私聊 / 组队 / 公会 / 喊话路由,实体气泡、名字、HP 条、伤害飘字和本地气泡已有;**头顶名字颜色已 1:1(增量 110,`name_color.gd` `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 驱动 `Label3D.modulate`);头顶公会名 / 称号色 `GetTitleColor` / 等级子标签已 1:1(增量 111,`text_tail.gd` `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag`);头顶聊天 / 信息尾标已 1:1(增量 112,`chat_tail.gd` `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail`,`net_world` 每 vid 一条 `"bubble"` + 5s 硬删 + 前缀剥离 + SHOUT·COMMAND 排除);头顶名字「是否显示」`IsAlwaysShowName` / `__IsShowName` / `ShowAllTextTail` 的 3500 平面距裁剪 / `ShowCharacterTextTail` 资格已 1:1(增量 113,`name_show.gd` 纯静态,`net_world._name_shown()` 四路 + `game_option_ui` `display_option_changed` 信号 + `net_play.target_changed` 强显)** | 缺目标高亮框、`GC_DAMAGE_INFO` 伤害数字整合和屏幕空间血条(名字颜色 / 称号色 / 公会名 / 等级 / 聊天气泡增量 110–112 已 1:1,名字显示开关 113);悬停 `chr.Pick()` 强显 / 屏幕空间 `ArrangeTextTail` 防重叠未做(seam ⑩);经验 / 金钱变化的原版日志行、notice / big-notice 排版、历史滚动和 `InsultChecker` 过滤未完整迁移 | P1 | +| `PythonChat` / `PythonTextTail` / `PythonPlayer` | 聊天四标签、私聊 / 组队 / 公会 / 喊话路由,实体气泡、名字、HP 条、伤害飘字和本地气泡已有;**头顶名字颜色已 1:1(增量 110,`name_color.gd` `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 驱动 `Label3D.modulate`);头顶公会名 / 称号色 `GetTitleColor` / 等级子标签已 1:1(增量 111,`text_tail.gd` `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag`);头顶聊天 / 信息尾标已 1:1(增量 112,`chat_tail.gd` `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail`,`net_world` 每 vid 一条 `"bubble"` + 5s 硬删 + 前缀剥离 + SHOUT·COMMAND 排除);头顶名字「是否显示」`IsAlwaysShowName` / `__IsShowName` / `ShowAllTextTail` 的 3500 平面距裁剪 / `ShowCharacterTextTail` 资格已 1:1(增量 113,`name_show.gd` 纯静态,`net_world._name_shown()` 四路 + `game_option_ui` `display_option_changed` 信号 + `net_play.target_changed` 强显);头顶伤害飘字 `GC_DAMAGE_INFO` / `AddDamageEffect` / `ProcessDamage` 分类 + `IsShowDamage` 门已 1:1(增量 114,`damage_effect.gd` 纯静态,`net_world._on_damage` / `_process_damage_queue` 每帧 pop 一条 + `game_option_ui`「伤害数字」radio 接 `net_world.set_show_damage`);头顶目标框 `GC_TARGET` / `RecvTargetPacket` 收包分派 + `CanViewTargetHP` 已 1:1(增量 115,`target_board.gd` 纯静态 `Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE }` 判定树,`net_play._on_target_info` 按判定树分派:PC / building 不显血条框、NPC / warp 关框、石头 / 敌对怪 `SetHPTargetBoard`、换 VID 先 Reset);`game.py OnRender` Picking 块分派 + 悬停角色强显名字已 1:1(增量 116,`pick_show.gd` 纯静态第 27 个 canonical:`IsPickedWindow` 门 + `chr.Pick()` / `targetBoard.GetTargetVID()` → `ShowCharacterTextTail` + `__IsShowName()` 分叉 `item.Pick()` ↔ `ShowAllTextTail` + `textTail.Pick()` + `SelectItemName`,`net_world._hover_vid` forced 第 5 路 + `player_controller.hover_entity_changed`);头顶名字尾标随聊天气泡上移 17px 已 1:1(增量 117,`name_tail_layout.gd` 纯静态第 28 个 canonical `UpdateShowingTextTail` :110-114 —— chat 尾标 `bNameFlag` TRUE 时名字 `Label3D` 局部 y 顶到「气泡 y + 17*pixel_size」,`net_world._reposition_name_tag` 由 `_apply_chat_tail` / `_expire_chat_tails` 调用;seam ⑨ 收口);目标框「看他国玩家」前置过滤 + §8.4 名字色 radio 接线已 1:1(增量 118,`target_board.hidden_as_other_empire` + `net_play._on_target_info` 前置过滤 + `net_play.set_view_other_empire_target` / `net_world.set_empire_name_mode` 经 `game_scene._on_display_option` 接 `game_option_ui` 的 `target_board` / `name_color` radio;`game_option_ui.gd` `target_board` 默认修 0→1;§8.4 三显示 radio + LALT 全部接线);头顶「公会名 / 称号 / 等级」子标签随名字尾标纵向位移已 1:1(增量 119,`name_tail_layout.gd` `name_tail_lift_delta` / `subtag_world_y` 照抄 `ArrangeTextTail` :211/:228/:258/:283,`net_world` `SUBTAG_DEFAULT_Y` 常量 + `_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`,气泡过期 / 「信息」尾标时回默认);掉落物名条屏幕空间去重叠 `ArrangeTextTail` item 块 + `isIn` 已 1:1(增量 120,`text_tail_arrange.gd` 纯静态第 29 个 canonical:`boxes_overlap`=`isIn` AABB 含边界 + `arrange_item_tails` 外层逐条 / 内层比对 / 命中顶到 `pCompareTextTail->y + yEnd + 5` / 从头重跑 / `MAX_ITERS` 20 封顶 + `box_*` 四边 `-w/2-2` `w/2+2` `-2` `h`,`ground_items._arrange_tags` 每帧把所有掉落名条锚点 `cam.unproject_position` 到屏幕、按字符数估框、跑去重叠、按 `Label3D.pixel_size` 折回局部 y、可见名条 < 2 回默认,`game_scene` 传相机 getter);头顶「公会名 / 称号 / 等级」子标签逐像素水平排版(`ArrangeTextTail` 角色块 `PythonTextTail.cpp:211-320` 非 EUROPE 分支)已 1:1(增量 121,`name_tail_layout.gd` `name_x_shift`(fxAdd 8/4/0)/ `title_x_offset`(`−nameW/2−8`)/ `level_x_offset`(有称号再 `−titleW`)/ `guild_name_x_offset` / `mark_x_offset` / `subtag_world_x`,`net_world` `const SUBTAG_CHAR_W_PX := 7.0` + `_layout_subtags_x` 在 `_apply_text_tail_to` 尾部设名字 / `TitleTag` / `LevelTag` 的 `.position.x`,`GuildTag` 恒 0(poc 无公会徽记实体);纵向 `.position.y`(117/119)与横向 `.position.x`(121)互不干扰);目标框点击 `LCONTROL` 悄悄话捷径已 1:1(增量 122,`net_play` `signal whisper_requested(name)` + `var _lcontrol_down` + `set_lcontrol_down()`,`_on_pick` 在 `KIND_PC` 分支(C++ picking 只对 PC 派发 `SetPCTargetBoard`)轮询 `target_board.whisper_on_ctrl_click`(`lcontrol_down and same_empire and not is_main and not is_building`,谓词增量 118 已备)→ 发 `whisper_requested`,`game_scene._unhandled_input` `KEY_CTRL` 按下 / 松开喂 `set_lcontrol_down(e.pressed)` + `whisper_requested` → `chat.start_whisper`(poc 版 `OpenWhisperDialog`=聊天输入预填 `"/w 名 "`);`is_building` 实参恒 false(poc 无门实体、:814 分支永不触发)、无独立 whisper dialog 窗口 = seam ⑫ 末尾);头顶子标签 / 掉落名条排版的文本宽换真实字体度量 `CGraphicTextInstance::GetTextSize` 已 1:1(增量 123,`text_metrics.gd` 纯静态第 30 个 canonical `text_size` / `width_of` / `label_width` = `Font.get_string_size`,`net_world._layout_subtags_x` 的 `name_w` / `title_w` 与 `ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width`,`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX` 保留仅作无字体兜底、`name_tail_layout.gd` / `text_tail_arrange.gd` 排布公式不动);头顶掉落名条屏幕空间去重叠框的「单行文本高」换真实字体度量 `CGraphicTextInstance::GetTextSize` 的 h 已 1:1(增量 124,`text_metrics.gd` 增 `const FALLBACK_LINE_H_PX := 15.0` + `height_of` / `label_height` = `Font.get_string_size(...).y`,`ground_items._arrange_tags` 的名条框 `h` 由硬编码 `TAG_LINE_H_PX` 改走 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`,`TAG_LINE_H_PX`(15.0)保留仅作无字体兜底、`text_tail_arrange.gd`(`box_end_y(h) = h`)/ `name_tail_layout.gd` 排布公式不动);带 owner 的头顶掉落名条屏幕空间去重叠框按 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立度量 + `yEnd += yOwnerSize + 4`」已 1:1(增量 125,`text_tail_arrange.gd` 扩第 29 个 canonical 加纯静态 `item_box_with_owner`——无 owner -> 纯名字框、有 owner -> `{maxf(name_w, owner_w-2.0), name_h+owner_h+4.0}` 使 `box_end_x(w)` 恰等于参考 :737 `fMAX(nameW/2+2, ownerW/2+1)`;`text_metrics.gd` 扩第 30 个 canonical 加 `string_width_like` / `string_height_like` 量某 `Label3D` 字体下的任意子串;`ground_items._on_added` 存 `item_name` / `item_owner` meta,`_arrange_tags` 把名字子串与 owner + `'s` 子串分别度量后走 `item_box_with_owner`;渲染仍单个合并 `"name (owner)"` `Label3D`)** | 缺屏幕空间血条(名字颜色 / 称号色 / 公会名 / 等级 / 聊天气泡增量 110–112 已 1:1,名字显示开关 113,伤害数字 114,目标框 115,OnRender Picking + 悬停强显 116,名字尾标随气泡上移 17px 117,看他国目标框 + 名字色 radio 接线 118,子标签随名字尾标纵向位移 119,掉落物名条屏幕空间去重叠 120,子标签逐像素水平排版 121,目标框 `LCONTROL` 悄悄话捷径 122,头顶子标签 / 掉落名条文本宽换真实字体度量 `GetTextSize` 123,掉落名条去重叠框单行文本高换 `GetTextSize` 的 h 124,带 owner 的掉落名条去重叠框换 `SetItemTextTailOwner` 1:1 125);名字与气泡合成单尾标链表未做(seam ⑨ 遗留,名字随 chat 气泡下移 17px 已由 117 收口、子标签随名字纵向位移已由 119 收口、`ArrangeTextTail` item 尾标去重叠已由 120 收口、子标签逐像素水平排版已由 121 收口、文本宽换真实字体度量 `GetTextSize` 已由 123 收口、单行文本高换 `GetTextSize` 的 h 已由 124 收口 —— seam ⑦ 剩余:poc 无公会徽记 mark 实体、三子标签竖排叠 x 偏移 vs 参考同一屏幕行);掉落名条框宽 / 单行文本高已分别由增量 123 / 124 换真实字体度量 `GetTextSize` 的 w / h 1:1、带 owner 的去重叠框已由增量 125 按 `SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立度量 + `yEnd += yOwnerSize + 4`」换 1:1(seam ⑩ 剩余:owner 未真正独立渲染成 CENTER 黄字、`strOwnership` 未接 `ApplicationStringTable` 本地化语素、只对掉落 item 名条);伤害飘字 `damagevalue` dds 逐位特效 / self 双资源交替未做(seam ⑪);目标框 poc 无 building / wooden-door 实体类型(`is_building` 恒 false)/ `LCONTROL` 悄悄话捷径已由增量 122 接 whisper(`net_play.whisper_requested` → `chat.start_whisper`)/ 无独立 whisper dialog 窗口(seam ⑫ 末尾);经验 / 金钱变化的原版日志行、notice / big-notice 排版、历史滚动和 `InsultChecker` 过滤未完整迁移 | P1 | | `PythonPlayer` HUD / affect / target | HP、SP、经验、等级、目标血条、死亡复活窗和 affect 色块条已绑定 | 原版状态栏布局、图标贴图、持续时间倒计时、目标板按钮 / 交互、所有 point 类型和战斗日志格式仍是简化版 | P1 | | `PythonPlayerInput*` / `CameraProcedure` / `PythonApplicationCursor` | WASD、点地、触屏点选 / 单指相机拖动 / 双指捏合、桌面右键拖拽与滚轮缩放、网格 AABB 点选、相机遮挡淡出和碰撞、首版 TALK / ATTACK / PICK 光标和窗口键盘焦点已有;`GC_CHANGE_SPEED` 会校准本地移动速度 | 缺 BUY / SELL / CANT_GO 等完整光标状态、真实 `.sub` 光标资源、相机模式与灵敏度配置持久化;macOS F1–F12 仍需实机验收 | P1 | | `EterPythonLib` / `PythonApplicationModule` UI 运行时 | 80 个真实 `uiscript` 可解析;约 20 类控件、窗口栈、modal、拖动标题栏、全局物品 attach / drop / cancel 和通用弹窗已有 | `ani_image`、`mark`、真实 `grid_table` / scrollbar / list 行为、`.dds` UI 贴图、复杂窗口的落点分支、焦点 / 锚点 / 层级和原版九宫格视觉仍未完全对齐 | P1 | @@ -2234,10 +2605,260 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 radio 经 `display_option_changed` 信号)`or` `_alt_held`(LALT);`ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪;`net_play.target_changed` 目标 VID / chat 尾标 `bNameFlag` 强显绕过 3500 但仍过 `isShow · !IsGuildWall · CanPickInstance` 资格)。 - **仍是 seam ⑩**:悬停 `chr.Pick()` 强显、屏幕空间 `ArrangeTextTail` 防重叠、`CanPickInstance` - 仅按死透近似;`ShowName()` 的 `playerGauge.EnableShowAlways()` / `SetQuickPage` 未移植。 + 头顶伤害飘字已由增量 114 的 `damage_effect.gd`(纯静态)+ `net_world._on_damage` / + `_process_damage_queue()` 补齐:`RecvDamageInfoPacket`(`PythonNetworkStreamPhaseGame.cpp:2401`) + 的 `bSelf`(目标是主实例)/ `bTarget`(目标是 `m_pInstTarget`)/ `damage >= 0` 门、 + `CInstanceBase::AddDamageEffect`(`InstanceBaseEffect.cpp:87`)的 `CPythonSystem::IsShowDamage()` + 门、`ProcessDamage`(:100,`InstanceBase.cpp:1910` 每帧调一次,`m_DamageQueue` 每帧 `pop_front` + 一条)的分类(`(flag & DODGE) || (flag & BLOCK)` → `bSelf ? MISS : TARGETMISS` 并 return; + `CRITICAL` / `POISON` 的 `CreateEffect` 参考端均注释掉 → 落普通数字;`bSelf` → `"damage_"` + + `SELFDAMAGE` / `SELFDAMAGE2` 交替;`bTarget == false` → `"nontarget_"` + return(不渲染); + 否则 `"target_"`;`while (damage > 0)` 逐位 `% 10` / `/= 10`,`index 0..7`,每位一张 + `damagevalue/.dds` 按 `FONT_WIDTH(30) * index` 相机右向铺开)、 + `CPythonSystem::SetShowDamageFlag`(`PythonSystem.cpp:342`,`bShowDamage = iFlag == 1`, + 默认 `true`)全部 1:1;`game_option_ui`「伤害数字」radio 经 `display_option_changed` 接 + `net_world.set_show_damage`,`net_world.clear_for_map_change` 清 `_damage_queue`。 + 自检 `project/damage_effect_test.gd`(第 25 个 canonical,~50 组断言)。 + **seam ⑪**:本移植用一块 billboard `Label3D` 写 `str(amount)` / `"MISS"`,无 `damagevalue` + dds 逐位特效 / 相机右向像素铺排;self `SELFDAMAGE`⇄`SELFDAMAGE2` 退化为落字水平抖动符号 + 交替(±0.3);暴击不再放大 / 变色(对齐该 40250 build 本就无独立暴击视觉);`_damage_queue` + 全局单条 vs 参考每实例一条(同帧多 VID 受击分帧显示);`damage_number` 信号仍无条件发 + (本移植扩展供战斗日志 / 测试)而参考端整个 `AddDamageEffect` 受 `IsShowDamage()` 门控。 + **seam ⑩**:~~悬停 `chr.Pick()` 强显~~(增量 116 收口,见下)、~~屏幕空间 `ArrangeTextTail` 防重叠~~ + (增量 120 收口,见下)、`CanPickInstance` 仅按死透近似;`ShowName()` 的 + `playerGauge.EnableShowAlways()` / `SetQuickPage` 未移植。 + 头顶目标框(TargetBoard)收包分派已由增量 115 的 `target_board.gd`(纯静态)+ + `net_play._on_target_info` 补齐:`CPythonNetworkStream::RecvTargetPacket` + (`PythonNetworkStreamPhaseGame.cpp:2424`)的判定树 —— 实例缺失 → `CloseTargetBoard`(:2453); + 目标 `!IsDead()` 时:`IsPC() || IsBuilding()` → `CloseTargetBoardIfDifferent`(:2440)、 + 否则 `pInstPlayer->CanViewTargetHP(*pInstTarget)`(`InstanceBase.cpp:2293` = + `IsStone() || IsWoodenDoor() || IsEnemy()`)→ `SetHPTargetBoard`(:2442)、否则 + `CloseTargetBoard`(:2444);`m_pInstTarget = pInstTarget` 仅在 `!IsDead()` 块内(:2447); + 目标已死透 → 不动框。`game.py` 的 `SetHPTargetBoard`(:829,换 VID 先 `ResetTargetBoard` + + `SetEnemyVID`)/ `CloseTargetBoardIfDifferent`(:837)/ `CloseTargetBoard`(:841)/ + `SetPCTargetBoard` 的 `LCONTROL` 悄悄话捷径(:804)全部 1:1。`net_play._on_target_info(vid, hp%)` + 由「任意 `vid != 0` 且有名字都 `hud.set_target`」改为按 `classify` 分派(`is_pc = _entity_kind + == KIND_PC`、`can_view = KIND_STONE 或 KIND_MONSTER`):`CLOSE` / `CLOSE_IF_DIFFERENT` + (`vid != _target_vid` 才)→ `_clear_target()`;`SET_HP` → 换 VID 先 `_target_vid = vid` + + `target_changed.emit` 再 `hud.set_target(名字, clamp_hp_pct(hp%))`;`NONE` → 不动。 + 自检 `project/target_board_test.gd`(第 26 个 canonical,~35 组断言)。 + **seam ⑫**:poc 无 building / wooden-door / stone-door 实体类型(`is_building` 恒 false、 + 木门分类缺,做出来后再补 `IsWoodenDoor` / `IsBuilding`);`target_info(vid, hp%)` 由 C++ + `extension/src/net/m2_client.cpp:3129/3633` 从 EntityStore 抽出,本移植不改 C++ 收包 + (对齐「纯 GDScript」约束);~~`LCONTROL` 悄悄话捷径谓词 `whisper_on_ctrl_click` 已备, + `net_play` 暂不接(无 whisper dialog 钩子)~~ 增量 122 已接线:`net_play._on_pick` 在 + `_entity_kind(e) == KIND_PC` 分支(C++ picking 只对 PC 派发 `SetPCTargetBoard`)调 + `TargetBoard.whisper_on_ctrl_click(_lcontrol_down, EntityRules._is_same_empire(_main_entity(), e), + vid == _main_vid, false)`,真且 `_entity_name(e) != ""` → `whisper_requested.emit(name)`; + `net_play` 加 `signal whisper_requested(name)` + `var _lcontrol_down` + `set_lcontrol_down(down)`, + `game_scene._unhandled_input` `KEY_CTRL` 按下 / 松开喂 `set_lcontrol_down(e.pressed)`, + `net_play.whisper_requested` → `chat.start_whisper`(poc 版 `OpenWhisperDialog`=聊天输入 + 预填 `"/w 名 "`);`is_building` 实参恒 false(:814 分支 poc 永不触发)、无独立 whisper dialog + 窗口 = seam ⑫ 末尾;`netplay_test.gd` 加「8c) seam ⑫」段(无新增测试文件,PASS=69)。 + 行为变化:此前对任意有名字 VID(含 NPC)都显 + 目标框;现 PC / building 目标不显血条框(只在框上是别的 VID 时清旧框)、NPC / warp 关框、 + 目标已死透不动框、`hp%` 钳到 0..100。 + `game.py GameWindow.OnRender()` 的「Picking」块(:1519)分派已由增量 116 的 `pick_show.gd` + (纯静态第 27 个 canonical)补齐:`textTail.UpdateAllTextTail()`(:1531)→ + `wndMgr.IsPickedWindow(self.hWnd)` 门(:1533)内 `chr.Pick()`(:1535)命中角色(!= -1)→ + `textTail.ShowCharacterTextTail(idx)`(:1537)、`targetBoard.GetTargetVID()` != 0 → + `ShowCharacterTextTail(targetVID)`(:1539)、`not __IsShowName()` → `item.Pick()` → + `ShowItemTextTail`(:1543/:1546);门外 `__IsShowName()` → `ShowAllTextTail()` + + `PickingItemIndex = textTail.Pick(x, y)`(:1552/:1554);末尾 `UpdateShowingTextTail` + + `ArrangeTextTail`(:1557/:1558)、`PickingItemIndex != -1` → `SelectItemName`(:1560)全部 1:1。 + `pick_show.gd` API:`NO_PICK := -1`、`char_pick_active(is_picked_window)`、`forced_name_vids( + is_picked_window, pick_char_vid, target_vid)`(收 `chr.Pick()` 命中 + 目标框 VID,去重、顺序 + `[hover, target]`、非窗口 → `[]`)、`should_pick_item(is_picked_window, is_show_name)`= `and not`、 + `should_show_all_names` / `should_pick_text_tail`= `is_show_name`、`should_select_item_name`= + `!= NO_PICK`。poc 侧仿增量 113 的 `_target_vid` forced 路数给 `net_world._name_shown()` 加第 5 路 + forced `_hover_vid`:`net_world.set_hover_vid(vid)`(值变才刷 prev / vid 两节点名字可见性,仿 + `set_target_vid`)由 `player_controller._update_hover_cursor()` 三出口喂 —— 鼠标压 UI → 0、 + 悬停地面物品 → 0、射线命中 `pickable` → `best.get_meta("vid")`、无命中 → 0;新增 + `hover_entity_changed(vid)` 信号(值变去抖),`game_scene` 紧挨 `net_play.target_changed → + net_world.set_target_vid` 那行连 `pc.hover_entity_changed → net_world.set_hover_vid`。 + 自检 `project/pick_show_test.gd`(第 27 个 canonical,~24 组断言)+ 全 67 个离线回归通过。 + **seam ⑩**:`ArrangeTextTail` 屏幕空间尾标去重叠排布仍缺(poc 名字牌是 3D billboard,靠深度 + 天然分层,未做 2D 投影后的挤开)—— 本增量只收口「悬停角色强显名字」一小块。近似:参考里 + `chr.Pick()` 强显与 `targetBoard` 强显都在 :1533 的 `IsPickedWindow` 门内,poc 把 `_target_vid` + / `_hover_vid` 都当无条件 forced,`_hover_vid` 靠 `player_controller` 压 UI / 悬停物品时置 0 + 部分等效该门。行为变化:此前只有 `targetBoard` 目标(增量 113)forced 强显头顶名字;现悬停到 + 的角色(`pickables` 带 `vid` meta)也 forced 强显 —— 即便未开「总显名字」且超 3500 平面距。 + 悬停命中沿用既有 `_ray_pick_t`,无新增射线开销。 + `CPythonTextTail::UpdateShowingTextTail`(`PythonTextTail.cpp:88`)里「名字尾标随聊天气泡上移 + 17px」已由增量 117 的 `name_tail_layout.gd`(纯静态第 28 个 canonical)收口:角色名字尾标投影完 + 屏幕坐标后(:104),`m_ChatTailMap.find(dwVirtualID)` 命中(:106/:110)且那条 `bNameFlag` 为 + TRUE(:112,= `RegisterChatTail` 的「聊天」气泡,非 `RegisterInfoTail` 的「信息」气泡)→ + `pTextTail->y = pChatTail->y - 17.0f`(:114)。`name_follows_bubble(has_chat_tail, chat_name_flag)` + = `and`、`name_tail_screen_y(default_y, bubble_y, follows)`= 参考端字面 `bubble_y − 17`、 + `name_tail_world_y(default_world_y, bubble_world_y, follows, px_size)`= poc 3D 适配(屏幕 y + 向下 −17px → 世界 y 向上 `bubble_world_y + 17*px_size`)。poc 侧 `net_world._reposition_name_tag(n)` + 由 `_apply_chat_tail` / `_expire_chat_tails` 调用,读 `_chat_tails[vid].name_flag` 决定名字 + `Label3D` 局部 y 在 `NAME_TAG_DEFAULT_Y`(2.1)与「`BUBBLE_TAG_Y` 2.35 + 17*pixel_size」之间 + 切换。自检 `project/name_tail_layout_test.gd`(第 28 个 canonical)+ `chat_test.gd` 扩断言 + + 全 68 个离线回归通过。 + **增量 119 收口 seam ⑦(子标签随名字尾标纵向位移)**:`CPythonTextTail::ArrangeTextTail` + (`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把公会名(:228,`pTextTail->y - + c_fyGuildNamePosition`,`c_fyGuildNamePosition = 15.0f` :21)/ 称号(:258,同 `pTextTail->y`)/ + 等级(:283,同 y)三个子标签屏幕 y 全部锚在 `pTextTail->y` 上 —— 而这个 y 已被 + `UpdateShowingTextTail :114` 顶过 −17。`name_tail_layout.gd` 增 `GUILD_NAME_ABOVE_NAME_PX + := 15.0`、`name_tail_lift_delta(default_world_y, bubble_world_y, follows, px_size)`(= + `name_tail_world_y(...) - default_world_y`)、`subtag_world_y(default_subtag_y, + name_tail_lift_delta)`(=相加)。`net_world` 把 `GuildTag` / `TitleTag` / `LevelTag` 默认 + 局部 y 提为 `SUBTAG_DEFAULT_Y`(2.34 / 1.86 / 1.74)常量,`_reposition_name_tag` 顶完名字 + 尾标后按同一 `lift` 平移三个子标签,气泡过期 / 「信息」尾标时回默认。自检 + `name_tail_layout_test.gd`(+「4. seam ⑦ 收口」段)+ `chat_test.gd`(+真 `NetWorld` 断言: + 冒气泡后三子标签 `position.y` == 默认 + lift、覆盖成信息尾标后回默认)+ 全 68 个离线回归通过。 + **seam ⑦ 剩余**:~~子标签竖排 vs 参考屏幕空间逐像素水平排版(公会名上方 15px、称号 / 等级 + 同水平行左偏)~~ 逐像素水平排版已由增量 121 收口(见下)、~~文本宽按字符数估 vs + `GetTextSize`~~ 已由增量 123 收口(见下)、~~单行文本高仍 15px 估~~ 已由增量 124 换 + `GetTextSize` 的 h 收口(见下),剩余:poc 无公会徽记 mark 实体、三子标签仍竖排叠 + x 偏移 vs 参考同一屏幕行。 + **seam ⑨ 剩余**:气泡与名字仍是两个并存 `Label3D` 节点(非参考单尾标链表)。 + **增量 120 收口 seam ⑩(`ArrangeTextTail` item 尾标去重叠 + `isIn`)**:`PythonTextTail.cpp:153` + 的 `m_ItemTextTailList` 块(:160-190)—— 逐条掉落名条 `pInsertTextTail` 与其它每条 + `pCompareTextTail` 比 `isIn`(:489-507,`[x+xStart,x+xEnd]×[y+yStart,y+yEnd]` 的 AABB, + `<=`/`>=` 含边界),命中就把「自己」顶到 `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp` + (`yTemp = 5`),`itorCompare` 回 `begin()` 从头重跑、`LimitCount >= 20` 封顶。新增第 29 个 + canonical `project/text_tail_arrange.gd`(`extends RefCounted` 全静态):`PUSH_GAP := 5.0` / + `MAX_ITERS := 20`、`box_start_x(w)`=`-w/2-2` / `box_end_x(w)`=`w/2+2` / `box_start_y()`=`-2` / + `box_end_y(h)`=`h`(`PythonTextTail.cpp:810-813`)、`boxes_overlap(a, b)`(=`isIn`, + `a`/`b`=`{x,y,w,h}`)、`arrange_item_tails(tails: Array) -> Array`(外层逐条 i、内层逐条 j + 跳过自身、`boxes_overlap` 命中则 `ys[i] = ys[j] + box_end_y(h_j) + PUSH_GAP`、j 归零 limit++、 + `limit >= MAX_ITERS` break;`pCompare` 用「已排布后」的 `ys[j]`,与参考端共用一张链表就地改写 + 一致)。poc 侧 `project/ui/ground_items.gd` 加 `_camera_getter` + 常量 `TAG_BASE_Y 0.75` / + `TAG_CHAR_W_PX 7` / `TAG_LINE_H_PX 15`,`setup()` 尾部加可选 camera-getter 参数,`_process` + 尾部 `_arrange_tags()`:按 vid 排序遍历掉落 → `cam.unproject_position` 各名条世界锚点 + (背面 / 无相机跳过并回默认)→ 组 `{x, y, w = max(1, len*7), h = 15}` → `arrange_item_tails` → + `position.y = TAG_BASE_Y - (arranged_y - y0) * pixel_size`,可见名条 < 2 直接回默认; + `project/game_scene.gd` 把 `func() -> Camera3D: return cam` 传进 `ground_items.setup` 第 6 参。 + 自检 `project/text_tail_arrange_test.gd`(第 29 个 canonical:`box_*` 四边、`boxes_overlap` + x/y 都叠 / 边界相接含边界 / x·y 分离、`arrange_item_tails` 单条 / 不叠 / 两条同位 → [121, 100] / + 三条同位 → [121, 142, 100] 且两两不叠 / 40 条全同位不死循环)+ `p2b_test.gd`(+真 `Camera3D` + + 真 `GroundItems`:同位两掉落名条投影后靠前一条按 `(15+5)*0.005 = 0.1` 下推、靠后留默认、 + 拉开距离回默认)+ 全 69 个离线回归通过(PASS=69 FAIL=0)。**seam ⑩ 剩余**:~~名条框按 + 「字符数 * 7px」估宽 vs 参考 `GetTextSize` 精确像素~~ 已由增量 123 换真实字体度量 + `GetTextSize` 1:1(见下);~~单行文本高仍 15px 估~~ 已由增量 124 换 `GetTextSize` 的 h + 1:1(见下);只对掉落 item 名条做(与参考 + `m_ItemTextTailList` / `m_CharacterTextTailList` 分表一致,角色名字尾标不参与)。 + **增量 121 深化 seam ⑦(子标签逐像素水平排版)**:`CPythonTextTail::ArrangeTextTail` 角色块 + (`PythonTextTail.cpp:211-320` 非 EUROPE 分支)—— `fxAdd` = 0(无称号无等级,:236)/ 8 + (有称号,:242)/ 4(仅等级无称号,:288);名字文本 CENTER 对齐画在 `x + fxAdd`(:320)、 + 称号 CENTER 对齐画在 `x − iNameWidth/2 − fxAdd`(:258)、等级 RIGHT 对齐画在有称号 + `x − iNameWidth/2 − fxAdd − iTitleWidth`(:282)/ 仅等级 `x − iNameWidth/2 − fxAdd`(:312); + 公会名 / 徽记仅当 `pMarkInstance && pGuildNameInstance` 才由 ArrangeTextTail 重定位(:220), + `iImageHalfSize = markW/2 + c_fxMarkPosition(1.5)`(:224),公会名 `x + iImageHalfSize`(:228)、 + 徽记 `x − guildW/2 − iImageHalfSize`(:227)。`name_tail_layout.gd` 增 `NAME_X_ADD_TITLE := 8.0` + / `NAME_X_ADD_LEVEL_ONLY := 4.0` / `MARK_X_PAD := 1.5` / `MARK_ABOVE_NAME_PX := 26.0` + 6 静态 + 方法 `name_x_shift(has_title, has_level)`(fxAdd 8/4/0)/ `title_x_offset(name_w)`(`−name_w/2−8`) + / `level_x_offset(name_w, title_w, has_title)`(有称号再减 `title_w`)/ + `guild_name_x_offset(mark_w)`(`mark_w/2+1.5`)/ `mark_x_offset(guild_w, mark_w)` / + `subtag_world_x(px_offset, px_size)`(`px_offset * px_size` 线性、符号保留左负右正)。 + `net_world` 加 `const SUBTAG_CHAR_W_PX := 7.0`(无字体度量 → 文本宽按「字符数 * 约 7px」估, + 与 `ground_items` 的 `TAG_CHAR_W_PX` 同源)、`_apply_text_tail_to` 尾部调 `_layout_subtags_x(n, + ttag, ltag)`:由 `.text != ""` 判 `has_title` / `has_level`,设名字 `Label3D` / + `TitleTag` / `LevelTag` 的 `.position.x`(无则回 0),`GuildTag.position.x` 恒 0(poc 无公会 + 徽记实体、参考的 `pMarkInstance && pGuildNameInstance` 门恒不满足)。纵向位移(增量 117/119 + 写 `.position.y`)与横向排布(增量 121 写 `.position.x`)互不干扰。自检 + `name_tail_layout_test.gd`(+「5. seam ⑦ 水平排版」段)+ `chat_test.gd`(+真 `NetWorld`: + Charlie vid 3000 有公会 / 称号 / 等级 → 三子标签 x 偏移方向与精确值断言、去称号后名字回移 4)+ + 全 69 个离线回归通过(PASS=69 FAIL=0,无新增测试文件)。**seam ⑦ 剩余**:poc 无公会徽记 mark + 实体(`GuildTag` x 恒 0)、~~文本宽按字符数估 vs 参考 `GetTextSize` 精确像素~~ 已由增量 123 + 换真实字体度量 `GetTextSize` 1:1(见下)、三子标签仍竖排叠 x 偏移 vs 参考同一屏幕行 + (poc 可读性折衷)、~~单行文本高仍 15px 估~~ 已由增量 124 换 `GetTextSize` 的 h 收口(见下)。 + **增量 123 收口 seam ⑦ / ⑩(文本宽换真实字体度量 `GetTextSize`)**:`CPythonTextTail::ArrangeTextTail` + 里 `iNameWidth` / `iTitleWidth` / `iLevelWidth` / `guildW`(`PythonTextTail.cpp:225` / `:233` / + `:240` / `:267` / `:297`)与 item 尾标框 `xSize` / `ySize`(`:733` / `:748` / `:809`)全部取自 + `pTextInstance->GetTextSize(&w, &h)` —— 字体的实际逐字形排版度量(含字距、非等宽),不是 + 「字符数 * 定宽」。新增第 30 个 canonical `project/text_metrics.gd`(`extends RefCounted` 全静态): + `text_size(font, text, font_size)`(空 / null / `font_size<=0` → `Vector2.ZERO`,否则 + `Font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)` = `GetTextSize` 1:1)、 + `width_of(font, text, font_size, fallback_char_px := 7.0)`(有字体量得正宽 → 精确像素,否则 → + `text.length() * fallback_char_px`)、`label_width(label: Label3D, fallback_char_px := 7.0)` + (从节点自身取 `font ?? ThemeDB.fallback_font` 和 `font_size`)。`net_world._layout_subtags_x` + 的 `name_w` / `title_w` 改走 `TextMetrics.label_width(label, SUBTAG_CHAR_W_PX)`(原 + `字符数 * SUBTAG_CHAR_W_PX`)、`ground_items._arrange_tags` 的名条框 `w` 改走 + `TextMetrics.label_width(tag, TAG_CHAR_W_PX)`(原 `text.length() * TAG_CHAR_W_PX`); + `SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX`(均 7.0)保留仅作无字体(headless / 未配主题)兜底; + `name_tail_layout.gd` / `text_tail_arrange.gd` 收 `name_w` / `title_w` / 框 `w` 作入参,排布公式 + (增量 117 / 119 / 120 / 121)不动。自检 `project/text_metrics_test.gd`(第 30 个 canonical:常量、 + 退化输入 → ZERO、真实度量随串长 / 字号单调、`width_of` 有字体 == `text_size.x`、无字体回退 + `字符数 * px`、`label_width` 回落 `ThemeDB.fallback_font` / null → 0、`Label3D` 实测)+ 全 70 个 + 离线回归通过(PASS=70 FAIL=0,新增 `text_metrics_test`)。**seam ⑦ / ⑩ 剩余**:~~单行文本高仍按 + 15px 估(未走 `GetTextSize` 的 h)~~ 已由增量 124 收口(见下)、poc 无公会徽记 mark 实体、 + 三子标签竖排叠 x 偏移 vs 参考同一屏幕行、掉落名条为唯一 item 尾标来源。 + **增量 124 末尾 seam ⑦ / ⑩(单行文本高换真实字体度量 `GetTextSize` 的 h)**:`ArrangeTextTail` + 的 item 尾标去重叠把「自己」顶到 `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp` + (`PythonTextTail.cpp:182`),而 `yEnd = ySize`(`:751` / `:812`),`ySize` 与增量 123 换过的 + `xSize` 同出 `pTextInstance->GetTextSize(&xSize, &ySize)`(`:749` / `:809`)—— 单行文本的 `ySize` + ≈ 字体行高(ascent + descent),与字形无关,`Font.get_string_size(...).y` 是 1:1 对应物。 + `text_metrics.gd`(仍第 30 个 canonical,扩非新增)加 `const FALLBACK_LINE_H_PX := 15.0` + (=旧 `ground_items.TAG_LINE_H_PX`)、`height_of(font, text, font_size, fallback_line_px := + 15.0)`(空串 → 0;量得正高 → `text_size(...).y`;否则 → 回退)、`label_height(label: Label3D, + fallback_line_px := 15.0)`(`label.font ?? ThemeDB.fallback_font` + `label.font_size`,null → 0)。 + `ground_items._arrange_tags` 的名条框 `h` 由硬编码 `TAG_LINE_H_PX` 改走 + `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`;`TAG_LINE_H_PX`(15.0)保留仅作无字体 + 兜底。`text_tail_arrange.gd`(`box_end_y(h)` = `h`,对齐参考 `yEnd = ySize`)/ `name_tail_layout.gd` + 收框高作入参、排布公式不动。自检 `text_metrics_test.gd`(+「5. height_of / label_height」段: + `FALLBACK_LINE_H_PX = 15`、空串 → 0、无字体 / `font_size<=0` → 回退、有字体高为正且与串内容无关、 + 随字号单调、`height_of == text_size(...).y`、`label_height(null) → 0` / 空 `Label3D` → 0)+ 全 70 个 + 离线回归通过(PASS=70 FAIL=0,无新增测试文件)。**seam ⑦ 剩余**:poc 无公会徽记 mark 实体、 + 三子标签竖排叠 x 偏移 vs 参考同一屏幕行。**seam ⑩ 剩余**:~~掉落名条 owner 未拆成独立 text + instance(参考端 `yEnd += yOwnerSize + 4`,`:733-736`,poc 仍按单行 `"name (owner)"` 处理)~~ + 增量 125 已把去重叠框度量按 `SetItemTextTailOwner` 换 1:1(见下)、掉落名条为唯一 item 尾标来源。 + **增量 125 末尾 seam ⑩(owner 后缀框度量:owner 独立度量 + `yEnd += yOwnerSize + 4`)**:参考 + `CPythonTextTail::SetItemTextTailOwner(dwVID, c_szName)`(`PythonTextTail.cpp:706-737`)—— 带 + owner 的掉落名条,owner 后缀挂成一个与名字分开的 `CGraphicTextInstance`(`SetTextPointer(ms_pFont)`, + 文本 = `c_szName + strOwnership`,`strOwnership` 在 `IDS_POSSESSIVE_MORPHENE == ""` 时取字面 + `"'s"`(`:722`);`SetColor(1.0f, 1.0f, 0.0f)` 黄、`HORIZONTAL_ALIGN_CENTER`),单独 + `GetTextSize(&xOwnerSize, &yOwnerSize)`(`:733`),据此把去重叠框扩张:`yStart = -2.0f`(`:734`)、 + `yEnd += float(yOwnerSize + 4)`(`:735`)、`xStart = fMIN(xStart, float(-xOwnerSize/2 - 1))` + (`:736`)、`xEnd = fMAX(xEnd, float(xOwnerSize/2 + 1))`(`:737`);owner 为空则 + `Delete(pOwnerTextInstance)` 并把框复位纯名字 `xStart=-xSize/2-2` / `xEnd=xSize/2+2` / `yEnd=ySize` + (`:739-751`)。代入 poc 的 `{w, h}` 框(`box_start_x(w) = -w/2-2` / `box_end_x(w) = w/2+2` / + `box_end_y(h) = h`):`h = name_h + owner_h + 4`;`box_end_x(w)` 要等于参考 `:737` 的 + `fMAX(nameW/2+2, ownerW/2+1)` → `w = maxf(name_w, owner_w - 2.0)`(框 x 两边对称,故 `box_start_x` + 自动 = 参考 `:736` 的 `fMIN`);`owner_w<=0` 或 `owner_h<=0`(无 owner)→ `{name_w, name_h}`, + 与参考 else 分支一致。`text_tail_arrange.gd`(扩第 29 个 canonical)加纯静态 + `item_box_with_owner(name_w, name_h, owner_w := 0.0, owner_h := 0.0) -> Dictionary`。 + `text_metrics.gd`(扩第 30 个 canonical)加 `string_width_like(label: Label3D, text, + fallback_char_px := 7.0)` / `string_height_like(label: Label3D, text, fallback_line_px := 15.0)` + —— 用某 `Label3D` 的 `font ?? ThemeDB.fallback_font` + `font_size` 量任意子串(`label` 为 null → + 宽 `text.length() * fallback_char_px` / 高 空串 0 否则 `fallback_line_px`;空串宽恒 0),对应 + 参考端「名字 instance」「owner instance」各自 `GetTextSize`。`ground_items._on_added` 新建 / 复用 + drop 节点时都 `set_meta("item_name", _name_for(vnum))` + `set_meta("item_owner", owner)`; + `_arrange_tags` 的去重叠框改为 `iname` = meta `item_name`、`owner_txt` = `item_owner + "'s"`(空则 + `""`,`const OWNER_POSSESSIVE := "'s"`),`TextTailArrange.item_box_with_owner(string_width_like(tag, + iname), string_height_like(tag, iname), string_width_like(tag, owner_txt), string_height_like(tag, + owner_txt))` 合成 `{w, h}`;渲染仍是单个合并 `"name (owner)"` billboard `Label3D`(seam ⑩ 记录 + 在案的折中)。自检 `text_tail_arrange_test.gd`(+「5. item_box_with_owner」段:无 owner / + `owner_w<=0` / `owner_h<=0` → 退化、有 owner → `h += ownerH + 4`、owner 更宽 → `w = ownerW-2` 使 + `box_end_x == ownerW/2+1`、顶到带 owner 框底下 → push = `(name_h+owner_h+4) + PUSH_GAP`)+ + `text_metrics_test.gd`(+「6. `string_*_like`」段:`label` null → 回退 / 空串 → 0 / 有字体量的是 + 给定子串而非 `label.text`)+ `p2b_test.gd`(`FakeClient.add_ground` 加可选 owner 入参 + 新增 + `mount3/fc3/gi3` 段:301 无 owner 靠前、302 带 owner `"Alice"` 靠后同位 → 无字体度量下 302 框 + `yEnd = 15+15+4 = 34`、靠前 301 被顶 `(34+5)*0.005 = 0.195`)+ 全 70 个离线回归通过(PASS=70 + FAIL=0,无新增测试文件)。**seam ⑩ 剩余**:owner 未真正独立渲染成 CENTER 黄字(poc 仍单条 + `"name (owner)"`)、`strOwnership` 只取了字面 `"'s"`(未接 `ApplicationStringTable` + `IDS_POSSESSIVE_MORPHENE` 的本地化语素)、掉落名条为唯一 item 尾标来源。 + `root/uitarget.py` `TargetBoard.Open(vid, name)`(:189-193)在整个 `RecvTargetPacket` 判定之前的 + 异阵营前置过滤(`if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): if not + player.IsSameEmpire(vid): self.Hide(); return`,`constinfo.VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD + = 1` 默认)已由增量 118 补齐:`target_board.gd` 加纯静态 `hidden_as_other_empire(view_other_empire, + same_empire)`= `not view_other_empire and not same_empire`(不按 `is_pc` 收窄 —— empire 0 的 + 怪 / NPC / 石头在 `EntityRules._is_same_empire` 里恒「同阵营」故永不被挡);`net_play` 加 + `_view_other_empire_target`(默认 true)+ `set_view_other_empire_target(on)`,`_on_target_info` + 在 `TargetBoard.classify(...)` 之前 `if exists and TargetBoard.hidden_as_other_empire( + _view_other_empire_target, EntityRules._is_same_empire(_main_entity(), e)): _clear_target(); + return`。同批把 §8.4「名字色」radio(`name_color_normal` / `name_color_empire`)接进 + `game_scene._on_display_option` → `net_world.set_empire_name_mode(v == 1)`(算法增量 110 已 1:1)、 + 「看他国玩家目标框」radio → `net_play.set_view_other_empire_target(v == 1)`,`game_scene` 开局 + 两处 `game_option_ui.display_value(...) == 1` 回填;修 `game_option_ui.gd` 的 `target_board` + 默认值 0 → 1(对齐 `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1`)。§8.4 三个显示 radio + + §8.6 LALT 至此全部接线完毕。自检 `target_board_test.gd`(+4 断言)/ `netplay_test.gd`(+8b 段)/ + `system_menu_ui_test.gd`(+ name_color / target_board radio → `_display` + cfg + 信号)+ 全 68 个 + 离线回归通过。**seam ⑫(延续)**:poc 无 building / wooden-door 实体类型(`is_building` 恒 false)、~~`LCONTROL` 悄悄话捷径未接~~(增量 122 已接 whisper)。 **仍缺**:PK 模式上行(`/pkmode` 增量 38 已有)、保护区 `IsInSafe` 的 - `ATTRIBUTE_BANPK` 地形属性、原版目标板 / 决斗 UI;seam:`__FindPVPKey`(`GC_PVP`)/ `__FindGVGKey`(公会战对局)/ `IsGameMaster` / + `ATTRIBUTE_BANPK` 地形属性、原版目标板视觉 / 决斗 UI;seam:`__FindPVPKey`(`GC_PVP`)/ `__FindGVGKey`(公会战对局)/ `IsGameMaster` / `IsBuilding`·`IsWall` race 白名单 / m2dev 后端 `IsKiller` 状态位的 packet setter 均未接。 ### 6. P1/P2:模型、动画和特效 @@ -2316,11 +2937,13 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 [`help_ui.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/help_ui.gd)。 4. **系统设置部分只是保存数值。** CPU/GPU tiling 不会改变实际渲染,BGM 更换按钮 disabled, - 伤害数字和部分画质设置没有完整接入渲染侧;名字颜色本身已 1:1(增量 110)、头顶称号色 / 公会名 / + 部分画质设置没有完整接入渲染侧;名字颜色本身已 1:1(增量 110)、头顶称号色 / 公会名 / 等级已 1:1(增量 111)、头顶聊天 / 信息气泡已 1:1(增量 112)、头顶名字「是否显示」/ - 「总是显示名字」/ LALT 名字显示已 1:1(增量 113,`name_show.gd` + `net_world._name_shown()`),但 game_option 的 + 「总是显示名字」/ LALT 名字显示已 1:1(增量 113,`name_show.gd` + `net_world._name_shown()`)、 + 「伤害数字」radio 已接到渲染侧(增量 114,`damage_effect.gd` + `net_world.set_show_damage` ← + `game_option_ui.display_option_changed("show_damage")`,`GC_DAMAGE_INFO` / `AddDamageEffect` / + `ProcessDamage` 整合),但 game_option 的 「empire 名字颜色」开关尚未接到 `net_world.set_empire_name_mode(bool)`(默认关)、 - 「伤害数字」radio 尚未接到渲染侧(`GC_DAMAGE_INFO` 整合)、 `EnablePKTitle` 开关尚未接到 `net_world.set_pk_title_enabled(bool)`(默认开)、 `TextTail_SetLivingTime`(气泡停留秒数)尚无运行期钩子(默认 5000)。见 [`system_option_ui.gd:143`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/system_option_ui.gd:143)。 @@ -2340,7 +2963,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 候选确认、输入法切换、特殊文本框行为、复数/性别规则和自动字体切换。 8. **聊天和头顶信息不完整。** 缺 notice/big notice、经验和金币日志、私聊窗口生命周期、历史 - 滚动、频道过滤、InsultChecker、目标框、`GC_DAMAGE_INFO` 伤害数字整合和屏幕空间血条。 + 滚动、频道过滤、InsultChecker、屏幕空间血条。 ~~empire 名字颜色 / PK 颜色~~ 增量 110 已 1:1(`name_color.gd` `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 事件 + ~2Hz 兜底重染 `Label3D.modulate`,帝国名色默认关); ~~称号色 `GetTitleColor` / 公会名 / 头顶等级~~ 增量 111 已 1:1(`text_tail.gd` `RefreshTextTail` / @@ -2348,12 +2971,150 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 ~~头顶聊天 / 信息气泡~~ 增量 112 已 1:1(`chat_tail.gd` `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail` + `RecvChatPacket` 派发,`net_world` 每 vid 一条 `"bubble"` `Label3D` + 5s 硬删 + `"名字: "` 前缀剥离 + SHOUT·COMMAND 排除 + `register_info_tail()`; - seam ⑨:气泡与名字 `Label3D` 并存、名字不随气泡强制顶出 / 下移 17px)。 + seam ⑨:气泡与名字 `Label3D` 并存;~~名字随气泡下移 17px~~ 增量 117 收口、~~子标签随名字 + 尾标纵向位移~~ 增量 119 收口(`ArrangeTextTail` :211/:228/:258/:283 —— 三子标签屏幕 y 锚在 + `pTextTail->y`;`name_tail_lift_delta` / `subtag_world_y` + `net_world` `SUBTAG_DEFAULT_Y` + 常量 + `_reposition_name_tag` 按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`),见下; + ~~剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版~~ 逐像素水平排版增量 121 收口,见下)。 ~~总是显示名字 / LALT 名字显示 / 目标强显~~ 增量 113 已 1:1(`name_show.gd` `IsAlwaysShowName` / `SetAlwaysShowNameFlag` / `__IsShowName` / `ShowAllTextTail` 的 3500 平面距裁剪 / `ShowCharacterTextTail` 资格 / `UpdateAllTextTail` 的 chat `bNameFlag` 强显;`net_world._name_shown()` 四路 + `game_option_ui` - `display_option_changed` 信号 + `net_play.target_changed`;seam ⑩:悬停 `chr.Pick()` 强显 / - 屏幕空间 `ArrangeTextTail` 防重叠 / `CanPickInstance` 仅按死透近似未做)。 + `display_option_changed` 信号 + `net_play.target_changed`;seam ⑩:~~悬停 `chr.Pick()` 强显~~ + (增量 116 收口)/ ~~屏幕空间 `ArrangeTextTail` 防重叠~~(增量 120 收口)/ `CanPickInstance` + 仅按死透近似未做)。 + ~~`GC_DAMAGE_INFO` 伤害数字整合~~ 增量 114 已 1:1(`damage_effect.gd` `enum DamageFlag` / + `RecvDamageInfoPacket` 的 `bSelf` / `bTarget` / `damage >= 0` 门 / `AddDamageEffect` 的 + `IsShowDamage()` 门 / `ProcessDamage` 分类(`DODGE|BLOCK`→MISS、`CRITICAL`·`POISON` 参考端 + 注释掉走普通数字、`bSelf`→`"damage_"`、`!bTarget`→`"nontarget_"` 不渲染、否则 `"target_"`、 + `while(damage>0)` 逐位 `%10` / `/10` index 0..7)/ `SetShowDamageFlag`;`net_world._on_damage` + 照 `AddDamageEffect` 入 `_damage_queue`、`_process_damage_queue()` 每帧 pop 一条照 `ProcessDamage`、 + `game_option_ui`「伤害数字」radio 经 `display_option_changed` 接 `net_world.set_show_damage`; + seam ⑪:单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效 / 相机右向像素铺排、self + `SELFDAMAGE`⇄`SELFDAMAGE2` 退化为落字水平抖动符号交替、暴击不再放大 / 变色(对齐该 build)、 + `_damage_queue` 全局单条 vs 参考每实例一条)。 + ~~目标框(TargetBoard)收包分派~~ 增量 115 已 1:1(`target_board.gd` `Action { CLOSE, + CLOSE_IF_DIFFERENT, SET_HP, NONE }`;`RecvTargetPacket`(`PythonNetworkStreamPhaseGame.cpp:2424`) + 判定树 —— 实例缺失→`CloseTargetBoard`、目标已死→不动框、`IsPC()|IsBuilding()`→ + `CloseTargetBoardIfDifferent`、`CanViewTargetHP`(`InstanceBase.cpp:2293` = + `IsStone()|IsWoodenDoor()|IsEnemy()`)→`SetHPTargetBoard`、否则→`CloseTargetBoard`; + `game.py` `SetHPTargetBoard` 换 VID 先 `ResetTargetBoard` + `SetEnemyVID`;`net_play._on_target_info` + 由「任意有名字 VID 都显框」改为按判定树分派:PC / building 不显血条框(只在框上是别的 VID + 时清旧框)、NPC / warp 关框、石头 / 敌对怪 `SetHPTargetBoard`、`hp%` 钳 0..100; + seam ⑫:poc 无 building / wooden-door / stone-door 实体类型(`is_building` 恒 false、木门分类缺)、 + `target_info` 由 C++ EntityStore 侧抽出不改 C++ 收包、~~`LCONTROL` 悄悄话捷径未接~~ + 增量 122 已接(`net_play._on_pick` KIND_PC 分支轮询 `whisper_on_ctrl_click` → `whisper_requested` + → `game_scene` `KEY_CTRL` 喂 `_lcontrol_down` + `whisper_requested` → `chat.start_whisper`; + `is_building` 实参恒 false、无独立 whisper dialog 窗口 = seam ⑫ 末尾))。 + ~~`game.py OnRender` Picking 块分派 + 悬停角色强显名字~~ 增量 116 已 1:1(`pick_show.gd` + 纯静态第 27 个 canonical:`textTail.UpdateAllTextTail()`(:1531)→ `wndMgr.IsPickedWindow` 门 + (:1533)内 `chr.Pick()` 命中角色 → `ShowCharacterTextTail`(:1537)、`targetBoard.GetTargetVID()` + → `ShowCharacterTextTail`(:1539)、`not __IsShowName()` → `item.Pick()`(:1543);门外 + `__IsShowName()` → `ShowAllTextTail()` + `textTail.Pick(x, y)`(:1552/:1554);末尾 + `UpdateShowingTextTail` + `ArrangeTextTail`(:1557/:1558)、`PickingItemIndex != -1` → + `SelectItemName`(:1560);`forced_name_vids` 收 `[hover, target]` 去重。poc 侧仿增量 113 的 + `_target_vid` forced 路数给 `net_world._name_shown()` 加第 5 路 forced `_hover_vid`: + `net_world.set_hover_vid` 由 `player_controller._update_hover_cursor()` 三出口喂 + (压 UI / 悬停地面物品 → 0、射线命中 `pickable` → `vid` meta、无命中 → 0)+ + `hover_entity_changed` 信号(值变去抖)+ `game_scene` 连线;seam ⑩:`ArrangeTextTail` 屏幕空间 + 去重叠本增量未做(只收口「悬停强显名字」一小块),已由增量 120 收口,见下)。 + ~~名字尾标随聊天气泡上移 17px~~ 增量 117 已 1:1(`name_tail_layout.gd` 纯静态第 28 个 canonical: + `CPythonTextTail::UpdateShowingTextTail`(`PythonTextTail.cpp:88`)里角色名字尾标投影完屏幕坐标后 + (:104),`m_ChatTailMap.find(dwVirtualID)` 命中(:106/:110)且那条 `bNameFlag` 为 TRUE(:112, + = `RegisterChatTail` 的「聊天」气泡,非 `RegisterInfoTail` 的「信息」气泡)→ + `pTextTail->y = pChatTail->y - 17.0f`(:114)。`name_follows_bubble` / `name_tail_screen_y` + (参考端字面 `bubble_y − 17`)/ `name_tail_world_y`(poc 3D:屏幕 y 向下 −17px → 世界 y 向上 + `bubble_world_y + 17*px_size`);`net_world._reposition_name_tag(n)` 由 `_apply_chat_tail` / + `_expire_chat_tails` 调用,名字 `Label3D` 局部 y 在 `NAME_TAG_DEFAULT_Y`(2.1)与「`BUBBLE_TAG_Y` + 2.35 + 17*pixel_size」之间切换;seam ⑨ 收口 —— 剩余:气泡与名字仍是两个并存 `Label3D`)。 + ~~公会名 / 称号 / 等级子标签随名字尾标纵向位移~~ 增量 119 已 1:1(`name_tail_layout.gd`: + `CPythonTextTail::ArrangeTextTail`(`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把公会名 + (:228,`pTextTail->y - c_fyGuildNamePosition` 15px)/ 称号(:258,同 `pTextTail->y`)/ 等级 + (:283,同 y)三子标签屏幕 y 全锚在已被 `UpdateShowingTextTail :114` 顶过的 `pTextTail->y` 上; + 新增 `GUILD_NAME_ABOVE_NAME_PX := 15.0`、`name_tail_lift_delta`(= `name_tail_world_y(...) - + default_world_y`)、`subtag_world_y`(= `default_subtag_y + lift_delta`);`net_world` 把三 + 子标签默认局部 y 提为 `SUBTAG_DEFAULT_Y`(2.34 / 1.86 / 1.74)常量,`_reposition_name_tag` + 顶完名字尾标后按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`,气泡过期 / 「信息」 + 尾标时回默认;`name_tail_layout_test.gd` + `chat_test.gd` 扩断言 + 全 68 个离线回归通过; + ~~seam ⑦ 收口 —— 剩余:子标签竖排 vs 参考屏幕空间逐像素水平排版仍近似~~ 逐像素水平排版增量 121 收口,见下)。 + ~~掉落物名条屏幕空间去重叠 `ArrangeTextTail` item 块 + `isIn`~~ 增量 120 已 1:1 + (`text_tail_arrange.gd` 纯静态第 29 个 canonical:`CPythonTextTail::ArrangeTextTail` 的 + `m_ItemTextTailList` 块(`PythonTextTail.cpp:153-190`)逐条 `pInsertTextTail`:`yTemp = 5` / + `LimitCount` 上限 20,内层遍历全表跳过自己,`isIn(pInsert, pCompare)`(:489-507 标准 AABB, + 框 `xStart=-xSize/2-2` / `yStart=-2` / `xEnd=xSize/2+2` / `yEnd=ySize`,含边界 `<=`/`>=`)命中则 + `pInsert->y = pCompare->y + pCompare->yEnd + yTemp` 并把内层游标重置回表头、`++LimitCount`; + `arrange_item_tails(tails)` 在共享 y 表上原地推、`boxes_overlap` = `isIn`;`ground_items.gd` + `_arrange_tags()` 把每条掉落名条世界锚点经 `unproject_position` 投到屏幕、按字符数 `*7px` 估框、 + 跑 `arrange_item_tails` 得下推屏幕 Δy 再按 `Label3D.pixel_size` 折回世界局部 y,无相机 / 少于 + 2 条回默认;`game_scene` 给 `ground_items.setup` 传 `camera_getter`;`text_tail_arrange_test.gd` + + `p2b_test.gd` 扩断言 + 全 69 个离线回归通过;seam ⑩ 收口 —— ~~剩余:名条框按字符数估宽 vs + 参考 `GetTextSize` 精确像素~~ 名条框宽 / 单行文本高已分别由增量 123 / 124 换真实字体度量 + `GetTextSize` 的 w / h 1:1(见下),~~剩余:掉落名条 owner 未拆成独立 text instance + (`yEnd += yOwnerSize + 4`)~~ 带 owner 的掉落名条去重叠框已由增量 125 按 + `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`:owner 独立 + `GetTextSize` → `yEnd += float(yOwnerSize + 4)` / `xStart = fMIN(xStart, -xOwnerSize/2-1)` / + `xEnd = fMAX(xEnd, xOwnerSize/2+1)`)换 1:1(`text_tail_arrange.item_box_with_owner` + + `text_metrics.string_width_like` / `string_height_like` 把「名字」「owner + 所有格」两子串 + 分开度量再合成框),剩余:owner 未独立渲染成 CENTER 黄字 text instance、`strOwnership` + 未接 `ApplicationStringTable` 的 `IDS_POSSESSIVE_MORPHENE`、只对掉落 item 名条不含角色名条)。 + ~~公会名 / 称号 / 等级子标签逐像素水平排版 `ArrangeTextTail` 角色块~~ 增量 121 已 1:1 + (`name_tail_layout.gd`:`CPythonTextTail::ArrangeTextTail`(`PythonTextTail.cpp:211-320` 非 + EUROPE 分支)—— `fxAdd` = 0(无称号无等级 :236)/ 8(有称号 :242)/ 4(仅等级 :288),名字 + CENTER `x + fxAdd`(:320)、称号 CENTER `x − iNameWidth/2 − fxAdd`(:258)、等级 RIGHT 有称号 + `x − iNameWidth/2 − fxAdd − iTitleWidth`(:282)/ 仅等级 `x − iNameWidth/2 − fxAdd`(:312), + 公会名 / 徽记仅 `pMarkInstance && pGuildNameInstance` 才重定位(:220,`iImageHalfSize = markW/2 + + 1.5` :224,公会名 `x + iImageHalfSize` :228 / 徽记 `x − guildW/2 − iImageHalfSize` :227); + 新增 `NAME_X_ADD_TITLE 8.0` / `NAME_X_ADD_LEVEL_ONLY 4.0` / `MARK_X_PAD 1.5` / + `MARK_ABOVE_NAME_PX 26.0` + `name_x_shift` / `title_x_offset` / `level_x_offset` / + `guild_name_x_offset` / `mark_x_offset` / `subtag_world_x`(`px_offset * px_size` 线性、 + 符号保留左负右正);`net_world` 加 `const SUBTAG_CHAR_W_PX := 7.0`(无字体度量 → 文本宽按 + 「字符数 * 7px」估)、`_apply_text_tail_to` 尾部 `_layout_subtags_x(n, ttag, ltag)` 设名字 / + `TitleTag` / `LevelTag` 的 `.position.x`(无则回 0)、`GuildTag.position.x` 恒 0(poc 无公会 + 徽记实体);纵向(增量 117/119 写 `.position.y`)与横向(增量 121 写 `.position.x`)互不干扰; + `name_tail_layout_test.gd` +「5. seam ⑦ 水平排版」段 + `chat_test.gd` 扩真 `NetWorld` 断言 + + 全 69 个离线回归通过(无新增测试文件);seam ⑦ 收口 —— 剩余:poc 无公会徽记 mark 实体 + (`GuildTag` x 恒 0)、~~文本宽字符数估 vs `GetTextSize`~~ 已由增量 123 换真实字体度量 + `GetTextSize` 1:1(见下)、三子标签仍竖排叠 x 偏移 vs 参考同一屏幕行、~~单行文本高仍 15px 估~~ + 已由增量 124 换 `GetTextSize` 的 h 1:1(见下))。 + ~~名条框 / 子标签文本宽按「字符数 * 约 7px」估 vs 参考 `GetTextSize` 精确像素~~ 增量 123 已 1:1 + (`text_metrics.gd` 纯静态第 30 个 canonical:`text_size(font, text, font_size)` = `GetTextSize` + 1:1(`Font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`,空 / null / + `font_size<=0` → `Vector2.ZERO`)/ `width_of(...)`(有字体 → 精确像素、否则 → `字符数 * + fallback_char_px`)/ `label_width(label: Label3D, ...)`(`font ?? ThemeDB.fallback_font`); + `net_world._layout_subtags_x` 的 `name_w` / `title_w` 与 `ground_items._arrange_tags` 的名条框 + `w` 改走 `TextMetrics.label_width`,`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX`(均 7.0)保留仅作无字体 + 兜底,`name_tail_layout.gd` / `text_tail_arrange.gd` 排布公式不动;`text_metrics_test.gd` 第 30 个 + canonical + 全 70 个离线回归通过(PASS=70 FAIL=0);剩余:~~单行文本高仍 15px 估(未走 + `GetTextSize` 的 h)~~ 已由增量 124 收口(见下)、poc 无公会徽记 mark 实体、掉落名条为唯一 item 尾标来源)。 + ~~名条框 / 子标签单行文本高按硬编码 15px 估 vs 参考 `GetTextSize` 的 h~~ 增量 124 已 1:1 + (`text_metrics.gd` 第 30 个 canonical 加 `const FALLBACK_LINE_H_PX := 15.0` + + `height_of(font, text, font_size, fallback_line_px := 15.0)`(空串 → 0;量得正高 → + `text_size(...).y`;否则 → 回退)+ `label_height(label: Label3D, fallback_line_px := 15.0)` + (`label.font ?? ThemeDB.fallback_font` + `label.font_size`,null → 0);参考端 item 尾标框 + `yEnd = ySize`(`PythonTextTail.cpp:751` / `:812`),`ySize` 与增量 123 换过的 `xSize` 同出 + `pTextInstance->GetTextSize(&xSize, &ySize)`(`:749` / `:809`),单行文本的 `ySize` ≈ 字体行高 + (ascent + descent),与字形无关,`Font.get_string_size(...).y` 是 1:1 对应物; + `ground_items._arrange_tags` 的名条框 `h` 由硬编码 `TAG_LINE_H_PX` 改走 + `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`,`TAG_LINE_H_PX`(15.0)保留仅作 + 无字体兜底;`text_tail_arrange.gd`(`box_end_y(h)` = `h`,对齐参考 `yEnd = ySize`)/ + `name_tail_layout.gd` 收框高作入参、排布公式不动;`text_metrics_test.gd` 扩「5. height_of / + label_height」段 + 全 70 个离线回归通过(PASS=70 FAIL=0,无新增测试文件);剩余:掉落名条 owner + 未拆成独立 text instance(参考端 `yEnd += yOwnerSize + 4`,`:733-736`)、poc 无公会徽记 mark + 实体、掉落名条为唯一 item 尾标来源)。 + ~~目标框「看他国玩家」前置过滤 + §8.4 名字色 radio 接线~~ 增量 118 已 1:1(`root/uitarget.py` + `TargetBoard.Open(vid, name)`(:189-193)在 `RecvTargetPacket` 判定之前:`if not + constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): if not player.IsSameEmpire(vid): + self.Hide(); return`,`constinfo.VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1` 默认;`target_board.gd` + 加纯静态 `hidden_as_other_empire(view_other_empire, same_empire)`= `not view_other_empire and + not same_empire`(不按 `is_pc` 收窄 —— empire 0 的怪 / NPC / 石头恒同阵营故永不被挡);`net_play` + 加 `_view_other_empire_target`(默认 true)+ `set_view_other_empire_target(on)`,`_on_target_info` + 在 `classify` 前 `if exists and hidden_as_other_empire(...): _clear_target(); return`。同批把 §8.4 + 「名字色」radio(`name_color_normal` / `name_color_empire`)接进 `game_scene._on_display_option` → + `net_world.set_empire_name_mode(v == 1)`(算法增量 110 已 1:1)、「看他国玩家目标框」radio → + `net_play.set_view_other_empire_target(v == 1)`,`game_scene` 开局两处 `display_value(...) == 1` + 回填;修 `game_option_ui.gd` `target_board` 默认 0 → 1(对齐 `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD + = 1`)。§8.4 三个显示 radio + §8.6 LALT 至此全部接线完毕;seam ⑫(延续):poc 无 building / + wooden-door 实体类型(`is_building` 恒 false)、~~`LCONTROL` 悄悄话捷径未接~~(增量 122 已接: + `net_play._on_pick` KIND_PC 分支 → `whisper_requested` → `game_scene` → `chat.start_whisper`))。 9. **任务导航和完整资源视觉仍未完成。** 增量 101 已接入默认 / 服务端 icon、5 行滚动、倒计时和 QuestButton 事件参数;仍缺世界箭头、屏幕边缘指示、多列定位、真实 `.sub` 状态图和服务端 @@ -2418,7 +3179,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 连接骨架和换图清理已经开始实现,但尚未完成真实双连接、换图和完整生命周期验收。 它还不能定义为:**ClientVS22 的跨平台等价客户端**。 -当前已完成 G0 公共接口的首版冻结,正在按附录 C 的并行波 A 持续交付;增量 49–107 的完成项、 +当前已完成 G0 公共接口的首版冻结,正在按附录 C 的并行波 A 持续交付;增量 49–128 的完成项、 自动化证据和剩余项以本文顶部看板及 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)“实施进度 / 进度看板 / C.5 批次记录”为准。W3 已完成远端 PC 真模型工厂、任务日志五行 / 滚动 / 事件和 钓鱼输入 / 水面扫描 / 反馈消息首版、坐骑攻击等级门、观战者小地图插值和副本目的地罗盘首版;W4 已完成环境格式解析、静态云层和 texture-mode 云层组合及 EffectLib diff --git a/extension/src/metin2_world.cpp b/extension/src/metin2_world.cpp index 777834c2..54238d8f 100644 --- a/extension/src/metin2_world.cpp +++ b/extension/src/metin2_world.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include #include @@ -13,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +34,7 @@ #include #include #include +#include #include "dxt.h" #include "environment_builder.h" @@ -51,6 +55,149 @@ using namespace godot; namespace mtgodot { +namespace { + +// AttributeData stores rotations as D3DX quaternions in Metin2's Z-up +// coordinate system. Convert through the same axis change as m2coord positions. +Basis attribute_quaternion_basis(const std::array &q) { + double x = q[0], y = q[1], z = q[2], w = q[3]; + double length = std::sqrt(x * x + y * y + z * z + w * w); + if (length < 1e-8) + return Basis(); + x /= length; y /= length; z /= length; w /= length; + const double r[9] = { + 1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w), + 2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w), + 2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y), + }; + const fmt::m2coord::Mat3 m2{{r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8]}}; + const fmt::m2coord::Mat3 c{{1, 0, 0, 0, 0, 1, 0, -1, 0}}; + const fmt::m2coord::Mat3 ci{{1, 0, 0, 0, 0, -1, 0, 1, 0}}; + auto mul = [](const fmt::m2coord::Mat3 &a, const fmt::m2coord::Mat3 &b) { + fmt::m2coord::Mat3 result{}; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + for (int k = 0; k < 3; ++k) + result.m[i * 3 + j] += a.m[i * 3 + k] * b.m[k * 3 + j]; + return result; + }; + const fmt::m2coord::Mat3 godot = mul(mul(c, m2), ci); + return Basis(Vector3(godot.m[0], godot.m[3], godot.m[6]), + Vector3(godot.m[1], godot.m[4], godot.m[7]), + Vector3(godot.m[2], godot.m[5], godot.m[8])); +} + +Vector3 attribute_position(const std::array &p) { + const fmt::m2coord::Vec3 v = fmt::m2coord::position_to_godot(p[0], p[1], p[2]); + return Vector3((float)v.x, (float)v.y, (float)v.z); +} + +bool add_attribute_collision(const fmt::AttributeCollision &collision, + StaticBody3D *body) { + using Type = fmt::AttributeCollisionType; + const auto type = static_cast(collision.type); + Ref shape; + Transform3D local(attribute_quaternion_basis(collision.quaternion), + attribute_position(collision.position)); + const float cm = 0.01f; + const float thickness = 0.04f; // finite-plane approximation, 4 cm + + switch (type) { + case Type::Plane: { + Ref plane; + plane.instantiate(); + plane->set_size(Vector3(std::max(0.02f, std::abs(collision.dimensions[0]) * cm), + thickness, std::max(0.02f, std::abs(collision.dimensions[1]) * cm))); + shape = plane; + break; + } + case Type::Box: + case Type::Aabb: + case Type::Obb: { + Ref box; + box.instantiate(); + // CollisionData stores these as half-extents. Swap source y/z for Godot. + box->set_size(Vector3(std::max(0.02f, std::abs(collision.dimensions[0]) * 2.0f * cm), + std::max(0.02f, std::abs(collision.dimensions[2]) * 2.0f * cm), + std::max(0.02f, std::abs(collision.dimensions[1]) * 2.0f * cm))); + shape = box; + if (type == Type::Aabb) + local.basis = Basis(); + break; + } + case Type::Sphere: { + Ref sphere; + sphere.instantiate(); + sphere->set_radius(std::max(0.01f, std::abs(collision.dimensions[0]) * cm)); + shape = sphere; + local.basis = Basis(); + break; + } + case Type::Cylinder: { + Ref cylinder; + cylinder.instantiate(); + cylinder->set_radius(std::max(0.01f, std::abs(collision.dimensions[0]) * cm)); + cylinder->set_height(std::max(0.02f, std::abs(collision.dimensions[1]) * cm)); + shape = cylinder; + break; + } + default: + return false; + } + + CollisionShape3D *node = memnew(CollisionShape3D); + node->set_name(String(collision.name.c_str())); + node->set_shape(shape); + node->set_transform(local); + body->add_child(node); + return true; +} + +int add_attribute_data(const std::string &path, const Transform3D &place, + Node3D *root, MeshInstance3D *mesh) { + fmt::AttributeData data; + std::string error; + if (!fmt::parse_attribute_data_file(path, data, &error)) { + UtilityFunctions::push_warning(String("[mdatr] load failed: ") + path.c_str() + + " (" + error.c_str() + ")"); + return 0; + } + StaticBody3D *body = memnew(StaticBody3D); + body->set_name(String("AttributeBody_") + String(path.c_str()).get_file().get_basename()); + body->set_collision_layer(2); + body->set_collision_mask(0); + body->set_transform(place); + int shapes = 0; + for (const auto &collision : data.collisions) + if (add_attribute_collision(collision, body)) + ++shapes; + for (const auto &height : data.heights) { + if (height.vertices.size() < 3) + continue; + PackedVector3Array faces; + faces.resize((int)height.vertices.size()); + for (int i = 0; i < (int)height.vertices.size(); ++i) + faces[i] = attribute_position(height.vertices[(size_t)i]); + Ref concave; + concave.instantiate(); + concave->set_faces(faces); + CollisionShape3D *node = memnew(CollisionShape3D); + node->set_name(String(height.name.c_str())); + node->set_shape(concave); + body->add_child(node); + ++shapes; + } + if (shapes == 0) { + body->queue_free(); + return 0; + } + body->set_meta("occ_mesh", mesh); + root->add_child(body); + return shapes; +} + +} // namespace + Metin2World::Metin2World() {} Metin2World::~Metin2World() {} @@ -424,17 +571,23 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, pids.push_back(pid); mi->set_meta("portal_ids", pids); } - // .mdatr 静态碰撞:未实现。有同名 .mdatr 的计数上报(SHINSOO §9-W3 第 7 项) - { - String md = String(rp.c_str()).get_basename() + ".mdatr"; - if (FileAccess::file_exists(md)) - ++objects_mdatr_pending; - } root->add_child(mi); // §8.2/§8.3: 盒碰撞体(层 2 = 静态遮挡物)。相机用它做防穿 + 遮挡淡出。 // 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv), // 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。 + bool mdatr_built = false; if (collision_enabled) { + String md = String(rp.c_str()).get_basename() + ".mdatr"; + if (FileAccess::file_exists(md)) { + const int built = add_attribute_data(std::string(md.utf8().get_data()), place, root, mi); + mdatr_built = built > 0; + if (mdatr_built) + ++objects_mdatr_built; + } + } + // A malformed/missing attribute resource still gets a conservative + // GR2 AABB fallback. A valid .mdatr owns the exact collision set. + if (collision_enabled && !mdatr_built) { AABB ab = mesh->get_aabb(); // gr2 原始 cm / Z-up if (ab.size.length() > 0.001f) { Vector3 c_cm = ab.position + ab.size * 0.5f; // 中心 cm Z-up @@ -701,7 +854,7 @@ void Metin2World::unload_map() { chunks_built = chunks_failed = chunks_splatted = 0; objects_placed = objects_skipped = objects_missing_model = 0; trees_placed = tree_species = 0; - objects_mdatr_pending = 0; + objects_mdatr_built = 0; build_ms = 0; splat_ready = registry_ok = false; last_error = ""; @@ -844,7 +997,7 @@ Dictionary Metin2World::get_load_report() const { d["ambience_sources"] = (int)ambience_sources.size(); d["trees_placed"] = trees_placed; d["tree_species"] = tree_species; - d["objects_mdatr_pending"] = objects_mdatr_pending; // 有 .mdatr 但未建碰撞 + d["objects_mdatr_built"] = objects_mdatr_built; // 使用 .mdatr 精确碰撞的对象数 d["static_meshes"] = static_cache.loaded; d["build_ms"] = build_ms; d["resident_chunks"] = (int)chunks.size(); diff --git a/extension/src/metin2_world.h b/extension/src/metin2_world.h index ce632a0b..c8d6400e 100644 --- a/extension/src/metin2_world.h +++ b/extension/src/metin2_world.h @@ -15,6 +15,7 @@ #include #include +#include #include #include "static_object.h" @@ -142,7 +143,7 @@ private: int chunks_built = 0, chunks_failed = 0, chunks_splatted = 0; int objects_placed = 0, objects_skipped = 0, objects_missing_model = 0; int trees_placed = 0, tree_species = 0; - int objects_mdatr_pending = 0; + int objects_mdatr_built = 0; godot::Node3D *objects_root = nullptr; double build_ms = 0; diff --git a/formats/CMakeLists.txt b/formats/CMakeLists.txt index 0d1922c7..61e113e9 100644 --- a/formats/CMakeLists.txt +++ b/formats/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(xr_formats STATIC msm.cpp # 模型:base gr2 + 发型(RaceDataScript 子集) msa.cpp # 动作:anim gr2 + duration + accumulation + 事件 combo_table.cpp # §3.5: playersettingmodule.py 的 ComboAttackNew -> PC 连击段表 + attribute_data.cpp # .mdatr: 静态碰撞与高度三角网格 m2_tokvec.cpp # W0: LoadMultipleTextData 等价(Start/End 块 → token 向量表) m2_coord.cpp # W0: Metin2↔Godot 坐标 / 单位 / 区块编号(BACKLOG I5) @@ -45,6 +46,14 @@ if(BUILD_TESTING) ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}") endif() + add_executable(formats_attribute_data_test tests/attribute_data_test.cpp) + target_link_libraries(formats_attribute_data_test PRIVATE xrender::formats) + add_test(NAME formats.attribute_data COMMAND formats_attribute_data_test) + if(DEFINED ENV{M2_ASSETS}) + set_tests_properties(formats.attribute_data PROPERTIES + ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}") + endif() + add_executable(formats_map_test tests/map_formats_test.cpp) target_link_libraries(formats_map_test PRIVATE xrender::formats) add_test(NAME formats.map_formats COMMAND formats_map_test) diff --git a/formats/attribute_data.cpp b/formats/attribute_data.cpp new file mode 100644 index 00000000..332ca82c --- /dev/null +++ b/formats/attribute_data.cpp @@ -0,0 +1,165 @@ +#include "attribute_data.h" + +#include +#include +#include + +namespace fmt { + +namespace { + +constexpr char kHeader[] = "AttributeData"; +constexpr std::size_t kHeaderSize = sizeof(kHeader); // include trailing NUL +constexpr std::uint32_t kMaxRecords = 1u << 20; +constexpr std::uint32_t kMaxHeightVertices = 1u << 24; + +struct Reader { + const std::uint8_t *data = nullptr; + std::size_t size = 0; + std::size_t offset = 0; + std::string *error = nullptr; + + bool fail(const std::string &message) { + if (error) + *error = message + " at offset " + std::to_string(offset); + return false; + } + + bool require(std::size_t count) { + if (count > size || offset > size - count) + return fail("AttributeData truncated"); + return true; + } + + template + bool read(T &value) { + if (!require(sizeof(T))) + return false; + std::memcpy(&value, data + offset, sizeof(T)); + offset += sizeof(T); + return true; + } + + bool read_bytes(void *dst, std::size_t count) { + if (!require(count)) + return false; + std::memcpy(dst, data + offset, count); + offset += count; + return true; + } +}; + +std::size_t dimension_count(std::uint32_t type) { + switch (static_cast(type)) { + case AttributeCollisionType::Plane: + case AttributeCollisionType::Cylinder: + return 2; + case AttributeCollisionType::Box: + case AttributeCollisionType::Sphere: + case AttributeCollisionType::Aabb: + case AttributeCollisionType::Obb: + return type == static_cast(AttributeCollisionType::Sphere) ? 1 : 3; + default: + return 0; + } +} + +std::string fixed_name(const char (&raw)[32]) { + const void *nul = std::memchr(raw, '\0', 32); + const auto count = nul ? static_cast(nul) - raw : 32; + return std::string(raw, static_cast(count)); +} + +} // namespace + +bool parse_attribute_data(const std::uint8_t *data, std::size_t size, + AttributeData &out, std::string *err) { + out = AttributeData{}; + if (data == nullptr) + return err ? ((*err = "AttributeData buffer is null"), false) : false; + + Reader r{data, size, 0, err}; + char header[kHeaderSize] = {}; + if (!r.read_bytes(header, kHeaderSize)) + return false; + if (std::memcmp(header, kHeader, kHeaderSize) != 0) + return r.fail("AttributeData bad header"); + + std::uint32_t collision_count = 0; + std::uint32_t height_count = 0; + if (!r.read(collision_count) || !r.read(height_count)) + return false; + if (collision_count > kMaxRecords || height_count > kMaxRecords) + return r.fail("AttributeData record count is unreasonable"); + + out.collisions.reserve(collision_count); + for (std::uint32_t i = 0; i < collision_count; ++i) { + AttributeCollision collision; + if (!r.read(collision.type)) + return false; + const std::size_t dims = dimension_count(collision.type); + if (dims == 0) + return r.fail("AttributeData unknown collision type"); + + char raw_name[32] = {}; + if (!r.read_bytes(raw_name, sizeof(raw_name)) || + !r.read_bytes(collision.position.data(), sizeof(float) * 3) || + !r.read_bytes(collision.dimensions.data(), sizeof(float) * dims) || + !r.read_bytes(collision.quaternion.data(), sizeof(float) * 4)) + return false; + collision.name = fixed_name(raw_name); + out.collisions.push_back(std::move(collision)); + } + + out.heights.reserve(height_count); + for (std::uint32_t i = 0; i < height_count; ++i) { + AttributeHeight height; + char raw_name[32] = {}; + std::uint32_t vertex_count = 0; + if (!r.read_bytes(raw_name, sizeof(raw_name)) || !r.read(vertex_count)) + return false; + if (vertex_count > kMaxHeightVertices) + return r.fail("AttributeData height vertex count is unreasonable"); + if (vertex_count > 0 && vertex_count > (std::numeric_limits::max() / 12)) + return r.fail("AttributeData height size overflows"); + if (!r.require(static_cast(vertex_count) * 12)) + return false; + height.name = fixed_name(raw_name); + height.vertices.resize(vertex_count); + for (auto &vertex : height.vertices) { + if (!r.read_bytes(vertex.data(), sizeof(float) * 3)) + return false; + } + out.heights.push_back(std::move(height)); + } + + if (r.offset != size) + return r.fail("AttributeData trailing bytes"); + return true; +} + +bool parse_attribute_data_file(const std::string &path, AttributeData &out, + std::string *err) { + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file) { + if (err) + *err = "cannot open AttributeData: " + path; + return false; + } + const std::streamoff end = file.tellg(); + if (end < 0) { + if (err) + *err = "cannot stat AttributeData: " + path; + return false; + } + std::vector bytes(static_cast(end)); + file.seekg(0, std::ios::beg); + if (!bytes.empty() && !file.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) { + if (err) + *err = "cannot read AttributeData: " + path; + return false; + } + return parse_attribute_data(bytes.data(), bytes.size(), out, err); +} + +} // namespace fmt diff --git a/formats/attribute_data.h b/formats/attribute_data.h new file mode 100644 index 00000000..09a79c7c --- /dev/null +++ b/formats/attribute_data.h @@ -0,0 +1,56 @@ +// .mdatr —— Metin2 AttributeData 静态碰撞 / 高度网格资源。 +// +// The legacy client reads this file as a packed little-endian stream. Keep the +// format layer independent from Godot so it can be used by the world loader and +// by host-side validation tests alike. +#pragma once + +#include +#include +#include +#include + +namespace fmt { + +enum class AttributeCollisionType : std::uint32_t { + Plane = 0, + Box = 1, + Sphere = 2, + Cylinder = 3, + Aabb = 4, + Obb = 5, +}; + +struct AttributeCollision { + std::uint32_t type = 0; + std::string name; + std::array position = {0.0f, 0.0f, 0.0f}; + // Plane/Cylinder use dimensions[0..1], the other supported primitives use + // dimensions[0..2]. Values retain the source units (centimetres). + std::array dimensions = {0.0f, 0.0f, 0.0f}; + // x, y, z, w in the same order as the D3DXQUATERNION on disk. + std::array quaternion = {0.0f, 0.0f, 0.0f, 1.0f}; +}; + +struct AttributeHeight { + std::string name; + // The legacy file calls these vertices "primitives". They are a flat + // triangle list: every consecutive three vertices form one triangle. + std::vector> vertices; +}; + +struct AttributeData { + std::vector collisions; + std::vector heights; +}; + +// Parse the complete byte buffer. The parser rejects truncated data, unknown +// primitive types and unreasonable counts rather than accepting a partial +// collision resource. +bool parse_attribute_data(const std::uint8_t *data, std::size_t size, + AttributeData &out, std::string *err = nullptr); + +bool parse_attribute_data_file(const std::string &path, AttributeData &out, + std::string *err = nullptr); + +} // namespace fmt diff --git a/formats/tests/attribute_data_test.cpp b/formats/tests/attribute_data_test.cpp new file mode 100644 index 00000000..efe392d3 --- /dev/null +++ b/formats/tests/attribute_data_test.cpp @@ -0,0 +1,132 @@ +#include "attribute_data.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; + +#define CHECK(condition, message) \ + do { \ + if (!(condition)) { \ + std::fprintf(stderr, "FAIL: %s (%s:%d)\n", (message), __FILE__, \ + __LINE__); \ + ++failures; \ + } \ + } while (0) + +template +void append(std::vector &bytes, const T &value) { + const auto *p = reinterpret_cast(&value); + bytes.insert(bytes.end(), p, p + sizeof(T)); +} + +void append_name(std::vector &bytes, const char *name) { + char fixed[32] = {}; + std::strncpy(fixed, name, sizeof(fixed)); + bytes.insert(bytes.end(), reinterpret_cast(fixed), + reinterpret_cast(fixed) + sizeof(fixed)); +} + +void append_collision(std::vector &bytes, std::uint32_t type, + const char *name, const float *position, + const float *dimensions, const float *quaternion, + std::size_t dimension_count) { + append(bytes, type); + append_name(bytes, name); + for (std::size_t i = 0; i < 3; ++i) + append(bytes, position[i]); + for (std::size_t i = 0; i < dimension_count; ++i) + append(bytes, dimensions[i]); + for (std::size_t i = 0; i < 4; ++i) + append(bytes, quaternion[i]); +} + +void test_synthetic() { + std::vector bytes; + const char header[] = "AttributeData"; + bytes.insert(bytes.end(), header, header + sizeof(header)); + const std::uint32_t collisions = 3; + const std::uint32_t heights = 1; + append(bytes, collisions); + append(bytes, heights); + + const float p0[] = {10, 20, 30}, d0[] = {40, 50}, q0[] = {0, 0, 0, 1}; + append_collision(bytes, 0, "plane", p0, d0, q0, 2); + const float p1[] = {-1, 2, -3}, d1[] = {7}, q1[] = {1, 2, 3, 4}; + append_collision(bytes, 2, "sphere", p1, d1, q1, 1); + const float p2[] = {4, 5, 6}, d2[] = {8, 9, 10}, q2[] = {0, 0.5f, 0, 0.5f}; + append_collision(bytes, 5, "obb", p2, d2, q2, 3); + + append_name(bytes, "height0"); + const std::uint32_t vertex_count = 3; + append(bytes, vertex_count); + const float vertices[] = {0, 0, 0, 100, 0, 0, 0, 100, 0}; + for (float value : vertices) + append(bytes, value); + + fmt::AttributeData parsed; + std::string error; + CHECK(fmt::parse_attribute_data(bytes.data(), bytes.size(), parsed, &error), + "synthetic .mdatr parses"); + CHECK(parsed.collisions.size() == 3 && parsed.heights.size() == 1, + "synthetic counts"); + CHECK(parsed.collisions[0].name == "plane" && + parsed.collisions[0].dimensions[1] == 50.0f, + "plane fields"); + CHECK(parsed.collisions[1].type == 2 && + parsed.collisions[1].dimensions[0] == 7.0f && + parsed.collisions[1].quaternion[0] == 1.0f, + "sphere fields"); + CHECK(parsed.collisions[2].name == "obb" && + parsed.collisions[2].dimensions[2] == 10.0f, + "obb fields"); + CHECK(parsed.heights[0].name == "height0" && + parsed.heights[0].vertices.size() == 3 && + parsed.heights[0].vertices[1][0] == 100.0f, + "height triangle fields"); + + auto truncated = bytes; + truncated.pop_back(); + CHECK(!fmt::parse_attribute_data(truncated.data(), truncated.size(), parsed, &error), + "truncated .mdatr rejected"); + auto trailing = bytes; + trailing.push_back(0); + CHECK(!fmt::parse_attribute_data(trailing.data(), trailing.size(), parsed, &error), + "trailing .mdatr rejected"); +} + +void test_real_asset() { + const char *root = std::getenv("M2_ASSETS"); + if (!root || !*root) + return; + const std::string path = std::string(root) + + "/metin2_patch_eu3/ymir work/zone/devilcave/devil_entrance1f.mdatr"; + fmt::AttributeData parsed; + std::string error; + CHECK(fmt::parse_attribute_data_file(path, parsed, &error), + "real devil_entrance1f.mdatr parses"); + CHECK(parsed.collisions.size() == 9 && parsed.heights.empty(), + "real .mdatr collision/height count"); + CHECK(parsed.collisions[0].type == 0 && parsed.collisions[0].name == "collision04", + "real .mdatr first collision"); +} + +} // namespace + +int main() { + test_synthetic(); + test_real_asset(); + if (failures == 0) { + std::puts("PASS: formats_attribute_data_test"); + return 0; + } + std::fprintf(stderr, "%d check(s) failed\n", failures); + return 1; +} diff --git a/project/chat_test.gd b/project/chat_test.gd index 3d7ccfb5..1c53e3f8 100644 --- a/project/chat_test.gd +++ b/project/chat_test.gd @@ -26,6 +26,7 @@ class FakeClient extends Node: func get_entities() -> Array: return ents.values() func say(t, s) -> bool: said.append([t, s]); return true func whisper(to, s) -> bool: whispered.append([to, s]); return true + func get_guild_name(gid: int) -> String: return "Camelot" if gid != 0 else "" class FakeProto extends Node: func item(vnum: int) -> Dictionary: @@ -112,6 +113,14 @@ func _run() -> void: chat._on_submit("/w Bob psst hey") _ck(fc.whispered.size() == 1 and fc.whispered[0] == ["Bob", "psst hey"], "/w Bob ... -> whisper('Bob','psst hey')") _ck(_last(tWhisper).contains("→ Bob: psst hey"), "/w -> local echo in Whisper tab") + # game.py OpenWhisperDialog:目标框独立于公共聊天输入行,回车发送后关闭。 + chat.start_whisper("Carol") + _ck(chat.is_whisper_open() and chat.whisper_target() == "Carol", + "start_whisper -> 独立私聊窗口打开并锁定目标") + chat._on_whisper_submit("private window") + _ck(fc.whispered.size() == 2 and fc.whispered[1] == ["Carol", "private window"], + "独立私聊窗口发送 -> whisper('Carol', msg)") + _ck(not chat.is_whisper_open(), "私聊发送后关闭独立窗口") # --- 气泡 --- var nw: Node = NetWorld.new() @@ -126,6 +135,67 @@ func _run() -> void: nw._bubble(2000, "hi there") var bob: Node3D = nw.node_for(2000) _ck(bob != null and bob.get_node_or_null("bubble") != null, "entity 2000 got a bubble node") + # §8.8 seam ⑨(UpdateShowingTextTail :110-114):有「聊天」气泡时名字尾标顶到气泡上方 17px。 + var bob_name := bob.get_node_or_null("Label3D") as Label3D + var bob_bubble := bob.get_node_or_null("bubble") as Label3D + _ck(bob_name != null and bob_bubble != null, "entity 2000 has Label3D + bubble") + _ck(absf(bob_name.position.y - (bob_bubble.position.y + 17.0 * bob_name.pixel_size)) < 0.0001, + "聊天气泡在 -> 名字 y = 气泡 y + 17*pixel_size") + _ck(bob_name.position.y > bob_bubble.position.y, "名字尾标顶到气泡上方") + # §8.8 seam ⑦ 收口(增量 119 / ArrangeTextTail :228/:258/:283):公会名 / 称号 / 等级 + # 子标签锚在名字尾标上,名字被气泡顶起时三者整体平移同一段。 + var g_tag := bob.get_node_or_null("GuildTag") as Label3D + var t_tag := bob.get_node_or_null("TitleTag") as Label3D + var l_tag := bob.get_node_or_null("LevelTag") as Label3D + _ck(g_tag != null and t_tag != null and l_tag != null, "entity 2000 has 公会/称号/等级子标签") + var lift := bob_name.position.y - 2.1 + _ck(lift > 0.0, "名字尾标确有上移量") + _ck(absf(g_tag.position.y - (2.34 + lift)) < 0.0001, "GuildTag 随名字上移同一段") + _ck(absf(t_tag.position.y - (1.86 + lift)) < 0.0001, "TitleTag 随名字上移同一段") + _ck(absf(l_tag.position.y - (1.74 + lift)) < 0.0001, "LevelTag 随名字上移同一段") + # 信息尾标(bNameFlag FALSE)不顶名字 —— 复用同一张 _chat_tails,覆盖成 info 后名字回默认。 + nw.register_info_tail(2000, "skill fizzled") + _ck(absf(bob_name.position.y - 2.1) < 0.0001, "信息尾标覆盖 -> 名字回默认局部 y 2.1") + _ck(absf(g_tag.position.y - 2.34) < 0.0001, "信息尾标覆盖 -> GuildTag 回默认 2.34") + _ck(absf(t_tag.position.y - 1.86) < 0.0001, "信息尾标覆盖 -> TitleTag 回默认 1.86") + _ck(absf(l_tag.position.y - 1.74) < 0.0001, "信息尾标覆盖 -> LevelTag 回默认 1.74") + # --- §8.8 seam ⑦ 水平排版(增量 121 / ArrangeTextTail :211-320 非 EUROPE 分支)------ + # charlie 有公会 + 善恶度(称号) + 等级 -> 三子标签沿名字锚点水平排开: + # 等级在最左、称号居中偏左、名字本身右移 fxAdd(=8);公会名(无徽记)留 x = 0。 + fc.ents[3000] = {"vid": 3000, "name": "Charlie", "guild": 7, "alignment": 15000, "level": 42} + fc.entity_spawned.emit({"vid": 3000, "name": "Charlie", "pos": Vector3(3, 0, 0), "func": 0, "angle_deg": 0.0}) + await process_frame + var charlie: Node3D = nw.node_for(3000) + _ck(charlie != null, "entity 3000 spawned") + nw._apply_text_tail(3000) + var c_name := charlie.get_node_or_null("Label3D") as Label3D + var c_g := charlie.get_node_or_null("GuildTag") as Label3D + var c_t := charlie.get_node_or_null("TitleTag") as Label3D + var c_l := charlie.get_node_or_null("LevelTag") as Label3D + _ck(c_g.text == "Camelot", "GuildTag 文本经 get_guild_name 解出") + _ck(c_t.text != "", "TitleTag 文本非空(alignment 15000 -> grade 0 -> 有称号)") + _ck(c_l.text != "", "LevelTag 文本非空(level 42)") + # 名字本身右移 fxAdd —— 有称号 -> 8px 折世界。 + _ck(absf(c_name.position.x - 8.0 * c_name.pixel_size) < 0.0001, + "名字 x = name_x_shift(有称号)=8 * pixel_size") + # 称号 / 等级在名字左侧(x < 0),等级比称号更靠左。 + _ck(c_t.position.x < 0.0, "称号在名字锚点左侧") + _ck(c_l.position.x < c_t.position.x, "等级比称号更靠左(-nameW/2 - 8 - titleW)") + # 公会名无徽记 -> 留在名字中线正上方。 + _ck(absf(c_g.position.x) < 0.0001, "GuildTag x = 0(poc 无公会徽记)") + # 精确值:nameW = len("Charlie")*7 = 49,称号 x 偏移 = -(49/2) - 8 = -32.5。 + _ck(absf(c_t.position.x - (-(7.0 * 7.0 / 2.0) - 8.0) * c_t.pixel_size) < 0.0001, + "称号 x = (-nameW/2 - 8) * pixel_size 精确") + # 撤销称号(alignment -> 0,grade 4 = TITLE_NONE)后称号 x 归零、名字改右移 4(只剩等级)。 + fc.ents[3000]["alignment"] = 0 + nw._apply_text_tail(3000) + _ck(c_t.text == "" and absf(c_t.position.x) < 0.0001, "称号消失 -> TitleTag x 归零") + _ck(absf(c_name.position.x - 4.0 * c_name.pixel_size) < 0.0001, + "只剩等级 -> 名字 x = name_x_shift(仅等级)=4 * pixel_size") + # 精确值(无称号):等级 x 偏移 = -(nameW/2) - 4 = -(49/2) - 4 = -28.5。 + _ck(absf(c_l.position.x - (-(7.0 * 7.0 / 2.0) - 4.0) * c_l.pixel_size) < 0.0001, + "只剩等级:等级 x = (-nameW/2 - 4) * pixel_size 精确") + nw.set_local_vid(1000) nw._bubble(1000, "my line") _ck(got_main[0] == "my line", "local vid bubble -> main_bubble signal") diff --git a/project/damage_effect.gd b/project/damage_effect.gd new file mode 100644 index 00000000..f51e90b2 --- /dev/null +++ b/project/damage_effect.gd @@ -0,0 +1,117 @@ +# damage_effect —— §8.8 / §5.4 头顶伤害飘字「是否 / 如何显示」1:1: +# CInstanceBase::AddDamageEffect + ProcessDamage 的分类(DODGE|BLOCK -> MISS、 +# self -> "damage_"、nontarget -> 不渲染、target -> "target_")+ 数字逐位拆分 + +# CPythonSystem::IsShowDamage / SetShowDamageFlag 门。 +# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd 同一套纯静态镜像套路。) +# +# REF(40250 ClientVS22 source/ 为唯一基线): +# UserInterface/InstanceBase.h:300 enum DamageFlag { NORMAL 1<<0, POISON 1<<1, DODGE 1<<2, BLOCK 1<<3, PENETRATE 1<<4, CRITICAL 1<<5 } +# UserInterface/Packet.h:1943 TPacketGCDamageInfo { BYTE header; DWORD dwVID; BYTE flag; int damage; } +# UserInterface/PythonNetworkStreamPhaseGame.cpp:2401 RecvDamageInfoPacket: +# pInstTarget = GetInstancePtr(dwVID);bSelf = (pInstTarget == MainInstance); +# bTarget = (pInstTarget == m_pInstTarget);damage >= 0 才 AddDamageEffect(< 0 记 TraceError) +# UserInterface/InstanceBaseEffect.cpp:87 AddDamageEffect:仅当 IsShowDamage() 才 push m_DamageQueue({bSelf,bTarget,damage,flag}) +# UserInterface/InstanceBaseEffect.cpp:100 ProcessDamage(InstanceBase.cpp:1910 每帧调一次): +# 每帧 pop_front 一条; +# (flag & DODGE) || (flag & BLOCK) -> bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS,return +# (flag & CRITICAL) -> 该 build CreateEffect 被注释掉,落到普通数字(无独立暴击视觉) +# POISON 分支整段注释掉 +# bSelf -> strDamageType="damage_",EFFECT_DAMAGE_SELFDAMAGE / SELFDAMAGE2 交替(m_bDamageEffectType 每次取反) +# !bTarget -> strDamageType="nontarget_",EFFECT_DAMAGE_NOT_TARGET 后 **return(不渲染)** +# else -> strDamageType="target_",EFFECT_DAMAGE_TARGET +# while(damage>0):num=damage%10; damage/=10; index 0..7(>7 TraceError break); +# 贴图 "d:/ymir work/effect/affect/damagevalue/.dds", +# 水平偏移 FONT_WIDTH(30)*index(相机右方向,matTrans._41 = -(FONT_WIDTH*index)) +# UserInterface/PythonSystem.cpp:308 m_Config.bShowDamage = true(默认) +# UserInterface/PythonSystem.cpp:337 IsShowDamage() -> m_Config.bShowDamage +# UserInterface/PythonSystem.cpp:342 SetShowDamageFlag(iFlag) -> bShowDamage = (iFlag == 1) +# UserInterface/PythonSystem.cpp:451 config "SHOW_DAMAGE" -> atoi(value) == 1 +# UserInterface/PythonSystem.cpp:537 save "SHOW_DAMAGE %d"(仅当 == 0,即非默认时落盘) +# UserInterface/PythonSystemModule.cpp:192/219 systemSetShowDamageFlag / systemIsShowDamage +# root/uigameoption.py __OnClickShowDamageOn/OffButton -> systemSetting.SetShowDamageFlag(...) +# +# seam ⑪:参考端每位数字是一张 damagevalue/.dds 贴图做的 CEffectManager 特效, +# 在相机右方向按 FONT_WIDTH*index 像素铺开;本移植 net_world 用一块 billboard Label3D 直接写 +# str(amount)(无 damagevalue dds 图集 / 无逐位特效 / 无相机右向像素铺排),MISS 写 "MISS"。 +# self 的 SELFDAMAGE/SELFDAMAGE2 交替本移植退化成落字水平抖动符号交替(仅存在感,无双特效资源)。 +# 暴击在该 40250 build 参考端本就无独立视觉,这里也不再放大 / 变色(与 <=112 的占位实现相比是回归对齐)。 +# 另:参考端 m_DamageQueue 是「每个实例一条」,ProcessDamage 每帧每实例 pop 一条;本移植是 +# net_world 一条全局队列每帧 pop 一条 —— 同一帧多个 VID 同时受击时会分帧显示。 +extends RefCounted + +# CInstanceBase::DamageFlag +const DAMAGE_NORMAL := 1 << 0 +const DAMAGE_POISON := 1 << 1 +const DAMAGE_DODGE := 1 << 2 +const DAMAGE_BLOCK := 1 << 3 +const DAMAGE_PENETRATE := 1 << 4 +const DAMAGE_CRITICAL := 1 << 5 + +const SHOW_DAMAGE_DEFAULT := true # m_Config.bShowDamage 默认 +const CFG_KEY := "SHOW_DAMAGE" # PythonSystem 配置行 / 存盘键 +const FONT_WIDTH := 30 # ProcessDamage 逐位水平像素步进 +const MAX_DIGITS := 8 # index > 7 -> break + +enum Kind { MISS, DIGITS } + +# AddDamageEffect:仅当 IsShowDamage() 才入队 +static func should_queue(show_damage: bool) -> bool: + return show_damage + +# SetShowDamageFlag(int iFlag): m_Config.bShowDamage = iFlag == 1 +static func show_damage_from_flag(flag: int) -> bool: + return flag == 1 + +# config "SHOW_DAMAGE" 行:atoi(value) == 1 +static func show_damage_from_cfg(value: Variant) -> bool: + return int(value) == 1 + +# RecvDamageInfoPacket:bSelf = 目标实例是主角 +static func is_self(vid: int, main_vid: int) -> bool: + return vid != 0 and vid == main_vid + +# RecvDamageInfoPacket:bTarget = 目标实例是当前目标框 VID +static func is_target(vid: int, target_vid: int) -> bool: + return vid != 0 and vid == target_vid + +# RecvDamageInfoPacket:damage < 0 记 TraceError 不处理 +static func valid_amount(amount: int) -> bool: + return amount >= 0 + +# ProcessDamage 分类。返回: +# {"kind": Kind, "type": String("damage_"/"target_"/"nontarget_"), "show": bool, "self": bool} +# show == false 只有一种来源:非 self 且非 target 的数字伤害(ProcessDamage 里 return,不渲染)。 +static func classify(flag: int, b_self: bool, b_target: bool) -> Dictionary: + if (flag & DAMAGE_DODGE) != 0 or (flag & DAMAGE_BLOCK) != 0: + # EFFECT_DAMAGE_MISS(self)/ EFFECT_DAMAGE_TARGETMISS(其它) + return {"kind": Kind.MISS, "type": "damage_" if b_self else "target_", "show": true, "self": b_self} + if b_self: + return {"kind": Kind.DIGITS, "type": "damage_", "show": true, "self": true} + if not b_target: + return {"kind": Kind.DIGITS, "type": "nontarget_", "show": false, "self": false} + return {"kind": Kind.DIGITS, "type": "target_", "show": true, "self": false} + +# while(damage>0){ num=damage%10; damage/=10; } —— 低位在前,最多 8 位(index>7 break)。 +# damage <= 0 不产出任何位(与参考 while 一致)。 +static func digits(damage: int) -> Array: + var out: Array = [] + var d := damage + if d <= 0: + return out + var index := 0 + while d > 0: + if index > MAX_DIGITS - 1: + break + out.append(d % 10) + d = d / 10 + index += 1 + return out + +# 第 index 位数字相对锚点的水平像素偏移(相机右方向)。 +static func digit_offset_px(index: int) -> float: + return float(FONT_WIDTH * index) + +# ProcessDamage 的 self 特效交替:m_bDamageEffectType==false -> SELFDAMAGE(0),否则 SELFDAMAGE2(1), +# 每次自伤后取反。返回本次用的槽位(0/1)。 +static func self_damage_slot(toggle_state: bool) -> int: + return 1 if toggle_state else 0 diff --git a/project/damage_effect_test.gd b/project/damage_effect_test.gd new file mode 100644 index 00000000..c4442f96 --- /dev/null +++ b/project/damage_effect_test.gd @@ -0,0 +1,112 @@ +# damage_effect_test —— §8.8 / §5.4 头顶伤害飘字 1:1: +# CInstanceBase::AddDamageEffect / ProcessDamage 的分类(DODGE|BLOCK -> MISS、 +# self -> "damage_"、nontarget -> 不渲染、target -> "target_")、数字逐位拆分、 +# CPythonSystem::IsShowDamage / SetShowDamageFlag 门。 +# godot --headless --path project --script damage_effect_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const DamageEffect = preload("res://damage_effect.gd") + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _init() -> void: + _run() + if _fail == 0: + print("PASS: damage_effect_test (§8.8/§5.4 AddDamageEffect / ProcessDamage 分类 + IsShowDamage 1:1)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + # --- 0. 常量(InstanceBase.h / PythonSystem.cpp / InstanceBaseEffect.cpp)---- + _ck(DamageEffect.DAMAGE_NORMAL == 1 << 0, "DAMAGE_NORMAL == 1<<0") + _ck(DamageEffect.DAMAGE_POISON == 1 << 1, "DAMAGE_POISON == 1<<1") + _ck(DamageEffect.DAMAGE_DODGE == 1 << 2, "DAMAGE_DODGE == 1<<2") + _ck(DamageEffect.DAMAGE_BLOCK == 1 << 3, "DAMAGE_BLOCK == 1<<3") + _ck(DamageEffect.DAMAGE_PENETRATE == 1 << 4, "DAMAGE_PENETRATE == 1<<4") + _ck(DamageEffect.DAMAGE_CRITICAL == 1 << 5, "DAMAGE_CRITICAL == 1<<5") + _ck(DamageEffect.SHOW_DAMAGE_DEFAULT == true, "m_Config.bShowDamage 默认 true") + _ck(DamageEffect.CFG_KEY == "SHOW_DAMAGE", "配置键 == SHOW_DAMAGE") + _ck(DamageEffect.FONT_WIDTH == 30, "FONT_WIDTH == 30") + _ck(DamageEffect.MAX_DIGITS == 8, "index 上限 8(>7 break)") + + # --- 1. IsShowDamage 门 ----------------------------------------------------- + _ck(DamageEffect.should_queue(true) == true, "IsShowDamage() -> 入队") + _ck(DamageEffect.should_queue(false) == false, "!IsShowDamage() -> 不入队") + _ck(DamageEffect.show_damage_from_flag(1) == true, "SetShowDamageFlag(1) -> true") + _ck(DamageEffect.show_damage_from_flag(0) == false, "SetShowDamageFlag(0) -> false") + _ck(DamageEffect.show_damage_from_flag(2) == false, "SetShowDamageFlag(2) -> false(严格 == 1)") + _ck(DamageEffect.show_damage_from_cfg("1") == true, "cfg \"1\" -> true") + _ck(DamageEffect.show_damage_from_cfg("0") == false, "cfg \"0\" -> false") + _ck(DamageEffect.show_damage_from_cfg("2") == false, "cfg \"2\" -> false") + _ck(DamageEffect.show_damage_from_cfg(1) == true, "cfg int 1 -> true") + + # --- 2. bSelf / bTarget(RecvDamageInfoPacket)---------------------------- + _ck(DamageEffect.is_self(42, 42) == true, "vid == main -> bSelf") + _ck(DamageEffect.is_self(42, 7) == false, "vid != main -> !bSelf") + _ck(DamageEffect.is_self(0, 0) == false, "vid 0 -> !bSelf") + _ck(DamageEffect.is_target(9, 9) == true, "vid == targetBoard VID -> bTarget") + _ck(DamageEffect.is_target(9, 0) == false, "无目标 -> !bTarget") + _ck(DamageEffect.is_target(0, 0) == false, "vid 0 -> !bTarget") + + # --- 3. damage >= 0 门 -------------------------------------------------- + _ck(DamageEffect.valid_amount(0) == true, "damage 0 -> 处理(>= 0)") + _ck(DamageEffect.valid_amount(5) == true, "damage 5 -> 处理") + _ck(DamageEffect.valid_amount(-1) == false, "damage < 0 -> TraceError 不处理") + + # --- 4. ProcessDamage 分类 -------------------------------------------------- + var m := DamageEffect.Kind.MISS + var g := DamageEffect.Kind.DIGITS + # DODGE / BLOCK -> MISS + var c := DamageEffect.classify(DamageEffect.DAMAGE_DODGE, true, true) + _ck(c["kind"] == m and c["type"] == "damage_" and c["show"] and c["self"], "DODGE + self -> MISS damage_ show self") + c = DamageEffect.classify(DamageEffect.DAMAGE_DODGE, false, true) + _ck(c["kind"] == m and c["type"] == "target_" and c["show"] and not c["self"], "DODGE + 非 self -> MISS target_") + c = DamageEffect.classify(DamageEffect.DAMAGE_BLOCK, true, false) + _ck(c["kind"] == m and c["type"] == "damage_", "BLOCK + self -> MISS damage_") + c = DamageEffect.classify(DamageEffect.DAMAGE_BLOCK, false, false) + _ck(c["kind"] == m and c["type"] == "target_", "BLOCK + 非 self -> MISS target_(即便非 target)") + c = DamageEffect.classify(DamageEffect.DAMAGE_DODGE | DamageEffect.DAMAGE_BLOCK, false, true) + _ck(c["kind"] == m, "DODGE|BLOCK 合并 -> MISS") + # 数字:self / target / nontarget + c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, true, false) + _ck(c["kind"] == g and c["type"] == "damage_" and c["show"] and c["self"], "普通 + self -> DIGITS damage_ show self") + c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, false, true) + _ck(c["kind"] == g and c["type"] == "target_" and c["show"] and not c["self"], "普通 + target -> DIGITS target_ show") + c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, false, false) + _ck(c["kind"] == g and c["type"] == "nontarget_" and c["show"] == false, "普通 + 非 self 非 target -> nontarget_ 不渲染") + # CRITICAL 在该 build 无独立视觉 -> 仍按数字 + c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, true, false) + _ck(c["kind"] == g and c["type"] == "damage_" and c["show"], "CRITICAL + self -> 仍普通数字 damage_") + c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, false, true) + _ck(c["kind"] == g and c["type"] == "target_" and c["show"], "CRITICAL + target -> 普通数字 target_") + c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, false, false) + _ck(c["show"] == false, "CRITICAL + nontarget -> 仍不渲染") + # POISON 分支被注释掉 -> 走普通 + c = DamageEffect.classify(DamageEffect.DAMAGE_POISON, false, true) + _ck(c["kind"] == g and c["type"] == "target_", "POISON(分支注释掉)+ target -> 普通数字 target_") + + # --- 5. digits:低位在前,最多 8 位 -------------------------------------- + _ck(DamageEffect.digits(0) == [], "digits(0) == [](while 零次)") + _ck(DamageEffect.digits(-5) == [], "digits(-5) == []") + _ck(DamageEffect.digits(7) == [7], "digits(7) == [7]") + _ck(DamageEffect.digits(123) == [3, 2, 1], "digits(123) == [3,2,1](低位在前)") + _ck(DamageEffect.digits(90) == [0, 9], "digits(90) == [0,9]") + _ck(DamageEffect.digits(2000000) == [0, 0, 0, 0, 0, 0, 2], "digits(2000000) 7 位") + _ck(DamageEffect.digits(999999999).size() == 8, "digits(9 位) 截到 8 位(index>7 break)") + _ck(DamageEffect.digits(999999999) == [9, 9, 9, 9, 9, 9, 9, 9], "digits(999999999) == 8×9") + + # --- 6. digit_offset_px:FONT_WIDTH*index ------------------------------ + _ck(is_equal_approx(DamageEffect.digit_offset_px(0), 0.0), "第 0 位偏移 0") + _ck(is_equal_approx(DamageEffect.digit_offset_px(1), 30.0), "第 1 位偏移 30") + _ck(is_equal_approx(DamageEffect.digit_offset_px(3), 90.0), "第 3 位偏移 90") + + # --- 7. self 特效交替(m_bDamageEffectType)--------------------------- + _ck(DamageEffect.self_damage_slot(false) == 0, "toggle false -> SELFDAMAGE(0)") + _ck(DamageEffect.self_damage_slot(true) == 1, "toggle true -> SELFDAMAGE2(1)") diff --git a/project/dungeon_result_test.gd b/project/dungeon_result_test.gd new file mode 100644 index 00000000..b17b26d3 --- /dev/null +++ b/project/dungeon_result_test.gd @@ -0,0 +1,44 @@ +# dungeon_result_test —— DUNGEON_RESULT 的九字段 UI 消费回归。 +extends SceneTree + +const DungeonResultUI = preload("res://ui/dungeon_result_ui.gd") + +var _fail := 0 + +func _ck(condition: bool, message: String) -> void: + if not condition: + _fail += 1 + printerr("FAIL: " + message) + +func _init() -> void: + await _run() + if _fail == 0: + print("PASS: dungeon_result_test (ShowDungeonResult nine fields)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + var layer := CanvasLayer.new() + get_root().add_child(layer) + var result_ui := DungeonResultUI.new() + get_root().add_child(result_ui) + result_ui.setup(layer) + var result := { + "killstone_count": 2, "killmob_count": 40, "find_hidden": 1, "hidden_total": 3, + "use_potion": 5, "is_revived": 0, "killallmob": 1, "total_time": 600, + "bonus_exp": 1234, + } + result_ui.show_result(result) + await process_frame + _ck(result_ui.is_open(), "result window is visible") + _ck(result_ui.result() == result, "all nine server fields are retained") + var rows: Array = result_ui._display_rows() + _ck(rows.size() == 8, "display has the eight grouped rows") + _ck(str(rows[6][1]) == "10:00", "total_time is formatted as mm:ss") + _ck(str(rows[7][1]) == "1234", "bonus_exp is displayed") + result_ui.close() + _ck(not result_ui.is_open(), "close hides result window") + layer.queue_free() + result_ui.queue_free() diff --git a/project/entity_rules.gd b/project/entity_rules.gd index 3540c9aa..b129b701 100644 --- a/project/entity_rules.gd +++ b/project/entity_rules.gd @@ -39,12 +39,15 @@ const DUEL_START := 2 const STATE_KILLER := 1 << 3 const STATE_PARTY := 1 << 4 -# _entity_kind 语义(net_play._entity_kind):0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP +# _entity_kind 语义(net_play._entity_kind):0 PC / 1 NPC / 2 MONSTER / 3 STONE / +# 4 WARP / 5 BUILDING / 6 WOODEN_DOOR。 const KIND_PC := 0 const KIND_NPC := 1 const KIND_MONSTER := 2 const KIND_STONE := 3 const KIND_WARP := 4 +const KIND_BUILDING := 5 +const KIND_WOODEN_DOOR := 6 # --- alignment(善恶)--------------------------------------------------------- @@ -97,13 +100,16 @@ static func _is_enemy(e: Dictionary) -> bool: static func _is_wooden_door(e: Dictionary) -> bool: # CInstanceBase::IsWoodenDoor(InstanceBase.cpp:2390) + if _kind(e) == KIND_WOODEN_DOOR: + return true var vnum := int(e.get("race", e.get("vnum", 0))) return vnum == 13000 or (vnum >= 30111 and vnum <= 30119) static func _is_building(e: Dictionary) -> bool: # CInstanceBase::IsBuilding() = m_GraphicThingInstance.IsBuilding()。 - # 建筑 / 城墙的 thing type 在本 POC 未单独下发(IsWall 的 race 白名单未随源码发布)→ seam。 - return bool(e.get("building", false)) + # classic 的 bType / POC fixture 可直接给 kind;旧包仍可用 building 布尔字段。 + return bool(e.get("building", false)) or _kind(e) == KIND_BUILDING \ + or _kind(e) == KIND_WOODEN_DOOR # --- killer / party / empire / duel / pvp ----------------------------------- diff --git a/project/entity_rules_test.gd b/project/entity_rules_test.gd index a598f4dd..772bc6a4 100644 --- a/project/entity_rules_test.gd +++ b/project/entity_rules_test.gd @@ -142,6 +142,12 @@ func _run() -> void: "PC vs wooden door 30115 -> true") _ck(not R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 13001}, _ctx()), "PC vs stone door 13001 -> false") + _ck(R._is_wooden_door({"kind": R.KIND_WOODEN_DOOR, "race": 999}), + "explicit WOODEN_DOOR kind -> IsWoodenDoor") + _ck(R._is_building({"kind": R.KIND_BUILDING}), + "explicit BUILDING kind -> IsBuilding") + _ck(R._is_building({"kind": R.KIND_WOODEN_DOOR}), + "wooden door kind -> IsBuilding") # 24) is_conflict_alignment_instance:victim PK_PROTECT 一律 false _ck(not R.is_conflict_alignment_instance(me_w, diff --git a/project/eterngrn_polish_test.gd b/project/eterngrn_polish_test.gd index 31de27c1..27161e86 100644 --- a/project/eterngrn_polish_test.gd +++ b/project/eterngrn_polish_test.gd @@ -95,6 +95,8 @@ func spawn_at(n, p, gp, o=true): log["spawn"].append(n); return null fake_audio.set_script(_inline_stub(""" extends Node var log +func set_listener(pos, forward=Vector3(0, 0, -1), up=Vector3.UP): pass +func play_character_sound_3d(x, y, z, rel, check_frequency=false): log["sound"].append(rel); return 1 func play_at(rel, pos, parent=null): log["sound"].append(rel) """)) fake_audio.set("log", calls) diff --git a/project/game_camera.gd b/project/game_camera.gd index e397f7d7..fc5a1698 100644 --- a/project/game_camera.gd +++ b/project/game_camera.gd @@ -6,17 +6,31 @@ # c.world = metin2_world; add_child(c); c.make_current() extends Camera3D +enum CameraMode { + NORMAL, + STAND, + BLEND, +} + +# ClientVS22 stores camera settings in centimetres/degrees. The Godot scene +# is metres/radians, so keep the conversion at this boundary instead of +# leaking mixed units into the orbit controls. +const DEFAULT_DISTANCE_CM := 1550.0 +const DEFAULT_PITCH_DEG := 27.0 +const DEFAULT_ROTATION_DEG := 0.0 +const DEFAULT_HEIGHT_CM := 100.0 + var target: Node3D var world: Node # Metin2World,用 sample_height 防穿(可空) var head_offset := Vector3(0, 1.15, 0) -var yaw := 0.7 -var pitch := 0.55 -var dist := 8.0 +var yaw := deg_to_rad(DEFAULT_ROTATION_DEG) +var pitch := deg_to_rad(DEFAULT_PITCH_DEG) +var dist := DEFAULT_DISTANCE_CM * 0.01 var min_pitch := deg_to_rad(12.0) var max_pitch := deg_to_rad(78.0) -var min_dist := 3.5 -var max_dist := 20.0 +var min_dist := 2.0 +var max_dist := 25.0 var sensitivity := 0.006 var zoom_step := 1.4 var follow_lerp := 12.0 @@ -41,6 +55,20 @@ var _key_orbit := 0 var _key_zoom := 0 var _key_pitch := 0 +# PythonApplicationCamera.cpp's NORMAL / STAND / BLEND modes. The normal +# camera follows the player; event cameras use a fixed centre and temporarily +# lock the orbit input. `setting` dictionaries intentionally retain the +# reference field names so quest scripts can be passed through unchanged. +var camera_mode: CameraMode = CameraMode.NORMAL +var _event_setting := {} +var _default_setting := {} +var _blend_from := {} +var _blend_to := {} +var _blend_elapsed := 0.0 +var _blend_duration := 0.0 +var _event_offset := Vector3.ZERO +var _event_center := Vector3.ZERO + const OCCLUDER_MASK := 1 << 1 # 静态遮挡物层(Metin2World 给建筑的盒碰撞) const FADE_ALPHA := 0.72 # 挡住玩家时的透明度 var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D @@ -48,6 +76,7 @@ var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D func _ready() -> void: fov = 55.0 far = 4000.0 + _apply_setting(_normal_setting()) if target: global_position = _desired_pos(true) _pos_ready = true @@ -55,12 +84,14 @@ func _ready() -> void: func _unhandled_input(e: InputEvent) -> void: if e is InputEventMouseButton: if e.button_index == MOUSE_BUTTON_RIGHT: - _dragging = e.pressed + _dragging = e.pressed and not is_event_locked() elif e.button_index == MOUSE_BUTTON_WHEEL_UP and e.pressed: - dist = maxf(min_dist, dist - zoom_step) + if not is_event_locked(): + dist = maxf(min_dist, dist - zoom_step) elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN and e.pressed: - dist = minf(max_dist, dist + zoom_step) - elif e is InputEventMouseMotion and _dragging: + if not is_event_locked(): + dist = minf(max_dist, dist + zoom_step) + elif e is InputEventMouseMotion and _dragging and not is_event_locked(): yaw -= e.relative.x * sensitivity pitch = clampf(pitch + e.relative.y * sensitivity, min_pitch, max_pitch) elif e is InputEventScreenTouch: @@ -115,6 +146,9 @@ func heading() -> float: # 相机看向的水平方向(供角色移动「相对相机」用) return yaw +func is_event_locked() -> bool: + return camera_mode != CameraMode.NORMAL + func set_key_orbit(direction: int) -> void: _key_orbit = clampi(direction, -1, 1) @@ -125,12 +159,16 @@ func set_key_pitch(direction: int) -> void: _key_pitch = clampi(direction, -1, 1) func _desired_pos(snap := false) -> Vector3: - var head := target.global_position + head_offset + var head := _look_target() var off := Vector3( sin(yaw) * cos(pitch), sin(pitch), cos(yaw) * cos(pitch)) * dist - var want := head + off + var want := head + off + _event_offset + if is_event_locked(): + # Event cameras are deliberately fixed shots. The reference event + # camera does not chase the actor while a quest is being presented. + return want # 地形防穿:沿 head->want 采样,低于地表就把相机拉回 if world and world.has_method("sample_height"): var d := want - head @@ -151,6 +189,129 @@ func _desired_pos(snap := false) -> Vector3: want = hit.position - n * 0.3 return want +func _look_target() -> Vector3: + if is_event_locked(): + return _event_center + if target: + return target.global_position + head_offset + return global_position + +func _normal_setting() -> Dictionary: + var centre := target.global_position if target else Vector3.ZERO + var server := MapCoord.to_server_cm(centre) + return { + "x": server.x, "y": server.y, "z": centre.y * 100.0, + "up": 0.0, "view": 0.0, "cross": 0.0, + "distance": dist * 100.0, + "rot": rad_to_deg(yaw), + "pitch": rad_to_deg(pitch), + } + +static func _setting_value(setting: Dictionary, key: String, fallback: float) -> float: + if not setting.has(key): + return fallback + return float(setting[key]) + +func _setting_world_center(setting: Dictionary) -> Vector3: + # SCameraSetting uses the same x/y ground plane and z-up convention as + # server positions. MapCoord is the single source of truth for the map + # origin and the Y/Z handedness conversion. + var sx := _setting_value(setting, "x", 0.0) + var sy := _setting_value(setting, "y", 0.0) + var sz := _setting_value(setting, "z", DEFAULT_HEIGHT_CM) + return MapCoord.to_world(Vector3(sx * 0.01, sz * 0.01, -sy * 0.01)) + +func _runtime_setting(setting: Dictionary) -> Dictionary: + var s := setting.duplicate(true) + s["_world_center"] = _setting_world_center(s) + s["distance"] = clampf(_setting_value(s, "distance", DEFAULT_DISTANCE_CM) * 0.01, + min_dist, max_dist) + s["pitch"] = clampf(deg_to_rad(_setting_value(s, "pitch", DEFAULT_PITCH_DEG)), + min_pitch, max_pitch) + s["rot"] = deg_to_rad(_setting_value(s, "rot", DEFAULT_ROTATION_DEG)) + s["up"] = _setting_value(s, "up", 0.0) * 0.01 + s["view"] = _setting_value(s, "view", 0.0) * 0.01 + s["cross"] = _setting_value(s, "cross", 0.0) * 0.01 + return s + +func _apply_setting(setting: Dictionary) -> void: + var s := _runtime_setting(setting) + yaw = s["rot"] + pitch = s["pitch"] + dist = s["distance"] + _event_offset = Vector3(s["cross"], s["up"], s["view"]) + +func _setting_for_blend() -> Dictionary: + if camera_mode == CameraMode.NORMAL: + return _normal_setting() + return _event_setting.duplicate(true) + +func _lerp_setting(a: Dictionary, b: Dictionary, t: float) -> Dictionary: + var out := {} + for key in ["x", "y", "z", "up", "view", "cross", "distance", "rot", "pitch"]: + out[key] = lerpf(_setting_value(a, key, 0.0), _setting_value(b, key, 0.0), t) + return out + +# CPythonApplication::SetEventCamera. The first event preserves the player's +# current normal settings so RESTORE_CAMERA returns to the user's view. +func set_event_camera(setting: Dictionary) -> void: + if camera_mode == CameraMode.NORMAL: + _default_setting = _normal_setting() + _event_setting = setting.duplicate(true) + _event_center = _setting_world_center(_event_setting) + _apply_setting(_event_setting) + camera_mode = CameraMode.STAND + _pos_ready = false + +# CPythonApplication::BlendEventCamera. `blendtime` follows the reference +# camera API and is expressed in seconds (the event parser passes it through). +func blend_event_camera(setting: Dictionary, blendtime: float) -> void: + _blend_from = _setting_for_blend() + _blend_to = setting.duplicate(true) + _blend_elapsed = 0.0 + _blend_duration = maxf(0.0, blendtime) + _event_setting = _blend_from.duplicate(true) + _event_center = _setting_world_center(_blend_from) + camera_mode = CameraMode.BLEND + _event_offset = Vector3.ZERO + _event_center = Vector3.ZERO + if _blend_duration <= 0.0: + _event_setting = _blend_to.duplicate(true) + _event_center = _setting_world_center(_event_setting) + _apply_setting(_event_setting) + camera_mode = CameraMode.STAND + _pos_ready = false + +func set_default_camera() -> void: + camera_mode = CameraMode.NORMAL + _blend_elapsed = 0.0 + _blend_duration = 0.0 + _event_offset = Vector3.ZERO + if not _default_setting.is_empty(): + _apply_setting(_default_setting) + _default_setting = {} + _event_setting = {} + _pos_ready = false + +func get_camera_setting() -> Dictionary: + var s := _setting_for_blend() + # During BLEND, expose the interpolated frame, matching GetCameraSetting's + # use by a subsequent camera event. + if camera_mode == CameraMode.BLEND: + var t := 1.0 if _blend_duration <= 0.0 else clampf(_blend_elapsed / _blend_duration, 0.0, 1.0) + s = _lerp_setting(_blend_from, _blend_to, t) + return s + +func snap_to_target() -> void: + if target == null: + return + _pos_ready = false + if camera_mode == CameraMode.NORMAL: + global_position = _desired_pos(true) + else: + global_position = _desired_pos(true) + look_at(_look_target(), Vector3.UP) + var _shake := 0.0 # 当前抖动强度(米),指数衰减 var _shake_decay := 8.0 @@ -162,11 +323,20 @@ func shake(strength: float, decay := 8.0) -> void: func _process(dt: float) -> void: if target == null: return - if _key_orbit != 0: + if camera_mode == CameraMode.BLEND: + _blend_elapsed += maxf(0.0, dt) + var t := 1.0 if _blend_duration <= 0.0 else clampf(_blend_elapsed / _blend_duration, 0.0, 1.0) + _event_setting = _lerp_setting(_blend_from, _blend_to, clampf(t, 0.0, 1.0)) + _event_center = _setting_world_center(_event_setting) + _apply_setting(_event_setting) + if t >= 1.0: + camera_mode = CameraMode.STAND + _event_setting = _blend_to.duplicate(true) + if camera_mode == CameraMode.NORMAL and _key_orbit != 0: yaw += float(_key_orbit) * dt * 1.8 - if _key_zoom != 0: + if camera_mode == CameraMode.NORMAL and _key_zoom != 0: dist = clampf(dist + float(_key_zoom) * dt * 5.0, min_dist, max_dist) - if _key_pitch != 0: + if camera_mode == CameraMode.NORMAL and _key_pitch != 0: pitch = clampf(pitch + float(_key_pitch) * dt * 1.2, min_pitch, max_pitch) var want := _desired_pos() if _pos_ready: @@ -179,8 +349,9 @@ func _process(dt: float) -> void: _shake = lerpf(_shake, 0.0, clampf(_shake_decay * dt, 0.0, 1.0)) else: _shake = 0.0 - look_at(target.global_position + head_offset, Vector3.UP) - _fade_occluders() + look_at(_look_target(), Vector3.UP) + if camera_mode == CameraMode.NORMAL: + _fade_occluders() # §8.2 相机与玩家之间的建筑半透明淡出。沿 cam->head 逐段射线,逐个 hit 淡出, # 移过撞点继续;本帧没被挡到的恢复不透明。 diff --git a/project/game_camera_test.gd b/project/game_camera_test.gd new file mode 100644 index 00000000..f066c607 --- /dev/null +++ b/project/game_camera_test.gd @@ -0,0 +1,60 @@ +# game_camera_test —— CameraProcedure / PythonApplicationCamera 的离线状态回归。 +extends SceneTree + +const GameCamera = preload("res://game_camera.gd") + +var _fail := 0 + +func _ck(condition: bool, message: String) -> void: + if not condition: + _fail += 1 + printerr("FAIL: " + message) + +func _init() -> void: + await _run() + if _fail == 0: + print("PASS: game_camera_test (NORMAL/STAND/BLEND + cm/degree conversion)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + var root := Node3D.new() + get_root().add_child(root) + var actor := Node3D.new() + actor.position = Vector3(10, 2, 20) + root.add_child(actor) + var camera: Camera3D = GameCamera.new() + camera.target = actor + root.add_child(camera) + await process_frame + + _ck(camera.camera_mode == GameCamera.CameraMode.NORMAL, "starts in NORMAL mode") + _ck(is_equal_approx(camera.dist, 15.5), "default distance is 1550cm") + _ck(is_equal_approx(camera.pitch, deg_to_rad(27.0)), "default pitch is 27 degrees") + + var shot := {"x": 1000, "y": 2000, "z": 300, "distance": 500, "pitch": 40, "rot": 90, + "up": 0, "view": 0, "cross": 0} + camera.set_event_camera(shot) + _ck(camera.camera_mode == GameCamera.CameraMode.STAND, "SET_CAMERA enters STAND") + _ck(camera.is_event_locked(), "event camera locks orbit") + var setting: Dictionary = camera.get_camera_setting() + _ck(is_equal_approx(float(setting.distance), 500.0), "event distance remains centimetres") + camera.snap_to_target() + _ck(camera.global_position.distance_to(Vector3(13.830, 6.214, 20)) < 0.02, + "event centre converts server x/y/z into world axes: %s" % camera.global_position) + + camera.blend_event_camera({"x": 3000, "y": 4000, "z": 500, "distance": 1000, + "pitch": 60, "rot": 180}, 1.0) + camera._process(0.5) + var middle: Dictionary = camera.get_camera_setting() + _ck(camera.camera_mode == GameCamera.CameraMode.BLEND, "blend remains BLEND before deadline") + _ck(is_equal_approx(float(middle.distance), 750.0), "blend is linear at half duration") + camera._process(0.6) + _ck(camera.camera_mode == GameCamera.CameraMode.STAND, "blend settles into STAND") + _ck(is_equal_approx(camera.dist, 10.0), "blend target distance applied") + camera.set_default_camera() + _ck(camera.camera_mode == GameCamera.CameraMode.NORMAL, "RESTORE_CAMERA enters NORMAL") + _ck(is_equal_approx(camera.dist, 15.5), "RESTORE_CAMERA restores saved normal view") + root.queue_free() diff --git a/project/game_scene.gd b/project/game_scene.gd index 9e2bda31..20155f2d 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -43,6 +43,7 @@ const SystemMenuUI = preload("res://ui/system_menu_ui.gd") const Quickbar = preload("res://ui/quickbar.gd") const QuestDialog = preload("res://ui/quest_dialog.gd") const QuestLog = preload("res://ui/quest_log.gd") +const DungeonResultUI = preload("res://ui/dungeon_result_ui.gd") const SelectItemUI = preload("res://ui/select_item_ui.gd") const PartyUI = preload("res://ui/party_ui.gd") const FriendUI = preload("res://ui/friend_ui.gd") @@ -95,6 +96,7 @@ var system_menu_ui: Node # SystemMenuUI var quickbar: Node # Quickbar var quest_dialog: Node # QuestDialog var quest_log: Node # QuestLog +var dungeon_result_ui: Node # DungeonResultUI var select_item_ui: Node # SelectItemUI var party_ui: Node # PartyUI var friend_ui: Node # FriendUI @@ -171,6 +173,10 @@ func setup(m2client: Node, assets_root: String, add_child(net_world) net_world.world = world net_world.setup(client, _mount) + # §8.8 屏幕空间实体血条:明确绑定轨道相机,避免依赖 viewport 当前相机, + # 也让地图切换 / 多视口时投影来源稳定。 + if net_world.has_method("set_camera"): + net_world.set_camera(cam) net_world.set_local_node(player) net_world.entity_added.connect(func(node: Node3D, _vid: int): if node not in pc.pickables: @@ -195,6 +201,9 @@ func setup(m2client: Node, assets_root: String, # §8.6 targetBoard.GetTargetVID() -> net_world:选中目标始终强显名字。 if net_play.has_signal("target_changed") and net_world and net_world.has_method("set_target_vid"): net_play.target_changed.connect(func(vid: int): net_world.set_target_vid(int(vid))) + # §8.8 game.py OnRender chr.Pick() -> ShowCharacterTextTail:悬停角色也强显名字(pick_show.gd)。 + if pc.has_signal("hover_entity_changed") and net_world and net_world.has_method("set_hover_vid"): + pc.hover_entity_changed.connect(func(vid: int): net_world.set_hover_vid(int(vid))) if _assets != "": net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表 @@ -225,6 +234,10 @@ func setup(m2client: Node, assets_root: String, chat.setup(client, ui, _assets) if net_play and net_play.has_signal("fishing_feedback") and chat.has_method("on_fishing_feedback"): net_play.fishing_feedback.connect(chat.on_fishing_feedback) + # §8.8 / §5.4 game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时 + # self.interface.OpenWhisperDialog(name)。chat.start_whisper 打开独立私聊窗口。 + if net_play and net_play.has_signal("whisper_requested") and chat.has_method("start_whisper"): + net_play.whisper_requested.connect(chat.start_whisper) if net_world and net_world.has_signal("main_bubble"): net_world.main_bubble.connect(_player_bubble) @@ -295,6 +308,15 @@ func setup(m2client: Node, assets_root: String, quest_dialog = QuestDialog.new() add_child(quest_dialog) quest_dialog.setup(client, ui, proto, assets_root) + if quest_dialog.has_signal("camera_event_requested_full"): + quest_dialog.camera_event_requested_full.connect(_on_quest_camera_event) + if quest_dialog.has_signal("fade_event_requested_full"): + quest_dialog.fade_event_requested_full.connect(_on_quest_fade_event) + if quest_dialog.has_signal("dungeon_result_requested"): + quest_dialog.dungeon_result_requested.connect(_on_dungeon_result) + dungeon_result_ui = DungeonResultUI.new() + add_child(dungeon_result_ui) + dungeon_result_ui.setup(ui) quest_log = QuestLog.new() add_child(quest_log) quest_log.setup(client, ui) @@ -330,6 +352,8 @@ func setup(m2client: Node, assets_root: String, await _yield() if chat and chat.has_method("set_proto"): chat.set_proto(proto) + if quest_dialog and quest_dialog.has_method("set_proto"): + quest_dialog.set_proto(proto) var mp := assets_root.path_join("locale/locale/en/mob_proto") if FileAccess.file_exists(mp): proto.call("load_mob_proto", mp) @@ -367,7 +391,16 @@ func setup(m2client: Node, assets_root: String, # 地面掉落物 ground_items = GroundItems.new() add_child(ground_items) - ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list) + # SetItemTextTailOwner :722:strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE) + # 为空则字面 "'s"。poc 用 EterLocale 的 POSSESSIVE_MORPHENE 键作对应物(缺则回 "")。 + var _possessive := "" + if assets_root != "": + var _poss_loc: RefCounted = load("res://locale.gd").new() + _poss_loc.setup(assets_root, "en") + if _poss_loc.has("POSSESSIVE_MORPHENE"): + _possessive = _poss_loc.t("POSSESSIVE_MORPHENE") + ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list, + func() -> Camera3D: return cam, _possessive) pc.set_input_surfaces(cursor_manager, ui, ground_items, Callable(net_play, "cancel_fishing"), Callable(net_play, "on_ground_click")) view_equipment_ui = ViewEquipmentUI.new() @@ -489,6 +522,15 @@ func setup(m2client: Node, assets_root: String, game_option_ui.display_option_changed.connect(_on_display_option) if net_world and net_world.has_method("set_always_show_name") and game_option_ui.has_method("display_value"): net_world.set_always_show_name(game_option_ui.display_value("always_show_name") == 1) + # §8.8 / §5.4「伤害数字」radio -> net_world(CPythonSystem::SetShowDamageFlag)。 + if net_world and net_world.has_method("set_show_damage") and game_option_ui.has_method("display_value"): + net_world.set_show_damage(game_option_ui.display_value("show_damage") == 1) + # §8.4「名字色」radio -> net_world(uigameoption.__SetNameColorMode → chrmgr.SetEmpireNameMode)。 + if net_world and net_world.has_method("set_empire_name_mode") and game_option_ui.has_method("display_value"): + net_world.set_empire_name_mode(game_option_ui.display_value("name_color") == 1) + # §8.4「看他国玩家目标框」radio -> net_play(uitarget.TargetBoard.Open 前置过滤)。 + if net_play and net_play.has_method("set_view_other_empire_target") and game_option_ui.has_method("display_value"): + net_play.set_view_other_empire_target(game_option_ui.display_value("target_board") == 1) system_menu_ui = SystemMenuUI.new() add_child(system_menu_ui) system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui) @@ -614,6 +656,69 @@ func _queue_shot(skill: int) -> void: _pending_shots.pop_front() var _flash_rect: ColorRect +var _quest_fade_rect: ColorRect +var _quest_fade_tween: Tween + +func _on_quest_camera_event(kind: String, setting: Dictionary, blendtime: float) -> void: + if cam == null or not is_instance_valid(cam): + return + match kind: + "SET_CAMERA": + if cam.has_method("set_event_camera"): + cam.set_event_camera(setting) + "BLEND_CAMERA": + if cam.has_method("blend_event_camera"): + cam.blend_event_camera(setting, blendtime) + "RESTORE_CAMERA": + if cam.has_method("set_default_camera"): + cam.set_default_camera() + +func _on_quest_fade_event(kind: String, speed: float) -> void: + if ui == null: + if quest_dialog and quest_dialog.has_method("end_event_process"): + quest_dialog.end_event_process() + return + if _quest_fade_rect == null or not is_instance_valid(_quest_fade_rect): + _quest_fade_rect = ColorRect.new() + _quest_fade_rect.name = "QuestFade" + _quest_fade_rect.set_anchors_preset(Control.PRESET_FULL_RECT) + _quest_fade_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + _quest_fade_rect.z_index = 100 + ui.add_child(_quest_fade_rect) + var white := kind.begins_with("WHITE_") + _quest_fade_rect.color = Color(1, 1, 1, _quest_fade_rect.color.a) if white \ + else Color(0, 0, 0, _quest_fade_rect.color.a) + var fade_in := kind.ends_with("_IN") + var target_alpha := 0.0 if fade_in else 1.0 + _quest_fade_rect.visible = target_alpha > 0.0 or _quest_fade_rect.color.a > 0.001 + if _quest_fade_tween and _quest_fade_tween.is_valid(): + _quest_fade_tween.kill() + # uiquest.py advances alpha by speed once per frame. At 60 FPS this is + # equivalent to 1 / (speed * 60) seconds; malformed zero speeds use the + # reference default to avoid permanently blocking the event set. + var frame_speed := maxf(absf(speed), 0.035) + var duration := absf(target_alpha - _quest_fade_rect.color.a) / (frame_speed * 60.0) + _quest_fade_tween = create_tween() + _quest_fade_tween.tween_property(_quest_fade_rect, "color:a", target_alpha, duration) + _quest_fade_tween.tween_callback(func(): + _quest_fade_rect.visible = target_alpha > 0.0 + if quest_dialog and quest_dialog.has_method("end_event_process"): + quest_dialog.end_event_process()) + +func _on_dungeon_result(result: Dictionary) -> void: + if dungeon_result_ui and dungeon_result_ui.has_method("show_result"): + dungeon_result_ui.show_result(result) + +func _clear_quest_presentation() -> void: + if _quest_fade_tween and _quest_fade_tween.is_valid(): + _quest_fade_tween.kill() + _quest_fade_tween = null + if _quest_fade_rect and is_instance_valid(_quest_fade_rect): + _quest_fade_rect.color.a = 0.0 + _quest_fade_rect.visible = false + if cam and cam.has_method("set_default_camera"): + cam.set_default_camera() + func _screen_flash(secs: float) -> void: if hud == null: return @@ -661,6 +766,9 @@ func _on_world_reset() -> void: widget.call("_close") if atlas_ui and atlas_ui.has_method("close"): atlas_ui.close() + if dungeon_result_ui and dungeon_result_ui.has_method("close"): + dungeon_result_ui.close() + _clear_quest_presentation() if ui and ui.has_method("close_top"): while ui.close_top(): pass @@ -731,6 +839,12 @@ func _unhandled_input(e: InputEvent) -> void: if key == KEY_SHIFT: _quick_page_mode = e.pressed return + # game.py SetPCTargetBoard :807 —— app.IsPressed(app.DIK_LCONTROL)。参考端在点 PC 的 + # 那一刻轮询 LCONTROL 按住态;poc 把按下 / 松开喂给 net_play,点 PC 时再查(seam ⑫)。 + if key == KEY_CTRL: + if net_play and net_play.has_method("set_lcontrol_down"): + net_play.set_lcontrol_down(e.pressed) + return if not e.pressed: match key: KEY_SPACE: @@ -855,6 +969,12 @@ func _set_names(visible: bool) -> void: func _on_display_option(key: String, value: int) -> void: if key == "always_show_name" and net_world and net_world.has_method("set_always_show_name"): net_world.set_always_show_name(value == 1) + elif key == "show_damage" and net_world and net_world.has_method("set_show_damage"): + net_world.set_show_damage(value == 1) + elif key == "name_color" and net_world and net_world.has_method("set_empire_name_mode"): + net_world.set_empire_name_mode(value == 1) + elif key == "target_board" and net_play and net_play.has_method("set_view_other_empire_target"): + net_play.set_view_other_empire_target(value == 1) func _send_command(text: String) -> void: if client and client.has_method("say"): diff --git a/project/gamescene_test.gd b/project/gamescene_test.gd index 6a73caac..4579b304 100644 --- a/project/gamescene_test.gd +++ b/project/gamescene_test.gd @@ -19,6 +19,7 @@ class FakeClient extends Node: signal motion(vid: int, victim_vid: int, motion: int) signal gift_available() signal guild_make_requested() + signal script_dialog(skin: int, text: String) var ents := {} var main := 0 func is_in_game() -> bool: return true @@ -99,6 +100,14 @@ func _run() -> void: _ck(gs.guild_ui._create_dialog != null and gs.guild_ui._create_dialog.visible, "guild make request reaches the in-game guild creation dialog") gs.guild_ui._close_guild_create_dialog() + # Quest EventManager side effects are consumed by the scene owner, not left + # as parser-only signals. + fc.script_dialog.emit(0, "[SET_CAMERA x(1000) y(2000) z(300) distance(500) pitch(40) rot(90)][DUNGEON_RESULT killstone_count(2) killmob_count(40) find_hidden(1) hidden_total(3) use_potion(5) is_revived(0) killallmob(1) total_time(600) bonus_exp(1234)]") + await process_frame + _ck(gs.cam.has_method("is_event_locked") and bool(gs.cam.call("is_event_locked")), + "quest SET_CAMERA reaches the scene camera") + _ck(gs.dungeon_result_ui != null and bool(gs.dungeon_result_ui.call("is_open")), + "quest DUNGEON_RESULT opens the scene result window") # 4) 别的实体会被镜像成挂载节点 fc.spawn(2000, "Wolf", Vector3(25, 0, -30)) diff --git a/project/guild_mark_tail_test.gd b/project/guild_mark_tail_test.gd new file mode 100644 index 00000000..21db8af1 --- /dev/null +++ b/project/guild_mark_tail_test.gd @@ -0,0 +1,114 @@ +# guild_mark_tail_test —— 已下载的公会会徽接入角色头顶尾标。 +# 覆盖会徽晚于角色实体到达、参考端 mark/guild/name 的水平排布,以及清空会徽缓存后 +# 不伪留旧 Sprite3D。godot --headless --path project --script guild_mark_tail_test.gd +extends SceneTree + +const NetWorld = preload("res://net_world.gd") +const NameTailLayout = preload("res://name_tail_layout.gd") + +class FakeClient extends Node: + signal entity_spawned(entity: Dictionary) + signal entity_despawned(vid: int) + signal entity_moved(vid: int) + signal entity_main_set(vid: int) + signal entity_info(vid: int, entity: Dictionary) + signal chat(type: int, vid: int, text: String) + signal vitals_changed(vid: int) + signal entity_dead(vid: int) + signal damage(vid: int, amount: int, flag: int) + signal guild_marks_ready(mark_count: int) + signal guild_mark_updated(guild_id: int, img_idx: int) + var entities := {} + var mark: Image + + func get_entity(vid: int) -> Dictionary: return entities.get(vid, {}) + func get_entities() -> Array: return entities.values() + func get_pvp_relations() -> Array: return [] + func get_duel() -> Dictionary: return {} + func get_guild_name(guild_id: int) -> String: return "Wolves" if guild_id == 88 else "" + func get_guild_mark_image(guild_id: int) -> Image: + return mark if guild_id == 88 else null + + func add_entity(vid: int, guild_id: int) -> Dictionary: + var d := {"vid": vid, "name": "Alice", "guild": guild_id, "level": 42, + "alignment": 0, "ch_type": 0, "race": 0, "hp": 100, "max_hp": 100, + "dead": false, "pos": Vector3.ZERO, "pos_cm": Vector3.ZERO, + "func": 0, "moving": false, "angle_deg": 0.0, "parts": [0, 0, 0, 0], + "affect_flags": 0} + entities[vid] = d + return d + +var _fail := 0 +func _ck(condition: bool, message: String) -> void: + if not condition: + _fail += 1 + printerr("FAIL: " + message) + +func _mark_image(color: Color) -> Image: + var bytes := PackedByteArray() + bytes.resize(16 * 12 * 4) + for i in range(0, bytes.size(), 4): + bytes[i] = int(color.r * 255.0) + bytes[i + 1] = int(color.g * 255.0) + bytes[i + 2] = int(color.b * 255.0) + bytes[i + 3] = 255 + return Image.create_from_data(16, 12, false, Image.FORMAT_RGBA8, bytes) + +func _init() -> void: + await _run() + if _fail == 0: + print("PASS: guild_mark_tail_test") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + var mount := Node3D.new() + get_root().add_child(mount) + var client := FakeClient.new() + get_root().add_child(client) + var world: Node = NetWorld.new() + get_root().add_child(world) + world.setup(client, mount) + + # The character is rendered before the side connection finishes downloading marks. + var entity := client.add_entity(7, 88) + client.entity_spawned.emit(entity) + var actor: Node3D = world.node_for(7) + _ck(actor != null, "guild actor is spawned") + _ck(actor.get_node_or_null("GuildMark") == null, "no mark image -> no stale GuildMark") + + # Mark download completion refreshes already-visible actors. + client.mark = _mark_image(Color(0.2, 0.6, 0.9)) + client.guild_marks_ready.emit(1) + var mark := actor.get_node_or_null("GuildMark") as Sprite3D + var guild := actor.get_node_or_null("GuildTag") as Label3D + _ck(mark != null and mark.texture != null, "guild_marks_ready -> world GuildMark Sprite3D") + _ck(mark != null and mark.texture.get_width() == 16 and mark.texture.get_height() == 12, + "GuildMark keeps the 16x12 mark dimensions") + _ck(guild != null and guild.text == "Wolves", "guild tag remains visible beside the mark") + _ck(mark != null and guild != null and not is_zero_approx(guild.position.x), + "mark present -> guild name gets mark-relative x offset") + _ck(mark != null and is_equal_approx(mark.position.y, + world.NAME_TAG_DEFAULT_Y + NameTailLayout.MARK_ABOVE_NAME_PX * actor.get_node("Label3D").pixel_size), + "mark is placed 26px above the name row") + + # An incremental update replaces the texture in place rather than duplicating the node. + var old_mark := mark + client.mark = _mark_image(Color(0.9, 0.2, 0.2)) + client.guild_mark_updated.emit(88, 3) + var refreshed := actor.get_node_or_null("GuildMark") as Sprite3D + _ck(refreshed == old_mark, "guild_mark_updated reuses the existing Sprite3D") + _ck(refreshed.texture != null and refreshed.texture.get_image().get_pixel(0, 0).r > 0.8, + "guild_mark_updated refreshes the texture") + + # GC_CHAR_ADD_INFO can remove a guild; the mark is removed with the guild tag. + entity["guild"] = 0 + client.entity_info.emit(7, entity) + await process_frame + _ck(actor.get_node_or_null("GuildMark") == null, "guild removal deletes the mark instance") + + world.free() + mount.free() + client.free() diff --git a/project/name_tail_layout.gd b/project/name_tail_layout.gd new file mode 100644 index 00000000..0b93a4f0 --- /dev/null +++ b/project/name_tail_layout.gd @@ -0,0 +1,150 @@ +# name_tail_layout —— §8.8 seam ⑨ 收口:头顶名字尾标「有聊天气泡时上移贴气泡」1:1。 +# CPythonTextTail::UpdateShowingTextTail 里,每条角色名字尾标投影完屏幕坐标后, +# 若该 VID 同时有一条 chat 尾标且 `pChatTail->bNameFlag`(= RegisterChatTail 注册的 +# 「聊天」气泡,非 RegisterInfoTail 的「信息」气泡),就把名字尾标的屏幕 y 顶到 +# `chatTail.y - 17.0f`(屏幕 y 向下,减 17px = 上移一行,正好贴在气泡上沿)。 +# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd / +# target_board.gd / pick_show.gd 同一套纯静态镜像套路,本模块是第 28 个 canonical。) +# +# REF(40250 ClientVS22 source/ 为唯一基线): +# UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f +# UserInterface/PythonTextTail.cpp:509 RegisterCharacterTextTail(..., float fAddHeight=10.0f) +# :519 fHeight = GetHeight() + fAddHeight —— 名字尾标世界高=角色高 + 10 +# UserInterface/PythonTextTail.cpp:581 RegisterChatTail:新建时 fHeight = GetHeight() + 10.0f、 +# :603 / :618 pTextTail->bNameFlag = TRUE —— 「聊天」气泡(白) +# UserInterface/PythonTextTail.cpp:623 RegisterInfoTail:同高, +# :646 / :661 pTextTail->bNameFlag = FALSE —— 「信息」气泡(淡红),不顶名字 +# UserInterface/PythonTextTail.cpp:88 UpdateShowingTextTail() +# :101 for (角色名字尾标 : m_CharacterTextTailList) +# :104 UpdateTextTail(pTextTail) —— 先投影出屏幕 x/y/z +# :106 itor = m_ChatTailMap.find(pTextTail->dwVirtualID) +# :110 if (m_ChatTailMap.end() != itor) +# :112 if (pChatTail->bNameFlag) +# :114 pTextTail->y = pChatTail->y - 17.0f —— 名字屏幕 y 顶到气泡上方 17px +# UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:同一个 bNameFlag 还驱动 +# ShowCharacterTextTail(dwVID) 强显(增量 112/113 已接 chat_tail.gd / name_show.gd)。 +# UserInterface/PythonTextTail.cpp:151 ArrangeTextTail() +# :211 for (pTextTail : m_CharacterTextTailList) —— 逐条角色名字尾标 +# :228 pGuildNameInstance->SetPosition(x + iImageHalfSize, pTextTail->y - c_fyGuildNamePosition, z) +# :21 const float c_fyGuildNamePosition = 15.0f —— 公会名锚在名字 y 上方 15px +# :258 pTitle->SetPosition(x - iNameWidth/2 - fxAdd, pTextTail->y, z) —— 称号与名字同一行 +# :283 pLevel->SetPosition(x - iNameWidth/2 - fxAdd - iTitleWidth, pTextTail->y, z) —— 等级同一行 +# —— 公会名 / 称号 / 等级三个子标签的屏幕 y 全部取自 `pTextTail->y`,而这个 y 已在 +# UpdateShowingTextTail :114 被聊天气泡顶过 -17。所以名字尾标一旦上移,子标签整体跟随。 +# +# seam ⑨(已收口):参考端名字与气泡默认投影到同一世界点(GetHeight()+10),靠这条屏幕 +# -17px 把名字从「和气泡重叠」顶成「贴气泡上沿」。poc 是每实体一个 billboard Label3D、 +# 气泡默认在名字上方(局部 y 2.35 vs 2.1),本模块把屏幕 y 向下的 -17px 换成世界 y +# 向上的 +17px*pixel_size:有「聊天」气泡时名字世界 y 顶到气泡 y 之上 17px,气泡消失 +# (或只是「信息」气泡)时回默认 y。像素 vs 世界的换算按名字 Label3D 的 pixel_size, +# 与参考的字体无关常量 17 对齐。 +# seam ⑦(部分收口 · 增量 119):子标签(GuildTag/TitleTag/LevelTag)随名字尾标纵向位移 +# 已 1:1(name_tail_lift_delta / subtag_world_y:名字上移多少子标签整体上移多少)。 +# seam ⑦(水平排版 · 增量 121):ArrangeTextTail 角色块(PythonTextTail.cpp:211-320)非 +# EUROPE 分支的逐像素水平排布也已 1:1(下方 name_x_shift / title_x_offset / +# level_x_offset / guild_name_x_offset / mark_x_offset / subtag_world_x): +# :236 fxAdd = 0 —— 无称号无等级 +# :242 if (pTitle) fxAdd = 8.0f +# :258 pTitle->SetPosition(x - iNameWidth/2 - fxAdd, y, z) (称号,居中对齐) +# :263 if (pLevel) —— 有称号时等级在称号左侧 +# :282 pLevel->SetPosition(x - iNameWidth/2 - fxAdd - iTitleWidth, y, z)(等级,右对齐) +# :288 else fxAdd = 4.0f —— 只有等级 +# :312 pLevel->SetPosition(x - iNameWidth/2 - fxAdd, y, z) +# :320 pTextInstance->SetPosition(x + fxAdd, y, z) —— 名字本身右移 fxAdd +# :224 iImageHalfSize = pMarkInstance->GetWidth()/2 + c_fxMarkPosition(1.5) +# :227 pMarkInstance->SetPosition(x - guildW/2 - iImageHalfSize, y - c_fyMarkPosition(26)) +# :228 pGuildNameInstance->SetPosition(x + iImageHalfSize, y - c_fyGuildNamePosition(15), z) +# —— 公会名的水平锚点只在「有公会徽记 pMarkInstance」时才被 ArrangeTextTail 改写。 +# 仍近似(seam ⑦ 遗留):① poc 无公会徽记 mark 实体 —— 参考端 :220 的 +# `if (pMarkInstance && pGuildNameInstance)` 不成立时公会名水平锚点不动,poc 亦让 +# GuildTag 留在名字中线正上方(x 偏移 0),不做 +markW/2+1.5;② 文本宽度 poc 用 +# 「字符数 * 约 7px」估,参考端是 GetTextSize 精确像素;③ 三子标签竖排 vs 参考同一 +# 水平行(poc 3D billboard 里三条挤在一行会互相糊,保留竖排换 x 偏移的折衷)。 +extends RefCounted + +# PythonTextTail.cpp:114 的 `17.0f`。屏幕坐标下是「名字 y 减 17」(上移一行贴气泡)。 +const NAME_OFFSET_ABOVE_BUBBLE_PX := 17.0 + +# PythonTextTail.cpp:21 的 `c_fyGuildNamePosition = 15.0f`:参考端公会名锚在名字尾标 +# 屏幕 y 上方 15px(称号 / 等级则与名字同一行)。poc 竖排近似不用它做像素排版,仅作 +# 1:1 记录——子标签真正跟随的是「名字尾标位移量」,见 subtag_world_y。 +const GUILD_NAME_ABOVE_NAME_PX := 15.0 + +# UpdateShowingTextTail :110 `m_ChatTailMap.end() != itor` && :112 `pChatTail->bNameFlag`。 +# has_chat_tail —— 该 VID 在 m_ChatTailMap 里有一条(聊天或信息); +# chat_name_flag —— 那条的 bNameFlag(RegisterChatTail=TRUE / RegisterInfoTail=FALSE)。 +static func name_follows_bubble(has_chat_tail: bool, chat_name_flag: bool) -> bool: + return has_chat_tail and chat_name_flag + +# 参考端字面公式(屏幕坐标,y 向下):follows 时 `pChatTail->y - 17.0f`,否则名字投影原值。 +static func name_tail_screen_y(default_y: float, bubble_y: float, follows: bool) -> float: + if follows: + return bubble_y - NAME_OFFSET_ABOVE_BUBBLE_PX + return default_y + +# poc 3D 适配(世界坐标,y 向上):屏幕的 -17px 变成 +17px*pixel_size —— follows 时名字 +# 世界 y 顶到气泡世界 y 之上 17 个名字像素,否则回默认世界 y。 +static func name_tail_world_y(default_world_y: float, bubble_world_y: float, + follows: bool, px_size: float) -> float: + if follows: + return bubble_world_y + NAME_OFFSET_ABOVE_BUBBLE_PX * px_size + return default_world_y + +# 名字尾标相对默认世界 y 的位移量(follows 时 = 顶到气泡上方后的净上移,否则 0)。 +# ArrangeTextTail 里公会名 / 称号 / 等级都锚在 pTextTail->y 上,位移量就是子标签要跟随的量。 +static func name_tail_lift_delta(default_world_y: float, bubble_world_y: float, + follows: bool, px_size: float) -> float: + return name_tail_world_y(default_world_y, bubble_world_y, follows, px_size) - default_world_y + +# ArrangeTextTail :228 / :258 / :283:子标签屏幕 y 取自 pTextTail->y(已被聊天气泡顶过)。 +# poc 里子标签各有自己的默认局部 y(竖排近似),名字尾标上移 lift_delta 时三者整体加同一段。 +static func subtag_world_y(default_subtag_y: float, name_tail_lift_delta: float) -> float: + return default_subtag_y + name_tail_lift_delta + +# --- seam ⑦ 水平排版(ArrangeTextTail 角色块,PythonTextTail.cpp:211-320 非 EUROPE 分支)--- + +# :242 `fxAdd = 8.0f`(if pTitle)。屏幕像素,名字锚点 x 与三子标签公用。 +const NAME_X_ADD_TITLE := 8.0 +# :288 `fxAdd = 4.0f`(else,只有等级)。 +const NAME_X_ADD_LEVEL_ONLY := 4.0 +# :20 `const float c_fxMarkPosition = 1.5f`。 +const MARK_X_PAD := 1.5 +# :22 `const float c_fyMarkPosition = 15.0f + 11.0f`。公会徽记锚在名字 y 上方 26px。 +const MARK_ABOVE_NAME_PX := 26.0 + +# :320 `pTextInstance->SetPosition(pTextTail->x + fxAdd, ...)`:名字本身相对锚点右移 fxAdd。 +# 有称号 -> 8;无称号只有等级 -> 4;都没有 -> 0。 +static func name_x_shift(has_title: bool, has_level: bool) -> float: + if has_title: + return NAME_X_ADD_TITLE + if has_level: + return NAME_X_ADD_LEVEL_ONLY + return 0.0 + +# :258 `pTitle->SetPosition(pTextTail->x - (iNameWidth/2) - fxAdd, ...)`(fxAdd = 8)。 +# 返回称号中心相对名字锚点 x 的像素偏移(负 = 在名字左侧)。 +static func title_x_offset(name_w: float) -> float: + return -(name_w / 2.0) - NAME_X_ADD_TITLE + +# 等级相对名字锚点 x 的像素偏移(右对齐实例,故这是其右边缘位置): +# 有称号(:282,fxAdd = 8):x - iNameWidth/2 - 8 - iTitleWidth —— 再往称号左边挪一个称号宽; +# 只有等级(:312,fxAdd = 4):x - iNameWidth/2 - 4。 +static func level_x_offset(name_w: float, title_w: float, has_title: bool) -> float: + if has_title: + return -(name_w / 2.0) - NAME_X_ADD_TITLE - title_w + return -(name_w / 2.0) - NAME_X_ADD_LEVEL_ONLY + +# :224/:228 公会名中心 = x + iImageHalfSize,iImageHalfSize = markW/2 + c_fxMarkPosition(1.5)。 +# 仅在有公会徽记时 ArrangeTextTail 才改写公会名 x;无徽记(poc 现状)应传 mark_w = 0 +# 并按「参考端此路不改写」处理(调用方直接留 0 偏移),此函数仅供有徽记时用。 +static func guild_name_x_offset(mark_w: float) -> float: + return mark_w / 2.0 + MARK_X_PAD + +# :227 徽记位置 = x - guildW/2 - iImageHalfSize(相对名字锚点 x 的像素偏移,负 = 左侧)。 +static func mark_x_offset(guild_w: float, mark_w: float) -> float: + return -(guild_w / 2.0) - (mark_w / 2.0 + MARK_X_PAD) + +# poc 3D 适配:参考端在屏幕像素空间摆位(+x 向右),poc 子标签是 billboard Label3D —— +# billboard 本地 +x 恒等于屏幕右,按该 Label3D 的 pixel_size 把像素偏移折成世界本地 x。 +static func subtag_world_x(px_offset: float, px_size: float) -> float: + return px_offset * px_size diff --git a/project/name_tail_layout_test.gd b/project/name_tail_layout_test.gd new file mode 100644 index 00000000..2e626cb2 --- /dev/null +++ b/project/name_tail_layout_test.gd @@ -0,0 +1,134 @@ +# name_tail_layout_test —— §8.8 seam ⑨:名字尾标「有聊天气泡时顶到气泡上方 17px」1:1。 +# name_follows_bubble(有 chat 尾标 && bNameFlag)、name_tail_screen_y(参考端字面 +# `chat.y - 17`)、name_tail_world_y(poc 世界坐标 `bubble_y + 17*pixel_size`)、 +# 信息气泡(bNameFlag FALSE)不顶名字、无气泡回默认。 +# 增量 119(seam ⑦ 纵向):name_tail_lift_delta / subtag_world_y —— 公会名 / 称号 / 等级 +# 子标签随名字尾标纵向位移整体平移同一段(ArrangeTextTail :228/:258/:283)。 +# 增量 121(seam ⑦ 水平):name_x_shift / title_x_offset / level_x_offset / +# guild_name_x_offset / mark_x_offset / subtag_world_x —— 逐像素水平排布 +# (ArrangeTextTail :211-320 非 EUROPE 分支:称号 / 等级在名字左侧、名字右移 fxAdd)。 +# godot --headless --path project --script name_tail_layout_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const NameTailLayout = preload("res://name_tail_layout.gd") + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _feq(a: float, b: float, m: String) -> void: + _ck(absf(a - b) < 0.0001, "%s (got %f want %f)" % [m, a, b]) + +func _init() -> void: + _run() + if _fail == 0: + print("PASS: name_tail_layout_test (§8.8 seam ⑨ 名字尾标贴气泡 1:1)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + _feq(NameTailLayout.NAME_OFFSET_ABOVE_BUBBLE_PX, 17.0, "常量 17.0(PythonTextTail.cpp:114)") + + # --- 0. name_follows_bubble(:110 有尾标 && :112 bNameFlag)------------------ + _ck(NameTailLayout.name_follows_bubble(true, true) == true, "有聊天气泡(bNameFlag TRUE) -> 顶名字") + _ck(NameTailLayout.name_follows_bubble(true, false) == false, "有信息气泡(bNameFlag FALSE) -> 不顶") + _ck(NameTailLayout.name_follows_bubble(false, true) == false, "无气泡 -> 不顶(即便 flag 传 true)") + _ck(NameTailLayout.name_follows_bubble(false, false) == false, "无气泡无 flag -> 不顶") + + # --- 1. name_tail_screen_y(参考端字面:屏幕 y 向下,follows -> bubble_y - 17)--- + _feq(NameTailLayout.name_tail_screen_y(300.0, 250.0, true), 233.0, "follows -> 气泡屏幕 y 250 - 17 = 233") + _feq(NameTailLayout.name_tail_screen_y(300.0, 250.0, false), 300.0, "!follows -> 名字投影原值 300") + # 与 name_follows_bubble 串起来:信息气泡不顶 -> 用默认 + var follows_info := NameTailLayout.name_follows_bubble(true, false) + _feq(NameTailLayout.name_tail_screen_y(300.0, 250.0, follows_info), 300.0, "信息气泡 -> 名字留默认屏幕 y") + + # --- 2. name_tail_world_y(poc:世界 y 向上,follows -> bubble_y + 17*px_size)--- + # 名字 Label3D pixel_size = 0.006 -> 17px = 0.102 世界单位;气泡局部 y = 2.35。 + _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.006), 2.35 + 17.0 * 0.006, + "follows -> 名字世界 y = 气泡 2.35 + 17*0.006") + _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.006), 2.452, "= 2.452") + _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, false, 0.006), 2.1, "!follows -> 回默认世界 y 2.1") + # 顶起来后名字确实在气泡之上(世界 y 更大) + _ck(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.006) > 2.35, "顶起后名字在气泡上方") + # 不同 pixel_size 线性缩放 + _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.012), 2.35 + 0.204, "px_size 翻倍 -> 偏移翻倍") + + # --- 3. 组合:完整判定链 -------------------------------------------------- + # 聊天气泡在,名字顶上去 + var f1 := NameTailLayout.name_follows_bubble(true, true) + _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, f1, 0.006), 2.452, "聊天气泡 -> 名字 2.452") + # 气泡过期(has=false),名字回落 + var f2 := NameTailLayout.name_follows_bubble(false, false) + _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, f2, 0.006), 2.1, "气泡过期 -> 名字回 2.1") + + # --- 4. seam ⑦ 收口(增量 119):子标签随名字尾标纵向位移 ------------------- + _feq(NameTailLayout.GUILD_NAME_ABOVE_NAME_PX, 15.0, "常量 15.0(c_fyGuildNamePosition)") + # name_tail_lift_delta:follows -> 顶起后的净上移量;否则 0。 + _feq(NameTailLayout.name_tail_lift_delta(2.1, 2.35, true, 0.006), 2.452 - 2.1, + "follows -> lift_delta = 2.452 - 2.1 = 0.352") + _feq(NameTailLayout.name_tail_lift_delta(2.1, 2.35, false, 0.006), 0.0, + "!follows -> lift_delta = 0") + _feq(NameTailLayout.name_tail_lift_delta(2.1, 2.35, true, 0.012), (2.35 + 0.204) - 2.1, + "px_size 翻倍 -> lift_delta 随之变大") + # subtag_world_y:默认局部 y 加上位移量。三个子标签整体平移同一段。 + var lift := NameTailLayout.name_tail_lift_delta(2.1, 2.35, true, 0.006) + _feq(NameTailLayout.subtag_world_y(2.34, lift), 2.34 + 0.352, "GuildTag 2.34 -> 2.692") + _feq(NameTailLayout.subtag_world_y(1.86, lift), 1.86 + 0.352, "TitleTag 1.86 -> 2.212") + _feq(NameTailLayout.subtag_world_y(1.74, lift), 1.74 + 0.352, "LevelTag 1.74 -> 2.092") + # 三者之间的相对间距不变(整体平移,不是各自缩放) + var g := NameTailLayout.subtag_world_y(2.34, lift) + var t := NameTailLayout.subtag_world_y(1.86, lift) + _feq(g - t, 2.34 - 1.86, "平移后 GuildTag/TitleTag 相对间距不变") + # 气泡过期 -> lift 0 -> 子标签回默认 + var lift0 := NameTailLayout.name_tail_lift_delta(2.1, 2.35, false, 0.006) + _feq(NameTailLayout.subtag_world_y(2.34, lift0), 2.34, "气泡过期 -> GuildTag 回默认 2.34") + _feq(NameTailLayout.subtag_world_y(1.74, lift0), 1.74, "气泡过期 -> LevelTag 回默认 1.74") + + # --- 5. seam ⑦ 水平排版(增量 121 / ArrangeTextTail :211-320 非 EUROPE 分支)-------- + _feq(NameTailLayout.NAME_X_ADD_TITLE, 8.0, "常量 8.0(:242 fxAdd when pTitle)") + _feq(NameTailLayout.NAME_X_ADD_LEVEL_ONLY, 4.0, "常量 4.0(:288 fxAdd when 仅等级)") + _feq(NameTailLayout.MARK_X_PAD, 1.5, "常量 1.5(:20 c_fxMarkPosition)") + _feq(NameTailLayout.MARK_ABOVE_NAME_PX, 26.0, "常量 26.0(:22 c_fyMarkPosition = 15 + 11)") + + # name_x_shift(:320 名字本身右移 fxAdd) + _feq(NameTailLayout.name_x_shift(true, true), 8.0, "有称号 -> 名字右移 8") + _feq(NameTailLayout.name_x_shift(true, false), 8.0, "有称号无等级 -> 名字右移 8") + _feq(NameTailLayout.name_x_shift(false, true), 4.0, "只有等级 -> 名字右移 4") + _feq(NameTailLayout.name_x_shift(false, false), 0.0, "都没有 -> 名字不移") + + # title_x_offset(:258 x - iNameWidth/2 - 8) + _feq(NameTailLayout.title_x_offset(40.0), -(40.0 / 2.0) - 8.0, "称号 x 偏移 = -nameW/2 - 8 = -28") + _ck(NameTailLayout.title_x_offset(40.0) < 0.0, "称号在名字左侧") + + # level_x_offset:有称号 -> :282 x - nameW/2 - 8 - titleW;只有等级 -> :312 x - nameW/2 - 4 + _feq(NameTailLayout.level_x_offset(40.0, 30.0, true), -(40.0 / 2.0) - 8.0 - 30.0, + "有称号:等级 x 偏移 = -nameW/2 - 8 - titleW = -58") + _feq(NameTailLayout.level_x_offset(40.0, 30.0, false), -(40.0 / 2.0) - 4.0, + "只有等级:等级 x 偏移 = -nameW/2 - 4 = -24(titleW 被忽略)") + _ck(NameTailLayout.level_x_offset(40.0, 30.0, true) < NameTailLayout.title_x_offset(40.0), + "有称号时等级比称号更靠左") + + # guild_name_x_offset / mark_x_offset(:224/:227/:228,仅有公会徽记时用) + _feq(NameTailLayout.guild_name_x_offset(20.0), 20.0 / 2.0 + 1.5, "公会名 x 偏移 = markW/2 + 1.5 = 11.5") + _feq(NameTailLayout.guild_name_x_offset(0.0), 1.5, "无徽记宽 -> 1.5(调用方实际按此路不改写处理)") + _feq(NameTailLayout.mark_x_offset(60.0, 20.0), -(60.0 / 2.0) - (20.0 / 2.0 + 1.5), + "徽记 x 偏移 = -guildW/2 - (markW/2 + 1.5) = -41.5") + + # subtag_world_x:像素偏移 * pixel_size,线性;符号保留(左负右正) + _feq(NameTailLayout.subtag_world_x(-28.0, 0.006), -28.0 * 0.006, "-28px @ 0.006 -> -0.168 世界本地 x") + _feq(NameTailLayout.subtag_world_x(8.0, 0.006), 0.048, "8px @ 0.006 -> +0.048") + _feq(NameTailLayout.subtag_world_x(-28.0, 0.012), -0.336, "pixel_size 翻倍 -> 偏移翻倍") + _feq(NameTailLayout.subtag_world_x(0.0, 0.006), 0.0, "0 偏移 -> 0") + + # 组合:名字 40px、称号 30px、等级 12px,都在 -> 三者从左到右 = 等级、称号、名字 + var name_px := 40.0 + var title_px := 30.0 + var nx := NameTailLayout.name_x_shift(true, true) # +8 + var tx := NameTailLayout.title_x_offset(name_px) # -28 + var lx := NameTailLayout.level_x_offset(name_px, title_px, true) # -58 + _ck(lx < tx and tx < nx, "水平顺序:等级(-58) < 称号(-28) < 名字(+8)") diff --git a/project/net_play.gd b/project/net_play.gd index 474c601d..53979c2a 100644 --- a/project/net_play.gd +++ b/project/net_play.gd @@ -16,6 +16,9 @@ extends Node signal target_changed(vid: int) +# game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时请求开悄悄话对话框 +# (参考端 self.interface.OpenWhisperDialog(name))。game_scene 接到 chat.start_whisper()。 +signal whisper_requested(name: String) # OnCannotAttack / OnCannotShot / OnCannotUseSkill 的字符串码(game.py 同名回调), # 供 HUD 弹 localeInfo 文案。§3.4 signal cannot_act(code: String) @@ -61,6 +64,8 @@ const CM := 100.0 # 米 -> 厘米 # §5.4 —— PVP / PK / 决斗可攻击判据(CInstanceBase::IsAttackableInstance 1:1,逐行)。 const EntityRules = preload("res://entity_rules.gd") +# §5.4 / §8.8 —— RecvTargetPacket 的目标框收包分派(判定树 + CanViewTargetHP)1:1。 +const TargetBoard = preload("res://target_board.gd") # §3.2 上行节流表(对齐 `PythonPlayerEventHandler.cpp` 的 6 个回调): # OnMove —— 首次开走 / 改向:立即发 FUNC_MOVE,并重置 moving/waiting 两个窗口 # OnMoving —— 移动中周期上行:300 ms 节流,发 FUNC_MOVE @@ -162,6 +167,12 @@ const NAME_COMBO_ATTACK_1 := 14 var _target_vid := 0 var _main_vid := 0 +# game-option「看他国玩家目标框」(constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1)。 +# 关掉时 TargetBoard.Open 前置过滤:异阵营 PC 目标框直接不开(uitarget.py:189-193)。 +var _view_other_empire_target := true +# game.py SetPCTargetBoard :807 —— app.IsPressed(app.DIK_LCONTROL)。poc 侧由 game_scene +# 的 KEY_CTRL 按下 / 松开喂 set_lcontrol_down(),点 PC 时轮询(对齐参考端 IsPressed 语义)。 +var _lcontrol_down := false var _last_sent_pos := Vector3.ZERO # 上一次上行时的玩家世界坐标 var _last_moving_sent_t := 0.0 # 上一次 OnMoving 上行时刻(秒) var _last_waiting_sent_t := 0.0 # 上一次 OnWaiting 上行时刻(秒) @@ -502,13 +513,22 @@ func _heading_deg(yaw_rad: float) -> float: func _player_yaw() -> float: return pc.player.rotation.y if pc.player else 0.0 -# CHRTYPE 分类:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP。 +# CHRTYPE 分类:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP / 5 BUILDING / +# 6 WOODEN_DOOR。门类型既可由实体 fixture 的 building / kind 给出,也可由 +# mob_proto 的 bType=4 + 13000 / 30111..30119 race 识别。 # 本 fork 的 GC_CHARACTER_ADD.bType 对 NPC/怪都是 0,按 race 查 mob_proto 补。 func _entity_kind(e: Dictionary) -> int: + if bool(e.get("building", false)): + return EntityRules.KIND_BUILDING + var explicit_kind := int(e.get("kind", -1)) + if explicit_kind >= EntityRules.KIND_BUILDING: + return explicit_kind var ct := int(e.get("ch_type", 0)) if ct != 0: return ct var race := int(e.get("race", 0)) + if EntityRules._is_wooden_door(e): + return EntityRules.KIND_WOODEN_DOOR if race >= 1 and proto and proto.has_method("mob"): var m: Dictionary = proto.mob(race) if not m.is_empty(): @@ -518,6 +538,8 @@ func _entity_kind(e: Dictionary) -> int: 1: return 1 2: return 3 3: return 4 + 4: return EntityRules.KIND_WOODEN_DOOR \ + if EntityRules._is_wooden_door(e) else EntityRules.KIND_BUILDING return 0 # 实体显示名:玩家 / NPC 走 GC_CHAR_ADD_INFO 的 name;怪没有名字包 -> 查 mob_proto。 @@ -697,16 +719,64 @@ func _on_pick(node: Node3D) -> void: return _attack_cd = 0.0 _change_target_to_picked_instance(vid) + # game.py SetPCTargetBoard :804 —— 点到 PC 时 targetBoard.Open 之后,若按住 LCONTROL + # 且同阵营(:809 IsSameEmpire)且非本人(:812 IsMainCharacterIndex)且非 building + # (:814 INSTANCE_TYPE_BUILDING)→ interface.OpenWhisperDialog(name)。C++ 侧只对 PC + # 分派 SetPCTargetBoard,故 poc 也只在 KIND_PC 分支查。建筑 / 木门由实体分类链识别, + # 不满足 PC 分支时不会误触发悄悄话。 + if _entity_kind(e) == EntityRules.KIND_PC: + var same_empire := EntityRules._is_same_empire(_main_entity(), e) + if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false): + var nm := _entity_name(e) + if nm != "": + whisper_requested.emit(nm) +# poc 版 RecvTargetPacket(PythonNetworkStreamPhaseGame.cpp:2424):C++ 侧从 EntityStore +# 抽出 (vid, hp%),这里照参考端的判定树分派目标框——目标实例缺失 -> 关框;已死透 -> 不动框; +# PC / building 目标 -> 只在框上是别的 VID 时关掉(不显血条);石头 / 敌对怪等「可看血」 +# 目标 -> SetHPTargetBoard;NPC / warp 等不可看血目标 -> 关框。经 target_board.gd 纯静态镜像。 func _on_target_info(vid: int, hp_pct: int) -> void: - if vid == 0: - _clear_target() - return - _target_vid = vid - if hud and hud.has_method("set_target"): - var e: Dictionary = client.get_entity(vid) - var nm := _entity_name(e) if not e.is_empty() else "" - hud.set_target(nm if nm != "" else "目标", hp_pct) + var e: Dictionary = client.get_entity(vid) if vid != 0 else {} + var exists := vid != 0 and not e.is_empty() + var dead := exists and bool(e.get("dead", false)) + var kind := _entity_kind(e) if exists else -1 + var is_pc := kind == EntityRules.KIND_PC + var is_building := EntityRules._is_building(e) + # uitarget.py:189-193 前置过滤:game-option 关「看他国目标框」时异阵营目标不开框。 + # empire 0 的怪/NPC/石头在 _is_same_empire 里算同阵营 → 不受影响,只挡真·他国 PC。 + if exists: + var same_empire := EntityRules._is_same_empire(_main_entity(), e) + if TargetBoard.hidden_as_other_empire(_view_other_empire_target, same_empire): + _clear_target() + return + var can_view := TargetBoard.can_view_target_hp( + kind == EntityRules.KIND_STONE, EntityRules._is_wooden_door(e), + kind == EntityRules.KIND_MONSTER) + match TargetBoard.classify(exists, dead, is_pc, is_building, can_view): + TargetBoard.Action.CLOSE: + _clear_target() + TargetBoard.Action.CLOSE_IF_DIFFERENT: + if TargetBoard.should_close_if_different(vid, _target_vid): + _clear_target() + TargetBoard.Action.SET_HP: + if TargetBoard.needs_reset(vid, _target_vid): + _target_vid = vid + target_changed.emit(vid) + if hud and hud.has_method("set_target"): + var nm := _entity_name(e) + hud.set_target(nm if nm != "" else "目标", + TargetBoard.clamp_hp_pct(hp_pct)) + TargetBoard.Action.NONE: + pass + +# game_option_ui 的「看他国玩家目标框」radio(display_option_changed "target_board")。 +# uigameoption.py → constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)。 +func set_view_other_empire_target(on: bool) -> void: + _view_other_empire_target = on + +# game_scene 的 KEY_CTRL 按下 / 松开 → app.IsPressed(app.DIK_LCONTROL) 语义(game.py:807)。 +func set_lcontrol_down(down: bool) -> void: + _lcontrol_down = down func _clear_target() -> void: if _target_vid == 0: diff --git a/project/net_world.gd b/project/net_world.gd index 141b48cb..00ee59c8 100644 --- a/project/net_world.gd +++ b/project/net_world.gd @@ -21,7 +21,12 @@ const NameColor = preload("res://name_color.gd") const TextTail = preload("res://text_tail.gd") const ChatTail = preload("res://chat_tail.gd") const NameShow = preload("res://name_show.gd") +const DamageEffect = preload("res://damage_effect.gd") const EntityRules = preload("res://entity_rules.gd") +const PickShow = preload("res://pick_show.gd") +const NameTailLayout = preload("res://name_tail_layout.gd") +const TextMetrics = preload("res://text_metrics.gd") +const ScreenHpBar = preload("res://screen_hp_bar.gd") signal entity_added(node: Node3D, vid: int) signal entity_removed(vid: int) @@ -43,9 +48,25 @@ const FUNC_MOB_SKILL := 4 const FUNC_EMOTION := 5 const FUNC_SKILL := 0x80 -# EDamageFlag -const DAMAGE_DODGE := 1 << 2 -const DAMAGE_CRITICAL := 1 << 5 +# EDamageFlag / IsShowDamage / ProcessDamage 分类见 damage_effect.gd(DamageEffect.DAMAGE_*)。 + +# §8.8 头顶名字 / 聊天气泡 billboard 的默认局部 y(世界单位)。seam ⑨(name_tail_layout.gd): +# 有「聊天」气泡时名字尾标顶到气泡上方 17px(换算按名字 Label3D 的 pixel_size)。 +const NAME_TAG_DEFAULT_Y := 2.1 +const BUBBLE_TAG_Y := 2.35 + +# §8.8 公会名 / 称号 / 等级子标签的默认局部 y(竖排近似:公会名在名字上、称号 / 等级在下)。 +# seam ⑦(name_tail_layout.gd):参考端 ArrangeTextTail 把它们锚在名字尾标 pTextTail->y 上, +# 名字因聊天气泡上移时三者整体跟随——_reposition_name_tag 按 name_tail_lift_delta 补这段位移。 +const SUBTAG_DEFAULT_Y := {"GuildTag": 2.34, "TitleTag": 1.86, "LevelTag": 1.74} +# seam ⑦ 水平排版(增量 121):ArrangeTextTail 按 GetTextSize 精确像素排称号 / 等级, +# poc 无字体度量 -> 按「字符数 * 约 7px」估文本宽(与 ground_items 的 TAG_CHAR_W_PX 同源)。 +const SUBTAG_CHAR_W_PX := 7.0 + +# §8.8:参考端的实体 HP 条属于屏幕尾标,而不是 3D billboard。条的锚点 +# 保持在原占位条高度附近;实际像素位置由 Camera3D.unproject_position() 计算。 +const HP_BAR_WORLD_Y := 1.95 +const HP_BAR_SCREEN_MARGIN := 32.0 # CLIENT-GAP §2.1 two-layer model. The data layer (M2Client / EntityStore) always # holds every known entity; a scene node under `parent` exists only while the @@ -88,11 +109,22 @@ var _fading := {} # vid:int -> true while a culled node twee # §8.4 / §8.6 头顶名字「是否显示」(name_show.gd)。game.py::__IsShowName() 的两个来源: # _always_show_name —— uigameoption「总显示名字」radio(CPythonSystem::IsAlwaysShowName) # _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag) -# 另加 forced 路径:_target_vid(targetBoard.GetTargetVID())+ chat 尾标 bNameFlag。 +# 另加 forced 路径:_target_vid(targetBoard.GetTargetVID())+ chat 尾标 bNameFlag + +# _hover_vid(game.py OnRender chr.Pick() -> ShowCharacterTextTail,见 pick_show.gd)。 var _always_show_name := NameShow.ALWAYS_SHOW_NAME_DEFAULT var _alt_held := false var _target_vid := 0 +var _hover_vid := 0 # player_controller._update_hover_cursor 射线拾到的角色 vid(0 = 无) +# §8.8 / §5.4 伤害飘字(damage_effect.gd)。CPythonSystem::IsShowDamage 门(uigameoption +# 「伤害数字」radio)+ CInstanceBase::m_DamageQueue(每帧 pop 一条)+ ProcessDamage 分类。 +var _show_damage := DamageEffect.SHOW_DAMAGE_DEFAULT +var _damage_queue: Array = [] # m_DamageQueue:{vid, amount, flag, self, target} +var _dmg_self_toggle := false # m_bDamageEffectType(SELFDAMAGE / SELFDAMAGE2 交替) var _fly_mgr: Node # FlyManager(fly_object.gd)—— GC_CREATE_FLY 的真实弹道 + 命中判定。§3.6 +var _hp_layer: CanvasLayer # 屏幕空间尾标层(低于 HUD / UiManager) +var _hp_overlay: Control +var _hp_bars := {} # vid:int -> ScreenHpBar +var _camera: Camera3D # Fishing surface provider bridge. NetWorld owns entity presentation while # Metin2World owns map data; keep the ClientVS22 GetFishingRot query on the @@ -140,12 +172,28 @@ func setup(m2client: Node, mount: Node3D) -> void: client.pvp_changed.connect(_on_pvp_changed) if client.has_signal("duel_changed"): client.duel_changed.connect(_on_duel_changed) + # Guild marks are downloaded by AppFlow on the side connection. The mark + # store is independent from the entity stream, so a character can already + # be visible when its 16x12 image arrives; refresh the affected text tails + # on both the initial completion and an incremental GC_MARK_UPDATE. + if client.has_signal("guild_marks_ready"): + client.guild_marks_ready.connect(func(_count: int): _retint_all_text_tails()) + if client.has_signal("guild_mark_updated"): + client.guild_mark_updated.connect(func(guild_id: int, _img_idx: int): _refresh_guild_mark(guild_id)) # §3.6 飞行物实体:GC_CREATE_FLY 之后由 FlyManager 跑真实弹道 + 线段命中判定。 _fly_mgr = FlyObject.new() _fly_mgr.name = "FlyManager" add_child(_fly_mgr) _fly_mgr.setup(parent, world) _fly_mgr.exploded.connect(func(wp: Vector3, cause: String): fly_exploded.emit(wp, cause)) + _ensure_hp_overlay() + +# GameScene normally calls this after creating the orbit camera. Keeping the +# setter explicit makes camera hand-off / map reload deterministic and leaves +# headless tests independent of the active viewport camera. +func set_camera(camera: Camera3D) -> void: + _camera = camera + _update_hp_bars() # 已在局内(重连 / setup 是协程,进来时 spawn burst 已被 pump 抽干)—— # 把当前所有实体补建一遍,别漏掉进游戏那一批怪 / NPC / 玩家。 @@ -173,6 +221,7 @@ func set_local_vid(vid: int) -> void: if n: _by_vid.erase(vid) n.queue_free() + _remove_hp_bar(vid) func set_local_node(node: Node3D) -> void: _local_node = node @@ -192,6 +241,10 @@ func set_always_show_name(on: bool) -> void: _always_show_name = on _refresh_all_name_visibility() +# uigameoption「伤害数字」radio -> CPythonSystem::SetShowDamageFlag(AddDamageEffect 的入队门)。 +func set_show_damage(on: bool) -> void: + _show_damage = on + # targetBoard.GetTargetVID():目标始终强显名字(game.py OnRender ShowCharacterTextTail(targetVID))。 func set_target_vid(vid: int) -> void: if _target_vid == vid: @@ -202,6 +255,18 @@ func set_target_vid(vid: int) -> void: if v != 0 and _by_vid.has(v): _set_name_visibility(_by_vid[v]) +# game.py OnRender:wndMgr.IsPickedWindow 门内 chr.Pick() 命中的角色 -> ShowCharacterTextTail +# (强显名字,绕过 __IsShowName())。poc 由 player_controller 的射线悬停拾取喂入。 +# 见 pick_show.gd(第 27 个 canonical)与增量 113 的 _target_vid 同路数。 +func set_hover_vid(vid: int) -> void: + if _hover_vid == vid: + return + var prev := _hover_vid + _hover_vid = vid + for v in [prev, vid]: + if v != 0 and _by_vid.has(v): + _set_name_visibility(_by_vid[v]) + func _refresh_all_name_visibility() -> void: for node in _by_vid.values(): _set_name_visibility(node) @@ -215,7 +280,7 @@ func _main_planar_cm() -> Vector2: return Vector2(mc.x, mc.y) return Vector2(INF, INF) -# name_show.name_visible_for 的 net_world 侧组装:forced(target / chat bNameFlag)+ +# name_show.name_visible_for 的 net_world 侧组装:forced(target / hover / chat bNameFlag)+ # __IsShowName()(_always_show_name or _alt_held)+ 3500 平面距 + isShow/wall/CanPick 资格。 func _name_shown(root: Node3D) -> bool: if root == null or not is_instance_valid(root): @@ -228,7 +293,9 @@ func _name_shown(root: Node3D) -> bool: var is_wall := _is_wall(int(e.get("race", 0))) # CanPickInstance() 更细(隐身 / 特定 affect 亦不可拾取)—— 这里只按死透近似,记 seam ⑩。 var alive := not dead - var forced := vid != 0 and vid == _target_vid + # game.py OnRender:targetBoard.GetTargetVID()(增量 113)+ chr.Pick() 悬停命中 + # (_hover_vid,增量 116 / pick_show.gd)都走 ShowCharacterTextTail 强显。 + var forced := vid != 0 and (vid == _target_vid or vid == _hover_vid) var ct: Variant = _chat_tails.get(vid, null) if ct is Dictionary and bool(ct.get("name_flag", false)): forced = true @@ -253,6 +320,10 @@ func _set_name_visibility(root: Node3D) -> void: var st := root.get_node_or_null(sub) as Label3D if st: st.visible = shown and st.text != "" + var mark := root.get_node_or_null("GuildMark") as Sprite3D + if mark: + var guild_tag := root.get_node_or_null("GuildTag") as Label3D + mark.visible = shown and guild_tag != null and guild_tag.text != "" # ClientVS22 destroys the network actor manager when Loading starts. This is # called from M2Client.world_reset before the replacement connection sends its @@ -264,7 +335,9 @@ func clear_for_map_change() -> void: node.queue_free() entity_removed.emit(int(vid)) _by_vid.clear() + _clear_hp_bars() _fading.clear() + _damage_queue.clear() _main_vid = 0 _pvp_relations.clear() _duel_opponents.clear() @@ -325,6 +398,7 @@ func _on_spawn(d: Dictionary) -> void: var ch_type := int(d.get("ch_type", 0)) node.set_meta("cursor_shape", "TALK" if ch_type in [1, 4] else "ATTACK") _by_vid[vid] = node + _sync_hp_bar(vid, d) _set_name_visibility(node) _refresh_shop_sign(node, str(d.get("shop_sign", ""))) _refresh_pvp_tag(vid) @@ -340,6 +414,7 @@ func _on_info(vid: int, d: Dictionary) -> void: var n: Node3D = _by_vid.get(vid, null) if n == null: return + _sync_hp_bar(vid, d) var nm := _display_name(d) if nm != "": if n.has_node("Label3D"): @@ -628,6 +703,90 @@ func _apply_text_tail_to(n: Node3D, e: Dictionary) -> void: ltag.modulate = lv["color"] ltag.visible = shown and ltag.text != "" + _apply_guild_mark(n, guild_id, gtag, shown) + _layout_subtags_x(n, ttag, ltag) + # Keep the mark on the same adjusted screen row as the name immediately on + # spawn/info refresh, not only after the next chat-tail update. + _reposition_name_tag(n) + +# ArrangeTextTail 角色块(PythonTextTail.cpp:211-320 非 EUROPE 分支)的逐像素水平排布: +# 称号在名字锚点左侧 nameW/2 + 8、等级再往左一个称号宽(无称号时 nameW/2 + 4)、 +# 名字本身右移 fxAdd(8 / 4 / 0)。公会名的水平锚点参考端只在「有公会徽记」时才改写, +# poc 无徽记 -> GuildTag 留 x = 0(名字中线正上方)。文本宽度取各 Label3D 的真实字体度量 +# (TextMetrics.label_width = GetTextSize 1:1,增量 123;SUBTAG_CHAR_W_PX 仅作无字体兜底)。 +# 经 name_tail_layout.gd 纯静态镜像。 +func _layout_subtags_x(n: Node3D, ttag: Label3D, ltag: Label3D) -> void: + var nlbl := n.get_node_or_null("Label3D") as Label3D + var gtag := n.get_node_or_null("GuildTag") as Label3D + var mark := n.get_node_or_null("GuildMark") as Sprite3D + var has_title := ttag != null and ttag.text != "" + var has_level := ltag != null and ltag.text != "" + var name_w := 0.0 + if nlbl: + name_w = maxf(1.0, TextMetrics.label_width(nlbl, SUBTAG_CHAR_W_PX)) + var title_w := 0.0 + if has_title: + title_w = maxf(1.0, TextMetrics.label_width(ttag, SUBTAG_CHAR_W_PX)) + if nlbl: + nlbl.position.x = NameTailLayout.subtag_world_x( + NameTailLayout.name_x_shift(has_title, has_level), nlbl.pixel_size) + if ttag: + ttag.position.x = NameTailLayout.subtag_world_x( + NameTailLayout.title_x_offset(name_w), ttag.pixel_size) if has_title else 0.0 + if ltag: + ltag.position.x = NameTailLayout.subtag_world_x( + NameTailLayout.level_x_offset(name_w, title_w, has_title), ltag.pixel_size) if has_level else 0.0 + if gtag: + if mark != null and mark.texture != null and gtag.text != "": + var guild_w := maxf(1.0, TextMetrics.label_width(gtag, SUBTAG_CHAR_W_PX)) + var mark_w := maxf(1.0, float(mark.texture.get_width())) + gtag.position.x = NameTailLayout.subtag_world_x( + NameTailLayout.guild_name_x_offset(mark_w), gtag.pixel_size) + mark.position.x = NameTailLayout.subtag_world_x( + NameTailLayout.mark_x_offset(guild_w, mark_w), mark.pixel_size) + else: + gtag.position.x = 0.0 # 无已下载会徽时保持参考端 if(pMark && pGuild) 不成立 + if mark: + mark.position.x = 0.0 + +func _apply_guild_mark(n: Node3D, guild_id: int, guild_tag: Label3D, shown: bool) -> void: + # CPythonTextTail only creates pMarkInstance when a guild id resolves to a + # downloaded mark. Do not leave a stale image after a guild change or a + # mark-table refresh that no longer contains this guild. + var mark := n.get_node_or_null("GuildMark") as Sprite3D + if guild_id == 0 or guild_tag == null or guild_tag.text == "" \ + or client == null or not client.has_method("get_guild_mark_image"): + if mark: + mark.queue_free() + return + var img: Image = client.get_guild_mark_image(guild_id) + if img == null or img.get_width() <= 0 or img.get_height() <= 0: + if mark: + mark.queue_free() + return + if mark == null: + mark = Sprite3D.new() + mark.name = "GuildMark" + mark.billboard = BaseMaterial3D.BILLBOARD_ENABLED + mark.no_depth_test = true + mark.pixel_size = 0.005 + mark.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + n.add_child(mark) + mark.texture = ImageTexture.create_from_image(img) + mark.visible = shown + mark.set_meta("guild_id", guild_id) + +func _refresh_guild_mark(guild_id: int) -> void: + if guild_id == 0 or client == null or not client.has_method("get_entities"): + return + for vid in _by_vid.keys(): + var n: Node3D = _by_vid[vid] + if not is_instance_valid(n): + continue + var e: Dictionary = client.get_entity(int(vid)) + if int(e.get("guild", 0)) == guild_id: + _apply_text_tail_to(n, e) + func _apply_text_tail(vid: int) -> void: if vid == 0 or client == null or not client.has_method("get_entity"): return @@ -660,6 +819,7 @@ func _on_despawn(vid: int) -> void: _duel_opponents.erase(vid) _fading.erase(vid) _chat_tails.erase(vid) + _remove_hp_bar(vid) var n: Node3D = _by_vid.get(vid, null) if n: _by_vid.erase(vid) @@ -822,7 +982,7 @@ func _apply_chat_tail(n: Node3D, tail: Dictionary) -> void: if lbl == null: lbl = Label3D.new() lbl.name = "bubble" - lbl.position.y = 2.35 + lbl.position.y = BUBBLE_TAG_Y lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED lbl.no_depth_test = true lbl.pixel_size = 0.0055 @@ -832,6 +992,8 @@ func _apply_chat_tail(n: Node3D, tail: Dictionary) -> void: lbl.modulate = tail.get("color", ChatTail.CHAT_COLOR) # UpdateAllTextTail:chat 尾标 bNameFlag TRUE 时强制显示该角色名字(不受 3500 限制)。 _set_name_visibility(n) + # UpdateShowingTextTail :110-114:有「聊天」气泡(bNameFlag)时名字尾标顶到气泡上方 17px。 + _reposition_name_tag(n) func _expire_chat_tails() -> void: # UpdateAllTextTail:LivingTime < now 即删除对应 "bubble" 节点 @@ -843,6 +1005,40 @@ func _expire_chat_tails() -> void: lbl.queue_free() # 尾标没了 -> bNameFlag 强显撤销,名字回到 __IsShowName / 距离裁决。 _set_name_visibility(n) + # 气泡没了 -> 名字尾标从「贴气泡上方」回默认局部 y(chat 尾标不在 + # m_ChatTailMap 里,UpdateShowingTextTail :110 的 find 落空)。 + _reposition_name_tag(n) + +# CPythonTextTail::UpdateShowingTextTail :101-118 —— 角色名字尾标投影后,若该 VID 有一条 +# 「聊天」气泡(m_ChatTailMap 命中且 bNameFlag TRUE,即 RegisterChatTail 而非 +# RegisterInfoTail),把名字尾标顶到气泡上方 17px(屏幕 y - 17 → poc 世界 y + 17*pixel_size)。 +# 经 name_tail_layout.gd 纯静态镜像。 +func _reposition_name_tag(n: Node3D) -> void: + if n == null or not is_instance_valid(n): + return + var tag := n.get_node_or_null("Label3D") as Label3D + if tag == null: + return + var vid := int(n.get_meta("vid", 0)) + var ct: Variant = _chat_tails.get(vid, null) + var has_tail := ct is Dictionary + var name_flag := has_tail and bool((ct as Dictionary).get("name_flag", false)) + var follows := NameTailLayout.name_follows_bubble(has_tail, name_flag) + tag.position.y = NameTailLayout.name_tail_world_y( + NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size) + # ArrangeTextTail :228/:258/:283 —— 公会名 / 称号 / 等级子标签都锚在名字尾标 pTextTail->y + # 上,名字因聊天气泡上移时三者整体跟随同一段位移(seam ⑦ 仅剩竖排像素排布近似)。 + var lift := NameTailLayout.name_tail_lift_delta( + NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size) + for sub_name in SUBTAG_DEFAULT_Y: + var st := n.get_node_or_null(sub_name) as Label3D + if st: + st.position.y = NameTailLayout.subtag_world_y(SUBTAG_DEFAULT_Y[sub_name], lift) + var mark := n.get_node_or_null("GuildMark") as Sprite3D + if mark: + # ArrangeTextTail :227 puts the mark 26 px above the name instance; it + # follows the same already-adjusted name y when a chat tail is active. + mark.position.y = NAME_TAG_DEFAULT_Y + NameTailLayout.MARK_ABOVE_NAME_PX * tag.pixel_size + lift func _on_vitals(vid: int) -> void: var n: Node3D = _by_vid.get(vid, null) @@ -853,10 +1049,7 @@ func _on_vitals(vid: int) -> void: var max_hp := int(e.get("max_hp", 0)) var dead := bool(e.get("dead", false)) vitals.emit(vid, hp, max_hp, dead) - if n and n.has_node("HpBar") and max_hp > 0: - var bar := n.get_node("HpBar") as Sprite3D - bar.scale.x = clampf(float(hp) / float(max_hp), 0.0, 1.0) - bar.modulate = Color(0.9, 0.2, 0.2) if not dead else Color(0.3, 0.3, 0.3) + _sync_hp_bar_values(vid, hp, max_hp, dead) func _on_dead(vid: int) -> void: var n: Node3D = _by_vid.get(vid, null) @@ -984,22 +1177,54 @@ func _on_fishing_event(subheader: int, info: int, dir: int) -> void: n.call("set_anim_state", state) fishing_event.emit(info, subheader, dir) +# GC_DAMAGE_INFO -> RecvDamageInfoPacket -> CInstanceBase::AddDamageEffect。 +# damage_number 信号是本移植扩展(战斗日志 / 测试消费),始终发; +# 飘字本体照 AddDamageEffect:仅 IsShowDamage() 时入 m_DamageQueue,ProcessDamage 每帧 pop 一条。 func _on_damage(vid: int, amount: int, flag: int) -> void: - var crit := (flag & DAMAGE_CRITICAL) != 0 - var dodge := (flag & DAMAGE_DODGE) != 0 + var crit := (flag & DamageEffect.DAMAGE_CRITICAL) != 0 + var dodge := (flag & DamageEffect.DAMAGE_DODGE) != 0 damage_number.emit(vid, amount, crit, dodge) - var n: Node3D = _by_vid.get(vid, null) - if n == null or parent == null: + if not DamageEffect.valid_amount(amount): + return # RecvDamageInfoPacket:damage < 0 -> TraceError,不处理 + if not DamageEffect.should_queue(_show_damage): + return # AddDamageEffect:!IsShowDamage() -> 不入队 + _damage_queue.append({ + "vid": vid, "amount": amount, "flag": flag, + "self": DamageEffect.is_self(vid, _main_vid), + "target": DamageEffect.is_target(vid, _target_vid), + }) + +# ProcessDamage:每帧 pop_front 一条。DODGE|BLOCK -> "MISS";nontarget 数字 -> 不渲染; +# 其余把 amount 铺一块 billboard Label3D(seam ⑪:无 damagevalue dds 图集 / 逐位特效)。 +func _process_damage_queue() -> void: + if _damage_queue.is_empty(): return + var d: Dictionary = _damage_queue.pop_front() + var n: Node3D = _by_vid.get(int(d["vid"]), null) + if n == null or not is_instance_valid(n) or parent == null: + return + var info := DamageEffect.classify(int(d["flag"]), bool(d["self"]), bool(d["target"])) + if not info["show"]: + return # ProcessDamage:nontarget_ 后直接 return(不渲染) + var text := "MISS" + if info["kind"] == DamageEffect.Kind.DIGITS: + if DamageEffect.digits(int(d["amount"])).is_empty(): + return # while(damage>0) 零次 -> 无贴图 + text = str(int(d["amount"])) + # self 数字:SELFDAMAGE / SELFDAMAGE2 交替(seam ⑪:退化成落字水平抖动符号交替)。 + var jitter := 0.0 + if bool(d["self"]) and info["kind"] == DamageEffect.Kind.DIGITS: + jitter = 0.3 if DamageEffect.self_damage_slot(_dmg_self_toggle) == 0 else -0.3 + _dmg_self_toggle = not _dmg_self_toggle var lbl := Label3D.new() - lbl.text = "MISS" if dodge else str(amount) - lbl.modulate = Color(1.0, 0.85, 0.2) if crit else Color(1, 1, 1) - lbl.font_size = 48 if crit else 32 + lbl.text = text + lbl.modulate = Color(1, 1, 1) # 该 build 无独立暴击 / self 颜色(ProcessDamage) + lbl.font_size = 32 lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED lbl.no_depth_test = true lbl.pixel_size = 0.007 parent.add_child(lbl) - lbl.global_position = n.global_position + Vector3(randf_range(-0.3, 0.3), 1.9, 0) + lbl.global_position = n.global_position + Vector3(jitter, 1.9, 0) var tw := create_tween() tw.set_parallel(true) tw.tween_property(lbl, "global_position:y", lbl.global_position.y + 1.2, 0.8) @@ -1010,6 +1235,7 @@ func _process(dt: float) -> void: if client == null or parent == null: return _update_visibility() + _process_damage_queue() # §8.8:m_DamageQueue 每帧 pop 一条(CInstanceBase::ProcessDamage) # §8.8:名字色随队伍 / 帝国名色模式 / PVP·决斗边沿变化——用 ~2Hz 节流整表刷新 # 兜底(单发路径已覆盖 spawn/info/pvp/duel/main_set;参考端每帧都算 GetNameColor)。 _name_color_accum += dt @@ -1047,6 +1273,7 @@ func _process(dt: float) -> void: n.set_meta("moving", moving) n.set_meta("walk_mode", walk_mode) _apply_anim(n, f, moving, walk_mode) + _update_hp_bars() func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void: var state := "wait" @@ -1086,13 +1313,14 @@ func _make_placeholder(d: Dictionary) -> Node3D: _attach_nameplate(root, d) return root -# 头顶名字 Label3D + HP 条 Sprite3D(占位胶囊和真模型共用)。 +# 头顶名字 Label3D + 屏幕空间 HP 条(占位胶囊和真模型共用)。HP 条由 +# _ensure_hp_overlay() 创建到 CanvasLayer,_update_hp_bars() 负责世界坐标投影。 func _attach_nameplate(root: Node3D, d: Dictionary) -> void: var tag := Label3D.new() tag.name = "Label3D" var nm := _display_name(d) tag.text = nm if nm != "" else "vid %d" % int(d.get("vid", 0)) - tag.position.y = 2.1 + tag.position.y = NAME_TAG_DEFAULT_Y tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED tag.no_depth_test = true tag.pixel_size = 0.006 @@ -1103,19 +1331,9 @@ func _attach_nameplate(root: Node3D, d: Dictionary) -> void: # c_fyGuildNamePosition(15px)、称号 / 等级贴右侧。这里在 billboard 3D 里近似成竖排 # (公会名上、称号 / 等级下),精确像素排版差异记为 §8.8 seam ⑦。内容 / 颜色由 # _apply_text_tail 按 RefreshTextTail / AttachTitle / AttachLevel 决定,初始空 = 隐藏。 - root.add_child(_mk_sub_tag("GuildTag", 2.34, TextTail.GUILD_NAME_COLOR)) - root.add_child(_mk_sub_tag("TitleTag", 1.86, Color(1, 1, 1))) - root.add_child(_mk_sub_tag("LevelTag", 1.74, TextTail.LEVEL_COLOR)) - var bar := Sprite3D.new() - bar.name = "HpBar" - bar.texture = _white_1px() - bar.pixel_size = 0.01 # 100px -> 1m - bar.position.y = 1.95 - bar.billboard = BaseMaterial3D.BILLBOARD_ENABLED - bar.no_depth_test = true - bar.modulate = Color(0.9, 0.2, 0.2) - bar.scale.x = 1.0 - root.add_child(bar) + root.add_child(_mk_sub_tag("GuildTag", SUBTAG_DEFAULT_Y["GuildTag"], TextTail.GUILD_NAME_COLOR)) + root.add_child(_mk_sub_tag("TitleTag", SUBTAG_DEFAULT_Y["TitleTag"], Color(1, 1, 1))) + root.add_child(_mk_sub_tag("LevelTag", SUBTAG_DEFAULT_Y["LevelTag"], TextTail.LEVEL_COLOR)) func _mk_sub_tag(sub_name: String, y: float, col: Color) -> Label3D: var t := Label3D.new() @@ -1129,10 +1347,93 @@ func _mk_sub_tag(sub_name: String, y: float, col: Color) -> Label3D: t.visible = false return t -static var _white_tex: Texture2D -static func _white_1px() -> Texture2D: - if _white_tex == null: - var img := Image.create(100, 6, false, Image.FORMAT_RGBA8) - img.fill(Color.WHITE) - _white_tex = ImageTexture.create_from_image(img) - return _white_tex +func _ensure_hp_overlay() -> void: + if _hp_layer != null and is_instance_valid(_hp_layer): + return + _hp_layer = CanvasLayer.new() + _hp_layer.name = "ScreenHpLayer" + _hp_layer.layer = 5 + add_child(_hp_layer) + _hp_overlay = Control.new() + _hp_overlay.name = "HpOverlay" + _hp_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) + _hp_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE + _hp_layer.add_child(_hp_overlay) + +func _make_hp_bar(vid: int, e: Dictionary) -> Control: + _ensure_hp_overlay() + var bar := _hp_bars.get(vid, null) as Control + if bar == null or not is_instance_valid(bar): + bar = ScreenHpBar.new() + bar.name = "hp_%d" % vid + _hp_overlay.add_child(bar) + _hp_bars[vid] = bar + _sync_hp_bar_values(vid, int(e.get("hp", 0)), int(e.get("max_hp", 0)), + bool(e.get("dead", false))) + return bar + +func _sync_hp_bar(vid: int, e: Dictionary) -> void: + if vid == 0 or e.is_empty(): + return + _make_hp_bar(vid, e) + _sync_hp_bar_values(vid, int(e.get("hp", 0)), int(e.get("max_hp", 0)), + bool(e.get("dead", false))) + +func _sync_hp_bar_values(vid: int, hp: int, max_hp: int, dead: bool) -> void: + var bar := _hp_bars.get(vid, null) as Control + if bar == null or not is_instance_valid(bar): + return + if bar.has_method("set_vitals"): + bar.call("set_vitals", hp, max_hp, dead) + bar.visible = max_hp > 0 and not dead + +func _remove_hp_bar(vid: int) -> void: + var bar := _hp_bars.get(vid, null) as Control + _hp_bars.erase(vid) + if bar != null and is_instance_valid(bar): + bar.queue_free() + +func _clear_hp_bars() -> void: + for vid in _hp_bars.keys(): + var bar := _hp_bars[vid] as Control + if bar != null and is_instance_valid(bar): + bar.queue_free() + _hp_bars.clear() + +func _update_hp_bars() -> void: + if _hp_bars.is_empty(): + return + var cam := _camera + if cam == null or not is_instance_valid(cam): + cam = get_viewport().get_camera_3d() + var viewport_size := get_viewport().get_visible_rect().size + if cam == null or viewport_size.x <= 0.0 or viewport_size.y <= 0.0: + for bar in _hp_bars.values(): + if is_instance_valid(bar): + bar.visible = false + return + var viewport_rect := Rect2(Vector2.ZERO, viewport_size).grow(HP_BAR_SCREEN_MARGIN) + for vid in _hp_bars.keys(): + var bar := _hp_bars[vid] as Control + var node := _by_vid.get(vid, null) as Node3D + if bar == null or not is_instance_valid(bar) or node == null \ + or not is_instance_valid(node): + if bar != null and is_instance_valid(bar): + bar.visible = false + continue + var e: Dictionary = client.get_entity(int(vid)) + var max_hp := int(e.get("max_hp", 0)) + var dead := bool(e.get("dead", false)) + if max_hp <= 0 or dead: + bar.visible = false + continue + var anchor := node.global_position + Vector3(0.0, HP_BAR_WORLD_Y, 0.0) + if cam.is_position_behind(anchor): + bar.visible = false + continue + var screen_pos := cam.unproject_position(anchor) + if not viewport_rect.has_point(screen_pos): + bar.visible = false + continue + bar.position = screen_pos - Vector2(bar.size.x * 0.5, 0.0) + bar.visible = true diff --git a/project/netbridge_test.gd b/project/netbridge_test.gd index bba235ee..56a4529d 100644 --- a/project/netbridge_test.gd +++ b/project/netbridge_test.gd @@ -245,8 +245,9 @@ func _test_netbridge() -> void: await process_frame _ck(dmg[0] == 40, "damage_number relayed (40)") _ck(vit[0] == 60 and vit[1] == 100 and not vit[2], "vitals relayed (60/100)") - var bar := mob.get_node_or_null("HpBar") - _ck(bar != null and absf(bar.scale.x - 0.6) < 0.01, "HP bar scaled to 0.6") + var bar := nw._hp_bars.get(2000, null) as Control + _ck(bar != null and absf(float(bar.ratio) - 0.6) < 0.01, + "screen-space HP bar ratio updated to 0.6") fc.hurt(2000, 0, 60, 1 << 5) # crit, lethal await process_frame _ck(dmg[1] == true, "crit flag relayed") diff --git a/project/netplay_test.gd b/project/netplay_test.gd index 80eab135..4f6e1a3e 100644 --- a/project/netplay_test.gd +++ b/project/netplay_test.gd @@ -6,6 +6,7 @@ extends SceneTree const NetPlay = preload("res://net_play.gd") const NetWorld = preload("res://net_world.gd") +const EntityRules = preload("res://entity_rules.gd") class FakeClient extends Node: signal entity_spawned(entity: Dictionary) @@ -176,6 +177,11 @@ func _run() -> void: fc.ents[3000] = {"vid": 3000, "ch_type": 1, "name": "Cube NPC", "pos": npc.position} pc.target_selected.emit(npc) _ck(fc.calls.click_npc == [3000], "NPC selection -> click_npc(3000)") + # §5.4 / §8.8:木门不应退化为 PC;它可进入 CanViewTargetHP / 攻击分类。 + _ck(np._entity_kind({"vid": 3001, "race": 13000}) == EntityRules.KIND_WOODEN_DOOR, + "race 13000 -> WOODEN_DOOR") + _ck(np._entity_kind({"vid": 3002, "kind": EntityRules.KIND_BUILDING}) == EntityRules.KIND_BUILDING, + "explicit BUILDING kind -> BUILDING") # 5) 只有按住 SPACE(参考端 m_isAtkKey)才在攻击距离内 attack np.set_attack_key(true) @@ -381,6 +387,76 @@ func _run() -> void: fc.target_info.emit(2000, 40) _ck(hud.target.size() == 2 and hud.target[1] == 40, "hud.set_target(pct=40)") + # 8b) §8.4「看他国玩家目标框」前置过滤(uitarget.py:189-193 / target_board.hidden_as_other_empire) + fc.ents[1000]["empire"] = 1 + fc.ents[2500] = {"vid": 2500, "ch_type": 0, "name": "Enemy", "empire": 2, + "pos": Vector3(5, 0, 5), "hp": 100, "max_hp": 100, "dead": false} + # 开关开(默认 true):异阵营 PC 目标框照旧走 CLOSE_IF_DIFFERENT,框上正是该 VID -> 保留 + np._target_vid = 2500 + fc.target_info.emit(2500, 60) + _ck(np._target_vid == 2500, "看他国框开 -> 异阵营 PC 目标框保留") + # 开关关:异阵营 PC -> 前置过滤直接清目标框 + var cleared_n: int = hud.cleared + np.set_view_other_empire_target(false) + np._target_vid = 2500 + fc.target_info.emit(2500, 60) + _ck(np._target_vid == 0 and hud.cleared == cleared_n + 1, "看他国框关 -> 异阵营 PC 目标框被前置过滤清掉") + # 开关关不影响怪(empire 0 -> IsSameEmpire 为真) + np._target_vid = 0 + fc.target_info.emit(2000, 30) + _ck(hud.target[1] == 30 and np._target_vid == 2000, "看他国框关不影响怪目标(HP 血条照设)") + np.set_view_other_empire_target(true) + fc.ents[1000].erase("empire") + fc.ents.erase(2500) + # 收尾:_target_vid 落在 2000(与原 8 一致),供 9) 目标死亡清框 + + # 8c) seam ⑫ 收口 —— game.py SetPCTargetBoard :804:targetBoard.Open 之后,若按住 + # LCONTROL(:807)且同阵营(:809 IsSameEmpire)且非本人(:812)且非 building + # (:814)-> interface.OpenWhisperDialog(name)。poc 侧 net_play 发 whisper_requested, + # game_scene 接到 chat.start_whisper()。C++ 只对 PC 分派 SetPCTargetBoard。 + var whispers: Array = [] + np.whisper_requested.connect(func(nm: String): whispers.append(nm)) + fc.ents[1000]["empire"] = 1 + var bob := Node3D.new() + bob.set_meta("vid", 6100) + bob.position = Vector3(4, 0, -5) + root.add_child(bob) + nw._by_vid[6100] = bob + fc.ents[6100] = {"vid": 6100, "ch_type": 0, "name": "Bob", "empire": 1, + "pos": bob.position, "hp": 100, "max_hp": 100, "dead": false} + # LCONTROL 没按住 -> 只普通选中,不开悄悄话 + np._target_vid = 0 + np.set_lcontrol_down(false) + pc.target_selected.emit(bob) + _ck(whispers.is_empty(), "无 LCONTROL 点 PC -> 不开悄悄话") + _ck(fc.calls.set_target.back() == 6100, "点 PC 仍照常选中(set_target 6100)") + # 按住 LCONTROL + 同阵营他人 PC -> whisper_requested("Bob") + np.set_lcontrol_down(true) + pc.target_selected.emit(bob) + _ck(whispers == ["Bob"], "LCONTROL 点同阵营他人 PC -> whisper_requested('Bob')") + # 异阵营 PC -> 不请求(:809 IsSameEmpire) + whispers.clear() + fc.ents[6100]["empire"] = 2 + pc.target_selected.emit(bob) + _ck(whispers.is_empty(), "LCONTROL 点异阵营 PC -> 不开悄悄话") + fc.ents[6100]["empire"] = 1 + # 怪(KIND_MONSTER)即使按住 LCONTROL 也不走(C++ 只对 PC 分派 SetPCTargetBoard) + whispers.clear() + var wolf2 := Node3D.new() + wolf2.set_meta("vid", 6200) + wolf2.position = Vector3(4, 0, -5) + root.add_child(wolf2) + nw._by_vid[6200] = wolf2 + fc.ents[6200] = {"vid": 6200, "ch_type": 2, "name": "Wolf2", "empire": 0, + "pos": wolf2.position, "hp": 50, "max_hp": 50, "dead": false} + pc.target_selected.emit(wolf2) + _ck(whispers.is_empty(), "LCONTROL 点怪 -> 不开悄悄话(仅 PC 分派)") + np.set_lcontrol_down(false) + fc.ents[1000].erase("empire") + fc.ents.erase(6100); fc.ents.erase(6200) + nw._by_vid.erase(6100); nw._by_vid.erase(6200) + np._target_vid = 2000 # 复位到 8b 收尾态,供 9) 目标死亡清框 + # 9) 目标死亡 -> 清目标 fc.ents[2000]["dead"] = true fc.entity_dead.emit(2000) diff --git a/project/p10_test.gd b/project/p10_test.gd index f25d7741..6ecb8174 100644 --- a/project/p10_test.gd +++ b/project/p10_test.gd @@ -42,7 +42,9 @@ func _run() -> void: _ck(si.count() == 1 and si.server(0)["auth_host"] == "192.168.21.203", "serverinfo: built-in default") var addr: Dictionary = si.address(0, 3) _ck(addr["game_port"] == 13022, "serverinfo: 内置 CH3 -> 13022 (step 10)") - var tmp := "user://p10_servers.txt" + # user:// may be read-only in the managed headless runner; the parser only + # needs a temporary text file, so keep this fixture outside the project. + var tmp := "/tmp/mtgodot-p10-servers.txt" var f := FileAccess.open(tmp, FileAccess.WRITE) f.store_line("# test") f.store_line("欧服\teu.example.com\t13000\teu.example.com\t13011\t1,2\t10") diff --git a/project/p2b_test.gd b/project/p2b_test.gd index 1832f0fa..bba822e0 100644 --- a/project/p2b_test.gd +++ b/project/p2b_test.gd @@ -4,6 +4,7 @@ extends SceneTree const GroundItems = preload("res://ui/ground_items.gd") const PlayerView = preload("res://ui/player_view.gd") +const TextTailArrange = preload("res://text_tail_arrange.gd") class FakeClient extends Node: signal ground_item_added(item: Dictionary) @@ -12,8 +13,10 @@ class FakeClient extends Node: var picked := [] func get_ground_items() -> Array: return ground.values() func pickup_item(vid) -> bool: picked.append(vid); return true - func add_ground(vid, vnum, pos): - ground[vid] = {"vid": vid, "vnum": vnum, "pos": pos} + func add_ground(vid, vnum, pos, owner := "", item_color: Variant = null): + ground[vid] = {"vid": vid, "vnum": vnum, "pos": pos, "owner": owner} + if item_color != null: + ground[vid]["item_color"] = item_color ground_item_added.emit(ground[vid]) func del_ground(vid): ground.erase(vid); ground_item_removed.emit(vid) @@ -74,6 +77,92 @@ func _run() -> void: # 远处的捡不到 _ck(gi.try_pickup() == 0, "far item not picked") + # --- §8.8 seam ⑩:多条名条投影后去重叠(ArrangeTextTail item 块)--------------- + # 独立的 mount / FakeClient,避免与上面的 gi 抢同名 drop 节点。 + var mount2 := Node3D.new() + var fc2 := FakeClient.new() + get_root().add_child(mount2) + get_root().add_child(fc2) + var cam := Camera3D.new() + get_root().add_child(cam) + cam.position = Vector3(0, 3, 6) + cam.look_at(Vector3.ZERO, Vector3.UP) + cam.current = true + var gi2: Node = GroundItems.new() + get_root().add_child(gi2) + gi2.setup(fc2, mount2, func() -> Node: return pl, fp, null, func() -> Camera3D: return cam) + # 单条:名条留默认局部 y。 + fc2.add_ground(201, 27, Vector3(0, 0, 0)) + await process_frame + gi2._process(0.0) + var t201 := mount2.get_node("drop_201").get_node("tag") as Label3D + _ck(absf(t201.position.y - gi2.TAG_BASE_Y) < 0.0001, "单条名条 -> 留默认局部 y 0.75") + # 同位两条:靠前的(vid 小,m_ItemTextTailList 先)被顶下去,靠后的留原位。 + fc2.add_ground(202, 28, Vector3(0, 0, 0)) + await process_frame + gi2._process(0.0) + var t202 := mount2.get_node("drop_202").get_node("tag") as Label3D + _ck(t201.position.y < gi2.TAG_BASE_Y - 0.0001, "重叠 -> 靠前名条被下推(世界 y 变小)") + _ck(absf(t202.position.y - gi2.TAG_BASE_Y) < 0.0001, "重叠 -> 靠后名条留默认") + # 被下推量 = (h + gap) px 折世界:(15 + 5) * pixel_size(0.005) = 0.1。 + _ck(absf(t201.position.y - (gi2.TAG_BASE_Y - (gi2.TAG_LINE_H_PX + TextTailArrange.PUSH_GAP) * t201.pixel_size)) < 0.0001, + "下推量 = (TAG_LINE_H_PX + PUSH_GAP) * pixel_size = 0.1") + _ck(absf(t201.position.y - t202.position.y) > 0.0001, "两名条错开后局部 y 不同") + # 远离:把 202 挪远,重跑 -> 两条都回默认。 + mount2.get_node("drop_202").position = Vector3(-40, 0, -2) + gi2._process(0.0) + _ck(absf(t201.position.y - gi2.TAG_BASE_Y) < 0.0001, "拉开距离 -> 201 名条回默认") + _ck(absf(t202.position.y - gi2.TAG_BASE_Y) < 0.0001, "拉开距离 -> 202 名条回默认") + + # --- §8.8 seam ⑩ 末尾:owner 拆成独立 CENTER 黄字 Label3D(SetItemTextTailOwner) + # + 名字实例下移 15px(ArrangeTextTail :200);框仍 yEnd += ownerH + 4(增量 125)--- + var mount3 := Node3D.new() + var fc3 := FakeClient.new() + get_root().add_child(mount3) + get_root().add_child(fc3) + var gi3: Node = GroundItems.new() + get_root().add_child(gi3) + gi3.setup(fc3, mount3, func() -> Node: return pl, fp, null, func() -> Camera3D: return cam) + fc3.add_ground(301, 27, Vector3(0, 0, 0)) # 靠前、无 owner + var owner_color := Color(0.25, 0.75, 1.0) + fc3.add_ground(302, 28, Vector3(0, 0, 0), "Alice", owner_color) # 靠后、有 owner + await process_frame + gi3._process(0.0) + var d301 := mount3.get_node("drop_301") + var d302 := mount3.get_node("drop_302") + var t301 := d301.get_node("tag") as Label3D + var t302 := d302.get_node("tag") as Label3D + # owner 拆成独立 owner_tag:名字实例只放名字,owner_tag 放 "Alice" + "'s"(黄 / CENTER)。 + _ck(t302.text == "掉落28", "名字实例只放名字(owner 后缀不再拼串,:723)") + _ck(d301.get_node_or_null("owner_tag") == null, "无 owner 的掉落不建 owner_tag(:741-745)") + var o302 := d302.get_node_or_null("owner_tag") as Label3D + _ck(o302 != null, "有 owner 的掉落建独立 owner_tag(:715)") + _ck(o302.text == "Alice's", "owner_tag 文本 = owner + strOwnership(:722 字面 \"'s\")") + _ck(o302.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER, "owner_tag CENTER 对齐(:727)") + _ck(o302.modulate.is_equal_approx(owner_color), "owner_tag 使用道具品阶色(:729 pInsertTextTail->Color)") + # owner 名条框:无字体度量时 name_h = owner_h = 15 -> yEnd = 15 + 15 + 4 = 34(增量 125 不变)。 + # 靠前 301(无 owner)被顶 (34 + PUSH_GAP) px 折世界:(34 + 5) * 0.005 = 0.195。 + var push_owner: float = (gi3.TAG_LINE_H_PX * 2.0 + 4.0 + TextTailArrange.PUSH_GAP) * t301.pixel_size + _ck(absf(t301.position.y - (gi3.TAG_BASE_Y - push_owner)) < 0.0001, + "靠前名条被带 owner 的框顶 (34+5)*px = 0.195") + # 靠后 302 未被推:owner 行落基点、名字行下移 15px(:195 owner 在 y、:200 名字在 y+15)。 + _ck(absf(o302.position.y - gi3.TAG_BASE_Y) < 0.0001, "owner_tag 落尾标基点(:195)") + _ck(absf(t302.position.y - (gi3.TAG_BASE_Y - gi3.OWNER_NAME_GAP_PX * t302.pixel_size)) < 0.0001, + "名字实例在 owner 行下移 15px(:200 y + 15.0f)") + + # --- strOwnership 经 setup(possessive) 注入(SetItemTextTailOwner :722 本地化语素)--- + var mount4 := Node3D.new() + var fc4 := FakeClient.new() + get_root().add_child(mount4) + get_root().add_child(fc4) + var gi4: Node = GroundItems.new() + get_root().add_child(gi4) + gi4.setup(fc4, mount4, func() -> Node: return pl, fp, null, func() -> Camera3D: return cam, "@") + fc4.add_ground(401, 27, Vector3(0, 0, 0), "Bob") + await process_frame + var o401 := mount4.get_node("drop_401").get_node_or_null("owner_tag") as Label3D + _ck(o401 != null and o401.text == "Bob@", "setup(possessive=\"@\") -> owner_tag 文本 = \"Bob@\"") + # --- player view(有真资产才建)--- var assets := AssetRoot.path() if not DirAccess.dir_exists_absolute(assets.path_join("PC/ymir work/pc/warrior")): diff --git a/project/pick_show.gd b/project/pick_show.gd new file mode 100644 index 00000000..01578615 --- /dev/null +++ b/project/pick_show.gd @@ -0,0 +1,69 @@ +# pick_show —— §8.8 / §5.4 game.py OnRender 的「Picking」块分派 1:1: +# 每帧渲染时,textTail 的强显(ShowCharacterTextTail)/ 全显(ShowAllTextTail)/ +# 拾取命中(chr.Pick / item.Pick / textTail.Pick)该走哪条,取决于 +# wndMgr.IsPickedWindow(hWnd)(鼠标是否在 3D game 窗口上)与 __IsShowName()。 +# 本模块把这棵判定树抽成纯静态谓词,供 net_world 的「悬停强显名字」用 +# (仿增量 113 的 _target_vid forced 路数,给 _hover_vid 加第 5 路 forced)。 +# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd / +# target_board.gd 同一套纯静态镜像套路,本模块是第 27 个 canonical。) +# +# REF(40250 ClientVS22 source/ 为唯一基线;运行时 python 见 assets/root/game.py): +# root/game.py:1519 GameWindow.OnRender() +# root/game.py:1531 textTail.UpdateAllTextTail() —— 每帧先刷全部尾标 +# root/game.py:1533 if True == wndMgr.IsPickedWindow(self.hWnd) —— 鼠标在 3D 窗口上才拾取 +# root/game.py:1535 self.PickingCharacterIndex = chr.Pick() —— 射线拾取角色(-1 = 无) +# root/game.py:1537 if -1 != PickingCharacterIndex: textTail.ShowCharacterTextTail(idx) +# root/game.py:1539 if 0 != targetBoard.GetTargetVID(): textTail.ShowCharacterTextTail(targetVID) +# root/game.py:1543 if not self.__IsShowName(): PickingItemIndex = item.Pick() —— 只在「非总显名字」时拾取地面物品 +# root/game.py:1546 if -1 != PickingItemIndex: textTail.ShowItemTextTail(idx) +# root/game.py:1552 if self.__IsShowName(): textTail.ShowAllTextTail() —— 总显名字:全显 + 从尾标里拾取 +# root/game.py:1554 self.PickingItemIndex = textTail.Pick(x, y) +# root/game.py:1557 textTail.UpdateShowingTextTail() +# root/game.py:1558 textTail.ArrangeTextTail() —— 屏幕空间去重叠排布 +# root/game.py:1560 if -1 != PickingItemIndex: textTail.SelectItemName(idx) +# +# seam ⑨:名字尾标 y = 聊天气泡 y − 17px(PythonTextTail.cpp:112-114)尚未接入; +# 本 poc 名字牌是每实体一个 Label3D,暂无「随 chat 气泡下移」的整合。 +# seam ⑩:ArrangeTextTail 的屏幕空间尾标去重叠排布(PythonTextTail.cpp ArrangeTextTail) +# 仍缺——poc 名字牌是 3D billboard,靠深度天然分层,未做 2D 投影后的挤开。 +# 本增量只收口其中一小块:把「悬停角色强显名字」按 :1535/:1537 补上(forced 第 5 路)。 +# seam(近似):参考里 chr.Pick() 强显与 targetBoard 强显都在 :1533 的 IsPickedWindow 门内; +# poc 把 _target_vid(增量 113)与 _hover_vid(本增量)都当作「无条件 forced」路径—— +# _hover_vid 在鼠标压 UI 时由 player_controller 主动置 0,算是部分等效于 IsPickedWindow 门。 +extends RefCounted + +# chr.Pick() / item.Pick() 的「无命中」哨兵。 +const NO_PICK := -1 + +# :1533 —— 鼠标在 3D game 窗口上时,chr.Pick() 这一支才跑。 +static func char_pick_active(is_picked_window: bool) -> bool: + return is_picked_window + +# :1533..:1540 —— 本帧要 ShowCharacterTextTail(强显、绕过 __IsShowName)的 vid 集合: +# 悬停拾取到的角色(pick_char_vid,≈ poc 的 _hover_vid)+ 目标框 VID(target_vid)。 +# 两者都只在 is_picked_window 时收集;去重;0 / NO_PICK 视为无。 +static func forced_name_vids(is_picked_window: bool, pick_char_vid: int, target_vid: int) -> Array: + var out: Array = [] + if not is_picked_window: + return out + if pick_char_vid != NO_PICK and pick_char_vid != 0: + out.append(pick_char_vid) # :1537 + if target_vid != 0 and not out.has(target_vid): + out.append(target_vid) # :1539 + return out + +# :1543 —— 只有「鼠标在窗口上」且「非总显名字」时才 item.Pick() 拾取地面物品。 +static func should_pick_item(is_picked_window: bool, is_show_name: bool) -> bool: + return is_picked_window and not is_show_name + +# :1552 —— 总显名字(__IsShowName())时才 ShowAllTextTail() 全量显名。 +static func should_show_all_names(is_show_name: bool) -> bool: + return is_show_name + +# :1554 —— 总显名字时,物品拾取改从已显的尾标里 textTail.Pick(x, y)。 +static func should_pick_text_tail(is_show_name: bool) -> bool: + return is_show_name + +# :1560 —— 拾取到物品尾标(PickingItemIndex != -1)才 SelectItemName 高亮其名。 +static func should_select_item_name(pick_item_index: int) -> bool: + return pick_item_index != NO_PICK diff --git a/project/pick_show_test.gd b/project/pick_show_test.gd new file mode 100644 index 00000000..9f4cba9a --- /dev/null +++ b/project/pick_show_test.gd @@ -0,0 +1,69 @@ +# pick_show_test —— §8.8 / §5.4 game.py OnRender「Picking」块分派 1:1: +# IsPickedWindow 门、chr.Pick() 强显、targetBoard 强显、去重、__IsShowName() 分叉 +# (非总显名字 -> item.Pick;总显名字 -> ShowAllTextTail + textTail.Pick)、 +# SelectItemName 门。 +# godot --headless --path project --script pick_show_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const PickShow = preload("res://pick_show.gd") + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _init() -> void: + _run() + if _fail == 0: + print("PASS: pick_show_test (§8.8/§5.4 OnRender Picking 分派 1:1)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + var NP := PickShow.NO_PICK + _ck(NP == -1, "NO_PICK == -1") + + # --- 0. char_pick_active(game.py:1533)------------------------------------ + _ck(PickShow.char_pick_active(true) == true, "鼠标在窗口 -> chr.Pick() 跑") + _ck(PickShow.char_pick_active(false) == false, "鼠标不在窗口 -> chr.Pick() 不跑") + + # --- 1. forced_name_vids(:1533..:1540 ShowCharacterTextTail 集合)-------- + # 不在窗口 -> 空(连 target 强显也不做,参考里都在 :1533 门内) + _ck(PickShow.forced_name_vids(false, 2000, 1500) == [], "不在窗口 -> 无强显") + # 悬停命中 + 有目标框 -> 两个都强显,顺序 [hover, target] + _ck(PickShow.forced_name_vids(true, 2000, 1500) == [2000, 1500], "悬停+目标 -> [hover, target]") + # 只悬停命中 + _ck(PickShow.forced_name_vids(true, 2000, 0) == [2000], "只悬停 -> [hover]") + # 只有目标框 + _ck(PickShow.forced_name_vids(true, NP, 1500) == [1500], "只目标框 -> [target]") + _ck(PickShow.forced_name_vids(true, 0, 1500) == [1500], "hover=0 视为无 -> [target]") + # 悬停的正是目标 -> 去重 + _ck(PickShow.forced_name_vids(true, 1500, 1500) == [1500], "悬停==目标 -> 去重成一个") + # 啥也没有 + _ck(PickShow.forced_name_vids(true, NP, 0) == [], "在窗口但无命中无目标 -> 空") + + # --- 2. should_pick_item(:1543)---------------------------------------- + _ck(PickShow.should_pick_item(true, false) == true, "在窗口 且 非总显名字 -> item.Pick()") + _ck(PickShow.should_pick_item(true, true) == false, "总显名字 -> 不走 item.Pick()") + _ck(PickShow.should_pick_item(false, false) == false, "不在窗口 -> 不走 item.Pick()") + + # --- 3. should_show_all_names / should_pick_text_tail(:1552 / :1554)-- + _ck(PickShow.should_show_all_names(true) == true, "总显名字 -> ShowAllTextTail") + _ck(PickShow.should_show_all_names(false) == false, "非总显名字 -> 不 ShowAllTextTail") + _ck(PickShow.should_pick_text_tail(true) == true, "总显名字 -> textTail.Pick()") + _ck(PickShow.should_pick_text_tail(false) == false, "非总显名字 -> 不 textTail.Pick()") + + # item 拾取的两条路互斥:非总显名字走 item.Pick,总显名字走 textTail.Pick + for isn in [true, false]: + var via_item := PickShow.should_pick_item(true, isn) + var via_tail := PickShow.should_pick_text_tail(isn) + _ck(via_item != via_tail, "在窗口时 item.Pick 与 textTail.Pick 恰好二选一(is_show_name=%s)" % isn) + + # --- 4. should_select_item_name(:1560)------------------------------ + _ck(PickShow.should_select_item_name(7) == true, "PickingItemIndex=7 -> SelectItemName") + _ck(PickShow.should_select_item_name(0) == true, "PickingItemIndex=0(有效索引)-> SelectItemName") + _ck(PickShow.should_select_item_name(NP) == false, "PickingItemIndex=-1 -> 不 SelectItemName") diff --git a/project/player_controller.gd b/project/player_controller.gd index 397b925f..5c442384 100644 --- a/project/player_controller.gd +++ b/project/player_controller.gd @@ -11,6 +11,9 @@ extends Node signal anim_state(state: String) signal target_selected(node: Node3D) signal moved(pos: Vector3) +# game.py OnRender:wndMgr.IsPickedWindow 门内 chr.Pick() 拾到的角色 -> ShowCharacterTextTail。 +# 悬停命中的实体 vid 变化时 emit(无命中 / 鼠标压 UI / 悬停地面物品 -> emit 0)。见 pick_show.gd。 +signal hover_entity_changed(vid: int) var player: Node3D var camera: Camera3D @@ -21,6 +24,7 @@ var ground_items: Node var cancel_fishing_input := Callable() # NEW_SetSingleDIKKeyState 等价入口 var cancel_fishing_ground := Callable() # __OnPressGround 专用入口 var pickables: Array[Node3D] = [] # 可点选实体(NPC/怪等) +var _hover_vid := 0 # 上一帧悬停命中的实体 vid(hover_entity_changed 去抖) const SPEED_WALK := 8.0 const SPEED_RUN := 18.0 @@ -386,9 +390,11 @@ func _update_hover_cursor() -> void: motion.position = mouse_pos if ui_manager.blocks_game_input(motion): cursor_manager.set_cursor("NORMAL") + _set_hover_vid(0) # 鼠标压 UI:IsPickedWindow 为假 -> 无 chr.Pick 强显 return if ground_items and ground_items.has_method("hover_at") and ground_items.hover_at(camera, mouse_pos): cursor_manager.set_cursor("PICK") + _set_hover_vid(0) # 悬停地面物品:item.Pick 领域,非 chr.Pick return var from := camera.project_ray_origin(mouse_pos) var dir := camera.project_ray_normal(mouse_pos) @@ -403,5 +409,14 @@ func _update_hover_cursor() -> void: best = n if best: cursor_manager.set_cursor(String(best.get_meta("cursor_shape", "ATTACK"))) + _set_hover_vid(int(best.get_meta("vid", 0))) else: cursor_manager.set_cursor("NORMAL") + _set_hover_vid(0) + +# game.py OnRender chr.Pick() -> ShowCharacterTextTail:悬停命中的角色 vid 变化时才 emit。 +func _set_hover_vid(vid: int) -> void: + if vid == _hover_vid: + return + _hover_vid = vid + hover_entity_changed.emit(vid) diff --git a/project/quest_event_test.gd b/project/quest_event_test.gd index 408bf05e..0531a2f3 100644 --- a/project/quest_event_test.gd +++ b/project/quest_event_test.gd @@ -74,9 +74,11 @@ func _run() -> void: # --- 7. NEXT / DONE -> has_next + iAdjustLine += 2 --- var nx := qd.parse_script("x[NEXT]") - _ck(nx.has_next and nx.adjust_line == 2, "NEXT -> has_next + adjust_line=2 (%d)" % nx.adjust_line) + _ck(nx.has_next and nx.next_button_type == "NEXT" and nx.adjust_line == 2, + "NEXT -> button type + adjust_line=2 (%d)" % nx.adjust_line) var dn := qd.parse_script("x[NEXT][DONE]") - _ck(dn.has_next and dn.adjust_line == 4, "NEXT+DONE -> adjust_line=4 (%d)" % dn.adjust_line) + _ck(dn.has_next and dn.next_button_type == "DONE" and dn.adjust_line == 4, + "NEXT+DONE -> DONE button type + adjust_line=4 (%d)" % dn.adjust_line) # --- 8. WAIT -> isLock --- var w := qd.parse_script("x[WAIT]y") diff --git a/project/quest_test.gd b/project/quest_test.gd index 07e6b083..12c475ec 100644 --- a/project/quest_test.gd +++ b/project/quest_test.gd @@ -88,6 +88,19 @@ func _run() -> void: (qd._btnrow.get_child(0) as Button).pressed.emit() _ck(fc.answers == [1, 255], "continue -> script_answer(255)") + # DONE is a button action in ClientVS22. Parsing / receiving the token must + # not fire DoneEvent before the player confirms the close button. + var done_events := 0 + qd.done_event.connect(func(): done_events += 1) + var done_parse: Dictionary = qd.parse_script("Finished.[DONE]") + _ck(done_parse.next_button_type == "DONE" and done_events == 0, + "DONE parse stays side-effect free") + fc.script_dialog.emit(0, "Finished.[DONE]") + await process_frame + (qd._btnrow.get_child(0) as Button).pressed.emit() + _ck(done_events == 1 and fc.answers == [1, 255, 255], + "DONE click -> DoneEvent + script_answer(255)") + # ESC dismisses the active script through the legacy QUEST_CANCEL packet. fc.script_dialog.emit(0, "Cancelable quest.[NEXT]") await process_frame diff --git a/project/screen_hp_bar.gd b/project/screen_hp_bar.gd new file mode 100644 index 00000000..99eafa8e --- /dev/null +++ b/project/screen_hp_bar.gd @@ -0,0 +1,41 @@ +# ScreenHpBar —— 角色头顶的屏幕空间 HP 条(§8.8)。 +# +# 参考端在 CPythonTextTail / CGraphicTextInstance 的屏幕坐标中绘制尾标; +# 这里用一个无输入的 Control 自绘背景、边框和填充,NetWorld 每帧负责把 +# 实体的世界坐标投影到屏幕。这样 HP 条不会随 Label3D 的 billboard 深度 +# 缩放,也不会被地图几何遮住。 +extends Control + +const DEFAULT_SIZE := Vector2(72.0, 6.0) +const MIN_FILL_RATIO := 0.0 +const MAX_FILL_RATIO := 1.0 + +var hp := 0 +var max_hp := 0 +var ratio := 0.0 +var dead := false + +func _init() -> void: + mouse_filter = MOUSE_FILTER_IGNORE + size = DEFAULT_SIZE + custom_minimum_size = DEFAULT_SIZE + +func set_vitals(current_hp: int, current_max_hp: int, is_dead: bool) -> void: + hp = current_hp + max_hp = current_max_hp + dead = is_dead + ratio = clampf(float(hp) / float(max_hp), MIN_FILL_RATIO, MAX_FILL_RATIO) \ + if max_hp > 0 else 0.0 + queue_redraw() + +func _draw() -> void: + var r := Rect2(Vector2.ZERO, size) + # Dark backing and a one-pixel black border keep the bar legible over terrain. + draw_rect(r, Color(0.02, 0.02, 0.025, 0.86), true) + var inner := Rect2(Vector2.ONE, Vector2(maxf(0.0, size.x - 2.0), maxf(0.0, size.y - 2.0))) + var fill_color := Color(0.35, 0.35, 0.38, 0.95) if dead \ + else Color(0.88, 0.14, 0.14, 0.98) + inner.size.x *= ratio + if inner.size.x > 0.0: + draw_rect(inner, fill_color, true) + draw_rect(r, Color(0.0, 0.0, 0.0, 0.95), false, 1.0) diff --git a/project/screen_hp_bar_test.gd b/project/screen_hp_bar_test.gd new file mode 100644 index 00000000..ca1b80f1 --- /dev/null +++ b/project/screen_hp_bar_test.gd @@ -0,0 +1,98 @@ +# screen_hp_bar_test —— §8.8 屏幕空间 HP 条 headless 自检。 +# godot --headless --path project --script screen_hp_bar_test.gd +# 覆盖:不再创建 3D Sprite3D、实体血量同步、世界坐标投影、镜头后/死亡隐藏、地图清理。 +extends SceneTree + +const NetWorld = preload("res://net_world.gd") +const ScreenHpBar = preload("res://screen_hp_bar.gd") + +class FakeClient extends Node: + signal entity_spawned(entity: Dictionary) + signal entity_despawned(vid: int) + signal entity_moved(vid: int) + signal entity_main_set(vid: int) + signal entity_info(vid: int, entity: Dictionary) + signal chat(type: int, vid: int, text: String) + signal vitals_changed(vid: int) + signal entity_dead(vid: int) + signal damage(vid: int, amount: int, flag: int) + var entities := {} + func get_entity(vid: int) -> Dictionary: return entities.get(vid, {}) + func get_entities() -> Array: return entities.values() + func get_pvp_relations() -> Array: return [] + func get_duel() -> Dictionary: return {} + func add(vid: int, pos: Vector3, main := false) -> Dictionary: + var d := {"vid": vid, "name": "e%d" % vid, "is_main": main, + "pos": pos, "pos_cm": Vector3(pos.x * 100.0, -pos.z * 100.0, 0.0), + "func": 0, "moving": false, "angle_deg": 0.0, + "hp": 100, "max_hp": 100, "dead": false, "race": 0, + "affect_flags": 0, "parts": [0, 0, 0, 0]} + entities[vid] = d + return d + +var _fail := 0 +func _ck(condition: bool, message: String) -> void: + if not condition: + _fail += 1 + printerr("FAIL: " + message) + +func _init() -> void: + await _run() + if _fail == 0: + print("PASS: screen_hp_bar_test (§8.8 screen-space HP bar)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + var mount := Node3D.new() + var client := FakeClient.new() + var camera := Camera3D.new() + var nw: Node = NetWorld.new() + get_root().add_child(mount) + get_root().add_child(client) + get_root().add_child(camera) + get_root().add_child(nw) + camera.position = Vector3(0.0, 2.0, 6.0) + camera.look_at_from_position(Vector3(0.0, 2.0, 6.0), Vector3.ZERO, Vector3.UP) + camera.current = true + nw.setup(client, mount) + nw.set_camera(camera) + client.add(1, Vector3.ZERO, true) + client.entity_main_set.emit(1) + nw.set_local_vid(1) + var entity := client.add(2, Vector3.ZERO) + client.entity_spawned.emit(entity) + await process_frame + await process_frame + + var node: Node3D = nw.node_for(2) + var bar := nw._hp_bars.get(2, null) as Control + _ck(node != null, "remote entity exists") + _ck(bar != null and bar is ScreenHpBar, "entity owns a screen Control HP bar") + _ck(node.get_node_or_null("HpBar") == null, "legacy Sprite3D HpBar removed") + _ck(bar != null and bar.visible, "front-facing living entity shows HP bar") + if bar != null: + var expected := camera.unproject_position(node.global_position + Vector3(0, nw.HP_BAR_WORLD_Y, 0)) + _ck(is_equal_approx(bar.position.x, expected.x - bar.size.x * 0.5), + "bar x is centered on projected world anchor") + _ck(is_equal_approx(bar.ratio, 1.0), "initial hp ratio = 1") + + entity["hp"] = 25 + client.vitals_changed.emit(2) + await process_frame + _ck(is_equal_approx(bar.ratio, 0.25), "vitals update changes screen bar ratio") + entity["dead"] = true + client.vitals_changed.emit(2) + await process_frame + _ck(not bar.visible, "dead entity hides screen bar") + + entity["dead"] = false + entity["pos"] = Vector3(0, 0, -12) + client.vitals_changed.emit(2) + await process_frame + _ck(not bar.visible, "entity behind camera hides screen bar") + + nw.clear_for_map_change() + _ck(nw._hp_bars.is_empty(), "map change clears screen bars") diff --git a/project/system_menu_ui_test.gd b/project/system_menu_ui_test.gd index cab42e80..d1b6a509 100644 --- a/project/system_menu_ui_test.gd +++ b/project/system_menu_ui_test.gd @@ -90,6 +90,22 @@ func _run() -> void: cfg.load(_cfg_path()) _ck(int(cfg.get_value("gameopt", "always_show_name", -1)) == 0, "cfg gameopt.always_show_name persisted") _ck(int(cfg.get_value("gameopt", "block_mode", -1)) == 2, "cfg gameopt.block_mode persisted") + + # §8.4 名字色 radio(name_color_normal / name_color_empire)-> _display + cfg + 信号 + var opt_evt := [] + go.display_option_changed.connect(func(k, v): opt_evt.append([k, v])) + (go._node("name_color_empire") as BaseButton).pressed.emit() + _ck(int(go._display.get("name_color", -1)) == 1, "name_color_empire -> _display.name_color = 1") + _ck(opt_evt.size() >= 1 and opt_evt[-1] == ["name_color", 1], "name_color_empire -> display_option_changed('name_color',1)") + # §8.4 看他国目标框 radio(target_board_view / target_board_no_view,constinfo 默认 1 = 看) + _ck(int(go._display.get("target_board", -1)) == 1, "target_board 默认 1(VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD)") + (go._node("target_board_no_view") as BaseButton).pressed.emit() + _ck(int(go._display.get("target_board", -1)) == 0, "target_board_no_view -> _display.target_board = 0") + _ck(opt_evt[-1] == ["target_board", 0], "target_board_no_view -> display_option_changed('target_board',0)") + var cfg2 := ConfigFile.new() + cfg2.load(_cfg_path()) + _ck(int(cfg2.get_value("gameopt", "name_color", -1)) == 1, "cfg gameopt.name_color persisted") + _ck(int(cfg2.get_value("gameopt", "target_board", -1)) == 0, "cfg gameopt.target_board persisted") go.close() # 二次实例读 cfg:block_mode 回填 diff --git a/project/target_board.gd b/project/target_board.gd new file mode 100644 index 00000000..53e4171a --- /dev/null +++ b/project/target_board.gd @@ -0,0 +1,91 @@ +# target_board —— §5.4 / §8.8 目标框(TargetBoard)收包分派 1:1: +# CPythonNetworkStream::RecvTargetPacket 的「目标存在 / 未死 → PC|Building 关掉异 VID 框、 +# 可看血就设血条、否则关框」判定树,加 CInstanceBase::CanViewTargetHP 的 +# IsStone()||IsWoodenDoor()||IsEnemy(),再加 game.py 的 SetHPTargetBoard(换 VID 先 Reset)/ +# CloseTargetBoardIfDifferent / CloseTargetBoard / SetPCTargetBoard 的 LCONTROL 悄悄话捷径。 +# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd 同一套 +# 纯静态镜像套路,本模块是第 26 个 canonical。) +# +# REF(40250 ClientVS22 source/ 为唯一基线): +# UserInterface/Packet.h:1937 TPacketGCTarget { BYTE header; DWORD dwVID; BYTE bHPPercent; } +# UserInterface/PythonNetworkStreamPhaseGame.cpp:2424 RecvTargetPacket() +# :2436 if (pInstPlayer && pInstTarget) —— 主角与目标实例都在 +# :2438 if (!pInstTarget->IsDead()) —— 目标没死透才动框 +# :2440 if (IsPC() || IsBuilding()) CloseTargetBoardIfDifferent(dwVID) +# :2442 else if (CanViewTargetHP(*pInstTarget)) SetHPTargetBoard(dwVID, bHPPercent) +# :2444 else CloseTargetBoard() +# :2447 m_pInstTarget = pInstTarget —— 仅当目标存在且未死 +# :2453 else CloseTargetBoard() —— 实例缺失 +# UserInterface/InstanceBase.cpp:2293 CanViewTargetHP(victim) = victim.IsStone()||victim.IsWoodenDoor()||victim.IsEnemy() +# UserInterface/InstanceBase.cpp:2380/2390 IsBuilding() / IsWoodenDoor()(vnum 13000 或 30111..30119) +# root/game.py:804 SetPCTargetBoard(vid,name):Open(vid,name);若按住 LCONTROL 且 IsSameEmpire +# 且非本人且非 building → interface.OpenWhisperDialog(name) +# root/game.py:829 SetHPTargetBoard(vid,hpPct):vid != GetTargetVID() → ResetTargetBoard()+SetEnemyVID(vid);SetHP(hpPct)+Show() +# root/game.py:837 CloseTargetBoardIfDifferent(vid):vid != GetTargetVID() → Close() +# root/game.py:841 CloseTargetBoard():Close() +# root/uitarget.py:189-193 TargetBoard.Open(vid,name) 最前置过滤: +# if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): +# if not player.IsSameEmpire(vid): self.Hide(); return +# —— game-option「看他国玩家目标框」关掉时,异阵营玩家框直接不开(constinfo 默认 1 = 开)。 +# +# seam ⑫:旧实体快照没有独立建筑字段;当前 net_play 已支持 fixture / mob_proto +# 的 BUILDING(5) / WOODEN_DOOR(6) 分类。没有分类信息的旧快照仍按 NPC/怪/石头路径。 +# 另:poc 的 target_info 由 C++ 侧(extension/src/net/m2_client.cpp)从 EntityStore 抽出, +# 本模块只镜像 GDScript 侧对 (vid, hp%) 的分派,不改 C++ 收包。 +# LCONTROL 悄悄话捷径:增量 122 接线 —— net_play._on_pick 在 KIND_PC 分支轮询 +# whisper_on_ctrl_click(_lcontrol_down, same_empire, vid==_main_vid, false),真则发 +# whisper_requested(name),game_scene 转 chat.start_whisper()(poc 版 OpenWhisperDialog); +# _lcontrol_down 由 game_scene 的 KEY_CTRL 按下 / 松开喂。 +extends RefCounted + +# RecvTargetPacket 分派结果。 +# CLOSE —— 关框(实例缺失 / 不可看血的非 PC 非 building 目标) +# CLOSE_IF_DIFFERENT —— 目标是 PC 或 building:仅当框上是别的 VID 才关 +# SET_HP —— 可看血:设血条并显示(换 VID 先 Reset) +# NONE —— 目标已死透:不动框,但仍更新 m_pInstTarget +enum Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE } + +# CanViewTargetHP(victim):石头 / 木门 / 敌对,任一即可看血。 +static func can_view_target_hp(is_stone: bool, is_wooden_door: bool, is_enemy: bool) -> bool: + return is_stone or is_wooden_door or is_enemy + +# RecvTargetPacket 判定树。target_exists = (pInstPlayer && pInstTarget)。 +static func classify(target_exists: bool, is_dead: bool, is_pc: bool, + is_building: bool, can_view_hp: bool) -> int: + if not target_exists: + return Action.CLOSE # :2453 else CloseTargetBoard() + if is_dead: + return Action.NONE # :2438 !IsDead() 为假 —— 整块跳过 + if is_pc or is_building: + return Action.CLOSE_IF_DIFFERENT # :2440 + if can_view_hp: + return Action.SET_HP # :2442 + return Action.CLOSE # :2444 + +# m_pInstTarget = pInstTarget 只在「目标存在且没死透」时写(:2447 在 !IsDead() 块内)。 +static func updates_current_target(target_exists: bool, is_dead: bool) -> bool: + return target_exists and not is_dead + +# SetHPTargetBoard:vid != GetTargetVID() → 先 ResetTargetBoard()+SetEnemyVID(vid)。 +static func needs_reset(new_vid: int, cur_vid: int) -> bool: + return new_vid != cur_vid + +# CloseTargetBoardIfDifferent:vid != GetTargetVID() 才 Close()。 +static func should_close_if_different(vid: int, cur_vid: int) -> bool: + return vid != cur_vid + +# TargetBoard.Open :189-193 前置过滤:game-option「看他国玩家目标框」(constinfo +# VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1) 关掉时,异阵营目标框不开。 +# view_other_empire=false 且 same_empire=false → 隐藏。empire 0 的怪/NPC/石头在 +# IsSameEmpire 里算「同阵营」(entity_rules._is_same_empire),故只挡真·他国 PC。 +static func hidden_as_other_empire(view_other_empire: bool, same_empire: bool) -> bool: + return not view_other_empire and not same_empire + +# SetPCTargetBoard 的 LCONTROL 悄悄话捷径:按住 LCONTROL 且同阵营且非本人且非 building。 +static func whisper_on_ctrl_click(lcontrol_down: bool, same_empire: bool, + is_main: bool, is_building: bool) -> bool: + return lcontrol_down and same_empire and not is_main and not is_building + +# bHPPercent 是 BYTE,钳到 0..100。 +static func clamp_hp_pct(v: Variant) -> int: + return clampi(int(v), 0, 100) diff --git a/project/target_board_test.gd b/project/target_board_test.gd new file mode 100644 index 00000000..d4f21f3e --- /dev/null +++ b/project/target_board_test.gd @@ -0,0 +1,96 @@ +# target_board_test —— §5.4 / §8.8 目标框收包分派 1:1: +# RecvTargetPacket 的判定树(实例缺失→CLOSE、已死→NONE、PC|Building→CLOSE_IF_DIFFERENT、 +# 可看血→SET_HP、否则→CLOSE)、CanViewTargetHP、m_pInstTarget 写入条件、SetHPTargetBoard +# 换 VID 先 Reset、CloseTargetBoardIfDifferent、SetPCTargetBoard 的 LCONTROL 悄悄话捷径、 +# bHPPercent 钳位。 +# godot --headless --path project --script target_board_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const TargetBoard = preload("res://target_board.gd") + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _init() -> void: + _run() + if _fail == 0: + print("PASS: target_board_test (§5.4/§8.8 RecvTargetPacket 分派 + CanViewTargetHP 1:1)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + var CLOSE := TargetBoard.Action.CLOSE + var DIFF := TargetBoard.Action.CLOSE_IF_DIFFERENT + var SETHP := TargetBoard.Action.SET_HP + var NONE := TargetBoard.Action.NONE + + # --- 0. CanViewTargetHP(InstanceBase.cpp:2293)------------------------------ + _ck(TargetBoard.can_view_target_hp(true, false, false) == true, "IsStone -> 可看血") + _ck(TargetBoard.can_view_target_hp(false, true, false) == true, "IsWoodenDoor -> 可看血") + _ck(TargetBoard.can_view_target_hp(false, false, true) == true, "IsEnemy -> 可看血") + _ck(TargetBoard.can_view_target_hp(false, false, false) == false, "皆非 -> 不可看血") + _ck(TargetBoard.can_view_target_hp(true, true, true) == true, "任一为真即可看血") + # door/building 实体现在由 net_play/entity_rules 识别后分别进入这两条参考分支。 + _ck(TargetBoard.classify(true, false, false, true, true) == DIFF, + "建筑即使可看血也优先 CLOSE_IF_DIFFERENT") + _ck(TargetBoard.classify(true, false, false, false, true) == SETHP, + "木门经 CanViewTargetHP -> SET_HP") + + # --- 1. RecvTargetPacket 判定树(PythonNetworkStreamPhaseGame.cpp:2424)------ + # 实例缺失(pInstPlayer && pInstTarget 为假)-> :2453 CloseTargetBoard + _ck(TargetBoard.classify(false, false, false, false, false) == CLOSE, "目标实例缺失 -> CLOSE") + _ck(TargetBoard.classify(false, false, true, false, true) == CLOSE, "实例缺失优先于其它分支") + # 目标已死透 -> :2438 !IsDead() 为假 -> 整块跳过(NONE),但 m_pInstTarget 也不写 + _ck(TargetBoard.classify(true, true, false, false, true) == NONE, "目标已死 -> NONE(不动框)") + _ck(TargetBoard.classify(true, true, true, false, false) == NONE, "已死优先于 PC 分支") + # PC / Building -> :2440 CloseTargetBoardIfDifferent + _ck(TargetBoard.classify(true, false, true, false, false) == DIFF, "PC 目标 -> CLOSE_IF_DIFFERENT") + _ck(TargetBoard.classify(true, false, false, true, false) == DIFF, "Building 目标 -> CLOSE_IF_DIFFERENT") + _ck(TargetBoard.classify(true, false, true, false, true) == DIFF, "PC 即便可看血也走 CLOSE_IF_DIFFERENT(PC 分支在前)") + # 可看血 -> :2442 SetHPTargetBoard + _ck(TargetBoard.classify(true, false, false, false, true) == SETHP, "非 PC 非 building 且可看血 -> SET_HP") + # 不可看血的非 PC 非 building(例如 NPC / warp)-> :2444 CloseTargetBoard + _ck(TargetBoard.classify(true, false, false, false, false) == CLOSE, "NPC/warp 不可看血 -> CLOSE") + + # --- 2. m_pInstTarget 写入条件(:2447 在 !IsDead() 块内)--------------------- + _ck(TargetBoard.updates_current_target(true, false) == true, "目标存在且未死 -> 写 m_pInstTarget") + _ck(TargetBoard.updates_current_target(true, true) == false, "目标已死 -> 不写 m_pInstTarget") + _ck(TargetBoard.updates_current_target(false, false) == false, "实例缺失 -> 不写 m_pInstTarget") + + # --- 3. SetHPTargetBoard 换 VID 先 Reset(game.py:829)--------------------- + _ck(TargetBoard.needs_reset(2000, 0) == true, "无当前目标 -> 先 Reset") + _ck(TargetBoard.needs_reset(2000, 1500) == true, "换目标 -> 先 Reset") + _ck(TargetBoard.needs_reset(2000, 2000) == false, "同目标 -> 只 SetHP,不 Reset") + + # --- 4. CloseTargetBoardIfDifferent(game.py:837)------------------------ + _ck(TargetBoard.should_close_if_different(2000, 1500) == true, "框上是别的 VID -> Close") + _ck(TargetBoard.should_close_if_different(2000, 2000) == false, "框上正是该 VID -> 保留") + _ck(TargetBoard.should_close_if_different(2000, 0) == true, "框上无 VID -> Close") + + # --- 5. SetPCTargetBoard 的 LCONTROL 悄悄话捷径(game.py:804-817)--------- + _ck(TargetBoard.whisper_on_ctrl_click(true, true, false, false) == true, "LCONTROL+同阵营+非本人+非building -> 开悄悄话") + _ck(TargetBoard.whisper_on_ctrl_click(false, true, false, false) == false, "没按 LCONTROL -> 不开") + _ck(TargetBoard.whisper_on_ctrl_click(true, false, false, false) == false, "!IsSameEmpire -> return") + _ck(TargetBoard.whisper_on_ctrl_click(true, true, true, false) == false, "IsMainCharacterIndex -> return") + _ck(TargetBoard.whisper_on_ctrl_click(true, true, false, true) == false, "INSTANCE_TYPE_BUILDING -> return") + + # --- 5b. TargetBoard.Open 异阵营前置过滤(uitarget.py:189-193)------------ + # game-option 关「看他国目标框」+ 异阵营 -> 隐藏;其它组合都不挡。 + _ck(TargetBoard.hidden_as_other_empire(false, false) == true, "关看他国框 + 异阵营 -> 隐藏") + _ck(TargetBoard.hidden_as_other_empire(false, true) == false, "关看他国框 + 同阵营 -> 不挡") + _ck(TargetBoard.hidden_as_other_empire(true, false) == false, "开看他国框 + 异阵营 -> 不挡") + _ck(TargetBoard.hidden_as_other_empire(true, true) == false, "开看他国框 + 同阵营 -> 不挡") + + # --- 6. bHPPercent 钳位(BYTE 0..100)---------------------------------- + _ck(TargetBoard.clamp_hp_pct(40) == 40, "40 -> 40") + _ck(TargetBoard.clamp_hp_pct(0) == 0, "0 -> 0") + _ck(TargetBoard.clamp_hp_pct(100) == 100, "100 -> 100") + _ck(TargetBoard.clamp_hp_pct(-3) == 0, "负数 -> 0") + _ck(TargetBoard.clamp_hp_pct(150) == 100, ">100 -> 100") + _ck(TargetBoard.clamp_hp_pct(37.9) == 37, "float 37.9 -> 37(int 截断)") diff --git a/project/text_metrics.gd b/project/text_metrics.gd new file mode 100644 index 00000000..3d21fde5 --- /dev/null +++ b/project/text_metrics.gd @@ -0,0 +1,115 @@ +# text_metrics —— §8.8 seam ⑦ / ⑩ 收口:头顶子标签 / 掉落名条排版的文本尺寸 +# 从「字符数 * 约 7px」/「单行 15px」估算换成 CGraphicTextInstance::GetTextSize 的 +# 真实字体度量 1:1(增量 123 换宽 w、增量 124 换高 h、增量 125 加 owner 子串独立度量)。 +# ArrangeTextTail(PythonTextTail.cpp:211-320 角色块、:160-190 item 块)里 +# iNameWidth / iTitleWidth / iLevelWidth / guildW,以及 item 尾标框的 xSize / ySize +# (框 yEnd = ySize,:749 / :809),参考端全部来自 `pTextInstance->GetTextSize(&w, &h)`。 +# Godot 的 Font.get_string_size() 是 1:1 对应物——本模块把它包成纯静态查询,并在无字体 +# (headless / 未配主题)时回退到旧的字符数估宽 / 单行估高,保持 name_tail_layout / +# text_tail_arrange 的接口不变。 +# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd / +# target_board.gd / pick_show.gd / name_tail_layout.gd / text_tail_arrange.gd 同一套 +# 纯静态镜像套路,本模块是第 30 个 canonical。) +# +# REF(40250 ClientVS22 source/ 为唯一基线): +# UserInterface/PythonTextTail.cpp:225 pGuildNameInstance->GetTextSize(&iWidth, &iHeight) +# UserInterface/PythonTextTail.cpp:233 pTextTail->pTextInstance->GetTextSize(&iNameWidth, &iNameHeight) +# UserInterface/PythonTextTail.cpp:240 pTitle->GetTextSize(&iTitleWidth, &iTitleHeight) +# UserInterface/PythonTextTail.cpp:267 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(有称号分支) +# UserInterface/PythonTextTail.cpp:297 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(只有等级分支) +# UserInterface/PythonTextTail.cpp:733 pOwnerTextInstance->GetTextSize(&xOwnerSize, &yOwnerSize) +# UserInterface/PythonTextTail.cpp:748 pTextInstance->GetTextSize(&xSize, &ySize)(SetTextTailOwnerText) +# UserInterface/PythonTextTail.cpp:809 pTextInstance->GetTextSize(&xSize, &ySize)(SetTextTailText,item 框尺寸) +# —— GetTextSize 由 CGraphicTextInstance 转发到字体的实际字形排版度量(含字距、非等宽), +# 不是「字符数 * 定宽」。poc 之前的 SUBTAG_CHAR_W_PX / TAG_CHAR_W_PX(7.0) 是它 w 的近似、 +# TAG_LINE_H_PX(15.0) 是它 h 的近似。 +# +# seam ⑦ / ⑩:本模块只负责「量文本宽 / 高」。有字体时 = get_string_size 的精确像素(1:1); +# 无字体时回退字符数估宽 / 单行估高(headless 测试、或工程未配 Label3D.font 且无 +# ThemeDB.fallback_font 的极端情况)。折算成世界坐标仍由调用方按各 Label3D 的 pixel_size +# 处理(见 name_tail_layout.subtag_world_x / text_tail_arrange)。子标签竖排 vs 参考同一 +# 屏幕行、无公会徽记 mark 实体这两条 seam ⑦ 遗留不在本模块范围。 +extends RefCounted + +# 无字体 / font_size 非法时的每字符估宽(=增量 121 之前 net_world.SUBTAG_CHAR_W_PX、 +# ground_items.TAG_CHAR_W_PX 用的 7.0)。仅作兜底,有字体时不参与。 +const FALLBACK_CHAR_W_PX := 7.0 + +# 无字体 / font_size 非法时的单行估高(=增量 124 之前 ground_items.TAG_LINE_H_PX 用的 +# 15.0)。仅作兜底,有字体时走 get_string_size(...).y。 +const FALLBACK_LINE_H_PX := 15.0 + +# GetTextSize(&w, &h) 1:1:text 在 font @ font_size 下的像素尺寸。 +# 空串 / font 为 null / font_size <= 0 -> Vector2.ZERO(对齐参考端未建 text instance +# 时该子标签不参与 ArrangeTextTail 的宽度计算)。 +static func text_size(font: Font, text: String, font_size: int) -> Vector2: + if text == "" or font == null or font_size <= 0: + return Vector2.ZERO + return font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + +# ArrangeTextTail 里各 iXxxWidth 取的就是 GetTextSize 的 w。 +# 有字体且量得正宽 -> 精确像素;否则(无字体 / 非法 size)-> 字符数 * fallback_char_px。 +# 空串恒 0(参考端无 text instance -> 该项不占宽)。 +static func width_of(font: Font, text: String, font_size: int, + fallback_char_px := FALLBACK_CHAR_W_PX) -> float: + if text == "": + return 0.0 + var w := text_size(font, text, font_size).x + if w > 0.0: + return w + return float(text.length()) * fallback_char_px + +# Label3D 便捷入口:从节点自身取字体(Label3D.font 未设时回落 ThemeDB.fallback_font) +# 和 font_size,量出该 Label3D 当前文本的像素宽。label 为 null -> 0。 +static func label_width(label: Label3D, fallback_char_px := FALLBACK_CHAR_W_PX) -> float: + if label == null: + return 0.0 + var f: Font = label.font + if f == null: + f = ThemeDB.fallback_font + return width_of(f, label.text, label.font_size, fallback_char_px) + +# GetTextSize 的 h:item 尾标框 yEnd = ySize(PythonTextTail.cpp:749 / :809)。单行文本的 +# ySize ≈ 字体行高(ascent + descent),与具体字形无关。空串恒 0(参考端无 text +# instance -> 不占高);有字体量得正高 -> 精确像素;否则(无字体 / 非法 size)-> fallback。 +static func height_of(font: Font, text: String, font_size: int, + fallback_line_px := FALLBACK_LINE_H_PX) -> float: + if text == "": + return 0.0 + var h := text_size(font, text, font_size).y + if h > 0.0: + return h + return fallback_line_px + +# Label3D 便捷入口:同 label_width,量该 Label3D 当前文本的像素高。label 为 null -> 0。 +static func label_height(label: Label3D, fallback_line_px := FALLBACK_LINE_H_PX) -> float: + if label == null: + return 0.0 + var f: Font = label.font + if f == null: + f = ThemeDB.fallback_font + return height_of(f, label.text, label.font_size, fallback_line_px) + +# 增量 125(seam ⑩ 末尾 SetItemTextTailOwner):owner 后缀在参考端是与名字分开的 +# pOwnerTextInstance,单独 GetTextSize。poc 只有一个合并 Label3D,故提供「用某 Label3D 的 +# 字体 / 字号量任意子串」入口,让 ground_items 把名字子串、owner 子串分别度量。 +# label 为 null -> 回退(宽 = 字符数 * fallback_char_px;高 = 空串 0 / 非空 fallback_line_px)。 +static func string_width_like(label: Label3D, text: String, + fallback_char_px := FALLBACK_CHAR_W_PX) -> float: + if label == null: + return float(text.length()) * fallback_char_px + var f: Font = label.font + if f == null: + f = ThemeDB.fallback_font + return width_of(f, text, label.font_size, fallback_char_px) + +static func string_height_like(label: Label3D, text: String, + fallback_line_px := FALLBACK_LINE_H_PX) -> float: + if text == "": + return 0.0 + if label == null: + return fallback_line_px + var f: Font = label.font + if f == null: + f = ThemeDB.fallback_font + return height_of(f, text, label.font_size, fallback_line_px) diff --git a/project/text_metrics_test.gd b/project/text_metrics_test.gd new file mode 100644 index 00000000..178bd5f7 --- /dev/null +++ b/project/text_metrics_test.gd @@ -0,0 +1,141 @@ +# text_metrics_test —— §8.8 seam ⑦ / ⑩:文本尺寸换真实字体度量(GetTextSize 1:1)。 +# text_size(空 / null / 非法 size -> ZERO;真实度量为正、随串长 / 字号单调)、 +# width_of(空串 0 / 无字体回退字符数*px / 有字体 == text_size.x / font_size<=0 回退)、 +# label_width(Label3D.font 未设回落 ThemeDB.fallback_font / null -> 0)、 +# height_of / label_height(增量 124:空串 0 / 无字体回退 15px / 有字体 == text_size.y / +# 与串内容无关随字号单调 / font_size<=0 回退)、 +# string_width_like / string_height_like(增量 125:量 label 字体下的任意子串 —— owner +# 子串独立度量;label null -> 回退字符数*px / 单行估高;空串 -> 0)。 +# godot --headless --path project --script text_metrics_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const TextMetrics = preload("res://text_metrics.gd") + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _feq(a: float, b: float, m: String) -> void: + _ck(absf(a - b) < 0.0001, "%s (got %f want %f)" % [m, a, b]) + +func _init() -> void: + _run() + if _fail == 0: + print("PASS: text_metrics_test (§8.8 seam ⑦/⑩ 文本宽 w 123 / 高 h 124 / owner 子串独立度量 125 换真实字体度量 GetTextSize 1:1)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + # --- 0. 常量 ----------------------------------------------------------- + _feq(TextMetrics.FALLBACK_CHAR_W_PX, 7.0, "FALLBACK_CHAR_W_PX = 旧 SUBTAG/TAG_CHAR_W_PX 7.0") + + var font: Font = ThemeDB.fallback_font + + # --- 1. text_size 退化输入 -> Vector2.ZERO -------------------------- + _ck(TextMetrics.text_size(font, "", 16) == Vector2.ZERO, "空串 -> ZERO") + _ck(TextMetrics.text_size(null, "abc", 16) == Vector2.ZERO, "font 为 null -> ZERO") + _ck(TextMetrics.text_size(font, "abc", 0) == Vector2.ZERO, "font_size 0 -> ZERO") + _ck(TextMetrics.text_size(font, "abc", -4) == Vector2.ZERO, "font_size 负 -> ZERO") + + # --- 2. text_size 真实度量(有 fallback_font 时)------------------ + if font != null: + var w5 := TextMetrics.text_size(font, "MMMMM", 24).x + var w10 := TextMetrics.text_size(font, "MMMMMMMMMM", 24).x + _ck(w5 > 0.0, "非空串 @ 有效字号 -> 宽为正") + _ck(w10 > w5, "更长的串更宽(GetTextSize 逐字形累加)") + var wSmall := TextMetrics.text_size(font, "Test", 12).x + var wBig := TextMetrics.text_size(font, "Test", 48).x + _ck(wBig > wSmall, "同串更大字号更宽") + # width_of 有字体时应完全等于 text_size(...).x + _feq(TextMetrics.width_of(font, "Hello", 20), + TextMetrics.text_size(font, "Hello", 20).x, "width_of 有字体 == text_size.x") + else: + printerr("note: ThemeDB.fallback_font 为 null,跳过真实度量断言(仅测回退路径)") + + # --- 3. width_of 回退与边界 -------------------------------------------- + _feq(TextMetrics.width_of(font, "", 16), 0.0, "空串宽恒 0") + _feq(TextMetrics.width_of(null, "abcd", 16, 7.0), 28.0, "无字体 -> 字符数 * 7 = 28") + _feq(TextMetrics.width_of(null, "abcd", 16), 4.0 * TextMetrics.FALLBACK_CHAR_W_PX, + "无字体默认回退用 FALLBACK_CHAR_W_PX") + _feq(TextMetrics.width_of(font, "ab", 0, 5.0), 10.0, "有字体但 font_size 0 -> 回退 2*5") + _feq(TextMetrics.width_of(null, "x", 16, 3.0), 3.0, "单字符回退 = char_px") + + # --- 4. label_width(Label3D)--------------------------------------- + _ck(TextMetrics.label_width(null) == 0.0, "label 为 null -> 0") + + var lbl := Label3D.new() + lbl.text = "WWWW" + lbl.font_size = 24 + get_root().add_child(lbl) + var lw := TextMetrics.label_width(lbl, 7.0) + if font != null: + _ck(lw > 0.0, "Label3D.font 未设 -> 回落 ThemeDB.fallback_font 量得正宽") + _feq(lw, TextMetrics.text_size(ThemeDB.fallback_font, "WWWW", 24).x, + "label_width 用节点自身 text / font_size") + else: + _feq(lw, 4.0 * 7.0, "无 fallback_font -> label_width 回退字符数*px") + var lbl_empty := Label3D.new() + lbl_empty.text = "" + lbl_empty.font_size = 24 + get_root().add_child(lbl_empty) + _feq(TextMetrics.label_width(lbl_empty), 0.0, "空 Label3D 宽 0") + + # --- 5. height_of / label_height(增量 124:GetTextSize 的 h)-------- + _feq(TextMetrics.FALLBACK_LINE_H_PX, 15.0, "FALLBACK_LINE_H_PX = 旧 TAG_LINE_H_PX 15.0") + _feq(TextMetrics.height_of(font, "", 16), 0.0, "空串高恒 0") + _feq(TextMetrics.height_of(null, "abcd", 16, 15.0), 15.0, "无字体 -> 回退 15") + _feq(TextMetrics.height_of(null, "abcd", 16), TextMetrics.FALLBACK_LINE_H_PX, + "无字体默认回退用 FALLBACK_LINE_H_PX") + _feq(TextMetrics.height_of(font, "ab", 0, 9.0), 9.0, "有字体但 font_size 0 -> 回退 9") + if font != null: + var h_short := TextMetrics.height_of(font, "x", 20) + var h_long := TextMetrics.height_of(font, "WWWWWWWWWW", 20) + _ck(h_short > 0.0, "非空串 @ 有效字号 -> 高为正") + _feq(h_short, h_long, "单行高与串内容无关(≈ 字体行高)") + var h12 := TextMetrics.height_of(font, "Test", 12) + var h48 := TextMetrics.height_of(font, "Test", 48) + _ck(h48 > h12, "更大字号更高") + _feq(TextMetrics.height_of(font, "Hello", 20), + TextMetrics.text_size(font, "Hello", 20).y, "height_of 有字体 == text_size.y") + _ck(TextMetrics.label_height(null) == 0.0, "label 为 null -> 高 0") + if font != null: + _feq(TextMetrics.label_height(lbl, 15.0), + TextMetrics.text_size(ThemeDB.fallback_font, "WWWW", 24).y, + "label_height 用节点自身 text / font_size、回落 fallback_font") + else: + _feq(TextMetrics.label_height(lbl, 15.0), 15.0, "无 fallback_font -> label_height 回退 15") + _feq(TextMetrics.label_height(lbl_empty), 0.0, "空 Label3D 高 0") + + # --- 6. string_width_like / string_height_like(增量 125:owner 子串独立度量)------ + _feq(TextMetrics.string_width_like(null, "abcd", 7.0), 28.0, "label null -> 字符数 * 7") + _feq(TextMetrics.string_width_like(null, "abcd"), 4.0 * TextMetrics.FALLBACK_CHAR_W_PX, + "label null 默认回退用 FALLBACK_CHAR_W_PX") + _feq(TextMetrics.string_width_like(null, "", 7.0), 0.0, "label null + 空串 -> 0") + _feq(TextMetrics.string_height_like(null, "", 15.0), 0.0, "空串高恒 0(不占行)") + _feq(TextMetrics.string_height_like(lbl, ""), 0.0, "有 label + 空串 -> 0") + _feq(TextMetrics.string_height_like(null, "abcd", 15.0), 15.0, "label null + 非空 -> 回退 15") + _feq(TextMetrics.string_height_like(null, "abcd"), TextMetrics.FALLBACK_LINE_H_PX, + "label null 默认回退用 FALLBACK_LINE_H_PX") + if font != null: + # 量的是「给定子串」而非 label.text(lbl.text = "WWWW") + _feq(TextMetrics.string_width_like(lbl, "Hello"), + TextMetrics.text_size(ThemeDB.fallback_font, "Hello", 24).x, + "string_width_like 用 label 字号量给定子串(非 label.text)") + _feq(TextMetrics.string_height_like(lbl, "Hello"), + TextMetrics.text_size(ThemeDB.fallback_font, "Hello", 24).y, + "string_height_like == text_size(子串).y") + _ck(TextMetrics.string_width_like(lbl, "WWWWWWWW") > TextMetrics.string_width_like(lbl, "WW"), + "更长子串更宽") + else: + _feq(TextMetrics.string_width_like(lbl, "Hello", 7.0), 5.0 * 7.0, + "无 fallback_font -> string_width_like 回退字符数*px") + _feq(TextMetrics.string_height_like(lbl, "Hello", 15.0), 15.0, + "无 fallback_font -> string_height_like 回退单行估高") + + lbl.queue_free() + lbl_empty.queue_free() diff --git a/project/text_tail_arrange.gd b/project/text_tail_arrange.gd new file mode 100644 index 00000000..24fff3af --- /dev/null +++ b/project/text_tail_arrange.gd @@ -0,0 +1,105 @@ +# text_tail_arrange —— §8.8 seam ⑩ 收口:地面物品名条「投影后互相挤开防重叠」1:1。 +# CPythonTextTail::ArrangeTextTail 的 m_ItemTextTailList 块(PythonTextTail.cpp:160-190): +# 逐条 item 尾标,与其它每条比 isIn(AABB 相交),命中就把自己顶到对方框底下 +# `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(yTemp = 5),内层循环从头重跑, +# LimitCount 上限 20 次防死循环。isIn(:489-507)是标准 AABB:两框在 x、y 都有交叠即真。 +# 尾标框(:810-813 等):xStart = -w/2-2、xEnd = w/2+2、yStart = -2、yEnd = h。 +# (与 name_color / text_tail / chat_tail / name_show / damage_effect / target_board / +# pick_show / name_tail_layout 同一套纯静态镜像套路,本模块是第 29 个 canonical。) +# +# REF(40250 ClientVS22 source/ 为唯一基线): +# UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f +# UserInterface/PythonTextTail.cpp:153 ArrangeTextTail() +# :160 for (pInsertTextTail : m_ItemTextTailList) —— 逐条 item 名条 +# :164 int yTemp = 5; int LimitCount = 0; +# :167 for (pCompareTextTail : m_ItemTextTailList) —— 内层比对 +# :171 if (*itorCompare == *itor) { ++itorCompare; continue; } —— 跳过自己 +# :176 if (LimitCount >= 20) break; +# :180 if (isIn(pInsertTextTail, pCompareTextTail)) +# :182 pInsertTextTail->y = pCompareTextTail->y + pCompareTextTail->yEnd + yTemp; +# :184 itorCompare = m_ItemTextTailList.begin(); ++LimitCount; continue; —— 从头重跑 +# :189 ++itorCompare; +# UserInterface/PythonTextTail.cpp:489 isIn(pSource, pTarget): +# 框 Source = [x+xStart, x+xEnd] × [y+yStart, y+yEnd],Target 同; +# :500 if (x1S <= x2T && x2S >= x1T && y1S <= y2T && y2S >= y1T) return true; +# UserInterface/PythonTextTail.cpp:810-813 尾标框(SetTextTailOwnerText 等): +# xStart = -xSize/2 - 2、yStart = -2、xEnd = xSize/2 + 2、yEnd = ySize(xSize/ySize = 文本像素尺寸) +# UserInterface/PythonTextTail.cpp:706-737 SetItemTextTailOwner(dwVID, c_szName):掉落名条 +# 挂 owner 后缀。owner 非空时 owner 走独立 CGraphicTextInstance(黄 1,1,0 / CENTER / 文本 +# = c_szName + strOwnership,IDS_POSSESSIVE_MORPHENE 为空取字面 "'s"),单独 GetTextSize: +# :735 pTextTail->yEnd += float(yOwnerSize + 4) —— owner 另起一行、行距 4 +# :736 pTextTail->xStart = fMIN(xStart, -xOwnerSize/2 - 1) +# :737 pTextTail->xEnd = fMAX(xEnd, xOwnerSize/2 + 1) +# owner 为空时删 owner 实例、框复位成纯名字 xStart=-w/2-2 / xEnd=w/2+2 / yEnd=h。 +# +# seam ⑩:参考端在屏幕像素空间跑(y 向下),poc 地面物品名条是每物一个 billboard Label3D。 +# 本模块保持屏幕像素语义(ground_items 把 tag 世界锚点投影到屏幕、按文本长度估框),算出 +# 每条被下推的屏幕 y 后,poc 侧按 name_tail_layout 的同一 pixel_size 约定折成世界 y 向下的 +# 位移(屏幕向下 Δpx → 世界向下 Δpx*pixel_size)。文本框尺寸按字符数估(参考端是 +# GetTextSize 精确像素)= seam ⑩ 近似;只对 item 名条做,角色名字尾标不参与(同参考)。 +extends RefCounted + +const PUSH_GAP := 5.0 # ArrangeTextTail :164 的 yTemp +const MAX_ITERS := 20 # :176 的 LimitCount 上限(单条最多被下推的次数) + +# 尾标框相对锚点 (x, y) 的四边(PythonTextTail.cpp:810-813)。屏幕坐标,y 向下。 +static func box_start_x(w: float) -> float: return -w / 2.0 - 2.0 +static func box_end_x(w: float) -> float: return w / 2.0 + 2.0 +static func box_start_y() -> float: return -2.0 +static func box_end_y(h: float) -> float: return h + +# SetItemTextTailOwner(PythonTextTail.cpp:706-737)代入 poc 的 {w, h} 框:掉落名条挂上 +# 「owner + 所有格语素」后,去重叠框在纯名字框(name_w / name_h)基础上按 owner 独立 +# 文本度量(owner_w / owner_h)扩张: +# :735 yEnd += ownerH + 4 -> h = name_h + owner_h + 4 +# :737 xEnd = fMAX(nameW/2+2, ownerW/2+1) +# box_end_x(w) = w/2 + 2,令它等于 fMAX 结果 -> w = max(name_w, owner_w - 2) +# (框 x 两边对称,box_start_x 自动 = 参考 :736 的 fMIN 结果,无需单独算)。 +# owner_w / owner_h <= 0(无 owner,参考端删 owner 实例、框复位纯名字)-> {name_w, name_h}。 +static func item_box_with_owner(name_w: float, name_h: float, + owner_w := 0.0, owner_h := 0.0) -> Dictionary: + if owner_w <= 0.0 or owner_h <= 0.0: + return {"w": name_w, "h": name_h} + return {"w": maxf(name_w, owner_w - 2.0), "h": name_h + owner_h + 4.0} + +# isIn(PythonTextTail.cpp:489-507):两条尾标框在屏幕上是否相交(含边界,<= / >=)。 +# a / b 为 {x, y, w, h}(x, y = 锚点屏幕坐标;w, h = 文本像素尺寸)。 +static func boxes_overlap(a: Dictionary, b: Dictionary) -> bool: + var ax1: float = a["x"] + box_start_x(a["w"]) + var ay1: float = a["y"] + box_start_y() + var ax2: float = a["x"] + box_end_x(a["w"]) + var ay2: float = a["y"] + box_end_y(a["h"]) + var bx1: float = b["x"] + box_start_x(b["w"]) + var by1: float = b["y"] + box_start_y() + var bx2: float = b["x"] + box_end_x(b["w"]) + var by2: float = b["y"] + box_end_y(b["h"]) + return ax1 <= bx2 and ax2 >= bx1 and ay1 <= by2 and ay2 >= by1 + +# ArrangeTextTail :160-190 的 item 块。tails: Array[{x, y, w, h}](屏幕坐标,y 向下), +# 列表顺序即参考端 m_ItemTextTailList 顺序。返回等长 Array[float]:每条排布后的屏幕 y +# (未重叠的保持原值)。逐条 pInsert 与其它每条 pCompare 比 isIn,命中就把 pInsert 顶到 +# (pCompare.y + pCompare.yEnd + PUSH_GAP),内层从头重跑,单条最多下推 MAX_ITERS 次。 +# pCompare 用的是「已排布后」的 y(与参考端共用一张链表、y 就地改写一致)。 +static func arrange_item_tails(tails: Array) -> Array: + var ys: Array = [] + for t in tails: + ys.append(float(t["y"])) + var n := tails.size() + for i in n: + var limit := 0 + var j := 0 + while j < n: + if j == i: + j += 1 + continue + if limit >= MAX_ITERS: + break + var a := {"x": tails[i]["x"], "y": ys[i], "w": tails[i]["w"], "h": tails[i]["h"]} + var b := {"x": tails[j]["x"], "y": ys[j], "w": tails[j]["w"], "h": tails[j]["h"]} + if boxes_overlap(a, b): + ys[i] = ys[j] + box_end_y(tails[j]["h"]) + PUSH_GAP + j = 0 + limit += 1 + continue + j += 1 + return ys diff --git a/project/text_tail_arrange_test.gd b/project/text_tail_arrange_test.gd new file mode 100644 index 00000000..d45694c9 --- /dev/null +++ b/project/text_tail_arrange_test.gd @@ -0,0 +1,152 @@ +# text_tail_arrange_test —— §8.8 seam ⑩:ArrangeTextTail item 尾标去重叠 + isIn 1:1。 +# boxes_overlap(isIn AABB,含边界)、box_* 四边(-w/2-2 / w/2+2 / -2 / h)、 +# arrange_item_tails(单条不动 / 两条不叠不动 / 两条叠 -> 后者顶到前者框底下 / +# 级联下推 / 病态全同位收敛在 MAX_ITERS 内)、 +# item_box_with_owner(增量 125:无 owner 退化 / 有 owner -> h += ownerH + 4、 +# owner 更宽时 w = ownerW - 2 使 box_end_x == ownerW/2 + 1)。 +# godot --headless --path project --script text_tail_arrange_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const TextTailArrange = preload("res://text_tail_arrange.gd") + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _feq(a: float, b: float, m: String) -> void: + _ck(absf(a - b) < 0.0001, "%s (got %f want %f)" % [m, a, b]) + +func _init() -> void: + _run() + if _fail == 0: + print("PASS: text_tail_arrange_test (§8.8 seam ⑩ ArrangeTextTail item 尾标去重叠 + isIn 1:1)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + # --- 0. 常量 ------------------------------------------------------------- + _feq(TextTailArrange.PUSH_GAP, 5.0, "PUSH_GAP = yTemp = 5(:164)") + _feq(TextTailArrange.MAX_ITERS, 20.0, "MAX_ITERS = LimitCount 上限 20(:176)") + + # --- 1. 框四边(PythonTextTail.cpp:810-813)---------------------------- + _feq(TextTailArrange.box_start_x(40.0), -40.0 / 2.0 - 2.0, "xStart = -w/2 - 2 = -22") + _feq(TextTailArrange.box_end_x(40.0), 40.0 / 2.0 + 2.0, "xEnd = w/2 + 2 = 22") + _feq(TextTailArrange.box_start_y(), -2.0, "yStart = -2") + _feq(TextTailArrange.box_end_y(16.0), 16.0, "yEnd = h = 16") + + # --- 2. boxes_overlap(isIn,:489-507)------------------------------- + var a := {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0} # x∈[78,122] y∈[98,116] + var b_hit := {"x": 110.0, "y": 105.0, "w": 40.0, "h": 16.0} # x∈[88,132] y∈[103,121] + _ck(TextTailArrange.boxes_overlap(a, b_hit), "x、y 都交叠 -> 相交") + _ck(TextTailArrange.boxes_overlap(b_hit, a), "isIn 对称") + # 边界相接:inclusive <= / >= -> 视为相交 + var b_edge := {"x": 100.0 + 44.0, "y": 100.0, "w": 40.0, "h": 16.0} # x1T = 144-22 = 122 == x2S + _ck(TextTailArrange.boxes_overlap(a, b_edge), "边界相接(x2S == x1T)含边界 -> 相交") + # x 完全分离 + var b_farx := {"x": 100.0 + 45.0, "y": 100.0, "w": 40.0, "h": 16.0} # x1T = 145-22 = 123 > 122 + _ck(not TextTailArrange.boxes_overlap(a, b_farx), "x 分离 -> 不相交") + # y 完全分离 + var b_fary := {"x": 100.0, "y": 100.0 + 40.0, "w": 40.0, "h": 16.0} # y1T = 140-2 = 138 > 116 + _ck(not TextTailArrange.boxes_overlap(a, b_fary), "y 分离 -> 不相交") + + # --- 3. arrange_item_tails --------------------------------------------- + # 单条:不动 + var one := [{"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}] + _feq(TextTailArrange.arrange_item_tails(one)[0], 100.0, "单条 -> y 不变") + + # 两条不叠:都不动 + var two_far := [ + {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, + {"x": 100.0, "y": 300.0, "w": 40.0, "h": 16.0}, + ] + var r_far := TextTailArrange.arrange_item_tails(two_far) + _feq(r_far[0], 100.0, "不叠 -> 第 1 条不变") + _feq(r_far[1], 300.0, "不叠 -> 第 2 条不变") + + # 两条叠:外层先处理 i=0 —— 与 j=1 相交,把「自己」(第 1 条)顶到第 2 条框底下。 + # ys[0] = ys[1] + yEnd(第2条=16) + PUSH_GAP(5) = 100 + 16 + 5 = 121;ys[1] 不变。 + # 再看 i=1:此时第 1 条已在 y=121(x∈[78,122] y∈[119,137]),第 2 条 y=100 + # (y∈[98,116])——115? 119 > 116 -> 不再相交,第 2 条不动。 + var two_hit := [ + {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, + {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, + ] + var r_hit := TextTailArrange.arrange_item_tails(two_hit) + _feq(r_hit[0], 100.0 + 16.0 + 5.0, "两条同位 -> 第 1 条被顶到 121") + _feq(r_hit[1], 100.0, "两条同位 -> 第 2 条留原位") + _ck(not TextTailArrange.boxes_overlap( + {"x": 100.0, "y": r_hit[0], "w": 40.0, "h": 16.0}, + {"x": 100.0, "y": r_hit[1], "w": 40.0, "h": 16.0}), + "排布后两框不再相交") + + # 三条同位。逐条各自移动自己(参考端每个 pInsert 只改自己的 y): + # i=0:与 1 撞 -> ys[0] = 100 + 16 + 5 = 121;与 2(100)此时已不撞(121 的框 y∈[119,137])。 + # i=1:与 2(100)撞 -> 121;再与已排好的 0(121)撞 -> 121 + 21 = 142。 + # i=2:0 在 121、1 在 142,都在 100 上方(y 更大 = 屏幕更下),100 的槽已空出 -> 第 3 条不动。 + # 结果 [121, 142, 100]:三框互不相交(100→[98,116]、121→[119,137]、142→[140,158])。 + var three := [ + {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, + {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, + {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, + ] + var r3 := TextTailArrange.arrange_item_tails(three) + _feq(r3[0], 121.0, "三条同位 -> 第 1 条 121") + _feq(r3[1], 142.0, "三条同位 -> 第 2 条 142") + _feq(r3[2], 100.0, "三条同位 -> 第 3 条留原位(前两条已让开)") + # 两两不再相交 + for p in [[0, 1], [0, 2], [1, 2]]: + _ck(not TextTailArrange.boxes_overlap( + {"x": 100.0, "y": r3[p[0]], "w": 40.0, "h": 16.0}, + {"x": 100.0, "y": r3[p[1]], "w": 40.0, "h": 16.0}), + "三条排布后 %d/%d 不相交" % p) + + # 病态:很多条全同位 —— 单条被下推次数封顶 MAX_ITERS,函数必然返回(不死循环)。 + var many: Array = [] + for k in 40: + many.append({"x": 0.0, "y": 0.0, "w": 30.0, "h": 12.0}) + var rm := TextTailArrange.arrange_item_tails(many) + _ck(rm.size() == 40, "40 条全同位 -> 正常返回 40 个 y(未死循环)") + # 第 1 条只需 1 次下推就让开其余全部(它们都还在 y=0):0 + 12 + 5 = 17。 + _feq(rm[0], 17.0, "第 1 条一次下推到 17") + # 靠后的条目下推次数封顶 MAX_ITERS:每次至多 +(12 + 5)= 17 -> <= 340。 + for k in 40: + _ck(rm[k] <= (12.0 + 5.0) * TextTailArrange.MAX_ITERS + 0.001, + "第 %d 条下推量不超过 MAX_ITERS * (h + gap) = 340" % (k + 1)) + + # --- 4. 列表顺序即 m_ItemTextTailList 顺序:靠前的先让位(各自移动自己)------- + var ordered := [ + {"x": 50.0, "y": 200.0, "w": 20.0, "h": 10.0}, + {"x": 50.0, "y": 203.0, "w": 20.0, "h": 10.0}, + ] + var ro := TextTailArrange.arrange_item_tails(ordered) + _ck(ro[0] > ro[1], "靠前的条目被顶到靠后条目下方(参考端各 pInsert 移动自己)") + _feq(ro[1], 203.0, "靠后的条目保持原位") + + # --- 5. item_box_with_owner(SetItemTextTailOwner, PythonTextTail.cpp:706-737)------ + # 无 owner(owner_w / owner_h <= 0)-> 退化为纯名字框 + var b0 := TextTailArrange.item_box_with_owner(40.0, 16.0) + _feq(b0["w"], 40.0, "无 owner -> w = name_w") + _feq(b0["h"], 16.0, "无 owner -> h = name_h") + _feq(TextTailArrange.item_box_with_owner(40.0, 16.0, 0.0, 12.0)["h"], 16.0, "owner_w<=0 -> 退化") + _feq(TextTailArrange.item_box_with_owner(40.0, 16.0, 30.0, 0.0)["h"], 16.0, "owner_h<=0 -> 退化") + # 有 owner:yEnd += ownerH + 4(:735) + var bo := TextTailArrange.item_box_with_owner(40.0, 16.0, 30.0, 12.0) + _feq(bo["h"], 16.0 + 12.0 + 4.0, "有 owner -> h = name_h + owner_h + 4 = 32") + _feq(bo["w"], 40.0, "owner 比名字窄 -> w 仍 = name_w") + _feq(TextTailArrange.box_end_x(bo["w"]), 40.0 / 2.0 + 2.0, "owner 窄 -> box_end_x(w) == nameW/2 + 2") + # owner 比名字宽 -> box_end_x 收进 fMAX(nameW/2+2, ownerW/2+1)(:737)-> w = owner_w - 2 + var bw := TextTailArrange.item_box_with_owner(20.0, 16.0, 50.0, 12.0) + _feq(bw["w"], 50.0 - 2.0, "owner 更宽 -> w = owner_w - 2 = 48") + _feq(TextTailArrange.box_end_x(bw["w"]), 50.0 / 2.0 + 1.0, "-> box_end_x(w) == ownerW/2 + 1(参考 :737 fMAX)") + # 顶到带 owner 的框底下时 push = yEnd + PUSH_GAP + var stacked := [ + {"x": 0.0, "y": 0.0, "w": 20.0, "h": 16.0}, + {"x": 0.0, "y": 0.0, "w": bo["w"], "h": bo["h"]}, + ] + _feq(TextTailArrange.arrange_item_tails(stacked)[0], bo["h"] + TextTailArrange.PUSH_GAP, + "靠前条被顶到带 owner 框底下 = (name_h + owner_h + 4) + PUSH_GAP = 37") diff --git a/project/ui/chat_ui.gd b/project/ui/chat_ui.gd index 567a3bfe..4c785fd5 100644 --- a/project/ui/chat_ui.gd +++ b/project/ui/chat_ui.gd @@ -47,6 +47,9 @@ var _buffers := {} # tab -> Array[String] var _locale: RefCounted var _proto: Node var _fishing_active := false +var _whisper_dialog: Panel +var _whisper_target: LineEdit +var _whisper_message: LineEdit func setup(m2client: Node, parent: Node, assets_root := "") -> void: client = m2client @@ -84,10 +87,19 @@ func focus_input() -> void: # 预填 "/w <名> " 并聚焦(好友列表点名字时调)。 func start_whisper(name: String) -> void: - if _input: - _input.text = "/w %s " % name - _input.caret_column = _input.text.length() - _input.grab_focus() + if _whisper_dialog == null or name.strip_edges() == "": + return + _whisper_target.text = name.strip_edges() + _whisper_message.clear() + _whisper_dialog.visible = true + if _whisper_message.is_inside_tree(): + _whisper_message.grab_focus() + +func is_whisper_open() -> bool: + return _whisper_dialog != null and _whisper_dialog.visible + +func whisper_target() -> String: + return _whisper_target.text if _whisper_target != null else "" func is_typing() -> bool: return _input != null and _input.has_focus() @@ -152,6 +164,92 @@ func _build(parent: Node) -> void: _input.add_theme_font_size_override("font_size", 12) _input.text_submitted.connect(_on_submit) _root.add_child(_input) + _build_whisper_dialog(parent) + +func _build_whisper_dialog(parent: Node) -> void: + # root/game.py 的 OpenWhisperDialog 是独立窗口,不应把私聊目标写回公共 + # 聊天输入行。这里用 UiManager 同级的独立 Panel,保留自己的焦点和发送键。 + _whisper_dialog = Panel.new() + _whisper_dialog.name = "WhisperDialog" + _whisper_dialog.set_anchors_preset(Control.PRESET_CENTER) + _whisper_dialog.position = Vector2(-170, -82) + _whisper_dialog.size = Vector2(340, 164) + _whisper_dialog.visible = false + _whisper_dialog.mouse_filter = Control.MOUSE_FILTER_STOP + var bg := StyleBoxFlat.new() + bg.bg_color = Color(0.04, 0.05, 0.08, 0.97) + bg.border_color = Color(0.62, 0.36, 0.72, 0.95) + bg.set_border_width_all(1) + bg.set_corner_radius_all(5) + _whisper_dialog.add_theme_stylebox_override("panel", bg) + parent.add_child(_whisper_dialog) + + var title := Label.new() + title.text = "私聊" + title.position = Vector2(14, 10) + title.add_theme_font_size_override("font_size", 16) + _whisper_dialog.add_child(title) + var target_label := Label.new() + target_label.text = "对象" + target_label.position = Vector2(14, 45) + _whisper_dialog.add_child(target_label) + _whisper_target = LineEdit.new() + _whisper_target.position = Vector2(66, 41) + _whisper_target.size = Vector2(256, 26) + _whisper_target.editable = false + _whisper_target.mouse_filter = Control.MOUSE_FILTER_IGNORE + _whisper_dialog.add_child(_whisper_target) + var message_label := Label.new() + message_label.text = "内容" + message_label.position = Vector2(14, 80) + _whisper_dialog.add_child(message_label) + _whisper_message = LineEdit.new() + _whisper_message.position = Vector2(66, 76) + _whisper_message.size = Vector2(256, 26) + _whisper_message.placeholder_text = "输入私聊内容" + _whisper_message.text_submitted.connect(_on_whisper_submit) + _whisper_dialog.add_child(_whisper_message) + var cancel := Button.new() + cancel.text = "取消" + cancel.position = Vector2(170, 119) + cancel.size = Vector2(70, 28) + cancel.pressed.connect(_close_whisper) + _whisper_dialog.add_child(cancel) + var send := Button.new() + send.text = "发送" + send.position = Vector2(248, 119) + send.size = Vector2(74, 28) + send.pressed.connect(func(): _on_whisper_submit(_whisper_message.text)) + _whisper_dialog.add_child(send) + +func _close_whisper() -> void: + if _whisper_dialog: + _whisper_dialog.visible = false + if _whisper_message and _whisper_message.is_inside_tree(): + _whisper_message.release_focus() + +func _on_whisper_submit(text: String) -> void: + var to := _whisper_target.text.strip_edges() if _whisper_target else "" + var msg := text.strip_edges() + if to == "" or msg == "" or client == null: + return + client.whisper(to, msg) + _append(1, T_WHISPER, "→ %s: %s" % [to, msg]) + _append(0, T_WHISPER, "→ %s: %s" % [to, msg]) + _close_whisper() + +func _unhandled_input(event: InputEvent) -> void: + if not is_whisper_open() or not (event is InputEventKey): + return + var key := event as InputEventKey + if not key.pressed: + return + if key.keycode == KEY_ESCAPE: + _close_whisper() + get_viewport().set_input_as_handled() + elif key.keycode not in [KEY_ENTER, KEY_KP_ENTER]: + # 独立私聊窗口打开时,WASD / 快捷栏键不应穿透到世界。 + get_viewport().set_input_as_handled() func _select_tab(i: int) -> void: _tab = i diff --git a/project/ui/dungeon_result_ui.gd b/project/ui/dungeon_result_ui.gd new file mode 100644 index 00000000..8bb71f17 --- /dev/null +++ b/project/ui/dungeon_result_ui.gd @@ -0,0 +1,106 @@ +# DungeonResultUI —— CPythonEventManager::ShowDungeonResult 的轻量 Godot 载体。 +# +# 参考端收到 [DUNGEON_RESULT] 后直接调用 interface.ShowDungeonResult,并把 +# 九个整数交给独立结算窗口。本组件只负责呈现这九个服务端字段,不修改副本 +# 状态,也不凭空发奖励;奖励仍由网络协议驱动。 +extends Node + +const RESULT_KEYS := [ + "killstone_count", "killmob_count", "find_hidden", "hidden_total", + "use_potion", "is_revived", "killallmob", "total_time", "bonus_exp", +] + +var _root: Control +var _rows: VBoxContainer +var _result: Dictionary = {} + +func setup(parent: Node) -> void: + if _root != null: + return + _root = Control.new() + _root.name = "DungeonResult" + _root.set_anchors_preset(Control.PRESET_CENTER) + _root.position = Vector2(-190, -175) + _root.size = Vector2(380, 350) + _root.visible = false + _root.mouse_filter = Control.MOUSE_FILTER_STOP + _root.z_index = 120 + parent.add_child(_root) + + var panel := Panel.new() + panel.set_anchors_preset(Control.PRESET_FULL_RECT) + var style := StyleBoxFlat.new() + style.bg_color = Color(0.055, 0.045, 0.035, 0.98) + style.border_color = Color(0.78, 0.62, 0.30, 1.0) + style.set_border_width_all(2) + style.set_corner_radius_all(6) + panel.add_theme_stylebox_override("panel", style) + _root.add_child(panel) + + var title := Label.new() + title.text = "副本结算" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.position = Vector2(16, 14) + title.size = Vector2(348, 32) + title.add_theme_font_size_override("font_size", 20) + _root.add_child(title) + + _rows = VBoxContainer.new() + _rows.position = Vector2(34, 58) + _rows.size = Vector2(312, 218) + _rows.add_theme_constant_override("separation", 5) + _root.add_child(_rows) + + var close_button := Button.new() + close_button.text = "关闭" + close_button.position = Vector2(130, 300) + close_button.size = Vector2(120, 32) + close_button.pressed.connect(close) + _root.add_child(close_button) + +func show_result(result: Dictionary) -> void: + if _root == null: + return + _result = result.duplicate(true) + for child in _rows.get_children(): + child.queue_free() + for spec in _display_rows(): + var row := Label.new() + row.text = str(spec[0]) + ":" + str(spec[1]) + row.add_theme_font_size_override("font_size", 14) + _rows.add_child(row) + _root.visible = true + +func open(result: Dictionary) -> void: + show_result(result) + +func close() -> void: + if _root: + _root.visible = false + +func is_open() -> bool: + return _root != null and _root.visible + +func result() -> Dictionary: + return _result.duplicate(true) + +func _display_rows() -> Array: + var elapsed := maxi(0, int(_result.get("total_time", 0))) + var mins := elapsed / 60 + var secs := elapsed % 60 + return [ + ["击杀石碑", int(_result.get("killstone_count", 0))], + ["击杀怪物", int(_result.get("killmob_count", 0))], + ["隐藏目标", "%d / %d" % [int(_result.get("find_hidden", 0)), int(_result.get("hidden_total", 0))]], + ["使用药水", int(_result.get("use_potion", 0))], + ["复活次数", int(_result.get("is_revived", 0))], + ["完成条件", "已完成" if int(_result.get("killallmob", 0)) != 0 else "未完成"], + ["用时", "%02d:%02d" % [mins, secs]], + ["奖励经验", int(_result.get("bonus_exp", 0))], + ] + +func _unhandled_input(event: InputEvent) -> void: + if is_open() and event is InputEventKey and event.pressed \ + and not event.echo and event.keycode == KEY_ESCAPE: + close() + get_viewport().set_input_as_handled() diff --git a/project/ui/game_option_ui.gd b/project/ui/game_option_ui.gd index f88425c2..d26aca73 100644 --- a/project/ui/game_option_ui.gd +++ b/project/ui/game_option_ui.gd @@ -92,7 +92,7 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void: uiscript_dir = assets_root.path_join("uiscript") _cfg.load(config_path()) for k in ["name_color", "target_board", "view_chat", "always_show_name", "show_damage", "salestext"]: - _display[k] = int(_cfg.get_value("gameopt", k, 1 if k in ["view_chat", "always_show_name", "show_damage"] else 0)) + _display[k] = int(_cfg.get_value("gameopt", k, 1 if k in ["target_board", "view_chat", "always_show_name", "show_damage"] else 0)) _block_mode = int(_cfg.get_value("gameopt", "block_mode", 0)) if client and client.has_signal("block_mode_changed"): client.block_mode_changed.connect(_on_server_block_mode) diff --git a/project/ui/ground_items.gd b/project/ui/ground_items.gd index f0f3c562..85d278cd 100644 --- a/project/ui/ground_items.gd +++ b/project/ui/ground_items.gd @@ -10,20 +10,58 @@ extends Node const PICKUP_RANGE := 3.0 +# §8.8 seam ⑩(CPythonTextTail::ArrangeTextTail, PythonTextTail.cpp:153-190):多条掉落 +# 名条投影到屏幕后互相挤开防重叠。参考端在屏幕像素空间跑(y 向下),poc 每物一个 +# billboard Label3D —— 把名条世界锚点投影到屏幕、按 Label3D 的真实字体度量取文本框,跑 +# text_tail_arrange.arrange_item_tails 得到每条被下推的屏幕 y,再按 Label3D 的 +# pixel_size 折回世界 y(屏幕向下 Δpx → 世界向下 Δpx*pixel_size)。 +# 文本框宽 / 高取名条 Label3D 的真实字体度量(TextMetrics.label_width / label_height +# = GetTextSize 的 w / h,1:1,增量 123 / 124;TAG_CHAR_W_PX / TAG_LINE_H_PX 仅作无字体兜底)。 +# 增量 125(SetItemTextTailOwner, PythonTextTail.cpp:706-737):有 owner 的掉落,参考端 +# owner 是与名字分开的 pOwnerTextInstance,单独 GetTextSize,令去重叠框 yEnd += ownerH + 4、 +# x 两边按 owner 半宽 ±1 外扩 —— 去重叠框把「名字」「owner + 所有格」两子串分别用 tag 的 +# 字体度量后走 TextTailArrange.item_box_with_owner 合成(框度量 1:1)。 +# 增量 126(SetItemTextTailOwner :712-745 + ArrangeTextTail :191-207):owner 后缀不再拼进 +# 名字串,而是 drop 节点下第二个 Label3D "owner_tag" —— CENTER 对齐、黄字 (1,1,0)、文本 +# = owner + strOwnership(:722,本地化语素为空时字面 "'s",经 setup(possessive) 注入); +# owner 落在尾标基点、名字实例下移 15px(:200 pTextInstance->SetPosition(x, y + 15.0f, z)); +# owner 为空时删 owner_tag(:741-745)。名字色仍走 _process 远近着色(参考名字实例取 +# pInsertTextTail->Color 的道具品阶色)。 +const TextTailArrange = preload("res://text_tail_arrange.gd") +const TextMetrics = preload("res://text_metrics.gd") +const TAG_BASE_Y := 0.75 # 名条相对掉落物的默认局部 y(未重叠时) +const TAG_CHAR_W_PX := 7.0 # 无字体时的每字符估宽兜底(有字体走 GetTextSize 的 w) +const TAG_LINE_H_PX := 15.0 # 无字体时的单行估高兜底(有字体走 GetTextSize 的 h) +const OWNER_POSSESSIVE := "'s" # SetItemTextTailOwner :722 strOwnership(IDS_POSSESSIVE_MORPHENE 为空时的字面值) +const OWNER_NAME_GAP_PX := 15.0 # ArrangeTextTail :200 pTextInstance->SetPosition(x, y + 15.0f, z):owner 行下方的名字行偏移 +const DEFAULT_ITEM_COLOR := Color(1.0, 0.9, 0.5) +const ITEM_GRADE_COLORS := { + 0: Color(1.0, 1.0, 1.0), + 1: Color(0.55, 0.85, 1.0), + 2: Color(1.0, 0.85, 0.25), + 3: Color(0.85, 0.45, 1.0), + 4: Color(1.0, 0.35, 0.35), +} + var client: Node var mount: Node3D var proto: Node var item_list: RefCounted var _player_getter: Callable +var _camera_getter: Callable var _by_vid := {} # vid -> Node3D +var _possessive := OWNER_POSSESSIVE # setup(possessive) 注入;空 -> 字面 "'s"(SetItemTextTailOwner :722) func setup(m2client: Node, mount_node: Node3D, player_getter: Callable, - proto_node: Node = null, il: RefCounted = null) -> void: + proto_node: Node = null, il: RefCounted = null, + camera_getter: Callable = Callable(), possessive := "") -> void: client = m2client + _possessive = possessive if possessive != "" else OWNER_POSSESSIVE # :722 == "" ? "'s" : loc mount = mount_node proto = proto_node item_list = il _player_getter = player_getter + _camera_getter = camera_getter if client.has_signal("ground_item_added"): client.ground_item_added.connect(_on_added) if client.has_signal("ground_item_removed"): @@ -39,10 +77,17 @@ func _on_added(d: Dictionary) -> void: return var vnum := int(d.get("vnum", 0)) var owner := String(d.get("owner", "")) + var item_color := _item_color(vnum, d) if _by_vid.has(vid): var existing: Node3D = _by_vid[vid] if is_instance_valid(existing): - (existing.get_node("tag") as Label3D).text = _tag_text(vnum, owner) + var existing_tag := existing.get_node("tag") as Label3D + existing_tag.text = _name_for(vnum) + existing_tag.modulate = item_color + existing.set_meta("item_name", existing_tag.text) + existing.set_meta("item_owner", owner) + existing.set_meta("item_color", item_color) + _apply_owner_tag(existing, owner, item_color) return var node := Node3D.new() node.name = "drop_%d" % vid @@ -60,14 +105,18 @@ func _on_added(d: Dictionary) -> void: node.add_child(mesh) var tag := Label3D.new() tag.name = "tag" - tag.text = _tag_text(vnum, owner) - tag.position.y = 0.75 + tag.text = _name_for(vnum) # 名字实例只放名字,owner 走独立 owner_tag(:723 不再拼串) + tag.position.y = TAG_BASE_Y tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED tag.no_depth_test = true tag.pixel_size = 0.005 - tag.modulate = Color(1.0, 0.9, 0.5) + tag.modulate = item_color node.add_child(tag) node.set_meta("vid", vid) + node.set_meta("item_name", tag.text) + node.set_meta("item_owner", owner) + node.set_meta("item_color", item_color) + _apply_owner_tag(node, owner, item_color) mount.add_child(node) _by_vid[vid] = node @@ -98,6 +147,67 @@ func _process(dt: float) -> void: # 近了名条高亮 var near := p.global_position.distance_to(n.global_position) <= PICKUP_RANGE (n.get_node("tag") as Label3D).modulate = Color(0.4, 1.0, 0.4) if near else Color(1.0, 0.9, 0.5) + _arrange_tags() + +# ArrangeTextTail :153-190 的 item 块:把所有掉落名条投影到屏幕,跑 isIn 去重叠, +# 再把被下推的屏幕 Δy 折回各 Label3D 的世界局部 y。无相机 / 少于 2 条时全部回默认。 +func _arrange_tags() -> void: + var cam: Camera3D = _camera_getter.call() if _camera_getter.is_valid() else null + if cam == null: + return + # m_ItemTextTailList 顺序:按 vid 排序保证稳定(≈ 注册先后)。 + var vids: Array = _by_vid.keys() + vids.sort() + var tags: Array = [] # 与 boxes 平行:{tag, base_screen_y} + var boxes: Array = [] + for vid in vids: + var n: Node3D = _by_vid[vid] + if not is_instance_valid(n): + continue + var tag := n.get_node_or_null("tag") as Label3D + if tag == null: + continue + var anchor := n.global_position + Vector3(0, TAG_BASE_Y, 0) + if cam.is_position_behind(anchor): + _place_tail(n, tag, 0.0) + continue + var sp := cam.unproject_position(anchor) + tags.append({"n": n, "tag": tag, "y0": sp.y}) + # 名字子串 + owner 子串分开度量(SetItemTextTailOwner 的 pOwnerTextInstance), + # 再走 item_box_with_owner 合成去重叠框(yEnd += ownerH + 4)。 + var iname := String(n.get_meta("item_name", tag.text)) + var iowner := String(n.get_meta("item_owner", "")) + var owner_txt := (iowner + _possessive) if iowner != "" else "" + var nbox: Dictionary = TextTailArrange.item_box_with_owner( + TextMetrics.string_width_like(tag, iname, TAG_CHAR_W_PX), + TextMetrics.string_height_like(tag, iname, TAG_LINE_H_PX), + TextMetrics.string_width_like(tag, owner_txt, TAG_CHAR_W_PX), + TextMetrics.string_height_like(tag, owner_txt, TAG_LINE_H_PX)) + boxes.append({ + "x": sp.x, "y": sp.y, + "w": maxf(1.0, nbox["w"]), + "h": maxf(1.0, nbox["h"]), + }) + if boxes.size() < 2: + if boxes.size() == 1: + _place_tail(tags[0]["n"], tags[0]["tag"], 0.0) + return + var arranged: Array = TextTailArrange.arrange_item_tails(boxes) + for i in tags.size(): + var d_px: float = arranged[i] - float(tags[i]["y0"]) # 屏幕向下为正 + _place_tail(tags[i]["n"], tags[i]["tag"], d_px) + +# ArrangeTextTail :191-207:owner 存在时 owner 实例落在尾标基点 (x, y)、名字实例下移 15px +# (:200 pTextInstance->SetPosition(x, y + 15.0f, z));无 owner 时名字实例落在基点 (:206)。 +# d_px = 去重叠把该尾标下推的屏幕像素(0 = 未被推)。屏幕向下 → 世界向下(* pixel_size)。 +func _place_tail(n: Node3D, tag: Label3D, d_px: float) -> void: + var px: float = tag.pixel_size + var owner_tag := n.get_node_or_null("owner_tag") as Label3D + if owner_tag != null: + owner_tag.position.y = TAG_BASE_Y - d_px * px + tag.position.y = TAG_BASE_Y - (d_px + OWNER_NAME_GAP_PX) * px + else: + tag.position.y = TAG_BASE_Y - d_px * px # 捡最近的一个(范围内)。返回捡的 vid,0 = 没有。 func try_pickup() -> int: @@ -145,6 +255,65 @@ func _name_for(vnum: int) -> String: return item_list.type_of(vnum) return "item %d" % vnum -func _tag_text(vnum: int, owner: String) -> String: - var text := _name_for(vnum) - return "%s (%s)" % [text, owner] if not owner.is_empty() else text +# SetItemTextTailOwner :712-745:owner 非空 → 一个独立的 CENTER 黄字 (1,1,0) text instance, +# 文本 = owner + strOwnership(:722/:723);owner 为空 → 删该实例(:741-745)。 +# poc:drop 节点下第二个 Label3D "owner_tag"(渲染顺序在名字之后,:373)。 +func _apply_owner_tag(node: Node3D, owner: String, item_color := DEFAULT_ITEM_COLOR) -> void: + var owner_tag := node.get_node_or_null("owner_tag") as Label3D + if owner.is_empty(): + if owner_tag != null: + node.remove_child(owner_tag) + owner_tag.queue_free() + return + if owner_tag == null: + owner_tag = Label3D.new() + owner_tag.name = "owner_tag" + owner_tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER # :727 HORIZONTAL_ALIGN_CENTER + owner_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED + owner_tag.no_depth_test = true + owner_tag.pixel_size = 0.005 + owner_tag.position.y = TAG_BASE_Y + node.add_child(owner_tag) + owner_tag.modulate = item_color # :729 的 pInsertTextTail->Color + owner_tag.text = owner + _possessive + +func _item_color(vnum: int, drop: Dictionary) -> Color: + # GC_ITEM_GROUND_ADD 只带 vnum/owner/pos;优先消费服务端或 fixture 显式携带 + # 的颜色,其次消费 item proto 的扩展字段。老数据没有品阶字段时保留旧的 + # 金色 fallback,而不会把 owner 行错误固定成纯黄。 + var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + for source in [drop, pd]: + for key in ["item_color", "rarity_color", "name_color", "color"]: + if source.has(key): + var parsed: Variant = _coerce_color(source[key]) + if parsed != null: + return parsed + for key in ["item_grade", "rarity", "quality", "grade"]: + if source.has(key): + var grade := int(source[key]) + if ITEM_GRADE_COLORS.has(grade): + return ITEM_GRADE_COLORS[grade] + return DEFAULT_ITEM_COLOR + +func _coerce_color(value: Variant) -> Variant: + if value is Color: + return value + if value is Array and value.size() >= 3: + var scale := 255.0 if float(value[0]) > 1.0 or float(value[1]) > 1.0 \ + or float(value[2]) > 1.0 else 1.0 + var alpha := float(value[3]) if value.size() > 3 else 1.0 + if alpha > 1.0: + alpha /= 255.0 + return Color(float(value[0]) / scale, float(value[1]) / scale, + float(value[2]) / scale, alpha) + if value is Dictionary and value.has_all(["r", "g", "b"]): + var scale := 255.0 if float(value["r"]) > 1.0 or float(value["g"]) > 1.0 \ + or float(value["b"]) > 1.0 else 1.0 + var alpha := float(value.get("a", 1.0)) + if alpha > 1.0: + alpha /= 255.0 + return Color(float(value["r"]) / scale, float(value["g"]) / scale, + float(value["b"]) / scale, alpha) + if value is String and not String(value).strip_edges().is_empty(): + return Color.from_string(String(value), DEFAULT_ITEM_COLOR) + return null diff --git a/project/ui/quest_dialog.gd b/project/ui/quest_dialog.gd index 8d56338b..3b7ec4e7 100644 --- a/project/ui/quest_dialog.gd +++ b/project/ui/quest_dialog.gd @@ -33,6 +33,9 @@ signal map_signals_cleared() signal atlas_center_requested(x: int, y: int) signal camera_event_requested(kind: String, values: Array) signal fade_event_requested(kind: String, speed: float) +signal camera_event_requested_full(kind: String, setting: Dictionary, blendtime: float) +signal fade_event_requested_full(kind: String, speed: float) +signal done_event() var client: Node var proto: Node @@ -43,27 +46,204 @@ var _btnrow: VBoxContainer var _image_layer: Control var _confirm_pid := 0 var _confirm_wait := false +var _active_set +var _active_commands: Array = [] +var _active_cursor := 0 +var _active_script := false + +signal dungeon_result_requested(result: Dictionary) func setup(m2client: Node, parent: Node, proto_node: Node = null, assets_root := "") -> void: client = m2client proto = proto_node _assets_root = assets_root if assets_root != "" else AssetRoot.path() set_process_unhandled_input(true) + set_process(true) _build(parent) if client.has_signal("script_dialog"): client.script_dialog.connect(_on_script) if client.has_signal("quest_confirm_ask"): client.quest_confirm_ask.connect(_on_confirm) +func set_proto(proto_node: Node) -> void: + # Proto is created after the dialog in GameScene because item/mob loading is + # expensive. Keep the dialog usable before that point, then resolve future + # [ITEM]/[MOB] tags against the real tables. + proto = proto_node + func is_open() -> bool: return _root != null and _root.visible -# --- 收 GC_SCRIPT ------------------------------------------------------ +func _process(delta: float) -> void: + if not _active_script or _active_set == null: + return + # The first headless frame may report a zero delta. ClientVS22 still runs + # one UpdateEventSet tick, so use one nominal 60 FPS frame as the floor. + _process_active(maxf(delta * 1000.0, 16.0)) -func _on_script(_skin: int, text: String) -> void: - var parsed := parse_script(text) - if parsed.body.strip_edges() == "" and parsed.choices.is_empty(): - _emit_side_effects(parsed) +# Deterministic entry point used by the frame loop and headless tests. The +# argument is milliseconds because EventManager's lLastDelayTime/lWaitingTime +# are millisecond values in ClientVS22. +func process_events(elapsed_ms: float) -> void: + if _active_script and _active_set != null: + _process_active(maxf(0.0, elapsed_ms)) + +func begin_script(src: String) -> void: + _active_set = EventSet.new() + _active_set.init_event_set() + _active_commands = _tokenize(src) + _active_cursor = 0 + _active_script = true + # RegisterEventSetFromString replaces a leading RUN_CINEMA event set with + # the referenced .msc file. Keep the signal for the presentation layer and + # load the event text when the loose asset is available. + if not _active_commands.is_empty() and String(_active_commands[0].get("literal", "")) == "" \ + and String(_active_commands[0].get("name", "")).to_upper().trim_prefix("/") == "RUN_CINEMA": + var cinema := _arg_s(String(_active_commands[0].get("tok", "")), "value") + if cinema != "": + cinema_requested.emit(cinema) + var cinema_text := _read_script_file(cinema) + if cinema_text != "": + _active_commands = _tokenize(cinema_text) + else: + # No local .msc: keep the empty event set alive so the caller can + # show its own cinema player, but do not re-process RUN_CINEMA. + _active_commands = [] + _present_event_set(_active_set) + _root.visible = true + opened.emit() + # Start the same nominal first tick immediately. This keeps the dialog + # responsive even when a SceneTree caller resumes before child _process() + # callbacks (notably in headless fixtures). + _process_active(16.0) + +func _read_script_file(vpath: String) -> String: + var rel := vpath.replace("\\", "/") + if rel.length() >= 2 and rel[1] == ":": + rel = rel.substr(2) + rel = rel.lstrip("/") + var candidates: Array[String] = [] + if _assets_root != "": + candidates.append(_assets_root.path_join(rel)) + var resolved := UiAssets._resolve(_assets_root, rel) + if resolved != "": + candidates.append(resolved) + for path in candidates: + if FileAccess.file_exists(path): + return FileAccess.get_file_as_string(path) + return "" + +func skip() -> void: + if not _active_script or _active_set == null: + return + # CPythonEventManager::Skip: an input lock is released; otherwise the + # current delay is forced to expire and the next command is consumed. + if _active_set.isLock: + _active_set.lLastDelayTime = 0 + _active_set.isLock = false + else: + _active_set.lLastDelayTime = -1 + _process_active(0.0) + +func end_event_process() -> void: + if _active_set != null: + _active_set.isWaitFlag = false + +func _process_active(elapsed_ms: float) -> void: + var remaining := elapsed_ms + var forced_step: bool = _active_set.lLastDelayTime < 0 + if forced_step: + _active_set.lLastDelayTime = 0 + while _active_script and _active_set != null: + if _active_set.isConfirmWait: + if int(Time.get_ticks_msec() / 1000) >= _active_set.iConfirmEndTime: + _active_set.isConfirmWait = false + _active_set.iConfirmEndTime = 0 + _confirm_wait = false + if client and client.has_method("quest_cancel"): + client.quest_cancel() + _active_script = false + close() + else: + _present_event_set(_active_set) + return + if _active_set.isWaitFlag or _active_set.isLock: + _present_event_set(_active_set) + return + if _active_set.lLastDelayTime > 0: + if remaining <= 0.0: + return + var wait := float(_active_set.lLastDelayTime) + if remaining < wait: + _active_set.lLastDelayTime -= int(remaining) + return + _active_set.lLastDelayTime = 0 + remaining -= wait + if _active_cursor >= _active_commands.size(): + _active_script = false + _active_set.isLock = true + _present_event_set(_active_set) + return + var command: Dictionary = _active_commands[_active_cursor] + _active_cursor += 1 + _process_event_set(_active_set, command) + _emit_runtime_side_effect(command, _active_set) + _present_event_set(_active_set) + forced_step = false + if _active_set.isWaitFlag or _active_set.isLock or _active_set.isConfirmWait: + return + if remaining <= 0.0: + return + +func _finish_runtime() -> void: + _active_script = false + _active_commands.clear() + _active_cursor = 0 + +func _emit_runtime_side_effect(command: Dictionary, es: EventSet) -> void: + var idx := _get_script_event_index(String(command.get("name", ""))) + if not idx.ok: + return + var tok := String(command.get("tok", "")) + match int(idx.type): + EVT_SELECT_ITEM: + select_item_requested.emit() + EVT_QUEST_BUTTON: + if not es.quest_buttons.is_empty(): + var q: Dictionary = es.quest_buttons.back() + quest_button_received.emit(int(q.get("index", -1)), String(q.get("title", "")), + String(q.get("icon_type", "")), String(q.get("icon_name", ""))) + EVT_QUEST_BUTTON_CLOSE: + quest_button_cleared.emit(_arg_i(tok, "idx", 0)) + EVT_ADD_MAP_SIGNAL: + map_signal_added.emit(_arg_f(tok, "x", 0), _arg_f(tok, "y", 1)) + EVT_CLEAR_MAP_SIGNAL: + map_signals_cleared.emit() + EVT_SET_CENTER_MAP_POSITION: + atlas_center_requested.emit(_arg_i(tok, "x", 0), _arg_i(tok, "y", 1)) + EVT_SET_CAMERA, EVT_BLEND_CAMERA, EVT_RESTORE_CAMERA: + var camera: Dictionary = es.camera_events.back() if not es.camera_events.is_empty() else {} + var kind := String(camera.get("kind", "")) + camera_event_requested.emit(kind, camera.get("values", [])) + camera_event_requested_full.emit(kind, camera.get("setting", {}).duplicate(true), + float(camera.get("blendtime", 0.0))) + EVT_FADE_OUT, EVT_FADE_IN, EVT_WHITE_OUT, EVT_WHITE_IN: + var fade: Dictionary = es.fade_events.back() if not es.fade_events.is_empty() else {} + var fade_kind := String(fade.get("kind", "")) + var fade_speed := float(fade.get("speed", 0.0)) + fade_event_requested.emit(fade_kind, fade_speed) + fade_event_requested_full.emit(fade_kind, fade_speed) + EVT_DUNGEON_RESULT: + dungeon_result_requested.emit(es.dungeon_result.duplicate()) + EVT_END_CONFIRM_WAIT: + _confirm_wait = false + _: + pass + +func _present_event_set(es: EventSet) -> void: + var parsed := _event_set_result(es) + if parsed.body.strip_edges() == "" and parsed.choices.is_empty() and not parsed.has_input \ + and not parsed.confirm_wait and not _active_script: close() return _text.text = parsed.body @@ -77,10 +257,13 @@ func _on_script(_skin: int, text: String) -> void: _btnrow.custom_minimum_size = Vector2(width - 32, 0) _confirm_wait = parsed.confirm_wait _render_images(parsed.images, parsed.title_image) - _fill_buttons(parsed.choices, parsed.has_next, parsed.has_input, _confirm_wait) - _root.visible = true - opened.emit() - _emit_side_effects(parsed) + _fill_buttons(parsed.choices, parsed.has_next, parsed.has_input, parsed.confirm_wait, + String(parsed.get("next_button_type", ""))) + +# --- 收 GC_SCRIPT ------------------------------------------------------ + +func _on_script(_skin: int, text: String) -> void: + begin_script(text) func _emit_side_effects(parsed: Dictionary) -> void: if parsed.has_select_item: @@ -97,13 +280,20 @@ func _emit_side_effects(parsed: Dictionary) -> void: if parsed.atlas_center.size() >= 2: atlas_center_requested.emit(int(parsed.atlas_center[0]), int(parsed.atlas_center[1])) for camera in parsed.camera_events: - camera_event_requested.emit(String(camera.get("kind", "")), camera.get("values", [])) + var kind := String(camera.get("kind", "")) + camera_event_requested.emit(kind, camera.get("values", [])) + camera_event_requested_full.emit(kind, camera.get("setting", {}).duplicate(true), + float(camera.get("blendtime", 0.0))) for fade in parsed.fade_events: - fade_event_requested.emit(String(fade.get("kind", "")), float(fade.get("speed", 0.0))) + var fade_kind := String(fade.get("kind", "")) + var fade_speed := float(fade.get("speed", 0.0)) + fade_event_requested.emit(fade_kind, fade_speed) + fade_event_requested_full.emit(fade_kind, fade_speed) if String(parsed.get("cinema_file", "")) != "": cinema_requested.emit(String(parsed.cinema_file)) func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void: + _finish_runtime() _confirm_pid = request_pid _text.text = msg _fill_confirm() @@ -111,6 +301,7 @@ func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void: opened.emit() func close() -> void: + _finish_runtime() if _root: _root.visible = false _confirm_wait = false @@ -123,6 +314,10 @@ func _unhandled_input(event: InputEvent) -> void: if key.pressed and not key.echo and key.keycode == KEY_ESCAPE: # The legacy client sends QUEST_CANCEL when the active script is dismissed # with Escape; ordinary answer buttons continue to use SCRIPT_ANSWER. + if _active_script: + skip() + get_viewport().set_input_as_handled() + return if client and client.has_method("quest_cancel"): client.quest_cancel() close() @@ -144,9 +339,8 @@ func _unhandled_input(event: InputEvent) -> void: # 的 30 字断行——reference 脚本只有显式 [LETTER],裸文本是本客户端的扩展,逐字符断句会 # 破坏服务器写好的句子)。 # -# seam(无逐帧 Update 循环):isLock/isWaitFlag/lLastDelayTime/lWaitingTime 作为 EventSet -# 状态保留并透出,但一次性把剩余 token 跑完(保证按钮 / 选项 / 副作用仍然落地);Skip / -# EndEventProcess / iConfirmEndTime 的 timeGetTime 走 seam。 +# EventSet 现在由 _process_active 按 ClientVS22::UpdateEventSet 的毫秒延迟逐步消费; +# parse_script() 仍保留为离线兼容接口,用于一次性检查整个脚本的最终状态。 signal cinema_requested(filename: String) @@ -224,6 +418,7 @@ class EventSet: var end_confirm := false var choices: Array[String] = [] var has_next := false + var next_button_type := "" var has_input := false var has_select_item := false var confirm_timeout := 0 @@ -315,6 +510,7 @@ func parse_script(src: String) -> Dictionary: "body": es.body_text(), "choices": es.choices, "has_next": es.has_next, + "next_button_type": es.next_button_type, "has_input": es.has_input, "has_select_item": es.has_select_item, "confirm_wait": es.isConfirmWait, @@ -342,6 +538,38 @@ func parse_script(src: String) -> Dictionary: "end_confirm": es.end_confirm, } +func _event_set_result(es: EventSet) -> Dictionary: + return { + "body": es.body_text(), + "choices": es.choices, + "has_next": es.has_next, + "next_button_type": es.next_button_type, + "has_input": es.has_input, + "has_select_item": es.has_select_item, + "confirm_wait": es.isConfirmWait, + "confirm_timeout": es.confirm_timeout, + "text_centered": es.isTextCenterMode, + "window_size": es.window_size, + "images": es.images, + "title_image": es.title_image, + "quest_buttons": es.quest_buttons, + "quest_button_closes": es.quest_button_closes, + "map_signals": es.map_signals, + "clear_map_signals": es.clear_map_signals, + "atlas_center": es.atlas_center, + "camera_events": es.camera_events, + "fade_events": es.fade_events, + "insert_images": es.insert_images, + "dungeon_result": es.dungeon_result, + "adjust_line": es.iAdjustLine, + "waiting_time": es.lWaitingTime, + "is_locked": es.isLock, + "wait_flag": es.isWaitFlag, + "answer_count": es.nAnswer, + "cinema_file": es.cinema_file, + "end_confirm": es.end_confirm, + } + # 把混排文本 / [tag ...] 拆成命令流。裸文本 -> { literal:String }; # tag -> { name:String(大写,带/前缀), pos:int, tok:String(方括号内原文) }。 func _tokenize(src: String) -> Array: @@ -411,10 +639,13 @@ func _process_event_set(es: EventSet, cmd: Dictionary) -> void: EVT_NEXT: # MakeNextButton(BUTTON_TYPE_NEXT); iAdjustLine += 2。 es.has_next = true + es.next_button_type = "NEXT" es.iAdjustLine += 2 EVT_DONE: - # MakeNextButton(BUTTON_TYPE_DONE); DoneEvent; iAdjustLine += 2。 + # MakeNextButton(BUTTON_TYPE_DONE); the reference invokes DoneEvent + # from the button callback, not while the token is being parsed. es.has_next = true + es.next_button_type = "DONE" es.iAdjustLine += 2 EVT_LEFT_IMAGE: es.images.append({"panel": "LEFTIMAGE", "path": _arg_s(tok, "src")}) @@ -501,7 +732,9 @@ func _process_event_set(es: EventSet, cmd: Dictionary) -> void: EVT_CONFIRM_WAIT: es.confirm_timeout = _arg_i(tok, "timeout", 0) es.isConfirmWait = true - es.iConfirmEndTime = es.confirm_timeout # timeGetTime()/1000 + timeout(seam) + # ClientVS22 stores the deadline in seconds (timeGetTime()/1000), + # not the timeout duration itself. + es.iConfirmEndTime = int(Time.get_ticks_msec() / 1000) + es.confirm_timeout es.insert_line(true) # MakeNextButton(BUTTON_TYPE_CANCEL) —— 取消按钮由 _fill_buttons(confirm_wait) 出。 EVT_END_CONFIRM_WAIT: @@ -812,7 +1045,8 @@ func _clear_buttons() -> void: for c in _btnrow.get_children(): c.queue_free() -func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false) -> void: +func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false, + next_button_type := "") -> void: _clear_buttons() if has_input: var input := LineEdit.new() @@ -831,9 +1065,16 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w close()) return if choices.is_empty(): - var b := _mkbtn("继续" if has_next else "关闭") + var is_done := next_button_type == "DONE" + var b := _mkbtn("关闭" if is_done else ("继续" if has_next else "关闭")) b.pressed.connect(func(): - client.script_answer(255) + if is_done: + # MakeNextButton(BUTTON_TYPE_DONE) wires DoneEvent to the + # actual click. Keep parse_script() side-effect free and only + # notify consumers when the player confirms the DONE button. + done_event.emit() + if client and client.has_method("script_answer"): + client.script_answer(255) close()) return for i in choices.size():