Complete remaining client gap features
This commit is contained in:
+102
-4
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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 / h,1:1,增量 123 / 124;TAG_CHAR_W_PX / TAG_LINE_H_PX 仅作无字体兜底)。
|
||||
# 增量 125(SetItemTextTailOwner, PythonTextTail.cpp:706-737):有 owner 的掉落,参考端
|
||||
# owner 是与名字分开的 pOwnerTextInstance,单独 GetTextSize,令去重叠框 yEnd += ownerH + 4、
|
||||
# x 两边按 owner 半宽 ±1 外扩 —— 去重叠框把「名字」「owner + 所有格」两子串分别用 tag 的
|
||||
# 字体度量后走 TextTailArrange.item_box_with_owner 合成(框度量 1:1)。
|
||||
# 增量 126(SetItemTextTailOwner :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 strOwnership(IDS_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-207:owner 存在时 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-745:owner 非空 → 一个独立的 CENTER 黄字 (1,1,0) text instance,
|
||||
# 文本 = owner + strOwnership(:722/:723);owner 为空 → 删该实例(:741-745)。
|
||||
# poc:drop 节点下第二个 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
@@ -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 + timeout(seam)
|
||||
# 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():
|
||||
|
||||
Reference in New Issue
Block a user