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:
+138
-28
@@ -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:
|
||||
|
||||
# MakeQuestion(PythonEventManager.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
|
||||
|
||||
Reference in New Issue
Block a user