1112 lines
40 KiB
GDScript
1112 lines
40 KiB
GDScript
# 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
|