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
+102 -4
View File
@@ -47,6 +47,9 @@ var _buffers := {} # tab -> Array[String]
var _locale: RefCounted
var _proto: Node
var _fishing_active := false
var _whisper_dialog: Panel
var _whisper_target: LineEdit
var _whisper_message: LineEdit
func setup(m2client: Node, parent: Node, assets_root := "") -> void:
client = m2client
@@ -84,10 +87,19 @@ func focus_input() -> void:
# 预填 "/w <名> " 并聚焦(好友列表点名字时调)。
func start_whisper(name: String) -> void:
if _input:
_input.text = "/w %s " % name
_input.caret_column = _input.text.length()
_input.grab_focus()
if _whisper_dialog == null or name.strip_edges() == "":
return
_whisper_target.text = name.strip_edges()
_whisper_message.clear()
_whisper_dialog.visible = true
if _whisper_message.is_inside_tree():
_whisper_message.grab_focus()
func is_whisper_open() -> bool:
return _whisper_dialog != null and _whisper_dialog.visible
func whisper_target() -> String:
return _whisper_target.text if _whisper_target != null else ""
func is_typing() -> bool:
return _input != null and _input.has_focus()
@@ -152,6 +164,92 @@ func _build(parent: Node) -> void:
_input.add_theme_font_size_override("font_size", 12)
_input.text_submitted.connect(_on_submit)
_root.add_child(_input)
_build_whisper_dialog(parent)
func _build_whisper_dialog(parent: Node) -> void:
# root/game.py 的 OpenWhisperDialog 是独立窗口,不应把私聊目标写回公共
# 聊天输入行。这里用 UiManager 同级的独立 Panel,保留自己的焦点和发送键。
_whisper_dialog = Panel.new()
_whisper_dialog.name = "WhisperDialog"
_whisper_dialog.set_anchors_preset(Control.PRESET_CENTER)
_whisper_dialog.position = Vector2(-170, -82)
_whisper_dialog.size = Vector2(340, 164)
_whisper_dialog.visible = false
_whisper_dialog.mouse_filter = Control.MOUSE_FILTER_STOP
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.04, 0.05, 0.08, 0.97)
bg.border_color = Color(0.62, 0.36, 0.72, 0.95)
bg.set_border_width_all(1)
bg.set_corner_radius_all(5)
_whisper_dialog.add_theme_stylebox_override("panel", bg)
parent.add_child(_whisper_dialog)
var title := Label.new()
title.text = "私聊"
title.position = Vector2(14, 10)
title.add_theme_font_size_override("font_size", 16)
_whisper_dialog.add_child(title)
var target_label := Label.new()
target_label.text = "对象"
target_label.position = Vector2(14, 45)
_whisper_dialog.add_child(target_label)
_whisper_target = LineEdit.new()
_whisper_target.position = Vector2(66, 41)
_whisper_target.size = Vector2(256, 26)
_whisper_target.editable = false
_whisper_target.mouse_filter = Control.MOUSE_FILTER_IGNORE
_whisper_dialog.add_child(_whisper_target)
var message_label := Label.new()
message_label.text = "内容"
message_label.position = Vector2(14, 80)
_whisper_dialog.add_child(message_label)
_whisper_message = LineEdit.new()
_whisper_message.position = Vector2(66, 76)
_whisper_message.size = Vector2(256, 26)
_whisper_message.placeholder_text = "输入私聊内容"
_whisper_message.text_submitted.connect(_on_whisper_submit)
_whisper_dialog.add_child(_whisper_message)
var cancel := Button.new()
cancel.text = "取消"
cancel.position = Vector2(170, 119)
cancel.size = Vector2(70, 28)
cancel.pressed.connect(_close_whisper)
_whisper_dialog.add_child(cancel)
var send := Button.new()
send.text = "发送"
send.position = Vector2(248, 119)
send.size = Vector2(74, 28)
send.pressed.connect(func(): _on_whisper_submit(_whisper_message.text))
_whisper_dialog.add_child(send)
func _close_whisper() -> void:
if _whisper_dialog:
_whisper_dialog.visible = false
if _whisper_message and _whisper_message.is_inside_tree():
_whisper_message.release_focus()
func _on_whisper_submit(text: String) -> void:
var to := _whisper_target.text.strip_edges() if _whisper_target else ""
var msg := text.strip_edges()
if to == "" or msg == "" or client == null:
return
client.whisper(to, msg)
_append(1, T_WHISPER, "%s: %s" % [to, msg])
_append(0, T_WHISPER, "%s: %s" % [to, msg])
_close_whisper()
func _unhandled_input(event: InputEvent) -> void:
if not is_whisper_open() or not (event is InputEventKey):
return
var key := event as InputEventKey
if not key.pressed:
return
if key.keycode == KEY_ESCAPE:
_close_whisper()
get_viewport().set_input_as_handled()
elif key.keycode not in [KEY_ENTER, KEY_KP_ENTER]:
# 独立私聊窗口打开时,WASD / 快捷栏键不应穿透到世界。
get_viewport().set_input_as_handled()
func _select_tab(i: int) -> void:
_tab = i
+106
View File
@@ -0,0 +1,106 @@
# DungeonResultUI —— CPythonEventManager::ShowDungeonResult 的轻量 Godot 载体。
#
# 参考端收到 [DUNGEON_RESULT] 后直接调用 interface.ShowDungeonResult,并把
# 九个整数交给独立结算窗口。本组件只负责呈现这九个服务端字段,不修改副本
# 状态,也不凭空发奖励;奖励仍由网络协议驱动。
extends Node
const RESULT_KEYS := [
"killstone_count", "killmob_count", "find_hidden", "hidden_total",
"use_potion", "is_revived", "killallmob", "total_time", "bonus_exp",
]
var _root: Control
var _rows: VBoxContainer
var _result: Dictionary = {}
func setup(parent: Node) -> void:
if _root != null:
return
_root = Control.new()
_root.name = "DungeonResult"
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-190, -175)
_root.size = Vector2(380, 350)
_root.visible = false
_root.mouse_filter = Control.MOUSE_FILTER_STOP
_root.z_index = 120
parent.add_child(_root)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.055, 0.045, 0.035, 0.98)
style.border_color = Color(0.78, 0.62, 0.30, 1.0)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
panel.add_theme_stylebox_override("panel", style)
_root.add_child(panel)
var title := Label.new()
title.text = "副本结算"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.position = Vector2(16, 14)
title.size = Vector2(348, 32)
title.add_theme_font_size_override("font_size", 20)
_root.add_child(title)
_rows = VBoxContainer.new()
_rows.position = Vector2(34, 58)
_rows.size = Vector2(312, 218)
_rows.add_theme_constant_override("separation", 5)
_root.add_child(_rows)
var close_button := Button.new()
close_button.text = "关闭"
close_button.position = Vector2(130, 300)
close_button.size = Vector2(120, 32)
close_button.pressed.connect(close)
_root.add_child(close_button)
func show_result(result: Dictionary) -> void:
if _root == null:
return
_result = result.duplicate(true)
for child in _rows.get_children():
child.queue_free()
for spec in _display_rows():
var row := Label.new()
row.text = str(spec[0]) + "" + str(spec[1])
row.add_theme_font_size_override("font_size", 14)
_rows.add_child(row)
_root.visible = true
func open(result: Dictionary) -> void:
show_result(result)
func close() -> void:
if _root:
_root.visible = false
func is_open() -> bool:
return _root != null and _root.visible
func result() -> Dictionary:
return _result.duplicate(true)
func _display_rows() -> Array:
var elapsed := maxi(0, int(_result.get("total_time", 0)))
var mins := elapsed / 60
var secs := elapsed % 60
return [
["击杀石碑", int(_result.get("killstone_count", 0))],
["击杀怪物", int(_result.get("killmob_count", 0))],
["隐藏目标", "%d / %d" % [int(_result.get("find_hidden", 0)), int(_result.get("hidden_total", 0))]],
["使用药水", int(_result.get("use_potion", 0))],
["复活次数", int(_result.get("is_revived", 0))],
["完成条件", "已完成" if int(_result.get("killallmob", 0)) != 0 else "未完成"],
["用时", "%02d:%02d" % [mins, secs]],
["奖励经验", int(_result.get("bonus_exp", 0))],
]
func _unhandled_input(event: InputEvent) -> void:
if is_open() and event is InputEventKey and event.pressed \
and not event.echo and event.keycode == KEY_ESCAPE:
close()
get_viewport().set_input_as_handled()
+1 -1
View File
@@ -92,7 +92,7 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
uiscript_dir = assets_root.path_join("uiscript")
_cfg.load(config_path())
for k in ["name_color", "target_board", "view_chat", "always_show_name", "show_damage", "salestext"]:
_display[k] = int(_cfg.get_value("gameopt", k, 1 if k in ["view_chat", "always_show_name", "show_damage"] else 0))
_display[k] = int(_cfg.get_value("gameopt", k, 1 if k in ["target_board", "view_chat", "always_show_name", "show_damage"] else 0))
_block_mode = int(_cfg.get_value("gameopt", "block_mode", 0))
if client and client.has_signal("block_mode_changed"):
client.block_mode_changed.connect(_on_server_block_mode)
+177 -8
View File
@@ -10,20 +10,58 @@ extends Node
const PICKUP_RANGE := 3.0
# §8.8 seam ⑩(CPythonTextTail::ArrangeTextTail, PythonTextTail.cpp:153-190):多条掉落
# 名条投影到屏幕后互相挤开防重叠。参考端在屏幕像素空间跑(y 向下),poc 每物一个
# billboard Label3D —— 把名条世界锚点投影到屏幕、按 Label3D 的真实字体度量取文本框,跑
# text_tail_arrange.arrange_item_tails 得到每条被下推的屏幕 y,再按 Label3D 的
# pixel_size 折回世界 y(屏幕向下 Δpx → 世界向下 Δpx*pixel_size)。
# 文本框宽 / 高取名条 Label3D 的真实字体度量(TextMetrics.label_width / label_height
# = GetTextSize 的 w / h1:1,增量 123 / 124TAG_CHAR_W_PX / TAG_LINE_H_PX 仅作无字体兜底)。
# 增量 125SetItemTextTailOwner, PythonTextTail.cpp:706-737):有 owner 的掉落,参考端
# owner 是与名字分开的 pOwnerTextInstance,单独 GetTextSize,令去重叠框 yEnd += ownerH + 4、
# x 两边按 owner 半宽 ±1 外扩 —— 去重叠框把「名字」「owner + 所有格」两子串分别用 tag 的
# 字体度量后走 TextTailArrange.item_box_with_owner 合成(框度量 1:1)。
# 增量 126SetItemTextTailOwner :712-745 + ArrangeTextTail :191-207):owner 后缀不再拼进
# 名字串,而是 drop 节点下第二个 Label3D "owner_tag" —— CENTER 对齐、黄字 (1,1,0)、文本
# = owner + strOwnership:722,本地化语素为空时字面 "'s",经 setup(possessive) 注入);
# owner 落在尾标基点、名字实例下移 15px:200 pTextInstance->SetPosition(x, y + 15.0f, z));
# owner 为空时删 owner_tag:741-745)。名字色仍走 _process 远近着色(参考名字实例取
# pInsertTextTail->Color 的道具品阶色)。
const TextTailArrange = preload("res://text_tail_arrange.gd")
const TextMetrics = preload("res://text_metrics.gd")
const TAG_BASE_Y := 0.75 # 名条相对掉落物的默认局部 y(未重叠时)
const TAG_CHAR_W_PX := 7.0 # 无字体时的每字符估宽兜底(有字体走 GetTextSize 的 w
const TAG_LINE_H_PX := 15.0 # 无字体时的单行估高兜底(有字体走 GetTextSize 的 h
const OWNER_POSSESSIVE := "'s" # SetItemTextTailOwner :722 strOwnershipIDS_POSSESSIVE_MORPHENE 为空时的字面值)
const OWNER_NAME_GAP_PX := 15.0 # ArrangeTextTail :200 pTextInstance->SetPosition(x, y + 15.0f, z)owner 行下方的名字行偏移
const DEFAULT_ITEM_COLOR := Color(1.0, 0.9, 0.5)
const ITEM_GRADE_COLORS := {
0: Color(1.0, 1.0, 1.0),
1: Color(0.55, 0.85, 1.0),
2: Color(1.0, 0.85, 0.25),
3: Color(0.85, 0.45, 1.0),
4: Color(1.0, 0.35, 0.35),
}
var client: Node
var mount: Node3D
var proto: Node
var item_list: RefCounted
var _player_getter: Callable
var _camera_getter: Callable
var _by_vid := {} # vid -> Node3D
var _possessive := OWNER_POSSESSIVE # setup(possessive) 注入;空 -> 字面 "'s"SetItemTextTailOwner :722
func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
proto_node: Node = null, il: RefCounted = null) -> void:
proto_node: Node = null, il: RefCounted = null,
camera_getter: Callable = Callable(), possessive := "") -> void:
client = m2client
_possessive = possessive if possessive != "" else OWNER_POSSESSIVE # :722 == "" ? "'s" : loc
mount = mount_node
proto = proto_node
item_list = il
_player_getter = player_getter
_camera_getter = camera_getter
if client.has_signal("ground_item_added"):
client.ground_item_added.connect(_on_added)
if client.has_signal("ground_item_removed"):
@@ -39,10 +77,17 @@ func _on_added(d: Dictionary) -> void:
return
var vnum := int(d.get("vnum", 0))
var owner := String(d.get("owner", ""))
var item_color := _item_color(vnum, d)
if _by_vid.has(vid):
var existing: Node3D = _by_vid[vid]
if is_instance_valid(existing):
(existing.get_node("tag") as Label3D).text = _tag_text(vnum, owner)
var existing_tag := existing.get_node("tag") as Label3D
existing_tag.text = _name_for(vnum)
existing_tag.modulate = item_color
existing.set_meta("item_name", existing_tag.text)
existing.set_meta("item_owner", owner)
existing.set_meta("item_color", item_color)
_apply_owner_tag(existing, owner, item_color)
return
var node := Node3D.new()
node.name = "drop_%d" % vid
@@ -60,14 +105,18 @@ func _on_added(d: Dictionary) -> void:
node.add_child(mesh)
var tag := Label3D.new()
tag.name = "tag"
tag.text = _tag_text(vnum, owner)
tag.position.y = 0.75
tag.text = _name_for(vnum) # 名字实例只放名字,owner 走独立 owner_tag:723 不再拼串)
tag.position.y = TAG_BASE_Y
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.005
tag.modulate = Color(1.0, 0.9, 0.5)
tag.modulate = item_color
node.add_child(tag)
node.set_meta("vid", vid)
node.set_meta("item_name", tag.text)
node.set_meta("item_owner", owner)
node.set_meta("item_color", item_color)
_apply_owner_tag(node, owner, item_color)
mount.add_child(node)
_by_vid[vid] = node
@@ -98,6 +147,67 @@ func _process(dt: float) -> void:
# 近了名条高亮
var near := p.global_position.distance_to(n.global_position) <= PICKUP_RANGE
(n.get_node("tag") as Label3D).modulate = Color(0.4, 1.0, 0.4) if near else Color(1.0, 0.9, 0.5)
_arrange_tags()
# ArrangeTextTail :153-190 的 item 块:把所有掉落名条投影到屏幕,跑 isIn 去重叠,
# 再把被下推的屏幕 Δy 折回各 Label3D 的世界局部 y。无相机 / 少于 2 条时全部回默认。
func _arrange_tags() -> void:
var cam: Camera3D = _camera_getter.call() if _camera_getter.is_valid() else null
if cam == null:
return
# m_ItemTextTailList 顺序:按 vid 排序保证稳定(≈ 注册先后)。
var vids: Array = _by_vid.keys()
vids.sort()
var tags: Array = [] # 与 boxes 平行:{tag, base_screen_y}
var boxes: Array = []
for vid in vids:
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
continue
var tag := n.get_node_or_null("tag") as Label3D
if tag == null:
continue
var anchor := n.global_position + Vector3(0, TAG_BASE_Y, 0)
if cam.is_position_behind(anchor):
_place_tail(n, tag, 0.0)
continue
var sp := cam.unproject_position(anchor)
tags.append({"n": n, "tag": tag, "y0": sp.y})
# 名字子串 + owner 子串分开度量(SetItemTextTailOwner 的 pOwnerTextInstance),
# 再走 item_box_with_owner 合成去重叠框(yEnd += ownerH + 4)。
var iname := String(n.get_meta("item_name", tag.text))
var iowner := String(n.get_meta("item_owner", ""))
var owner_txt := (iowner + _possessive) if iowner != "" else ""
var nbox: Dictionary = TextTailArrange.item_box_with_owner(
TextMetrics.string_width_like(tag, iname, TAG_CHAR_W_PX),
TextMetrics.string_height_like(tag, iname, TAG_LINE_H_PX),
TextMetrics.string_width_like(tag, owner_txt, TAG_CHAR_W_PX),
TextMetrics.string_height_like(tag, owner_txt, TAG_LINE_H_PX))
boxes.append({
"x": sp.x, "y": sp.y,
"w": maxf(1.0, nbox["w"]),
"h": maxf(1.0, nbox["h"]),
})
if boxes.size() < 2:
if boxes.size() == 1:
_place_tail(tags[0]["n"], tags[0]["tag"], 0.0)
return
var arranged: Array = TextTailArrange.arrange_item_tails(boxes)
for i in tags.size():
var d_px: float = arranged[i] - float(tags[i]["y0"]) # 屏幕向下为正
_place_tail(tags[i]["n"], tags[i]["tag"], d_px)
# ArrangeTextTail :191-207owner 存在时 owner 实例落在尾标基点 (x, y)、名字实例下移 15px
# :200 pTextInstance->SetPosition(x, y + 15.0f, z));无 owner 时名字实例落在基点 (:206)。
# d_px = 去重叠把该尾标下推的屏幕像素(0 = 未被推)。屏幕向下 → 世界向下(* pixel_size)。
func _place_tail(n: Node3D, tag: Label3D, d_px: float) -> void:
var px: float = tag.pixel_size
var owner_tag := n.get_node_or_null("owner_tag") as Label3D
if owner_tag != null:
owner_tag.position.y = TAG_BASE_Y - d_px * px
tag.position.y = TAG_BASE_Y - (d_px + OWNER_NAME_GAP_PX) * px
else:
tag.position.y = TAG_BASE_Y - d_px * px
# 捡最近的一个(范围内)。返回捡的 vid,0 = 没有。
func try_pickup() -> int:
@@ -145,6 +255,65 @@ func _name_for(vnum: int) -> String:
return item_list.type_of(vnum)
return "item %d" % vnum
func _tag_text(vnum: int, owner: String) -> String:
var text := _name_for(vnum)
return "%s (%s)" % [text, owner] if not owner.is_empty() else text
# SetItemTextTailOwner :712-745owner 非空 → 一个独立的 CENTER 黄字 (1,1,0) text instance
# 文本 = owner + strOwnership:722/:723);owner 为空 → 删该实例(:741-745)。
# pocdrop 节点下第二个 Label3D "owner_tag"(渲染顺序在名字之后,:373)。
func _apply_owner_tag(node: Node3D, owner: String, item_color := DEFAULT_ITEM_COLOR) -> void:
var owner_tag := node.get_node_or_null("owner_tag") as Label3D
if owner.is_empty():
if owner_tag != null:
node.remove_child(owner_tag)
owner_tag.queue_free()
return
if owner_tag == null:
owner_tag = Label3D.new()
owner_tag.name = "owner_tag"
owner_tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER # :727 HORIZONTAL_ALIGN_CENTER
owner_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
owner_tag.no_depth_test = true
owner_tag.pixel_size = 0.005
owner_tag.position.y = TAG_BASE_Y
node.add_child(owner_tag)
owner_tag.modulate = item_color # :729 的 pInsertTextTail->Color
owner_tag.text = owner + _possessive
func _item_color(vnum: int, drop: Dictionary) -> Color:
# GC_ITEM_GROUND_ADD 只带 vnum/owner/pos;优先消费服务端或 fixture 显式携带
# 的颜色,其次消费 item proto 的扩展字段。老数据没有品阶字段时保留旧的
# 金色 fallback,而不会把 owner 行错误固定成纯黄。
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
for source in [drop, pd]:
for key in ["item_color", "rarity_color", "name_color", "color"]:
if source.has(key):
var parsed: Variant = _coerce_color(source[key])
if parsed != null:
return parsed
for key in ["item_grade", "rarity", "quality", "grade"]:
if source.has(key):
var grade := int(source[key])
if ITEM_GRADE_COLORS.has(grade):
return ITEM_GRADE_COLORS[grade]
return DEFAULT_ITEM_COLOR
func _coerce_color(value: Variant) -> Variant:
if value is Color:
return value
if value is Array and value.size() >= 3:
var scale := 255.0 if float(value[0]) > 1.0 or float(value[1]) > 1.0 \
or float(value[2]) > 1.0 else 1.0
var alpha := float(value[3]) if value.size() > 3 else 1.0
if alpha > 1.0:
alpha /= 255.0
return Color(float(value[0]) / scale, float(value[1]) / scale,
float(value[2]) / scale, alpha)
if value is Dictionary and value.has_all(["r", "g", "b"]):
var scale := 255.0 if float(value["r"]) > 1.0 or float(value["g"]) > 1.0 \
or float(value["b"]) > 1.0 else 1.0
var alpha := float(value.get("a", 1.0))
if alpha > 1.0:
alpha /= 255.0
return Color(float(value["r"]) / scale, float(value["g"]) / scale,
float(value["b"]) / scale, alpha)
if value is String and not String(value).strip_edges().is_empty():
return Color.from_string(String(value), DEFAULT_ITEM_COLOR)
return null
+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():