# GuildUI (P8/M4) —— 公会窗(G 键):成员 / 技能 / 公会战 / 公告 四页。 # # var gu := preload("res://ui/guild_ui.gd").new() # add_child(gu) # gu.setup(m2client, canvas_parent, skill_table) # skill_table 可空(出公会技能名) # gu.toggle() # G 键 # # `guild_changed` / `guild_skill_changed` / `guild_war_changed` 刷新,`guild_marks_ready` # 到时刷会徽图。等级/日志页仍取决于协议是否提供对应数据,不在当前四页入口内伪造。 extends Node const TABS := ["成员", "技能", "公会战", "公告"] # EGuildWarState (wire.h) const WAR_STATE_NAMES := ["无", "已宣战", "被拒", "收到宣战", "待开始", "取消", "交战中", "结束"] var client: Node var skill_table: Object # SkillTable (tests may provide a Node stub) var _ui_parent: Node # 返回一张 Image(16×12,或任意会被转换/缩放)用作会徽上传源;null = 不显示上传按钮。 var mark_image_provider: Callable var _root: Control var _title: Label var _info: Label var _mark: TextureRect var _tabrow: HBoxContainer var _scroll: ScrollContainer var _stack: Control var _tab := 0 var _tab_btns: Array[Button] = [] var _pages: Array[VBoxContainer] = [] var _war_name_edit: LineEdit var _upload_status: Label var _create_dialog: ConfirmationDialog var _create_name_edit: LineEdit var _create_status: Label var _mobile_mode := false var _war_ask_dialog: ConfirmationDialog # GUILD_AUTH_*(服务器 guild.h:94-97):等级权限位。会长(grade 0)恒全权。 const AUTH_ADD_MEMBER := 1 << 0 const AUTH_REMOVE_MEMBER := 1 << 1 const AUTH_NOTICE := 1 << 2 const AUTH_USE_SKILL := 1 << 3 const MOBILE_SIZE := Vector2(700, 340) func setup(m2client: Node, parent: Node, table: Object = null) -> void: client = m2client skill_table = table _ui_parent = parent _build(parent) for sig in ["guild_changed", "guild_skill_changed", "guild_war_changed", "guild_comments_changed"]: if client.has_signal(sig): client.connect(sig, refresh) if client.has_signal("guild_make_requested"): client.guild_make_requested.connect(_on_guild_make_requested) if client.has_signal("guild_marks_ready"): client.guild_marks_ready.connect(func(_n): refresh()) if client.has_signal("guild_mark_uploaded"): client.guild_mark_uploaded.connect(_on_mark_uploaded) # GUILD_WAR_RECV_DECLARE(3) 时弹被宣战应答框(原版 AcceptGuildWarDialog: # 接受发 /war ,拒绝发 /nowar ,均走 chat 通道)。 if client.has_signal("guild_war_event"): client.guild_war_event.connect(_on_guild_war_event) func is_open() -> bool: return _root != null and _root.visible func set_mobile_mode(enabled: bool) -> void: _mobile_mode = enabled _apply_mobile_layout() if _mobile_mode and is_open() and _ui_parent and _ui_parent.has_method("reflow_mobile_window"): _ui_parent.reflow_mobile_window(_root) func close() -> void: if _root: _root.visible = false func get_mobile_window() -> Control: return _root func toggle() -> void: _root.visible = not _root.visible if _root.visible: refresh() func _set_tab(i: int) -> void: _tab = i for t in _pages.size(): _pages[t].visible = (t == i) _tab_btns[t].disabled = (t == i) refresh() func refresh() -> void: if not is_open() or client == null: return var g: Dictionary = client.get_guild() var in_guild: bool = g.get("in_guild", false) _refresh_mark(int(g.get("id", 0)) if in_guild else 0) if not in_guild: _info.text = "(未加入公会)" for p in _pages: for c in p.get_children(): c.queue_free() return _info.text = "%s Lv.%d 成员 %d/%d 资金 %d" % [ g.get("name", "?"), int(g.get("level", 0)), int(g.get("member_count", 0)), int(g.get("max_member_count", 0)), int(g.get("gold", 0))] match _tab: 0: _fill_members() 1: _fill_skills() 2: _fill_war(int(g.get("id", 0))) 3: _fill_comments() # 我的等级权限位(会长 grade 0 恒 0xF;否则取 grades[grade-1].auth)。 # 成员/等级数据未同步(成员表为空、找不到自己)时**返回全权**——权限只是 UI 预判, # 不可判定时不拦操作,由服务器权威兜底(与原版一致:原版也不在前端做这层门)。 const AUTH_ALL := AUTH_ADD_MEMBER | AUTH_REMOVE_MEMBER | AUTH_NOTICE | AUTH_USE_SKILL func _my_auth() -> int: if client == null or not client.has_method("get_guild_grades") \ or not client.has_method("get_main_pid") \ or not client.has_method("get_guild_members"): return AUTH_ALL var my_pid := int(client.get_main_pid()) var my_grade := -1 for m in client.get_guild_members(): if int(m.get("pid", 0)) == my_pid: my_grade = int(m.get("grade", -1)) break if my_grade == -1: return AUTH_ALL # 成员表里还没有自己(未同步) if my_grade == 0: return AUTH_ALL # GUILD_LEADER_GRADE var grades: Array = client.get_guild_grades() if my_grade >= 1 and my_grade <= grades.size(): return int(grades[my_grade - 1].get("auth", 0)) return AUTH_ALL # 被宣战应答(对齐原版 __GuildWar_OpenAskDialog / OnAccept / OnDecline)。 func _on_guild_war_event(state: int, opp_guild_id: int, opp_name: String) -> void: if state != 3 or opp_guild_id == 0: # GUILD_WAR_RECV_DECLARE return if is_instance_valid(_war_ask_dialog): _war_ask_dialog.queue_free() _war_ask_dialog = ConfirmationDialog.new() _war_ask_dialog.title = "公会战邀请" var shown := opp_name if opp_name != "" else "#%d" % opp_guild_id _war_ask_dialog.dialog_text = "公会 [%s] 向我方宣战,接受吗?" % shown _war_ask_dialog.ok_button_text = "接受" _war_ask_dialog.cancel_button_text = "拒绝" _war_ask_dialog.confirmed.connect(func() -> void: if client.has_method("declare_guild_war"): client.declare_guild_war(opp_name) # /war _war_ask_dialog.queue_free() _war_ask_dialog = null) _war_ask_dialog.canceled.connect(func() -> void: if client.has_method("say") and opp_name != "": client.say(0, "/nowar " + opp_name) # 原版 __GuildWar_OnDecline _war_ask_dialog.queue_free() _war_ask_dialog = null) if _root and _root.get_parent(): _root.get_parent().add_child(_war_ask_dialog) if _mobile_mode: _war_ask_dialog.set_meta("mobile_modal", true) _war_ask_dialog.set_meta("mobile_title", "公会战邀请") _war_ask_dialog.popup_centered() func _my_grade_is_leader() -> bool: if client == null or not client.has_method("get_main_pid") \ or not client.has_method("get_guild_members"): return false var my_pid := int(client.get_main_pid()) for m in client.get_guild_members(): if int(m.get("pid", 0)) == my_pid: return int(m.get("grade", -1)) == 0 # GUILD_LEADER_GRADE return false func _fill_members() -> void: var page := _pages[0] for c in page.get_children(): c.queue_free() # 会徽上传(需要配了 mark server + 提供了图源) var srv: Dictionary = client.get_mark_server() if client.has_method("get_mark_server") else {} if mark_image_provider.is_valid() and int(srv.get("port", 0)) > 0: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) page.add_child(row) var ub := Button.new() ub.text = "上传会徽" ub.custom_minimum_size = Vector2(140, 40) if _mobile_mode else Vector2(96, 26) ub.pressed.connect(_upload_mark) row.add_child(ub) _upload_status = Label.new() _upload_status.add_theme_font_size_override("font_size", 11) _upload_status.modulate = Color(0.7, 0.8, 0.7) row.add_child(_upload_status) var grades: Array = client.get_guild_grades() var members: Array = client.get_guild_members() # 公会资金存取(GUILD_CG_DEPOSIT/WITHDRAW_MONEY = 13/14)。40250 服务器把 # 两个处理分支都禁用了(input_main.cpp 直接 return),但原版 uiguild.py 的 # OnDeposit/OnWithdraw 仍照发——保持 1:1,结果以服务器回执为准。 if client.has_method("guild_deposit_money"): var money := HBoxContainer.new() money.add_theme_constant_override("separation", 6) page.add_child(money) var ginfo: Dictionary = client.get_guild() var mlabel := Label.new() mlabel.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) mlabel.custom_minimum_size = Vector2(300, 40) if _mobile_mode else Vector2(160, 0) mlabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER mlabel.text = "公会资金: %d" % int(ginfo.get("gold", 0)) money.add_child(mlabel) var amount := LineEdit.new() amount.placeholder_text = "金额" amount.max_length = 9 amount.custom_minimum_size = Vector2(160, 40) if _mobile_mode else Vector2(96, 26) money.add_child(amount) var dep := Button.new() dep.text = "存入" dep.tooltip_text = "40250 服务器已禁用公会存款" dep.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 26) dep.pressed.connect(func(): var v := int(amount.text) if v > 0: client.guild_deposit_money(v)) money.add_child(dep) var wdr := Button.new() wdr.text = "取出" wdr.tooltip_text = "40250 服务器已禁用公会取款" wdr.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 26) wdr.pressed.connect(func(): var v := int(amount.text) if v > 0: client.guild_withdraw_money(v)) money.add_child(wdr) var can_kick := (_my_auth() & AUTH_REMOVE_MEMBER) != 0 var my_pid := int(client.get_main_pid()) if client.has_method("get_main_pid") else 0 members.sort_custom(func(a, b): return int(a.get("grade", 99)) < int(b.get("grade", 99))) for m in members: var gi := int(m.get("grade", 0)) var gname := "" if gi >= 1 and gi <= grades.size(): gname = String(grades[gi - 1].get("name", "")) var mrow := HBoxContainer.new() mrow.add_theme_constant_override("separation", 6) page.add_child(mrow) var row := Label.new() row.custom_minimum_size = Vector2(560, 34) if _mobile_mode else Vector2.ZERO row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER row.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) row.text = "%s Lv.%d [%s] 贡献 %d%s" % [ String(m.get("name", "?")), int(m.get("level", 0)), gname if gname != "" else str(gi), int(m.get("offer", 0)), (" 在线" if m.get("online", false) else " 离线") + (" ★" if m.get("general", false) else "")] mrow.add_child(row) # 成员管理:REMOVE_MEMBER 权限位可见的踢出按钮(自己与会长不可踢,服务器兜底)。 var pid := int(m.get("pid", 0)) if can_kick and pid != 0 and pid != my_pid and gi != 0: var kick := Button.new() kick.text = "踢出" kick.custom_minimum_size = Vector2(72, 34) if _mobile_mode else Vector2(44, 24) kick.pressed.connect(func(): if client.has_method("guild_remove_member"): client.guild_remove_member(pid)) mrow.add_child(kick) func _fill_comments() -> void: var page := _pages[3] for c in page.get_children(): c.queue_free() if not client.has_method("get_guild_comments"): var missing := Label.new() missing.text = "(当前协议没有公告接口)" page.add_child(missing) return var actions := HBoxContainer.new() actions.add_theme_constant_override("separation", 5) page.add_child(actions) var board_status := Label.new() board_status.add_theme_font_size_override("font_size", 11) board_status.add_theme_color_override("font_color", Color(1, 0.65, 0.55)) page.add_child(board_status) var edit := LineEdit.new() edit.placeholder_text = "公告内容(最多 50 字)" edit.custom_minimum_size = Vector2(440, 40) if _mobile_mode else Vector2(245, 26) actions.add_child(edit) var post := Button.new() post.text = "发布" post.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(52, 26) post.pressed.connect(func(): var text := edit.text.strip_edges() if text == "": return # 服务器 input_main.cpp:2819:'!' 前缀(公告)需要 GUILD_AUTH_NOTICE。 if text.begins_with("!") and (_my_auth() & AUTH_NOTICE) == 0: board_status.text = "发布公告需要相应权限" return board_status.text = "" if client.has_method("guild_post_comment"): client.guild_post_comment(text)) actions.add_child(post) var refresh_btn := Button.new() refresh_btn.text = "刷新" refresh_btn.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(52, 26) refresh_btn.pressed.connect(func(): if client.has_method("guild_refresh_comments"): client.guild_refresh_comments()) actions.add_child(refresh_btn) var comments: Array = client.get_guild_comments() if comments.is_empty(): var none := Label.new() none.text = "(暂无公告)" page.add_child(none) return for item in comments: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) page.add_child(row) var label := Label.new() label.custom_minimum_size = Vector2(560, 42) if _mobile_mode else Vector2(286, 0) label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.add_theme_font_size_override("font_size", 13 if _mobile_mode else 12) label.text = "%s:%s" % [String(item.get("name", "?")), String(item.get("content", ""))] row.add_child(label) var delete := Button.new() delete.text = "删" delete.custom_minimum_size = Vector2(64, 40) if _mobile_mode else Vector2(32, 24) delete.pressed.connect(func(): if client.has_method("guild_delete_comment"): client.guild_delete_comment(int(item.get("id", 0)))) row.add_child(delete) func _guild_skill_rows() -> Array: # skill_table 的 GUILD 分类给 id(升序);按顺序配 levels[i]。返回 [{id, name}]。 var out := [] if skill_table and skill_table.has_method("for_category"): for id in skill_table.for_category("GUILD"): out.append({"id": int(id), "name": skill_table.name_of(int(id))}) return out func _fill_skills() -> void: var page := _pages[1] for c in page.get_children(): c.queue_free() var sk: Dictionary = client.get_guild_skill() if not sk.get("valid", false): var l := Label.new() l.text = "(还没收到公会技能数据)" page.add_child(l) return var head := Label.new() head.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO head.vertical_alignment = VERTICAL_ALIGNMENT_CENTER head.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) head.modulate = Color(0.8, 0.9, 1.0) head.text = "技能点 %d 公会点 %d / %d" % [ int(sk.get("skill_point", 0)), int(sk.get("guild_point", 0)), int(sk.get("max_guild_point", 0))] page.add_child(head) var levels: Array = sk.get("levels", []) var meta := _guild_skill_rows() var auth := _my_auth() var can_use_skill := (auth & AUTH_USE_SKILL) != 0 # do_guildskillup 只认会长(GUILD_LEADER_GRADE);技能点 > 0 才显示升级按钮。 var is_leader := auth == AUTH_ALL and _my_grade_is_leader() var has_point := int(sk.get("skill_point", 0)) > 0 for i in levels.size(): var lv := int(levels[i]) var nm := "公会技能 %d" % i var sid := -1 if i < meta.size(): nm = String(meta[i].get("name", nm)) sid = int(meta[i].get("id", -1)) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) page.add_child(row) var lbl := Label.new() lbl.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) lbl.custom_minimum_size = Vector2(430, 40) if _mobile_mode else Vector2(240, 0) lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER lbl.text = "%s Lv %d" % [nm, lv] row.add_child(lbl) # 施放:服务器 guild.cpp:1248 要求 GUILD_AUTH_USE_SKILL。 if sid >= 0 and lv > 0 and can_use_skill: var btn := Button.new() btn.text = "施放" btn.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 24) btn.pressed.connect(func(): client.use_guild_skill(sid, 0)) row.add_child(btn) # 升级:/gskillup <技能 vnum>(cmd_general.cpp do_guildskillup,仅会长)。 if sid >= 0 and is_leader and has_point: var up := Button.new() up.text = "升级" up.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 24) up.pressed.connect(func(): if client.has_method("say"): client.say(0, "/gskillup %d" % sid)) row.add_child(up) func _fill_war(my_guild_id: int) -> void: var page := _pages[2] for c in page.get_children(): c.queue_free() var cur: Dictionary = client.get_guild_war() var st := int(cur.get("state", 0)) var cur_lbl := Label.new() cur_lbl.custom_minimum_size = Vector2(640, 36) if _mobile_mode else Vector2.ZERO cur_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER cur_lbl.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) cur_lbl.modulate = Color(1.0, 0.8, 0.7) if st == 0: cur_lbl.text = "当前无公会战" else: var on := String(cur.get("opp_name", "")) cur_lbl.text = "对 %s : %s" % [ on if on != "" else "#%d" % int(cur.get("opp_guild_id", 0)), WAR_STATE_NAMES[st] if st < WAR_STATE_NAMES.size() else str(st)] page.add_child(cur_lbl) # 宣战输入 var decl := HBoxContainer.new() decl.add_theme_constant_override("separation", 6) page.add_child(decl) _war_name_edit = LineEdit.new() _war_name_edit.placeholder_text = "对方公会名" _war_name_edit.custom_minimum_size = Vector2(440, 40) if _mobile_mode else Vector2(200, 26) decl.add_child(_war_name_edit) var db := Button.new() db.text = "宣战" db.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(60, 26) db.pressed.connect(func(): var nm := _war_name_edit.text.strip_edges() if nm != "": client.declare_guild_war(nm)) decl.add_child(db) var hdr := Label.new() hdr.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO hdr.vertical_alignment = VERTICAL_ALIGNMENT_CENTER hdr.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) hdr.text = "— 进行中的公会战 —" page.add_child(hdr) var wars: Array = client.get_guild_wars() if wars.is_empty(): var none := Label.new() none.text = "(无)" page.add_child(none) for wpair in wars: var sn := String(wpair.get("src_name", "")) var dn := String(wpair.get("dst_name", "")) var row := Label.new() row.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER row.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) var mark_self := " ◀我方" if int(wpair.get("src", 0)) == my_guild_id or int(wpair.get("dst", 0)) == my_guild_id else "" row.text = "%s vs %s%s" % [ sn if sn != "" else "#%d" % int(wpair.get("src", 0)), dn if dn != "" else "#%d" % int(wpair.get("dst", 0)), mark_self] page.add_child(row) func _upload_mark() -> void: var g: Dictionary = client.get_guild() if not g.get("in_guild", false) or not mark_image_provider.is_valid(): return var img = mark_image_provider.call() if img == null: if _upload_status: _upload_status.text = "没有会徽图" return var srv: Dictionary = client.get_mark_server() var ok: bool = client.upload_guild_mark( String(srv.get("host", "")), int(srv.get("port", 0)), int(g.get("id", 0)), img) if _upload_status: _upload_status.text = "上传中…" if ok else "上传发起失败" func _on_mark_uploaded(ok: bool) -> void: if _upload_status: _upload_status.text = "✔ 已上传(等服务器刷新)" if ok else "✘ 上传失败" # 40250 原版在收到 GC_REQUEST_MAKE_GUILD 后弹 InputDialog,再由玩家回送 # CG_ANSWER_MAKE_GUILD。这里保留同样的“服务端请求 -> 输入 -> 应答”边界, # 让名称校验发生在发包前,同时把最终权限 / 敏感词判断留给服务端。 func _on_guild_make_requested() -> void: if is_instance_valid(_create_dialog): _popup_create_dialog() _create_name_edit.grab_focus() return if _ui_parent == null or client == null: return _create_dialog = ConfirmationDialog.new() _create_dialog.name = "GuildCreateDialog" _create_dialog.title = "创建公会" _create_dialog.dialog_text = "请输入公会名称(最多 12 字)" _create_dialog.ok_button_text = "创建" _create_dialog.cancel_button_text = "取消" _create_name_edit = LineEdit.new() _create_name_edit.name = "GuildName" _create_name_edit.max_length = 12 _create_name_edit.placeholder_text = "公会名称" _create_name_edit.custom_minimum_size = Vector2(380, 44) if _mobile_mode else Vector2(250, 28) _create_dialog.add_child(_create_name_edit) _create_status = Label.new() _create_status.name = "Status" _create_status.add_theme_font_size_override("font_size", 13 if _mobile_mode else 11) _create_status.modulate = Color(1.0, 0.7, 0.6) _create_dialog.add_child(_create_status) _ui_parent.add_child(_create_dialog) _create_dialog.confirmed.connect(_on_guild_create_confirmed) _create_dialog.canceled.connect(_close_guild_create_dialog) if _ui_parent.has_method("track_mobile_modal"): _ui_parent.track_mobile_modal(_create_dialog) _popup_create_dialog() _create_name_edit.grab_focus() func _on_guild_create_confirmed() -> void: if not is_instance_valid(_create_dialog) or _create_name_edit == null: return var name := _create_name_edit.text.strip_edges() var error := "" if name.is_empty(): error = "公会名称不能为空" elif name.length() > 12: error = "公会名称最多 12 字" else: for ch in name: if ch.unicode_at(0) < 32: error = "公会名称包含非法字符" break if error != "": _create_status.text = error _create_dialog.call_deferred("popup_centered", Vector2i(520, 270) if _mobile_mode else Vector2i(-1, -1)) _create_name_edit.call_deferred("grab_focus") return if not client.has_method("guild_answer_make") or not bool(client.call("guild_answer_make", name)): _create_status.text = "创建请求发送失败" _create_dialog.call_deferred("popup_centered", Vector2i(520, 270) if _mobile_mode else Vector2i(-1, -1)) _create_name_edit.call_deferred("grab_focus") return _close_guild_create_dialog() func _close_guild_create_dialog() -> void: var dialog := _create_dialog _create_dialog = null _create_name_edit = null _create_status = null if is_instance_valid(dialog): dialog.queue_free() func _popup_create_dialog() -> void: if not is_instance_valid(_create_dialog): return if _mobile_mode: _create_dialog.set_meta("mobile_title", "创建公会") _create_dialog.min_size = Vector2i(520, 270) _create_dialog.size = Vector2i(520, 270) _create_dialog.popup_centered(Vector2i(520, 270)) else: _create_dialog.popup_centered() # 公会 id 变了就重新取会徽图(16x12,放大 3x 显示)。 func _refresh_mark(guild_id: int) -> void: if _mark == null: return if guild_id == 0 or client == null or not client.has_method("get_guild_mark_image"): _mark.texture = null _mark.visible = false return var img: Image = client.get_guild_mark_image(guild_id) if img == null: _mark.texture = null _mark.visible = false return _mark.texture = ImageTexture.create_from_image(img) _mark.visible = true func _build(parent: Node) -> void: _root = Control.new() _root.set_anchors_preset(Control.PRESET_CENTER) _root.position = Vector2(-190, -210) _root.size = Vector2(380, 420) _root.visible = false _root.set_meta("is_titlebar", true) parent.add_child(_root) var panel := Panel.new() panel.set_anchors_preset(Control.PRESET_FULL_RECT) var sb := StyleBoxFlat.new() sb.bg_color = Color(0.07, 0.08, 0.1, 0.97) sb.set_corner_radius_all(4) panel.add_theme_stylebox_override("panel", sb) _root.add_child(panel) _title = Label.new() _title.text = "公会" _title.position = Vector2(12, 8) _root.add_child(_title) _mark = TextureRect.new() _mark.position = Vector2(320, 6) _mark.custom_minimum_size = Vector2(48, 36) # 16x12 放大 3x _mark.size = Vector2(48, 36) _mark.stretch_mode = TextureRect.STRETCH_SCALE _mark.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST _mark.visible = false _root.add_child(_mark) _info = Label.new() _info.position = Vector2(12, 32) _info.add_theme_font_size_override("font_size", 12) _info.modulate = Color(0.9, 0.85, 0.6) _root.add_child(_info) _tabrow = HBoxContainer.new() _tabrow.position = Vector2(12, 54) _tabrow.add_theme_constant_override("separation", 4) _root.add_child(_tabrow) for i in TABS.size(): var b := Button.new() b.text = TABS[i] b.custom_minimum_size = Vector2(84, 26) b.pressed.connect(_set_tab.bind(i)) _tabrow.add_child(b) _tab_btns.append(b) _scroll = ScrollContainer.new() _scroll.position = Vector2(12, 86) _scroll.custom_minimum_size = Vector2(356, 322) _root.add_child(_scroll) _stack = Control.new() _stack.custom_minimum_size = Vector2(350, 0) _scroll.add_child(_stack) for i in TABS.size(): var page := VBoxContainer.new() page.custom_minimum_size = Vector2(350, 0) page.add_theme_constant_override("separation", 3) page.visible = (i == 0) _stack.add_child(page) _pages.append(page) _tab_btns[0].disabled = true _apply_mobile_layout() func _apply_mobile_layout() -> void: if _root == null or _title == null or _tabrow == null or _scroll == null or _stack == null: return if _mobile_mode: _root.size = MOBILE_SIZE _root.position = Vector2(-MOBILE_SIZE.x * 0.5, -MOBILE_SIZE.y * 0.5) _title.visible = false _info.position = Vector2(18, 46) _info.size = Vector2(580, 26) _info.add_theme_font_size_override("font_size", 14) _mark.position = Vector2(624, 46) _mark.size = Vector2(48, 36) _tabrow.position = Vector2(18, 78) _tabrow.size = Vector2(664, 40) for b in _tab_btns: b.custom_minimum_size = Vector2(160, 38) b.add_theme_font_size_override("font_size", 13) _scroll.custom_minimum_size = Vector2(664, 194) _scroll.position = Vector2(18, 124) _scroll.size = Vector2(664, 194) _stack.custom_minimum_size = Vector2(648, 0) for page in _pages: page.custom_minimum_size = Vector2(648, 0) page.add_theme_constant_override("separation", 6) else: _root.size = Vector2(380, 420) _root.position = Vector2(-190, -210) _title.visible = true _title.position = Vector2(12, 8) _info.position = Vector2(12, 32) _info.remove_theme_font_size_override("font_size") _mark.position = Vector2(320, 6) _mark.size = Vector2(48, 36) _tabrow.position = Vector2(12, 54) _tabrow.size = Vector2(0, 0) for b in _tab_btns: b.custom_minimum_size = Vector2(84, 26) b.remove_theme_font_size_override("font_size") _scroll.custom_minimum_size = Vector2(356, 322) _scroll.position = Vector2(12, 86) _scroll.size = Vector2(356, 322) _stack.custom_minimum_size = Vector2(350, 0) for page in _pages: page.custom_minimum_size = Vector2(350, 0) page.add_theme_constant_override("separation", 3)