class_name PanelModule extends RefCounted const I18nScript := preload("res://scripts/core/i18n.gd") # 面板模块基类。 # # 设计目标: # - 子面板只关心自己的 UI 和业务流程,不直接依赖 GameScene 的大量字段。 # - GameScene 通过 callbacks 暴露必要能力,例如玩家数据、库存刷新、Toast、弹窗开关。 # - 所有跨模块状态写入都从这里走统一 helper,后续要替换成更严格的 State/Facade 时只改这一层。 # 公共服务和工具引用由 GameScene 注入。 # 这里故意不写成强类型,避免每个面板都需要 preload 具体实现,降低模块之间的编译耦合。 var _ui var _texture_provider var _game_config var _activity_api var _owner: Node var _context # context 是面板到宿主的统一桥;callbacks 只保留给未迁完的旧面板兼容。 # 约定:子类不能直接访问 GameScene 字段,只能通过 _call/_call_async 间接调用。 var _callbacks: Dictionary = {} var _panel_screen_size := Vector2(640.0, 960.0) func setup(options: Dictionary) -> void: # 所有面板统一使用字典注入,方便每个功能只拿自己需要的依赖。 _ui = options.get("ui_builder") _texture_provider = options.get("texture_provider") _game_config = options.get("game_config") _activity_api = options.get("activity_api") _owner = options.get("owner") as Node _context = options.get("context") _callbacks = options.get("callbacks", {}) if _callbacks.is_empty() and _context != null and _context.has_method("legacy_callbacks"): _callbacks = _context.legacy_callbacks() _panel_screen_size = options.get("panel_screen_size", _panel_screen_size) func _clear_children(parent: Node) -> void: # 面板多次 render 时统一清理动态节点,避免旧按钮信号和旧列表项残留。 for child in parent.get_children(): child.queue_free() func _is_live_node(node: Variant) -> bool: return node != null and is_instance_valid(node) and node is Node func _is_current_node(candidate: Variant, current: Variant) -> bool: return _is_live_node(candidate) and _is_live_node(current) and candidate == current func _clear_children_if_live(parent: Variant) -> bool: if not _is_live_node(parent): return false _clear_children(parent as Node) return true func _format_timestamp(unix_time: int) -> String: if unix_time <= 0: return "" var dt := Time.get_datetime_dict_from_unix_time(unix_time) return "%02d-%02d %02d:%02d" % [int(dt.get("month", 1)), int(dt.get("day", 1)), int(dt.get("hour", 0)), int(dt.get("minute", 0))] func _player_data() -> Dictionary: # 返回的是宿主玩家字典引用,面板内修改会直接反映到 HUD/后续接口参数。 # 后续如果引入 PlayerInventoryFacade,这里是替换入口。 var result = _call("player_data", [], {}) return result if result is Dictionary else {} func _token() -> String: return str(_call("token", [], "")) func _player_id() -> int: return int(_call("player_id", [], 0)) func _is_busy() -> bool: # 全局操作锁,防止用户连续点击导致同一接口重复请求。 return bool(_call("is_operation_in_progress", [], false)) func _set_busy(value: bool) -> void: _call("set_operation_in_progress", [value]) func _show_toast(message: String) -> void: _call("show_toast", [message]) func _open_modal(panel: Control) -> void: _call("open_modal_panel", [panel]) func _close_modal(animated := true) -> void: _call("close_modal_panel", [animated]) func _response_data(result: Dictionary, fallback: Variant = {}) -> Variant: # ApiClient 返回统一包裹结构,业务数据固定从 body.data 取。 return _call("response_data", [result, fallback], fallback) func _response_status_text(result: Dictionary, fallback := "操作失败") -> String: return I18nScript.t(str(_call("response_status_text", [result, fallback], fallback))) func _parse_cost_string(cost_text: String) -> Array[Dictionary]: # 旧配置大量使用 "item_id:count,item_id:count" 字符串。 # 面板不要自己 split,统一走 CostRules,兼容金豆/钻石/道具的后续特殊规则。 var result = _call("parse_cost_string", [cost_text], []) var costs: Array[Dictionary] = [] if result is Array: for cost in result: if cost is Dictionary: costs.append(cost) return costs func _reward_fields_to_text(row: Dictionary, first_field: int, last_field: int, multiplier := 1) -> String: # 旧配置常用 fruit_id1..fruit_idN 表示奖励或消耗。 # 返回统一 cost_text,方便复用库存校验、奖励展示和道具格渲染。 return str(_call("reward_fields_to_text", [row, first_field, last_field, multiplier], "")) func _format_reward_text(cost_text: String) -> String: return str(_call("format_reward_text", [cost_text], "")) func _add_reward_items(cost_text: String) -> void: _call("add_reward_items", [cost_text]) func _render_player() -> void: _call("render_player") func _add_player_exp(amount: int) -> void: _call("add_player_exp", [amount]) func _refresh_store_house() -> Variant: # 库存刷新是异步接口,面板领取/购买/消耗后都要 await,保证再次 render 时数量正确。 return await _call_async("refresh_store_house") func _has_costs(cost_text: String) -> bool: # 所有按钮可用态都应该走统一成本校验,避免不同面板对材料数量理解不一致。 return bool(_call("has_costs", [cost_text], false)) func _apply_cost_deduction(cost_text: String) -> void: # 后端成功后才本地扣减,用于让 UI 立即刷新;失败时不能提前扣。 _call("apply_cost_deduction", [cost_text]) func _owned_amount(item_id: int) -> int: return int(_call("owned_amount", [item_id], 0)) func _decrease_warehouse_item(item_id: int, count: int) -> void: _call("decrease_warehouse_item", [item_id, count]) func _set_texture_button_enabled(button_holder: Control, enabled: bool, normal_texture: Texture2D = null, disabled_texture: Texture2D = null) -> void: # TextureButton 是自定义 Control + Button 组合,禁用逻辑需要同时改透明度、贴图和 hit.disabled。 _call("set_texture_button_enabled", [button_holder, enabled, normal_texture, disabled_texture]) func _format_duration(seconds: int) -> String: return I18nScript.t(str(_call("format_duration", [seconds], I18nScript.format_duration(seconds)))) func _get_item_icon_texture(icon_id: int) -> Texture2D: # 图标路径兼容 item_icon、icon、petIcon、plant 多个旧资源目录。 if _texture_provider != null and _texture_provider.has_method("item_icon"): return _texture_provider.item_icon(icon_id) var result = _call("get_item_icon_texture", [icon_id], null) return result as Texture2D func _style_label(label: Label, size: int, color: Color, outline: int, outline_color: Color) -> void: _call("style_label", [label, size, color, outline, outline_color]) func _apply_empty_button_style(button: Button) -> void: _call("apply_empty_button_style", [button]) func _create_tab_bar(parent: Control, name: String, labels: Array, position: Vector2, selected_index: int, callback: Callable, base_height := 40.0) -> Control: return _ui.create_tab_bar(parent, name, labels, position, selected_index, callback, base_height) func _update_tab_bar(group: Control, selected_index: int) -> void: _ui.update_tab_bar(group, selected_index) func _add_cost_slots(parent: Control, cost_text: String, position: Vector2, max_count: int, gap := 84.0, size := Vector2(76.0, 76.0)) -> void: # 多数旧面板都用横向材料格。这里只负责展示,是否足够由 _has_costs 决定。 var costs := _parse_cost_string(cost_text) for index in range(mini(costs.size(), max_count)): var cost := costs[index] _ui.add_item_slot(parent, "CostSlot%s" % index, int(cost.get("item_id", 0)), int(cost.get("count", 0)), position + Vector2(gap * index, 0.0), size) func _find_config_by_id(rows: Array[Dictionary], id: int) -> Dictionary: for row in rows: if int(row.get("id", 0)) == id: return row return {} func _first_cost(cost_text: String) -> Dictionary: # 周卡/月卡、签到等配置只取首个奖励时使用。 var costs := _parse_cost_string(cost_text) return costs[0] if not costs.is_empty() else {} func _same_local_day(left_time: int, right_time: int) -> bool: # 旧后端用 Unix 秒记录领取时间,日卡类奖励需要按本地日期判断是否已领。 if left_time <= 0 or right_time <= 0: return false var left := Time.get_datetime_dict_from_unix_time(left_time) var right := Time.get_datetime_dict_from_unix_time(right_time) return int(left.get("year", 0)) == int(right.get("year", 0)) and int(left.get("month", 0)) == int(right.get("month", 0)) and int(left.get("day", 0)) == int(right.get("day", 0)) func _normalize_reward_triplets(raw: String) -> String: # 游乐园奖励配置可能是 item:type:count,这里转成统一 item:count 文本给奖励展示层。 var parts: Array[String] = [] for piece in raw.split(",", false): var fields := piece.split(":", false) if fields.size() >= 3: parts.append("%s:%s" % [fields[0], fields[2]]) elif fields.size() == 2: parts.append(piece) return ",".join(parts) func _callback(name: String) -> Callable: if _context != null and _context.has_method("callback"): return _context.callback(name) return _callbacks.get(name, Callable()) as Callable func _call(name: String, args: Array = [], fallback: Variant = null) -> Variant: # 同步 callback 的统一入口。无效 callback 必须静默返回 fallback,保证面板可单独 check-only。 if _context != null and _context.has_method("call_action"): return _context.call_action(name, args, fallback) var callable := _callback(name) if not callable.is_valid(): return fallback return callable.callv(args) func _call_async(name: String, args: Array = []) -> Variant: # Godot 允许 await Callable.callv() 返回的异步函数;这里集中处理缺失 callback。 if _context != null and _context.has_method("call_action_async"): return await _context.call_action_async(name, args) var callable := _callback(name) if not callable.is_valid(): return null return await callable.callv(args)