Files
mtgodot-poc/project/ui/char_select_screen.gd
T
shenandshen 66d217b313 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 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

1032 lines
36 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CharSelectScreen —— Metin2 风格的选人界面。
#
# var s := preload("res://ui/char_select_screen.gd").new()
# parent.add_child(s)
# s.setup(m2client, assets_root, chars) # chars = M2Client.char_list 数组
# s.select_requested.connect(func(idx): client.select_character(idx))
# s.back_requested.connect(_goto_login)
#
# 组成:全屏背景(select.jpg+ 右侧 3D 角色(SubViewport 里的 PlayerView,缓转 + wait
# + 左侧信息板(thinboard:帮会徽 / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷)
# + 左上职业名(locale name_<class>.dds,解不出就用中文描边字)+ 底部 开始/创建/删除/退出。
# 所有素材都是可选的:缺哪个降级哪个,绝不崩。
extends Control
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)
signal delete_requested(index: int, private_code: String)
signal create_requested(spec: Dictionary) # {slot, name, job, shape, con, int, str, dex}
signal change_name_requested(index: int, name: String)
signal back_requested()
const CLASS_CN := ["猛将", "刺客", "术士", "巫女"]
const CLASS_DIR := ["warrior", "assassin", "sura", "shaman"]
# 建号默认四维(CON, INT, STR, DEX)—— 对齐经典 Metin2 各职业起始属性。
const JOB_BASE_STATS := {
0: {"con": 4, "int": 3, "str": 6, "dex": 3}, # warrior
1: {"con": 3, "int": 3, "str": 4, "dex": 6}, # assassin
2: {"con": 3, "int": 5, "str": 5, "dex": 3}, # sura
3: {"con": 4, "int": 6, "str": 3, "dex": 3}, # shaman
}
# §1.10 名称长度校验统一:与 wire 契约 CHARACTER_NAME_MAX_LEN(=24, name[25])
# 对齐。角色名可含多字节字符,wire 缓冲是定长 25 字节,故按 UTF-8 字节长度设限,
# 与 guild_ui.gd 的公会名校验(非空 / 超长 / 控制字符)保持同一形状。
const CHAR_NAME_MAX_BYTES := 24
const CHAR_NAME_MIN_CHARS := 2
const EMPIRE_KEY := {1: "EMPIRE_A", 2: "EMPIRE_B", 3: "EMPIRE_C"}
const EMPIRE_FALLBACK := {0: "—", 1: "神獸王國", 2: "天朝王國", 3: "眞魔王國"}
const EMPIRE_FLAG := {1: "a", 2: "b", 3: "c"}
const STAT_ROWS := [
{"key": "ht", "label": "体力", "color": Color(0.90, 0.30, 0.30)},
{"key": "iq", "label": "智力", "color": Color(0.85, 0.45, 0.95)},
{"key": "st", "label": "力量", "color": Color(0.70, 0.55, 1.00)},
{"key": "dx", "label": "敏捷", "color": Color(0.35, 0.75, 1.00)},
]
var _client: Node
var _assets := ""
var _chars: Array = []
var _sel := 0
var _viewport: SubViewport
var _pivot: Node3D
var _cam: Camera3D
var _pv: Node # PlayerView
var _panel_box: VBoxContainer
var _class_img: TextureRect
var _class_lbl: Label
var _status: Label
var _slot_lbl: Label
var _last_empire := -1
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()
_layout = Control.new()
_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_layout)
_build_class_name()
_build_panel()
_build_navigation()
_build_status()
resized.connect(_resize_layout)
_resize_layout()
_last_empire = _empire_id()
set_process(true)
_select(_first_occupied())
# char_list 只带占位了角色的槽;补齐到 slot_count 个(空槽 name=""),◀▶ 能走到空槽建号。
func _pad_slots(chars: Array) -> Array:
var n := 3
if _client and _client.has_method("get_slot_count"):
n = maxi(int(_client.get_slot_count()), chars.size())
n = maxi(n, chars.size())
var by_idx := {}
for c in chars:
by_idx[int(c.get("index", 0))] = c
var out: Array = []
for i in n:
out.append(by_idx.get(i, {"index": i, "name": "", "job": 0, "level": 0}))
return out
func _first_occupied() -> int:
for i in _chars.size():
if String(_chars[i].get("name", "")) != "":
return i
return 0
# §1.10:角色名校验("" = 合法)。创建与改名共用同一实现。
func _validate_char_name(nm: String) -> String:
if nm.length() < CHAR_NAME_MIN_CHARS:
return "名称太短"
if nm.to_utf8_buffer().size() > CHAR_NAME_MAX_BYTES:
return "名称太长(最多 %d 字节)" % CHAR_NAME_MAX_BYTES
for ch in nm:
if ch.unicode_at(0) < 32:
return "名称包含非法字符"
return ""
# 由 app_flow 在收到新的 char_list(建/删号后 M2Client 重发)时调用,保持当前选中槽。
func set_chars(chars: Array) -> void:
var keep := _slot_index()
_chars = _pad_slots(chars)
for i in _chars.size():
if int(_chars[i].get("index", -1)) == keep:
_sel = i
break
_sel = clampi(_sel, 0, maxi(_chars.size() - 1, 0))
_refresh_class_name()
_refresh_panel()
_rebuild_model()
func on_char_created(slot: int) -> void:
_close_dialog()
_set_status("角色已创建(槽位 %d" % slot)
func on_char_create_failed(reason_type: int) -> void:
if _rename_pending:
_rename_pending = false
const RENAME_R := {0: "名称不合法", 1: "该名称已被使用", 100: "角色槽位无效"}
_set_status("改名失败:%s" % RENAME_R.get(reason_type, "错误码 %d" % reason_type))
return
const R := {1: "该名称已被使用", 2: "名称不合法", 3: "该槽位已有角色", 4: "权限不足"}
_set_status("创建失败:%s" % R.get(reason_type, "错误码 %d" % reason_type))
func on_char_name_send_failed() -> void:
_rename_pending = false
_set_status("改名请求发送失败")
func on_char_name_changed(pid: int, name: String) -> void:
var matched := false
for c in _chars:
if int(c.get("id", 0)) == pid:
matched = true
c["name"] = name
c["change_name"] = false
if not matched and _rename_pending:
return
_rename_pending = false
_close_dialog()
_refresh_class_name()
_refresh_panel()
_set_status("角色已改名:%s" % name)
func on_char_deleted(slot: int) -> void:
_close_dialog()
_set_status("角色已删除(槽位 %d" % slot)
func on_char_delete_failed() -> void:
var e: Node = _dialog_node()
if e:
var code := e.find_child("Code", true, false)
if code is LineEdit:
(code as LineEdit).text = ""
_set_status("删除失败:删除码(社交号)不正确")
# --- 背景 ---------------------------------------------------------------------
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
parent.add_child(cr)
if tex != null:
var tr := TextureRect.new()
tr.texture = tex
tr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
# The original stretches the 1024x768 atlas region to the window.
tr.stretch_mode = TextureRect.STRETCH_SCALE
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(tr)
func _build_background() -> void:
_add_background(self)
# --- 右侧 3D 角色 -----------------------------------------------------------
func _build_stage() -> void:
var vpc := SubViewportContainer.new()
vpc.stretch = true
vpc.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vpc.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(vpc)
_viewport = SubViewport.new()
# 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)
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-32, 28, 0)
key.light_energy = 0.9
key.light_color = Color(1.0, 0.96, 0.90)
root.add_child(key)
var rim := DirectionalLight3D.new()
rim.rotation_degrees = Vector3(-6, 200, 0)
rim.light_energy = 0.25
rim.light_color = Color(0.75, 0.83, 1.0)
root.add_child(rim)
var we := WorldEnvironment.new()
var e := Environment.new()
# 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
e.tonemap_mode = Environment.TONE_MAPPER_LINEAR
we.environment = e
root.add_child(we)
_pivot = Node3D.new()
# Animation sampling no longer reapplies the exported InitialPlacement;
# the intro pose already faces the camera without a quarter-turn correction.
_pivot.rotation.y = 0.0
root.add_child(_pivot)
_cam = Camera3D.new()
_cam.fov = 10.0
_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 平移“半径的一小截”把人物推到右半屏(横向视锥比纵向宽,不会切到远侧手)。
func _frame_model() -> void:
if _cam == null or _pv == null or not is_instance_valid(_pv):
return
var aabb := AABB()
var first := true
# GPU skinning happens in the vertex shader, so MeshInstance3D's mesh AABB
# remains in bind pose. Prefer the extension's matching deformed bounds for
# the main body; accessories continue through their own mesh bounds below.
if _pv.model and _pv.model.has_method("get_visual_aabb"):
var posed: AABB = _pv.model.call("get_visual_aabb")
if posed.size.length_squared() > 0.0:
aabb = (_pv.model as Node3D).global_transform * posed
first = false
for mi in (_pv as Node).find_children("*", "MeshInstance3D", true, false):
var m := mi as MeshInstance3D
if m.mesh == null:
continue
if m.get_parent() == _pv.model and m.name == "MeshInstance3D":
continue # already covered by Metin2Model.get_visual_aabb()
var wb: AABB = m.get_global_transform() * m.get_aabb()
aabb = wb if first else aabb.merge(wb)
first = false
if first:
return
var height := maxf(aabb.size.y, 1.0)
# Reference: head at 14%, feet at 85%, center at 59% of the window.
var view_height := height / 0.71
var dist := view_height / (2.0 * tan(deg_to_rad(_cam.fov * 0.5)))
var aspect := size.x / maxf(size.y, 1.0)
var target := aabb.get_center()
target.x -= view_height * aspect * 0.09
target.y -= height * 0.007
_cam.position = target + Vector3(0, dist * sin(deg_to_rad(6.0)), dist * cos(deg_to_rad(6.0)))
_cam.look_at(target, Vector3.UP)
# Metin2AnimPlayer enables the optional GPU skin path on its first animation
# sample. Its material/mesh swap changes the MeshInstance AABB after the
# initial CPU framing pass, so frame again after the extension confirms it.
func _on_model_visual_bounds_changed(pv: Node) -> void:
if pv == _pv and is_instance_valid(pv):
call_deferred("_frame_model")
func _rebuild_model() -> void:
if _pivot == null:
return
if _pv != null and is_instance_valid(_pv):
_pv.hide()
_pv.queue_free()
_pv = null
var c := _cur()
if c.is_empty() or String(c.get("name", "")) == "":
return
var job := clampi(int(c.get("job", 0)), 0, 7)
var pump: Callable = _client.net_poll if _client and _client.has_method("net_poll") else Callable()
var pv := PlayerView.new()
_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"):
_proto = ClassDB.instantiate("Metin2Proto")
add_child(_proto)
for lang in ["en", "common"]:
var ip := _assets.path_join("locale/locale/%s/item_proto" % lang)
if FileAccess.file_exists(ip):
_proto.call("load_item_proto", ip)
break
var equip := EquipModel.new()
pv.add_child(equip)
equip.setup_remote(null, func() -> Node: return pv, _assets, job,
[int(c.get("main_part", 0)), 0, 0, int(c.get("hair_part", 0))], _proto)
# Expansion armor textures live beside their GR2, outside the base PC pack.
var body_path := String(pv.model.get("gr2_path"))
pv.model.set("texture_dir", body_path.get_base_dir())
pv.model.set("lod_enabled", false)
pv.model.set("flip_winding", true)
pv._ground_model()
var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa")
if pv.anim and FileAccess.file_exists(intro):
pv.anim.set("anim_path", intro)
pv.anim.set("loop", true)
# 蒙皮 / LOD 定型要几帧,多 fit 两次取稳定包围盒
for i in 4:
await get_tree().process_frame
if pv != _pv:
return
_frame_model()
else:
pv.queue_free()
# --- 左上职业名 -----------------------------------------------------------
func _build_class_name() -> void:
_class_img = TextureRect.new()
_class_img.position = Vector2(92, 164)
_class_img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
_class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
_layout.add_child(_class_img)
_class_lbl = Label.new()
_class_lbl.position = Vector2(120, 180)
_class_lbl.add_theme_font_size_override("font_size", 84)
_class_lbl.add_theme_color_override("font_color", Color(0.86, 0.11, 0.09))
_class_lbl.add_theme_color_override("font_outline_color", Color(0.15, 0.05, 0.02))
_class_lbl.add_theme_constant_override("outline_size", 10)
_class_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
_layout.add_child(_class_lbl)
func _refresh_class_name() -> void:
var job := int(_cur().get("job", 0)) & 3
var has_char := not _cur().is_empty() and String(_cur().get("name", "")) != ""
var tex: Texture2D = null
if has_char:
var cn: String = CLASS_DIR[job]
for loc in ["en", "de", "tr", "gr", "ru"]:
tex = UiAssets.load_tex(_assets, "locale/locale/%s/ui/select/name_%s.sub" % [loc, cn])
if tex != null:
break
_class_img.texture = tex
_class_img.visible = tex != null
_class_lbl.visible = tex == null and has_char
_class_lbl.text = CLASS_CN[job] if has_char else ""
# --- 左侧信息板 --------------------------------------------------------------
func _build_panel() -> void:
var board := UiKit.board(_assets, "thinboard", 16, 16)
_board = board
board.position = Vector2(119, 313)
board.size = Vector2(208, 323)
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
_layout.add_child(board)
# 板底纹太透就垫一层
var tint := ColorRect.new()
tint.color = Color(0.03, 0.05, 0.06, 0.62)
tint.position = board.position + Vector2(10, 10)
tint.size = board.size - Vector2(20, 20)
tint.mouse_filter = Control.MOUSE_FILTER_IGNORE
_layout.add_child(tint)
_panel_box = VBoxContainer.new()
_panel_box.position = board.position + Vector2(14, 12)
_panel_box.custom_minimum_size = Vector2(180, 0)
_panel_box.add_theme_constant_override("separation", 6)
_layout.add_child(_panel_box)
func _refresh_panel() -> void:
for c in _panel_box.get_children():
_panel_box.remove_child(c)
c.queue_free()
var d := _cur()
var has_char := not d.is_empty() and String(d.get("name", "")) != ""
# 帮会徽 + 帮会名
var head := HBoxContainer.new()
head.add_theme_constant_override("separation", 10)
_panel_box.add_child(head)
var crest := TextureRect.new()
crest.custom_minimum_size = Vector2(70, 40)
crest.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
crest.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
var gid := int(d.get("guild_id", 0))
if has_char and gid != 0 and _client and _client.has_method("get_guild_mark_image"):
var gimg = _client.call("get_guild_mark_image", gid)
if gimg is Image and gimg.get_width() > 0:
crest.texture = ImageTexture.create_from_image(gimg)
if crest.texture == null and has_char:
crest.texture = _empire_flag_tex() # 无会徽时退国旗
if crest.texture == null:
var ph := Image.create_empty(48, 48, false, Image.FORMAT_RGBA8)
ph.fill(Color(0.16, 0.20, 0.28, 0.9))
crest.texture = ImageTexture.create_from_image(ph)
head.add_child(crest)
var gbox := VBoxContainer.new()
head.add_child(gbox)
gbox.add_child(_plain(_empire_name() if has_char else "—", 10, Color(0.95, 0.9, 0.7)))
var gname := String(d.get("guild_name", "")).strip_edges()
gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 10, Color(0.8, 0.82, 0.86)))
_panel_box.add_child(_sep())
# 名称 / 等级 / 游戏时间
_panel_box.add_child(_kv("名称", String(d.get("name", "")) if has_char else "空槽位"))
_panel_box.add_child(_kv("等级", str(int(d.get("level", 0))) if has_char else "-"))
var mins := int(d.get("play_minutes", 0))
_panel_box.add_child(_kv("游戏时间", ("%d%d 分" % [mins / 60, mins % 60]) if has_char else "-"))
_panel_box.add_child(_sep())
# 四维
for row in STAT_ROWS:
var rd: Dictionary = row
var v := int(d.get(rd["key"], 0)) if has_char else 0
_panel_box.add_child(_stat(String(rd["label"]), v, Color(rd["color"])))
_panel_box.add_child(_sep())
# A fresh account has no empire yet. 40250 keeps the connection in the
# select phase and expects CG_EMPIRE before character creation/selection;
# expose that real protocol step instead of leaving the page apparently
# idle with a permanently disabled start button.
var empire_required := _empire_id() == 0
if empire_required:
_panel_box.add_child(_plain("请选择阵营", 14, Color(0.95, 0.85, 0.55)))
var erow := HBoxContainer.new()
erow.add_theme_constant_override("separation", 6)
_panel_box.add_child(erow)
for eid in [1, 2, 3]:
var empire_id: int = eid
var eb := Button.new()
eb.text = _empire_name_for(empire_id)
eb.custom_minimum_size = Vector2(82, 30)
eb.pressed.connect(func():
if _client == null or not _client.has_method("select_empire"):
_set_status("当前网络后端不支持选择阵营")
return
if bool(_client.call("select_empire", empire_id)):
_set_status("已发送阵营选择:%s" % _empire_name_for(empire_id))
else:
_set_status("阵营选择发送失败")
)
erow.add_child(eb)
# 按钮
var brow := GridContainer.new()
brow.columns = 2
brow.add_theme_constant_override("h_separation", 8)
brow.add_theme_constant_override("v_separation", 8)
_panel_box.add_child(brow)
var start := Button.new()
start.text = "开始"
start.custom_minimum_size = Vector2(180, 22)
_style_button(start)
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 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 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.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)
for state in ["normal", "hover", "pressed", "disabled"]:
var suffix := "02" if state == "hover" else "03" if state == "pressed" else "01"
var prefix := "XLarge" if button.custom_minimum_size.x > 100 else "Large"
var texture := UiAssets.load_tex(_assets,
"d:/ymir work/ui/public/%s_Button_%s.sub" % [prefix, suffix])
if texture:
var textured := StyleBoxTexture.new()
textured.texture = texture
textured.set_texture_margin_all(3)
button.add_theme_stylebox_override(state, textured)
continue
var style := StyleBoxFlat.new()
style.bg_color = Color(0.22, 0.22, 0.20, 0.95)
if state == "hover":
style.bg_color = Color(0.32, 0.31, 0.27)
style.border_color = Color(0.48, 0.46, 0.39)
style.set_border_width_all(1)
style.set_corner_radius_all(3)
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"
var base := "d:/ymir work/ui/intro/select/dragon_%s_button_" % side
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(): if not _starting: _select(wrapi(_sel + direction, 0, _chars.size())))
_layout.add_child(button)
_nav_buttons.append(button)
func _resize_layout() -> void:
if _layout:
_layout.size = DESIGN_SIZE
_layout.scale = size / DESIGN_SIZE
_frame_model()
# --- 底部状态 --------------------------------------------------------------
func _build_status() -> void:
_status = Label.new()
_status.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
_status.position = Vector2(24, -32)
_status.add_theme_font_size_override("font_size", 13)
_status.add_theme_color_override("font_color", Color(0.9, 0.92, 0.95))
_status.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_status.add_theme_constant_override("outline_size", 4)
add_child(_status)
func set_status(t: String) -> void:
_set_status(t)
# 会徽下载完成后由 app_flow 调用,重绘信息板即可拿到真徽记。
func refresh_crest() -> void:
_refresh_panel()
func _set_status(t: String) -> void:
if _status:
_status.text = t
# --- 选择 / 循环 ----------------------------------------------------------
func _select(i: int) -> void:
if _starting:
return
if _chars.is_empty():
_sel = 0
_refresh_class_name()
_refresh_panel()
return
_sel = clampi(i, 0, _chars.size() - 1)
if _slot_lbl:
_slot_lbl.text = "%d / %d" % [_sel + 1, _chars.size()]
_refresh_class_name()
_refresh_panel()
_rebuild_model()
var nm := String(_cur().get("name", ""))
_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("空槽位不能进入")
return
# 40250's original select screen forces a character marked with
# change_name to complete the rename flow before entering the world.
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())
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)
# --- 建号 / 删号弹窗 ------------------------------------------------------
var _dialog: Control
func _close_dialog() -> void:
if _dialog and is_instance_valid(_dialog):
_dialog.queue_free()
_dialog = null
func _dialog_node() -> Node:
return _dialog if _dialog and is_instance_valid(_dialog) else null
func _modal_root(title: String, w: int, h: int) -> VBoxContainer:
_close_dialog()
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.55)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(dim)
_dialog = dim
var board := UiKit.board(_assets, "board", 24, 96)
board.set_anchors_preset(Control.PRESET_CENTER)
board.position = Vector2(-w / 2.0, -h / 2.0)
board.size = Vector2(w, h)
dim.add_child(board)
var bg := ColorRect.new()
bg.color = Color(0.04, 0.06, 0.07, 0.92)
bg.position = Vector2(10, 10)
bg.size = Vector2(w - 20, h - 20)
board.add_child(bg)
var box := VBoxContainer.new()
box.position = Vector2(24, 22)
box.custom_minimum_size = Vector2(w - 48, 0)
box.add_theme_constant_override("separation", 10)
board.add_child(box)
var t := _plain(title, 18, Color(0.95, 0.85, 0.55))
box.add_child(t)
box.add_child(_sep())
return box
func _open_create_dialog() -> void:
var d := _cur()
if String(d.get("name", "")) != "":
_set_status("该槽位已有角色")
return
var slot := _slot_index()
# 空槽默认按 slot 序轮职业,让四个槽能各建一职
var job := clampi(slot, 0, 3)
var box := _modal_root("创建角色 · 槽位 %d" % slot, 420, 360)
var jrow := HBoxContainer.new()
jrow.add_theme_constant_override("separation", 8)
box.add_child(jrow)
jrow.add_child(_plain("职业", 13, Color(0.72, 0.78, 0.85)))
var jopt := OptionButton.new()
jopt.name = "Job"
for i in 4:
jopt.add_item("%s / %s" % [CLASS_CN[i], CLASS_DIR[i]], i)
jopt.selected = job
jrow.add_child(jopt)
var nrow := HBoxContainer.new()
nrow.add_theme_constant_override("separation", 8)
box.add_child(nrow)
nrow.add_child(_plain("名称", 13, Color(0.72, 0.78, 0.85)))
var name_edit := LineEdit.new()
name_edit.name = "Name"
name_edit.max_length = CHAR_NAME_MAX_BYTES
name_edit.custom_minimum_size = Vector2(240, 0)
name_edit.placeholder_text = "角色名(最多 24 字节)"
nrow.add_child(name_edit)
# 四维(起始属性,只读,随职业变)
var stat_box := VBoxContainer.new()
stat_box.name = "Stats"
stat_box.add_theme_constant_override("separation", 5)
box.add_child(stat_box)
var render_stats := func(j: int) -> void:
for c in stat_box.get_children():
c.queue_free()
var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0])
stat_box.add_child(_stat("体力", int(bs["con"]), Color(0.90, 0.30, 0.30)))
stat_box.add_child(_stat("智力", int(bs["int"]), Color(0.85, 0.45, 0.95)))
stat_box.add_child(_stat("力量", int(bs["str"]), Color(0.70, 0.55, 1.00)))
stat_box.add_child(_stat("敏捷", int(bs["dex"]), Color(0.35, 0.75, 1.00)))
render_stats.call(job)
jopt.item_selected.connect(func(j: int): render_stats.call(j))
box.add_child(_sep())
var brow := HBoxContainer.new()
brow.alignment = BoxContainer.ALIGNMENT_CENTER
brow.add_theme_constant_override("separation", 16)
box.add_child(brow)
var ok := Button.new(); ok.text = "创建"; ok.custom_minimum_size = Vector2(120, 34)
ok.pressed.connect(func():
var nm := name_edit.text.strip_edges()
var err := _validate_char_name(nm)
if err != "":
_set_status(err)
return
var j := jopt.get_selected_id()
var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0])
create_requested.emit({
"slot": slot, "name": nm, "job": j, "shape": 0,
"con": int(bs["con"]), "int": int(bs["int"]),
"str": int(bs["str"]), "dex": int(bs["dex"]),
})
_set_status("创建中…"))
brow.add_child(ok)
var cancel := Button.new(); cancel.text = "取消"; cancel.custom_minimum_size = Vector2(120, 34)
cancel.pressed.connect(_close_dialog)
brow.add_child(cancel)
func _open_change_name_dialog() -> void:
var d := _cur()
if String(d.get("name", "")) == "":
_set_status("空槽位不能改名")
return
var slot := _slot_index()
var box := _modal_root("修改角色名称", 400, 250)
box.add_child(_plain("该角色需要先修改名称才能进入游戏。", 13,
Color(0.95, 0.75, 0.7)))
var nrow := HBoxContainer.new()
nrow.add_theme_constant_override("separation", 8)
box.add_child(nrow)
nrow.add_child(_plain("新名称", 13, Color(0.72, 0.78, 0.85)))
var name_edit := LineEdit.new()
name_edit.name = "Name"
name_edit.max_length = CHAR_NAME_MAX_BYTES
name_edit.custom_minimum_size = Vector2(220, 0)
name_edit.placeholder_text = "角色名(最多 24 字节)"
nrow.add_child(name_edit)
box.add_child(_sep())
var brow := HBoxContainer.new()
brow.alignment = BoxContainer.ALIGNMENT_CENTER
brow.add_theme_constant_override("separation", 16)
box.add_child(brow)
var ok := Button.new()
ok.text = "改名"
ok.custom_minimum_size = Vector2(120, 34)
ok.pressed.connect(func():
var nm := name_edit.text.strip_edges()
var err := _validate_char_name(nm)
if err != "":
_set_status(err)
return
_rename_pending = true
_close_dialog()
_set_status("改名中…")
change_name_requested.emit(slot, nm))
brow.add_child(ok)
var cancel := Button.new()
cancel.text = "取消"
cancel.custom_minimum_size = Vector2(120, 34)
cancel.pressed.connect(_close_dialog)
brow.add_child(cancel)
func _open_delete_dialog() -> void:
var d := _cur()
if String(d.get("name", "")) == "":
return
var slot := _slot_index()
var box := _modal_root("删除角色", 400, 240)
box.add_child(_plain("将永久删除 “%s” (Lv.%d)。" % [d.get("name", ""), int(d.get("level", 0))],
13, Color(0.95, 0.75, 0.7)))
var crow := HBoxContainer.new()
crow.add_theme_constant_override("separation", 8)
box.add_child(crow)
crow.add_child(_plain("删除码", 13, Color(0.72, 0.78, 0.85)))
var code := LineEdit.new()
code.name = "Code"
code.secret = true
code.max_length = 7
code.custom_minimum_size = Vector2(200, 0)
code.placeholder_text = "社交号 / 删除码"
crow.add_child(code)
box.add_child(_sep())
var brow := HBoxContainer.new()
brow.alignment = BoxContainer.ALIGNMENT_CENTER
brow.add_theme_constant_override("separation", 16)
box.add_child(brow)
var ok := Button.new(); ok.text = "删除"; ok.custom_minimum_size = Vector2(120, 34)
ok.pressed.connect(func():
delete_requested.emit(slot, code.text)
_set_status("删除中…"))
brow.add_child(ok)
var cancel := Button.new(); cancel.text = "取消"; cancel.custom_minimum_size = Vector2(120, 34)
cancel.pressed.connect(_close_dialog)
brow.add_child(cancel)
# --- 国家 --------------------------------------------------------------
func _empire_id() -> int:
if _client and _client.has_method("get_empire"):
return int(_client.get_empire())
return 0
func _empire_name() -> String:
return _empire_name_for(_empire_id())
func _empire_name_for(e: int) -> String:
# 优先 locale_game.txt 的 EMPIRE_A/B/C
var key: String = EMPIRE_KEY.get(e, "")
if key != "":
var loc := _locale_line(key)
if loc != "":
return loc
return EMPIRE_FALLBACK.get(e, "—")
static var _loc_cache: Dictionary = {}
func _locale_line(key: String) -> String:
if _loc_cache.has(key):
return _loc_cache[key]
var val := ""
for rel in ["locale/locale/en/locale_game.txt", "locale/locale/de/locale_game.txt"]:
var p := _assets.path_join(rel)
if not FileAccess.file_exists(p):
continue
var f := FileAccess.open(p, FileAccess.READ)
while f and not f.eof_reached():
var parts := f.get_line().split("\t", false)
if parts.size() >= 2 and String(parts[0]) == key:
val = String(parts[1]).strip_edges()
break
if val != "":
break
_loc_cache[key] = val
return val
func _empire_flag_tex() -> Texture2D:
var f: String = EMPIRE_FLAG.get(_empire_id(), "")
if f == "":
return null
return UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/empire/empireflag_%s.sub" % f)
func _process(_dt: float) -> void:
var empire := _empire_id()
if empire != _last_empire:
_last_empire = empire
_refresh_panel()
# --- 小工具 --------------------------------------------------------------
func _cur() -> Dictionary:
return _chars[_sel] if _sel >= 0 and _sel < _chars.size() else {}
func _slot_index() -> int:
return int(_cur().get("index", _sel))
func _plain(text: String, size: int, col: Color) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", col)
return l
func _sep() -> Control:
var c := ColorRect.new()
c.color = Color(1, 1, 1, 0.12)
c.custom_minimum_size = Vector2(0, 2)
return c
func _kv(k: String, v: String) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
row.custom_minimum_size.y = 20
var kl := _plain(k, 10, Color(0.82, 0.82, 0.80))
kl.custom_minimum_size = Vector2(42, 0)
row.add_child(kl)
var value := _plain(v, 10, Color(1, 1, 1))
value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
var style := StyleBoxFlat.new()
style.bg_color = Color(0, 0, 0, 0.85)
style.border_color = Color(0.4, 0.4, 0.37)
style.set_border_width_all(1)
value.add_theme_stylebox_override("normal", style)
row.add_child(value)
return row
func _stat(label: String, value: int, col: Color) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
row.custom_minimum_size.y = 20
var kl := _plain(label, 10, Color(0.82, 0.82, 0.80))
kl.custom_minimum_size = Vector2(26, 0)
row.add_child(kl)
var track := ColorRect.new()
track.color = Color(0, 0, 0, 0.5)
track.custom_minimum_size = Vector2(98, 8)
track.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(track)
var fill := ColorRect.new()
fill.color = col
fill.anchor_left = 0.0
fill.anchor_top = 0.0
fill.anchor_bottom = 1.0
fill.offset_left = 1
fill.offset_top = 1
fill.offset_bottom = -1
fill.offset_right = 1 + int(clampf(value / 90.0, 0.0, 1.0) * 96.0)
track.add_child(fill)
row.add_child(_plain(str(value), 10, Color(1, 1, 1)))
return row