Files
mtgodot-poc/project/ui/quest_dialog.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

1222 lines
43 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.
# QuestDialog (P7) —— 渲染 GC_SCRIPT 的 NPC 对话(EventManager 脚本,客户端只渲染)。
#
# var qd := preload("res://ui/quest_dialog.gd").new()
# add_child(qd)
# qd.setup(m2client, canvas_parent)
#
# 脚本格式(`CPythonEventManager`):纯文本 + `[命令 arg(值)]` token 交错。
# [ENTER] 换行
# [CLEAR] 清空
# [NEXT] / [DONE] 「继续 / 关闭」按钮 -> M2Client.script_answer(255)
# [QUESTION arg("是") arg("否")] 选项按钮 -> script_answer(0..N-1)
# [LETTER value("...")] 追加文本(旧写法)
# [INPUT] 显示文本输入框,提交 -> M2Client.quest_input()
# [SELECT_ITEM] 要求从背包选一颗魔石 -> 发 select_item_requested 信号(打开 SelectItemUI
# [CONFIRM_WAIT] 等:本版忽略
#
# quest_confirm_askGC_QUEST_CONFIRM)也在这里弹 是/否 -> quest_confirm(yes, pid)。
extends Node
const UiAssets = preload("res://ui/ui_assets.gd")
signal opened()
signal closed()
# EventManager EVENT_TYPE_SELECT_ITEM —— 对应 interfacemodule.BINARY_OpenSelectItemWindow
signal select_item_requested()
# EventManager side effects that are owned by the interface layer in ClientVS22.
# Keeping them as signals makes the dialog usable with both the real client and
# headless fixtures without smuggling minimap/camera state into the parser.
signal quest_button_received(index: int, title: String, icon_type: String, icon_name: String)
signal quest_button_cleared(index: int)
signal map_signal_added(x: float, y: float)
signal map_signals_cleared()
signal atlas_center_requested(x: int, y: int)
signal camera_event_requested(kind: String, values: Array)
signal fade_event_requested(kind: String, speed: float)
signal camera_event_requested_full(kind: String, setting: Dictionary, blendtime: float)
signal fade_event_requested_full(kind: String, speed: float)
signal done_event()
const MAX_CHOICES_PER_PAGE := 8
var client: Node
var proto: Node
var _assets_root := ""
var _root: Control
var _panel: Panel
var _text: RichTextLabel
var _btnrow: VBoxContainer
var _image_layer: Control
var _confirm_pid := 0
var _confirm_wait := false
var _active_set
var _active_commands: Array = []
var _active_cursor := 0
var _active_script := false
var _current_skin := 0
var _current_left_w := 0.0
var _current_choice_page := 0
var _cached_choices: Array = []
signal dungeon_result_requested(result: Dictionary)
func setup(m2client: Node, parent: Node, proto_node: Node = null, assets_root := "") -> void:
client = m2client
proto = proto_node
_assets_root = assets_root if assets_root != "" else AssetRoot.path()
set_process_unhandled_input(true)
set_process(true)
_build(parent)
if client.has_signal("script_dialog"):
client.script_dialog.connect(_on_script)
if client.has_signal("quest_confirm_ask"):
client.quest_confirm_ask.connect(_on_confirm)
func set_proto(proto_node: Node) -> void:
# Proto is created after the dialog in GameScene because item/mob loading is
# expensive. Keep the dialog usable before that point, then resolve future
# [ITEM]/[MOB] tags against the real tables.
proto = proto_node
func is_open() -> bool:
return _root != null and _root.visible
func _process(delta: float) -> void:
if not _active_script or _active_set == null:
return
# The first headless frame may report a zero delta. ClientVS22 still runs
# one UpdateEventSet tick, so use one nominal 60 FPS frame as the floor.
_process_active(maxf(delta * 1000.0, 16.0))
# Deterministic entry point used by the frame loop and headless tests. The
# argument is milliseconds because EventManager's lLastDelayTime/lWaitingTime
# are millisecond values in ClientVS22.
func process_events(elapsed_ms: float) -> void:
if _active_script and _active_set != null:
_process_active(maxf(0.0, elapsed_ms))
func begin_script(src: String) -> void:
_active_set = EventSet.new()
_active_set.init_event_set()
_active_commands = _tokenize(src)
_active_cursor = 0
_active_script = true
# RegisterEventSetFromString replaces a leading RUN_CINEMA event set with
# the referenced .msc file. Keep the signal for the presentation layer and
# load the event text when the loose asset is available.
if not _active_commands.is_empty() and String(_active_commands[0].get("literal", "")) == "" \
and String(_active_commands[0].get("name", "")).to_upper().trim_prefix("/") == "RUN_CINEMA":
var cinema := _arg_s(String(_active_commands[0].get("tok", "")), "value")
if cinema != "":
cinema_requested.emit(cinema)
var cinema_text := _read_script_file(cinema)
if cinema_text != "":
_active_commands = _tokenize(cinema_text)
else:
# No local .msc: keep the empty event set alive so the caller can
# show its own cinema player, but do not re-process RUN_CINEMA.
_active_commands = []
_present_event_set(_active_set)
_root.visible = true
opened.emit()
# Start the same nominal first tick immediately. This keeps the dialog
# responsive even when a SceneTree caller resumes before child _process()
# callbacks (notably in headless fixtures).
_process_active(16.0)
func _read_script_file(vpath: String) -> String:
var rel := vpath.replace("\\", "/")
if rel.length() >= 2 and rel[1] == ":":
rel = rel.substr(2)
rel = rel.lstrip("/")
var candidates: Array[String] = []
if _assets_root != "":
candidates.append(_assets_root.path_join(rel))
var resolved := UiAssets._resolve(_assets_root, rel)
if resolved != "":
candidates.append(resolved)
for path in candidates:
if FileAccess.file_exists(path):
return FileAccess.get_file_as_string(path)
return ""
func skip() -> void:
if not _active_script or _active_set == null:
return
# CPythonEventManager::Skip: an input lock is released; otherwise the
# current delay is forced to expire and the next command is consumed.
if _active_set.isLock:
_active_set.lLastDelayTime = 0
_active_set.isLock = false
else:
_active_set.lLastDelayTime = -1
_process_active(0.0)
func end_event_process() -> void:
if _active_set != null:
_active_set.isWaitFlag = false
func _process_active(elapsed_ms: float) -> void:
var remaining := elapsed_ms
var forced_step: bool = _active_set.lLastDelayTime < 0
if forced_step:
_active_set.lLastDelayTime = 0
while _active_script and _active_set != null:
if _active_set.isConfirmWait:
if int(Time.get_ticks_msec() / 1000) >= _active_set.iConfirmEndTime:
_active_set.isConfirmWait = false
_active_set.iConfirmEndTime = 0
_confirm_wait = false
if client and client.has_method("quest_cancel"):
client.quest_cancel()
_active_script = false
close()
else:
_present_event_set(_active_set)
return
if _active_set.isWaitFlag or _active_set.isLock:
_present_event_set(_active_set)
return
if _active_set.lLastDelayTime > 0:
if remaining <= 0.0:
return
var wait := float(_active_set.lLastDelayTime)
if remaining < wait:
_active_set.lLastDelayTime -= int(remaining)
return
_active_set.lLastDelayTime = 0
remaining -= wait
if _active_cursor >= _active_commands.size():
_active_script = false
_active_set.isLock = true
_present_event_set(_active_set)
return
var command: Dictionary = _active_commands[_active_cursor]
_active_cursor += 1
_process_event_set(_active_set, command)
_emit_runtime_side_effect(command, _active_set)
_present_event_set(_active_set)
forced_step = false
if _active_set.isWaitFlag or _active_set.isLock or _active_set.isConfirmWait:
return
if remaining <= 0.0:
return
func _finish_runtime() -> void:
_active_script = false
_active_commands.clear()
_active_cursor = 0
func _emit_runtime_side_effect(command: Dictionary, es: EventSet) -> void:
var idx := _get_script_event_index(String(command.get("name", "")))
if not idx.ok:
return
var tok := String(command.get("tok", ""))
match int(idx.type):
EVT_SELECT_ITEM:
select_item_requested.emit()
EVT_QUEST_BUTTON:
if not es.quest_buttons.is_empty():
var q: Dictionary = es.quest_buttons.back()
quest_button_received.emit(int(q.get("index", -1)), String(q.get("title", "")),
String(q.get("icon_type", "")), String(q.get("icon_name", "")))
EVT_QUEST_BUTTON_CLOSE:
quest_button_cleared.emit(_arg_i(tok, "idx", 0))
EVT_ADD_MAP_SIGNAL:
map_signal_added.emit(_arg_f(tok, "x", 0), _arg_f(tok, "y", 1))
EVT_CLEAR_MAP_SIGNAL:
map_signals_cleared.emit()
EVT_SET_CENTER_MAP_POSITION:
atlas_center_requested.emit(_arg_i(tok, "x", 0), _arg_i(tok, "y", 1))
EVT_SET_CAMERA, EVT_BLEND_CAMERA, EVT_RESTORE_CAMERA:
var camera: Dictionary = es.camera_events.back() if not es.camera_events.is_empty() else {}
var kind := String(camera.get("kind", ""))
camera_event_requested.emit(kind, camera.get("values", []))
camera_event_requested_full.emit(kind, camera.get("setting", {}).duplicate(true),
float(camera.get("blendtime", 0.0)))
EVT_FADE_OUT, EVT_FADE_IN, EVT_WHITE_OUT, EVT_WHITE_IN:
var fade: Dictionary = es.fade_events.back() if not es.fade_events.is_empty() else {}
var fade_kind := String(fade.get("kind", ""))
var fade_speed := float(fade.get("speed", 0.0))
fade_event_requested.emit(fade_kind, fade_speed)
fade_event_requested_full.emit(fade_kind, fade_speed)
EVT_DUNGEON_RESULT:
dungeon_result_requested.emit(es.dungeon_result.duplicate())
EVT_END_CONFIRM_WAIT:
_confirm_wait = false
_:
pass
func _present_event_set(es: EventSet) -> void:
var parsed := _event_set_result(es)
if parsed.body.strip_edges() == "" and parsed.choices.is_empty() and not parsed.has_input \
and not parsed.confirm_wait and not _active_script:
close()
return
if _panel:
# 40250 uiquest.py:328 - SKIN_CINEMA(5) and SKIN_NOWINDOW(1) hide background board
_panel.visible = (_current_skin != 1 and _current_skin != 5)
_text.text = parsed.body
_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER if parsed.text_centered else HORIZONTAL_ALIGNMENT_LEFT
var width := _root.size.x
var height := _root.size.y
if parsed.window_size.size() >= 2:
width = maxi(320, int(parsed.window_size[0]))
height = maxi(220, int(parsed.window_size[1]))
_root.size = Vector2(width, height)
var left_w := _render_images(parsed.images, parsed.title_image)
_current_left_w = left_w
var content_w := maxf(200.0, width - 32.0 - left_w)
_text.position = Vector2(16.0 + left_w, 14.0)
_text.size = Vector2(content_w, height - 100.0)
_btnrow.position = Vector2(16.0 + left_w, height - 78.0)
_btnrow.custom_minimum_size = Vector2(content_w, 0)
_confirm_wait = parsed.confirm_wait
_fill_buttons(parsed.choices, parsed.has_next, parsed.has_input, parsed.confirm_wait,
String(parsed.get("next_button_type", "")))
# --- 收 GC_SCRIPT ------------------------------------------------------
func _on_script(skin: int, text: String) -> void:
_current_skin = skin
begin_script(text)
func _emit_side_effects(parsed: Dictionary) -> void:
if parsed.has_select_item:
select_item_requested.emit()
for quest in parsed.quest_buttons:
quest_button_received.emit(int(quest.get("index", -1)), String(quest.get("title", "")),
String(quest.get("icon_type", "")), String(quest.get("icon_name", "")))
for index in parsed.quest_button_closes:
quest_button_cleared.emit(int(index))
for point in parsed.map_signals:
map_signal_added.emit(float(point.x), float(point.y))
if parsed.clear_map_signals:
map_signals_cleared.emit()
if parsed.atlas_center.size() >= 2:
atlas_center_requested.emit(int(parsed.atlas_center[0]), int(parsed.atlas_center[1]))
for camera in parsed.camera_events:
var kind := String(camera.get("kind", ""))
camera_event_requested.emit(kind, camera.get("values", []))
camera_event_requested_full.emit(kind, camera.get("setting", {}).duplicate(true),
float(camera.get("blendtime", 0.0)))
for fade in parsed.fade_events:
var fade_kind := String(fade.get("kind", ""))
var fade_speed := float(fade.get("speed", 0.0))
fade_event_requested.emit(fade_kind, fade_speed)
fade_event_requested_full.emit(fade_kind, fade_speed)
if String(parsed.get("cinema_file", "")) != "":
cinema_requested.emit(String(parsed.cinema_file))
func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void:
_finish_runtime()
_confirm_pid = request_pid
_text.text = msg
_fill_confirm()
_root.visible = true
opened.emit()
func close() -> void:
_finish_runtime()
if _root:
_root.visible = false
_confirm_wait = false
closed.emit()
func _unhandled_input(event: InputEvent) -> void:
if not is_open() or not (event is InputEventKey):
return
var key := event as InputEventKey
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
if _active_script:
skip()
var vp := get_viewport()
if vp:
vp.set_input_as_handled()
return
if client and client.has_method("quest_cancel"):
client.quest_cancel()
elif client and client.has_method("script_answer"):
client.script_answer(254)
close()
var vp := get_viewport()
if vp:
vp.set_input_as_handled()
# --- 脚本解析(CPythonEventManager 1:1----------------------------------
#
# 参照 REF/UserInterface/PythonEventManager.{h,cpp}
# · EVENT_TYPE_MAP —— EventTypeMapPythonEventManager.cpp:1148 起 42 条)
# · _get_script_event_index —— GetScriptEventIndex:1093,前导 '/' = EVENT_POSITION_END
# · EventSet —— SEventSetPythonEventManager.h:98,字段名保持一致)
# · _process_event_set —— ProcessEventSet:377 的 switch 逐 case 对拷)
# · EventSet.insert_line —— __InsertLine:976
# · EventSet.clear_line —— ClearLine:953
# · _make_question —— MakeQuestion:884
# · _make_next_button 效果 —— MakeNextButton:878,本 POC 只置 has_next / iAdjustLine
#
# 网络 GC_SCRIPT 里的裸文本被折成合成 LETTERis_raw=true,跳过 iRestrictedCharacterCount
# 的 30 字断行——reference 脚本只有显式 [LETTER],裸文本是本客户端的扩展,逐字符断句会
# 破坏服务器写好的句子)。
#
# EventSet 现在由 _process_active 按 ClientVS22::UpdateEventSet 的毫秒延迟逐步消费;
# parse_script() 仍保留为离线兼容接口,用于一次性检查整个脚本的最终状态。
signal cinema_requested(filename: String)
enum {
EVT_LETTER, EVT_COLOR, EVT_DELAY, EVT_ENTER, EVT_WAIT, EVT_CLEAR, EVT_QUESTION,
EVT_NEXT, EVT_DONE,
EVT_LEFT_IMAGE, EVT_TOP_IMAGE, EVT_BACKGROUND_IMAGE, EVT_IMAGE,
EVT_ADD_MAP_SIGNAL, EVT_CLEAR_MAP_SIGNAL, EVT_SET_MESSAGE_POSITION,
EVT_ADJUST_MESSAGE_POSITION, EVT_SET_CENTER_MAP_POSITION,
EVT_QUEST_BUTTON, EVT_QUEST_BUTTON_CLOSE,
EVT_SLEEP, EVT_SET_CAMERA, EVT_BLEND_CAMERA, EVT_RESTORE_CAMERA,
EVT_FADE_OUT, EVT_FADE_IN, EVT_WHITE_OUT, EVT_WHITE_IN,
EVT_CLEAR_TEXT, EVT_TEXT_HORIZONTAL_ALIGN_CENTER, EVT_TITLE_IMAGE,
EVT_RUN_CINEMA, EVT_DUNGEON_RESULT,
EVT_ITEM_NAME, EVT_MONSTER_NAME,
EVT_COLOR256, EVT_WINDOW_SIZE,
EVT_INPUT, EVT_CONFIRM_WAIT, EVT_END_CONFIRM_WAIT,
EVT_INSERT_IMAGE, EVT_SELECT_ITEM,
}
# EventTypeMapPythonEventManager.cpp:1148-1207)。键即脚本 tag,前导 '/' 由
# _get_script_event_index 剥离后再查表。
const EVENT_TYPE_MAP := {
"LETTER": EVT_LETTER, "COLOR": EVT_COLOR, "DELAY": EVT_DELAY, "ENTER": EVT_ENTER,
"WAIT": EVT_WAIT, "CLEAR": EVT_CLEAR, "QUESTION": EVT_QUESTION, "NEXT": EVT_NEXT, "DONE": EVT_DONE,
"LEFTIMAGE": EVT_LEFT_IMAGE, "TOPIMAGE": EVT_TOP_IMAGE, "BGIMAGE": EVT_BACKGROUND_IMAGE, "IMAGE": EVT_IMAGE,
"ADDMAPSIGNAL": EVT_ADD_MAP_SIGNAL, "CLEARMAPSIGNAL": EVT_CLEAR_MAP_SIGNAL,
"SETMSGPOS": EVT_SET_MESSAGE_POSITION, "ADJMSGPOS": EVT_ADJUST_MESSAGE_POSITION,
"SETCMAPPOS": EVT_SET_CENTER_MAP_POSITION,
"QUESTBUTTON": EVT_QUEST_BUTTON, "QUESTBUTTON_CLOSE": EVT_QUEST_BUTTON_CLOSE,
"SLEEP": EVT_SLEEP, "SET_CAMERA": EVT_SET_CAMERA, "BLEND_CAMERA": EVT_BLEND_CAMERA,
"RESTORE_CAMERA": EVT_RESTORE_CAMERA, "FADE_OUT": EVT_FADE_OUT, "FADE_IN": EVT_FADE_IN,
"WHITE_OUT": EVT_WHITE_OUT, "WHITE_IN": EVT_WHITE_IN, "CLEAR_TEXT": EVT_CLEAR_TEXT,
"TEXT_HORIZONTAL_ALIGN_CENTER": EVT_TEXT_HORIZONTAL_ALIGN_CENTER, "TITLE_IMAGE": EVT_TITLE_IMAGE,
"RUN_CINEMA": EVT_RUN_CINEMA, "DUNGEON_RESULT": EVT_DUNGEON_RESULT,
"ITEM": EVT_ITEM_NAME, "MOB": EVT_MONSTER_NAME,
"COLOR256": EVT_COLOR256, "WINDOW_SIZE": EVT_WINDOW_SIZE,
"INPUT": EVT_INPUT, "CONFIRM_WAIT": EVT_CONFIRM_WAIT, "END_CONFIRM_WAIT": EVT_END_CONFIRM_WAIT,
"INSERT_IMAGE": EVT_INSERT_IMAGE, "SELECT_ITEM": EVT_SELECT_ITEM,
}
const EVENT_POSITION_START := 0
const EVENT_POSITION_END := 1
const C_NORMAL_WAITING_TIME := 10 # c_lNormal_Waiting_Time
const C_LINE_TEMP := 16 # c_fLine_Temp
const BOX_VISIBLE_LINE_COUNT := 5
# SEventSetPythonEventManager.h:98)—— 字段名与 C++ 保持一致,另加本 POC 的
# 行累加 / 副作用收集容器(顶替 CGraphicTextInstance 链表与 Python handler 回调)。
class EventSet:
extends RefCounted
var ix := 0
var iy := 0
var iWidth := 0
var iyLocal := 0
var isLock := false
var lLastDelayTime := 0
var iCurrentLetter := 0
var CurrentColor := Color(1, 1, 1, 1)
var strCurrentLine := ""
var isConfirmWait := false
var iConfirmEndTime := 0
var DiffuseColor := Color(1, 1, 1, 1)
var lWaitingTime := C_NORMAL_WAITING_TIME
var iRestrictedCharacterCount := 30
var iVisibleStartLine := 0
var iVisibleLineCount := BOX_VISIBLE_LINE_COUNT
var iAdjustLine := 0
var isTextCenterMode := false
var isWaitFlag := false
var nAnswer := 0
# --- POC 累加 ---
var lines: Array[String] = []
var color_open := false
var end_confirm := false
var choices: Array[String] = []
var has_next := false
var next_button_type := ""
var has_input := false
var has_select_item := false
var confirm_timeout := 0
var window_size: Array[int] = []
var images: Array[Dictionary] = []
var insert_images: Array[Dictionary] = []
var title_image := ""
var quest_buttons: Array[Dictionary] = []
var quest_button_closes: Array[int] = []
var map_signals: Array[Vector2] = []
var clear_map_signals := false
var atlas_center: Array[int] = []
var camera_events: Array[Dictionary] = []
var fade_events: Array[Dictionary] = []
var dungeon_result: Dictionary = {}
var cinema_file := ""
func init_event_set() -> void: # __InitEventSetPythonEventManager.cpp:72
ix = 0; iy = 0; iWidth = 0; iyLocal = 0
isLock = false
lLastDelayTime = 0
iCurrentLetter = 0
CurrentColor = Color(1, 1, 1, 1)
strCurrentLine = ""
isConfirmWait = false
iConfirmEndTime = 0
DiffuseColor = Color(1, 1, 1, 1)
lWaitingTime = C_NORMAL_WAITING_TIME
iRestrictedCharacterCount = 30
iVisibleStartLine = 0
iVisibleLineCount = BOX_VISIBLE_LINE_COUNT
iAdjustLine = 0
isTextCenterMode = false
isWaitFlag = false
# __InsertLineinit 时 pCurrentTextLine==NULL,不推行,只备一条空当前行。
func insert_line(_center := false) -> void: # __InsertLine:976
lines.append(strCurrentLine)
iyLocal += C_LINE_TEMP # __AddSpace(c_fLine_Temp)
strCurrentLine = ""
iCurrentLetter = 0
func clear_line() -> void: # ClearLine:953
lines.clear()
strCurrentLine = ""
iCurrentLetter = 0
color_open = false
func body_text() -> String:
var parts := lines.duplicate()
parts.append(strCurrentLine)
var s := "\n".join(parts)
if color_open:
s += "[/color]"
return s.strip_edges()
# GetScriptEventIndexPythonEventManager.cpp:1093)—— 前导 '/' = EVENT_POSITION_END。
# -> { ok:bool, position:int, type:int }
func _get_script_event_index(raw_name: String) -> Dictionary:
var position := EVENT_POSITION_START
var name := raw_name
if name.begins_with("/"):
position = EVENT_POSITION_END
name = name.substr(1)
name = name.to_upper()
if not EVENT_TYPE_MAP.has(name):
return {"ok": false, "position": position, "type": -1}
return {"ok": true, "position": position, "type": int(EVENT_TYPE_MAP[name])}
# -> { body, choices, has_next, has_input, has_select_item, confirm_wait,
# confirm_timeout, text_centered, window_size, images, title_image,
# quest_buttons, quest_button_closes, map_signals, clear_map_signals,
# atlas_center, camera_events, fade_events,
# insert_images, dungeon_result, adjust_line, waiting_time,
# is_locked, wait_flag, answer_count, cinema_file, end_confirm }
func parse_script(src: String) -> Dictionary:
var es := EventSet.new()
es.init_event_set()
var cmds := _tokenize(src)
# RUN_CINEMA 首命令特例(RegisterEventSetFromString:142 —— ScriptGroup.ReadCmd 读
# 第一条命令)。本 POC 无法加载 .msc 事件文件,记录文件名走 seam;其余 token 照常
# 处理。仅当 RUN_CINEMA 是脚本的第一个 token(无前导文本)时生效。
if not cmds.is_empty() and String(cmds[0].get("literal", "")) == "" \
and String(cmds[0].get("name", "")).to_upper().trim_prefix("/") == "RUN_CINEMA":
es.cinema_file = _arg_s(String(cmds[0].get("tok", "")), "value")
for cmd in cmds:
_process_event_set(es, cmd)
return {
"body": es.body_text(),
"choices": es.choices,
"has_next": es.has_next,
"next_button_type": es.next_button_type,
"has_input": es.has_input,
"has_select_item": es.has_select_item,
"confirm_wait": es.isConfirmWait,
"confirm_timeout": es.confirm_timeout,
"text_centered": es.isTextCenterMode,
"window_size": es.window_size,
"images": es.images,
"title_image": es.title_image,
"quest_buttons": es.quest_buttons,
"quest_button_closes": es.quest_button_closes,
"map_signals": es.map_signals,
"clear_map_signals": es.clear_map_signals,
"atlas_center": es.atlas_center,
"camera_events": es.camera_events,
"fade_events": es.fade_events,
# --- 增量 95 新增:EventSet 状态透出 ---
"insert_images": es.insert_images,
"dungeon_result": es.dungeon_result,
"adjust_line": es.iAdjustLine,
"waiting_time": es.lWaitingTime,
"is_locked": es.isLock,
"wait_flag": es.isWaitFlag,
"answer_count": es.nAnswer,
"cinema_file": es.cinema_file,
"end_confirm": es.end_confirm,
}
func _event_set_result(es: EventSet) -> Dictionary:
return {
"body": es.body_text(),
"choices": es.choices,
"has_next": es.has_next,
"next_button_type": es.next_button_type,
"has_input": es.has_input,
"has_select_item": es.has_select_item,
"confirm_wait": es.isConfirmWait,
"confirm_timeout": es.confirm_timeout,
"text_centered": es.isTextCenterMode,
"window_size": es.window_size,
"images": es.images,
"title_image": es.title_image,
"quest_buttons": es.quest_buttons,
"quest_button_closes": es.quest_button_closes,
"map_signals": es.map_signals,
"clear_map_signals": es.clear_map_signals,
"atlas_center": es.atlas_center,
"camera_events": es.camera_events,
"fade_events": es.fade_events,
"insert_images": es.insert_images,
"dungeon_result": es.dungeon_result,
"adjust_line": es.iAdjustLine,
"waiting_time": es.lWaitingTime,
"is_locked": es.isLock,
"wait_flag": es.isWaitFlag,
"answer_count": es.nAnswer,
"cinema_file": es.cinema_file,
"end_confirm": es.end_confirm,
}
# 把混排文本 / [tag ...] 拆成命令流。裸文本 -> { literal:String }
# tag -> { name:String(大写,带/前缀), pos:int, tok:String(方括号内原文) }。
func _tokenize(src: String) -> Array:
var out := []
var i := 0
var n := src.length()
while i < n:
var c := src[i]
if c == "[":
var close_i := src.find("]", i)
if close_i == -1:
out.append({"literal": src.substr(i)})
break
var tok := src.substr(i + 1, close_i - i - 1).strip_edges()
i = close_i + 1
if tok == "":
continue
var first := tok.split(" ", false)[0]
var pos := EVENT_POSITION_START
if first.begins_with("/"):
pos = EVENT_POSITION_END
out.append({"name": first.to_upper(), "pos": pos, "tok": tok})
else:
var next_tag := src.find("[", i)
if next_tag == -1:
next_tag = n
var lit := src.substr(i, next_tag - i)
if lit != "":
out.append({"literal": lit})
i = next_tag
return out
# ProcessEventSetPythonEventManager.cpp:377)的 switch 逐 case 对拷。
func _process_event_set(es: EventSet, cmd: Dictionary) -> void:
var literal := String(cmd.get("literal", ""))
if literal != "":
_evt_letter(es, literal, true)
return
var idx := _get_script_event_index(String(cmd.get("name", "")))
if not idx.ok:
# GetScriptEventIndex: 未知命令 -> PARSING ERROR,跳过(不进 switch)。
return
var etype: int = idx.type
var pos: int = idx.position
var tok := String(cmd.get("tok", ""))
match etype:
EVT_LETTER:
var vv := _tag_strings(tok)
if vv.size() > 0:
_evt_letter(es, vv[0], false)
EVT_DELAY:
# START: lWaitingTime = atoi(value)END(/DELAY): 复位 c_lNormal_Waiting_Time。
es.lWaitingTime = _arg_i(tok, "value", 0) if pos == EVENT_POSITION_START else C_NORMAL_WAITING_TIME
EVT_COLOR:
_evt_color(es, tok, pos, 1.0)
EVT_COLOR256:
_evt_color(es, tok, pos, 255.0)
EVT_ENTER:
es.insert_line()
EVT_WAIT:
es.iyLocal = 0
es.isLock = true
EVT_CLEAR, EVT_CLEAR_TEXT:
es.clear_line()
EVT_QUESTION:
_make_question(es, tok)
EVT_NEXT:
# MakeNextButton(BUTTON_TYPE_NEXT); iAdjustLine += 2。
es.has_next = true
es.next_button_type = "NEXT"
es.iAdjustLine += 2
EVT_DONE:
# MakeNextButton(BUTTON_TYPE_DONE); the reference invokes DoneEvent
# from the button callback, not while the token is being parsed.
es.has_next = true
es.next_button_type = "DONE"
es.iAdjustLine += 2
EVT_LEFT_IMAGE:
es.images.append({"panel": "LEFTIMAGE", "path": _arg_s(tok, "src")})
EVT_TOP_IMAGE:
es.images.append({"panel": "TOPIMAGE", "path": _arg_s(tok, "src")})
EVT_BACKGROUND_IMAGE:
es.images.append({"panel": "BGIMAGE", "path": _arg_s(tok, "src")})
EVT_IMAGE:
var ipath := _arg_s(tok, "src", 0)
if ipath != "":
es.images.append({"x": _arg_i(tok, "x", 0), "y": _arg_i(tok, "y", 1), "path": ipath})
EVT_INSERT_IMAGE:
var img := _arg_s(tok, "image_name")
es.insert_images.append({
"image_name": img,
"image_type": _arg_s(tok, "image_type"),
"idx": _arg_i(tok, "idx"),
"title": _arg_s(tok, "title"),
"desc": _arg_s(tok, "desc"),
"index": _arg_i(tok, "index"),
"total": _arg_i(tok, "total"),
"is_item_icon": img == "",
})
es.iAdjustLine += 2
EVT_ADD_MAP_SIGNAL:
es.map_signals.append(Vector2(_arg_f(tok, "x", 0), _arg_f(tok, "y", 1)))
EVT_CLEAR_MAP_SIGNAL:
es.clear_map_signals = true
EVT_QUEST_BUTTON_CLOSE:
es.quest_button_closes.append(_arg_i(tok, "idx", 0))
EVT_QUEST_BUTTON:
_make_quest_button(es, tok)
EVT_SET_MESSAGE_POSITION, EVT_ADJUST_MESSAGE_POSITION:
# PythonEventManager.cpp:562 / :566 —— 客户端故意留空(无副作用)。
pass
EVT_SET_CENTER_MAP_POSITION:
es.atlas_center = [_arg_i(tok, "x", 0), _arg_i(tok, "y", 1)]
EVT_SLEEP:
es.lLastDelayTime = _arg_i(tok, "value", 0)
EVT_SET_CAMERA:
es.camera_events.append({"kind": "SET_CAMERA", "values": _tag_numbers(tok),
"setting": _camera_setting(tok)})
EVT_BLEND_CAMERA:
es.camera_events.append({"kind": "BLEND_CAMERA", "values": _tag_numbers(tok),
"setting": _camera_setting(tok), "blendtime": _arg_f(tok, "blendtime")})
EVT_RESTORE_CAMERA:
es.camera_events.append({"kind": "RESTORE_CAMERA", "values": []})
EVT_FADE_OUT, EVT_FADE_IN, EVT_WHITE_OUT, EVT_WHITE_IN:
var kinds := {EVT_FADE_OUT: "FADE_OUT", EVT_FADE_IN: "FADE_IN",
EVT_WHITE_OUT: "WHITE_OUT", EVT_WHITE_IN: "WHITE_IN"}
es.fade_events.append({"kind": kinds[etype], "speed": _arg_f(tok, "speed", 0)})
es.isWaitFlag = true # 等 EndEventProcessseam:无逐帧循环,不真正阻塞)。
EVT_TEXT_HORIZONTAL_ALIGN_CENTER:
es.isTextCenterMode = true
EVT_TITLE_IMAGE:
es.title_image = _arg_s(tok, "src", 0)
EVT_RUN_CINEMA:
# ProcessEventSet 无此 case(仅 RegisterEventSetFromString 首命令阶段处理)。
pass
EVT_DUNGEON_RESULT:
es.dungeon_result = {
"killstone_count": _arg_i(tok, "killstone_count"),
"killmob_count": _arg_i(tok, "killmob_count"),
"find_hidden": _arg_i(tok, "find_hidden"),
"hidden_total": _arg_i(tok, "hidden_total"),
"use_potion": _arg_i(tok, "use_potion"),
"is_revived": _arg_i(tok, "is_revived"),
"killallmob": _arg_i(tok, "killallmob"),
"total_time": _arg_i(tok, "total_time"),
"bonus_exp": _arg_i(tok, "bonus_exp"),
}
EVT_ITEM_NAME:
_evt_item_name(es, _arg_i(tok, "value", 0))
EVT_MONSTER_NAME:
_evt_mob_name(es, _arg_i(tok, "value", 0))
EVT_WINDOW_SIZE:
var w := _arg_i(tok, "width", 0)
var h := _arg_i(tok, "height", 1)
if w > 0 and h > 0:
es.window_size = [w, h]
EVT_INPUT:
es.insert_line()
es.has_input = true
EVT_CONFIRM_WAIT:
es.confirm_timeout = _arg_i(tok, "timeout", 0)
es.isConfirmWait = true
# ClientVS22 stores the deadline in seconds (timeGetTime()/1000),
# not the timeout duration itself.
es.iConfirmEndTime = int(Time.get_ticks_msec() / 1000) + es.confirm_timeout
es.insert_line(true)
# MakeNextButton(BUTTON_TYPE_CANCEL) —— 取消按钮由 _fill_buttons(confirm_wait) 出。
EVT_END_CONFIRM_WAIT:
es.isConfirmWait = false
es.iConfirmEndTime = 0
es.end_confirm = true
EVT_SELECT_ITEM:
es.has_select_item = true
_:
pass
# EVENT_TYPE_LETTER / 裸文本(is_raw)。
func _evt_letter(es: EventSet, value: String, is_raw: bool) -> void:
es.strCurrentLine += value
if not is_raw:
es.iCurrentLetter += value.length()
if es.iCurrentLetter >= es.iRestrictedCharacterCount:
es.insert_line()
es.lLastDelayTime = es.lWaitingTime
# EVENT_TYPE_COLOR / EVENT_TYPE_COLOR256。BBCode span:新 span 前先关旧的。
func _evt_color(es: EventSet, tok: String, pos: int, divisor: float) -> void:
if es.color_open:
es.strCurrentLine += "[/color]"
es.color_open = false
if pos == EVENT_POSITION_START:
var kv := _tag_kv(tok)
var r: float
var g: float
var b: float
if kv.has("r") or kv.has("g") or kv.has("b"):
r = float(kv.get("r", "0"))
g = float(kv.get("g", "0"))
b = float(kv.get("b", "0"))
else:
var nums := _tag_numbers(tok)
if nums.size() < 3:
return
r = nums[0]; g = nums[1]; b = nums[2]
es.CurrentColor = Color(clampf(r / divisor, 0.0, 1.0),
clampf(g / divisor, 0.0, 1.0), clampf(b / divisor, 0.0, 1.0), 1.0)
es.strCurrentLine += "[color=#%s]" % es.CurrentColor.to_html(false)
es.color_open = true
else:
es.CurrentColor = Color(1, 1, 1, 1)
# MakeQuestionPythonEventManager.cpp:884)。
func _make_question(es: EventSet, tok: String) -> void:
# 40250 官方标准格式:[QUESTION 1;选项一|2;选项二|...]
# 参考 40250 服务端 questlua.cpp:642 GotoSelectState 与 客户端 EterLib/parser.cpp:180
var space_idx := tok.find(" ")
var body := tok.substr(space_idx + 1).strip_edges() if space_idx >= 0 else ""
if body.contains("|") or (body.contains(";") and not body.contains("arg(") and not body.contains("value(")):
var parts := body.split("|", false)
var options: Array[String] = []
for p in parts:
var trimmed := p.strip_edges()
if trimmed.is_empty():
continue
var semi := trimmed.find(";")
if semi >= 0:
var label := trimmed.substr(semi + 1).strip_edges()
options.append(label)
else:
options.append(trimmed)
if not options.is_empty():
for opt in options:
es.choices.append(opt)
es.nAnswer = options.size()
return
# 回落至 legacy 格式:[QUESTION arg("A") arg("B")]
var args := _tag_strings(tok)
if args.is_empty():
return
for a in args:
es.choices.append(String(a))
es.nAnswer = args.size()
# EVENT_TYPE_QUEST_BUTTON:558)—— idx/name/icon_type/icon_name,带位置回退。
func _make_quest_button(es: EventSet, tok: String) -> void:
var kv := _tag_kv(tok)
var nums := _tag_numbers(tok)
var strs := _tag_strings(tok)
var qidx := -1
if kv.has("idx") and String(kv["idx"]).is_valid_int():
qidx = int(kv["idx"])
elif nums.size() > 0:
qidx = int(nums[0])
if qidx < 0:
return
var nm := String(kv.get("name", strs[0] if strs.size() > 0 else ""))
var itype := String(kv.get("icon_type", strs[1] if strs.size() > 1 else ""))
var iname := String(kv.get("icon_name", strs[2] if strs.size() > 2 else ""))
es.quest_buttons.append({"index": qidx, "title": nm, "icon_type": itype, "icon_name": iname})
func _evt_item_name(es: EventSet, vnum: int) -> void:
var nm := _quest_item_name([float(vnum)])
es.strCurrentLine += nm
es.iCurrentLetter += nm.length()
if es.iCurrentLetter >= es.iRestrictedCharacterCount:
es.insert_line()
es.lLastDelayTime = es.lWaitingTime
func _evt_mob_name(es: EventSet, vnum: int) -> void:
var nm := _quest_mob_name([float(vnum)])
es.strCurrentLine += nm
es.iCurrentLetter += nm.length()
if es.iCurrentLetter >= es.iRestrictedCharacterCount:
es.insert_line()
es.lLastDelayTime = es.lWaitingTime
# GetCameraSettingFromArgListPythonEventManager.cpp:48)。
func _camera_setting(tok: String) -> Dictionary:
return {
"x": _arg_i(tok, "x"), "y": _arg_i(tok, "y"), "z": _arg_i(tok, "z"),
"up": _arg_i(tok, "up"), "view": _arg_i(tok, "view"), "cross": _arg_i(tok, "cross"),
"distance": _arg_i(tok, "distance"), "rot": _arg_i(tok, "rot"), "pitch": _arg_i(tok, "pitch"),
}
# --- arg 提取(keyword 优先,位置回退)--------------------------------
# key(val) / key("val") / key;val 的键值对。重复键取最后一个。
func _tag_kv(tok: String) -> Dictionary:
var out := {}
var raw := tok.substr(tok.find(" ") + 1) if tok.find(" ") >= 0 else ""
var i := 0
var n := raw.length()
while i < n:
while i < n and raw[i] in [" ", "\t", ","]:
i += 1
var start := i
while i < n and not (raw[i] in [" ", "\t", ",", "(", ";"]):
i += 1
if i >= n:
break
var key := raw.substr(start, i - start)
var val := ""
if raw[i] == "(":
var depth := 1
i += 1
var vs := i
while i < n and depth > 0:
if raw[i] == "(":
depth += 1
elif raw[i] == ")":
depth -= 1
if depth == 0:
break
i += 1
val = raw.substr(vs, i - vs)
i += 1
elif raw[i] == ";":
i += 1
var vs2 := i
while i < n and not (raw[i] in [" ", "\t", ","]):
i += 1
val = raw.substr(vs2, i - vs2)
val = val.strip_edges()
if val.length() >= 2 and val[0] == '"' and val[val.length() - 1] == '"':
val = val.substr(1, val.length() - 2)
if key != "":
out[key] = val
return out
func _arg_s(tok: String, key: String, fallback_idx: int = -1) -> String:
var kv := _tag_kv(tok)
if kv.has(key):
return String(kv[key])
if fallback_idx >= 0:
var strs := _tag_strings(tok)
if fallback_idx < strs.size():
return String(strs[fallback_idx])
return ""
func _arg_f(tok: String, key: String, fallback_idx: int = -1) -> float:
var kv := _tag_kv(tok)
if kv.has(key) and String(kv[key]).is_valid_float():
return float(kv[key])
if fallback_idx >= 0:
var nums := _tag_numbers(tok)
if fallback_idx < nums.size():
return nums[fallback_idx]
return 0.0
func _arg_i(tok: String, key: String, fallback_idx: int = -1) -> int:
return int(_arg_f(tok, key, fallback_idx))
func _tag_strings(tok: String) -> Array[String]:
var quoted: Array[String] = []
for raw in _rx_args(tok):
quoted.append(String(raw))
if not quoted.is_empty():
return quoted
var out: Array[String] = []
var raw := tok.substr(tok.find(" ") + 1).strip_edges() if tok.find(" ") >= 0 else ""
for part in raw.replace(",", ";").split(";", false):
var value := part.strip_edges()
if value in ["value", "arg"] or value.is_empty():
continue
if value.begins_with("value("):
value = value.trim_prefix("value(").trim_suffix(")")
if value.begins_with("arg("):
value = value.trim_prefix("arg(").trim_suffix(")")
if not value.is_empty():
out.append(value)
return out
func _tag_numbers(tok: String) -> Array[float]:
var out: Array[float] = []
var raw := tok.substr(tok.find(" ") + 1).strip_edges() if tok.find(" ") >= 0 else ""
raw = raw.replace(",", " ").replace(";", " ").replace("(", " ").replace(")", " ")
for part in raw.split(" ", false):
var value := part.strip_edges()
if value.is_valid_float():
out.append(float(value))
return out
func _quest_item_name(values: Array[float]) -> String:
if values.is_empty():
return ""
var vnum := int(values[0])
if proto and proto.has_method("item"):
var data: Dictionary = proto.item(vnum)
var name := String(data.get("locale_name", data.get("name", "")))
if name != "":
return name
return "物品 #%d" % vnum
func _quest_mob_name(values: Array[float]) -> String:
if values.is_empty():
return ""
var vid := int(values[0])
if client and client.has_method("get_entity"):
var entity: Dictionary = client.get_entity(vid)
var name := String(entity.get("name", ""))
if name != "":
return name
return "怪物 #%d" % vid
# 抓 tok 里所有 arg("...") / value("...") 的字符串
func _rx_args(tok: String) -> Array:
var out := []
var i := 0
while true:
var q1 := tok.find('"', i)
if q1 == -1:
break
var q2 := tok.find('"', q1 + 1)
if q2 == -1:
break
out.append(tok.substr(q1 + 1, q2 - q1 - 1))
i = q2 + 1
return out
# --- UI --------------------------------------------------------------
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-230, -160)
_root.size = Vector2(460, 300)
_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.08, 0.07, 0.06, 0.97)
sb.border_color = Color(0.5, 0.42, 0.3)
sb.set_border_width_all(1)
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
_panel = panel
_image_layer = Control.new()
_image_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
_image_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(_image_layer)
_text = RichTextLabel.new()
_text.bbcode_enabled = true
_text.position = Vector2(16, 14)
_text.size = Vector2(428, 200)
_text.add_theme_font_size_override("normal_font_size", 13)
_root.add_child(_text)
_btnrow = VBoxContainer.new()
_btnrow.position = Vector2(16, 222)
_btnrow.custom_minimum_size = Vector2(428, 0)
_btnrow.add_theme_constant_override("separation", 4)
_root.add_child(_btnrow)
func _render_images(images: Array, title_image: String) -> float:
if _image_layer == null:
return 0.0
for child in _image_layer.get_children():
_image_layer.remove_child(child)
child.queue_free()
var left_w := 0.0
for spec in images:
var path := String(spec.get("path", ""))
var tex: Texture2D = UiAssets.load_tex(_assets_root, path)
if tex == null:
continue
var image := TextureRect.new()
image.texture = tex
image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
image.mouse_filter = Control.MOUSE_FILTER_IGNORE
var panel_name := String(spec.get("panel", ""))
if panel_name == "BGIMAGE":
image.set_anchors_preset(Control.PRESET_FULL_RECT)
elif panel_name == "TOPIMAGE":
image.position = Vector2(0, 0)
image.size = Vector2(_root.size.x, minf(84.0, tex.get_size().y))
elif panel_name == "LEFTIMAGE":
var img_w := minf(160.0, tex.get_size().x)
var img_h := minf(_root.size.y - 28.0, tex.get_size().y)
image.position = Vector2(10, 14)
image.size = Vector2(img_w, img_h)
left_w = maxf(left_w, img_w + 14.0)
else:
image.position = Vector2(float(spec.get("x", 0)), float(spec.get("y", 0)))
image.size = tex.get_size()
_image_layer.add_child(image)
if title_image != "":
var title_tex: Texture2D = UiAssets.load_tex(_assets_root, title_image)
if title_tex:
var title := TextureRect.new()
title.texture = title_tex
title.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
title.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
title.position = Vector2((_root.size.x - title_tex.get_size().x) * 0.5, 4)
title.size = title_tex.get_size()
_image_layer.add_child(title)
return left_w
func _clear_buttons() -> void:
for c in _btnrow.get_children():
_btnrow.remove_child(c)
c.queue_free()
func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false,
next_button_type := "") -> void:
_clear_buttons()
if has_input:
var input := LineEdit.new()
input.placeholder_text = "请输入内容"
input.custom_minimum_size = Vector2(maxf(200.0, 428.0 - _current_left_w), 28)
_btnrow.add_child(input)
var submit := _mkbtn("确定")
submit.pressed.connect(func(): _submit_input(input))
input.text_submitted.connect(func(_text): _submit_input(input))
if input.is_inside_tree():
input.grab_focus()
else:
input.ready.connect(func():
if input.is_inside_tree():
input.grab_focus()
, CONNECT_ONE_SHOT)
return
if confirm_wait:
var cancel := _mkbtn("取消")
cancel.pressed.connect(func():
if client and client.has_method("quest_cancel"):
client.quest_cancel()
elif client and client.has_method("script_answer"):
client.script_answer(254)
close())
return
if choices.is_empty():
var is_done := next_button_type == "DONE"
var b := _mkbtn("关闭" if is_done else ("继续" if has_next else "关闭"))
b.pressed.connect(func():
if is_done:
done_event.emit()
if client and client.has_method("script_answer"):
client.script_answer(254)
close())
return
_render_choices(choices, 0)
func _render_choices(choices: Array, page: int = 0) -> void:
_cached_choices = choices
_current_choice_page = page
_clear_buttons()
var total := choices.size()
if total == 0:
return
if total <= MAX_CHOICES_PER_PAGE:
for i in total:
var idx := i
var b := _mkbtn(String(choices[i]))
b.pressed.connect(func():
if client and client.has_method("script_answer"):
client.script_answer(idx)
close())
return
# Multi-page pagination (40250 uiquest.py parity)
var start_idx := page * MAX_CHOICES_PER_PAGE
var end_idx := mini(start_idx + MAX_CHOICES_PER_PAGE, total)
var total_pages := int(ceil(float(total) / float(MAX_CHOICES_PER_PAGE)))
for i in range(start_idx, end_idx):
var idx := i
var b := _mkbtn(String(choices[i]))
b.pressed.connect(func():
if client and client.has_method("script_answer"):
client.script_answer(idx)
close())
var nav_row := HBoxContainer.new()
nav_row.alignment = BoxContainer.ALIGNMENT_CENTER
nav_row.add_theme_constant_override("separation", 12)
_btnrow.add_child(nav_row)
if page > 0:
var prev_btn := Button.new()
prev_btn.text = "上一页"
prev_btn.custom_minimum_size = Vector2(80, 26)
prev_btn.pressed.connect(func(): _render_choices(_cached_choices, page - 1))
nav_row.add_child(prev_btn)
var page_label := Label.new()
page_label.text = "%d / %d" % [page + 1, total_pages]
page_label.add_theme_font_size_override("font_size", 12)
nav_row.add_child(page_label)
if page < total_pages - 1:
var next_btn := Button.new()
next_btn.text = "下一页"
next_btn.custom_minimum_size = Vector2(80, 26)
next_btn.pressed.connect(func(): _render_choices(_cached_choices, page + 1))
nav_row.add_child(next_btn)
func _submit_input(input: LineEdit) -> void:
var val := input.text.strip_edges()
if client and client.has_method("quest_input"):
client.quest_input(val)
close()
func _fill_confirm() -> void:
_clear_buttons()
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
_btnrow.add_child(row)
for spec in [["接受", true], ["拒绝", false]]:
var yes: bool = spec[1]
var b := Button.new()
b.text = spec[0]
b.custom_minimum_size = Vector2(120, 30)
b.pressed.connect(func():
if client and client.has_method("quest_confirm"):
client.quest_confirm(yes, _confirm_pid)
close())
row.add_child(b)
func _mkbtn(text: String) -> Button:
var b := Button.new()
b.text = text
b.custom_minimum_size = Vector2(maxf(200.0, 428.0 - _current_left_w), 26)
_btnrow.add_child(b)
return b