Complete remaining client gap features

This commit is contained in:
shenlei
2026-09-04 13:25:05 +09:00
parent 13ccb8d02d
commit 9a4a044da5
47 changed files with 6667 additions and 140 deletions
+260 -19
View File
@@ -33,6 +33,9 @@ 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
@@ -43,27 +46,204 @@ 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
# --- 收 GC_SCRIPT ------------------------------------------------------
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))
func _on_script(_skin: int, text: String) -> void:
var parsed := parse_script(text)
if parsed.body.strip_edges() == "" and parsed.choices.is_empty():
_emit_side_effects(parsed)
# 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
@@ -77,10 +257,13 @@ func _on_script(_skin: int, text: String) -> void:
_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, _confirm_wait)
_root.visible = true
opened.emit()
_emit_side_effects(parsed)
_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:
@@ -97,13 +280,20 @@ func _emit_side_effects(parsed: Dictionary) -> void:
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:
camera_event_requested.emit(String(camera.get("kind", "")), camera.get("values", []))
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:
fade_event_requested.emit(String(fade.get("kind", "")), float(fade.get("speed", 0.0)))
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()
@@ -111,6 +301,7 @@ func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void:
opened.emit()
func close() -> void:
_finish_runtime()
if _root:
_root.visible = false
_confirm_wait = false
@@ -123,6 +314,10 @@ func _unhandled_input(event: InputEvent) -> void:
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()
@@ -144,9 +339,8 @@ func _unhandled_input(event: InputEvent) -> void:
# 的 30 字断行——reference 脚本只有显式 [LETTER],裸文本是本客户端的扩展,逐字符断句会
# 破坏服务器写好的句子)。
#
# seam(无逐帧 Update 循环):isLock/isWaitFlag/lLastDelayTime/lWaitingTime 作为 EventSet
# 状态保留并透出,但一次性把剩余 token 跑完(保证按钮 / 选项 / 副作用仍然落地);Skip /
# EndEventProcess / iConfirmEndTime 的 timeGetTime 走 seam。
# EventSet 现在由 _process_active 按 ClientVS22::UpdateEventSet 的毫秒延迟逐步消费;
# parse_script() 仍保留为离线兼容接口,用于一次性检查整个脚本的最终状态。
signal cinema_requested(filename: String)
@@ -224,6 +418,7 @@ class EventSet:
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
@@ -315,6 +510,7 @@ func parse_script(src: String) -> Dictionary:
"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,
@@ -342,6 +538,38 @@ func parse_script(src: String) -> Dictionary:
"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:
@@ -411,10 +639,13 @@ func _process_event_set(es: EventSet, cmd: Dictionary) -> void:
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); DoneEvent; iAdjustLine += 2。
# 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")})
@@ -501,7 +732,9 @@ func _process_event_set(es: EventSet, cmd: Dictionary) -> void:
EVT_CONFIRM_WAIT:
es.confirm_timeout = _arg_i(tok, "timeout", 0)
es.isConfirmWait = true
es.iConfirmEndTime = es.confirm_timeout # timeGetTime()/1000 + timeoutseam
# 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:
@@ -812,7 +1045,8 @@ 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) -> void:
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()
@@ -831,9 +1065,16 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w
close())
return
if choices.is_empty():
var b := _mkbtn("继续" if has_next else "关闭")
var is_done := next_button_type == "DONE"
var b := _mkbtn("关闭" if is_done else ("继续" if has_next else "关闭"))
b.pressed.connect(func():
client.script_answer(255)
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():