# QuestLog (P7) —— 任务日志窗:GC_QUEST_INFO 的条目列表。 # # var ql := preload("res://ui/quest_log.gd").new() # add_child(ql) # ql.setup(m2client, canvas_parent) # ql.toggle() # J 键 # # 每条: 图标(占位) + 标题 + counter「名: 值」 + clock「名: 值」。 # `quest_info` 信号来时刷新。空标题的条目视为「已完成 / 移除」。 extends Node const UiAssets = preload("res://ui/ui_assets.gd") const QUEST_MAX_NUM := 5 const QUEST_EVENT_BUTTON_BASE := -2147483648 # interfacemodule.__ArrangeQuestButton(interfacemodule.py:1357-1386)1:1 布局参数: # xPos = 20(无组队窗)/ 130 = 100 + 30(组队窗显示时);(+15 RTL,本迁移不做 RTL) # yPos = 170 * screenHeight // 600 # yCount = (screenHeight - 330) // 63 每列容纳的按钮数 # 第 i 个: x = xPos + (i // yCount) * 100 ; y = yPos + (i % yCount) * 63 const QUEST_BTN_X := 20 const QUEST_BTN_X_PARTY := 130 const QUEST_BTN_COL_W := 100 const QUEST_BTN_ROW_H := 63 const QUEST_BTN_SIZE := Vector2(198, 48) signal quest_selected(index: int) var client: Node var _root: Control var _list: VBoxContainer var _quest_scrollbar: VScrollBar var _quests: Array = [] var _quest_start_index := 0 var _visible_quest_indices: Array[int] = [] var _button_root: Control var _button_list: Control var _quest_buttons_visible := true var _quest_buttons_suppressed := false var _quest_button_data: Dictionary = {} var _quest_button_order: Array[int] = [] # 最新在前:BINARY_RecvQuest 的 questButtonList.insert(0, btn) var _party_shown := false var _party_probe: Callable = Callable() var _screen_size_override := Vector2.ZERO # >0 时覆盖 viewport 尺寸(离线回归用) var _mobile_mode := false var _clock_remaining: Dictionary = {} var _clock_labels: Dictionary = {} var _clock_accumulator := 0.0 var _proto: Node var _item_list: RefCounted var _assets_root := "" func setup(m2client: Node, parent: Node) -> void: client = m2client set_process(true) _build(parent) if client.has_signal("quest_info"): client.quest_info.connect(func(_i): refresh()) _refresh_quest_buttons() func set_item_sources(proto_node: Node, item_list: RefCounted, assets_root: String) -> void: _proto = proto_node _item_list = item_list _assets_root = assets_root _refresh_quest_buttons() func is_open() -> bool: return _root != null and _root.visible func get_mobile_window() -> Control: return _root func close() -> void: if _root: _root.visible = false func toggle() -> void: _root.visible = not _root.visible if _root.visible: refresh() # game.py Ctrl+Q toggles the quest-button strip. This migration has the quest # entries in the quest-log surface, so keep the same state boundary explicit; # a future HUD strip can bind this without changing the input contract. func toggle_buttons() -> void: _quest_buttons_visible = not _quest_buttons_visible _update_button_visibility() func quest_buttons_visible() -> bool: return _quest_buttons_visible func set_buttons_suppressed(suppressed: bool) -> void: _quest_buttons_suppressed = suppressed _update_button_visibility() # EventManager QUESTBUTTON / QUESTBUTTON_CLOSE interface. The quest-log # snapshot and the transient quest-letter strip are separate in ClientVS22. func recv_quest(index: int, title: String, icon_type := "file", icon_name := "") -> void: if index < 0: return # BINARY_RecvQuest:同 index 先 __DestroyQuestButton 旧的,再 questButtonList.insert(0, btn) # —— 即无论新旧都移到最前。 _quest_button_data[index] = {"title": title, "icon_type": icon_type, "icon_name": icon_name} _quest_button_order.erase(index) _quest_button_order.push_front(index) _refresh_quest_buttons() func clear_quest(index: int) -> void: _quest_button_data.erase(index) _quest_button_order.erase(index) _refresh_quest_buttons() func quest_button_order() -> Array[int]: return _quest_button_order.duplicate() # __ArrangeQuestButton 的 xPos 取决于 self.wndParty.IsShow()。组队窗可见性在本迁移里 # 由 party_ui 的成员数驱动(无信号),故用一个探针回调,在 _process 里轮询变化。 func set_party_probe(probe: Callable) -> void: _party_probe = probe func set_party_shown(shown: bool) -> void: if shown == _party_shown: return _party_shown = shown _refresh_quest_buttons() # MobileHud supplies one compact task entry at the right side of the # landscape HUD. Keep the quest-letter data here, but hide the desktop # absolute-positioned buttons while mobile mode is active. func set_mobile_mode(enabled: bool) -> void: _mobile_mode = enabled _update_button_visibility() func set_screen_size_override(size: Vector2) -> void: _screen_size_override = size _refresh_quest_buttons() func _screen_size() -> Vector2: if _screen_size_override.x > 0.0 and _screen_size_override.y > 0.0: return _screen_size_override var vp := get_viewport() if vp: return vp.get_visible_rect().size return Vector2(800, 600) # interfacemodule.py:1371 yCount = (screenHeight - 330) // 63 # 参考端无下限,screenHeight < 393 会 ZeroDivision;这里夹到 1(防御性偏离,seam)。 static func quest_button_y_count(screen_height: float) -> int: return maxi(1, int(screen_height - 330.0) / 63) # interfacemodule.py:1370 yPos = 170 * screenHeight // 600 static func quest_button_y_pos(screen_height: float) -> int: return int(170.0 * screen_height) / 600 # interfacemodule.py:1375 # btn.SetPosition(xPos + (int(count // yCount) * 100), yPos + (count % yCount * 63)) static func quest_button_slot_pos(slot: int, y_count: int, x_pos: int, y_pos: int) -> Vector2: var col := slot / y_count var row := slot % y_count return Vector2(x_pos + col * QUEST_BTN_COL_W, y_pos + row * QUEST_BTN_ROW_H) func quest_button_position(index: int) -> Vector2: var slot := _quest_button_order.find(index) if slot < 0: return Vector2(-1, -1) var screen := _screen_size() return quest_button_slot_pos(slot, quest_button_y_count(screen.y), QUEST_BTN_X_PARTY if _party_shown else QUEST_BTN_X, quest_button_y_pos(screen.y)) func quest_button_count() -> int: return _quest_button_data.size() func quest_count() -> int: return _quests.size() func quest_start_index() -> int: return _quest_start_index func visible_quest_indices() -> Array[int]: return _visible_quest_indices.duplicate() func scroll_ratio() -> float: if _quest_scrollbar == null or _quest_scrollbar.max_value <= 0.0: return 0.0 return _quest_scrollbar.value / _quest_scrollbar.max_value func set_scroll_ratio(ratio: float) -> void: if _quest_scrollbar == null: return _quest_scrollbar.value = clampf(ratio, 0.0, 1.0) * _quest_scrollbar.max_value func select_quest(index: int) -> void: if index < 0 or index >= _quests.size(): return var quest_index := int(_quests[index].get("index", -1)) if quest_index < 0: return if client and client.has_method("script_button"): client.script_button(QUEST_EVENT_BUTTON_BASE + quest_index) quest_selected.emit(quest_index) func refresh() -> void: if not is_open() or client == null: return _quests.clear() for q in client.get_quests(): if String(q.get("title", "")).strip_edges() != "": _quests.append(q) _quest_start_index = mini(_quest_start_index, maxi(0, _quests.size() - QUEST_MAX_NUM)) if _quest_scrollbar: _quest_scrollbar.max_value = maxi(0, _quests.size() - QUEST_MAX_NUM) _quest_scrollbar.value = _quest_start_index _quest_scrollbar.visible = _quests.size() > QUEST_MAX_NUM _render_quests() func _render_quests() -> void: for c in _list.get_children(): c.queue_free() _clock_labels.clear() _clock_remaining.clear() _clock_accumulator = 0.0 _visible_quest_indices.clear() if _quests.is_empty(): var e := Label.new() e.text = "(无进行中的任务)" e.add_theme_font_size_override("font_size", 12) _list.add_child(e) return var end_index := mini(_quests.size(), _quest_start_index + QUEST_MAX_NUM) for data_index in range(_quest_start_index, end_index): var q: Dictionary = _quests[data_index] _visible_quest_indices.append(data_index) var quest_index := int(q.get("index", -1)) var clock_name := String(q.get("clock_name", "")) if quest_index >= 0 and clock_name != "": _clock_remaining[quest_index] = maxi(0, int(q.get("clock_value", 0))) var box := VBoxContainer.new() box.add_theme_constant_override("separation", 1) box.mouse_filter = Control.MOUSE_FILTER_STOP box.gui_input.connect(func(event): if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: select_quest(data_index) ) var heading := HBoxContainer.new() heading.add_theme_constant_override("separation", 6) var icon_path := String(q.get("icon", "")) if icon_path == "": icon_path = "season1/icon/scroll_open.tga" var icon_tex: Texture2D = UiAssets.load_tex(AssetRoot.path(), icon_path) if icon_tex: var icon := TextureRect.new() icon.texture = icon_tex icon.custom_minimum_size = Vector2(24, 24) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.mouse_filter = Control.MOUSE_FILTER_IGNORE heading.add_child(icon) var text_column := VBoxContainer.new() text_column.mouse_filter = Control.MOUSE_FILTER_IGNORE var t := Label.new() t.text = "◆ " + String(q.get("title", "")) t.add_theme_font_size_override("font_size", 13) t.mouse_filter = Control.MOUSE_FILTER_IGNORE text_column.add_child(t) var cn := String(q.get("counter_name", "")) if cn != "": var cl := Label.new() cl.text = " %s: %d" % [cn, int(q.get("counter_value", 0))] cl.add_theme_font_size_override("font_size", 11) cl.modulate = Color(0.8, 0.85, 0.7) cl.mouse_filter = Control.MOUSE_FILTER_IGNORE text_column.add_child(cl) if clock_name != "": var kl := Label.new() kl.add_theme_font_size_override("font_size", 11) kl.modulate = Color(0.7, 0.8, 0.9) kl.mouse_filter = Control.MOUSE_FILTER_IGNORE text_column.add_child(kl) _clock_labels[quest_index] = kl _update_clock_label(quest_index, kl, clock_name) heading.add_child(text_column) heading.mouse_filter = Control.MOUSE_FILTER_IGNORE box.add_child(heading) _list.add_child(box) func _process(delta: float) -> void: # 组队窗可见性变化 → 重排(__ArrangeQuestButton 每帧读 wndParty.IsShow())。 if _party_probe.is_valid(): var shown := bool(_party_probe.call()) if shown != _party_shown: _party_shown = shown _refresh_quest_buttons() if not is_open() or _clock_remaining.is_empty(): return _clock_accumulator += delta if _clock_accumulator < 1.0: return var elapsed := int(_clock_accumulator) _clock_accumulator -= float(elapsed) for quest_index in _clock_remaining.keys(): _clock_remaining[quest_index] = maxi(0, int(_clock_remaining[quest_index]) - elapsed) if _clock_labels.has(quest_index): _update_clock_label(quest_index, _clock_labels[quest_index], "") func _update_clock_label(quest_index: int, label: Label, name: String) -> void: var remaining := int(_clock_remaining.get(quest_index, 0)) var prefix := name if prefix == "": var old := String(label.text) prefix = old.get_slice(":", 0) if old.contains(":") else "时间" if remaining <= 0: label.text = " %s:已超时" % prefix return var minutes := remaining / 60 var seconds := remaining % 60 var value := "%d秒" % seconds if minutes > 0: value = "%d分%s" % [minutes, value] if seconds > 0 else "%d分" % minutes label.text = " %s:%s" % [prefix, value] func _build(parent: Node) -> void: # 任务信件按钮条:绝对定位以 1:1 复刻 __ArrangeQuestButton 的多列布局, # 因此 root 落在屏幕原点,按钮坐标里已含 xPos / yPos。 _button_root = Control.new() _button_root.set_anchors_preset(Control.PRESET_FULL_RECT) _button_root.position = Vector2.ZERO _button_root.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(_button_root) _button_list = Control.new() _button_list.set_anchors_preset(Control.PRESET_FULL_RECT) _button_list.mouse_filter = Control.MOUSE_FILTER_IGNORE _button_root.add_child(_button_list) _root = Control.new() _root.set_anchors_preset(Control.PRESET_TOP_RIGHT) _root.position = Vector2(-320, 60) _root.size = Vector2(300, 360) _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.95) sb.set_corner_radius_all(4) panel.add_theme_stylebox_override("panel", sb) _root.add_child(panel) var title := Label.new() title.text = "任务日志" title.position = Vector2(12, 8) _root.add_child(title) _list = VBoxContainer.new() _list.position = Vector2(12, 34) _list.size = Vector2(260, 310) _list.custom_minimum_size = Vector2(260, 310) _list.add_theme_constant_override("separation", 8) _root.add_child(_list) _quest_scrollbar = VScrollBar.new() _quest_scrollbar.position = Vector2(278, 34) _quest_scrollbar.size = Vector2(14, 310) _quest_scrollbar.step = 1.0 _quest_scrollbar.page = 1.0 _quest_scrollbar.visible = false _quest_scrollbar.value_changed.connect(_on_quest_scroll) _root.add_child(_quest_scrollbar) func _on_quest_scroll(value: float) -> void: var next_start := clampi(roundi(value), 0, maxi(0, _quests.size() - QUEST_MAX_NUM)) if next_start == _quest_start_index: return _quest_start_index = next_start _render_quests() func _refresh_quest_buttons() -> void: if _button_list == null: return for child in _button_list.get_children(): child.queue_free() # 顺序 = questButtonList(最新在前)。清掉 order 里 data 已删的残留 index。 var order: Array[int] = [] for idx in _quest_button_order: if _quest_button_data.has(idx): order.append(int(idx)) _quest_button_order = order var screen := _screen_size() var x_pos := QUEST_BTN_X_PARTY if _party_shown else QUEST_BTN_X var y_pos := quest_button_y_pos(screen.y) var y_count := quest_button_y_count(screen.y) var slot := 0 for index in _quest_button_order: var spec: Dictionary = _quest_button_data[index] var button := Button.new() button.custom_minimum_size = QUEST_BTN_SIZE button.size = QUEST_BTN_SIZE button.position = quest_button_slot_pos(slot, y_count, x_pos, y_pos) button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.text = " " + String(spec.get("title", "")) button.tooltip_text = String(spec.get("title", "")) button.add_theme_font_size_override("font_size", 12) if String(spec.get("icon_type", "")) == "highlight": button.modulate = Color(1.0, 0.85, 0.35) var tex := _quest_icon(spec) if tex: button.icon = tex button.expand_icon = true button.icon_max_width = 32 var captured := int(index) button.pressed.connect(func(): _start_quest(captured)) _button_list.add_child(button) slot += 1 _update_button_visibility() func _quest_icon(spec: Dictionary) -> Texture2D: var icon_type := String(spec.get("icon_type", "")) var icon_name := String(spec.get("icon_name", "")) if icon_type == "item": var vnum := int(icon_name) var path := "" if _item_list and _item_list.has_method("icon"): path = String(_item_list.icon(vnum)) if path == "": path = "icon/item/%05d.tga" % ((vnum / 10) * 10) return UiAssets.load_tex(_assets_root if _assets_root != "" else AssetRoot.path(), path) if icon_name != "": return UiAssets.load_tex(_assets_root if _assets_root != "" else AssetRoot.path(), icon_name) return null func _start_quest(index: int) -> void: if client and client.has_method("script_button"): client.script_button(index) # ClientVS22 removes the letter as soon as it is clicked. The server can # still re-send it if the quest remains actionable. _quest_button_data.erase(index) _quest_button_order.erase(index) _refresh_quest_buttons() func _update_button_visibility() -> void: if _button_root: _button_root.visible = not _mobile_mode and _quest_buttons_visible \ and not _quest_buttons_suppressed and not _quest_button_data.is_empty()