Files
mtgodot-poc/project/ui/quest_log.gd
T

429 lines
15 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.__ArrangeQuestButtoninterfacemodule.py:1357-13861: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 _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 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()
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 = _quest_buttons_visible and not _quest_buttons_suppressed and not _quest_button_data.is_empty()