feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复

- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+138 -28
View File
@@ -37,10 +37,13 @@ signal camera_event_requested_full(kind: String, setting: Dictionary, blendtime:
signal fade_event_requested_full(kind: String, speed: float)
signal done_event()
const MAX_CHOICES_PER_PAGE := 8
var client: Node
var proto: Node
var _assets_root := ""
var _root: Control
var _panel: Panel
var _text: RichTextLabel
var _btnrow: VBoxContainer
var _image_layer: Control
@@ -50,6 +53,10 @@ var _active_set
var _active_commands: Array = []
var _active_cursor := 0
var _active_script := false
var _current_skin := 0
var _current_left_w := 0.0
var _current_choice_page := 0
var _cached_choices: Array = []
signal dungeon_result_requested(result: Dictionary)
@@ -246,23 +253,32 @@ func _present_event_set(es: EventSet) -> void:
and not parsed.confirm_wait and not _active_script:
close()
return
if _panel:
# 40250 uiquest.py:328 - SKIN_CINEMA(5) and SKIN_NOWINDOW(1) hide background board
_panel.visible = (_current_skin != 1 and _current_skin != 5)
_text.text = parsed.body
_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER if parsed.text_centered else HORIZONTAL_ALIGNMENT_LEFT
var width := _root.size.x
var height := _root.size.y
if parsed.window_size.size() >= 2:
var width := maxi(320, int(parsed.window_size[0]))
var height := maxi(220, int(parsed.window_size[1]))
width = maxi(320, int(parsed.window_size[0]))
height = maxi(220, int(parsed.window_size[1]))
_root.size = Vector2(width, height)
_text.size = Vector2(width - 32, height - 100)
_btnrow.position = Vector2(16, height - 78)
_btnrow.custom_minimum_size = Vector2(width - 32, 0)
var left_w := _render_images(parsed.images, parsed.title_image)
_current_left_w = left_w
var content_w := maxf(200.0, width - 32.0 - left_w)
_text.position = Vector2(16.0 + left_w, 14.0)
_text.size = Vector2(content_w, height - 100.0)
_btnrow.position = Vector2(16.0 + left_w, height - 78.0)
_btnrow.custom_minimum_size = Vector2(content_w, 0)
_confirm_wait = parsed.confirm_wait
_render_images(parsed.images, parsed.title_image)
_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:
func _on_script(skin: int, text: String) -> void:
_current_skin = skin
begin_script(text)
func _emit_side_effects(parsed: Dictionary) -> void:
@@ -312,16 +328,20 @@ func _unhandled_input(event: InputEvent) -> void:
return
var key := event as InputEventKey
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()
var vp := get_viewport()
if vp:
vp.set_input_as_handled()
return
if client and client.has_method("quest_cancel"):
client.quest_cancel()
elif client and client.has_method("script_answer"):
client.script_answer(254)
close()
get_viewport().set_input_as_handled()
var vp := get_viewport()
if vp:
vp.set_input_as_handled()
# --- 脚本解析(CPythonEventManager 1:1----------------------------------
#
@@ -783,6 +803,30 @@ func _evt_color(es: EventSet, tok: String, pos: int, divisor: float) -> void:
# MakeQuestionPythonEventManager.cpp:884)。
func _make_question(es: EventSet, tok: String) -> void:
# 40250 官方标准格式:[QUESTION 1;选项一|2;选项二|...]
# 参考 40250 服务端 questlua.cpp:642 GotoSelectState 与 客户端 EterLib/parser.cpp:180
var space_idx := tok.find(" ")
var body := tok.substr(space_idx + 1).strip_edges() if space_idx >= 0 else ""
if body.contains("|") or (body.contains(";") and not body.contains("arg(") and not body.contains("value(")):
var parts := body.split("|", false)
var options: Array[String] = []
for p in parts:
var trimmed := p.strip_edges()
if trimmed.is_empty():
continue
var semi := trimmed.find(";")
if semi >= 0:
var label := trimmed.substr(semi + 1).strip_edges()
options.append(label)
else:
options.append(trimmed)
if not options.is_empty():
for opt in options:
es.choices.append(opt)
es.nAnswer = options.size()
return
# 回落至 legacy 格式:[QUESTION arg("A") arg("B")]
var args := _tag_strings(tok)
if args.is_empty():
return
@@ -985,6 +1029,7 @@ func _build(parent: Node) -> void:
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
_panel = panel
_image_layer = Control.new()
_image_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
_image_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -1001,11 +1046,13 @@ func _build(parent: Node) -> void:
_btnrow.add_theme_constant_override("separation", 4)
_root.add_child(_btnrow)
func _render_images(images: Array, title_image: String) -> void:
func _render_images(images: Array, title_image: String) -> float:
if _image_layer == null:
return
return 0.0
for child in _image_layer.get_children():
_image_layer.remove_child(child)
child.queue_free()
var left_w := 0.0
for spec in images:
var path := String(spec.get("path", ""))
var tex: Texture2D = UiAssets.load_tex(_assets_root, path)
@@ -1023,8 +1070,11 @@ func _render_images(images: Array, title_image: String) -> void:
image.position = Vector2(0, 0)
image.size = Vector2(_root.size.x, minf(84.0, tex.get_size().y))
elif panel_name == "LEFTIMAGE":
image.position = Vector2(0, 0)
image.size = Vector2(minf(150.0, tex.get_size().x), _root.size.y)
var img_w := minf(160.0, tex.get_size().x)
var img_h := minf(_root.size.y - 28.0, tex.get_size().y)
image.position = Vector2(10, 14)
image.size = Vector2(img_w, img_h)
left_w = maxf(left_w, img_w + 14.0)
else:
image.position = Vector2(float(spec.get("x", 0)), float(spec.get("y", 0)))
image.size = tex.get_size()
@@ -1040,9 +1090,11 @@ func _render_images(images: Array, title_image: String) -> void:
title.position = Vector2((_root.size.x - title_tex.get_size().x) * 0.5, 4)
title.size = title_tex.get_size()
_image_layer.add_child(title)
return left_w
func _clear_buttons() -> void:
for c in _btnrow.get_children():
_btnrow.remove_child(c)
c.queue_free()
func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false,
@@ -1050,18 +1102,27 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w
_clear_buttons()
if has_input:
var input := LineEdit.new()
input.placeholder_text = "请输入"
input.custom_minimum_size = Vector2(428, 28)
input.placeholder_text = "请输入内容"
input.custom_minimum_size = Vector2(maxf(200.0, 428.0 - _current_left_w), 28)
_btnrow.add_child(input)
var submit := _mkbtn("提交")
var submit := _mkbtn("确定")
submit.pressed.connect(func(): _submit_input(input))
input.text_submitted.connect(func(_text): _submit_input(input))
if input.is_inside_tree():
input.grab_focus()
else:
input.ready.connect(func():
if input.is_inside_tree():
input.grab_focus()
, CONNECT_ONE_SHOT)
return
if confirm_wait:
var cancel := _mkbtn("取消")
cancel.pressed.connect(func():
if client and client.has_method("quest_cancel"):
client.quest_cancel()
elif client and client.has_method("script_answer"):
client.script_answer(254)
close())
return
if choices.is_empty():
@@ -1069,24 +1130,72 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w
var b := _mkbtn("关闭" if is_done else ("继续" if has_next else "关闭"))
b.pressed.connect(func():
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)
client.script_answer(254)
close())
return
for i in choices.size():
_render_choices(choices, 0)
func _render_choices(choices: Array, page: int = 0) -> void:
_cached_choices = choices
_current_choice_page = page
_clear_buttons()
var total := choices.size()
if total == 0:
return
if total <= MAX_CHOICES_PER_PAGE:
for i in total:
var idx := i
var b := _mkbtn(String(choices[i]))
b.pressed.connect(func():
if client and client.has_method("script_answer"):
client.script_answer(idx)
close())
return
# Multi-page pagination (40250 uiquest.py parity)
var start_idx := page * MAX_CHOICES_PER_PAGE
var end_idx := mini(start_idx + MAX_CHOICES_PER_PAGE, total)
var total_pages := int(ceil(float(total) / float(MAX_CHOICES_PER_PAGE)))
for i in range(start_idx, end_idx):
var idx := i
var b := _mkbtn(String(choices[i]))
b.pressed.connect(func():
client.script_answer(idx)
if client and client.has_method("script_answer"):
client.script_answer(idx)
close())
var nav_row := HBoxContainer.new()
nav_row.alignment = BoxContainer.ALIGNMENT_CENTER
nav_row.add_theme_constant_override("separation", 12)
_btnrow.add_child(nav_row)
if page > 0:
var prev_btn := Button.new()
prev_btn.text = "上一页"
prev_btn.custom_minimum_size = Vector2(80, 26)
prev_btn.pressed.connect(func(): _render_choices(_cached_choices, page - 1))
nav_row.add_child(prev_btn)
var page_label := Label.new()
page_label.text = "%d / %d" % [page + 1, total_pages]
page_label.add_theme_font_size_override("font_size", 12)
nav_row.add_child(page_label)
if page < total_pages - 1:
var next_btn := Button.new()
next_btn.text = "下一页"
next_btn.custom_minimum_size = Vector2(80, 26)
next_btn.pressed.connect(func(): _render_choices(_cached_choices, page + 1))
nav_row.add_child(next_btn)
func _submit_input(input: LineEdit) -> void:
if client and client.has_method("quest_input") and client.quest_input(input.text):
close()
var val := input.text.strip_edges()
if client and client.has_method("quest_input"):
client.quest_input(val)
close()
func _fill_confirm() -> void:
_clear_buttons()
@@ -1099,13 +1208,14 @@ func _fill_confirm() -> void:
b.text = spec[0]
b.custom_minimum_size = Vector2(120, 30)
b.pressed.connect(func():
client.quest_confirm(yes, _confirm_pid)
if client and client.has_method("quest_confirm"):
client.quest_confirm(yes, _confirm_pid)
close())
row.add_child(b)
func _mkbtn(text: String) -> Button:
var b := Button.new()
b.text = text
b.custom_minimum_size = Vector2(428, 26)
b.custom_minimum_size = Vector2(maxf(200.0, 428.0 - _current_left_w), 26)
_btnrow.add_child(b)
return b