# 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_.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