Complete remaining client gap features

This commit is contained in:
shenlei
2026-09-04 13:25:05 +09:00
parent 13ccb8d02d
commit 9a4a044da5
47 changed files with 6667 additions and 140 deletions
+70
View File
@@ -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 = 0poc 无公会徽记)")
# 精确值: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 -> 0grade 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")
+117
View File
@@ -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 同一套纯静态镜像套路。)
#
# REF40250 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 ProcessDamageInstanceBase.cpp:1910 每帧调一次):
# 每帧 pop_front 一条;
# (flag & DODGE) || (flag & BLOCK) -> bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISSreturn
# (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/<type><num>.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/<type><digit>.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
# RecvDamageInfoPacketbSelf = 目标实例是主角
static func is_self(vid: int, main_vid: int) -> bool:
return vid != 0 and vid == main_vid
# RecvDamageInfoPacketbTarget = 目标实例是当前目标框 VID
static func is_target(vid: int, target_vid: int) -> bool:
return vid != 0 and vid == target_vid
# RecvDamageInfoPacketdamage < 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_MISSself/ 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
+112
View File
@@ -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 / bTargetRecvDamageInfoPacket----------------------------
_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_pxFONT_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)")
+44
View File
@@ -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()
+9 -3
View File
@@ -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::IsWoodenDoorInstanceBase.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 -----------------------------------
+6
View File
@@ -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_instancevictim PK_PROTECT 一律 false
_ck(not R.is_conflict_alignment_instance(me_w,
+2
View File
@@ -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)
+187 -16
View File
@@ -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 淡出,
# 移过撞点继续;本帧没被挡到的恢复不透明。
+60
View File
@@ -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()
+121 -1
View File
@@ -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 :722strOwnership = 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_worldCPythonSystem::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_worlduigameoption.__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_playuitarget.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"):
+9
View File
@@ -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))
+114
View File
@@ -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()
+150
View File
@@ -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。)
#
# REF40250 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:1name_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 —— 那条的 bNameFlagRegisterChatTail=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 的像素偏移(右对齐实例,故这是其右边缘位置):
# 有称号(:282fxAdd = 8):x - iNameWidth/2 - 8 - iTitleWidth —— 再往称号左边挪一个称号宽;
# 只有等级(:312fxAdd = 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 + iImageHalfSizeiImageHalfSize = 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
+134
View File
@@ -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_ypoc 世界坐标 `bubble_y + 17*pixel_size`)、
# 信息气泡(bNameFlag FALSE)不顶名字、无气泡回默认。
# 增量 119seam ⑦ 纵向):name_tail_lift_delta / subtag_world_y —— 公会名 / 称号 / 等级
# 子标签随名字尾标纵向位移整体平移同一段(ArrangeTextTail :228/:258/:283)。
# 增量 121seam ⑦ 水平):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.0PythonTextTail.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_ypoc:世界 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.0c_fyGuildNamePosition")
# name_tail_lift_deltafollows -> 顶起后的净上移量;否则 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 = -24titleW 被忽略)")
_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)")
+79 -9
View File
@@ -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 的目标框收包分派(判定树 + CanViewTargetHP1: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 版 RecvTargetPacketPythonNetworkStreamPhaseGame.cpp:2424):C++ 侧从 EntityStore
# 抽出 (vid, hp%),这里照参考端的判定树分派目标框——目标实例缺失 -> 关框;已死透 -> 不动框;
# PC / building 目标 -> 只在框上是别的 VID 时关掉(不显血条);石头 / 敌对怪等「可看血」
# 目标 -> SetHPTargetBoardNPC / 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 的「看他国玩家目标框」radiodisplay_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:
+342 -41
View File
@@ -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.gdDamageEffect.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「总显示名字」radioCPythonSystem::IsAlwaysShowName
# _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag
# 另加 forced 路径:_target_vidtargetBoard.GetTargetVID()+ chat 尾标 bNameFlag
# 另加 forced 路径:_target_vidtargetBoard.GetTargetVID()+ chat 尾标 bNameFlag +
# _hover_vidgame.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 射线拾到的角色 vid0 = 无)
# §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_bDamageEffectTypeSELFDAMAGE / SELFDAMAGE2 交替)
var _fly_mgr: Node # FlyManagerfly_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::SetShowDamageFlagAddDamageEffect 的入队门)。
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 OnRenderwndMgr.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 侧组装:forcedtarget / chat bNameFlag+
# name_show.name_visible_for 的 net_world 侧组装:forcedtarget / 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 OnRendertargetBoard.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,增量 123SUBTAG_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)
# UpdateAllTextTailchat 尾标 bNameFlag TRUE 时强制显示该角色名字(不受 3500 限制)。
_set_name_visibility(n)
# UpdateShowingTextTail :110-114:有「聊天」气泡(bNameFlag)时名字尾标顶到气泡上方 17px。
_reposition_name_tag(n)
func _expire_chat_tails() -> void:
# UpdateAllTextTailLivingTime < 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_DamageQueueProcessDamage 每帧 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 # RecvDamageInfoPacketdamage < 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 Label3Dseam ⑪:无 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 # ProcessDamagenontarget_ 后直接 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.8m_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
+3 -2
View File
@@ -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")
+76
View File
@@ -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 :804targetBoard.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)
+3 -1
View File
@@ -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")
+91 -2
View File
@@ -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 黄字 Label3DSetItemTextTailOwner
# + 名字实例下移 15pxArrangeTextTail :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")):
+69
View File
@@ -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。)
#
# REF40250 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 17pxPythonTextTail.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+ 目标框 VIDtarget_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
+69
View File
@@ -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_activegame.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")
+15
View File
@@ -11,6 +11,9 @@ extends Node
signal anim_state(state: String)
signal target_selected(node: Node3D)
signal moved(pos: Vector3)
# game.py OnRenderwndMgr.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 # 上一帧悬停命中的实体 vidhover_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) # 鼠标压 UIIsPickedWindow 为假 -> 无 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)
+4 -2
View File
@@ -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")
+13
View File
@@ -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
+41
View File
@@ -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)
+98
View File
@@ -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")
+16
View File
@@ -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 名字色 radioname_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 看他国目标框 radiotarget_board_view / target_board_no_viewconstinfo 默认 1 = 看)
_ck(int(go._display.get("target_board", -1)) == 1, "target_board 默认 1VIEW_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()
# 二次实例读 cfgblock_mode 回填
+91
View File
@@ -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。)
#
# REF40250 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
# SetHPTargetBoardvid != GetTargetVID() → 先 ResetTargetBoard()+SetEnemyVID(vid)。
static func needs_reset(new_vid: int, cur_vid: int) -> bool:
return new_vid != cur_vid
# CloseTargetBoardIfDifferentvid != 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)
+96
View File
@@ -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. CanViewTargetHPInstanceBase.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_DIFFERENTPC 分支在前)")
# 可看血 -> :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 先 Resetgame.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. CloseTargetBoardIfDifferentgame.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 -> 37int 截断)")
+115
View File
@@ -0,0 +1,115 @@
# text_metrics —— §8.8 seam ⑦ / ⑩ 收口:头顶子标签 / 掉落名条排版的文本尺寸
# 从「字符数 * 约 7px」/「单行 15px」估算换成 CGraphicTextInstance::GetTextSize 的
# 真实字体度量 1:1(增量 123 换宽 w、增量 124 换高 h、增量 125 加 owner 子串独立度量)。
# ArrangeTextTailPythonTextTail.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。)
#
# REF40250 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)SetTextTailTextitem 框尺寸)
# —— 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:1text 在 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 的 hitem 尾标框 yEnd = ySizePythonTextTail.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)
# 增量 125seam ⑩ 末尾 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)
+141
View File
@@ -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_widthLabel3D.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_widthLabel3D---------------------------------------
_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(增量 124GetTextSize 的 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(增量 125owner 子串独立度量)------
_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.textlbl.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()
+105
View File
@@ -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。)
#
# REF40250 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 = ySizexSize/ySize = 文本像素尺寸)
# UserInterface/PythonTextTail.cpp:706-737 SetItemTextTailOwner(dwVID, c_szName):掉落名条
# 挂 owner 后缀。owner 非空时 owner 走独立 CGraphicTextInstance(黄 1,1,0 / CENTER / 文本
# = c_szName + strOwnershipIDS_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
# SetItemTextTailOwnerPythonTextTail.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}
# isInPythonTextTail.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
+152
View File
@@ -0,0 +1,152 @@
# text_tail_arrange_test —— §8.8 seam ⑩:ArrangeTextTail item 尾标去重叠 + isIn 1:1。
# boxes_overlapisIn 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_overlapisIn: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 = 121ys[1] 不变。
# 再看 i=1:此时第 1 条已在 y=121x∈[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;与 2100)此时已不撞(121 的框 y∈[119,137])。
# i=1:与 2100)撞 -> 121;再与已排好的 0121)撞 -> 121 + 21 = 142。
# i=20 在 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_ownerSetItemTextTailOwner, PythonTextTail.cpp:706-737------
# 无 ownerowner_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 -> 退化")
# 有 owneryEnd += 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")
+102 -4
View File
@@ -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
+106
View File
@@ -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()
+1 -1
View File
@@ -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)
+177 -8
View File
@@ -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 / h1:1,增量 123 / 124TAG_CHAR_W_PX / TAG_LINE_H_PX 仅作无字体兜底)。
# 增量 125SetItemTextTailOwner, PythonTextTail.cpp:706-737):有 owner 的掉落,参考端
# owner 是与名字分开的 pOwnerTextInstance,单独 GetTextSize,令去重叠框 yEnd += ownerH + 4、
# x 两边按 owner 半宽 ±1 外扩 —— 去重叠框把「名字」「owner + 所有格」两子串分别用 tag 的
# 字体度量后走 TextTailArrange.item_box_with_owner 合成(框度量 1:1)。
# 增量 126SetItemTextTailOwner :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 strOwnershipIDS_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-207owner 存在时 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-745owner 非空 → 一个独立的 CENTER 黄字 (1,1,0) text instance
# 文本 = owner + strOwnership:722/:723);owner 为空 → 删该实例(:741-745)。
# pocdrop 节点下第二个 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
+260 -19
View File
@@ -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 + timeoutseam
# 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():