Complete remaining client gap features
This commit is contained in:
@@ -26,6 +26,7 @@ class FakeClient extends Node:
|
||||
func get_entities() -> Array: return ents.values()
|
||||
func say(t, s) -> bool: said.append([t, s]); return true
|
||||
func whisper(to, s) -> bool: whispered.append([to, s]); return true
|
||||
func get_guild_name(gid: int) -> String: return "Camelot" if gid != 0 else ""
|
||||
|
||||
class FakeProto extends Node:
|
||||
func item(vnum: int) -> Dictionary:
|
||||
@@ -112,6 +113,14 @@ func _run() -> void:
|
||||
chat._on_submit("/w Bob psst hey")
|
||||
_ck(fc.whispered.size() == 1 and fc.whispered[0] == ["Bob", "psst hey"], "/w Bob ... -> whisper('Bob','psst hey')")
|
||||
_ck(_last(tWhisper).contains("→ Bob: psst hey"), "/w -> local echo in Whisper tab")
|
||||
# game.py OpenWhisperDialog:目标框独立于公共聊天输入行,回车发送后关闭。
|
||||
chat.start_whisper("Carol")
|
||||
_ck(chat.is_whisper_open() and chat.whisper_target() == "Carol",
|
||||
"start_whisper -> 独立私聊窗口打开并锁定目标")
|
||||
chat._on_whisper_submit("private window")
|
||||
_ck(fc.whispered.size() == 2 and fc.whispered[1] == ["Carol", "private window"],
|
||||
"独立私聊窗口发送 -> whisper('Carol', msg)")
|
||||
_ck(not chat.is_whisper_open(), "私聊发送后关闭独立窗口")
|
||||
|
||||
# --- 气泡 ---
|
||||
var nw: Node = NetWorld.new()
|
||||
@@ -126,6 +135,67 @@ func _run() -> void:
|
||||
nw._bubble(2000, "hi there")
|
||||
var bob: Node3D = nw.node_for(2000)
|
||||
_ck(bob != null and bob.get_node_or_null("bubble") != null, "entity 2000 got a bubble node")
|
||||
# §8.8 seam ⑨(UpdateShowingTextTail :110-114):有「聊天」气泡时名字尾标顶到气泡上方 17px。
|
||||
var bob_name := bob.get_node_or_null("Label3D") as Label3D
|
||||
var bob_bubble := bob.get_node_or_null("bubble") as Label3D
|
||||
_ck(bob_name != null and bob_bubble != null, "entity 2000 has Label3D + bubble")
|
||||
_ck(absf(bob_name.position.y - (bob_bubble.position.y + 17.0 * bob_name.pixel_size)) < 0.0001,
|
||||
"聊天气泡在 -> 名字 y = 气泡 y + 17*pixel_size")
|
||||
_ck(bob_name.position.y > bob_bubble.position.y, "名字尾标顶到气泡上方")
|
||||
# §8.8 seam ⑦ 收口(增量 119 / ArrangeTextTail :228/:258/:283):公会名 / 称号 / 等级
|
||||
# 子标签锚在名字尾标上,名字被气泡顶起时三者整体平移同一段。
|
||||
var g_tag := bob.get_node_or_null("GuildTag") as Label3D
|
||||
var t_tag := bob.get_node_or_null("TitleTag") as Label3D
|
||||
var l_tag := bob.get_node_or_null("LevelTag") as Label3D
|
||||
_ck(g_tag != null and t_tag != null and l_tag != null, "entity 2000 has 公会/称号/等级子标签")
|
||||
var lift := bob_name.position.y - 2.1
|
||||
_ck(lift > 0.0, "名字尾标确有上移量")
|
||||
_ck(absf(g_tag.position.y - (2.34 + lift)) < 0.0001, "GuildTag 随名字上移同一段")
|
||||
_ck(absf(t_tag.position.y - (1.86 + lift)) < 0.0001, "TitleTag 随名字上移同一段")
|
||||
_ck(absf(l_tag.position.y - (1.74 + lift)) < 0.0001, "LevelTag 随名字上移同一段")
|
||||
# 信息尾标(bNameFlag FALSE)不顶名字 —— 复用同一张 _chat_tails,覆盖成 info 后名字回默认。
|
||||
nw.register_info_tail(2000, "skill fizzled")
|
||||
_ck(absf(bob_name.position.y - 2.1) < 0.0001, "信息尾标覆盖 -> 名字回默认局部 y 2.1")
|
||||
_ck(absf(g_tag.position.y - 2.34) < 0.0001, "信息尾标覆盖 -> GuildTag 回默认 2.34")
|
||||
_ck(absf(t_tag.position.y - 1.86) < 0.0001, "信息尾标覆盖 -> TitleTag 回默认 1.86")
|
||||
_ck(absf(l_tag.position.y - 1.74) < 0.0001, "信息尾标覆盖 -> LevelTag 回默认 1.74")
|
||||
# --- §8.8 seam ⑦ 水平排版(增量 121 / ArrangeTextTail :211-320 非 EUROPE 分支)------
|
||||
# charlie 有公会 + 善恶度(称号) + 等级 -> 三子标签沿名字锚点水平排开:
|
||||
# 等级在最左、称号居中偏左、名字本身右移 fxAdd(=8);公会名(无徽记)留 x = 0。
|
||||
fc.ents[3000] = {"vid": 3000, "name": "Charlie", "guild": 7, "alignment": 15000, "level": 42}
|
||||
fc.entity_spawned.emit({"vid": 3000, "name": "Charlie", "pos": Vector3(3, 0, 0), "func": 0, "angle_deg": 0.0})
|
||||
await process_frame
|
||||
var charlie: Node3D = nw.node_for(3000)
|
||||
_ck(charlie != null, "entity 3000 spawned")
|
||||
nw._apply_text_tail(3000)
|
||||
var c_name := charlie.get_node_or_null("Label3D") as Label3D
|
||||
var c_g := charlie.get_node_or_null("GuildTag") as Label3D
|
||||
var c_t := charlie.get_node_or_null("TitleTag") as Label3D
|
||||
var c_l := charlie.get_node_or_null("LevelTag") as Label3D
|
||||
_ck(c_g.text == "Camelot", "GuildTag 文本经 get_guild_name 解出")
|
||||
_ck(c_t.text != "", "TitleTag 文本非空(alignment 15000 -> grade 0 -> 有称号)")
|
||||
_ck(c_l.text != "", "LevelTag 文本非空(level 42)")
|
||||
# 名字本身右移 fxAdd —— 有称号 -> 8px 折世界。
|
||||
_ck(absf(c_name.position.x - 8.0 * c_name.pixel_size) < 0.0001,
|
||||
"名字 x = name_x_shift(有称号)=8 * pixel_size")
|
||||
# 称号 / 等级在名字左侧(x < 0),等级比称号更靠左。
|
||||
_ck(c_t.position.x < 0.0, "称号在名字锚点左侧")
|
||||
_ck(c_l.position.x < c_t.position.x, "等级比称号更靠左(-nameW/2 - 8 - titleW)")
|
||||
# 公会名无徽记 -> 留在名字中线正上方。
|
||||
_ck(absf(c_g.position.x) < 0.0001, "GuildTag x = 0(poc 无公会徽记)")
|
||||
# 精确值:nameW = len("Charlie")*7 = 49,称号 x 偏移 = -(49/2) - 8 = -32.5。
|
||||
_ck(absf(c_t.position.x - (-(7.0 * 7.0 / 2.0) - 8.0) * c_t.pixel_size) < 0.0001,
|
||||
"称号 x = (-nameW/2 - 8) * pixel_size 精确")
|
||||
# 撤销称号(alignment -> 0,grade 4 = TITLE_NONE)后称号 x 归零、名字改右移 4(只剩等级)。
|
||||
fc.ents[3000]["alignment"] = 0
|
||||
nw._apply_text_tail(3000)
|
||||
_ck(c_t.text == "" and absf(c_t.position.x) < 0.0001, "称号消失 -> TitleTag x 归零")
|
||||
_ck(absf(c_name.position.x - 4.0 * c_name.pixel_size) < 0.0001,
|
||||
"只剩等级 -> 名字 x = name_x_shift(仅等级)=4 * pixel_size")
|
||||
# 精确值(无称号):等级 x 偏移 = -(nameW/2) - 4 = -(49/2) - 4 = -28.5。
|
||||
_ck(absf(c_l.position.x - (-(7.0 * 7.0 / 2.0) - 4.0) * c_l.pixel_size) < 0.0001,
|
||||
"只剩等级:等级 x = (-nameW/2 - 4) * pixel_size 精确")
|
||||
|
||||
nw.set_local_vid(1000)
|
||||
nw._bubble(1000, "my line")
|
||||
_ck(got_main[0] == "my line", "local vid bubble -> main_bubble signal")
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# damage_effect —— §8.8 / §5.4 头顶伤害飘字「是否 / 如何显示」1:1:
|
||||
# CInstanceBase::AddDamageEffect + ProcessDamage 的分类(DODGE|BLOCK -> MISS、
|
||||
# self -> "damage_"、nontarget -> 不渲染、target -> "target_")+ 数字逐位拆分 +
|
||||
# CPythonSystem::IsShowDamage / SetShowDamageFlag 门。
|
||||
# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd 同一套纯静态镜像套路。)
|
||||
#
|
||||
# REF(40250 ClientVS22 source/ 为唯一基线):
|
||||
# UserInterface/InstanceBase.h:300 enum DamageFlag { NORMAL 1<<0, POISON 1<<1, DODGE 1<<2, BLOCK 1<<3, PENETRATE 1<<4, CRITICAL 1<<5 }
|
||||
# UserInterface/Packet.h:1943 TPacketGCDamageInfo { BYTE header; DWORD dwVID; BYTE flag; int damage; }
|
||||
# UserInterface/PythonNetworkStreamPhaseGame.cpp:2401 RecvDamageInfoPacket:
|
||||
# pInstTarget = GetInstancePtr(dwVID);bSelf = (pInstTarget == MainInstance);
|
||||
# bTarget = (pInstTarget == m_pInstTarget);damage >= 0 才 AddDamageEffect(< 0 记 TraceError)
|
||||
# UserInterface/InstanceBaseEffect.cpp:87 AddDamageEffect:仅当 IsShowDamage() 才 push m_DamageQueue({bSelf,bTarget,damage,flag})
|
||||
# UserInterface/InstanceBaseEffect.cpp:100 ProcessDamage(InstanceBase.cpp:1910 每帧调一次):
|
||||
# 每帧 pop_front 一条;
|
||||
# (flag & DODGE) || (flag & BLOCK) -> bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS,return
|
||||
# (flag & CRITICAL) -> 该 build CreateEffect 被注释掉,落到普通数字(无独立暴击视觉)
|
||||
# POISON 分支整段注释掉
|
||||
# bSelf -> strDamageType="damage_",EFFECT_DAMAGE_SELFDAMAGE / SELFDAMAGE2 交替(m_bDamageEffectType 每次取反)
|
||||
# !bTarget -> strDamageType="nontarget_",EFFECT_DAMAGE_NOT_TARGET 后 **return(不渲染)**
|
||||
# else -> strDamageType="target_",EFFECT_DAMAGE_TARGET
|
||||
# while(damage>0):num=damage%10; damage/=10; index 0..7(>7 TraceError break);
|
||||
# 贴图 "d:/ymir work/effect/affect/damagevalue/<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
|
||||
|
||||
# RecvDamageInfoPacket:bSelf = 目标实例是主角
|
||||
static func is_self(vid: int, main_vid: int) -> bool:
|
||||
return vid != 0 and vid == main_vid
|
||||
|
||||
# RecvDamageInfoPacket:bTarget = 目标实例是当前目标框 VID
|
||||
static func is_target(vid: int, target_vid: int) -> bool:
|
||||
return vid != 0 and vid == target_vid
|
||||
|
||||
# RecvDamageInfoPacket:damage < 0 记 TraceError 不处理
|
||||
static func valid_amount(amount: int) -> bool:
|
||||
return amount >= 0
|
||||
|
||||
# ProcessDamage 分类。返回:
|
||||
# {"kind": Kind, "type": String("damage_"/"target_"/"nontarget_"), "show": bool, "self": bool}
|
||||
# show == false 只有一种来源:非 self 且非 target 的数字伤害(ProcessDamage 里 return,不渲染)。
|
||||
static func classify(flag: int, b_self: bool, b_target: bool) -> Dictionary:
|
||||
if (flag & DAMAGE_DODGE) != 0 or (flag & DAMAGE_BLOCK) != 0:
|
||||
# EFFECT_DAMAGE_MISS(self)/ EFFECT_DAMAGE_TARGETMISS(其它)
|
||||
return {"kind": Kind.MISS, "type": "damage_" if b_self else "target_", "show": true, "self": b_self}
|
||||
if b_self:
|
||||
return {"kind": Kind.DIGITS, "type": "damage_", "show": true, "self": true}
|
||||
if not b_target:
|
||||
return {"kind": Kind.DIGITS, "type": "nontarget_", "show": false, "self": false}
|
||||
return {"kind": Kind.DIGITS, "type": "target_", "show": true, "self": false}
|
||||
|
||||
# while(damage>0){ num=damage%10; damage/=10; } —— 低位在前,最多 8 位(index>7 break)。
|
||||
# damage <= 0 不产出任何位(与参考 while 一致)。
|
||||
static func digits(damage: int) -> Array:
|
||||
var out: Array = []
|
||||
var d := damage
|
||||
if d <= 0:
|
||||
return out
|
||||
var index := 0
|
||||
while d > 0:
|
||||
if index > MAX_DIGITS - 1:
|
||||
break
|
||||
out.append(d % 10)
|
||||
d = d / 10
|
||||
index += 1
|
||||
return out
|
||||
|
||||
# 第 index 位数字相对锚点的水平像素偏移(相机右方向)。
|
||||
static func digit_offset_px(index: int) -> float:
|
||||
return float(FONT_WIDTH * index)
|
||||
|
||||
# ProcessDamage 的 self 特效交替:m_bDamageEffectType==false -> SELFDAMAGE(0),否则 SELFDAMAGE2(1),
|
||||
# 每次自伤后取反。返回本次用的槽位(0/1)。
|
||||
static func self_damage_slot(toggle_state: bool) -> int:
|
||||
return 1 if toggle_state else 0
|
||||
@@ -0,0 +1,112 @@
|
||||
# damage_effect_test —— §8.8 / §5.4 头顶伤害飘字 1:1:
|
||||
# CInstanceBase::AddDamageEffect / ProcessDamage 的分类(DODGE|BLOCK -> MISS、
|
||||
# self -> "damage_"、nontarget -> 不渲染、target -> "target_")、数字逐位拆分、
|
||||
# CPythonSystem::IsShowDamage / SetShowDamageFlag 门。
|
||||
# godot --headless --path project --script damage_effect_test.gd
|
||||
# 退出码 0 = 全过。
|
||||
extends SceneTree
|
||||
|
||||
const DamageEffect = preload("res://damage_effect.gd")
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: damage_effect_test (§8.8/§5.4 AddDamageEffect / ProcessDamage 分类 + IsShowDamage 1:1)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
# --- 0. 常量(InstanceBase.h / PythonSystem.cpp / InstanceBaseEffect.cpp)----
|
||||
_ck(DamageEffect.DAMAGE_NORMAL == 1 << 0, "DAMAGE_NORMAL == 1<<0")
|
||||
_ck(DamageEffect.DAMAGE_POISON == 1 << 1, "DAMAGE_POISON == 1<<1")
|
||||
_ck(DamageEffect.DAMAGE_DODGE == 1 << 2, "DAMAGE_DODGE == 1<<2")
|
||||
_ck(DamageEffect.DAMAGE_BLOCK == 1 << 3, "DAMAGE_BLOCK == 1<<3")
|
||||
_ck(DamageEffect.DAMAGE_PENETRATE == 1 << 4, "DAMAGE_PENETRATE == 1<<4")
|
||||
_ck(DamageEffect.DAMAGE_CRITICAL == 1 << 5, "DAMAGE_CRITICAL == 1<<5")
|
||||
_ck(DamageEffect.SHOW_DAMAGE_DEFAULT == true, "m_Config.bShowDamage 默认 true")
|
||||
_ck(DamageEffect.CFG_KEY == "SHOW_DAMAGE", "配置键 == SHOW_DAMAGE")
|
||||
_ck(DamageEffect.FONT_WIDTH == 30, "FONT_WIDTH == 30")
|
||||
_ck(DamageEffect.MAX_DIGITS == 8, "index 上限 8(>7 break)")
|
||||
|
||||
# --- 1. IsShowDamage 门 -----------------------------------------------------
|
||||
_ck(DamageEffect.should_queue(true) == true, "IsShowDamage() -> 入队")
|
||||
_ck(DamageEffect.should_queue(false) == false, "!IsShowDamage() -> 不入队")
|
||||
_ck(DamageEffect.show_damage_from_flag(1) == true, "SetShowDamageFlag(1) -> true")
|
||||
_ck(DamageEffect.show_damage_from_flag(0) == false, "SetShowDamageFlag(0) -> false")
|
||||
_ck(DamageEffect.show_damage_from_flag(2) == false, "SetShowDamageFlag(2) -> false(严格 == 1)")
|
||||
_ck(DamageEffect.show_damage_from_cfg("1") == true, "cfg \"1\" -> true")
|
||||
_ck(DamageEffect.show_damage_from_cfg("0") == false, "cfg \"0\" -> false")
|
||||
_ck(DamageEffect.show_damage_from_cfg("2") == false, "cfg \"2\" -> false")
|
||||
_ck(DamageEffect.show_damage_from_cfg(1) == true, "cfg int 1 -> true")
|
||||
|
||||
# --- 2. bSelf / bTarget(RecvDamageInfoPacket)----------------------------
|
||||
_ck(DamageEffect.is_self(42, 42) == true, "vid == main -> bSelf")
|
||||
_ck(DamageEffect.is_self(42, 7) == false, "vid != main -> !bSelf")
|
||||
_ck(DamageEffect.is_self(0, 0) == false, "vid 0 -> !bSelf")
|
||||
_ck(DamageEffect.is_target(9, 9) == true, "vid == targetBoard VID -> bTarget")
|
||||
_ck(DamageEffect.is_target(9, 0) == false, "无目标 -> !bTarget")
|
||||
_ck(DamageEffect.is_target(0, 0) == false, "vid 0 -> !bTarget")
|
||||
|
||||
# --- 3. damage >= 0 门 --------------------------------------------------
|
||||
_ck(DamageEffect.valid_amount(0) == true, "damage 0 -> 处理(>= 0)")
|
||||
_ck(DamageEffect.valid_amount(5) == true, "damage 5 -> 处理")
|
||||
_ck(DamageEffect.valid_amount(-1) == false, "damage < 0 -> TraceError 不处理")
|
||||
|
||||
# --- 4. ProcessDamage 分类 --------------------------------------------------
|
||||
var m := DamageEffect.Kind.MISS
|
||||
var g := DamageEffect.Kind.DIGITS
|
||||
# DODGE / BLOCK -> MISS
|
||||
var c := DamageEffect.classify(DamageEffect.DAMAGE_DODGE, true, true)
|
||||
_ck(c["kind"] == m and c["type"] == "damage_" and c["show"] and c["self"], "DODGE + self -> MISS damage_ show self")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_DODGE, false, true)
|
||||
_ck(c["kind"] == m and c["type"] == "target_" and c["show"] and not c["self"], "DODGE + 非 self -> MISS target_")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_BLOCK, true, false)
|
||||
_ck(c["kind"] == m and c["type"] == "damage_", "BLOCK + self -> MISS damage_")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_BLOCK, false, false)
|
||||
_ck(c["kind"] == m and c["type"] == "target_", "BLOCK + 非 self -> MISS target_(即便非 target)")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_DODGE | DamageEffect.DAMAGE_BLOCK, false, true)
|
||||
_ck(c["kind"] == m, "DODGE|BLOCK 合并 -> MISS")
|
||||
# 数字:self / target / nontarget
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, true, false)
|
||||
_ck(c["kind"] == g and c["type"] == "damage_" and c["show"] and c["self"], "普通 + self -> DIGITS damage_ show self")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, false, true)
|
||||
_ck(c["kind"] == g and c["type"] == "target_" and c["show"] and not c["self"], "普通 + target -> DIGITS target_ show")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, false, false)
|
||||
_ck(c["kind"] == g and c["type"] == "nontarget_" and c["show"] == false, "普通 + 非 self 非 target -> nontarget_ 不渲染")
|
||||
# CRITICAL 在该 build 无独立视觉 -> 仍按数字
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, true, false)
|
||||
_ck(c["kind"] == g and c["type"] == "damage_" and c["show"], "CRITICAL + self -> 仍普通数字 damage_")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, false, true)
|
||||
_ck(c["kind"] == g and c["type"] == "target_" and c["show"], "CRITICAL + target -> 普通数字 target_")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, false, false)
|
||||
_ck(c["show"] == false, "CRITICAL + nontarget -> 仍不渲染")
|
||||
# POISON 分支被注释掉 -> 走普通
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_POISON, false, true)
|
||||
_ck(c["kind"] == g and c["type"] == "target_", "POISON(分支注释掉)+ target -> 普通数字 target_")
|
||||
|
||||
# --- 5. digits:低位在前,最多 8 位 --------------------------------------
|
||||
_ck(DamageEffect.digits(0) == [], "digits(0) == [](while 零次)")
|
||||
_ck(DamageEffect.digits(-5) == [], "digits(-5) == []")
|
||||
_ck(DamageEffect.digits(7) == [7], "digits(7) == [7]")
|
||||
_ck(DamageEffect.digits(123) == [3, 2, 1], "digits(123) == [3,2,1](低位在前)")
|
||||
_ck(DamageEffect.digits(90) == [0, 9], "digits(90) == [0,9]")
|
||||
_ck(DamageEffect.digits(2000000) == [0, 0, 0, 0, 0, 0, 2], "digits(2000000) 7 位")
|
||||
_ck(DamageEffect.digits(999999999).size() == 8, "digits(9 位) 截到 8 位(index>7 break)")
|
||||
_ck(DamageEffect.digits(999999999) == [9, 9, 9, 9, 9, 9, 9, 9], "digits(999999999) == 8×9")
|
||||
|
||||
# --- 6. digit_offset_px:FONT_WIDTH*index ------------------------------
|
||||
_ck(is_equal_approx(DamageEffect.digit_offset_px(0), 0.0), "第 0 位偏移 0")
|
||||
_ck(is_equal_approx(DamageEffect.digit_offset_px(1), 30.0), "第 1 位偏移 30")
|
||||
_ck(is_equal_approx(DamageEffect.digit_offset_px(3), 90.0), "第 3 位偏移 90")
|
||||
|
||||
# --- 7. self 特效交替(m_bDamageEffectType)---------------------------
|
||||
_ck(DamageEffect.self_damage_slot(false) == 0, "toggle false -> SELFDAMAGE(0)")
|
||||
_ck(DamageEffect.self_damage_slot(true) == 1, "toggle true -> SELFDAMAGE2(1)")
|
||||
@@ -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()
|
||||
@@ -39,12 +39,15 @@ const DUEL_START := 2
|
||||
const STATE_KILLER := 1 << 3
|
||||
const STATE_PARTY := 1 << 4
|
||||
|
||||
# _entity_kind 语义(net_play._entity_kind):0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP
|
||||
# _entity_kind 语义(net_play._entity_kind):0 PC / 1 NPC / 2 MONSTER / 3 STONE /
|
||||
# 4 WARP / 5 BUILDING / 6 WOODEN_DOOR。
|
||||
const KIND_PC := 0
|
||||
const KIND_NPC := 1
|
||||
const KIND_MONSTER := 2
|
||||
const KIND_STONE := 3
|
||||
const KIND_WARP := 4
|
||||
const KIND_BUILDING := 5
|
||||
const KIND_WOODEN_DOOR := 6
|
||||
|
||||
# --- alignment(善恶)---------------------------------------------------------
|
||||
|
||||
@@ -97,13 +100,16 @@ static func _is_enemy(e: Dictionary) -> bool:
|
||||
|
||||
static func _is_wooden_door(e: Dictionary) -> bool:
|
||||
# CInstanceBase::IsWoodenDoor(InstanceBase.cpp:2390)
|
||||
if _kind(e) == KIND_WOODEN_DOOR:
|
||||
return true
|
||||
var vnum := int(e.get("race", e.get("vnum", 0)))
|
||||
return vnum == 13000 or (vnum >= 30111 and vnum <= 30119)
|
||||
|
||||
static func _is_building(e: Dictionary) -> bool:
|
||||
# CInstanceBase::IsBuilding() = m_GraphicThingInstance.IsBuilding()。
|
||||
# 建筑 / 城墙的 thing type 在本 POC 未单独下发(IsWall 的 race 白名单未随源码发布)→ seam。
|
||||
return bool(e.get("building", false))
|
||||
# classic 的 bType / POC fixture 可直接给 kind;旧包仍可用 building 布尔字段。
|
||||
return bool(e.get("building", false)) or _kind(e) == KIND_BUILDING \
|
||||
or _kind(e) == KIND_WOODEN_DOOR
|
||||
|
||||
# --- killer / party / empire / duel / pvp -----------------------------------
|
||||
|
||||
|
||||
@@ -142,6 +142,12 @@ func _run() -> void:
|
||||
"PC vs wooden door 30115 -> true")
|
||||
_ck(not R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 13001}, _ctx()),
|
||||
"PC vs stone door 13001 -> false")
|
||||
_ck(R._is_wooden_door({"kind": R.KIND_WOODEN_DOOR, "race": 999}),
|
||||
"explicit WOODEN_DOOR kind -> IsWoodenDoor")
|
||||
_ck(R._is_building({"kind": R.KIND_BUILDING}),
|
||||
"explicit BUILDING kind -> IsBuilding")
|
||||
_ck(R._is_building({"kind": R.KIND_WOODEN_DOOR}),
|
||||
"wooden door kind -> IsBuilding")
|
||||
|
||||
# 24) is_conflict_alignment_instance:victim PK_PROTECT 一律 false
|
||||
_ck(not R.is_conflict_alignment_instance(me_w,
|
||||
|
||||
@@ -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
@@ -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 淡出,
|
||||
# 移过撞点继续;本帧没被挡到的恢复不透明。
|
||||
|
||||
@@ -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
@@ -43,6 +43,7 @@ const SystemMenuUI = preload("res://ui/system_menu_ui.gd")
|
||||
const Quickbar = preload("res://ui/quickbar.gd")
|
||||
const QuestDialog = preload("res://ui/quest_dialog.gd")
|
||||
const QuestLog = preload("res://ui/quest_log.gd")
|
||||
const DungeonResultUI = preload("res://ui/dungeon_result_ui.gd")
|
||||
const SelectItemUI = preload("res://ui/select_item_ui.gd")
|
||||
const PartyUI = preload("res://ui/party_ui.gd")
|
||||
const FriendUI = preload("res://ui/friend_ui.gd")
|
||||
@@ -95,6 +96,7 @@ var system_menu_ui: Node # SystemMenuUI
|
||||
var quickbar: Node # Quickbar
|
||||
var quest_dialog: Node # QuestDialog
|
||||
var quest_log: Node # QuestLog
|
||||
var dungeon_result_ui: Node # DungeonResultUI
|
||||
var select_item_ui: Node # SelectItemUI
|
||||
var party_ui: Node # PartyUI
|
||||
var friend_ui: Node # FriendUI
|
||||
@@ -171,6 +173,10 @@ func setup(m2client: Node, assets_root: String,
|
||||
add_child(net_world)
|
||||
net_world.world = world
|
||||
net_world.setup(client, _mount)
|
||||
# §8.8 屏幕空间实体血条:明确绑定轨道相机,避免依赖 viewport 当前相机,
|
||||
# 也让地图切换 / 多视口时投影来源稳定。
|
||||
if net_world.has_method("set_camera"):
|
||||
net_world.set_camera(cam)
|
||||
net_world.set_local_node(player)
|
||||
net_world.entity_added.connect(func(node: Node3D, _vid: int):
|
||||
if node not in pc.pickables:
|
||||
@@ -195,6 +201,9 @@ func setup(m2client: Node, assets_root: String,
|
||||
# §8.6 targetBoard.GetTargetVID() -> net_world:选中目标始终强显名字。
|
||||
if net_play.has_signal("target_changed") and net_world and net_world.has_method("set_target_vid"):
|
||||
net_play.target_changed.connect(func(vid: int): net_world.set_target_vid(int(vid)))
|
||||
# §8.8 game.py OnRender chr.Pick() -> ShowCharacterTextTail:悬停角色也强显名字(pick_show.gd)。
|
||||
if pc.has_signal("hover_entity_changed") and net_world and net_world.has_method("set_hover_vid"):
|
||||
pc.hover_entity_changed.connect(func(vid: int): net_world.set_hover_vid(int(vid)))
|
||||
if _assets != "":
|
||||
net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表
|
||||
|
||||
@@ -225,6 +234,10 @@ func setup(m2client: Node, assets_root: String,
|
||||
chat.setup(client, ui, _assets)
|
||||
if net_play and net_play.has_signal("fishing_feedback") and chat.has_method("on_fishing_feedback"):
|
||||
net_play.fishing_feedback.connect(chat.on_fishing_feedback)
|
||||
# §8.8 / §5.4 game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时
|
||||
# self.interface.OpenWhisperDialog(name)。chat.start_whisper 打开独立私聊窗口。
|
||||
if net_play and net_play.has_signal("whisper_requested") and chat.has_method("start_whisper"):
|
||||
net_play.whisper_requested.connect(chat.start_whisper)
|
||||
if net_world and net_world.has_signal("main_bubble"):
|
||||
net_world.main_bubble.connect(_player_bubble)
|
||||
|
||||
@@ -295,6 +308,15 @@ func setup(m2client: Node, assets_root: String,
|
||||
quest_dialog = QuestDialog.new()
|
||||
add_child(quest_dialog)
|
||||
quest_dialog.setup(client, ui, proto, assets_root)
|
||||
if quest_dialog.has_signal("camera_event_requested_full"):
|
||||
quest_dialog.camera_event_requested_full.connect(_on_quest_camera_event)
|
||||
if quest_dialog.has_signal("fade_event_requested_full"):
|
||||
quest_dialog.fade_event_requested_full.connect(_on_quest_fade_event)
|
||||
if quest_dialog.has_signal("dungeon_result_requested"):
|
||||
quest_dialog.dungeon_result_requested.connect(_on_dungeon_result)
|
||||
dungeon_result_ui = DungeonResultUI.new()
|
||||
add_child(dungeon_result_ui)
|
||||
dungeon_result_ui.setup(ui)
|
||||
quest_log = QuestLog.new()
|
||||
add_child(quest_log)
|
||||
quest_log.setup(client, ui)
|
||||
@@ -330,6 +352,8 @@ func setup(m2client: Node, assets_root: String,
|
||||
await _yield()
|
||||
if chat and chat.has_method("set_proto"):
|
||||
chat.set_proto(proto)
|
||||
if quest_dialog and quest_dialog.has_method("set_proto"):
|
||||
quest_dialog.set_proto(proto)
|
||||
var mp := assets_root.path_join("locale/locale/en/mob_proto")
|
||||
if FileAccess.file_exists(mp):
|
||||
proto.call("load_mob_proto", mp)
|
||||
@@ -367,7 +391,16 @@ func setup(m2client: Node, assets_root: String,
|
||||
# 地面掉落物
|
||||
ground_items = GroundItems.new()
|
||||
add_child(ground_items)
|
||||
ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list)
|
||||
# SetItemTextTailOwner :722:strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE)
|
||||
# 为空则字面 "'s"。poc 用 EterLocale 的 POSSESSIVE_MORPHENE 键作对应物(缺则回 "")。
|
||||
var _possessive := ""
|
||||
if assets_root != "":
|
||||
var _poss_loc: RefCounted = load("res://locale.gd").new()
|
||||
_poss_loc.setup(assets_root, "en")
|
||||
if _poss_loc.has("POSSESSIVE_MORPHENE"):
|
||||
_possessive = _poss_loc.t("POSSESSIVE_MORPHENE")
|
||||
ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list,
|
||||
func() -> Camera3D: return cam, _possessive)
|
||||
pc.set_input_surfaces(cursor_manager, ui, ground_items,
|
||||
Callable(net_play, "cancel_fishing"), Callable(net_play, "on_ground_click"))
|
||||
view_equipment_ui = ViewEquipmentUI.new()
|
||||
@@ -489,6 +522,15 @@ func setup(m2client: Node, assets_root: String,
|
||||
game_option_ui.display_option_changed.connect(_on_display_option)
|
||||
if net_world and net_world.has_method("set_always_show_name") and game_option_ui.has_method("display_value"):
|
||||
net_world.set_always_show_name(game_option_ui.display_value("always_show_name") == 1)
|
||||
# §8.8 / §5.4「伤害数字」radio -> net_world(CPythonSystem::SetShowDamageFlag)。
|
||||
if net_world and net_world.has_method("set_show_damage") and game_option_ui.has_method("display_value"):
|
||||
net_world.set_show_damage(game_option_ui.display_value("show_damage") == 1)
|
||||
# §8.4「名字色」radio -> net_world(uigameoption.__SetNameColorMode → chrmgr.SetEmpireNameMode)。
|
||||
if net_world and net_world.has_method("set_empire_name_mode") and game_option_ui.has_method("display_value"):
|
||||
net_world.set_empire_name_mode(game_option_ui.display_value("name_color") == 1)
|
||||
# §8.4「看他国玩家目标框」radio -> net_play(uitarget.TargetBoard.Open 前置过滤)。
|
||||
if net_play and net_play.has_method("set_view_other_empire_target") and game_option_ui.has_method("display_value"):
|
||||
net_play.set_view_other_empire_target(game_option_ui.display_value("target_board") == 1)
|
||||
system_menu_ui = SystemMenuUI.new()
|
||||
add_child(system_menu_ui)
|
||||
system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui)
|
||||
@@ -614,6 +656,69 @@ func _queue_shot(skill: int) -> void:
|
||||
_pending_shots.pop_front()
|
||||
|
||||
var _flash_rect: ColorRect
|
||||
var _quest_fade_rect: ColorRect
|
||||
var _quest_fade_tween: Tween
|
||||
|
||||
func _on_quest_camera_event(kind: String, setting: Dictionary, blendtime: float) -> void:
|
||||
if cam == null or not is_instance_valid(cam):
|
||||
return
|
||||
match kind:
|
||||
"SET_CAMERA":
|
||||
if cam.has_method("set_event_camera"):
|
||||
cam.set_event_camera(setting)
|
||||
"BLEND_CAMERA":
|
||||
if cam.has_method("blend_event_camera"):
|
||||
cam.blend_event_camera(setting, blendtime)
|
||||
"RESTORE_CAMERA":
|
||||
if cam.has_method("set_default_camera"):
|
||||
cam.set_default_camera()
|
||||
|
||||
func _on_quest_fade_event(kind: String, speed: float) -> void:
|
||||
if ui == null:
|
||||
if quest_dialog and quest_dialog.has_method("end_event_process"):
|
||||
quest_dialog.end_event_process()
|
||||
return
|
||||
if _quest_fade_rect == null or not is_instance_valid(_quest_fade_rect):
|
||||
_quest_fade_rect = ColorRect.new()
|
||||
_quest_fade_rect.name = "QuestFade"
|
||||
_quest_fade_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_quest_fade_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_quest_fade_rect.z_index = 100
|
||||
ui.add_child(_quest_fade_rect)
|
||||
var white := kind.begins_with("WHITE_")
|
||||
_quest_fade_rect.color = Color(1, 1, 1, _quest_fade_rect.color.a) if white \
|
||||
else Color(0, 0, 0, _quest_fade_rect.color.a)
|
||||
var fade_in := kind.ends_with("_IN")
|
||||
var target_alpha := 0.0 if fade_in else 1.0
|
||||
_quest_fade_rect.visible = target_alpha > 0.0 or _quest_fade_rect.color.a > 0.001
|
||||
if _quest_fade_tween and _quest_fade_tween.is_valid():
|
||||
_quest_fade_tween.kill()
|
||||
# uiquest.py advances alpha by speed once per frame. At 60 FPS this is
|
||||
# equivalent to 1 / (speed * 60) seconds; malformed zero speeds use the
|
||||
# reference default to avoid permanently blocking the event set.
|
||||
var frame_speed := maxf(absf(speed), 0.035)
|
||||
var duration := absf(target_alpha - _quest_fade_rect.color.a) / (frame_speed * 60.0)
|
||||
_quest_fade_tween = create_tween()
|
||||
_quest_fade_tween.tween_property(_quest_fade_rect, "color:a", target_alpha, duration)
|
||||
_quest_fade_tween.tween_callback(func():
|
||||
_quest_fade_rect.visible = target_alpha > 0.0
|
||||
if quest_dialog and quest_dialog.has_method("end_event_process"):
|
||||
quest_dialog.end_event_process())
|
||||
|
||||
func _on_dungeon_result(result: Dictionary) -> void:
|
||||
if dungeon_result_ui and dungeon_result_ui.has_method("show_result"):
|
||||
dungeon_result_ui.show_result(result)
|
||||
|
||||
func _clear_quest_presentation() -> void:
|
||||
if _quest_fade_tween and _quest_fade_tween.is_valid():
|
||||
_quest_fade_tween.kill()
|
||||
_quest_fade_tween = null
|
||||
if _quest_fade_rect and is_instance_valid(_quest_fade_rect):
|
||||
_quest_fade_rect.color.a = 0.0
|
||||
_quest_fade_rect.visible = false
|
||||
if cam and cam.has_method("set_default_camera"):
|
||||
cam.set_default_camera()
|
||||
|
||||
func _screen_flash(secs: float) -> void:
|
||||
if hud == null:
|
||||
return
|
||||
@@ -661,6 +766,9 @@ func _on_world_reset() -> void:
|
||||
widget.call("_close")
|
||||
if atlas_ui and atlas_ui.has_method("close"):
|
||||
atlas_ui.close()
|
||||
if dungeon_result_ui and dungeon_result_ui.has_method("close"):
|
||||
dungeon_result_ui.close()
|
||||
_clear_quest_presentation()
|
||||
if ui and ui.has_method("close_top"):
|
||||
while ui.close_top():
|
||||
pass
|
||||
@@ -731,6 +839,12 @@ func _unhandled_input(e: InputEvent) -> void:
|
||||
if key == KEY_SHIFT:
|
||||
_quick_page_mode = e.pressed
|
||||
return
|
||||
# game.py SetPCTargetBoard :807 —— app.IsPressed(app.DIK_LCONTROL)。参考端在点 PC 的
|
||||
# 那一刻轮询 LCONTROL 按住态;poc 把按下 / 松开喂给 net_play,点 PC 时再查(seam ⑫)。
|
||||
if key == KEY_CTRL:
|
||||
if net_play and net_play.has_method("set_lcontrol_down"):
|
||||
net_play.set_lcontrol_down(e.pressed)
|
||||
return
|
||||
if not e.pressed:
|
||||
match key:
|
||||
KEY_SPACE:
|
||||
@@ -855,6 +969,12 @@ func _set_names(visible: bool) -> void:
|
||||
func _on_display_option(key: String, value: int) -> void:
|
||||
if key == "always_show_name" and net_world and net_world.has_method("set_always_show_name"):
|
||||
net_world.set_always_show_name(value == 1)
|
||||
elif key == "show_damage" and net_world and net_world.has_method("set_show_damage"):
|
||||
net_world.set_show_damage(value == 1)
|
||||
elif key == "name_color" and net_world and net_world.has_method("set_empire_name_mode"):
|
||||
net_world.set_empire_name_mode(value == 1)
|
||||
elif key == "target_board" and net_play and net_play.has_method("set_view_other_empire_target"):
|
||||
net_play.set_view_other_empire_target(value == 1)
|
||||
|
||||
func _send_command(text: String) -> void:
|
||||
if client and client.has_method("say"):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,150 @@
|
||||
# name_tail_layout —— §8.8 seam ⑨ 收口:头顶名字尾标「有聊天气泡时上移贴气泡」1:1。
|
||||
# CPythonTextTail::UpdateShowingTextTail 里,每条角色名字尾标投影完屏幕坐标后,
|
||||
# 若该 VID 同时有一条 chat 尾标且 `pChatTail->bNameFlag`(= RegisterChatTail 注册的
|
||||
# 「聊天」气泡,非 RegisterInfoTail 的「信息」气泡),就把名字尾标的屏幕 y 顶到
|
||||
# `chatTail.y - 17.0f`(屏幕 y 向下,减 17px = 上移一行,正好贴在气泡上沿)。
|
||||
# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd /
|
||||
# target_board.gd / pick_show.gd 同一套纯静态镜像套路,本模块是第 28 个 canonical。)
|
||||
#
|
||||
# REF(40250 ClientVS22 source/ 为唯一基线):
|
||||
# UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f
|
||||
# UserInterface/PythonTextTail.cpp:509 RegisterCharacterTextTail(..., float fAddHeight=10.0f)
|
||||
# :519 fHeight = GetHeight() + fAddHeight —— 名字尾标世界高=角色高 + 10
|
||||
# UserInterface/PythonTextTail.cpp:581 RegisterChatTail:新建时 fHeight = GetHeight() + 10.0f、
|
||||
# :603 / :618 pTextTail->bNameFlag = TRUE —— 「聊天」气泡(白)
|
||||
# UserInterface/PythonTextTail.cpp:623 RegisterInfoTail:同高,
|
||||
# :646 / :661 pTextTail->bNameFlag = FALSE —— 「信息」气泡(淡红),不顶名字
|
||||
# UserInterface/PythonTextTail.cpp:88 UpdateShowingTextTail()
|
||||
# :101 for (角色名字尾标 : m_CharacterTextTailList)
|
||||
# :104 UpdateTextTail(pTextTail) —— 先投影出屏幕 x/y/z
|
||||
# :106 itor = m_ChatTailMap.find(pTextTail->dwVirtualID)
|
||||
# :110 if (m_ChatTailMap.end() != itor)
|
||||
# :112 if (pChatTail->bNameFlag)
|
||||
# :114 pTextTail->y = pChatTail->y - 17.0f —— 名字屏幕 y 顶到气泡上方 17px
|
||||
# UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:同一个 bNameFlag 还驱动
|
||||
# ShowCharacterTextTail(dwVID) 强显(增量 112/113 已接 chat_tail.gd / name_show.gd)。
|
||||
# UserInterface/PythonTextTail.cpp:151 ArrangeTextTail()
|
||||
# :211 for (pTextTail : m_CharacterTextTailList) —— 逐条角色名字尾标
|
||||
# :228 pGuildNameInstance->SetPosition(x + iImageHalfSize, pTextTail->y - c_fyGuildNamePosition, z)
|
||||
# :21 const float c_fyGuildNamePosition = 15.0f —— 公会名锚在名字 y 上方 15px
|
||||
# :258 pTitle->SetPosition(x - iNameWidth/2 - fxAdd, pTextTail->y, z) —— 称号与名字同一行
|
||||
# :283 pLevel->SetPosition(x - iNameWidth/2 - fxAdd - iTitleWidth, pTextTail->y, z) —— 等级同一行
|
||||
# —— 公会名 / 称号 / 等级三个子标签的屏幕 y 全部取自 `pTextTail->y`,而这个 y 已在
|
||||
# UpdateShowingTextTail :114 被聊天气泡顶过 -17。所以名字尾标一旦上移,子标签整体跟随。
|
||||
#
|
||||
# seam ⑨(已收口):参考端名字与气泡默认投影到同一世界点(GetHeight()+10),靠这条屏幕
|
||||
# -17px 把名字从「和气泡重叠」顶成「贴气泡上沿」。poc 是每实体一个 billboard Label3D、
|
||||
# 气泡默认在名字上方(局部 y 2.35 vs 2.1),本模块把屏幕 y 向下的 -17px 换成世界 y
|
||||
# 向上的 +17px*pixel_size:有「聊天」气泡时名字世界 y 顶到气泡 y 之上 17px,气泡消失
|
||||
# (或只是「信息」气泡)时回默认 y。像素 vs 世界的换算按名字 Label3D 的 pixel_size,
|
||||
# 与参考的字体无关常量 17 对齐。
|
||||
# seam ⑦(部分收口 · 增量 119):子标签(GuildTag/TitleTag/LevelTag)随名字尾标纵向位移
|
||||
# 已 1:1(name_tail_lift_delta / subtag_world_y:名字上移多少子标签整体上移多少)。
|
||||
# seam ⑦(水平排版 · 增量 121):ArrangeTextTail 角色块(PythonTextTail.cpp:211-320)非
|
||||
# EUROPE 分支的逐像素水平排布也已 1:1(下方 name_x_shift / title_x_offset /
|
||||
# level_x_offset / guild_name_x_offset / mark_x_offset / subtag_world_x):
|
||||
# :236 fxAdd = 0 —— 无称号无等级
|
||||
# :242 if (pTitle) fxAdd = 8.0f
|
||||
# :258 pTitle->SetPosition(x - iNameWidth/2 - fxAdd, y, z) (称号,居中对齐)
|
||||
# :263 if (pLevel) —— 有称号时等级在称号左侧
|
||||
# :282 pLevel->SetPosition(x - iNameWidth/2 - fxAdd - iTitleWidth, y, z)(等级,右对齐)
|
||||
# :288 else fxAdd = 4.0f —— 只有等级
|
||||
# :312 pLevel->SetPosition(x - iNameWidth/2 - fxAdd, y, z)
|
||||
# :320 pTextInstance->SetPosition(x + fxAdd, y, z) —— 名字本身右移 fxAdd
|
||||
# :224 iImageHalfSize = pMarkInstance->GetWidth()/2 + c_fxMarkPosition(1.5)
|
||||
# :227 pMarkInstance->SetPosition(x - guildW/2 - iImageHalfSize, y - c_fyMarkPosition(26))
|
||||
# :228 pGuildNameInstance->SetPosition(x + iImageHalfSize, y - c_fyGuildNamePosition(15), z)
|
||||
# —— 公会名的水平锚点只在「有公会徽记 pMarkInstance」时才被 ArrangeTextTail 改写。
|
||||
# 仍近似(seam ⑦ 遗留):① poc 无公会徽记 mark 实体 —— 参考端 :220 的
|
||||
# `if (pMarkInstance && pGuildNameInstance)` 不成立时公会名水平锚点不动,poc 亦让
|
||||
# GuildTag 留在名字中线正上方(x 偏移 0),不做 +markW/2+1.5;② 文本宽度 poc 用
|
||||
# 「字符数 * 约 7px」估,参考端是 GetTextSize 精确像素;③ 三子标签竖排 vs 参考同一
|
||||
# 水平行(poc 3D billboard 里三条挤在一行会互相糊,保留竖排换 x 偏移的折衷)。
|
||||
extends RefCounted
|
||||
|
||||
# PythonTextTail.cpp:114 的 `17.0f`。屏幕坐标下是「名字 y 减 17」(上移一行贴气泡)。
|
||||
const NAME_OFFSET_ABOVE_BUBBLE_PX := 17.0
|
||||
|
||||
# PythonTextTail.cpp:21 的 `c_fyGuildNamePosition = 15.0f`:参考端公会名锚在名字尾标
|
||||
# 屏幕 y 上方 15px(称号 / 等级则与名字同一行)。poc 竖排近似不用它做像素排版,仅作
|
||||
# 1:1 记录——子标签真正跟随的是「名字尾标位移量」,见 subtag_world_y。
|
||||
const GUILD_NAME_ABOVE_NAME_PX := 15.0
|
||||
|
||||
# UpdateShowingTextTail :110 `m_ChatTailMap.end() != itor` && :112 `pChatTail->bNameFlag`。
|
||||
# has_chat_tail —— 该 VID 在 m_ChatTailMap 里有一条(聊天或信息);
|
||||
# chat_name_flag —— 那条的 bNameFlag(RegisterChatTail=TRUE / RegisterInfoTail=FALSE)。
|
||||
static func name_follows_bubble(has_chat_tail: bool, chat_name_flag: bool) -> bool:
|
||||
return has_chat_tail and chat_name_flag
|
||||
|
||||
# 参考端字面公式(屏幕坐标,y 向下):follows 时 `pChatTail->y - 17.0f`,否则名字投影原值。
|
||||
static func name_tail_screen_y(default_y: float, bubble_y: float, follows: bool) -> float:
|
||||
if follows:
|
||||
return bubble_y - NAME_OFFSET_ABOVE_BUBBLE_PX
|
||||
return default_y
|
||||
|
||||
# poc 3D 适配(世界坐标,y 向上):屏幕的 -17px 变成 +17px*pixel_size —— follows 时名字
|
||||
# 世界 y 顶到气泡世界 y 之上 17 个名字像素,否则回默认世界 y。
|
||||
static func name_tail_world_y(default_world_y: float, bubble_world_y: float,
|
||||
follows: bool, px_size: float) -> float:
|
||||
if follows:
|
||||
return bubble_world_y + NAME_OFFSET_ABOVE_BUBBLE_PX * px_size
|
||||
return default_world_y
|
||||
|
||||
# 名字尾标相对默认世界 y 的位移量(follows 时 = 顶到气泡上方后的净上移,否则 0)。
|
||||
# ArrangeTextTail 里公会名 / 称号 / 等级都锚在 pTextTail->y 上,位移量就是子标签要跟随的量。
|
||||
static func name_tail_lift_delta(default_world_y: float, bubble_world_y: float,
|
||||
follows: bool, px_size: float) -> float:
|
||||
return name_tail_world_y(default_world_y, bubble_world_y, follows, px_size) - default_world_y
|
||||
|
||||
# ArrangeTextTail :228 / :258 / :283:子标签屏幕 y 取自 pTextTail->y(已被聊天气泡顶过)。
|
||||
# poc 里子标签各有自己的默认局部 y(竖排近似),名字尾标上移 lift_delta 时三者整体加同一段。
|
||||
static func subtag_world_y(default_subtag_y: float, name_tail_lift_delta: float) -> float:
|
||||
return default_subtag_y + name_tail_lift_delta
|
||||
|
||||
# --- seam ⑦ 水平排版(ArrangeTextTail 角色块,PythonTextTail.cpp:211-320 非 EUROPE 分支)---
|
||||
|
||||
# :242 `fxAdd = 8.0f`(if pTitle)。屏幕像素,名字锚点 x 与三子标签公用。
|
||||
const NAME_X_ADD_TITLE := 8.0
|
||||
# :288 `fxAdd = 4.0f`(else,只有等级)。
|
||||
const NAME_X_ADD_LEVEL_ONLY := 4.0
|
||||
# :20 `const float c_fxMarkPosition = 1.5f`。
|
||||
const MARK_X_PAD := 1.5
|
||||
# :22 `const float c_fyMarkPosition = 15.0f + 11.0f`。公会徽记锚在名字 y 上方 26px。
|
||||
const MARK_ABOVE_NAME_PX := 26.0
|
||||
|
||||
# :320 `pTextInstance->SetPosition(pTextTail->x + fxAdd, ...)`:名字本身相对锚点右移 fxAdd。
|
||||
# 有称号 -> 8;无称号只有等级 -> 4;都没有 -> 0。
|
||||
static func name_x_shift(has_title: bool, has_level: bool) -> float:
|
||||
if has_title:
|
||||
return NAME_X_ADD_TITLE
|
||||
if has_level:
|
||||
return NAME_X_ADD_LEVEL_ONLY
|
||||
return 0.0
|
||||
|
||||
# :258 `pTitle->SetPosition(pTextTail->x - (iNameWidth/2) - fxAdd, ...)`(fxAdd = 8)。
|
||||
# 返回称号中心相对名字锚点 x 的像素偏移(负 = 在名字左侧)。
|
||||
static func title_x_offset(name_w: float) -> float:
|
||||
return -(name_w / 2.0) - NAME_X_ADD_TITLE
|
||||
|
||||
# 等级相对名字锚点 x 的像素偏移(右对齐实例,故这是其右边缘位置):
|
||||
# 有称号(:282,fxAdd = 8):x - iNameWidth/2 - 8 - iTitleWidth —— 再往称号左边挪一个称号宽;
|
||||
# 只有等级(:312,fxAdd = 4):x - iNameWidth/2 - 4。
|
||||
static func level_x_offset(name_w: float, title_w: float, has_title: bool) -> float:
|
||||
if has_title:
|
||||
return -(name_w / 2.0) - NAME_X_ADD_TITLE - title_w
|
||||
return -(name_w / 2.0) - NAME_X_ADD_LEVEL_ONLY
|
||||
|
||||
# :224/:228 公会名中心 = x + iImageHalfSize,iImageHalfSize = markW/2 + c_fxMarkPosition(1.5)。
|
||||
# 仅在有公会徽记时 ArrangeTextTail 才改写公会名 x;无徽记(poc 现状)应传 mark_w = 0
|
||||
# 并按「参考端此路不改写」处理(调用方直接留 0 偏移),此函数仅供有徽记时用。
|
||||
static func guild_name_x_offset(mark_w: float) -> float:
|
||||
return mark_w / 2.0 + MARK_X_PAD
|
||||
|
||||
# :227 徽记位置 = x - guildW/2 - iImageHalfSize(相对名字锚点 x 的像素偏移,负 = 左侧)。
|
||||
static func mark_x_offset(guild_w: float, mark_w: float) -> float:
|
||||
return -(guild_w / 2.0) - (mark_w / 2.0 + MARK_X_PAD)
|
||||
|
||||
# poc 3D 适配:参考端在屏幕像素空间摆位(+x 向右),poc 子标签是 billboard Label3D ——
|
||||
# billboard 本地 +x 恒等于屏幕右,按该 Label3D 的 pixel_size 把像素偏移折成世界本地 x。
|
||||
static func subtag_world_x(px_offset: float, px_size: float) -> float:
|
||||
return px_offset * px_size
|
||||
@@ -0,0 +1,134 @@
|
||||
# name_tail_layout_test —— §8.8 seam ⑨:名字尾标「有聊天气泡时顶到气泡上方 17px」1:1。
|
||||
# name_follows_bubble(有 chat 尾标 && bNameFlag)、name_tail_screen_y(参考端字面
|
||||
# `chat.y - 17`)、name_tail_world_y(poc 世界坐标 `bubble_y + 17*pixel_size`)、
|
||||
# 信息气泡(bNameFlag FALSE)不顶名字、无气泡回默认。
|
||||
# 增量 119(seam ⑦ 纵向):name_tail_lift_delta / subtag_world_y —— 公会名 / 称号 / 等级
|
||||
# 子标签随名字尾标纵向位移整体平移同一段(ArrangeTextTail :228/:258/:283)。
|
||||
# 增量 121(seam ⑦ 水平):name_x_shift / title_x_offset / level_x_offset /
|
||||
# guild_name_x_offset / mark_x_offset / subtag_world_x —— 逐像素水平排布
|
||||
# (ArrangeTextTail :211-320 非 EUROPE 分支:称号 / 等级在名字左侧、名字右移 fxAdd)。
|
||||
# godot --headless --path project --script name_tail_layout_test.gd
|
||||
# 退出码 0 = 全过。
|
||||
extends SceneTree
|
||||
|
||||
const NameTailLayout = preload("res://name_tail_layout.gd")
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _feq(a: float, b: float, m: String) -> void:
|
||||
_ck(absf(a - b) < 0.0001, "%s (got %f want %f)" % [m, a, b])
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: name_tail_layout_test (§8.8 seam ⑨ 名字尾标贴气泡 1:1)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
_feq(NameTailLayout.NAME_OFFSET_ABOVE_BUBBLE_PX, 17.0, "常量 17.0(PythonTextTail.cpp:114)")
|
||||
|
||||
# --- 0. name_follows_bubble(:110 有尾标 && :112 bNameFlag)------------------
|
||||
_ck(NameTailLayout.name_follows_bubble(true, true) == true, "有聊天气泡(bNameFlag TRUE) -> 顶名字")
|
||||
_ck(NameTailLayout.name_follows_bubble(true, false) == false, "有信息气泡(bNameFlag FALSE) -> 不顶")
|
||||
_ck(NameTailLayout.name_follows_bubble(false, true) == false, "无气泡 -> 不顶(即便 flag 传 true)")
|
||||
_ck(NameTailLayout.name_follows_bubble(false, false) == false, "无气泡无 flag -> 不顶")
|
||||
|
||||
# --- 1. name_tail_screen_y(参考端字面:屏幕 y 向下,follows -> bubble_y - 17)---
|
||||
_feq(NameTailLayout.name_tail_screen_y(300.0, 250.0, true), 233.0, "follows -> 气泡屏幕 y 250 - 17 = 233")
|
||||
_feq(NameTailLayout.name_tail_screen_y(300.0, 250.0, false), 300.0, "!follows -> 名字投影原值 300")
|
||||
# 与 name_follows_bubble 串起来:信息气泡不顶 -> 用默认
|
||||
var follows_info := NameTailLayout.name_follows_bubble(true, false)
|
||||
_feq(NameTailLayout.name_tail_screen_y(300.0, 250.0, follows_info), 300.0, "信息气泡 -> 名字留默认屏幕 y")
|
||||
|
||||
# --- 2. name_tail_world_y(poc:世界 y 向上,follows -> bubble_y + 17*px_size)---
|
||||
# 名字 Label3D pixel_size = 0.006 -> 17px = 0.102 世界单位;气泡局部 y = 2.35。
|
||||
_feq(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.006), 2.35 + 17.0 * 0.006,
|
||||
"follows -> 名字世界 y = 气泡 2.35 + 17*0.006")
|
||||
_feq(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.006), 2.452, "= 2.452")
|
||||
_feq(NameTailLayout.name_tail_world_y(2.1, 2.35, false, 0.006), 2.1, "!follows -> 回默认世界 y 2.1")
|
||||
# 顶起来后名字确实在气泡之上(世界 y 更大)
|
||||
_ck(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.006) > 2.35, "顶起后名字在气泡上方")
|
||||
# 不同 pixel_size 线性缩放
|
||||
_feq(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.012), 2.35 + 0.204, "px_size 翻倍 -> 偏移翻倍")
|
||||
|
||||
# --- 3. 组合:完整判定链 --------------------------------------------------
|
||||
# 聊天气泡在,名字顶上去
|
||||
var f1 := NameTailLayout.name_follows_bubble(true, true)
|
||||
_feq(NameTailLayout.name_tail_world_y(2.1, 2.35, f1, 0.006), 2.452, "聊天气泡 -> 名字 2.452")
|
||||
# 气泡过期(has=false),名字回落
|
||||
var f2 := NameTailLayout.name_follows_bubble(false, false)
|
||||
_feq(NameTailLayout.name_tail_world_y(2.1, 2.35, f2, 0.006), 2.1, "气泡过期 -> 名字回 2.1")
|
||||
|
||||
# --- 4. seam ⑦ 收口(增量 119):子标签随名字尾标纵向位移 -------------------
|
||||
_feq(NameTailLayout.GUILD_NAME_ABOVE_NAME_PX, 15.0, "常量 15.0(c_fyGuildNamePosition)")
|
||||
# name_tail_lift_delta:follows -> 顶起后的净上移量;否则 0。
|
||||
_feq(NameTailLayout.name_tail_lift_delta(2.1, 2.35, true, 0.006), 2.452 - 2.1,
|
||||
"follows -> lift_delta = 2.452 - 2.1 = 0.352")
|
||||
_feq(NameTailLayout.name_tail_lift_delta(2.1, 2.35, false, 0.006), 0.0,
|
||||
"!follows -> lift_delta = 0")
|
||||
_feq(NameTailLayout.name_tail_lift_delta(2.1, 2.35, true, 0.012), (2.35 + 0.204) - 2.1,
|
||||
"px_size 翻倍 -> lift_delta 随之变大")
|
||||
# subtag_world_y:默认局部 y 加上位移量。三个子标签整体平移同一段。
|
||||
var lift := NameTailLayout.name_tail_lift_delta(2.1, 2.35, true, 0.006)
|
||||
_feq(NameTailLayout.subtag_world_y(2.34, lift), 2.34 + 0.352, "GuildTag 2.34 -> 2.692")
|
||||
_feq(NameTailLayout.subtag_world_y(1.86, lift), 1.86 + 0.352, "TitleTag 1.86 -> 2.212")
|
||||
_feq(NameTailLayout.subtag_world_y(1.74, lift), 1.74 + 0.352, "LevelTag 1.74 -> 2.092")
|
||||
# 三者之间的相对间距不变(整体平移,不是各自缩放)
|
||||
var g := NameTailLayout.subtag_world_y(2.34, lift)
|
||||
var t := NameTailLayout.subtag_world_y(1.86, lift)
|
||||
_feq(g - t, 2.34 - 1.86, "平移后 GuildTag/TitleTag 相对间距不变")
|
||||
# 气泡过期 -> lift 0 -> 子标签回默认
|
||||
var lift0 := NameTailLayout.name_tail_lift_delta(2.1, 2.35, false, 0.006)
|
||||
_feq(NameTailLayout.subtag_world_y(2.34, lift0), 2.34, "气泡过期 -> GuildTag 回默认 2.34")
|
||||
_feq(NameTailLayout.subtag_world_y(1.74, lift0), 1.74, "气泡过期 -> LevelTag 回默认 1.74")
|
||||
|
||||
# --- 5. seam ⑦ 水平排版(增量 121 / ArrangeTextTail :211-320 非 EUROPE 分支)--------
|
||||
_feq(NameTailLayout.NAME_X_ADD_TITLE, 8.0, "常量 8.0(:242 fxAdd when pTitle)")
|
||||
_feq(NameTailLayout.NAME_X_ADD_LEVEL_ONLY, 4.0, "常量 4.0(:288 fxAdd when 仅等级)")
|
||||
_feq(NameTailLayout.MARK_X_PAD, 1.5, "常量 1.5(:20 c_fxMarkPosition)")
|
||||
_feq(NameTailLayout.MARK_ABOVE_NAME_PX, 26.0, "常量 26.0(:22 c_fyMarkPosition = 15 + 11)")
|
||||
|
||||
# name_x_shift(:320 名字本身右移 fxAdd)
|
||||
_feq(NameTailLayout.name_x_shift(true, true), 8.0, "有称号 -> 名字右移 8")
|
||||
_feq(NameTailLayout.name_x_shift(true, false), 8.0, "有称号无等级 -> 名字右移 8")
|
||||
_feq(NameTailLayout.name_x_shift(false, true), 4.0, "只有等级 -> 名字右移 4")
|
||||
_feq(NameTailLayout.name_x_shift(false, false), 0.0, "都没有 -> 名字不移")
|
||||
|
||||
# title_x_offset(:258 x - iNameWidth/2 - 8)
|
||||
_feq(NameTailLayout.title_x_offset(40.0), -(40.0 / 2.0) - 8.0, "称号 x 偏移 = -nameW/2 - 8 = -28")
|
||||
_ck(NameTailLayout.title_x_offset(40.0) < 0.0, "称号在名字左侧")
|
||||
|
||||
# level_x_offset:有称号 -> :282 x - nameW/2 - 8 - titleW;只有等级 -> :312 x - nameW/2 - 4
|
||||
_feq(NameTailLayout.level_x_offset(40.0, 30.0, true), -(40.0 / 2.0) - 8.0 - 30.0,
|
||||
"有称号:等级 x 偏移 = -nameW/2 - 8 - titleW = -58")
|
||||
_feq(NameTailLayout.level_x_offset(40.0, 30.0, false), -(40.0 / 2.0) - 4.0,
|
||||
"只有等级:等级 x 偏移 = -nameW/2 - 4 = -24(titleW 被忽略)")
|
||||
_ck(NameTailLayout.level_x_offset(40.0, 30.0, true) < NameTailLayout.title_x_offset(40.0),
|
||||
"有称号时等级比称号更靠左")
|
||||
|
||||
# guild_name_x_offset / mark_x_offset(:224/:227/:228,仅有公会徽记时用)
|
||||
_feq(NameTailLayout.guild_name_x_offset(20.0), 20.0 / 2.0 + 1.5, "公会名 x 偏移 = markW/2 + 1.5 = 11.5")
|
||||
_feq(NameTailLayout.guild_name_x_offset(0.0), 1.5, "无徽记宽 -> 1.5(调用方实际按此路不改写处理)")
|
||||
_feq(NameTailLayout.mark_x_offset(60.0, 20.0), -(60.0 / 2.0) - (20.0 / 2.0 + 1.5),
|
||||
"徽记 x 偏移 = -guildW/2 - (markW/2 + 1.5) = -41.5")
|
||||
|
||||
# subtag_world_x:像素偏移 * pixel_size,线性;符号保留(左负右正)
|
||||
_feq(NameTailLayout.subtag_world_x(-28.0, 0.006), -28.0 * 0.006, "-28px @ 0.006 -> -0.168 世界本地 x")
|
||||
_feq(NameTailLayout.subtag_world_x(8.0, 0.006), 0.048, "8px @ 0.006 -> +0.048")
|
||||
_feq(NameTailLayout.subtag_world_x(-28.0, 0.012), -0.336, "pixel_size 翻倍 -> 偏移翻倍")
|
||||
_feq(NameTailLayout.subtag_world_x(0.0, 0.006), 0.0, "0 偏移 -> 0")
|
||||
|
||||
# 组合:名字 40px、称号 30px、等级 12px,都在 -> 三者从左到右 = 等级、称号、名字
|
||||
var name_px := 40.0
|
||||
var title_px := 30.0
|
||||
var nx := NameTailLayout.name_x_shift(true, true) # +8
|
||||
var tx := NameTailLayout.title_x_offset(name_px) # -28
|
||||
var lx := NameTailLayout.level_x_offset(name_px, title_px, true) # -58
|
||||
_ck(lx < tx and tx < nx, "水平顺序:等级(-58) < 称号(-28) < 名字(+8)")
|
||||
+79
-9
@@ -16,6 +16,9 @@
|
||||
extends Node
|
||||
|
||||
signal target_changed(vid: int)
|
||||
# game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时请求开悄悄话对话框
|
||||
# (参考端 self.interface.OpenWhisperDialog(name))。game_scene 接到 chat.start_whisper()。
|
||||
signal whisper_requested(name: String)
|
||||
# OnCannotAttack / OnCannotShot / OnCannotUseSkill 的字符串码(game.py 同名回调),
|
||||
# 供 HUD 弹 localeInfo 文案。§3.4
|
||||
signal cannot_act(code: String)
|
||||
@@ -61,6 +64,8 @@ const CM := 100.0 # 米 -> 厘米
|
||||
|
||||
# §5.4 —— PVP / PK / 决斗可攻击判据(CInstanceBase::IsAttackableInstance 1:1,逐行)。
|
||||
const EntityRules = preload("res://entity_rules.gd")
|
||||
# §5.4 / §8.8 —— RecvTargetPacket 的目标框收包分派(判定树 + CanViewTargetHP)1:1。
|
||||
const TargetBoard = preload("res://target_board.gd")
|
||||
# §3.2 上行节流表(对齐 `PythonPlayerEventHandler.cpp` 的 6 个回调):
|
||||
# OnMove —— 首次开走 / 改向:立即发 FUNC_MOVE,并重置 moving/waiting 两个窗口
|
||||
# OnMoving —— 移动中周期上行:300 ms 节流,发 FUNC_MOVE
|
||||
@@ -162,6 +167,12 @@ const NAME_COMBO_ATTACK_1 := 14
|
||||
|
||||
var _target_vid := 0
|
||||
var _main_vid := 0
|
||||
# game-option「看他国玩家目标框」(constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1)。
|
||||
# 关掉时 TargetBoard.Open 前置过滤:异阵营 PC 目标框直接不开(uitarget.py:189-193)。
|
||||
var _view_other_empire_target := true
|
||||
# game.py SetPCTargetBoard :807 —— app.IsPressed(app.DIK_LCONTROL)。poc 侧由 game_scene
|
||||
# 的 KEY_CTRL 按下 / 松开喂 set_lcontrol_down(),点 PC 时轮询(对齐参考端 IsPressed 语义)。
|
||||
var _lcontrol_down := false
|
||||
var _last_sent_pos := Vector3.ZERO # 上一次上行时的玩家世界坐标
|
||||
var _last_moving_sent_t := 0.0 # 上一次 OnMoving 上行时刻(秒)
|
||||
var _last_waiting_sent_t := 0.0 # 上一次 OnWaiting 上行时刻(秒)
|
||||
@@ -502,13 +513,22 @@ func _heading_deg(yaw_rad: float) -> float:
|
||||
func _player_yaw() -> float:
|
||||
return pc.player.rotation.y if pc.player else 0.0
|
||||
|
||||
# CHRTYPE 分类:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP。
|
||||
# CHRTYPE 分类:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP / 5 BUILDING /
|
||||
# 6 WOODEN_DOOR。门类型既可由实体 fixture 的 building / kind 给出,也可由
|
||||
# mob_proto 的 bType=4 + 13000 / 30111..30119 race 识别。
|
||||
# 本 fork 的 GC_CHARACTER_ADD.bType 对 NPC/怪都是 0,按 race 查 mob_proto 补。
|
||||
func _entity_kind(e: Dictionary) -> int:
|
||||
if bool(e.get("building", false)):
|
||||
return EntityRules.KIND_BUILDING
|
||||
var explicit_kind := int(e.get("kind", -1))
|
||||
if explicit_kind >= EntityRules.KIND_BUILDING:
|
||||
return explicit_kind
|
||||
var ct := int(e.get("ch_type", 0))
|
||||
if ct != 0:
|
||||
return ct
|
||||
var race := int(e.get("race", 0))
|
||||
if EntityRules._is_wooden_door(e):
|
||||
return EntityRules.KIND_WOODEN_DOOR
|
||||
if race >= 1 and proto and proto.has_method("mob"):
|
||||
var m: Dictionary = proto.mob(race)
|
||||
if not m.is_empty():
|
||||
@@ -518,6 +538,8 @@ func _entity_kind(e: Dictionary) -> int:
|
||||
1: return 1
|
||||
2: return 3
|
||||
3: return 4
|
||||
4: return EntityRules.KIND_WOODEN_DOOR \
|
||||
if EntityRules._is_wooden_door(e) else EntityRules.KIND_BUILDING
|
||||
return 0
|
||||
|
||||
# 实体显示名:玩家 / NPC 走 GC_CHAR_ADD_INFO 的 name;怪没有名字包 -> 查 mob_proto。
|
||||
@@ -697,16 +719,64 @@ func _on_pick(node: Node3D) -> void:
|
||||
return
|
||||
_attack_cd = 0.0
|
||||
_change_target_to_picked_instance(vid)
|
||||
# game.py SetPCTargetBoard :804 —— 点到 PC 时 targetBoard.Open 之后,若按住 LCONTROL
|
||||
# 且同阵营(:809 IsSameEmpire)且非本人(:812 IsMainCharacterIndex)且非 building
|
||||
# (:814 INSTANCE_TYPE_BUILDING)→ interface.OpenWhisperDialog(name)。C++ 侧只对 PC
|
||||
# 分派 SetPCTargetBoard,故 poc 也只在 KIND_PC 分支查。建筑 / 木门由实体分类链识别,
|
||||
# 不满足 PC 分支时不会误触发悄悄话。
|
||||
if _entity_kind(e) == EntityRules.KIND_PC:
|
||||
var same_empire := EntityRules._is_same_empire(_main_entity(), e)
|
||||
if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false):
|
||||
var nm := _entity_name(e)
|
||||
if nm != "":
|
||||
whisper_requested.emit(nm)
|
||||
|
||||
# poc 版 RecvTargetPacket(PythonNetworkStreamPhaseGame.cpp:2424):C++ 侧从 EntityStore
|
||||
# 抽出 (vid, hp%),这里照参考端的判定树分派目标框——目标实例缺失 -> 关框;已死透 -> 不动框;
|
||||
# PC / building 目标 -> 只在框上是别的 VID 时关掉(不显血条);石头 / 敌对怪等「可看血」
|
||||
# 目标 -> SetHPTargetBoard;NPC / warp 等不可看血目标 -> 关框。经 target_board.gd 纯静态镜像。
|
||||
func _on_target_info(vid: int, hp_pct: int) -> void:
|
||||
if vid == 0:
|
||||
_clear_target()
|
||||
return
|
||||
_target_vid = vid
|
||||
if hud and hud.has_method("set_target"):
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
var nm := _entity_name(e) if not e.is_empty() else ""
|
||||
hud.set_target(nm if nm != "" else "目标", hp_pct)
|
||||
var e: Dictionary = client.get_entity(vid) if vid != 0 else {}
|
||||
var exists := vid != 0 and not e.is_empty()
|
||||
var dead := exists and bool(e.get("dead", false))
|
||||
var kind := _entity_kind(e) if exists else -1
|
||||
var is_pc := kind == EntityRules.KIND_PC
|
||||
var is_building := EntityRules._is_building(e)
|
||||
# uitarget.py:189-193 前置过滤:game-option 关「看他国目标框」时异阵营目标不开框。
|
||||
# empire 0 的怪/NPC/石头在 _is_same_empire 里算同阵营 → 不受影响,只挡真·他国 PC。
|
||||
if exists:
|
||||
var same_empire := EntityRules._is_same_empire(_main_entity(), e)
|
||||
if TargetBoard.hidden_as_other_empire(_view_other_empire_target, same_empire):
|
||||
_clear_target()
|
||||
return
|
||||
var can_view := TargetBoard.can_view_target_hp(
|
||||
kind == EntityRules.KIND_STONE, EntityRules._is_wooden_door(e),
|
||||
kind == EntityRules.KIND_MONSTER)
|
||||
match TargetBoard.classify(exists, dead, is_pc, is_building, can_view):
|
||||
TargetBoard.Action.CLOSE:
|
||||
_clear_target()
|
||||
TargetBoard.Action.CLOSE_IF_DIFFERENT:
|
||||
if TargetBoard.should_close_if_different(vid, _target_vid):
|
||||
_clear_target()
|
||||
TargetBoard.Action.SET_HP:
|
||||
if TargetBoard.needs_reset(vid, _target_vid):
|
||||
_target_vid = vid
|
||||
target_changed.emit(vid)
|
||||
if hud and hud.has_method("set_target"):
|
||||
var nm := _entity_name(e)
|
||||
hud.set_target(nm if nm != "" else "目标",
|
||||
TargetBoard.clamp_hp_pct(hp_pct))
|
||||
TargetBoard.Action.NONE:
|
||||
pass
|
||||
|
||||
# game_option_ui 的「看他国玩家目标框」radio(display_option_changed "target_board")。
|
||||
# uigameoption.py → constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)。
|
||||
func set_view_other_empire_target(on: bool) -> void:
|
||||
_view_other_empire_target = on
|
||||
|
||||
# game_scene 的 KEY_CTRL 按下 / 松开 → app.IsPressed(app.DIK_LCONTROL) 语义(game.py:807)。
|
||||
func set_lcontrol_down(down: bool) -> void:
|
||||
_lcontrol_down = down
|
||||
|
||||
func _clear_target() -> void:
|
||||
if _target_vid == 0:
|
||||
|
||||
+342
-41
@@ -21,7 +21,12 @@ const NameColor = preload("res://name_color.gd")
|
||||
const TextTail = preload("res://text_tail.gd")
|
||||
const ChatTail = preload("res://chat_tail.gd")
|
||||
const NameShow = preload("res://name_show.gd")
|
||||
const DamageEffect = preload("res://damage_effect.gd")
|
||||
const EntityRules = preload("res://entity_rules.gd")
|
||||
const PickShow = preload("res://pick_show.gd")
|
||||
const NameTailLayout = preload("res://name_tail_layout.gd")
|
||||
const TextMetrics = preload("res://text_metrics.gd")
|
||||
const ScreenHpBar = preload("res://screen_hp_bar.gd")
|
||||
|
||||
signal entity_added(node: Node3D, vid: int)
|
||||
signal entity_removed(vid: int)
|
||||
@@ -43,9 +48,25 @@ const FUNC_MOB_SKILL := 4
|
||||
const FUNC_EMOTION := 5
|
||||
const FUNC_SKILL := 0x80
|
||||
|
||||
# EDamageFlag
|
||||
const DAMAGE_DODGE := 1 << 2
|
||||
const DAMAGE_CRITICAL := 1 << 5
|
||||
# EDamageFlag / IsShowDamage / ProcessDamage 分类见 damage_effect.gd(DamageEffect.DAMAGE_*)。
|
||||
|
||||
# §8.8 头顶名字 / 聊天气泡 billboard 的默认局部 y(世界单位)。seam ⑨(name_tail_layout.gd):
|
||||
# 有「聊天」气泡时名字尾标顶到气泡上方 17px(换算按名字 Label3D 的 pixel_size)。
|
||||
const NAME_TAG_DEFAULT_Y := 2.1
|
||||
const BUBBLE_TAG_Y := 2.35
|
||||
|
||||
# §8.8 公会名 / 称号 / 等级子标签的默认局部 y(竖排近似:公会名在名字上、称号 / 等级在下)。
|
||||
# seam ⑦(name_tail_layout.gd):参考端 ArrangeTextTail 把它们锚在名字尾标 pTextTail->y 上,
|
||||
# 名字因聊天气泡上移时三者整体跟随——_reposition_name_tag 按 name_tail_lift_delta 补这段位移。
|
||||
const SUBTAG_DEFAULT_Y := {"GuildTag": 2.34, "TitleTag": 1.86, "LevelTag": 1.74}
|
||||
# seam ⑦ 水平排版(增量 121):ArrangeTextTail 按 GetTextSize 精确像素排称号 / 等级,
|
||||
# poc 无字体度量 -> 按「字符数 * 约 7px」估文本宽(与 ground_items 的 TAG_CHAR_W_PX 同源)。
|
||||
const SUBTAG_CHAR_W_PX := 7.0
|
||||
|
||||
# §8.8:参考端的实体 HP 条属于屏幕尾标,而不是 3D billboard。条的锚点
|
||||
# 保持在原占位条高度附近;实际像素位置由 Camera3D.unproject_position() 计算。
|
||||
const HP_BAR_WORLD_Y := 1.95
|
||||
const HP_BAR_SCREEN_MARGIN := 32.0
|
||||
|
||||
# CLIENT-GAP §2.1 two-layer model. The data layer (M2Client / EntityStore) always
|
||||
# holds every known entity; a scene node under `parent` exists only while the
|
||||
@@ -88,11 +109,22 @@ var _fading := {} # vid:int -> true while a culled node twee
|
||||
# §8.4 / §8.6 头顶名字「是否显示」(name_show.gd)。game.py::__IsShowName() 的两个来源:
|
||||
# _always_show_name —— uigameoption「总显示名字」radio(CPythonSystem::IsAlwaysShowName)
|
||||
# _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag)
|
||||
# 另加 forced 路径:_target_vid(targetBoard.GetTargetVID())+ chat 尾标 bNameFlag。
|
||||
# 另加 forced 路径:_target_vid(targetBoard.GetTargetVID())+ chat 尾标 bNameFlag +
|
||||
# _hover_vid(game.py OnRender chr.Pick() -> ShowCharacterTextTail,见 pick_show.gd)。
|
||||
var _always_show_name := NameShow.ALWAYS_SHOW_NAME_DEFAULT
|
||||
var _alt_held := false
|
||||
var _target_vid := 0
|
||||
var _hover_vid := 0 # player_controller._update_hover_cursor 射线拾到的角色 vid(0 = 无)
|
||||
# §8.8 / §5.4 伤害飘字(damage_effect.gd)。CPythonSystem::IsShowDamage 门(uigameoption
|
||||
# 「伤害数字」radio)+ CInstanceBase::m_DamageQueue(每帧 pop 一条)+ ProcessDamage 分类。
|
||||
var _show_damage := DamageEffect.SHOW_DAMAGE_DEFAULT
|
||||
var _damage_queue: Array = [] # m_DamageQueue:{vid, amount, flag, self, target}
|
||||
var _dmg_self_toggle := false # m_bDamageEffectType(SELFDAMAGE / SELFDAMAGE2 交替)
|
||||
var _fly_mgr: Node # FlyManager(fly_object.gd)—— GC_CREATE_FLY 的真实弹道 + 命中判定。§3.6
|
||||
var _hp_layer: CanvasLayer # 屏幕空间尾标层(低于 HUD / UiManager)
|
||||
var _hp_overlay: Control
|
||||
var _hp_bars := {} # vid:int -> ScreenHpBar
|
||||
var _camera: Camera3D
|
||||
|
||||
# Fishing surface provider bridge. NetWorld owns entity presentation while
|
||||
# Metin2World owns map data; keep the ClientVS22 GetFishingRot query on the
|
||||
@@ -140,12 +172,28 @@ func setup(m2client: Node, mount: Node3D) -> void:
|
||||
client.pvp_changed.connect(_on_pvp_changed)
|
||||
if client.has_signal("duel_changed"):
|
||||
client.duel_changed.connect(_on_duel_changed)
|
||||
# Guild marks are downloaded by AppFlow on the side connection. The mark
|
||||
# store is independent from the entity stream, so a character can already
|
||||
# be visible when its 16x12 image arrives; refresh the affected text tails
|
||||
# on both the initial completion and an incremental GC_MARK_UPDATE.
|
||||
if client.has_signal("guild_marks_ready"):
|
||||
client.guild_marks_ready.connect(func(_count: int): _retint_all_text_tails())
|
||||
if client.has_signal("guild_mark_updated"):
|
||||
client.guild_mark_updated.connect(func(guild_id: int, _img_idx: int): _refresh_guild_mark(guild_id))
|
||||
# §3.6 飞行物实体:GC_CREATE_FLY 之后由 FlyManager 跑真实弹道 + 线段命中判定。
|
||||
_fly_mgr = FlyObject.new()
|
||||
_fly_mgr.name = "FlyManager"
|
||||
add_child(_fly_mgr)
|
||||
_fly_mgr.setup(parent, world)
|
||||
_fly_mgr.exploded.connect(func(wp: Vector3, cause: String): fly_exploded.emit(wp, cause))
|
||||
_ensure_hp_overlay()
|
||||
|
||||
# GameScene normally calls this after creating the orbit camera. Keeping the
|
||||
# setter explicit makes camera hand-off / map reload deterministic and leaves
|
||||
# headless tests independent of the active viewport camera.
|
||||
func set_camera(camera: Camera3D) -> void:
|
||||
_camera = camera
|
||||
_update_hp_bars()
|
||||
|
||||
# 已在局内(重连 / setup 是协程,进来时 spawn burst 已被 pump 抽干)——
|
||||
# 把当前所有实体补建一遍,别漏掉进游戏那一批怪 / NPC / 玩家。
|
||||
@@ -173,6 +221,7 @@ func set_local_vid(vid: int) -> void:
|
||||
if n:
|
||||
_by_vid.erase(vid)
|
||||
n.queue_free()
|
||||
_remove_hp_bar(vid)
|
||||
|
||||
func set_local_node(node: Node3D) -> void:
|
||||
_local_node = node
|
||||
@@ -192,6 +241,10 @@ func set_always_show_name(on: bool) -> void:
|
||||
_always_show_name = on
|
||||
_refresh_all_name_visibility()
|
||||
|
||||
# uigameoption「伤害数字」radio -> CPythonSystem::SetShowDamageFlag(AddDamageEffect 的入队门)。
|
||||
func set_show_damage(on: bool) -> void:
|
||||
_show_damage = on
|
||||
|
||||
# targetBoard.GetTargetVID():目标始终强显名字(game.py OnRender ShowCharacterTextTail(targetVID))。
|
||||
func set_target_vid(vid: int) -> void:
|
||||
if _target_vid == vid:
|
||||
@@ -202,6 +255,18 @@ func set_target_vid(vid: int) -> void:
|
||||
if v != 0 and _by_vid.has(v):
|
||||
_set_name_visibility(_by_vid[v])
|
||||
|
||||
# game.py OnRender:wndMgr.IsPickedWindow 门内 chr.Pick() 命中的角色 -> ShowCharacterTextTail
|
||||
# (强显名字,绕过 __IsShowName())。poc 由 player_controller 的射线悬停拾取喂入。
|
||||
# 见 pick_show.gd(第 27 个 canonical)与增量 113 的 _target_vid 同路数。
|
||||
func set_hover_vid(vid: int) -> void:
|
||||
if _hover_vid == vid:
|
||||
return
|
||||
var prev := _hover_vid
|
||||
_hover_vid = vid
|
||||
for v in [prev, vid]:
|
||||
if v != 0 and _by_vid.has(v):
|
||||
_set_name_visibility(_by_vid[v])
|
||||
|
||||
func _refresh_all_name_visibility() -> void:
|
||||
for node in _by_vid.values():
|
||||
_set_name_visibility(node)
|
||||
@@ -215,7 +280,7 @@ func _main_planar_cm() -> Vector2:
|
||||
return Vector2(mc.x, mc.y)
|
||||
return Vector2(INF, INF)
|
||||
|
||||
# name_show.name_visible_for 的 net_world 侧组装:forced(target / chat bNameFlag)+
|
||||
# name_show.name_visible_for 的 net_world 侧组装:forced(target / hover / chat bNameFlag)+
|
||||
# __IsShowName()(_always_show_name or _alt_held)+ 3500 平面距 + isShow/wall/CanPick 资格。
|
||||
func _name_shown(root: Node3D) -> bool:
|
||||
if root == null or not is_instance_valid(root):
|
||||
@@ -228,7 +293,9 @@ func _name_shown(root: Node3D) -> bool:
|
||||
var is_wall := _is_wall(int(e.get("race", 0)))
|
||||
# CanPickInstance() 更细(隐身 / 特定 affect 亦不可拾取)—— 这里只按死透近似,记 seam ⑩。
|
||||
var alive := not dead
|
||||
var forced := vid != 0 and vid == _target_vid
|
||||
# game.py OnRender:targetBoard.GetTargetVID()(增量 113)+ chr.Pick() 悬停命中
|
||||
# (_hover_vid,增量 116 / pick_show.gd)都走 ShowCharacterTextTail 强显。
|
||||
var forced := vid != 0 and (vid == _target_vid or vid == _hover_vid)
|
||||
var ct: Variant = _chat_tails.get(vid, null)
|
||||
if ct is Dictionary and bool(ct.get("name_flag", false)):
|
||||
forced = true
|
||||
@@ -253,6 +320,10 @@ func _set_name_visibility(root: Node3D) -> void:
|
||||
var st := root.get_node_or_null(sub) as Label3D
|
||||
if st:
|
||||
st.visible = shown and st.text != ""
|
||||
var mark := root.get_node_or_null("GuildMark") as Sprite3D
|
||||
if mark:
|
||||
var guild_tag := root.get_node_or_null("GuildTag") as Label3D
|
||||
mark.visible = shown and guild_tag != null and guild_tag.text != ""
|
||||
|
||||
# ClientVS22 destroys the network actor manager when Loading starts. This is
|
||||
# called from M2Client.world_reset before the replacement connection sends its
|
||||
@@ -264,7 +335,9 @@ func clear_for_map_change() -> void:
|
||||
node.queue_free()
|
||||
entity_removed.emit(int(vid))
|
||||
_by_vid.clear()
|
||||
_clear_hp_bars()
|
||||
_fading.clear()
|
||||
_damage_queue.clear()
|
||||
_main_vid = 0
|
||||
_pvp_relations.clear()
|
||||
_duel_opponents.clear()
|
||||
@@ -325,6 +398,7 @@ func _on_spawn(d: Dictionary) -> void:
|
||||
var ch_type := int(d.get("ch_type", 0))
|
||||
node.set_meta("cursor_shape", "TALK" if ch_type in [1, 4] else "ATTACK")
|
||||
_by_vid[vid] = node
|
||||
_sync_hp_bar(vid, d)
|
||||
_set_name_visibility(node)
|
||||
_refresh_shop_sign(node, str(d.get("shop_sign", "")))
|
||||
_refresh_pvp_tag(vid)
|
||||
@@ -340,6 +414,7 @@ func _on_info(vid: int, d: Dictionary) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null:
|
||||
return
|
||||
_sync_hp_bar(vid, d)
|
||||
var nm := _display_name(d)
|
||||
if nm != "":
|
||||
if n.has_node("Label3D"):
|
||||
@@ -628,6 +703,90 @@ func _apply_text_tail_to(n: Node3D, e: Dictionary) -> void:
|
||||
ltag.modulate = lv["color"]
|
||||
ltag.visible = shown and ltag.text != ""
|
||||
|
||||
_apply_guild_mark(n, guild_id, gtag, shown)
|
||||
_layout_subtags_x(n, ttag, ltag)
|
||||
# Keep the mark on the same adjusted screen row as the name immediately on
|
||||
# spawn/info refresh, not only after the next chat-tail update.
|
||||
_reposition_name_tag(n)
|
||||
|
||||
# ArrangeTextTail 角色块(PythonTextTail.cpp:211-320 非 EUROPE 分支)的逐像素水平排布:
|
||||
# 称号在名字锚点左侧 nameW/2 + 8、等级再往左一个称号宽(无称号时 nameW/2 + 4)、
|
||||
# 名字本身右移 fxAdd(8 / 4 / 0)。公会名的水平锚点参考端只在「有公会徽记」时才改写,
|
||||
# poc 无徽记 -> GuildTag 留 x = 0(名字中线正上方)。文本宽度取各 Label3D 的真实字体度量
|
||||
# (TextMetrics.label_width = GetTextSize 1:1,增量 123;SUBTAG_CHAR_W_PX 仅作无字体兜底)。
|
||||
# 经 name_tail_layout.gd 纯静态镜像。
|
||||
func _layout_subtags_x(n: Node3D, ttag: Label3D, ltag: Label3D) -> void:
|
||||
var nlbl := n.get_node_or_null("Label3D") as Label3D
|
||||
var gtag := n.get_node_or_null("GuildTag") as Label3D
|
||||
var mark := n.get_node_or_null("GuildMark") as Sprite3D
|
||||
var has_title := ttag != null and ttag.text != ""
|
||||
var has_level := ltag != null and ltag.text != ""
|
||||
var name_w := 0.0
|
||||
if nlbl:
|
||||
name_w = maxf(1.0, TextMetrics.label_width(nlbl, SUBTAG_CHAR_W_PX))
|
||||
var title_w := 0.0
|
||||
if has_title:
|
||||
title_w = maxf(1.0, TextMetrics.label_width(ttag, SUBTAG_CHAR_W_PX))
|
||||
if nlbl:
|
||||
nlbl.position.x = NameTailLayout.subtag_world_x(
|
||||
NameTailLayout.name_x_shift(has_title, has_level), nlbl.pixel_size)
|
||||
if ttag:
|
||||
ttag.position.x = NameTailLayout.subtag_world_x(
|
||||
NameTailLayout.title_x_offset(name_w), ttag.pixel_size) if has_title else 0.0
|
||||
if ltag:
|
||||
ltag.position.x = NameTailLayout.subtag_world_x(
|
||||
NameTailLayout.level_x_offset(name_w, title_w, has_title), ltag.pixel_size) if has_level else 0.0
|
||||
if gtag:
|
||||
if mark != null and mark.texture != null and gtag.text != "":
|
||||
var guild_w := maxf(1.0, TextMetrics.label_width(gtag, SUBTAG_CHAR_W_PX))
|
||||
var mark_w := maxf(1.0, float(mark.texture.get_width()))
|
||||
gtag.position.x = NameTailLayout.subtag_world_x(
|
||||
NameTailLayout.guild_name_x_offset(mark_w), gtag.pixel_size)
|
||||
mark.position.x = NameTailLayout.subtag_world_x(
|
||||
NameTailLayout.mark_x_offset(guild_w, mark_w), mark.pixel_size)
|
||||
else:
|
||||
gtag.position.x = 0.0 # 无已下载会徽时保持参考端 if(pMark && pGuild) 不成立
|
||||
if mark:
|
||||
mark.position.x = 0.0
|
||||
|
||||
func _apply_guild_mark(n: Node3D, guild_id: int, guild_tag: Label3D, shown: bool) -> void:
|
||||
# CPythonTextTail only creates pMarkInstance when a guild id resolves to a
|
||||
# downloaded mark. Do not leave a stale image after a guild change or a
|
||||
# mark-table refresh that no longer contains this guild.
|
||||
var mark := n.get_node_or_null("GuildMark") as Sprite3D
|
||||
if guild_id == 0 or guild_tag == null or guild_tag.text == "" \
|
||||
or client == null or not client.has_method("get_guild_mark_image"):
|
||||
if mark:
|
||||
mark.queue_free()
|
||||
return
|
||||
var img: Image = client.get_guild_mark_image(guild_id)
|
||||
if img == null or img.get_width() <= 0 or img.get_height() <= 0:
|
||||
if mark:
|
||||
mark.queue_free()
|
||||
return
|
||||
if mark == null:
|
||||
mark = Sprite3D.new()
|
||||
mark.name = "GuildMark"
|
||||
mark.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
mark.no_depth_test = true
|
||||
mark.pixel_size = 0.005
|
||||
mark.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
|
||||
n.add_child(mark)
|
||||
mark.texture = ImageTexture.create_from_image(img)
|
||||
mark.visible = shown
|
||||
mark.set_meta("guild_id", guild_id)
|
||||
|
||||
func _refresh_guild_mark(guild_id: int) -> void:
|
||||
if guild_id == 0 or client == null or not client.has_method("get_entities"):
|
||||
return
|
||||
for vid in _by_vid.keys():
|
||||
var n: Node3D = _by_vid[vid]
|
||||
if not is_instance_valid(n):
|
||||
continue
|
||||
var e: Dictionary = client.get_entity(int(vid))
|
||||
if int(e.get("guild", 0)) == guild_id:
|
||||
_apply_text_tail_to(n, e)
|
||||
|
||||
func _apply_text_tail(vid: int) -> void:
|
||||
if vid == 0 or client == null or not client.has_method("get_entity"):
|
||||
return
|
||||
@@ -660,6 +819,7 @@ func _on_despawn(vid: int) -> void:
|
||||
_duel_opponents.erase(vid)
|
||||
_fading.erase(vid)
|
||||
_chat_tails.erase(vid)
|
||||
_remove_hp_bar(vid)
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n:
|
||||
_by_vid.erase(vid)
|
||||
@@ -822,7 +982,7 @@ func _apply_chat_tail(n: Node3D, tail: Dictionary) -> void:
|
||||
if lbl == null:
|
||||
lbl = Label3D.new()
|
||||
lbl.name = "bubble"
|
||||
lbl.position.y = 2.35
|
||||
lbl.position.y = BUBBLE_TAG_Y
|
||||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
lbl.no_depth_test = true
|
||||
lbl.pixel_size = 0.0055
|
||||
@@ -832,6 +992,8 @@ func _apply_chat_tail(n: Node3D, tail: Dictionary) -> void:
|
||||
lbl.modulate = tail.get("color", ChatTail.CHAT_COLOR)
|
||||
# UpdateAllTextTail:chat 尾标 bNameFlag TRUE 时强制显示该角色名字(不受 3500 限制)。
|
||||
_set_name_visibility(n)
|
||||
# UpdateShowingTextTail :110-114:有「聊天」气泡(bNameFlag)时名字尾标顶到气泡上方 17px。
|
||||
_reposition_name_tag(n)
|
||||
|
||||
func _expire_chat_tails() -> void:
|
||||
# UpdateAllTextTail:LivingTime < now 即删除对应 "bubble" 节点
|
||||
@@ -843,6 +1005,40 @@ func _expire_chat_tails() -> void:
|
||||
lbl.queue_free()
|
||||
# 尾标没了 -> bNameFlag 强显撤销,名字回到 __IsShowName / 距离裁决。
|
||||
_set_name_visibility(n)
|
||||
# 气泡没了 -> 名字尾标从「贴气泡上方」回默认局部 y(chat 尾标不在
|
||||
# m_ChatTailMap 里,UpdateShowingTextTail :110 的 find 落空)。
|
||||
_reposition_name_tag(n)
|
||||
|
||||
# CPythonTextTail::UpdateShowingTextTail :101-118 —— 角色名字尾标投影后,若该 VID 有一条
|
||||
# 「聊天」气泡(m_ChatTailMap 命中且 bNameFlag TRUE,即 RegisterChatTail 而非
|
||||
# RegisterInfoTail),把名字尾标顶到气泡上方 17px(屏幕 y - 17 → poc 世界 y + 17*pixel_size)。
|
||||
# 经 name_tail_layout.gd 纯静态镜像。
|
||||
func _reposition_name_tag(n: Node3D) -> void:
|
||||
if n == null or not is_instance_valid(n):
|
||||
return
|
||||
var tag := n.get_node_or_null("Label3D") as Label3D
|
||||
if tag == null:
|
||||
return
|
||||
var vid := int(n.get_meta("vid", 0))
|
||||
var ct: Variant = _chat_tails.get(vid, null)
|
||||
var has_tail := ct is Dictionary
|
||||
var name_flag := has_tail and bool((ct as Dictionary).get("name_flag", false))
|
||||
var follows := NameTailLayout.name_follows_bubble(has_tail, name_flag)
|
||||
tag.position.y = NameTailLayout.name_tail_world_y(
|
||||
NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size)
|
||||
# ArrangeTextTail :228/:258/:283 —— 公会名 / 称号 / 等级子标签都锚在名字尾标 pTextTail->y
|
||||
# 上,名字因聊天气泡上移时三者整体跟随同一段位移(seam ⑦ 仅剩竖排像素排布近似)。
|
||||
var lift := NameTailLayout.name_tail_lift_delta(
|
||||
NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size)
|
||||
for sub_name in SUBTAG_DEFAULT_Y:
|
||||
var st := n.get_node_or_null(sub_name) as Label3D
|
||||
if st:
|
||||
st.position.y = NameTailLayout.subtag_world_y(SUBTAG_DEFAULT_Y[sub_name], lift)
|
||||
var mark := n.get_node_or_null("GuildMark") as Sprite3D
|
||||
if mark:
|
||||
# ArrangeTextTail :227 puts the mark 26 px above the name instance; it
|
||||
# follows the same already-adjusted name y when a chat tail is active.
|
||||
mark.position.y = NAME_TAG_DEFAULT_Y + NameTailLayout.MARK_ABOVE_NAME_PX * tag.pixel_size + lift
|
||||
|
||||
func _on_vitals(vid: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
@@ -853,10 +1049,7 @@ func _on_vitals(vid: int) -> void:
|
||||
var max_hp := int(e.get("max_hp", 0))
|
||||
var dead := bool(e.get("dead", false))
|
||||
vitals.emit(vid, hp, max_hp, dead)
|
||||
if n and n.has_node("HpBar") and max_hp > 0:
|
||||
var bar := n.get_node("HpBar") as Sprite3D
|
||||
bar.scale.x = clampf(float(hp) / float(max_hp), 0.0, 1.0)
|
||||
bar.modulate = Color(0.9, 0.2, 0.2) if not dead else Color(0.3, 0.3, 0.3)
|
||||
_sync_hp_bar_values(vid, hp, max_hp, dead)
|
||||
|
||||
func _on_dead(vid: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
@@ -984,22 +1177,54 @@ func _on_fishing_event(subheader: int, info: int, dir: int) -> void:
|
||||
n.call("set_anim_state", state)
|
||||
fishing_event.emit(info, subheader, dir)
|
||||
|
||||
# GC_DAMAGE_INFO -> RecvDamageInfoPacket -> CInstanceBase::AddDamageEffect。
|
||||
# damage_number 信号是本移植扩展(战斗日志 / 测试消费),始终发;
|
||||
# 飘字本体照 AddDamageEffect:仅 IsShowDamage() 时入 m_DamageQueue,ProcessDamage 每帧 pop 一条。
|
||||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
var crit := (flag & DAMAGE_CRITICAL) != 0
|
||||
var dodge := (flag & DAMAGE_DODGE) != 0
|
||||
var crit := (flag & DamageEffect.DAMAGE_CRITICAL) != 0
|
||||
var dodge := (flag & DamageEffect.DAMAGE_DODGE) != 0
|
||||
damage_number.emit(vid, amount, crit, dodge)
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null or parent == null:
|
||||
if not DamageEffect.valid_amount(amount):
|
||||
return # RecvDamageInfoPacket:damage < 0 -> TraceError,不处理
|
||||
if not DamageEffect.should_queue(_show_damage):
|
||||
return # AddDamageEffect:!IsShowDamage() -> 不入队
|
||||
_damage_queue.append({
|
||||
"vid": vid, "amount": amount, "flag": flag,
|
||||
"self": DamageEffect.is_self(vid, _main_vid),
|
||||
"target": DamageEffect.is_target(vid, _target_vid),
|
||||
})
|
||||
|
||||
# ProcessDamage:每帧 pop_front 一条。DODGE|BLOCK -> "MISS";nontarget 数字 -> 不渲染;
|
||||
# 其余把 amount 铺一块 billboard Label3D(seam ⑪:无 damagevalue dds 图集 / 逐位特效)。
|
||||
func _process_damage_queue() -> void:
|
||||
if _damage_queue.is_empty():
|
||||
return
|
||||
var d: Dictionary = _damage_queue.pop_front()
|
||||
var n: Node3D = _by_vid.get(int(d["vid"]), null)
|
||||
if n == null or not is_instance_valid(n) or parent == null:
|
||||
return
|
||||
var info := DamageEffect.classify(int(d["flag"]), bool(d["self"]), bool(d["target"]))
|
||||
if not info["show"]:
|
||||
return # ProcessDamage:nontarget_ 后直接 return(不渲染)
|
||||
var text := "MISS"
|
||||
if info["kind"] == DamageEffect.Kind.DIGITS:
|
||||
if DamageEffect.digits(int(d["amount"])).is_empty():
|
||||
return # while(damage>0) 零次 -> 无贴图
|
||||
text = str(int(d["amount"]))
|
||||
# self 数字:SELFDAMAGE / SELFDAMAGE2 交替(seam ⑪:退化成落字水平抖动符号交替)。
|
||||
var jitter := 0.0
|
||||
if bool(d["self"]) and info["kind"] == DamageEffect.Kind.DIGITS:
|
||||
jitter = 0.3 if DamageEffect.self_damage_slot(_dmg_self_toggle) == 0 else -0.3
|
||||
_dmg_self_toggle = not _dmg_self_toggle
|
||||
var lbl := Label3D.new()
|
||||
lbl.text = "MISS" if dodge else str(amount)
|
||||
lbl.modulate = Color(1.0, 0.85, 0.2) if crit else Color(1, 1, 1)
|
||||
lbl.font_size = 48 if crit else 32
|
||||
lbl.text = text
|
||||
lbl.modulate = Color(1, 1, 1) # 该 build 无独立暴击 / self 颜色(ProcessDamage)
|
||||
lbl.font_size = 32
|
||||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
lbl.no_depth_test = true
|
||||
lbl.pixel_size = 0.007
|
||||
parent.add_child(lbl)
|
||||
lbl.global_position = n.global_position + Vector3(randf_range(-0.3, 0.3), 1.9, 0)
|
||||
lbl.global_position = n.global_position + Vector3(jitter, 1.9, 0)
|
||||
var tw := create_tween()
|
||||
tw.set_parallel(true)
|
||||
tw.tween_property(lbl, "global_position:y", lbl.global_position.y + 1.2, 0.8)
|
||||
@@ -1010,6 +1235,7 @@ func _process(dt: float) -> void:
|
||||
if client == null or parent == null:
|
||||
return
|
||||
_update_visibility()
|
||||
_process_damage_queue() # §8.8:m_DamageQueue 每帧 pop 一条(CInstanceBase::ProcessDamage)
|
||||
# §8.8:名字色随队伍 / 帝国名色模式 / PVP·决斗边沿变化——用 ~2Hz 节流整表刷新
|
||||
# 兜底(单发路径已覆盖 spawn/info/pvp/duel/main_set;参考端每帧都算 GetNameColor)。
|
||||
_name_color_accum += dt
|
||||
@@ -1047,6 +1273,7 @@ func _process(dt: float) -> void:
|
||||
n.set_meta("moving", moving)
|
||||
n.set_meta("walk_mode", walk_mode)
|
||||
_apply_anim(n, f, moving, walk_mode)
|
||||
_update_hp_bars()
|
||||
|
||||
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void:
|
||||
var state := "wait"
|
||||
@@ -1086,13 +1313,14 @@ func _make_placeholder(d: Dictionary) -> Node3D:
|
||||
_attach_nameplate(root, d)
|
||||
return root
|
||||
|
||||
# 头顶名字 Label3D + HP 条 Sprite3D(占位胶囊和真模型共用)。
|
||||
# 头顶名字 Label3D + 屏幕空间 HP 条(占位胶囊和真模型共用)。HP 条由
|
||||
# _ensure_hp_overlay() 创建到 CanvasLayer,_update_hp_bars() 负责世界坐标投影。
|
||||
func _attach_nameplate(root: Node3D, d: Dictionary) -> void:
|
||||
var tag := Label3D.new()
|
||||
tag.name = "Label3D"
|
||||
var nm := _display_name(d)
|
||||
tag.text = nm if nm != "" else "vid %d" % int(d.get("vid", 0))
|
||||
tag.position.y = 2.1
|
||||
tag.position.y = NAME_TAG_DEFAULT_Y
|
||||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
tag.no_depth_test = true
|
||||
tag.pixel_size = 0.006
|
||||
@@ -1103,19 +1331,9 @@ func _attach_nameplate(root: Node3D, d: Dictionary) -> void:
|
||||
# c_fyGuildNamePosition(15px)、称号 / 等级贴右侧。这里在 billboard 3D 里近似成竖排
|
||||
# (公会名上、称号 / 等级下),精确像素排版差异记为 §8.8 seam ⑦。内容 / 颜色由
|
||||
# _apply_text_tail 按 RefreshTextTail / AttachTitle / AttachLevel 决定,初始空 = 隐藏。
|
||||
root.add_child(_mk_sub_tag("GuildTag", 2.34, TextTail.GUILD_NAME_COLOR))
|
||||
root.add_child(_mk_sub_tag("TitleTag", 1.86, Color(1, 1, 1)))
|
||||
root.add_child(_mk_sub_tag("LevelTag", 1.74, TextTail.LEVEL_COLOR))
|
||||
var bar := Sprite3D.new()
|
||||
bar.name = "HpBar"
|
||||
bar.texture = _white_1px()
|
||||
bar.pixel_size = 0.01 # 100px -> 1m
|
||||
bar.position.y = 1.95
|
||||
bar.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
bar.no_depth_test = true
|
||||
bar.modulate = Color(0.9, 0.2, 0.2)
|
||||
bar.scale.x = 1.0
|
||||
root.add_child(bar)
|
||||
root.add_child(_mk_sub_tag("GuildTag", SUBTAG_DEFAULT_Y["GuildTag"], TextTail.GUILD_NAME_COLOR))
|
||||
root.add_child(_mk_sub_tag("TitleTag", SUBTAG_DEFAULT_Y["TitleTag"], Color(1, 1, 1)))
|
||||
root.add_child(_mk_sub_tag("LevelTag", SUBTAG_DEFAULT_Y["LevelTag"], TextTail.LEVEL_COLOR))
|
||||
|
||||
func _mk_sub_tag(sub_name: String, y: float, col: Color) -> Label3D:
|
||||
var t := Label3D.new()
|
||||
@@ -1129,10 +1347,93 @@ func _mk_sub_tag(sub_name: String, y: float, col: Color) -> Label3D:
|
||||
t.visible = false
|
||||
return t
|
||||
|
||||
static var _white_tex: Texture2D
|
||||
static func _white_1px() -> Texture2D:
|
||||
if _white_tex == null:
|
||||
var img := Image.create(100, 6, false, Image.FORMAT_RGBA8)
|
||||
img.fill(Color.WHITE)
|
||||
_white_tex = ImageTexture.create_from_image(img)
|
||||
return _white_tex
|
||||
func _ensure_hp_overlay() -> void:
|
||||
if _hp_layer != null and is_instance_valid(_hp_layer):
|
||||
return
|
||||
_hp_layer = CanvasLayer.new()
|
||||
_hp_layer.name = "ScreenHpLayer"
|
||||
_hp_layer.layer = 5
|
||||
add_child(_hp_layer)
|
||||
_hp_overlay = Control.new()
|
||||
_hp_overlay.name = "HpOverlay"
|
||||
_hp_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_hp_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_hp_layer.add_child(_hp_overlay)
|
||||
|
||||
func _make_hp_bar(vid: int, e: Dictionary) -> Control:
|
||||
_ensure_hp_overlay()
|
||||
var bar := _hp_bars.get(vid, null) as Control
|
||||
if bar == null or not is_instance_valid(bar):
|
||||
bar = ScreenHpBar.new()
|
||||
bar.name = "hp_%d" % vid
|
||||
_hp_overlay.add_child(bar)
|
||||
_hp_bars[vid] = bar
|
||||
_sync_hp_bar_values(vid, int(e.get("hp", 0)), int(e.get("max_hp", 0)),
|
||||
bool(e.get("dead", false)))
|
||||
return bar
|
||||
|
||||
func _sync_hp_bar(vid: int, e: Dictionary) -> void:
|
||||
if vid == 0 or e.is_empty():
|
||||
return
|
||||
_make_hp_bar(vid, e)
|
||||
_sync_hp_bar_values(vid, int(e.get("hp", 0)), int(e.get("max_hp", 0)),
|
||||
bool(e.get("dead", false)))
|
||||
|
||||
func _sync_hp_bar_values(vid: int, hp: int, max_hp: int, dead: bool) -> void:
|
||||
var bar := _hp_bars.get(vid, null) as Control
|
||||
if bar == null or not is_instance_valid(bar):
|
||||
return
|
||||
if bar.has_method("set_vitals"):
|
||||
bar.call("set_vitals", hp, max_hp, dead)
|
||||
bar.visible = max_hp > 0 and not dead
|
||||
|
||||
func _remove_hp_bar(vid: int) -> void:
|
||||
var bar := _hp_bars.get(vid, null) as Control
|
||||
_hp_bars.erase(vid)
|
||||
if bar != null and is_instance_valid(bar):
|
||||
bar.queue_free()
|
||||
|
||||
func _clear_hp_bars() -> void:
|
||||
for vid in _hp_bars.keys():
|
||||
var bar := _hp_bars[vid] as Control
|
||||
if bar != null and is_instance_valid(bar):
|
||||
bar.queue_free()
|
||||
_hp_bars.clear()
|
||||
|
||||
func _update_hp_bars() -> void:
|
||||
if _hp_bars.is_empty():
|
||||
return
|
||||
var cam := _camera
|
||||
if cam == null or not is_instance_valid(cam):
|
||||
cam = get_viewport().get_camera_3d()
|
||||
var viewport_size := get_viewport().get_visible_rect().size
|
||||
if cam == null or viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
|
||||
for bar in _hp_bars.values():
|
||||
if is_instance_valid(bar):
|
||||
bar.visible = false
|
||||
return
|
||||
var viewport_rect := Rect2(Vector2.ZERO, viewport_size).grow(HP_BAR_SCREEN_MARGIN)
|
||||
for vid in _hp_bars.keys():
|
||||
var bar := _hp_bars[vid] as Control
|
||||
var node := _by_vid.get(vid, null) as Node3D
|
||||
if bar == null or not is_instance_valid(bar) or node == null \
|
||||
or not is_instance_valid(node):
|
||||
if bar != null and is_instance_valid(bar):
|
||||
bar.visible = false
|
||||
continue
|
||||
var e: Dictionary = client.get_entity(int(vid))
|
||||
var max_hp := int(e.get("max_hp", 0))
|
||||
var dead := bool(e.get("dead", false))
|
||||
if max_hp <= 0 or dead:
|
||||
bar.visible = false
|
||||
continue
|
||||
var anchor := node.global_position + Vector3(0.0, HP_BAR_WORLD_Y, 0.0)
|
||||
if cam.is_position_behind(anchor):
|
||||
bar.visible = false
|
||||
continue
|
||||
var screen_pos := cam.unproject_position(anchor)
|
||||
if not viewport_rect.has_point(screen_pos):
|
||||
bar.visible = false
|
||||
continue
|
||||
bar.position = screen_pos - Vector2(bar.size.x * 0.5, 0.0)
|
||||
bar.visible = true
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -6,6 +6,7 @@ extends SceneTree
|
||||
|
||||
const NetPlay = preload("res://net_play.gd")
|
||||
const NetWorld = preload("res://net_world.gd")
|
||||
const EntityRules = preload("res://entity_rules.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal entity_spawned(entity: Dictionary)
|
||||
@@ -176,6 +177,11 @@ func _run() -> void:
|
||||
fc.ents[3000] = {"vid": 3000, "ch_type": 1, "name": "Cube NPC", "pos": npc.position}
|
||||
pc.target_selected.emit(npc)
|
||||
_ck(fc.calls.click_npc == [3000], "NPC selection -> click_npc(3000)")
|
||||
# §5.4 / §8.8:木门不应退化为 PC;它可进入 CanViewTargetHP / 攻击分类。
|
||||
_ck(np._entity_kind({"vid": 3001, "race": 13000}) == EntityRules.KIND_WOODEN_DOOR,
|
||||
"race 13000 -> WOODEN_DOOR")
|
||||
_ck(np._entity_kind({"vid": 3002, "kind": EntityRules.KIND_BUILDING}) == EntityRules.KIND_BUILDING,
|
||||
"explicit BUILDING kind -> BUILDING")
|
||||
|
||||
# 5) 只有按住 SPACE(参考端 m_isAtkKey)才在攻击距离内 attack
|
||||
np.set_attack_key(true)
|
||||
@@ -381,6 +387,76 @@ func _run() -> void:
|
||||
fc.target_info.emit(2000, 40)
|
||||
_ck(hud.target.size() == 2 and hud.target[1] == 40, "hud.set_target(pct=40)")
|
||||
|
||||
# 8b) §8.4「看他国玩家目标框」前置过滤(uitarget.py:189-193 / target_board.hidden_as_other_empire)
|
||||
fc.ents[1000]["empire"] = 1
|
||||
fc.ents[2500] = {"vid": 2500, "ch_type": 0, "name": "Enemy", "empire": 2,
|
||||
"pos": Vector3(5, 0, 5), "hp": 100, "max_hp": 100, "dead": false}
|
||||
# 开关开(默认 true):异阵营 PC 目标框照旧走 CLOSE_IF_DIFFERENT,框上正是该 VID -> 保留
|
||||
np._target_vid = 2500
|
||||
fc.target_info.emit(2500, 60)
|
||||
_ck(np._target_vid == 2500, "看他国框开 -> 异阵营 PC 目标框保留")
|
||||
# 开关关:异阵营 PC -> 前置过滤直接清目标框
|
||||
var cleared_n: int = hud.cleared
|
||||
np.set_view_other_empire_target(false)
|
||||
np._target_vid = 2500
|
||||
fc.target_info.emit(2500, 60)
|
||||
_ck(np._target_vid == 0 and hud.cleared == cleared_n + 1, "看他国框关 -> 异阵营 PC 目标框被前置过滤清掉")
|
||||
# 开关关不影响怪(empire 0 -> IsSameEmpire 为真)
|
||||
np._target_vid = 0
|
||||
fc.target_info.emit(2000, 30)
|
||||
_ck(hud.target[1] == 30 and np._target_vid == 2000, "看他国框关不影响怪目标(HP 血条照设)")
|
||||
np.set_view_other_empire_target(true)
|
||||
fc.ents[1000].erase("empire")
|
||||
fc.ents.erase(2500)
|
||||
# 收尾:_target_vid 落在 2000(与原 8 一致),供 9) 目标死亡清框
|
||||
|
||||
# 8c) seam ⑫ 收口 —— game.py SetPCTargetBoard :804:targetBoard.Open 之后,若按住
|
||||
# LCONTROL(:807)且同阵营(:809 IsSameEmpire)且非本人(:812)且非 building
|
||||
# (:814)-> interface.OpenWhisperDialog(name)。poc 侧 net_play 发 whisper_requested,
|
||||
# game_scene 接到 chat.start_whisper()。C++ 只对 PC 分派 SetPCTargetBoard。
|
||||
var whispers: Array = []
|
||||
np.whisper_requested.connect(func(nm: String): whispers.append(nm))
|
||||
fc.ents[1000]["empire"] = 1
|
||||
var bob := Node3D.new()
|
||||
bob.set_meta("vid", 6100)
|
||||
bob.position = Vector3(4, 0, -5)
|
||||
root.add_child(bob)
|
||||
nw._by_vid[6100] = bob
|
||||
fc.ents[6100] = {"vid": 6100, "ch_type": 0, "name": "Bob", "empire": 1,
|
||||
"pos": bob.position, "hp": 100, "max_hp": 100, "dead": false}
|
||||
# LCONTROL 没按住 -> 只普通选中,不开悄悄话
|
||||
np._target_vid = 0
|
||||
np.set_lcontrol_down(false)
|
||||
pc.target_selected.emit(bob)
|
||||
_ck(whispers.is_empty(), "无 LCONTROL 点 PC -> 不开悄悄话")
|
||||
_ck(fc.calls.set_target.back() == 6100, "点 PC 仍照常选中(set_target 6100)")
|
||||
# 按住 LCONTROL + 同阵营他人 PC -> whisper_requested("Bob")
|
||||
np.set_lcontrol_down(true)
|
||||
pc.target_selected.emit(bob)
|
||||
_ck(whispers == ["Bob"], "LCONTROL 点同阵营他人 PC -> whisper_requested('Bob')")
|
||||
# 异阵营 PC -> 不请求(:809 IsSameEmpire)
|
||||
whispers.clear()
|
||||
fc.ents[6100]["empire"] = 2
|
||||
pc.target_selected.emit(bob)
|
||||
_ck(whispers.is_empty(), "LCONTROL 点异阵营 PC -> 不开悄悄话")
|
||||
fc.ents[6100]["empire"] = 1
|
||||
# 怪(KIND_MONSTER)即使按住 LCONTROL 也不走(C++ 只对 PC 分派 SetPCTargetBoard)
|
||||
whispers.clear()
|
||||
var wolf2 := Node3D.new()
|
||||
wolf2.set_meta("vid", 6200)
|
||||
wolf2.position = Vector3(4, 0, -5)
|
||||
root.add_child(wolf2)
|
||||
nw._by_vid[6200] = wolf2
|
||||
fc.ents[6200] = {"vid": 6200, "ch_type": 2, "name": "Wolf2", "empire": 0,
|
||||
"pos": wolf2.position, "hp": 50, "max_hp": 50, "dead": false}
|
||||
pc.target_selected.emit(wolf2)
|
||||
_ck(whispers.is_empty(), "LCONTROL 点怪 -> 不开悄悄话(仅 PC 分派)")
|
||||
np.set_lcontrol_down(false)
|
||||
fc.ents[1000].erase("empire")
|
||||
fc.ents.erase(6100); fc.ents.erase(6200)
|
||||
nw._by_vid.erase(6100); nw._by_vid.erase(6200)
|
||||
np._target_vid = 2000 # 复位到 8b 收尾态,供 9) 目标死亡清框
|
||||
|
||||
# 9) 目标死亡 -> 清目标
|
||||
fc.ents[2000]["dead"] = true
|
||||
fc.entity_dead.emit(2000)
|
||||
|
||||
+3
-1
@@ -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
@@ -4,6 +4,7 @@ extends SceneTree
|
||||
|
||||
const GroundItems = preload("res://ui/ground_items.gd")
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
const TextTailArrange = preload("res://text_tail_arrange.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal ground_item_added(item: Dictionary)
|
||||
@@ -12,8 +13,10 @@ class FakeClient extends Node:
|
||||
var picked := []
|
||||
func get_ground_items() -> Array: return ground.values()
|
||||
func pickup_item(vid) -> bool: picked.append(vid); return true
|
||||
func add_ground(vid, vnum, pos):
|
||||
ground[vid] = {"vid": vid, "vnum": vnum, "pos": pos}
|
||||
func add_ground(vid, vnum, pos, owner := "", item_color: Variant = null):
|
||||
ground[vid] = {"vid": vid, "vnum": vnum, "pos": pos, "owner": owner}
|
||||
if item_color != null:
|
||||
ground[vid]["item_color"] = item_color
|
||||
ground_item_added.emit(ground[vid])
|
||||
func del_ground(vid):
|
||||
ground.erase(vid); ground_item_removed.emit(vid)
|
||||
@@ -74,6 +77,92 @@ func _run() -> void:
|
||||
# 远处的捡不到
|
||||
_ck(gi.try_pickup() == 0, "far item not picked")
|
||||
|
||||
# --- §8.8 seam ⑩:多条名条投影后去重叠(ArrangeTextTail item 块)---------------
|
||||
# 独立的 mount / FakeClient,避免与上面的 gi 抢同名 drop 节点。
|
||||
var mount2 := Node3D.new()
|
||||
var fc2 := FakeClient.new()
|
||||
get_root().add_child(mount2)
|
||||
get_root().add_child(fc2)
|
||||
var cam := Camera3D.new()
|
||||
get_root().add_child(cam)
|
||||
cam.position = Vector3(0, 3, 6)
|
||||
cam.look_at(Vector3.ZERO, Vector3.UP)
|
||||
cam.current = true
|
||||
var gi2: Node = GroundItems.new()
|
||||
get_root().add_child(gi2)
|
||||
gi2.setup(fc2, mount2, func() -> Node: return pl, fp, null, func() -> Camera3D: return cam)
|
||||
# 单条:名条留默认局部 y。
|
||||
fc2.add_ground(201, 27, Vector3(0, 0, 0))
|
||||
await process_frame
|
||||
gi2._process(0.0)
|
||||
var t201 := mount2.get_node("drop_201").get_node("tag") as Label3D
|
||||
_ck(absf(t201.position.y - gi2.TAG_BASE_Y) < 0.0001, "单条名条 -> 留默认局部 y 0.75")
|
||||
# 同位两条:靠前的(vid 小,m_ItemTextTailList 先)被顶下去,靠后的留原位。
|
||||
fc2.add_ground(202, 28, Vector3(0, 0, 0))
|
||||
await process_frame
|
||||
gi2._process(0.0)
|
||||
var t202 := mount2.get_node("drop_202").get_node("tag") as Label3D
|
||||
_ck(t201.position.y < gi2.TAG_BASE_Y - 0.0001, "重叠 -> 靠前名条被下推(世界 y 变小)")
|
||||
_ck(absf(t202.position.y - gi2.TAG_BASE_Y) < 0.0001, "重叠 -> 靠后名条留默认")
|
||||
# 被下推量 = (h + gap) px 折世界:(15 + 5) * pixel_size(0.005) = 0.1。
|
||||
_ck(absf(t201.position.y - (gi2.TAG_BASE_Y - (gi2.TAG_LINE_H_PX + TextTailArrange.PUSH_GAP) * t201.pixel_size)) < 0.0001,
|
||||
"下推量 = (TAG_LINE_H_PX + PUSH_GAP) * pixel_size = 0.1")
|
||||
_ck(absf(t201.position.y - t202.position.y) > 0.0001, "两名条错开后局部 y 不同")
|
||||
# 远离:把 202 挪远,重跑 -> 两条都回默认。
|
||||
mount2.get_node("drop_202").position = Vector3(-40, 0, -2)
|
||||
gi2._process(0.0)
|
||||
_ck(absf(t201.position.y - gi2.TAG_BASE_Y) < 0.0001, "拉开距离 -> 201 名条回默认")
|
||||
_ck(absf(t202.position.y - gi2.TAG_BASE_Y) < 0.0001, "拉开距离 -> 202 名条回默认")
|
||||
|
||||
# --- §8.8 seam ⑩ 末尾:owner 拆成独立 CENTER 黄字 Label3D(SetItemTextTailOwner)
|
||||
# + 名字实例下移 15px(ArrangeTextTail :200);框仍 yEnd += ownerH + 4(增量 125)---
|
||||
var mount3 := Node3D.new()
|
||||
var fc3 := FakeClient.new()
|
||||
get_root().add_child(mount3)
|
||||
get_root().add_child(fc3)
|
||||
var gi3: Node = GroundItems.new()
|
||||
get_root().add_child(gi3)
|
||||
gi3.setup(fc3, mount3, func() -> Node: return pl, fp, null, func() -> Camera3D: return cam)
|
||||
fc3.add_ground(301, 27, Vector3(0, 0, 0)) # 靠前、无 owner
|
||||
var owner_color := Color(0.25, 0.75, 1.0)
|
||||
fc3.add_ground(302, 28, Vector3(0, 0, 0), "Alice", owner_color) # 靠后、有 owner
|
||||
await process_frame
|
||||
gi3._process(0.0)
|
||||
var d301 := mount3.get_node("drop_301")
|
||||
var d302 := mount3.get_node("drop_302")
|
||||
var t301 := d301.get_node("tag") as Label3D
|
||||
var t302 := d302.get_node("tag") as Label3D
|
||||
# owner 拆成独立 owner_tag:名字实例只放名字,owner_tag 放 "Alice" + "'s"(黄 / CENTER)。
|
||||
_ck(t302.text == "掉落28", "名字实例只放名字(owner 后缀不再拼串,:723)")
|
||||
_ck(d301.get_node_or_null("owner_tag") == null, "无 owner 的掉落不建 owner_tag(:741-745)")
|
||||
var o302 := d302.get_node_or_null("owner_tag") as Label3D
|
||||
_ck(o302 != null, "有 owner 的掉落建独立 owner_tag(:715)")
|
||||
_ck(o302.text == "Alice's", "owner_tag 文本 = owner + strOwnership(:722 字面 \"'s\")")
|
||||
_ck(o302.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER, "owner_tag CENTER 对齐(:727)")
|
||||
_ck(o302.modulate.is_equal_approx(owner_color), "owner_tag 使用道具品阶色(:729 pInsertTextTail->Color)")
|
||||
# owner 名条框:无字体度量时 name_h = owner_h = 15 -> yEnd = 15 + 15 + 4 = 34(增量 125 不变)。
|
||||
# 靠前 301(无 owner)被顶 (34 + PUSH_GAP) px 折世界:(34 + 5) * 0.005 = 0.195。
|
||||
var push_owner: float = (gi3.TAG_LINE_H_PX * 2.0 + 4.0 + TextTailArrange.PUSH_GAP) * t301.pixel_size
|
||||
_ck(absf(t301.position.y - (gi3.TAG_BASE_Y - push_owner)) < 0.0001,
|
||||
"靠前名条被带 owner 的框顶 (34+5)*px = 0.195")
|
||||
# 靠后 302 未被推:owner 行落基点、名字行下移 15px(:195 owner 在 y、:200 名字在 y+15)。
|
||||
_ck(absf(o302.position.y - gi3.TAG_BASE_Y) < 0.0001, "owner_tag 落尾标基点(:195)")
|
||||
_ck(absf(t302.position.y - (gi3.TAG_BASE_Y - gi3.OWNER_NAME_GAP_PX * t302.pixel_size)) < 0.0001,
|
||||
"名字实例在 owner 行下移 15px(:200 y + 15.0f)")
|
||||
|
||||
# --- strOwnership 经 setup(possessive) 注入(SetItemTextTailOwner :722 本地化语素)---
|
||||
var mount4 := Node3D.new()
|
||||
var fc4 := FakeClient.new()
|
||||
get_root().add_child(mount4)
|
||||
get_root().add_child(fc4)
|
||||
var gi4: Node = GroundItems.new()
|
||||
get_root().add_child(gi4)
|
||||
gi4.setup(fc4, mount4, func() -> Node: return pl, fp, null, func() -> Camera3D: return cam, "@")
|
||||
fc4.add_ground(401, 27, Vector3(0, 0, 0), "Bob")
|
||||
await process_frame
|
||||
var o401 := mount4.get_node("drop_401").get_node_or_null("owner_tag") as Label3D
|
||||
_ck(o401 != null and o401.text == "Bob@", "setup(possessive=\"@\") -> owner_tag 文本 = \"Bob@\"")
|
||||
|
||||
# --- player view(有真资产才建)---
|
||||
var assets := AssetRoot.path()
|
||||
if not DirAccess.dir_exists_absolute(assets.path_join("PC/ymir work/pc/warrior")):
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# pick_show —— §8.8 / §5.4 game.py OnRender 的「Picking」块分派 1:1:
|
||||
# 每帧渲染时,textTail 的强显(ShowCharacterTextTail)/ 全显(ShowAllTextTail)/
|
||||
# 拾取命中(chr.Pick / item.Pick / textTail.Pick)该走哪条,取决于
|
||||
# wndMgr.IsPickedWindow(hWnd)(鼠标是否在 3D game 窗口上)与 __IsShowName()。
|
||||
# 本模块把这棵判定树抽成纯静态谓词,供 net_world 的「悬停强显名字」用
|
||||
# (仿增量 113 的 _target_vid forced 路数,给 _hover_vid 加第 5 路 forced)。
|
||||
# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd /
|
||||
# target_board.gd 同一套纯静态镜像套路,本模块是第 27 个 canonical。)
|
||||
#
|
||||
# REF(40250 ClientVS22 source/ 为唯一基线;运行时 python 见 assets/root/game.py):
|
||||
# root/game.py:1519 GameWindow.OnRender()
|
||||
# root/game.py:1531 textTail.UpdateAllTextTail() —— 每帧先刷全部尾标
|
||||
# root/game.py:1533 if True == wndMgr.IsPickedWindow(self.hWnd) —— 鼠标在 3D 窗口上才拾取
|
||||
# root/game.py:1535 self.PickingCharacterIndex = chr.Pick() —— 射线拾取角色(-1 = 无)
|
||||
# root/game.py:1537 if -1 != PickingCharacterIndex: textTail.ShowCharacterTextTail(idx)
|
||||
# root/game.py:1539 if 0 != targetBoard.GetTargetVID(): textTail.ShowCharacterTextTail(targetVID)
|
||||
# root/game.py:1543 if not self.__IsShowName(): PickingItemIndex = item.Pick() —— 只在「非总显名字」时拾取地面物品
|
||||
# root/game.py:1546 if -1 != PickingItemIndex: textTail.ShowItemTextTail(idx)
|
||||
# root/game.py:1552 if self.__IsShowName(): textTail.ShowAllTextTail() —— 总显名字:全显 + 从尾标里拾取
|
||||
# root/game.py:1554 self.PickingItemIndex = textTail.Pick(x, y)
|
||||
# root/game.py:1557 textTail.UpdateShowingTextTail()
|
||||
# root/game.py:1558 textTail.ArrangeTextTail() —— 屏幕空间去重叠排布
|
||||
# root/game.py:1560 if -1 != PickingItemIndex: textTail.SelectItemName(idx)
|
||||
#
|
||||
# seam ⑨:名字尾标 y = 聊天气泡 y − 17px(PythonTextTail.cpp:112-114)尚未接入;
|
||||
# 本 poc 名字牌是每实体一个 Label3D,暂无「随 chat 气泡下移」的整合。
|
||||
# seam ⑩:ArrangeTextTail 的屏幕空间尾标去重叠排布(PythonTextTail.cpp ArrangeTextTail)
|
||||
# 仍缺——poc 名字牌是 3D billboard,靠深度天然分层,未做 2D 投影后的挤开。
|
||||
# 本增量只收口其中一小块:把「悬停角色强显名字」按 :1535/:1537 补上(forced 第 5 路)。
|
||||
# seam(近似):参考里 chr.Pick() 强显与 targetBoard 强显都在 :1533 的 IsPickedWindow 门内;
|
||||
# poc 把 _target_vid(增量 113)与 _hover_vid(本增量)都当作「无条件 forced」路径——
|
||||
# _hover_vid 在鼠标压 UI 时由 player_controller 主动置 0,算是部分等效于 IsPickedWindow 门。
|
||||
extends RefCounted
|
||||
|
||||
# chr.Pick() / item.Pick() 的「无命中」哨兵。
|
||||
const NO_PICK := -1
|
||||
|
||||
# :1533 —— 鼠标在 3D game 窗口上时,chr.Pick() 这一支才跑。
|
||||
static func char_pick_active(is_picked_window: bool) -> bool:
|
||||
return is_picked_window
|
||||
|
||||
# :1533..:1540 —— 本帧要 ShowCharacterTextTail(强显、绕过 __IsShowName)的 vid 集合:
|
||||
# 悬停拾取到的角色(pick_char_vid,≈ poc 的 _hover_vid)+ 目标框 VID(target_vid)。
|
||||
# 两者都只在 is_picked_window 时收集;去重;0 / NO_PICK 视为无。
|
||||
static func forced_name_vids(is_picked_window: bool, pick_char_vid: int, target_vid: int) -> Array:
|
||||
var out: Array = []
|
||||
if not is_picked_window:
|
||||
return out
|
||||
if pick_char_vid != NO_PICK and pick_char_vid != 0:
|
||||
out.append(pick_char_vid) # :1537
|
||||
if target_vid != 0 and not out.has(target_vid):
|
||||
out.append(target_vid) # :1539
|
||||
return out
|
||||
|
||||
# :1543 —— 只有「鼠标在窗口上」且「非总显名字」时才 item.Pick() 拾取地面物品。
|
||||
static func should_pick_item(is_picked_window: bool, is_show_name: bool) -> bool:
|
||||
return is_picked_window and not is_show_name
|
||||
|
||||
# :1552 —— 总显名字(__IsShowName())时才 ShowAllTextTail() 全量显名。
|
||||
static func should_show_all_names(is_show_name: bool) -> bool:
|
||||
return is_show_name
|
||||
|
||||
# :1554 —— 总显名字时,物品拾取改从已显的尾标里 textTail.Pick(x, y)。
|
||||
static func should_pick_text_tail(is_show_name: bool) -> bool:
|
||||
return is_show_name
|
||||
|
||||
# :1560 —— 拾取到物品尾标(PickingItemIndex != -1)才 SelectItemName 高亮其名。
|
||||
static func should_select_item_name(pick_item_index: int) -> bool:
|
||||
return pick_item_index != NO_PICK
|
||||
@@ -0,0 +1,69 @@
|
||||
# pick_show_test —— §8.8 / §5.4 game.py OnRender「Picking」块分派 1:1:
|
||||
# IsPickedWindow 门、chr.Pick() 强显、targetBoard 强显、去重、__IsShowName() 分叉
|
||||
# (非总显名字 -> item.Pick;总显名字 -> ShowAllTextTail + textTail.Pick)、
|
||||
# SelectItemName 门。
|
||||
# godot --headless --path project --script pick_show_test.gd
|
||||
# 退出码 0 = 全过。
|
||||
extends SceneTree
|
||||
|
||||
const PickShow = preload("res://pick_show.gd")
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: pick_show_test (§8.8/§5.4 OnRender Picking 分派 1:1)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var NP := PickShow.NO_PICK
|
||||
_ck(NP == -1, "NO_PICK == -1")
|
||||
|
||||
# --- 0. char_pick_active(game.py:1533)------------------------------------
|
||||
_ck(PickShow.char_pick_active(true) == true, "鼠标在窗口 -> chr.Pick() 跑")
|
||||
_ck(PickShow.char_pick_active(false) == false, "鼠标不在窗口 -> chr.Pick() 不跑")
|
||||
|
||||
# --- 1. forced_name_vids(:1533..:1540 ShowCharacterTextTail 集合)--------
|
||||
# 不在窗口 -> 空(连 target 强显也不做,参考里都在 :1533 门内)
|
||||
_ck(PickShow.forced_name_vids(false, 2000, 1500) == [], "不在窗口 -> 无强显")
|
||||
# 悬停命中 + 有目标框 -> 两个都强显,顺序 [hover, target]
|
||||
_ck(PickShow.forced_name_vids(true, 2000, 1500) == [2000, 1500], "悬停+目标 -> [hover, target]")
|
||||
# 只悬停命中
|
||||
_ck(PickShow.forced_name_vids(true, 2000, 0) == [2000], "只悬停 -> [hover]")
|
||||
# 只有目标框
|
||||
_ck(PickShow.forced_name_vids(true, NP, 1500) == [1500], "只目标框 -> [target]")
|
||||
_ck(PickShow.forced_name_vids(true, 0, 1500) == [1500], "hover=0 视为无 -> [target]")
|
||||
# 悬停的正是目标 -> 去重
|
||||
_ck(PickShow.forced_name_vids(true, 1500, 1500) == [1500], "悬停==目标 -> 去重成一个")
|
||||
# 啥也没有
|
||||
_ck(PickShow.forced_name_vids(true, NP, 0) == [], "在窗口但无命中无目标 -> 空")
|
||||
|
||||
# --- 2. should_pick_item(:1543)----------------------------------------
|
||||
_ck(PickShow.should_pick_item(true, false) == true, "在窗口 且 非总显名字 -> item.Pick()")
|
||||
_ck(PickShow.should_pick_item(true, true) == false, "总显名字 -> 不走 item.Pick()")
|
||||
_ck(PickShow.should_pick_item(false, false) == false, "不在窗口 -> 不走 item.Pick()")
|
||||
|
||||
# --- 3. should_show_all_names / should_pick_text_tail(:1552 / :1554)--
|
||||
_ck(PickShow.should_show_all_names(true) == true, "总显名字 -> ShowAllTextTail")
|
||||
_ck(PickShow.should_show_all_names(false) == false, "非总显名字 -> 不 ShowAllTextTail")
|
||||
_ck(PickShow.should_pick_text_tail(true) == true, "总显名字 -> textTail.Pick()")
|
||||
_ck(PickShow.should_pick_text_tail(false) == false, "非总显名字 -> 不 textTail.Pick()")
|
||||
|
||||
# item 拾取的两条路互斥:非总显名字走 item.Pick,总显名字走 textTail.Pick
|
||||
for isn in [true, false]:
|
||||
var via_item := PickShow.should_pick_item(true, isn)
|
||||
var via_tail := PickShow.should_pick_text_tail(isn)
|
||||
_ck(via_item != via_tail, "在窗口时 item.Pick 与 textTail.Pick 恰好二选一(is_show_name=%s)" % isn)
|
||||
|
||||
# --- 4. should_select_item_name(:1560)------------------------------
|
||||
_ck(PickShow.should_select_item_name(7) == true, "PickingItemIndex=7 -> SelectItemName")
|
||||
_ck(PickShow.should_select_item_name(0) == true, "PickingItemIndex=0(有效索引)-> SelectItemName")
|
||||
_ck(PickShow.should_select_item_name(NP) == false, "PickingItemIndex=-1 -> 不 SelectItemName")
|
||||
@@ -11,6 +11,9 @@ extends Node
|
||||
signal anim_state(state: String)
|
||||
signal target_selected(node: Node3D)
|
||||
signal moved(pos: Vector3)
|
||||
# game.py OnRender:wndMgr.IsPickedWindow 门内 chr.Pick() 拾到的角色 -> ShowCharacterTextTail。
|
||||
# 悬停命中的实体 vid 变化时 emit(无命中 / 鼠标压 UI / 悬停地面物品 -> emit 0)。见 pick_show.gd。
|
||||
signal hover_entity_changed(vid: int)
|
||||
|
||||
var player: Node3D
|
||||
var camera: Camera3D
|
||||
@@ -21,6 +24,7 @@ var ground_items: Node
|
||||
var cancel_fishing_input := Callable() # NEW_SetSingleDIKKeyState 等价入口
|
||||
var cancel_fishing_ground := Callable() # __OnPressGround 专用入口
|
||||
var pickables: Array[Node3D] = [] # 可点选实体(NPC/怪等)
|
||||
var _hover_vid := 0 # 上一帧悬停命中的实体 vid(hover_entity_changed 去抖)
|
||||
|
||||
const SPEED_WALK := 8.0
|
||||
const SPEED_RUN := 18.0
|
||||
@@ -386,9 +390,11 @@ func _update_hover_cursor() -> void:
|
||||
motion.position = mouse_pos
|
||||
if ui_manager.blocks_game_input(motion):
|
||||
cursor_manager.set_cursor("NORMAL")
|
||||
_set_hover_vid(0) # 鼠标压 UI:IsPickedWindow 为假 -> 无 chr.Pick 强显
|
||||
return
|
||||
if ground_items and ground_items.has_method("hover_at") and ground_items.hover_at(camera, mouse_pos):
|
||||
cursor_manager.set_cursor("PICK")
|
||||
_set_hover_vid(0) # 悬停地面物品:item.Pick 领域,非 chr.Pick
|
||||
return
|
||||
var from := camera.project_ray_origin(mouse_pos)
|
||||
var dir := camera.project_ray_normal(mouse_pos)
|
||||
@@ -403,5 +409,14 @@ func _update_hover_cursor() -> void:
|
||||
best = n
|
||||
if best:
|
||||
cursor_manager.set_cursor(String(best.get_meta("cursor_shape", "ATTACK")))
|
||||
_set_hover_vid(int(best.get_meta("vid", 0)))
|
||||
else:
|
||||
cursor_manager.set_cursor("NORMAL")
|
||||
_set_hover_vid(0)
|
||||
|
||||
# game.py OnRender chr.Pick() -> ShowCharacterTextTail:悬停命中的角色 vid 变化时才 emit。
|
||||
func _set_hover_vid(vid: int) -> void:
|
||||
if vid == _hover_vid:
|
||||
return
|
||||
_hover_vid = vid
|
||||
hover_entity_changed.emit(vid)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -90,6 +90,22 @@ func _run() -> void:
|
||||
cfg.load(_cfg_path())
|
||||
_ck(int(cfg.get_value("gameopt", "always_show_name", -1)) == 0, "cfg gameopt.always_show_name persisted")
|
||||
_ck(int(cfg.get_value("gameopt", "block_mode", -1)) == 2, "cfg gameopt.block_mode persisted")
|
||||
|
||||
# §8.4 名字色 radio(name_color_normal / name_color_empire)-> _display + cfg + 信号
|
||||
var opt_evt := []
|
||||
go.display_option_changed.connect(func(k, v): opt_evt.append([k, v]))
|
||||
(go._node("name_color_empire") as BaseButton).pressed.emit()
|
||||
_ck(int(go._display.get("name_color", -1)) == 1, "name_color_empire -> _display.name_color = 1")
|
||||
_ck(opt_evt.size() >= 1 and opt_evt[-1] == ["name_color", 1], "name_color_empire -> display_option_changed('name_color',1)")
|
||||
# §8.4 看他国目标框 radio(target_board_view / target_board_no_view,constinfo 默认 1 = 看)
|
||||
_ck(int(go._display.get("target_board", -1)) == 1, "target_board 默认 1(VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD)")
|
||||
(go._node("target_board_no_view") as BaseButton).pressed.emit()
|
||||
_ck(int(go._display.get("target_board", -1)) == 0, "target_board_no_view -> _display.target_board = 0")
|
||||
_ck(opt_evt[-1] == ["target_board", 0], "target_board_no_view -> display_option_changed('target_board',0)")
|
||||
var cfg2 := ConfigFile.new()
|
||||
cfg2.load(_cfg_path())
|
||||
_ck(int(cfg2.get_value("gameopt", "name_color", -1)) == 1, "cfg gameopt.name_color persisted")
|
||||
_ck(int(cfg2.get_value("gameopt", "target_board", -1)) == 0, "cfg gameopt.target_board persisted")
|
||||
go.close()
|
||||
|
||||
# 二次实例读 cfg:block_mode 回填
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# target_board —— §5.4 / §8.8 目标框(TargetBoard)收包分派 1:1:
|
||||
# CPythonNetworkStream::RecvTargetPacket 的「目标存在 / 未死 → PC|Building 关掉异 VID 框、
|
||||
# 可看血就设血条、否则关框」判定树,加 CInstanceBase::CanViewTargetHP 的
|
||||
# IsStone()||IsWoodenDoor()||IsEnemy(),再加 game.py 的 SetHPTargetBoard(换 VID 先 Reset)/
|
||||
# CloseTargetBoardIfDifferent / CloseTargetBoard / SetPCTargetBoard 的 LCONTROL 悄悄话捷径。
|
||||
# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd 同一套
|
||||
# 纯静态镜像套路,本模块是第 26 个 canonical。)
|
||||
#
|
||||
# REF(40250 ClientVS22 source/ 为唯一基线):
|
||||
# UserInterface/Packet.h:1937 TPacketGCTarget { BYTE header; DWORD dwVID; BYTE bHPPercent; }
|
||||
# UserInterface/PythonNetworkStreamPhaseGame.cpp:2424 RecvTargetPacket()
|
||||
# :2436 if (pInstPlayer && pInstTarget) —— 主角与目标实例都在
|
||||
# :2438 if (!pInstTarget->IsDead()) —— 目标没死透才动框
|
||||
# :2440 if (IsPC() || IsBuilding()) CloseTargetBoardIfDifferent(dwVID)
|
||||
# :2442 else if (CanViewTargetHP(*pInstTarget)) SetHPTargetBoard(dwVID, bHPPercent)
|
||||
# :2444 else CloseTargetBoard()
|
||||
# :2447 m_pInstTarget = pInstTarget —— 仅当目标存在且未死
|
||||
# :2453 else CloseTargetBoard() —— 实例缺失
|
||||
# UserInterface/InstanceBase.cpp:2293 CanViewTargetHP(victim) = victim.IsStone()||victim.IsWoodenDoor()||victim.IsEnemy()
|
||||
# UserInterface/InstanceBase.cpp:2380/2390 IsBuilding() / IsWoodenDoor()(vnum 13000 或 30111..30119)
|
||||
# root/game.py:804 SetPCTargetBoard(vid,name):Open(vid,name);若按住 LCONTROL 且 IsSameEmpire
|
||||
# 且非本人且非 building → interface.OpenWhisperDialog(name)
|
||||
# root/game.py:829 SetHPTargetBoard(vid,hpPct):vid != GetTargetVID() → ResetTargetBoard()+SetEnemyVID(vid);SetHP(hpPct)+Show()
|
||||
# root/game.py:837 CloseTargetBoardIfDifferent(vid):vid != GetTargetVID() → Close()
|
||||
# root/game.py:841 CloseTargetBoard():Close()
|
||||
# root/uitarget.py:189-193 TargetBoard.Open(vid,name) 最前置过滤:
|
||||
# if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD():
|
||||
# if not player.IsSameEmpire(vid): self.Hide(); return
|
||||
# —— game-option「看他国玩家目标框」关掉时,异阵营玩家框直接不开(constinfo 默认 1 = 开)。
|
||||
#
|
||||
# seam ⑫:旧实体快照没有独立建筑字段;当前 net_play 已支持 fixture / mob_proto
|
||||
# 的 BUILDING(5) / WOODEN_DOOR(6) 分类。没有分类信息的旧快照仍按 NPC/怪/石头路径。
|
||||
# 另:poc 的 target_info 由 C++ 侧(extension/src/net/m2_client.cpp)从 EntityStore 抽出,
|
||||
# 本模块只镜像 GDScript 侧对 (vid, hp%) 的分派,不改 C++ 收包。
|
||||
# LCONTROL 悄悄话捷径:增量 122 接线 —— net_play._on_pick 在 KIND_PC 分支轮询
|
||||
# whisper_on_ctrl_click(_lcontrol_down, same_empire, vid==_main_vid, false),真则发
|
||||
# whisper_requested(name),game_scene 转 chat.start_whisper()(poc 版 OpenWhisperDialog);
|
||||
# _lcontrol_down 由 game_scene 的 KEY_CTRL 按下 / 松开喂。
|
||||
extends RefCounted
|
||||
|
||||
# RecvTargetPacket 分派结果。
|
||||
# CLOSE —— 关框(实例缺失 / 不可看血的非 PC 非 building 目标)
|
||||
# CLOSE_IF_DIFFERENT —— 目标是 PC 或 building:仅当框上是别的 VID 才关
|
||||
# SET_HP —— 可看血:设血条并显示(换 VID 先 Reset)
|
||||
# NONE —— 目标已死透:不动框,但仍更新 m_pInstTarget
|
||||
enum Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE }
|
||||
|
||||
# CanViewTargetHP(victim):石头 / 木门 / 敌对,任一即可看血。
|
||||
static func can_view_target_hp(is_stone: bool, is_wooden_door: bool, is_enemy: bool) -> bool:
|
||||
return is_stone or is_wooden_door or is_enemy
|
||||
|
||||
# RecvTargetPacket 判定树。target_exists = (pInstPlayer && pInstTarget)。
|
||||
static func classify(target_exists: bool, is_dead: bool, is_pc: bool,
|
||||
is_building: bool, can_view_hp: bool) -> int:
|
||||
if not target_exists:
|
||||
return Action.CLOSE # :2453 else CloseTargetBoard()
|
||||
if is_dead:
|
||||
return Action.NONE # :2438 !IsDead() 为假 —— 整块跳过
|
||||
if is_pc or is_building:
|
||||
return Action.CLOSE_IF_DIFFERENT # :2440
|
||||
if can_view_hp:
|
||||
return Action.SET_HP # :2442
|
||||
return Action.CLOSE # :2444
|
||||
|
||||
# m_pInstTarget = pInstTarget 只在「目标存在且没死透」时写(:2447 在 !IsDead() 块内)。
|
||||
static func updates_current_target(target_exists: bool, is_dead: bool) -> bool:
|
||||
return target_exists and not is_dead
|
||||
|
||||
# SetHPTargetBoard:vid != GetTargetVID() → 先 ResetTargetBoard()+SetEnemyVID(vid)。
|
||||
static func needs_reset(new_vid: int, cur_vid: int) -> bool:
|
||||
return new_vid != cur_vid
|
||||
|
||||
# CloseTargetBoardIfDifferent:vid != GetTargetVID() 才 Close()。
|
||||
static func should_close_if_different(vid: int, cur_vid: int) -> bool:
|
||||
return vid != cur_vid
|
||||
|
||||
# TargetBoard.Open :189-193 前置过滤:game-option「看他国玩家目标框」(constinfo
|
||||
# VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1) 关掉时,异阵营目标框不开。
|
||||
# view_other_empire=false 且 same_empire=false → 隐藏。empire 0 的怪/NPC/石头在
|
||||
# IsSameEmpire 里算「同阵营」(entity_rules._is_same_empire),故只挡真·他国 PC。
|
||||
static func hidden_as_other_empire(view_other_empire: bool, same_empire: bool) -> bool:
|
||||
return not view_other_empire and not same_empire
|
||||
|
||||
# SetPCTargetBoard 的 LCONTROL 悄悄话捷径:按住 LCONTROL 且同阵营且非本人且非 building。
|
||||
static func whisper_on_ctrl_click(lcontrol_down: bool, same_empire: bool,
|
||||
is_main: bool, is_building: bool) -> bool:
|
||||
return lcontrol_down and same_empire and not is_main and not is_building
|
||||
|
||||
# bHPPercent 是 BYTE,钳到 0..100。
|
||||
static func clamp_hp_pct(v: Variant) -> int:
|
||||
return clampi(int(v), 0, 100)
|
||||
@@ -0,0 +1,96 @@
|
||||
# target_board_test —— §5.4 / §8.8 目标框收包分派 1:1:
|
||||
# RecvTargetPacket 的判定树(实例缺失→CLOSE、已死→NONE、PC|Building→CLOSE_IF_DIFFERENT、
|
||||
# 可看血→SET_HP、否则→CLOSE)、CanViewTargetHP、m_pInstTarget 写入条件、SetHPTargetBoard
|
||||
# 换 VID 先 Reset、CloseTargetBoardIfDifferent、SetPCTargetBoard 的 LCONTROL 悄悄话捷径、
|
||||
# bHPPercent 钳位。
|
||||
# godot --headless --path project --script target_board_test.gd
|
||||
# 退出码 0 = 全过。
|
||||
extends SceneTree
|
||||
|
||||
const TargetBoard = preload("res://target_board.gd")
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: target_board_test (§5.4/§8.8 RecvTargetPacket 分派 + CanViewTargetHP 1:1)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var CLOSE := TargetBoard.Action.CLOSE
|
||||
var DIFF := TargetBoard.Action.CLOSE_IF_DIFFERENT
|
||||
var SETHP := TargetBoard.Action.SET_HP
|
||||
var NONE := TargetBoard.Action.NONE
|
||||
|
||||
# --- 0. CanViewTargetHP(InstanceBase.cpp:2293)------------------------------
|
||||
_ck(TargetBoard.can_view_target_hp(true, false, false) == true, "IsStone -> 可看血")
|
||||
_ck(TargetBoard.can_view_target_hp(false, true, false) == true, "IsWoodenDoor -> 可看血")
|
||||
_ck(TargetBoard.can_view_target_hp(false, false, true) == true, "IsEnemy -> 可看血")
|
||||
_ck(TargetBoard.can_view_target_hp(false, false, false) == false, "皆非 -> 不可看血")
|
||||
_ck(TargetBoard.can_view_target_hp(true, true, true) == true, "任一为真即可看血")
|
||||
# door/building 实体现在由 net_play/entity_rules 识别后分别进入这两条参考分支。
|
||||
_ck(TargetBoard.classify(true, false, false, true, true) == DIFF,
|
||||
"建筑即使可看血也优先 CLOSE_IF_DIFFERENT")
|
||||
_ck(TargetBoard.classify(true, false, false, false, true) == SETHP,
|
||||
"木门经 CanViewTargetHP -> SET_HP")
|
||||
|
||||
# --- 1. RecvTargetPacket 判定树(PythonNetworkStreamPhaseGame.cpp:2424)------
|
||||
# 实例缺失(pInstPlayer && pInstTarget 为假)-> :2453 CloseTargetBoard
|
||||
_ck(TargetBoard.classify(false, false, false, false, false) == CLOSE, "目标实例缺失 -> CLOSE")
|
||||
_ck(TargetBoard.classify(false, false, true, false, true) == CLOSE, "实例缺失优先于其它分支")
|
||||
# 目标已死透 -> :2438 !IsDead() 为假 -> 整块跳过(NONE),但 m_pInstTarget 也不写
|
||||
_ck(TargetBoard.classify(true, true, false, false, true) == NONE, "目标已死 -> NONE(不动框)")
|
||||
_ck(TargetBoard.classify(true, true, true, false, false) == NONE, "已死优先于 PC 分支")
|
||||
# PC / Building -> :2440 CloseTargetBoardIfDifferent
|
||||
_ck(TargetBoard.classify(true, false, true, false, false) == DIFF, "PC 目标 -> CLOSE_IF_DIFFERENT")
|
||||
_ck(TargetBoard.classify(true, false, false, true, false) == DIFF, "Building 目标 -> CLOSE_IF_DIFFERENT")
|
||||
_ck(TargetBoard.classify(true, false, true, false, true) == DIFF, "PC 即便可看血也走 CLOSE_IF_DIFFERENT(PC 分支在前)")
|
||||
# 可看血 -> :2442 SetHPTargetBoard
|
||||
_ck(TargetBoard.classify(true, false, false, false, true) == SETHP, "非 PC 非 building 且可看血 -> SET_HP")
|
||||
# 不可看血的非 PC 非 building(例如 NPC / warp)-> :2444 CloseTargetBoard
|
||||
_ck(TargetBoard.classify(true, false, false, false, false) == CLOSE, "NPC/warp 不可看血 -> CLOSE")
|
||||
|
||||
# --- 2. m_pInstTarget 写入条件(:2447 在 !IsDead() 块内)---------------------
|
||||
_ck(TargetBoard.updates_current_target(true, false) == true, "目标存在且未死 -> 写 m_pInstTarget")
|
||||
_ck(TargetBoard.updates_current_target(true, true) == false, "目标已死 -> 不写 m_pInstTarget")
|
||||
_ck(TargetBoard.updates_current_target(false, false) == false, "实例缺失 -> 不写 m_pInstTarget")
|
||||
|
||||
# --- 3. SetHPTargetBoard 换 VID 先 Reset(game.py:829)---------------------
|
||||
_ck(TargetBoard.needs_reset(2000, 0) == true, "无当前目标 -> 先 Reset")
|
||||
_ck(TargetBoard.needs_reset(2000, 1500) == true, "换目标 -> 先 Reset")
|
||||
_ck(TargetBoard.needs_reset(2000, 2000) == false, "同目标 -> 只 SetHP,不 Reset")
|
||||
|
||||
# --- 4. CloseTargetBoardIfDifferent(game.py:837)------------------------
|
||||
_ck(TargetBoard.should_close_if_different(2000, 1500) == true, "框上是别的 VID -> Close")
|
||||
_ck(TargetBoard.should_close_if_different(2000, 2000) == false, "框上正是该 VID -> 保留")
|
||||
_ck(TargetBoard.should_close_if_different(2000, 0) == true, "框上无 VID -> Close")
|
||||
|
||||
# --- 5. SetPCTargetBoard 的 LCONTROL 悄悄话捷径(game.py:804-817)---------
|
||||
_ck(TargetBoard.whisper_on_ctrl_click(true, true, false, false) == true, "LCONTROL+同阵营+非本人+非building -> 开悄悄话")
|
||||
_ck(TargetBoard.whisper_on_ctrl_click(false, true, false, false) == false, "没按 LCONTROL -> 不开")
|
||||
_ck(TargetBoard.whisper_on_ctrl_click(true, false, false, false) == false, "!IsSameEmpire -> return")
|
||||
_ck(TargetBoard.whisper_on_ctrl_click(true, true, true, false) == false, "IsMainCharacterIndex -> return")
|
||||
_ck(TargetBoard.whisper_on_ctrl_click(true, true, false, true) == false, "INSTANCE_TYPE_BUILDING -> return")
|
||||
|
||||
# --- 5b. TargetBoard.Open 异阵营前置过滤(uitarget.py:189-193)------------
|
||||
# game-option 关「看他国目标框」+ 异阵营 -> 隐藏;其它组合都不挡。
|
||||
_ck(TargetBoard.hidden_as_other_empire(false, false) == true, "关看他国框 + 异阵营 -> 隐藏")
|
||||
_ck(TargetBoard.hidden_as_other_empire(false, true) == false, "关看他国框 + 同阵营 -> 不挡")
|
||||
_ck(TargetBoard.hidden_as_other_empire(true, false) == false, "开看他国框 + 异阵营 -> 不挡")
|
||||
_ck(TargetBoard.hidden_as_other_empire(true, true) == false, "开看他国框 + 同阵营 -> 不挡")
|
||||
|
||||
# --- 6. bHPPercent 钳位(BYTE 0..100)----------------------------------
|
||||
_ck(TargetBoard.clamp_hp_pct(40) == 40, "40 -> 40")
|
||||
_ck(TargetBoard.clamp_hp_pct(0) == 0, "0 -> 0")
|
||||
_ck(TargetBoard.clamp_hp_pct(100) == 100, "100 -> 100")
|
||||
_ck(TargetBoard.clamp_hp_pct(-3) == 0, "负数 -> 0")
|
||||
_ck(TargetBoard.clamp_hp_pct(150) == 100, ">100 -> 100")
|
||||
_ck(TargetBoard.clamp_hp_pct(37.9) == 37, "float 37.9 -> 37(int 截断)")
|
||||
@@ -0,0 +1,115 @@
|
||||
# text_metrics —— §8.8 seam ⑦ / ⑩ 收口:头顶子标签 / 掉落名条排版的文本尺寸
|
||||
# 从「字符数 * 约 7px」/「单行 15px」估算换成 CGraphicTextInstance::GetTextSize 的
|
||||
# 真实字体度量 1:1(增量 123 换宽 w、增量 124 换高 h、增量 125 加 owner 子串独立度量)。
|
||||
# ArrangeTextTail(PythonTextTail.cpp:211-320 角色块、:160-190 item 块)里
|
||||
# iNameWidth / iTitleWidth / iLevelWidth / guildW,以及 item 尾标框的 xSize / ySize
|
||||
# (框 yEnd = ySize,:749 / :809),参考端全部来自 `pTextInstance->GetTextSize(&w, &h)`。
|
||||
# Godot 的 Font.get_string_size() 是 1:1 对应物——本模块把它包成纯静态查询,并在无字体
|
||||
# (headless / 未配主题)时回退到旧的字符数估宽 / 单行估高,保持 name_tail_layout /
|
||||
# text_tail_arrange 的接口不变。
|
||||
# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd /
|
||||
# target_board.gd / pick_show.gd / name_tail_layout.gd / text_tail_arrange.gd 同一套
|
||||
# 纯静态镜像套路,本模块是第 30 个 canonical。)
|
||||
#
|
||||
# REF(40250 ClientVS22 source/ 为唯一基线):
|
||||
# UserInterface/PythonTextTail.cpp:225 pGuildNameInstance->GetTextSize(&iWidth, &iHeight)
|
||||
# UserInterface/PythonTextTail.cpp:233 pTextTail->pTextInstance->GetTextSize(&iNameWidth, &iNameHeight)
|
||||
# UserInterface/PythonTextTail.cpp:240 pTitle->GetTextSize(&iTitleWidth, &iTitleHeight)
|
||||
# UserInterface/PythonTextTail.cpp:267 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(有称号分支)
|
||||
# UserInterface/PythonTextTail.cpp:297 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(只有等级分支)
|
||||
# UserInterface/PythonTextTail.cpp:733 pOwnerTextInstance->GetTextSize(&xOwnerSize, &yOwnerSize)
|
||||
# UserInterface/PythonTextTail.cpp:748 pTextInstance->GetTextSize(&xSize, &ySize)(SetTextTailOwnerText)
|
||||
# UserInterface/PythonTextTail.cpp:809 pTextInstance->GetTextSize(&xSize, &ySize)(SetTextTailText,item 框尺寸)
|
||||
# —— GetTextSize 由 CGraphicTextInstance 转发到字体的实际字形排版度量(含字距、非等宽),
|
||||
# 不是「字符数 * 定宽」。poc 之前的 SUBTAG_CHAR_W_PX / TAG_CHAR_W_PX(7.0) 是它 w 的近似、
|
||||
# TAG_LINE_H_PX(15.0) 是它 h 的近似。
|
||||
#
|
||||
# seam ⑦ / ⑩:本模块只负责「量文本宽 / 高」。有字体时 = get_string_size 的精确像素(1:1);
|
||||
# 无字体时回退字符数估宽 / 单行估高(headless 测试、或工程未配 Label3D.font 且无
|
||||
# ThemeDB.fallback_font 的极端情况)。折算成世界坐标仍由调用方按各 Label3D 的 pixel_size
|
||||
# 处理(见 name_tail_layout.subtag_world_x / text_tail_arrange)。子标签竖排 vs 参考同一
|
||||
# 屏幕行、无公会徽记 mark 实体这两条 seam ⑦ 遗留不在本模块范围。
|
||||
extends RefCounted
|
||||
|
||||
# 无字体 / font_size 非法时的每字符估宽(=增量 121 之前 net_world.SUBTAG_CHAR_W_PX、
|
||||
# ground_items.TAG_CHAR_W_PX 用的 7.0)。仅作兜底,有字体时不参与。
|
||||
const FALLBACK_CHAR_W_PX := 7.0
|
||||
|
||||
# 无字体 / font_size 非法时的单行估高(=增量 124 之前 ground_items.TAG_LINE_H_PX 用的
|
||||
# 15.0)。仅作兜底,有字体时走 get_string_size(...).y。
|
||||
const FALLBACK_LINE_H_PX := 15.0
|
||||
|
||||
# GetTextSize(&w, &h) 1:1:text 在 font @ font_size 下的像素尺寸。
|
||||
# 空串 / font 为 null / font_size <= 0 -> Vector2.ZERO(对齐参考端未建 text instance
|
||||
# 时该子标签不参与 ArrangeTextTail 的宽度计算)。
|
||||
static func text_size(font: Font, text: String, font_size: int) -> Vector2:
|
||||
if text == "" or font == null or font_size <= 0:
|
||||
return Vector2.ZERO
|
||||
return font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
||||
|
||||
# ArrangeTextTail 里各 iXxxWidth 取的就是 GetTextSize 的 w。
|
||||
# 有字体且量得正宽 -> 精确像素;否则(无字体 / 非法 size)-> 字符数 * fallback_char_px。
|
||||
# 空串恒 0(参考端无 text instance -> 该项不占宽)。
|
||||
static func width_of(font: Font, text: String, font_size: int,
|
||||
fallback_char_px := FALLBACK_CHAR_W_PX) -> float:
|
||||
if text == "":
|
||||
return 0.0
|
||||
var w := text_size(font, text, font_size).x
|
||||
if w > 0.0:
|
||||
return w
|
||||
return float(text.length()) * fallback_char_px
|
||||
|
||||
# Label3D 便捷入口:从节点自身取字体(Label3D.font 未设时回落 ThemeDB.fallback_font)
|
||||
# 和 font_size,量出该 Label3D 当前文本的像素宽。label 为 null -> 0。
|
||||
static func label_width(label: Label3D, fallback_char_px := FALLBACK_CHAR_W_PX) -> float:
|
||||
if label == null:
|
||||
return 0.0
|
||||
var f: Font = label.font
|
||||
if f == null:
|
||||
f = ThemeDB.fallback_font
|
||||
return width_of(f, label.text, label.font_size, fallback_char_px)
|
||||
|
||||
# GetTextSize 的 h:item 尾标框 yEnd = ySize(PythonTextTail.cpp:749 / :809)。单行文本的
|
||||
# ySize ≈ 字体行高(ascent + descent),与具体字形无关。空串恒 0(参考端无 text
|
||||
# instance -> 不占高);有字体量得正高 -> 精确像素;否则(无字体 / 非法 size)-> fallback。
|
||||
static func height_of(font: Font, text: String, font_size: int,
|
||||
fallback_line_px := FALLBACK_LINE_H_PX) -> float:
|
||||
if text == "":
|
||||
return 0.0
|
||||
var h := text_size(font, text, font_size).y
|
||||
if h > 0.0:
|
||||
return h
|
||||
return fallback_line_px
|
||||
|
||||
# Label3D 便捷入口:同 label_width,量该 Label3D 当前文本的像素高。label 为 null -> 0。
|
||||
static func label_height(label: Label3D, fallback_line_px := FALLBACK_LINE_H_PX) -> float:
|
||||
if label == null:
|
||||
return 0.0
|
||||
var f: Font = label.font
|
||||
if f == null:
|
||||
f = ThemeDB.fallback_font
|
||||
return height_of(f, label.text, label.font_size, fallback_line_px)
|
||||
|
||||
# 增量 125(seam ⑩ 末尾 SetItemTextTailOwner):owner 后缀在参考端是与名字分开的
|
||||
# pOwnerTextInstance,单独 GetTextSize。poc 只有一个合并 Label3D,故提供「用某 Label3D 的
|
||||
# 字体 / 字号量任意子串」入口,让 ground_items 把名字子串、owner 子串分别度量。
|
||||
# label 为 null -> 回退(宽 = 字符数 * fallback_char_px;高 = 空串 0 / 非空 fallback_line_px)。
|
||||
static func string_width_like(label: Label3D, text: String,
|
||||
fallback_char_px := FALLBACK_CHAR_W_PX) -> float:
|
||||
if label == null:
|
||||
return float(text.length()) * fallback_char_px
|
||||
var f: Font = label.font
|
||||
if f == null:
|
||||
f = ThemeDB.fallback_font
|
||||
return width_of(f, text, label.font_size, fallback_char_px)
|
||||
|
||||
static func string_height_like(label: Label3D, text: String,
|
||||
fallback_line_px := FALLBACK_LINE_H_PX) -> float:
|
||||
if text == "":
|
||||
return 0.0
|
||||
if label == null:
|
||||
return fallback_line_px
|
||||
var f: Font = label.font
|
||||
if f == null:
|
||||
f = ThemeDB.fallback_font
|
||||
return height_of(f, text, label.font_size, fallback_line_px)
|
||||
@@ -0,0 +1,141 @@
|
||||
# text_metrics_test —— §8.8 seam ⑦ / ⑩:文本尺寸换真实字体度量(GetTextSize 1:1)。
|
||||
# text_size(空 / null / 非法 size -> ZERO;真实度量为正、随串长 / 字号单调)、
|
||||
# width_of(空串 0 / 无字体回退字符数*px / 有字体 == text_size.x / font_size<=0 回退)、
|
||||
# label_width(Label3D.font 未设回落 ThemeDB.fallback_font / null -> 0)、
|
||||
# height_of / label_height(增量 124:空串 0 / 无字体回退 15px / 有字体 == text_size.y /
|
||||
# 与串内容无关随字号单调 / font_size<=0 回退)、
|
||||
# string_width_like / string_height_like(增量 125:量 label 字体下的任意子串 —— owner
|
||||
# 子串独立度量;label null -> 回退字符数*px / 单行估高;空串 -> 0)。
|
||||
# godot --headless --path project --script text_metrics_test.gd
|
||||
# 退出码 0 = 全过。
|
||||
extends SceneTree
|
||||
|
||||
const TextMetrics = preload("res://text_metrics.gd")
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _feq(a: float, b: float, m: String) -> void:
|
||||
_ck(absf(a - b) < 0.0001, "%s (got %f want %f)" % [m, a, b])
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: text_metrics_test (§8.8 seam ⑦/⑩ 文本宽 w 123 / 高 h 124 / owner 子串独立度量 125 换真实字体度量 GetTextSize 1:1)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
# --- 0. 常量 -----------------------------------------------------------
|
||||
_feq(TextMetrics.FALLBACK_CHAR_W_PX, 7.0, "FALLBACK_CHAR_W_PX = 旧 SUBTAG/TAG_CHAR_W_PX 7.0")
|
||||
|
||||
var font: Font = ThemeDB.fallback_font
|
||||
|
||||
# --- 1. text_size 退化输入 -> Vector2.ZERO --------------------------
|
||||
_ck(TextMetrics.text_size(font, "", 16) == Vector2.ZERO, "空串 -> ZERO")
|
||||
_ck(TextMetrics.text_size(null, "abc", 16) == Vector2.ZERO, "font 为 null -> ZERO")
|
||||
_ck(TextMetrics.text_size(font, "abc", 0) == Vector2.ZERO, "font_size 0 -> ZERO")
|
||||
_ck(TextMetrics.text_size(font, "abc", -4) == Vector2.ZERO, "font_size 负 -> ZERO")
|
||||
|
||||
# --- 2. text_size 真实度量(有 fallback_font 时)------------------
|
||||
if font != null:
|
||||
var w5 := TextMetrics.text_size(font, "MMMMM", 24).x
|
||||
var w10 := TextMetrics.text_size(font, "MMMMMMMMMM", 24).x
|
||||
_ck(w5 > 0.0, "非空串 @ 有效字号 -> 宽为正")
|
||||
_ck(w10 > w5, "更长的串更宽(GetTextSize 逐字形累加)")
|
||||
var wSmall := TextMetrics.text_size(font, "Test", 12).x
|
||||
var wBig := TextMetrics.text_size(font, "Test", 48).x
|
||||
_ck(wBig > wSmall, "同串更大字号更宽")
|
||||
# width_of 有字体时应完全等于 text_size(...).x
|
||||
_feq(TextMetrics.width_of(font, "Hello", 20),
|
||||
TextMetrics.text_size(font, "Hello", 20).x, "width_of 有字体 == text_size.x")
|
||||
else:
|
||||
printerr("note: ThemeDB.fallback_font 为 null,跳过真实度量断言(仅测回退路径)")
|
||||
|
||||
# --- 3. width_of 回退与边界 --------------------------------------------
|
||||
_feq(TextMetrics.width_of(font, "", 16), 0.0, "空串宽恒 0")
|
||||
_feq(TextMetrics.width_of(null, "abcd", 16, 7.0), 28.0, "无字体 -> 字符数 * 7 = 28")
|
||||
_feq(TextMetrics.width_of(null, "abcd", 16), 4.0 * TextMetrics.FALLBACK_CHAR_W_PX,
|
||||
"无字体默认回退用 FALLBACK_CHAR_W_PX")
|
||||
_feq(TextMetrics.width_of(font, "ab", 0, 5.0), 10.0, "有字体但 font_size 0 -> 回退 2*5")
|
||||
_feq(TextMetrics.width_of(null, "x", 16, 3.0), 3.0, "单字符回退 = char_px")
|
||||
|
||||
# --- 4. label_width(Label3D)---------------------------------------
|
||||
_ck(TextMetrics.label_width(null) == 0.0, "label 为 null -> 0")
|
||||
|
||||
var lbl := Label3D.new()
|
||||
lbl.text = "WWWW"
|
||||
lbl.font_size = 24
|
||||
get_root().add_child(lbl)
|
||||
var lw := TextMetrics.label_width(lbl, 7.0)
|
||||
if font != null:
|
||||
_ck(lw > 0.0, "Label3D.font 未设 -> 回落 ThemeDB.fallback_font 量得正宽")
|
||||
_feq(lw, TextMetrics.text_size(ThemeDB.fallback_font, "WWWW", 24).x,
|
||||
"label_width 用节点自身 text / font_size")
|
||||
else:
|
||||
_feq(lw, 4.0 * 7.0, "无 fallback_font -> label_width 回退字符数*px")
|
||||
var lbl_empty := Label3D.new()
|
||||
lbl_empty.text = ""
|
||||
lbl_empty.font_size = 24
|
||||
get_root().add_child(lbl_empty)
|
||||
_feq(TextMetrics.label_width(lbl_empty), 0.0, "空 Label3D 宽 0")
|
||||
|
||||
# --- 5. height_of / label_height(增量 124:GetTextSize 的 h)--------
|
||||
_feq(TextMetrics.FALLBACK_LINE_H_PX, 15.0, "FALLBACK_LINE_H_PX = 旧 TAG_LINE_H_PX 15.0")
|
||||
_feq(TextMetrics.height_of(font, "", 16), 0.0, "空串高恒 0")
|
||||
_feq(TextMetrics.height_of(null, "abcd", 16, 15.0), 15.0, "无字体 -> 回退 15")
|
||||
_feq(TextMetrics.height_of(null, "abcd", 16), TextMetrics.FALLBACK_LINE_H_PX,
|
||||
"无字体默认回退用 FALLBACK_LINE_H_PX")
|
||||
_feq(TextMetrics.height_of(font, "ab", 0, 9.0), 9.0, "有字体但 font_size 0 -> 回退 9")
|
||||
if font != null:
|
||||
var h_short := TextMetrics.height_of(font, "x", 20)
|
||||
var h_long := TextMetrics.height_of(font, "WWWWWWWWWW", 20)
|
||||
_ck(h_short > 0.0, "非空串 @ 有效字号 -> 高为正")
|
||||
_feq(h_short, h_long, "单行高与串内容无关(≈ 字体行高)")
|
||||
var h12 := TextMetrics.height_of(font, "Test", 12)
|
||||
var h48 := TextMetrics.height_of(font, "Test", 48)
|
||||
_ck(h48 > h12, "更大字号更高")
|
||||
_feq(TextMetrics.height_of(font, "Hello", 20),
|
||||
TextMetrics.text_size(font, "Hello", 20).y, "height_of 有字体 == text_size.y")
|
||||
_ck(TextMetrics.label_height(null) == 0.0, "label 为 null -> 高 0")
|
||||
if font != null:
|
||||
_feq(TextMetrics.label_height(lbl, 15.0),
|
||||
TextMetrics.text_size(ThemeDB.fallback_font, "WWWW", 24).y,
|
||||
"label_height 用节点自身 text / font_size、回落 fallback_font")
|
||||
else:
|
||||
_feq(TextMetrics.label_height(lbl, 15.0), 15.0, "无 fallback_font -> label_height 回退 15")
|
||||
_feq(TextMetrics.label_height(lbl_empty), 0.0, "空 Label3D 高 0")
|
||||
|
||||
# --- 6. string_width_like / string_height_like(增量 125:owner 子串独立度量)------
|
||||
_feq(TextMetrics.string_width_like(null, "abcd", 7.0), 28.0, "label null -> 字符数 * 7")
|
||||
_feq(TextMetrics.string_width_like(null, "abcd"), 4.0 * TextMetrics.FALLBACK_CHAR_W_PX,
|
||||
"label null 默认回退用 FALLBACK_CHAR_W_PX")
|
||||
_feq(TextMetrics.string_width_like(null, "", 7.0), 0.0, "label null + 空串 -> 0")
|
||||
_feq(TextMetrics.string_height_like(null, "", 15.0), 0.0, "空串高恒 0(不占行)")
|
||||
_feq(TextMetrics.string_height_like(lbl, ""), 0.0, "有 label + 空串 -> 0")
|
||||
_feq(TextMetrics.string_height_like(null, "abcd", 15.0), 15.0, "label null + 非空 -> 回退 15")
|
||||
_feq(TextMetrics.string_height_like(null, "abcd"), TextMetrics.FALLBACK_LINE_H_PX,
|
||||
"label null 默认回退用 FALLBACK_LINE_H_PX")
|
||||
if font != null:
|
||||
# 量的是「给定子串」而非 label.text(lbl.text = "WWWW")
|
||||
_feq(TextMetrics.string_width_like(lbl, "Hello"),
|
||||
TextMetrics.text_size(ThemeDB.fallback_font, "Hello", 24).x,
|
||||
"string_width_like 用 label 字号量给定子串(非 label.text)")
|
||||
_feq(TextMetrics.string_height_like(lbl, "Hello"),
|
||||
TextMetrics.text_size(ThemeDB.fallback_font, "Hello", 24).y,
|
||||
"string_height_like == text_size(子串).y")
|
||||
_ck(TextMetrics.string_width_like(lbl, "WWWWWWWW") > TextMetrics.string_width_like(lbl, "WW"),
|
||||
"更长子串更宽")
|
||||
else:
|
||||
_feq(TextMetrics.string_width_like(lbl, "Hello", 7.0), 5.0 * 7.0,
|
||||
"无 fallback_font -> string_width_like 回退字符数*px")
|
||||
_feq(TextMetrics.string_height_like(lbl, "Hello", 15.0), 15.0,
|
||||
"无 fallback_font -> string_height_like 回退单行估高")
|
||||
|
||||
lbl.queue_free()
|
||||
lbl_empty.queue_free()
|
||||
@@ -0,0 +1,105 @@
|
||||
# text_tail_arrange —— §8.8 seam ⑩ 收口:地面物品名条「投影后互相挤开防重叠」1:1。
|
||||
# CPythonTextTail::ArrangeTextTail 的 m_ItemTextTailList 块(PythonTextTail.cpp:160-190):
|
||||
# 逐条 item 尾标,与其它每条比 isIn(AABB 相交),命中就把自己顶到对方框底下
|
||||
# `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(yTemp = 5),内层循环从头重跑,
|
||||
# LimitCount 上限 20 次防死循环。isIn(:489-507)是标准 AABB:两框在 x、y 都有交叠即真。
|
||||
# 尾标框(:810-813 等):xStart = -w/2-2、xEnd = w/2+2、yStart = -2、yEnd = h。
|
||||
# (与 name_color / text_tail / chat_tail / name_show / damage_effect / target_board /
|
||||
# pick_show / name_tail_layout 同一套纯静态镜像套路,本模块是第 29 个 canonical。)
|
||||
#
|
||||
# REF(40250 ClientVS22 source/ 为唯一基线):
|
||||
# UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f
|
||||
# UserInterface/PythonTextTail.cpp:153 ArrangeTextTail()
|
||||
# :160 for (pInsertTextTail : m_ItemTextTailList) —— 逐条 item 名条
|
||||
# :164 int yTemp = 5; int LimitCount = 0;
|
||||
# :167 for (pCompareTextTail : m_ItemTextTailList) —— 内层比对
|
||||
# :171 if (*itorCompare == *itor) { ++itorCompare; continue; } —— 跳过自己
|
||||
# :176 if (LimitCount >= 20) break;
|
||||
# :180 if (isIn(pInsertTextTail, pCompareTextTail))
|
||||
# :182 pInsertTextTail->y = pCompareTextTail->y + pCompareTextTail->yEnd + yTemp;
|
||||
# :184 itorCompare = m_ItemTextTailList.begin(); ++LimitCount; continue; —— 从头重跑
|
||||
# :189 ++itorCompare;
|
||||
# UserInterface/PythonTextTail.cpp:489 isIn(pSource, pTarget):
|
||||
# 框 Source = [x+xStart, x+xEnd] × [y+yStart, y+yEnd],Target 同;
|
||||
# :500 if (x1S <= x2T && x2S >= x1T && y1S <= y2T && y2S >= y1T) return true;
|
||||
# UserInterface/PythonTextTail.cpp:810-813 尾标框(SetTextTailOwnerText 等):
|
||||
# xStart = -xSize/2 - 2、yStart = -2、xEnd = xSize/2 + 2、yEnd = ySize(xSize/ySize = 文本像素尺寸)
|
||||
# UserInterface/PythonTextTail.cpp:706-737 SetItemTextTailOwner(dwVID, c_szName):掉落名条
|
||||
# 挂 owner 后缀。owner 非空时 owner 走独立 CGraphicTextInstance(黄 1,1,0 / CENTER / 文本
|
||||
# = c_szName + strOwnership,IDS_POSSESSIVE_MORPHENE 为空取字面 "'s"),单独 GetTextSize:
|
||||
# :735 pTextTail->yEnd += float(yOwnerSize + 4) —— owner 另起一行、行距 4
|
||||
# :736 pTextTail->xStart = fMIN(xStart, -xOwnerSize/2 - 1)
|
||||
# :737 pTextTail->xEnd = fMAX(xEnd, xOwnerSize/2 + 1)
|
||||
# owner 为空时删 owner 实例、框复位成纯名字 xStart=-w/2-2 / xEnd=w/2+2 / yEnd=h。
|
||||
#
|
||||
# seam ⑩:参考端在屏幕像素空间跑(y 向下),poc 地面物品名条是每物一个 billboard Label3D。
|
||||
# 本模块保持屏幕像素语义(ground_items 把 tag 世界锚点投影到屏幕、按文本长度估框),算出
|
||||
# 每条被下推的屏幕 y 后,poc 侧按 name_tail_layout 的同一 pixel_size 约定折成世界 y 向下的
|
||||
# 位移(屏幕向下 Δpx → 世界向下 Δpx*pixel_size)。文本框尺寸按字符数估(参考端是
|
||||
# GetTextSize 精确像素)= seam ⑩ 近似;只对 item 名条做,角色名字尾标不参与(同参考)。
|
||||
extends RefCounted
|
||||
|
||||
const PUSH_GAP := 5.0 # ArrangeTextTail :164 的 yTemp
|
||||
const MAX_ITERS := 20 # :176 的 LimitCount 上限(单条最多被下推的次数)
|
||||
|
||||
# 尾标框相对锚点 (x, y) 的四边(PythonTextTail.cpp:810-813)。屏幕坐标,y 向下。
|
||||
static func box_start_x(w: float) -> float: return -w / 2.0 - 2.0
|
||||
static func box_end_x(w: float) -> float: return w / 2.0 + 2.0
|
||||
static func box_start_y() -> float: return -2.0
|
||||
static func box_end_y(h: float) -> float: return h
|
||||
|
||||
# SetItemTextTailOwner(PythonTextTail.cpp:706-737)代入 poc 的 {w, h} 框:掉落名条挂上
|
||||
# 「owner + 所有格语素」后,去重叠框在纯名字框(name_w / name_h)基础上按 owner 独立
|
||||
# 文本度量(owner_w / owner_h)扩张:
|
||||
# :735 yEnd += ownerH + 4 -> h = name_h + owner_h + 4
|
||||
# :737 xEnd = fMAX(nameW/2+2, ownerW/2+1)
|
||||
# box_end_x(w) = w/2 + 2,令它等于 fMAX 结果 -> w = max(name_w, owner_w - 2)
|
||||
# (框 x 两边对称,box_start_x 自动 = 参考 :736 的 fMIN 结果,无需单独算)。
|
||||
# owner_w / owner_h <= 0(无 owner,参考端删 owner 实例、框复位纯名字)-> {name_w, name_h}。
|
||||
static func item_box_with_owner(name_w: float, name_h: float,
|
||||
owner_w := 0.0, owner_h := 0.0) -> Dictionary:
|
||||
if owner_w <= 0.0 or owner_h <= 0.0:
|
||||
return {"w": name_w, "h": name_h}
|
||||
return {"w": maxf(name_w, owner_w - 2.0), "h": name_h + owner_h + 4.0}
|
||||
|
||||
# isIn(PythonTextTail.cpp:489-507):两条尾标框在屏幕上是否相交(含边界,<= / >=)。
|
||||
# a / b 为 {x, y, w, h}(x, y = 锚点屏幕坐标;w, h = 文本像素尺寸)。
|
||||
static func boxes_overlap(a: Dictionary, b: Dictionary) -> bool:
|
||||
var ax1: float = a["x"] + box_start_x(a["w"])
|
||||
var ay1: float = a["y"] + box_start_y()
|
||||
var ax2: float = a["x"] + box_end_x(a["w"])
|
||||
var ay2: float = a["y"] + box_end_y(a["h"])
|
||||
var bx1: float = b["x"] + box_start_x(b["w"])
|
||||
var by1: float = b["y"] + box_start_y()
|
||||
var bx2: float = b["x"] + box_end_x(b["w"])
|
||||
var by2: float = b["y"] + box_end_y(b["h"])
|
||||
return ax1 <= bx2 and ax2 >= bx1 and ay1 <= by2 and ay2 >= by1
|
||||
|
||||
# ArrangeTextTail :160-190 的 item 块。tails: Array[{x, y, w, h}](屏幕坐标,y 向下),
|
||||
# 列表顺序即参考端 m_ItemTextTailList 顺序。返回等长 Array[float]:每条排布后的屏幕 y
|
||||
# (未重叠的保持原值)。逐条 pInsert 与其它每条 pCompare 比 isIn,命中就把 pInsert 顶到
|
||||
# (pCompare.y + pCompare.yEnd + PUSH_GAP),内层从头重跑,单条最多下推 MAX_ITERS 次。
|
||||
# pCompare 用的是「已排布后」的 y(与参考端共用一张链表、y 就地改写一致)。
|
||||
static func arrange_item_tails(tails: Array) -> Array:
|
||||
var ys: Array = []
|
||||
for t in tails:
|
||||
ys.append(float(t["y"]))
|
||||
var n := tails.size()
|
||||
for i in n:
|
||||
var limit := 0
|
||||
var j := 0
|
||||
while j < n:
|
||||
if j == i:
|
||||
j += 1
|
||||
continue
|
||||
if limit >= MAX_ITERS:
|
||||
break
|
||||
var a := {"x": tails[i]["x"], "y": ys[i], "w": tails[i]["w"], "h": tails[i]["h"]}
|
||||
var b := {"x": tails[j]["x"], "y": ys[j], "w": tails[j]["w"], "h": tails[j]["h"]}
|
||||
if boxes_overlap(a, b):
|
||||
ys[i] = ys[j] + box_end_y(tails[j]["h"]) + PUSH_GAP
|
||||
j = 0
|
||||
limit += 1
|
||||
continue
|
||||
j += 1
|
||||
return ys
|
||||
@@ -0,0 +1,152 @@
|
||||
# text_tail_arrange_test —— §8.8 seam ⑩:ArrangeTextTail item 尾标去重叠 + isIn 1:1。
|
||||
# boxes_overlap(isIn AABB,含边界)、box_* 四边(-w/2-2 / w/2+2 / -2 / h)、
|
||||
# arrange_item_tails(单条不动 / 两条不叠不动 / 两条叠 -> 后者顶到前者框底下 /
|
||||
# 级联下推 / 病态全同位收敛在 MAX_ITERS 内)、
|
||||
# item_box_with_owner(增量 125:无 owner 退化 / 有 owner -> h += ownerH + 4、
|
||||
# owner 更宽时 w = ownerW - 2 使 box_end_x == ownerW/2 + 1)。
|
||||
# godot --headless --path project --script text_tail_arrange_test.gd
|
||||
# 退出码 0 = 全过。
|
||||
extends SceneTree
|
||||
|
||||
const TextTailArrange = preload("res://text_tail_arrange.gd")
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _feq(a: float, b: float, m: String) -> void:
|
||||
_ck(absf(a - b) < 0.0001, "%s (got %f want %f)" % [m, a, b])
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: text_tail_arrange_test (§8.8 seam ⑩ ArrangeTextTail item 尾标去重叠 + isIn 1:1)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
# --- 0. 常量 -------------------------------------------------------------
|
||||
_feq(TextTailArrange.PUSH_GAP, 5.0, "PUSH_GAP = yTemp = 5(:164)")
|
||||
_feq(TextTailArrange.MAX_ITERS, 20.0, "MAX_ITERS = LimitCount 上限 20(:176)")
|
||||
|
||||
# --- 1. 框四边(PythonTextTail.cpp:810-813)----------------------------
|
||||
_feq(TextTailArrange.box_start_x(40.0), -40.0 / 2.0 - 2.0, "xStart = -w/2 - 2 = -22")
|
||||
_feq(TextTailArrange.box_end_x(40.0), 40.0 / 2.0 + 2.0, "xEnd = w/2 + 2 = 22")
|
||||
_feq(TextTailArrange.box_start_y(), -2.0, "yStart = -2")
|
||||
_feq(TextTailArrange.box_end_y(16.0), 16.0, "yEnd = h = 16")
|
||||
|
||||
# --- 2. boxes_overlap(isIn,:489-507)-------------------------------
|
||||
var a := {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0} # x∈[78,122] y∈[98,116]
|
||||
var b_hit := {"x": 110.0, "y": 105.0, "w": 40.0, "h": 16.0} # x∈[88,132] y∈[103,121]
|
||||
_ck(TextTailArrange.boxes_overlap(a, b_hit), "x、y 都交叠 -> 相交")
|
||||
_ck(TextTailArrange.boxes_overlap(b_hit, a), "isIn 对称")
|
||||
# 边界相接:inclusive <= / >= -> 视为相交
|
||||
var b_edge := {"x": 100.0 + 44.0, "y": 100.0, "w": 40.0, "h": 16.0} # x1T = 144-22 = 122 == x2S
|
||||
_ck(TextTailArrange.boxes_overlap(a, b_edge), "边界相接(x2S == x1T)含边界 -> 相交")
|
||||
# x 完全分离
|
||||
var b_farx := {"x": 100.0 + 45.0, "y": 100.0, "w": 40.0, "h": 16.0} # x1T = 145-22 = 123 > 122
|
||||
_ck(not TextTailArrange.boxes_overlap(a, b_farx), "x 分离 -> 不相交")
|
||||
# y 完全分离
|
||||
var b_fary := {"x": 100.0, "y": 100.0 + 40.0, "w": 40.0, "h": 16.0} # y1T = 140-2 = 138 > 116
|
||||
_ck(not TextTailArrange.boxes_overlap(a, b_fary), "y 分离 -> 不相交")
|
||||
|
||||
# --- 3. arrange_item_tails ---------------------------------------------
|
||||
# 单条:不动
|
||||
var one := [{"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}]
|
||||
_feq(TextTailArrange.arrange_item_tails(one)[0], 100.0, "单条 -> y 不变")
|
||||
|
||||
# 两条不叠:都不动
|
||||
var two_far := [
|
||||
{"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0},
|
||||
{"x": 100.0, "y": 300.0, "w": 40.0, "h": 16.0},
|
||||
]
|
||||
var r_far := TextTailArrange.arrange_item_tails(two_far)
|
||||
_feq(r_far[0], 100.0, "不叠 -> 第 1 条不变")
|
||||
_feq(r_far[1], 300.0, "不叠 -> 第 2 条不变")
|
||||
|
||||
# 两条叠:外层先处理 i=0 —— 与 j=1 相交,把「自己」(第 1 条)顶到第 2 条框底下。
|
||||
# ys[0] = ys[1] + yEnd(第2条=16) + PUSH_GAP(5) = 100 + 16 + 5 = 121;ys[1] 不变。
|
||||
# 再看 i=1:此时第 1 条已在 y=121(x∈[78,122] y∈[119,137]),第 2 条 y=100
|
||||
# (y∈[98,116])——115? 119 > 116 -> 不再相交,第 2 条不动。
|
||||
var two_hit := [
|
||||
{"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0},
|
||||
{"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0},
|
||||
]
|
||||
var r_hit := TextTailArrange.arrange_item_tails(two_hit)
|
||||
_feq(r_hit[0], 100.0 + 16.0 + 5.0, "两条同位 -> 第 1 条被顶到 121")
|
||||
_feq(r_hit[1], 100.0, "两条同位 -> 第 2 条留原位")
|
||||
_ck(not TextTailArrange.boxes_overlap(
|
||||
{"x": 100.0, "y": r_hit[0], "w": 40.0, "h": 16.0},
|
||||
{"x": 100.0, "y": r_hit[1], "w": 40.0, "h": 16.0}),
|
||||
"排布后两框不再相交")
|
||||
|
||||
# 三条同位。逐条各自移动自己(参考端每个 pInsert 只改自己的 y):
|
||||
# i=0:与 1 撞 -> ys[0] = 100 + 16 + 5 = 121;与 2(100)此时已不撞(121 的框 y∈[119,137])。
|
||||
# i=1:与 2(100)撞 -> 121;再与已排好的 0(121)撞 -> 121 + 21 = 142。
|
||||
# i=2:0 在 121、1 在 142,都在 100 上方(y 更大 = 屏幕更下),100 的槽已空出 -> 第 3 条不动。
|
||||
# 结果 [121, 142, 100]:三框互不相交(100→[98,116]、121→[119,137]、142→[140,158])。
|
||||
var three := [
|
||||
{"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0},
|
||||
{"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0},
|
||||
{"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0},
|
||||
]
|
||||
var r3 := TextTailArrange.arrange_item_tails(three)
|
||||
_feq(r3[0], 121.0, "三条同位 -> 第 1 条 121")
|
||||
_feq(r3[1], 142.0, "三条同位 -> 第 2 条 142")
|
||||
_feq(r3[2], 100.0, "三条同位 -> 第 3 条留原位(前两条已让开)")
|
||||
# 两两不再相交
|
||||
for p in [[0, 1], [0, 2], [1, 2]]:
|
||||
_ck(not TextTailArrange.boxes_overlap(
|
||||
{"x": 100.0, "y": r3[p[0]], "w": 40.0, "h": 16.0},
|
||||
{"x": 100.0, "y": r3[p[1]], "w": 40.0, "h": 16.0}),
|
||||
"三条排布后 %d/%d 不相交" % p)
|
||||
|
||||
# 病态:很多条全同位 —— 单条被下推次数封顶 MAX_ITERS,函数必然返回(不死循环)。
|
||||
var many: Array = []
|
||||
for k in 40:
|
||||
many.append({"x": 0.0, "y": 0.0, "w": 30.0, "h": 12.0})
|
||||
var rm := TextTailArrange.arrange_item_tails(many)
|
||||
_ck(rm.size() == 40, "40 条全同位 -> 正常返回 40 个 y(未死循环)")
|
||||
# 第 1 条只需 1 次下推就让开其余全部(它们都还在 y=0):0 + 12 + 5 = 17。
|
||||
_feq(rm[0], 17.0, "第 1 条一次下推到 17")
|
||||
# 靠后的条目下推次数封顶 MAX_ITERS:每次至多 +(12 + 5)= 17 -> <= 340。
|
||||
for k in 40:
|
||||
_ck(rm[k] <= (12.0 + 5.0) * TextTailArrange.MAX_ITERS + 0.001,
|
||||
"第 %d 条下推量不超过 MAX_ITERS * (h + gap) = 340" % (k + 1))
|
||||
|
||||
# --- 4. 列表顺序即 m_ItemTextTailList 顺序:靠前的先让位(各自移动自己)-------
|
||||
var ordered := [
|
||||
{"x": 50.0, "y": 200.0, "w": 20.0, "h": 10.0},
|
||||
{"x": 50.0, "y": 203.0, "w": 20.0, "h": 10.0},
|
||||
]
|
||||
var ro := TextTailArrange.arrange_item_tails(ordered)
|
||||
_ck(ro[0] > ro[1], "靠前的条目被顶到靠后条目下方(参考端各 pInsert 移动自己)")
|
||||
_feq(ro[1], 203.0, "靠后的条目保持原位")
|
||||
|
||||
# --- 5. item_box_with_owner(SetItemTextTailOwner, PythonTextTail.cpp:706-737)------
|
||||
# 无 owner(owner_w / owner_h <= 0)-> 退化为纯名字框
|
||||
var b0 := TextTailArrange.item_box_with_owner(40.0, 16.0)
|
||||
_feq(b0["w"], 40.0, "无 owner -> w = name_w")
|
||||
_feq(b0["h"], 16.0, "无 owner -> h = name_h")
|
||||
_feq(TextTailArrange.item_box_with_owner(40.0, 16.0, 0.0, 12.0)["h"], 16.0, "owner_w<=0 -> 退化")
|
||||
_feq(TextTailArrange.item_box_with_owner(40.0, 16.0, 30.0, 0.0)["h"], 16.0, "owner_h<=0 -> 退化")
|
||||
# 有 owner:yEnd += ownerH + 4(:735)
|
||||
var bo := TextTailArrange.item_box_with_owner(40.0, 16.0, 30.0, 12.0)
|
||||
_feq(bo["h"], 16.0 + 12.0 + 4.0, "有 owner -> h = name_h + owner_h + 4 = 32")
|
||||
_feq(bo["w"], 40.0, "owner 比名字窄 -> w 仍 = name_w")
|
||||
_feq(TextTailArrange.box_end_x(bo["w"]), 40.0 / 2.0 + 2.0, "owner 窄 -> box_end_x(w) == nameW/2 + 2")
|
||||
# owner 比名字宽 -> box_end_x 收进 fMAX(nameW/2+2, ownerW/2+1)(:737)-> w = owner_w - 2
|
||||
var bw := TextTailArrange.item_box_with_owner(20.0, 16.0, 50.0, 12.0)
|
||||
_feq(bw["w"], 50.0 - 2.0, "owner 更宽 -> w = owner_w - 2 = 48")
|
||||
_feq(TextTailArrange.box_end_x(bw["w"]), 50.0 / 2.0 + 1.0, "-> box_end_x(w) == ownerW/2 + 1(参考 :737 fMAX)")
|
||||
# 顶到带 owner 的框底下时 push = yEnd + PUSH_GAP
|
||||
var stacked := [
|
||||
{"x": 0.0, "y": 0.0, "w": 20.0, "h": 16.0},
|
||||
{"x": 0.0, "y": 0.0, "w": bo["w"], "h": bo["h"]},
|
||||
]
|
||||
_feq(TextTailArrange.arrange_item_tails(stacked)[0], bo["h"] + TextTailArrange.PUSH_GAP,
|
||||
"靠前条被顶到带 owner 框底下 = (name_h + owner_h + 4) + PUSH_GAP = 37")
|
||||
+102
-4
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -10,20 +10,58 @@ extends Node
|
||||
|
||||
const PICKUP_RANGE := 3.0
|
||||
|
||||
# §8.8 seam ⑩(CPythonTextTail::ArrangeTextTail, PythonTextTail.cpp:153-190):多条掉落
|
||||
# 名条投影到屏幕后互相挤开防重叠。参考端在屏幕像素空间跑(y 向下),poc 每物一个
|
||||
# billboard Label3D —— 把名条世界锚点投影到屏幕、按 Label3D 的真实字体度量取文本框,跑
|
||||
# text_tail_arrange.arrange_item_tails 得到每条被下推的屏幕 y,再按 Label3D 的
|
||||
# pixel_size 折回世界 y(屏幕向下 Δpx → 世界向下 Δpx*pixel_size)。
|
||||
# 文本框宽 / 高取名条 Label3D 的真实字体度量(TextMetrics.label_width / label_height
|
||||
# = GetTextSize 的 w / h,1:1,增量 123 / 124;TAG_CHAR_W_PX / TAG_LINE_H_PX 仅作无字体兜底)。
|
||||
# 增量 125(SetItemTextTailOwner, PythonTextTail.cpp:706-737):有 owner 的掉落,参考端
|
||||
# owner 是与名字分开的 pOwnerTextInstance,单独 GetTextSize,令去重叠框 yEnd += ownerH + 4、
|
||||
# x 两边按 owner 半宽 ±1 外扩 —— 去重叠框把「名字」「owner + 所有格」两子串分别用 tag 的
|
||||
# 字体度量后走 TextTailArrange.item_box_with_owner 合成(框度量 1:1)。
|
||||
# 增量 126(SetItemTextTailOwner :712-745 + ArrangeTextTail :191-207):owner 后缀不再拼进
|
||||
# 名字串,而是 drop 节点下第二个 Label3D "owner_tag" —— CENTER 对齐、黄字 (1,1,0)、文本
|
||||
# = owner + strOwnership(:722,本地化语素为空时字面 "'s",经 setup(possessive) 注入);
|
||||
# owner 落在尾标基点、名字实例下移 15px(:200 pTextInstance->SetPosition(x, y + 15.0f, z));
|
||||
# owner 为空时删 owner_tag(:741-745)。名字色仍走 _process 远近着色(参考名字实例取
|
||||
# pInsertTextTail->Color 的道具品阶色)。
|
||||
const TextTailArrange = preload("res://text_tail_arrange.gd")
|
||||
const TextMetrics = preload("res://text_metrics.gd")
|
||||
const TAG_BASE_Y := 0.75 # 名条相对掉落物的默认局部 y(未重叠时)
|
||||
const TAG_CHAR_W_PX := 7.0 # 无字体时的每字符估宽兜底(有字体走 GetTextSize 的 w)
|
||||
const TAG_LINE_H_PX := 15.0 # 无字体时的单行估高兜底(有字体走 GetTextSize 的 h)
|
||||
const OWNER_POSSESSIVE := "'s" # SetItemTextTailOwner :722 strOwnership(IDS_POSSESSIVE_MORPHENE 为空时的字面值)
|
||||
const OWNER_NAME_GAP_PX := 15.0 # ArrangeTextTail :200 pTextInstance->SetPosition(x, y + 15.0f, z):owner 行下方的名字行偏移
|
||||
const DEFAULT_ITEM_COLOR := Color(1.0, 0.9, 0.5)
|
||||
const ITEM_GRADE_COLORS := {
|
||||
0: Color(1.0, 1.0, 1.0),
|
||||
1: Color(0.55, 0.85, 1.0),
|
||||
2: Color(1.0, 0.85, 0.25),
|
||||
3: Color(0.85, 0.45, 1.0),
|
||||
4: Color(1.0, 0.35, 0.35),
|
||||
}
|
||||
|
||||
var client: Node
|
||||
var mount: Node3D
|
||||
var proto: Node
|
||||
var item_list: RefCounted
|
||||
var _player_getter: Callable
|
||||
var _camera_getter: Callable
|
||||
var _by_vid := {} # vid -> Node3D
|
||||
var _possessive := OWNER_POSSESSIVE # setup(possessive) 注入;空 -> 字面 "'s"(SetItemTextTailOwner :722)
|
||||
|
||||
func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
|
||||
proto_node: Node = null, il: RefCounted = null) -> void:
|
||||
proto_node: Node = null, il: RefCounted = null,
|
||||
camera_getter: Callable = Callable(), possessive := "") -> void:
|
||||
client = m2client
|
||||
_possessive = possessive if possessive != "" else OWNER_POSSESSIVE # :722 == "" ? "'s" : loc
|
||||
mount = mount_node
|
||||
proto = proto_node
|
||||
item_list = il
|
||||
_player_getter = player_getter
|
||||
_camera_getter = camera_getter
|
||||
if client.has_signal("ground_item_added"):
|
||||
client.ground_item_added.connect(_on_added)
|
||||
if client.has_signal("ground_item_removed"):
|
||||
@@ -39,10 +77,17 @@ func _on_added(d: Dictionary) -> void:
|
||||
return
|
||||
var vnum := int(d.get("vnum", 0))
|
||||
var owner := String(d.get("owner", ""))
|
||||
var item_color := _item_color(vnum, d)
|
||||
if _by_vid.has(vid):
|
||||
var existing: Node3D = _by_vid[vid]
|
||||
if is_instance_valid(existing):
|
||||
(existing.get_node("tag") as Label3D).text = _tag_text(vnum, owner)
|
||||
var existing_tag := existing.get_node("tag") as Label3D
|
||||
existing_tag.text = _name_for(vnum)
|
||||
existing_tag.modulate = item_color
|
||||
existing.set_meta("item_name", existing_tag.text)
|
||||
existing.set_meta("item_owner", owner)
|
||||
existing.set_meta("item_color", item_color)
|
||||
_apply_owner_tag(existing, owner, item_color)
|
||||
return
|
||||
var node := Node3D.new()
|
||||
node.name = "drop_%d" % vid
|
||||
@@ -60,14 +105,18 @@ func _on_added(d: Dictionary) -> void:
|
||||
node.add_child(mesh)
|
||||
var tag := Label3D.new()
|
||||
tag.name = "tag"
|
||||
tag.text = _tag_text(vnum, owner)
|
||||
tag.position.y = 0.75
|
||||
tag.text = _name_for(vnum) # 名字实例只放名字,owner 走独立 owner_tag(:723 不再拼串)
|
||||
tag.position.y = TAG_BASE_Y
|
||||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
tag.no_depth_test = true
|
||||
tag.pixel_size = 0.005
|
||||
tag.modulate = Color(1.0, 0.9, 0.5)
|
||||
tag.modulate = item_color
|
||||
node.add_child(tag)
|
||||
node.set_meta("vid", vid)
|
||||
node.set_meta("item_name", tag.text)
|
||||
node.set_meta("item_owner", owner)
|
||||
node.set_meta("item_color", item_color)
|
||||
_apply_owner_tag(node, owner, item_color)
|
||||
mount.add_child(node)
|
||||
_by_vid[vid] = node
|
||||
|
||||
@@ -98,6 +147,67 @@ func _process(dt: float) -> void:
|
||||
# 近了名条高亮
|
||||
var near := p.global_position.distance_to(n.global_position) <= PICKUP_RANGE
|
||||
(n.get_node("tag") as Label3D).modulate = Color(0.4, 1.0, 0.4) if near else Color(1.0, 0.9, 0.5)
|
||||
_arrange_tags()
|
||||
|
||||
# ArrangeTextTail :153-190 的 item 块:把所有掉落名条投影到屏幕,跑 isIn 去重叠,
|
||||
# 再把被下推的屏幕 Δy 折回各 Label3D 的世界局部 y。无相机 / 少于 2 条时全部回默认。
|
||||
func _arrange_tags() -> void:
|
||||
var cam: Camera3D = _camera_getter.call() if _camera_getter.is_valid() else null
|
||||
if cam == null:
|
||||
return
|
||||
# m_ItemTextTailList 顺序:按 vid 排序保证稳定(≈ 注册先后)。
|
||||
var vids: Array = _by_vid.keys()
|
||||
vids.sort()
|
||||
var tags: Array = [] # 与 boxes 平行:{tag, base_screen_y}
|
||||
var boxes: Array = []
|
||||
for vid in vids:
|
||||
var n: Node3D = _by_vid[vid]
|
||||
if not is_instance_valid(n):
|
||||
continue
|
||||
var tag := n.get_node_or_null("tag") as Label3D
|
||||
if tag == null:
|
||||
continue
|
||||
var anchor := n.global_position + Vector3(0, TAG_BASE_Y, 0)
|
||||
if cam.is_position_behind(anchor):
|
||||
_place_tail(n, tag, 0.0)
|
||||
continue
|
||||
var sp := cam.unproject_position(anchor)
|
||||
tags.append({"n": n, "tag": tag, "y0": sp.y})
|
||||
# 名字子串 + owner 子串分开度量(SetItemTextTailOwner 的 pOwnerTextInstance),
|
||||
# 再走 item_box_with_owner 合成去重叠框(yEnd += ownerH + 4)。
|
||||
var iname := String(n.get_meta("item_name", tag.text))
|
||||
var iowner := String(n.get_meta("item_owner", ""))
|
||||
var owner_txt := (iowner + _possessive) if iowner != "" else ""
|
||||
var nbox: Dictionary = TextTailArrange.item_box_with_owner(
|
||||
TextMetrics.string_width_like(tag, iname, TAG_CHAR_W_PX),
|
||||
TextMetrics.string_height_like(tag, iname, TAG_LINE_H_PX),
|
||||
TextMetrics.string_width_like(tag, owner_txt, TAG_CHAR_W_PX),
|
||||
TextMetrics.string_height_like(tag, owner_txt, TAG_LINE_H_PX))
|
||||
boxes.append({
|
||||
"x": sp.x, "y": sp.y,
|
||||
"w": maxf(1.0, nbox["w"]),
|
||||
"h": maxf(1.0, nbox["h"]),
|
||||
})
|
||||
if boxes.size() < 2:
|
||||
if boxes.size() == 1:
|
||||
_place_tail(tags[0]["n"], tags[0]["tag"], 0.0)
|
||||
return
|
||||
var arranged: Array = TextTailArrange.arrange_item_tails(boxes)
|
||||
for i in tags.size():
|
||||
var d_px: float = arranged[i] - float(tags[i]["y0"]) # 屏幕向下为正
|
||||
_place_tail(tags[i]["n"], tags[i]["tag"], d_px)
|
||||
|
||||
# ArrangeTextTail :191-207:owner 存在时 owner 实例落在尾标基点 (x, y)、名字实例下移 15px
|
||||
# (:200 pTextInstance->SetPosition(x, y + 15.0f, z));无 owner 时名字实例落在基点 (:206)。
|
||||
# d_px = 去重叠把该尾标下推的屏幕像素(0 = 未被推)。屏幕向下 → 世界向下(* pixel_size)。
|
||||
func _place_tail(n: Node3D, tag: Label3D, d_px: float) -> void:
|
||||
var px: float = tag.pixel_size
|
||||
var owner_tag := n.get_node_or_null("owner_tag") as Label3D
|
||||
if owner_tag != null:
|
||||
owner_tag.position.y = TAG_BASE_Y - d_px * px
|
||||
tag.position.y = TAG_BASE_Y - (d_px + OWNER_NAME_GAP_PX) * px
|
||||
else:
|
||||
tag.position.y = TAG_BASE_Y - d_px * px
|
||||
|
||||
# 捡最近的一个(范围内)。返回捡的 vid,0 = 没有。
|
||||
func try_pickup() -> int:
|
||||
@@ -145,6 +255,65 @@ func _name_for(vnum: int) -> String:
|
||||
return item_list.type_of(vnum)
|
||||
return "item %d" % vnum
|
||||
|
||||
func _tag_text(vnum: int, owner: String) -> String:
|
||||
var text := _name_for(vnum)
|
||||
return "%s (%s)" % [text, owner] if not owner.is_empty() else text
|
||||
# SetItemTextTailOwner :712-745:owner 非空 → 一个独立的 CENTER 黄字 (1,1,0) text instance,
|
||||
# 文本 = owner + strOwnership(:722/:723);owner 为空 → 删该实例(:741-745)。
|
||||
# poc:drop 节点下第二个 Label3D "owner_tag"(渲染顺序在名字之后,:373)。
|
||||
func _apply_owner_tag(node: Node3D, owner: String, item_color := DEFAULT_ITEM_COLOR) -> void:
|
||||
var owner_tag := node.get_node_or_null("owner_tag") as Label3D
|
||||
if owner.is_empty():
|
||||
if owner_tag != null:
|
||||
node.remove_child(owner_tag)
|
||||
owner_tag.queue_free()
|
||||
return
|
||||
if owner_tag == null:
|
||||
owner_tag = Label3D.new()
|
||||
owner_tag.name = "owner_tag"
|
||||
owner_tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER # :727 HORIZONTAL_ALIGN_CENTER
|
||||
owner_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
owner_tag.no_depth_test = true
|
||||
owner_tag.pixel_size = 0.005
|
||||
owner_tag.position.y = TAG_BASE_Y
|
||||
node.add_child(owner_tag)
|
||||
owner_tag.modulate = item_color # :729 的 pInsertTextTail->Color
|
||||
owner_tag.text = owner + _possessive
|
||||
|
||||
func _item_color(vnum: int, drop: Dictionary) -> Color:
|
||||
# GC_ITEM_GROUND_ADD 只带 vnum/owner/pos;优先消费服务端或 fixture 显式携带
|
||||
# 的颜色,其次消费 item proto 的扩展字段。老数据没有品阶字段时保留旧的
|
||||
# 金色 fallback,而不会把 owner 行错误固定成纯黄。
|
||||
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
|
||||
for source in [drop, pd]:
|
||||
for key in ["item_color", "rarity_color", "name_color", "color"]:
|
||||
if source.has(key):
|
||||
var parsed: Variant = _coerce_color(source[key])
|
||||
if parsed != null:
|
||||
return parsed
|
||||
for key in ["item_grade", "rarity", "quality", "grade"]:
|
||||
if source.has(key):
|
||||
var grade := int(source[key])
|
||||
if ITEM_GRADE_COLORS.has(grade):
|
||||
return ITEM_GRADE_COLORS[grade]
|
||||
return DEFAULT_ITEM_COLOR
|
||||
|
||||
func _coerce_color(value: Variant) -> Variant:
|
||||
if value is Color:
|
||||
return value
|
||||
if value is Array and value.size() >= 3:
|
||||
var scale := 255.0 if float(value[0]) > 1.0 or float(value[1]) > 1.0 \
|
||||
or float(value[2]) > 1.0 else 1.0
|
||||
var alpha := float(value[3]) if value.size() > 3 else 1.0
|
||||
if alpha > 1.0:
|
||||
alpha /= 255.0
|
||||
return Color(float(value[0]) / scale, float(value[1]) / scale,
|
||||
float(value[2]) / scale, alpha)
|
||||
if value is Dictionary and value.has_all(["r", "g", "b"]):
|
||||
var scale := 255.0 if float(value["r"]) > 1.0 or float(value["g"]) > 1.0 \
|
||||
or float(value["b"]) > 1.0 else 1.0
|
||||
var alpha := float(value.get("a", 1.0))
|
||||
if alpha > 1.0:
|
||||
alpha /= 255.0
|
||||
return Color(float(value["r"]) / scale, float(value["g"]) / scale,
|
||||
float(value["b"]) / scale, alpha)
|
||||
if value is String and not String(value).strip_edges().is_empty():
|
||||
return Color.from_string(String(value), DEFAULT_ITEM_COLOR)
|
||||
return null
|
||||
|
||||
+260
-19
@@ -33,6 +33,9 @@ signal map_signals_cleared()
|
||||
signal atlas_center_requested(x: int, y: int)
|
||||
signal camera_event_requested(kind: String, values: Array)
|
||||
signal fade_event_requested(kind: String, speed: float)
|
||||
signal camera_event_requested_full(kind: String, setting: Dictionary, blendtime: float)
|
||||
signal fade_event_requested_full(kind: String, speed: float)
|
||||
signal done_event()
|
||||
|
||||
var client: Node
|
||||
var proto: Node
|
||||
@@ -43,27 +46,204 @@ var _btnrow: VBoxContainer
|
||||
var _image_layer: Control
|
||||
var _confirm_pid := 0
|
||||
var _confirm_wait := false
|
||||
var _active_set
|
||||
var _active_commands: Array = []
|
||||
var _active_cursor := 0
|
||||
var _active_script := false
|
||||
|
||||
signal dungeon_result_requested(result: Dictionary)
|
||||
|
||||
func setup(m2client: Node, parent: Node, proto_node: Node = null, assets_root := "") -> void:
|
||||
client = m2client
|
||||
proto = proto_node
|
||||
_assets_root = assets_root if assets_root != "" else AssetRoot.path()
|
||||
set_process_unhandled_input(true)
|
||||
set_process(true)
|
||||
_build(parent)
|
||||
if client.has_signal("script_dialog"):
|
||||
client.script_dialog.connect(_on_script)
|
||||
if client.has_signal("quest_confirm_ask"):
|
||||
client.quest_confirm_ask.connect(_on_confirm)
|
||||
|
||||
func set_proto(proto_node: Node) -> void:
|
||||
# Proto is created after the dialog in GameScene because item/mob loading is
|
||||
# expensive. Keep the dialog usable before that point, then resolve future
|
||||
# [ITEM]/[MOB] tags against the real tables.
|
||||
proto = proto_node
|
||||
|
||||
func is_open() -> bool:
|
||||
return _root != null and _root.visible
|
||||
|
||||
# --- 收 GC_SCRIPT ------------------------------------------------------
|
||||
func _process(delta: float) -> void:
|
||||
if not _active_script or _active_set == null:
|
||||
return
|
||||
# The first headless frame may report a zero delta. ClientVS22 still runs
|
||||
# one UpdateEventSet tick, so use one nominal 60 FPS frame as the floor.
|
||||
_process_active(maxf(delta * 1000.0, 16.0))
|
||||
|
||||
func _on_script(_skin: int, text: String) -> void:
|
||||
var parsed := parse_script(text)
|
||||
if parsed.body.strip_edges() == "" and parsed.choices.is_empty():
|
||||
_emit_side_effects(parsed)
|
||||
# Deterministic entry point used by the frame loop and headless tests. The
|
||||
# argument is milliseconds because EventManager's lLastDelayTime/lWaitingTime
|
||||
# are millisecond values in ClientVS22.
|
||||
func process_events(elapsed_ms: float) -> void:
|
||||
if _active_script and _active_set != null:
|
||||
_process_active(maxf(0.0, elapsed_ms))
|
||||
|
||||
func begin_script(src: String) -> void:
|
||||
_active_set = EventSet.new()
|
||||
_active_set.init_event_set()
|
||||
_active_commands = _tokenize(src)
|
||||
_active_cursor = 0
|
||||
_active_script = true
|
||||
# RegisterEventSetFromString replaces a leading RUN_CINEMA event set with
|
||||
# the referenced .msc file. Keep the signal for the presentation layer and
|
||||
# load the event text when the loose asset is available.
|
||||
if not _active_commands.is_empty() and String(_active_commands[0].get("literal", "")) == "" \
|
||||
and String(_active_commands[0].get("name", "")).to_upper().trim_prefix("/") == "RUN_CINEMA":
|
||||
var cinema := _arg_s(String(_active_commands[0].get("tok", "")), "value")
|
||||
if cinema != "":
|
||||
cinema_requested.emit(cinema)
|
||||
var cinema_text := _read_script_file(cinema)
|
||||
if cinema_text != "":
|
||||
_active_commands = _tokenize(cinema_text)
|
||||
else:
|
||||
# No local .msc: keep the empty event set alive so the caller can
|
||||
# show its own cinema player, but do not re-process RUN_CINEMA.
|
||||
_active_commands = []
|
||||
_present_event_set(_active_set)
|
||||
_root.visible = true
|
||||
opened.emit()
|
||||
# Start the same nominal first tick immediately. This keeps the dialog
|
||||
# responsive even when a SceneTree caller resumes before child _process()
|
||||
# callbacks (notably in headless fixtures).
|
||||
_process_active(16.0)
|
||||
|
||||
func _read_script_file(vpath: String) -> String:
|
||||
var rel := vpath.replace("\\", "/")
|
||||
if rel.length() >= 2 and rel[1] == ":":
|
||||
rel = rel.substr(2)
|
||||
rel = rel.lstrip("/")
|
||||
var candidates: Array[String] = []
|
||||
if _assets_root != "":
|
||||
candidates.append(_assets_root.path_join(rel))
|
||||
var resolved := UiAssets._resolve(_assets_root, rel)
|
||||
if resolved != "":
|
||||
candidates.append(resolved)
|
||||
for path in candidates:
|
||||
if FileAccess.file_exists(path):
|
||||
return FileAccess.get_file_as_string(path)
|
||||
return ""
|
||||
|
||||
func skip() -> void:
|
||||
if not _active_script or _active_set == null:
|
||||
return
|
||||
# CPythonEventManager::Skip: an input lock is released; otherwise the
|
||||
# current delay is forced to expire and the next command is consumed.
|
||||
if _active_set.isLock:
|
||||
_active_set.lLastDelayTime = 0
|
||||
_active_set.isLock = false
|
||||
else:
|
||||
_active_set.lLastDelayTime = -1
|
||||
_process_active(0.0)
|
||||
|
||||
func end_event_process() -> void:
|
||||
if _active_set != null:
|
||||
_active_set.isWaitFlag = false
|
||||
|
||||
func _process_active(elapsed_ms: float) -> void:
|
||||
var remaining := elapsed_ms
|
||||
var forced_step: bool = _active_set.lLastDelayTime < 0
|
||||
if forced_step:
|
||||
_active_set.lLastDelayTime = 0
|
||||
while _active_script and _active_set != null:
|
||||
if _active_set.isConfirmWait:
|
||||
if int(Time.get_ticks_msec() / 1000) >= _active_set.iConfirmEndTime:
|
||||
_active_set.isConfirmWait = false
|
||||
_active_set.iConfirmEndTime = 0
|
||||
_confirm_wait = false
|
||||
if client and client.has_method("quest_cancel"):
|
||||
client.quest_cancel()
|
||||
_active_script = false
|
||||
close()
|
||||
else:
|
||||
_present_event_set(_active_set)
|
||||
return
|
||||
if _active_set.isWaitFlag or _active_set.isLock:
|
||||
_present_event_set(_active_set)
|
||||
return
|
||||
if _active_set.lLastDelayTime > 0:
|
||||
if remaining <= 0.0:
|
||||
return
|
||||
var wait := float(_active_set.lLastDelayTime)
|
||||
if remaining < wait:
|
||||
_active_set.lLastDelayTime -= int(remaining)
|
||||
return
|
||||
_active_set.lLastDelayTime = 0
|
||||
remaining -= wait
|
||||
if _active_cursor >= _active_commands.size():
|
||||
_active_script = false
|
||||
_active_set.isLock = true
|
||||
_present_event_set(_active_set)
|
||||
return
|
||||
var command: Dictionary = _active_commands[_active_cursor]
|
||||
_active_cursor += 1
|
||||
_process_event_set(_active_set, command)
|
||||
_emit_runtime_side_effect(command, _active_set)
|
||||
_present_event_set(_active_set)
|
||||
forced_step = false
|
||||
if _active_set.isWaitFlag or _active_set.isLock or _active_set.isConfirmWait:
|
||||
return
|
||||
if remaining <= 0.0:
|
||||
return
|
||||
|
||||
func _finish_runtime() -> void:
|
||||
_active_script = false
|
||||
_active_commands.clear()
|
||||
_active_cursor = 0
|
||||
|
||||
func _emit_runtime_side_effect(command: Dictionary, es: EventSet) -> void:
|
||||
var idx := _get_script_event_index(String(command.get("name", "")))
|
||||
if not idx.ok:
|
||||
return
|
||||
var tok := String(command.get("tok", ""))
|
||||
match int(idx.type):
|
||||
EVT_SELECT_ITEM:
|
||||
select_item_requested.emit()
|
||||
EVT_QUEST_BUTTON:
|
||||
if not es.quest_buttons.is_empty():
|
||||
var q: Dictionary = es.quest_buttons.back()
|
||||
quest_button_received.emit(int(q.get("index", -1)), String(q.get("title", "")),
|
||||
String(q.get("icon_type", "")), String(q.get("icon_name", "")))
|
||||
EVT_QUEST_BUTTON_CLOSE:
|
||||
quest_button_cleared.emit(_arg_i(tok, "idx", 0))
|
||||
EVT_ADD_MAP_SIGNAL:
|
||||
map_signal_added.emit(_arg_f(tok, "x", 0), _arg_f(tok, "y", 1))
|
||||
EVT_CLEAR_MAP_SIGNAL:
|
||||
map_signals_cleared.emit()
|
||||
EVT_SET_CENTER_MAP_POSITION:
|
||||
atlas_center_requested.emit(_arg_i(tok, "x", 0), _arg_i(tok, "y", 1))
|
||||
EVT_SET_CAMERA, EVT_BLEND_CAMERA, EVT_RESTORE_CAMERA:
|
||||
var camera: Dictionary = es.camera_events.back() if not es.camera_events.is_empty() else {}
|
||||
var kind := String(camera.get("kind", ""))
|
||||
camera_event_requested.emit(kind, camera.get("values", []))
|
||||
camera_event_requested_full.emit(kind, camera.get("setting", {}).duplicate(true),
|
||||
float(camera.get("blendtime", 0.0)))
|
||||
EVT_FADE_OUT, EVT_FADE_IN, EVT_WHITE_OUT, EVT_WHITE_IN:
|
||||
var fade: Dictionary = es.fade_events.back() if not es.fade_events.is_empty() else {}
|
||||
var fade_kind := String(fade.get("kind", ""))
|
||||
var fade_speed := float(fade.get("speed", 0.0))
|
||||
fade_event_requested.emit(fade_kind, fade_speed)
|
||||
fade_event_requested_full.emit(fade_kind, fade_speed)
|
||||
EVT_DUNGEON_RESULT:
|
||||
dungeon_result_requested.emit(es.dungeon_result.duplicate())
|
||||
EVT_END_CONFIRM_WAIT:
|
||||
_confirm_wait = false
|
||||
_:
|
||||
pass
|
||||
|
||||
func _present_event_set(es: EventSet) -> void:
|
||||
var parsed := _event_set_result(es)
|
||||
if parsed.body.strip_edges() == "" and parsed.choices.is_empty() and not parsed.has_input \
|
||||
and not parsed.confirm_wait and not _active_script:
|
||||
close()
|
||||
return
|
||||
_text.text = parsed.body
|
||||
@@ -77,10 +257,13 @@ func _on_script(_skin: int, text: String) -> void:
|
||||
_btnrow.custom_minimum_size = Vector2(width - 32, 0)
|
||||
_confirm_wait = parsed.confirm_wait
|
||||
_render_images(parsed.images, parsed.title_image)
|
||||
_fill_buttons(parsed.choices, parsed.has_next, parsed.has_input, _confirm_wait)
|
||||
_root.visible = true
|
||||
opened.emit()
|
||||
_emit_side_effects(parsed)
|
||||
_fill_buttons(parsed.choices, parsed.has_next, parsed.has_input, parsed.confirm_wait,
|
||||
String(parsed.get("next_button_type", "")))
|
||||
|
||||
# --- 收 GC_SCRIPT ------------------------------------------------------
|
||||
|
||||
func _on_script(_skin: int, text: String) -> void:
|
||||
begin_script(text)
|
||||
|
||||
func _emit_side_effects(parsed: Dictionary) -> void:
|
||||
if parsed.has_select_item:
|
||||
@@ -97,13 +280,20 @@ func _emit_side_effects(parsed: Dictionary) -> void:
|
||||
if parsed.atlas_center.size() >= 2:
|
||||
atlas_center_requested.emit(int(parsed.atlas_center[0]), int(parsed.atlas_center[1]))
|
||||
for camera in parsed.camera_events:
|
||||
camera_event_requested.emit(String(camera.get("kind", "")), camera.get("values", []))
|
||||
var kind := String(camera.get("kind", ""))
|
||||
camera_event_requested.emit(kind, camera.get("values", []))
|
||||
camera_event_requested_full.emit(kind, camera.get("setting", {}).duplicate(true),
|
||||
float(camera.get("blendtime", 0.0)))
|
||||
for fade in parsed.fade_events:
|
||||
fade_event_requested.emit(String(fade.get("kind", "")), float(fade.get("speed", 0.0)))
|
||||
var fade_kind := String(fade.get("kind", ""))
|
||||
var fade_speed := float(fade.get("speed", 0.0))
|
||||
fade_event_requested.emit(fade_kind, fade_speed)
|
||||
fade_event_requested_full.emit(fade_kind, fade_speed)
|
||||
if String(parsed.get("cinema_file", "")) != "":
|
||||
cinema_requested.emit(String(parsed.cinema_file))
|
||||
|
||||
func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void:
|
||||
_finish_runtime()
|
||||
_confirm_pid = request_pid
|
||||
_text.text = msg
|
||||
_fill_confirm()
|
||||
@@ -111,6 +301,7 @@ func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void:
|
||||
opened.emit()
|
||||
|
||||
func close() -> void:
|
||||
_finish_runtime()
|
||||
if _root:
|
||||
_root.visible = false
|
||||
_confirm_wait = false
|
||||
@@ -123,6 +314,10 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
|
||||
# The legacy client sends QUEST_CANCEL when the active script is dismissed
|
||||
# with Escape; ordinary answer buttons continue to use SCRIPT_ANSWER.
|
||||
if _active_script:
|
||||
skip()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if client and client.has_method("quest_cancel"):
|
||||
client.quest_cancel()
|
||||
close()
|
||||
@@ -144,9 +339,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
# 的 30 字断行——reference 脚本只有显式 [LETTER],裸文本是本客户端的扩展,逐字符断句会
|
||||
# 破坏服务器写好的句子)。
|
||||
#
|
||||
# seam(无逐帧 Update 循环):isLock/isWaitFlag/lLastDelayTime/lWaitingTime 作为 EventSet
|
||||
# 状态保留并透出,但一次性把剩余 token 跑完(保证按钮 / 选项 / 副作用仍然落地);Skip /
|
||||
# EndEventProcess / iConfirmEndTime 的 timeGetTime 走 seam。
|
||||
# EventSet 现在由 _process_active 按 ClientVS22::UpdateEventSet 的毫秒延迟逐步消费;
|
||||
# parse_script() 仍保留为离线兼容接口,用于一次性检查整个脚本的最终状态。
|
||||
|
||||
signal cinema_requested(filename: String)
|
||||
|
||||
@@ -224,6 +418,7 @@ class EventSet:
|
||||
var end_confirm := false
|
||||
var choices: Array[String] = []
|
||||
var has_next := false
|
||||
var next_button_type := ""
|
||||
var has_input := false
|
||||
var has_select_item := false
|
||||
var confirm_timeout := 0
|
||||
@@ -315,6 +510,7 @@ func parse_script(src: String) -> Dictionary:
|
||||
"body": es.body_text(),
|
||||
"choices": es.choices,
|
||||
"has_next": es.has_next,
|
||||
"next_button_type": es.next_button_type,
|
||||
"has_input": es.has_input,
|
||||
"has_select_item": es.has_select_item,
|
||||
"confirm_wait": es.isConfirmWait,
|
||||
@@ -342,6 +538,38 @@ func parse_script(src: String) -> Dictionary:
|
||||
"end_confirm": es.end_confirm,
|
||||
}
|
||||
|
||||
func _event_set_result(es: EventSet) -> Dictionary:
|
||||
return {
|
||||
"body": es.body_text(),
|
||||
"choices": es.choices,
|
||||
"has_next": es.has_next,
|
||||
"next_button_type": es.next_button_type,
|
||||
"has_input": es.has_input,
|
||||
"has_select_item": es.has_select_item,
|
||||
"confirm_wait": es.isConfirmWait,
|
||||
"confirm_timeout": es.confirm_timeout,
|
||||
"text_centered": es.isTextCenterMode,
|
||||
"window_size": es.window_size,
|
||||
"images": es.images,
|
||||
"title_image": es.title_image,
|
||||
"quest_buttons": es.quest_buttons,
|
||||
"quest_button_closes": es.quest_button_closes,
|
||||
"map_signals": es.map_signals,
|
||||
"clear_map_signals": es.clear_map_signals,
|
||||
"atlas_center": es.atlas_center,
|
||||
"camera_events": es.camera_events,
|
||||
"fade_events": es.fade_events,
|
||||
"insert_images": es.insert_images,
|
||||
"dungeon_result": es.dungeon_result,
|
||||
"adjust_line": es.iAdjustLine,
|
||||
"waiting_time": es.lWaitingTime,
|
||||
"is_locked": es.isLock,
|
||||
"wait_flag": es.isWaitFlag,
|
||||
"answer_count": es.nAnswer,
|
||||
"cinema_file": es.cinema_file,
|
||||
"end_confirm": es.end_confirm,
|
||||
}
|
||||
|
||||
# 把混排文本 / [tag ...] 拆成命令流。裸文本 -> { literal:String };
|
||||
# tag -> { name:String(大写,带/前缀), pos:int, tok:String(方括号内原文) }。
|
||||
func _tokenize(src: String) -> Array:
|
||||
@@ -411,10 +639,13 @@ func _process_event_set(es: EventSet, cmd: Dictionary) -> void:
|
||||
EVT_NEXT:
|
||||
# MakeNextButton(BUTTON_TYPE_NEXT); iAdjustLine += 2。
|
||||
es.has_next = true
|
||||
es.next_button_type = "NEXT"
|
||||
es.iAdjustLine += 2
|
||||
EVT_DONE:
|
||||
# MakeNextButton(BUTTON_TYPE_DONE); DoneEvent; iAdjustLine += 2。
|
||||
# MakeNextButton(BUTTON_TYPE_DONE); the reference invokes DoneEvent
|
||||
# from the button callback, not while the token is being parsed.
|
||||
es.has_next = true
|
||||
es.next_button_type = "DONE"
|
||||
es.iAdjustLine += 2
|
||||
EVT_LEFT_IMAGE:
|
||||
es.images.append({"panel": "LEFTIMAGE", "path": _arg_s(tok, "src")})
|
||||
@@ -501,7 +732,9 @@ func _process_event_set(es: EventSet, cmd: Dictionary) -> void:
|
||||
EVT_CONFIRM_WAIT:
|
||||
es.confirm_timeout = _arg_i(tok, "timeout", 0)
|
||||
es.isConfirmWait = true
|
||||
es.iConfirmEndTime = es.confirm_timeout # timeGetTime()/1000 + timeout(seam)
|
||||
# ClientVS22 stores the deadline in seconds (timeGetTime()/1000),
|
||||
# not the timeout duration itself.
|
||||
es.iConfirmEndTime = int(Time.get_ticks_msec() / 1000) + es.confirm_timeout
|
||||
es.insert_line(true)
|
||||
# MakeNextButton(BUTTON_TYPE_CANCEL) —— 取消按钮由 _fill_buttons(confirm_wait) 出。
|
||||
EVT_END_CONFIRM_WAIT:
|
||||
@@ -812,7 +1045,8 @@ func _clear_buttons() -> void:
|
||||
for c in _btnrow.get_children():
|
||||
c.queue_free()
|
||||
|
||||
func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false) -> void:
|
||||
func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false,
|
||||
next_button_type := "") -> void:
|
||||
_clear_buttons()
|
||||
if has_input:
|
||||
var input := LineEdit.new()
|
||||
@@ -831,9 +1065,16 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w
|
||||
close())
|
||||
return
|
||||
if choices.is_empty():
|
||||
var b := _mkbtn("继续" if has_next else "关闭")
|
||||
var is_done := next_button_type == "DONE"
|
||||
var b := _mkbtn("关闭" if is_done else ("继续" if has_next else "关闭"))
|
||||
b.pressed.connect(func():
|
||||
client.script_answer(255)
|
||||
if is_done:
|
||||
# MakeNextButton(BUTTON_TYPE_DONE) wires DoneEvent to the
|
||||
# actual click. Keep parse_script() side-effect free and only
|
||||
# notify consumers when the player confirms the DONE button.
|
||||
done_event.emit()
|
||||
if client and client.has_method("script_answer"):
|
||||
client.script_answer(255)
|
||||
close())
|
||||
return
|
||||
for i in choices.size():
|
||||
|
||||
Reference in New Issue
Block a user