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

1112 lines
40 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()
var client: Node
var proto: Node
var _assets_root := ""
var _root: Control
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
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
_text.text = parsed.body
_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER if parsed.text_centered else HORIZONTAL_ALIGNMENT_LEFT
if parsed.window_size.size() >= 2:
var width := maxi(320, int(parsed.window_size[0]))
var height := maxi(220, int(parsed.window_size[1]))
_root.size = Vector2(width, height)
_text.size = Vector2(width - 32, height - 100)
_btnrow.position = Vector2(16, height - 78)
_btnrow.custom_minimum_size = Vector2(width - 32, 0)
_confirm_wait = parsed.confirm_wait
_render_images(parsed.images, parsed.title_image)
_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:
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:
# The legacy client sends QUEST_CANCEL when the active script is dismissed
# with Escape; ordinary answer buttons continue to use SCRIPT_ANSWER.
if _active_script:
skip()
get_viewport().set_input_as_handled()
return
if client and client.has_method("quest_cancel"):
client.quest_cancel()
close()
get_viewport().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:
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)
_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) -> void:
if _image_layer == null:
return
for child in _image_layer.get_children():
child.queue_free()
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":
image.position = Vector2(0, 0)
image.size = Vector2(minf(150.0, tex.get_size().x), _root.size.y)
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)
func _clear_buttons() -> void:
for c in _btnrow.get_children():
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(428, 28)
_btnrow.add_child(input)
var submit := _mkbtn("提交")
submit.pressed.connect(func(): _submit_input(input))
input.text_submitted.connect(func(_text): _submit_input(input))
return
if confirm_wait:
var cancel := _mkbtn("取消")
cancel.pressed.connect(func():
if client and client.has_method("quest_cancel"):
client.quest_cancel()
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:
# MakeNextButton(BUTTON_TYPE_DONE) wires DoneEvent to the
# actual click. Keep parse_script() side-effect free and only
# notify consumers when the player confirms the DONE button.
done_event.emit()
if client and client.has_method("script_answer"):
client.script_answer(255)
close())
return
for i in choices.size():
var idx := i
var b := _mkbtn(String(choices[i]))
b.pressed.connect(func():
client.script_answer(idx)
close())
func _submit_input(input: LineEdit) -> void:
if client and client.has_method("quest_input") and client.quest_input(input.text):
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():
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(428, 26)
_btnrow.add_child(b)
return b