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
+129 -12
View File
@@ -16,6 +16,8 @@ const UiAssets = preload("res://ui/ui_assets.gd")
const UiKit = preload("res://ui_kit.gd")
const PlayerView = preload("res://ui/player_view.gd")
const EquipModel = preload("res://ui/equip_model.gd")
const Audio = preload("res://audio.gd")
const EffectRegistry = preload("res://fx/effect_registry.gd")
const DESIGN_SIZE := Vector2(1472, 854)
signal select_requested(index: int)
@@ -68,11 +70,24 @@ var _rename_pending := false
var _layout: Control
var _board: Control
var _proto: Node
var _audio: Node
var _fxr: RefCounted
var _starting := false
var _btn_start: Button
var _btn_create: Button
var _btn_delete: Button
var _btn_quit: Button
var _nav_buttons: Array[TextureButton] = []
func setup(client: Node, assets_root: String, chars: Array) -> void:
_client = client
_assets = assets_root
_chars = _pad_slots(chars)
_audio = Audio.new()
_audio.setup(_assets)
add_child(_audio)
_fxr = EffectRegistry.new()
_fxr.setup(_assets)
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_background()
_build_stage()
@@ -179,13 +194,13 @@ func on_char_delete_failed() -> void:
# --- 背景 ---------------------------------------------------------------------
func _build_background() -> void:
func _add_background(parent: Node) -> void:
var tex := UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/select/select.sub")
var cr := ColorRect.new()
cr.color = Color(0.05, 0.06, 0.07)
cr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
cr.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(cr)
parent.add_child(cr)
if tex != null:
var tr := TextureRect.new()
tr.texture = tex
@@ -194,7 +209,10 @@ func _build_background() -> void:
# The original stretches the 1024x768 atlas region to the window.
tr.stretch_mode = TextureRect.STRETCH_SCALE
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(tr)
parent.add_child(tr)
func _build_background() -> void:
_add_background(self)
# --- 右侧 3D 角色 -----------------------------------------------------------
@@ -206,12 +224,27 @@ func _build_stage() -> void:
add_child(vpc)
_viewport = SubViewport.new()
_viewport.transparent_bg = true
# Refine effects use D3D-style SRC_ALPHA/ONE additive blending. Rendering
# them into a transparent texture first makes their black RGB texels increase
# the render target alpha; the later CanvasItem alpha composite then turns
# those invisible texels into large black rectangles. 40250 draws the select
# background before the character/effects, so reproduce that order inside an
# opaque viewport instead of trying to encode additive light in straight RGBA.
_viewport.transparent_bg = false
_viewport.own_world_3d = true
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_viewport.msaa_3d = Viewport.MSAA_4X
vpc.add_child(_viewport)
var background_canvas := CanvasLayer.new()
background_canvas.layer = -1
_viewport.add_child(background_canvas)
var background_root := Control.new()
background_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
background_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
background_canvas.add_child(background_root)
_add_background(background_root)
var root := Node3D.new()
_viewport.add_child(root)
@@ -228,8 +261,10 @@ func _build_stage() -> void:
var we := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0, 0, 0, 0)
# Canvas layer -1 is the stage backdrop and is rendered before 3D. Parent UI
# remains outside the SubViewport and is drawn afterward as before.
e.background_mode = Environment.BG_CANVAS
e.background_canvas_max_layer = -1
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.8, 0.8, 0.8)
e.ambient_light_energy = 0.65
@@ -248,6 +283,9 @@ func _build_stage() -> void:
_cam.position = Vector3(0.0, 1.0, 4.0)
root.add_child(_cam)
_cam.make_current()
var listener := AudioListener3D.new()
listener.make_current()
_cam.add_child(listener)
# 相机按模型包围球自适应:球心 + 半径/sin(fovV/2) 保证整模无论姿势都不出框,
# 再沿 -X 平移“半径的一小截”把人物推到右半屏(横向视锥比纵向宽,不会切到远侧手)。
@@ -309,6 +347,7 @@ func _rebuild_model() -> void:
_pivot.add_child(pv)
if pv.build(_assets, job, pump):
_pv = pv
pv._audio = _audio
if pv.model and pv.model.has_signal("visual_bounds_changed"):
pv.model.visual_bounds_changed.connect(_on_model_visual_bounds_changed.bind(pv))
if _proto == null and ClassDB.class_exists("Metin2Proto"):
@@ -485,33 +524,54 @@ func _refresh_panel() -> void:
start.text = "开始"
start.custom_minimum_size = Vector2(180, 22)
_style_button(start)
start.disabled = not has_char or empire_required
start.disabled = not has_char or empire_required or _starting
start.pressed.connect(_do_start)
_panel_box.add_child(start)
_panel_box.move_child(start, _panel_box.get_child_count() - 2)
start.visible = has_char
_btn_start = start
var create := Button.new()
create.text = "创建"
create.custom_minimum_size = Vector2(180, 22)
_style_button(create)
create.disabled = has_char or empire_required
create.disabled = has_char or empire_required or _starting
create.pressed.connect(_open_create_dialog)
_panel_box.add_child(create)
_panel_box.move_child(create, _panel_box.get_child_count() - 2)
create.visible = not has_char
_btn_create = create
var del := Button.new()
del.text = "删除"
del.custom_minimum_size = Vector2(86, 20)
_style_button(del)
del.disabled = not has_char or empire_required
del.disabled = not has_char or empire_required or _starting
del.pressed.connect(_open_delete_dialog)
brow.add_child(del)
_btn_delete = del
var quit := Button.new()
quit.text = "退出"
quit.custom_minimum_size = Vector2(86, 20)
_style_button(quit)
quit.pressed.connect(func(): back_requested.emit())
quit.disabled = _starting
quit.pressed.connect(func(): if not _starting: back_requested.emit())
brow.add_child(quit)
_btn_quit = quit
func _set_buttons_disabled(disabled: bool) -> void:
if _btn_start and is_instance_valid(_btn_start):
_btn_start.disabled = disabled
if _btn_create and is_instance_valid(_btn_create):
_btn_create.disabled = disabled
if _btn_delete and is_instance_valid(_btn_delete):
_btn_delete.disabled = disabled
if _btn_quit and is_instance_valid(_btn_quit):
_btn_quit.disabled = disabled
for b in _nav_buttons:
if b and is_instance_valid(b):
b.disabled = disabled
func _style_button(button: Button) -> void:
button.add_theme_font_size_override("font_size", 10)
@@ -536,6 +596,7 @@ func _style_button(button: Button) -> void:
button.add_theme_stylebox_override(state, style)
func _build_navigation() -> void:
_nav_buttons.clear()
for direction in [-1, 1]:
var button := TextureButton.new()
var side := "left" if direction < 0 else "right"
@@ -543,11 +604,13 @@ func _build_navigation() -> void:
button.texture_normal = UiAssets.load_tex(_assets, base + "01.sub")
button.texture_hover = UiAssets.load_tex(_assets, base + "02.sub")
button.texture_pressed = UiAssets.load_tex(_assets, base + "03.sub")
button.texture_disabled = UiAssets.load_tex(_assets, base + "01.sub")
button.position = Vector2(714 if direction < 0 else 1024, 712)
button.custom_minimum_size = Vector2(160, 44)
button.tooltip_text = "上一个角色" if direction < 0 else "下一个角色"
button.pressed.connect(func(): _select(wrapi(_sel + direction, 0, _chars.size())))
button.pressed.connect(func(): if not _starting: _select(wrapi(_sel + direction, 0, _chars.size())))
_layout.add_child(button)
_nav_buttons.append(button)
func _resize_layout() -> void:
if _layout:
@@ -581,6 +644,8 @@ func _set_status(t: String) -> void:
# --- 选择 / 循环 ----------------------------------------------------------
func _select(i: int) -> void:
if _starting:
return
if _chars.is_empty():
_sel = 0
_refresh_class_name()
@@ -596,6 +661,8 @@ func _select(i: int) -> void:
_set_status("已选:%s" % nm if nm != "" else "空槽位(点“创建”建号)")
func _do_start() -> void:
if _starting:
return
var d := _cur()
if d.is_empty() or String(d.get("name", "")) == "":
_set_status("空槽位不能进入")
@@ -605,8 +672,58 @@ func _do_start() -> void:
if bool(d.get("change_name", false)):
_open_change_name_dialog()
return
_starting = true
_set_buttons_disabled(true)
if _audio and _audio.has_method("play_sound"):
_audio.play_sound("sound/ui/click.wav")
if _audio.has_method("fade_out_bgm"):
_audio.fade_out_bgm(2.5)
_set_status("进入游戏:%s" % d.get("name"))
var anim_dur := 2.2
if _pv != null and is_instance_valid(_pv):
var intro: String = _pv.motion_dir.get_base_dir().path_join("intro/selected.msa")
if _pv.anim and FileAccess.file_exists(intro):
_pv.anim.set("loop", false)
_pv.anim.set("blend_time", 0.1)
_pv.anim.set("anim_path", intro)
_pv._refresh_sound_script(intro)
if _pv.anim.has_method("get_duration"):
var dur := float(_pv.anim.call("get_duration"))
if dur > 0.0:
anim_dur = dur
await get_tree().create_timer(anim_dur).timeout
if not is_inside_tree():
return
select_requested.emit(_slot_index())
_set_status("进入游戏:%s" % d.get("name"))
func _unhandled_input(event: InputEvent) -> void:
if _dialog != null and is_instance_valid(_dialog):
return
if not (event is InputEventKey) or not event.pressed or event.echo:
return
if _starting:
return
var ke := event as InputEventKey
match ke.keycode:
KEY_ENTER, KEY_KP_ENTER:
if not _cur().is_empty() and String(_cur().get("name", "")) != "":
_do_start()
else:
_open_create_dialog()
KEY_LEFT:
_select(wrapi(_sel - 1, 0, _chars.size()))
KEY_RIGHT:
_select(wrapi(_sel + 1, 0, _chars.size()))
KEY_ESCAPE:
back_requested.emit()
KEY_1, KEY_2, KEY_3, KEY_4:
var slot := ke.keycode - KEY_1
if slot < _chars.size():
_select(slot)
# --- 建号 / 删号弹窗 ------------------------------------------------------