441 lines
16 KiB
GDScript
441 lines
16 KiB
GDScript
# 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()
|