Handle late main-character data, guarded model construction, bounded retries and map correction. Validate player model sources and add lifecycle and real-asset visual regressions. Include pending material and character-selection changes, updated A1 screenshot, and the detailed rendering repair plan. Hair occlusion, full UI parity and final macOS package acceptance remain unfinished.
894 lines
31 KiB
GDScript
894 lines
31 KiB
GDScript
# 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 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
|
||
|
||
func setup(client: Node, assets_root: String, chars: Array) -> void:
|
||
_client = client
|
||
_assets = assets_root
|
||
_chars = _pad_slots(chars)
|
||
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 _build_background() -> 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)
|
||
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
|
||
add_child(tr)
|
||
|
||
# --- 右侧 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()
|
||
_viewport.transparent_bg = true
|
||
_viewport.own_world_3d = true
|
||
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||
_viewport.msaa_3d = Viewport.MSAA_4X
|
||
vpc.add_child(_viewport)
|
||
|
||
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()
|
||
e.background_mode = Environment.BG_COLOR
|
||
e.background_color = Color(0, 0, 0, 0)
|
||
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()
|
||
_pivot.rotation.y = -PI * 0.5
|
||
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()
|
||
|
||
# 相机按模型包围球自适应:球心 + 半径/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
|
||
for mi in (_pv as Node).find_children("*", "MeshInstance3D", true, false):
|
||
var m := mi as MeshInstance3D
|
||
if m.mesh == null:
|
||
continue
|
||
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)
|
||
|
||
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
|
||
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
|
||
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
|
||
var create := Button.new()
|
||
create.text = "创建"
|
||
create.custom_minimum_size = Vector2(180, 22)
|
||
_style_button(create)
|
||
create.disabled = has_char or empire_required
|
||
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
|
||
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.pressed.connect(_open_delete_dialog)
|
||
brow.add_child(del)
|
||
var quit := Button.new()
|
||
quit.text = "退出"
|
||
quit.custom_minimum_size = Vector2(86, 20)
|
||
_style_button(quit)
|
||
quit.pressed.connect(func(): back_requested.emit())
|
||
brow.add_child(quit)
|
||
|
||
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:
|
||
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.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())))
|
||
_layout.add_child(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 _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:
|
||
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
|
||
select_requested.emit(_slot_index())
|
||
_set_status("进入游戏:%s" % d.get("name"))
|
||
|
||
# --- 建号 / 删号弹窗 ------------------------------------------------------
|
||
|
||
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
|