# 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_ask(GC_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 —— EventTypeMap(PythonEventManager.cpp:1148 起 42 条) # · _get_script_event_index —— GetScriptEventIndex(:1093,前导 '/' = EVENT_POSITION_END) # · EventSet —— SEventSet(PythonEventManager.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 里的裸文本被折成合成 LETTER(is_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, } # EventTypeMap(PythonEventManager.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 # SEventSet(PythonEventManager.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: # __InitEventSet(PythonEventManager.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 # __InsertLine:init 时 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() # GetScriptEventIndex(PythonEventManager.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 # ProcessEventSet(PythonEventManager.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 # 等 EndEventProcess(seam:无逐帧循环,不真正阻塞)。 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) # MakeQuestion(PythonEventManager.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 # GetCameraSettingFromArgList(PythonEventManager.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