完善客户端功能并同步差距文档
This commit is contained in:
+12
-1
@@ -34,6 +34,7 @@ var _drag_origin := Vector2.ZERO
|
||||
var _map_origin := Vector2.ZERO
|
||||
var _map_px := Vector2.ZERO
|
||||
var _world_size_m := Vector2(1, 1)
|
||||
var _center_position_adjust := Vector2.ZERO
|
||||
|
||||
func setup(metin_world: Node, ui_manager: CanvasLayer, get_player: Callable,
|
||||
name := "", m2client: Node = null) -> void:
|
||||
@@ -64,6 +65,16 @@ func close() -> void:
|
||||
ui.close(_win)
|
||||
_win = null
|
||||
|
||||
# CPythonMiniMap::SetAtlasCenterPosition ultimately calls the atlas window's
|
||||
# SetCenterPositionAdjust. Keep the same screen-space adjustment instead of
|
||||
# treating this event as a world-coordinate teleport.
|
||||
func set_center_position_adjust(x: int, y: int) -> void:
|
||||
_center_position_adjust = Vector2(x, y)
|
||||
if _map_view and _map:
|
||||
_map_origin = (_map_view.size - _map.size) * 0.5 + _center_position_adjust
|
||||
_map.position = _map_origin
|
||||
_overlay.position = _map.position
|
||||
|
||||
func reload_map(metin_world: Node, name := "") -> void:
|
||||
world = metin_world
|
||||
if name != "":
|
||||
@@ -149,7 +160,7 @@ func _build_map() -> void:
|
||||
_map.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
_map.size = _map_px
|
||||
_map.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_map_origin = (_map_view.size - _map_px) * 0.5
|
||||
_map_origin = (_map_view.size - _map_px) * 0.5 + _center_position_adjust
|
||||
_map.position = _map_origin
|
||||
_map_view.add_child(_map)
|
||||
_overlay.position = _map.position
|
||||
|
||||
@@ -31,6 +31,12 @@ const JOB_BASE_STATS := {
|
||||
2: {"con": 3, "int": 5, "str": 5, "dex": 3}, # sura
|
||||
3: {"con": 4, "int": 6, "str": 3, "dex": 3}, # shaman
|
||||
}
|
||||
# §1.10 名称长度校验统一:与 wire 契约 CHARACTER_NAME_MAX_LEN(=24, name[25])
|
||||
# 对齐。角色名可含多字节字符,wire 缓冲是定长 25 字节,故按 UTF-8 字节长度设限,
|
||||
# 与 guild_ui.gd 的公会名校验(非空 / 超长 / 控制字符)保持同一形状。
|
||||
const CHAR_NAME_MAX_BYTES := 24
|
||||
const CHAR_NAME_MIN_CHARS := 2
|
||||
|
||||
const EMPIRE_KEY := {1: "EMPIRE_A", 2: "EMPIRE_B", 3: "EMPIRE_C"}
|
||||
const EMPIRE_FALLBACK := {0: "—", 1: "神獸王國", 2: "天朝王國", 3: "眞魔王國"}
|
||||
const EMPIRE_FLAG := {1: "a", 2: "b", 3: "c"}
|
||||
@@ -93,6 +99,17 @@ func _first_occupied() -> int:
|
||||
return i
|
||||
return 0
|
||||
|
||||
# §1.10:角色名校验("" = 合法)。创建与改名共用同一实现。
|
||||
func _validate_char_name(nm: String) -> String:
|
||||
if nm.length() < CHAR_NAME_MIN_CHARS:
|
||||
return "名称太短"
|
||||
if nm.to_utf8_buffer().size() > CHAR_NAME_MAX_BYTES:
|
||||
return "名称太长(最多 %d 字节)" % CHAR_NAME_MAX_BYTES
|
||||
for ch in nm:
|
||||
if ch.unicode_at(0) < 32:
|
||||
return "名称包含非法字符"
|
||||
return ""
|
||||
|
||||
# 由 app_flow 在收到新的 char_list(建/删号后 M2Client 重发)时调用,保持当前选中槽。
|
||||
func set_chars(chars: Array) -> void:
|
||||
var keep := _slot_index()
|
||||
@@ -560,9 +577,9 @@ func _open_create_dialog() -> void:
|
||||
nrow.add_child(_plain("名称", 13, Color(0.72, 0.78, 0.85)))
|
||||
var name_edit := LineEdit.new()
|
||||
name_edit.name = "Name"
|
||||
name_edit.max_length = 12
|
||||
name_edit.max_length = CHAR_NAME_MAX_BYTES
|
||||
name_edit.custom_minimum_size = Vector2(240, 0)
|
||||
name_edit.placeholder_text = "角色名(最多 12)"
|
||||
name_edit.placeholder_text = "角色名(最多 24 字节)"
|
||||
nrow.add_child(name_edit)
|
||||
|
||||
# 四维(起始属性,只读,随职业变)
|
||||
@@ -589,8 +606,9 @@ func _open_create_dialog() -> void:
|
||||
var ok := Button.new(); ok.text = "创建"; ok.custom_minimum_size = Vector2(120, 34)
|
||||
ok.pressed.connect(func():
|
||||
var nm := name_edit.text.strip_edges()
|
||||
if nm.length() < 2:
|
||||
_set_status("名称太短")
|
||||
var err := _validate_char_name(nm)
|
||||
if err != "":
|
||||
_set_status(err)
|
||||
return
|
||||
var j := jopt.get_selected_id()
|
||||
var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0])
|
||||
@@ -620,9 +638,9 @@ func _open_change_name_dialog() -> void:
|
||||
nrow.add_child(_plain("新名称", 13, Color(0.72, 0.78, 0.85)))
|
||||
var name_edit := LineEdit.new()
|
||||
name_edit.name = "Name"
|
||||
name_edit.max_length = 12
|
||||
name_edit.max_length = CHAR_NAME_MAX_BYTES
|
||||
name_edit.custom_minimum_size = Vector2(220, 0)
|
||||
name_edit.placeholder_text = "角色名(最多 12)"
|
||||
name_edit.placeholder_text = "角色名(最多 24 字节)"
|
||||
nrow.add_child(name_edit)
|
||||
box.add_child(_sep())
|
||||
var brow := HBoxContainer.new()
|
||||
@@ -634,8 +652,9 @@ func _open_change_name_dialog() -> void:
|
||||
ok.custom_minimum_size = Vector2(120, 34)
|
||||
ok.pressed.connect(func():
|
||||
var nm := name_edit.text.strip_edges()
|
||||
if nm.length() < 2:
|
||||
_set_status("名称太短")
|
||||
var err := _validate_char_name(nm)
|
||||
if err != "":
|
||||
_set_status(err)
|
||||
return
|
||||
_rename_pending = true
|
||||
_close_dialog()
|
||||
|
||||
+77
-11
@@ -9,6 +9,8 @@
|
||||
# 消费 M2Client:chat / whisper / item_picked_up。按类型分到 全部 / 私聊 / 系统 / 战斗 标签。
|
||||
extends Node
|
||||
|
||||
const Locale = preload("res://locale.gd")
|
||||
|
||||
# EChatType (wire.h)
|
||||
const T_TALKING := 0
|
||||
const T_INFO := 1
|
||||
@@ -42,9 +44,15 @@ var _tab := 0
|
||||
var _tab_btns := []
|
||||
var _max_lines := 200
|
||||
var _buffers := {} # tab -> Array[String]
|
||||
var _locale: RefCounted
|
||||
var _proto: Node
|
||||
var _fishing_active := false
|
||||
|
||||
func setup(m2client: Node, parent: Node) -> void:
|
||||
func setup(m2client: Node, parent: Node, assets_root := "") -> void:
|
||||
client = m2client
|
||||
if assets_root != "":
|
||||
_locale = Locale.new()
|
||||
_locale.setup(assets_root, "en")
|
||||
_build(parent)
|
||||
if client:
|
||||
if client.has_signal("chat"):
|
||||
@@ -56,6 +64,20 @@ func setup(m2client: Node, parent: Node) -> void:
|
||||
if client.has_signal("fishing_event"):
|
||||
client.fishing_event.connect(_on_fishing_event)
|
||||
|
||||
func set_proto(item_proto: Node) -> void:
|
||||
_proto = item_proto
|
||||
|
||||
func _loc(key: String, fallback: String, args: Array = []) -> String:
|
||||
if _locale and _locale.has(key):
|
||||
return _locale.t(key, args)
|
||||
return fallback % args if not args.is_empty() else fallback
|
||||
|
||||
func on_fishing_feedback(code: String) -> void:
|
||||
if code == "FISHING_WRONG_PLACE":
|
||||
var s := _loc("FISHING_WRONG_PLACE", "You cannot go fishing here.")
|
||||
_append(2, T_INFO, s)
|
||||
_append(3, T_INFO, s)
|
||||
|
||||
func focus_input() -> void:
|
||||
if _input:
|
||||
_input.grab_focus()
|
||||
@@ -70,6 +92,15 @@ func start_whisper(name: String) -> void:
|
||||
func is_typing() -> bool:
|
||||
return _input != null and _input.has_focus()
|
||||
|
||||
# game.py ToggleChatLogWindow hides/shows the complete chat log while keeping
|
||||
# the accumulated channel buffers. Enter can still focus the input afterward.
|
||||
func toggle_log() -> void:
|
||||
if _root:
|
||||
_root.visible = not _root.visible
|
||||
|
||||
func is_log_visible() -> bool:
|
||||
return _root != null and _root.visible
|
||||
|
||||
# --- 建 UI --------------------------------------------------------------
|
||||
|
||||
func _build(parent: Node) -> void:
|
||||
@@ -187,16 +218,51 @@ func _on_pickup(vnum: int, count: int, from: String) -> void:
|
||||
_append(3, T_INFO, s)
|
||||
|
||||
func _on_fishing_event(subheader: int, info: int, _dir: int) -> void:
|
||||
var s := ""
|
||||
if subheader == FISHING_SUCCESS:
|
||||
s = "鱼钩命中"
|
||||
elif subheader == FISHING_FAIL:
|
||||
s = "钓鱼失败"
|
||||
elif subheader == FISHING_FISH:
|
||||
s = "捕获物品 #%d" % info if info != 0 else "没有捕获到物品"
|
||||
if s != "":
|
||||
_append(2, T_INFO, s)
|
||||
_append(3, T_INFO, s)
|
||||
# RecvFishing only calls a UI callback for FAIL and FISH. SUCCESS is an
|
||||
# animation transition; the later FISH event decides notify vs success
|
||||
# from the actor's IsFishing() state.
|
||||
if subheader == 0: # FISHING_START
|
||||
_fishing_active = true
|
||||
return
|
||||
if subheader == 2: # FISHING_REACT
|
||||
_fishing_active = true
|
||||
return
|
||||
if subheader == FISHING_FAIL:
|
||||
_fishing_active = false
|
||||
var fail := _loc("FISHING_FAILURE", "You lost the Bait.")
|
||||
_append(2, T_INFO, fail)
|
||||
_append(3, T_INFO, fail)
|
||||
return
|
||||
if subheader != FISHING_FISH:
|
||||
if subheader == FISHING_SUCCESS:
|
||||
_fishing_active = false
|
||||
elif subheader == 1: # FISHING_STOP
|
||||
_fishing_active = false
|
||||
return
|
||||
if info == 0:
|
||||
var unknown := _loc("FISHING_UNKNOWN", "Something has taken the bait but you can't see what it is.")
|
||||
_append(2, T_INFO, unknown)
|
||||
_append(3, T_INFO, unknown)
|
||||
return
|
||||
var item: Dictionary = _proto.item(info) if _proto and _proto.has_method("item") else {}
|
||||
# ClientVS22 silently ignores a fish event when item_proto has no record.
|
||||
if item.is_empty():
|
||||
return
|
||||
var name := String(item.get("locale_name", item.get("name", "")))
|
||||
if name == "":
|
||||
name = "#%d" % info
|
||||
var is_fish := int(item.get("type", -1)) == 12 # CItemData::ITEM_TYPE_FISH
|
||||
var key := ""
|
||||
var fallback := ""
|
||||
if _fishing_active:
|
||||
key = "FISHING_NOTIFY1" if is_fish else "FISHING_NOTIFY2"
|
||||
fallback = "It looks like %s is hooked." if is_fish else "It looks like %s is on the hook."
|
||||
else:
|
||||
key = "FISHING_SUCCESS1" if is_fish else "FISHING_SUCCESS2"
|
||||
fallback = "You captured %s!" if is_fish else "You have pulled %s out of the water!"
|
||||
var s := _loc(key, fallback, [name])
|
||||
_append(2, T_INFO, s)
|
||||
_append(3, T_INFO, s)
|
||||
|
||||
# --- 追加行 --------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# CursorManager —— 对齐 ClientVS22 mousemodule.py 的光标状态机。
|
||||
#
|
||||
# 光标和“鼠标跟随物品”是两层:本节点只管理当前交互语义,
|
||||
# MouseController 负责跟随鼠标的物品图标。没有对应 .sub 资源时使用 Godot
|
||||
# 系统光标,避免把资源缺失误报成输入状态缺失。
|
||||
extends Node
|
||||
|
||||
signal changed(shape: String)
|
||||
|
||||
const NORMAL := "NORMAL"
|
||||
const ATTACK := "ATTACK"
|
||||
const TALK := "TALK"
|
||||
const CANT_GO := "CANT_GO"
|
||||
const PICK := "PICK"
|
||||
const BUY := "BUY"
|
||||
const SELL := "SELL"
|
||||
const ITEM := "ITEM"
|
||||
|
||||
var _shape := NORMAL
|
||||
|
||||
func set_cursor(shape: String) -> void:
|
||||
if shape not in [NORMAL, ATTACK, TALK, CANT_GO, PICK, BUY, SELL, ITEM]:
|
||||
shape = NORMAL
|
||||
_shape = shape
|
||||
Input.set_default_cursor_shape(_godot_shape(shape))
|
||||
changed.emit(shape)
|
||||
|
||||
func get_cursor() -> String:
|
||||
return _shape
|
||||
|
||||
func reset() -> void:
|
||||
set_cursor(NORMAL)
|
||||
|
||||
func _godot_shape(shape: String) -> Input.CursorShape:
|
||||
match shape:
|
||||
ATTACK:
|
||||
return Input.CURSOR_CROSS
|
||||
TALK, BUY, SELL, PICK:
|
||||
return Input.CURSOR_POINTING_HAND
|
||||
CANT_GO:
|
||||
return Input.CURSOR_FORBIDDEN
|
||||
ITEM:
|
||||
return Input.CURSOR_DRAG
|
||||
_:
|
||||
return Input.CURSOR_ARROW
|
||||
+97
-21
@@ -14,9 +14,15 @@
|
||||
# 时装(costume):Metin2 不发独立的 costume 槽包,服务器把 costume 的 vnum 直接写进
|
||||
# 主角实体的 parts[] 数组(ARMOR/HAIR)。所以每个渲染槽优先取 parts[],为 0 才回退
|
||||
# get_equipment()。这样 costume「自动」生效,也是渲染别的玩家用的同一条路。
|
||||
#
|
||||
# §4.1 修改 4(部位遮挡):解出身体 shape 后过 PartHiding —— 动物 / 怪物时装
|
||||
# (shape 100-103)抑制武器 + 头发;婚纱(shape 201)非捧花时抑制武器;变身
|
||||
# (seam,暂无实体字段)会把身体 shape 归 0 且清武器 / 头发。刷新顺序对齐
|
||||
# InstanceBase.cpp ChangeArmor:身体 → 头发 → 武器(→ 盾)。
|
||||
extends Node
|
||||
|
||||
const RaceSpec = preload("res://ui/race_spec.gd")
|
||||
const PartHiding = preload("res://part_hiding.gd")
|
||||
|
||||
const WEAR_BODY := 0
|
||||
const WEAR_HEAD := 1
|
||||
@@ -40,10 +46,12 @@ var _spec: RefCounted # RaceSpec(惰性)
|
||||
var _spec_tried := false
|
||||
var _last_weapon_vnum := -1
|
||||
var _last_body_vnum := -1
|
||||
var _last_head_vnum := -1
|
||||
var _last_head_vnum := -2 # -1 是「发型被遮挡」的有效态,用 -2 当「未应用」哨兵
|
||||
var _last_shield_vnum := -1
|
||||
var _last_hair_part := -1
|
||||
var main_getter: Callable = Callable() # func() -> int:主角 vid(取 parts 用)
|
||||
var _remote_mode := false
|
||||
var _remote_parts: Array = [] # 远端 PC 的 awPart[ARMOR..HAIR]
|
||||
# armor vnum -> race .msm 的 shape index。默认:有 proto 用 item_proto values[3]
|
||||
# (对齐客户端 __ArmorVnumToShape,SHAPE_VALUE_SLOT_INDEX=3),否则回退 =vnum。应用方可覆写。
|
||||
var armor_shape_of: Callable = func(vnum: int) -> int: return _armor_shape_default(vnum)
|
||||
@@ -70,6 +78,24 @@ func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "",
|
||||
if main_getter.is_valid() and int(main_getter.call()) == v:
|
||||
refresh())
|
||||
|
||||
# 远端 PC 不读取本地 get_equipment();参考端直接消费
|
||||
# GC_CHAR_ADDITIONAL_INFO / GC_CHARACTER_UPDATE 的 awPart[4]。
|
||||
func setup_remote(il: RefCounted, model_getter: Callable, assets := "",
|
||||
race_index := -1, parts := [], proto_node: Node = null) -> void:
|
||||
client = null
|
||||
item_list = il
|
||||
_model_getter = model_getter
|
||||
assets_root = assets
|
||||
race = race_index
|
||||
proto = proto_node
|
||||
_remote_mode = true
|
||||
_remote_parts = parts.duplicate() if parts is Array else []
|
||||
while _remote_parts.size() < 4:
|
||||
_remote_parts.append(0)
|
||||
if _remote_parts.size() > 4:
|
||||
_remote_parts = _remote_parts.slice(0, 4)
|
||||
refresh()
|
||||
|
||||
func set_race(r: int) -> void:
|
||||
race = r
|
||||
_spec = null
|
||||
@@ -81,36 +107,55 @@ func _on_inv_changed(window: int, _cell: int) -> void:
|
||||
|
||||
func refresh() -> void:
|
||||
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
|
||||
if model == null or client == null:
|
||||
if model == null or (client == null and not _remote_mode):
|
||||
return
|
||||
var eq: Array = client.get_equipment()
|
||||
if eq.size() <= WEAR_WEAPON:
|
||||
var eq: Array = client.get_equipment() if client != null and client.has_method("get_equipment") else []
|
||||
if not _remote_mode and eq.size() <= WEAR_WEAPON:
|
||||
return
|
||||
var parts := _parts() # 主角 parts[](时装 / 别的玩家用);空 -> 全回退 eq
|
||||
|
||||
# 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON
|
||||
var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON)
|
||||
if wpn_vnum != _last_weapon_vnum:
|
||||
_last_weapon_vnum = wpn_vnum
|
||||
model.set("weapon_gr2", _resolve_weapon(wpn_vnum))
|
||||
|
||||
if eq.size() > WEAR_SHIELD:
|
||||
var sh_vnum := int(eq[WEAR_SHIELD].get("vnum", 0))
|
||||
if sh_vnum != _last_shield_vnum:
|
||||
_last_shield_vnum = sh_vnum
|
||||
model.set("shield_gr2", _resolve_weapon(sh_vnum))
|
||||
|
||||
# 身体:parts[ARMOR] 优先(= 普通盔甲或时装),为 0 回退 WEAR_BODY
|
||||
# --- 身体:parts[ARMOR] 优先(普通盔甲或时装),为 0 回退 WEAR_BODY -------------
|
||||
var body_vnum := _eff(parts, PART_ARMOR, eq, WEAR_BODY)
|
||||
# 遮挡判定用的 shape(对齐 __ArmorVnumToShape:proto values[3] 非 0,否则 =vnum)
|
||||
var raw_shape := int(armor_shape_of.call(body_vnum)) if armor_shape_of.is_valid() else body_vnum
|
||||
var is_poly := _is_poly()
|
||||
if body_vnum != _last_body_vnum:
|
||||
_last_body_vnum = body_vnum
|
||||
_apply_body(model, body_vnum)
|
||||
|
||||
# 头:parts[HEAD] 优先,为 0 回退 WEAR_HEAD;有模型的头盔覆盖发型槽,否则用发型
|
||||
# --- 头 / 发:动物·怪物时装(shape 100-103)或变身 -> 隐藏;否则头盔覆盖 / 发型 ----
|
||||
var hair_off := PartHiding.hair_hidden(raw_shape, is_poly)
|
||||
var head_vnum := _eff(parts, PART_HEAD, eq, WEAR_HEAD)
|
||||
if head_vnum != _last_head_vnum:
|
||||
_last_head_vnum = head_vnum
|
||||
_apply_head_or_hair(model, head_vnum)
|
||||
var head_key := -1 if hair_off else head_vnum
|
||||
if head_key != _last_head_vnum:
|
||||
_last_head_vnum = head_key
|
||||
if hair_off:
|
||||
model.set("hair_gr2", "")
|
||||
model.set("hair_skin", "")
|
||||
_last_hair_part = -1 # 恢复时强制重挂发型
|
||||
else:
|
||||
_apply_head_or_hair(model, head_vnum)
|
||||
|
||||
# --- 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON;过 PartHiding 遮挡 ---------
|
||||
var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON)
|
||||
var wpn_eff := PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly)
|
||||
if wpn_eff != _last_weapon_vnum:
|
||||
_last_weapon_vnum = wpn_eff
|
||||
model.set("weapon_gr2", _resolve_weapon(wpn_eff))
|
||||
|
||||
# --- 盾(左手挂点,同武器遮挡规则)--------------------------------------------
|
||||
if eq.size() > WEAR_SHIELD:
|
||||
var sh_vnum := int(eq[WEAR_SHIELD].get("vnum", 0))
|
||||
var sh_eff := 0 if PartHiding.weapon_hidden(raw_shape, is_poly, sh_vnum) else sh_vnum
|
||||
if sh_eff != _last_shield_vnum:
|
||||
_last_shield_vnum = sh_eff
|
||||
model.set("shield_gr2", _resolve_weapon(sh_eff))
|
||||
|
||||
# §4.1 修改 4 seam:40250 实体表暂无变身字段(m2_client.cpp entity dict 无 poly_race /
|
||||
# polymorph)。一旦 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带上变身 race,这里改成读
|
||||
# client.get_entity(main_getter.call()).get("poly_race", 0) != 0 即可。
|
||||
func _is_poly() -> bool:
|
||||
return false
|
||||
|
||||
# 有效 vnum:主角 parts[part_idx] 非 0 就用它(时装 / 远端玩家),否则用装备槽
|
||||
func _eff(parts: Array, part_idx: int, eq: Array, wear_slot: int) -> int:
|
||||
@@ -119,6 +164,8 @@ func _eff(parts: Array, part_idx: int, eq: Array, wear_slot: int) -> int:
|
||||
return int(eq[wear_slot].get("vnum", 0)) if eq.size() > wear_slot else 0
|
||||
|
||||
func _parts() -> Array:
|
||||
if _remote_mode:
|
||||
return _remote_parts
|
||||
if not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
|
||||
return []
|
||||
var vid: int = main_getter.call()
|
||||
@@ -164,6 +211,8 @@ func _apply_hair_from_parts(model: Node, force := false) -> void:
|
||||
model.set("hair_skin", ts)
|
||||
|
||||
func _hair_part() -> int:
|
||||
if _remote_mode:
|
||||
return int(_remote_parts[PART_HAIR]) if _remote_parts.size() > PART_HAIR else -1
|
||||
if not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
|
||||
return -1
|
||||
var vid: int = main_getter.call()
|
||||
@@ -176,6 +225,10 @@ func _hair_part() -> int:
|
||||
return -1
|
||||
|
||||
func set_hair(index: int) -> void:
|
||||
if _remote_mode:
|
||||
_set_remote_part(PART_HAIR, index)
|
||||
refresh()
|
||||
return
|
||||
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
|
||||
if model == null:
|
||||
return
|
||||
@@ -189,6 +242,29 @@ func set_hair(index: int) -> void:
|
||||
if ts != "":
|
||||
model.set("hair_skin", ts)
|
||||
|
||||
func set_armor(index: int) -> void:
|
||||
if not _remote_mode:
|
||||
return
|
||||
_set_remote_part(PART_ARMOR, index)
|
||||
refresh()
|
||||
|
||||
func set_weapon(index: int) -> void:
|
||||
if not _remote_mode:
|
||||
return
|
||||
_set_remote_part(PART_WEAPON, index)
|
||||
refresh()
|
||||
|
||||
func set_head(index: int) -> void:
|
||||
if not _remote_mode:
|
||||
return
|
||||
_set_remote_part(PART_HEAD, index)
|
||||
refresh()
|
||||
|
||||
func _set_remote_part(part: int, value: int) -> void:
|
||||
while _remote_parts.size() <= part:
|
||||
_remote_parts.append(0)
|
||||
_remote_parts[part] = value
|
||||
|
||||
# --- body ------------------------------------------------------------------
|
||||
|
||||
# armor vnum -> item_proto values[3](非 0);否则 =vnum(对齐 __ArmorVnumToShape)
|
||||
|
||||
+191
-6
@@ -9,8 +9,11 @@
|
||||
# inventory_ui 在交易开着时右键道具 → 调 xu.offer(win, cell)。
|
||||
extends Node
|
||||
|
||||
const ItemTooltip = preload("res://ui/item_tooltip.gd")
|
||||
|
||||
var client: Node
|
||||
var proto: Node
|
||||
var _tooltip_builder: RefCounted
|
||||
var _root: Control
|
||||
var _self_box: VBoxContainer
|
||||
var _peer_box: VBoxContainer
|
||||
@@ -21,10 +24,24 @@ var _accept_btn: Button
|
||||
var _gold_input: LineEdit
|
||||
var _next_display := 0
|
||||
var _offered_cells := {}
|
||||
var _self_grid: GridContainer
|
||||
var _exchange_cells: Dictionary = {}
|
||||
var item_mouse: Node
|
||||
|
||||
const EXCHANGE_SLOT_COUNT := 12
|
||||
const INVENTORY_WINDOW := 1
|
||||
const EXCHANGE_LIMIT_RANGE := 1000.0
|
||||
const ITEM_ANTIFLAG_GIVE := 1 << 13
|
||||
|
||||
var _accept_pending := false
|
||||
var _anchor := Vector2.ZERO
|
||||
var _anchor_valid := false
|
||||
|
||||
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
|
||||
client = m2client
|
||||
proto = proto_node
|
||||
_tooltip_builder = ItemTooltip.new()
|
||||
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
|
||||
_build(parent)
|
||||
if client.has_signal("exchange_changed"):
|
||||
client.exchange_changed.connect(refresh)
|
||||
@@ -33,10 +50,16 @@ func is_open() -> bool:
|
||||
return _root != null and _root.visible
|
||||
|
||||
func close() -> void:
|
||||
if item_mouse and item_mouse.has_method("unregister_owner"):
|
||||
item_mouse.unregister_owner(self)
|
||||
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
|
||||
item_mouse.cancel()
|
||||
if _root:
|
||||
_root.visible = false
|
||||
_next_display = 0
|
||||
_offered_cells.clear()
|
||||
_accept_pending = false
|
||||
_anchor_valid = false
|
||||
if _gold_input:
|
||||
_gold_input.text = ""
|
||||
|
||||
@@ -47,6 +70,8 @@ func offer(inv_window: int, inv_cell: int) -> void:
|
||||
if _offered_cells.has(key):
|
||||
_status.text = "该物品已经放入交易"
|
||||
return
|
||||
if not _can_give(_item_at(inv_window, inv_cell)):
|
||||
return
|
||||
var x: Dictionary = client.get_exchange()
|
||||
var used := {}
|
||||
for item in x.get("self_items", []):
|
||||
@@ -73,6 +98,13 @@ func _name_of(vnum: int) -> String:
|
||||
return n
|
||||
return "#%d" % vnum
|
||||
|
||||
func _tooltip_for(item: Dictionary) -> String:
|
||||
var vnum := int(item.get("vnum", 0))
|
||||
if _tooltip_builder:
|
||||
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
|
||||
return _tooltip_builder.format(vnum, int(item.get("count", 1)), pd, item)
|
||||
return "%s\n#%d ×%d" % [_name_of(vnum), vnum, int(item.get("count", 1))]
|
||||
|
||||
func refresh() -> void:
|
||||
if client == null:
|
||||
return
|
||||
@@ -81,45 +113,183 @@ func refresh() -> void:
|
||||
_root.visible = false
|
||||
_next_display = 0
|
||||
_offered_cells.clear()
|
||||
_accept_pending = false
|
||||
_anchor_valid = false
|
||||
_gold_input.text = ""
|
||||
_status.text = ""
|
||||
return
|
||||
_root.visible = true
|
||||
_capture_anchor()
|
||||
_render_self_grid(x.get("self_items", []))
|
||||
_fill(_self_box, x.get("self_items", []))
|
||||
_fill(_peer_box, x.get("peer_items", []))
|
||||
_self_gold.text = "金币: %d" % int(x.get("self_gold", 0))
|
||||
_peer_gold.text = "金币: %d" % int(x.get("peer_gold", 0))
|
||||
var me: bool = x.get("self_accept", false)
|
||||
var peer: bool = x.get("peer_accept", false)
|
||||
var me := bool(x.get("self_accept", false))
|
||||
var peer := bool(x.get("peer_accept", false))
|
||||
# The server clears self_accept when either side changes the offer. Do not
|
||||
# invent a local transaction state: mirror the authoritative bit exactly.
|
||||
_accept_pending = me
|
||||
_accept_btn.text = "已接受 ✓" if me else "接受"
|
||||
_accept_btn.disabled = me
|
||||
_accept_btn.modulate = Color(0.5, 1, 0.5) if me else Color(1, 1, 1)
|
||||
_root.get_node("PeerAccept").text = "对方: 已接受" if peer else "对方: 未接受"
|
||||
_status.text = ""
|
||||
|
||||
func _item_at(inv_window: int, inv_cell: int) -> Dictionary:
|
||||
if client == null:
|
||||
return {}
|
||||
if client.has_method("get_item"):
|
||||
var direct: Dictionary = client.get_item(inv_window, inv_cell)
|
||||
if not direct.is_empty():
|
||||
return direct
|
||||
if inv_window == INVENTORY_WINDOW and client.has_method("get_inventory"):
|
||||
for item in client.get_inventory():
|
||||
if int(item.get("cell", -1)) == inv_cell:
|
||||
return item
|
||||
if inv_window == 2 and client.has_method("get_equipment"):
|
||||
var equipment: Array = client.get_equipment()
|
||||
if inv_cell >= 0 and inv_cell < equipment.size():
|
||||
return equipment[inv_cell]
|
||||
return {}
|
||||
|
||||
func _can_give(item_data: Dictionary) -> bool:
|
||||
var vnum := int(item_data.get("vnum", 0))
|
||||
var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
|
||||
var flags := int(item_data.get("anti_flags", 0)) | int(proto_data.get("anti_flags", 0))
|
||||
if (flags & ITEM_ANTIFLAG_GIVE) != 0:
|
||||
_status.text = "该物品不可交易"
|
||||
return false
|
||||
return true
|
||||
|
||||
func _render_self_grid(rows: Array) -> void:
|
||||
if _self_grid == null:
|
||||
return
|
||||
for pos in _exchange_cells:
|
||||
var cell: Button = _exchange_cells[pos]
|
||||
cell.text = ""
|
||||
cell.tooltip_text = ""
|
||||
cell.set_meta("vnum", 0)
|
||||
cell.set_meta("count", 0)
|
||||
for row in rows:
|
||||
var pos := int(row.get("slot", -1))
|
||||
if not _exchange_cells.has(pos):
|
||||
continue
|
||||
var vnum := int(row.get("vnum", 0))
|
||||
var count := int(row.get("count", 1))
|
||||
var cell: Button = _exchange_cells[pos]
|
||||
cell.text = "%s%s" % [_name_of(vnum).substr(0, 6), (" ×%d" % count) if count > 1 else ""]
|
||||
cell.tooltip_text = _tooltip_for(row)
|
||||
cell.set_meta("vnum", vnum)
|
||||
cell.set_meta("count", count)
|
||||
|
||||
func _drop_to_slot(payload: Dictionary, display_pos: int) -> bool:
|
||||
if payload.is_empty() or display_pos < 0 or display_pos >= EXCHANGE_SLOT_COUNT:
|
||||
return false
|
||||
if bool(payload.get("money", false)):
|
||||
if int(client.get_exchange().get("self_gold", 0)) > 0:
|
||||
_status.text = "金币已经放入,不能修改"
|
||||
return false
|
||||
var amount := int(payload.get("count", 0))
|
||||
if amount <= 0 or not client.has_method("exchange_add_gold"):
|
||||
return false
|
||||
return client.exchange_add_gold(amount)
|
||||
var source_window := int(payload.get("window", -1))
|
||||
var source_cell := int(payload.get("cell", -1))
|
||||
if source_window not in [INVENTORY_WINDOW, 2] or source_cell < 0:
|
||||
return false
|
||||
var item_data := payload.duplicate(true)
|
||||
var source_data := _item_at(source_window, source_cell)
|
||||
for key in source_data:
|
||||
if not item_data.has(key):
|
||||
item_data[key] = source_data[key]
|
||||
if not _can_give(item_data):
|
||||
return false
|
||||
for row in client.get_exchange().get("self_items", []):
|
||||
if int(row.get("slot", -1)) == display_pos:
|
||||
return false
|
||||
var key := "%d:%d" % [source_window, source_cell]
|
||||
if _offered_cells.has(key):
|
||||
return false
|
||||
if not client.has_method("exchange_add_item") or not client.exchange_add_item(
|
||||
source_window, source_cell, display_pos):
|
||||
_status.text = "交易请求发送失败"
|
||||
return false
|
||||
_offered_cells[key] = true
|
||||
return true
|
||||
|
||||
func _fill(box: VBoxContainer, rows: Array) -> void:
|
||||
for c in box.get_children():
|
||||
c.queue_free()
|
||||
for r in rows:
|
||||
var l := Label.new()
|
||||
l.text = "%s ×%d" % [_name_of(int(r.get("vnum", 0))), int(r.get("count", 1))]
|
||||
l.tooltip_text = _tooltip_for(r)
|
||||
l.add_theme_font_size_override("font_size", 12)
|
||||
box.add_child(l)
|
||||
|
||||
func _on_put_gold() -> void:
|
||||
var current := int(client.get_exchange().get("self_gold", 0))
|
||||
if current > 0:
|
||||
_status.text = "金币已经放入,不能修改"
|
||||
return
|
||||
var raw := _gold_input.text.strip_edges()
|
||||
if raw == "" or not raw.is_valid_int():
|
||||
_status.text = "请输入有效金币数量"
|
||||
return
|
||||
var g := int(raw)
|
||||
if g <= 0 or g > 2000000000:
|
||||
_status.text = "金币数量必须在 1~2000000000 之间"
|
||||
if g <= 0 or g > 9999999:
|
||||
_status.text = "金币数量必须在 1~9999999 之间"
|
||||
return
|
||||
if client.exchange_add_gold(g):
|
||||
if client.has_method("exchange_add_gold") and client.exchange_add_gold(g):
|
||||
_gold_input.text = ""
|
||||
_status.text = ""
|
||||
else:
|
||||
_status.text = "金币请求发送失败"
|
||||
|
||||
func _on_accept() -> void:
|
||||
if _accept_pending or _accept_btn.disabled:
|
||||
return
|
||||
if client.has_method("exchange_accept") and client.exchange_accept():
|
||||
_accept_pending = true
|
||||
_accept_btn.disabled = true
|
||||
else:
|
||||
_status.text = "接受请求发送失败"
|
||||
|
||||
func _capture_anchor() -> void:
|
||||
if _anchor_valid or client == null or not client.has_method("get_main_vid") \
|
||||
or not client.has_method("get_entity"):
|
||||
return
|
||||
var entity: Dictionary = client.get_entity(int(client.get_main_vid()))
|
||||
var p = entity.get("pos_cm", null)
|
||||
if p is Vector3:
|
||||
_anchor = Vector2(p.x, p.y)
|
||||
_anchor_valid = true
|
||||
elif entity.has("x") and entity.has("y"):
|
||||
_anchor = Vector2(float(entity.get("x", 0)), float(entity.get("y", 0)))
|
||||
_anchor_valid = true
|
||||
|
||||
func _current_position() -> Variant:
|
||||
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
|
||||
return null
|
||||
var entity: Dictionary = client.get_entity(int(client.get_main_vid()))
|
||||
var p = entity.get("pos_cm", null)
|
||||
if p is Vector3:
|
||||
return Vector2(p.x, p.y)
|
||||
if entity.has("x") and entity.has("y"):
|
||||
return Vector2(float(entity.get("x", 0)), float(entity.get("y", 0)))
|
||||
return null
|
||||
|
||||
func _process(_dt: float) -> void:
|
||||
if not is_open() or not _anchor_valid:
|
||||
return
|
||||
var current = _current_position()
|
||||
if current is Vector2 and (absf(current.x - _anchor.x) > EXCHANGE_LIMIT_RANGE \
|
||||
or absf(current.y - _anchor.y) > EXCHANGE_LIMIT_RANGE):
|
||||
if client.has_method("exchange_cancel"):
|
||||
client.exchange_cancel()
|
||||
close()
|
||||
|
||||
func _build(parent: Node) -> void:
|
||||
_root = Control.new()
|
||||
_root.set_anchors_preset(Control.PRESET_CENTER)
|
||||
@@ -148,6 +318,21 @@ func _build(parent: Node) -> void:
|
||||
_self_box.position = Vector2(20, 56)
|
||||
_self_box.custom_minimum_size = Vector2(200, 0)
|
||||
_root.add_child(_self_box)
|
||||
_self_grid = GridContainer.new()
|
||||
_self_grid.columns = 6
|
||||
_self_grid.position = Vector2(20, 138)
|
||||
_self_grid.add_theme_constant_override("h_separation", 3)
|
||||
_self_grid.add_theme_constant_override("v_separation", 3)
|
||||
_root.add_child(_self_grid)
|
||||
for pos in EXCHANGE_SLOT_COUNT:
|
||||
var cell := Button.new()
|
||||
cell.custom_minimum_size = Vector2(52, 32)
|
||||
cell.add_theme_font_size_override("font_size", 8)
|
||||
_self_grid.add_child(cell)
|
||||
_exchange_cells[pos] = cell
|
||||
if item_mouse and item_mouse.has_method("register_target"):
|
||||
item_mouse.register_target(cell,
|
||||
func(payload: Dictionary): return _drop_to_slot(payload, pos), self)
|
||||
_self_gold = Label.new()
|
||||
_self_gold.text = "金币: 0"
|
||||
_self_gold.position = Vector2(20, 250)
|
||||
@@ -191,7 +376,7 @@ func _build(parent: Node) -> void:
|
||||
bottom.add_child(put)
|
||||
_accept_btn = Button.new()
|
||||
_accept_btn.text = "接受"
|
||||
_accept_btn.pressed.connect(func() -> void: client.exchange_accept())
|
||||
_accept_btn.pressed.connect(_on_accept)
|
||||
bottom.add_child(_accept_btn)
|
||||
var cancel := Button.new()
|
||||
cancel.text = "取消"
|
||||
|
||||
@@ -32,6 +32,8 @@ func is_open() -> bool:
|
||||
func close() -> void:
|
||||
if _root:
|
||||
_root.visible = false
|
||||
if _invite_dialog:
|
||||
_invite_dialog.hide()
|
||||
|
||||
func toggle() -> void:
|
||||
_root.visible = not _root.visible
|
||||
@@ -94,6 +96,8 @@ func _on_friend_invite(name: String) -> void:
|
||||
func _answer_invite(accept: bool) -> void:
|
||||
var name := _pending_invite
|
||||
_pending_invite = ""
|
||||
if _invite_dialog:
|
||||
_invite_dialog.hide()
|
||||
if name != "" and client != null and client.has_method("friend_answer"):
|
||||
client.friend_answer(name, accept)
|
||||
|
||||
|
||||
@@ -60,6 +60,10 @@ const LABELS := {
|
||||
|
||||
const CFG_PATH := "user://system_option.cfg"
|
||||
|
||||
# uigameoption RefreshAlwaysShowName / 名字色 / 伤害数字等:radio 选中后广播给渲染层。
|
||||
# key ∈ DISPLAY_RADIOS 的 cfg key(name_color / always_show_name / show_damage / ...)。
|
||||
signal display_option_changed(key: String, value: int)
|
||||
|
||||
var ui: CanvasLayer
|
||||
var client: Node
|
||||
var assets_root := ""
|
||||
@@ -96,6 +100,10 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
|
||||
func is_open() -> bool:
|
||||
return not _win.is_empty() and is_instance_valid(_win.get("root"))
|
||||
|
||||
# 渲染层开局回填用:cfg [gameopt] 里某显示开关的当前值(0/1),缺省同 setup() 的默认。
|
||||
func display_value(key: String) -> int:
|
||||
return int(_display.get(key, 0))
|
||||
|
||||
func toggle() -> void:
|
||||
if is_open(): close()
|
||||
else: open()
|
||||
@@ -148,7 +156,8 @@ func _wire() -> void:
|
||||
_bind_radio(group, func(nm):
|
||||
var spec: Array = DISPLAY_RADIOS[nm]
|
||||
_display[spec[0]] = int(spec[1])
|
||||
_save())
|
||||
_save()
|
||||
display_option_changed.emit(String(spec[0]), int(spec[1])))
|
||||
var tb := _node("titlebar")
|
||||
if tb:
|
||||
for x in tb.find_children("*", "BaseButton", true, false):
|
||||
|
||||
@@ -118,6 +118,24 @@ func try_pickup() -> int:
|
||||
client.pickup_item(best_vid)
|
||||
return best_vid
|
||||
|
||||
# Mouse hover probe used by PlayerController's cursor state. Ground drops are
|
||||
# deliberately tested in screen space, so clicking a visible label / mesh
|
||||
# selects PICK even when the item is not the nearest drop in world distance.
|
||||
func hover_at(camera: Camera3D, screen_pos: Vector2) -> bool:
|
||||
if camera == null:
|
||||
return false
|
||||
var best := INF
|
||||
for node in _by_vid.values():
|
||||
if not is_instance_valid(node):
|
||||
continue
|
||||
if camera.is_position_behind(node.global_position):
|
||||
continue
|
||||
var p := camera.unproject_position(node.global_position + Vector3(0, 0.35, 0))
|
||||
var d := p.distance_to(screen_pos)
|
||||
if d <= 28.0:
|
||||
best = minf(best, d)
|
||||
return best < INF
|
||||
|
||||
func _name_for(vnum: int) -> String:
|
||||
if proto:
|
||||
var pd: Dictionary = proto.item(vnum)
|
||||
|
||||
+194
-21
@@ -10,39 +10,63 @@
|
||||
# 拖到别的格=move_item。inventory_changed 信号来时刷新。
|
||||
#
|
||||
# 格子编号约定(uiscript):0..89 背包,90+ 装备(wear = index-90)。
|
||||
# 新装备 / 腰带没有可靠的旧 uiscript 布局时,在背包右侧生成扩展面板;发包仍
|
||||
# 使用原客户端的 INVENTORY 全局 cell(90+wear / 152+belt cell)。
|
||||
# 新装备 / 腰带没有可靠的旧 uiscript 布局时,在背包右侧生成扩展面板。发包坐标见
|
||||
# `_to_wire()`:旧 11 格 wear(BODY..SHIELD)走 EQUIPMENT 窗(window=2, wear cell);
|
||||
# costume/ring/belt(wear >= 19)与 belt 背包走 INVENTORY 窗(window=1)的全局 cell
|
||||
# (109..113 / 152+belt cell,见 GameType.h `c_Costume_Slot_Start` / `c_Equipment_Belt`)。
|
||||
extends Node
|
||||
|
||||
# 右键点道具时发出(window, cell 为 wire 坐标)。P8 的商店 / 交易 / 仓库窗接它。
|
||||
signal item_context(window: int, cell: int)
|
||||
|
||||
# 背包物品右键穿戴被 EquipRules 前置门拦下时发出(code 见 equip_rules.gd,need = limit 要求值)。
|
||||
signal equip_rejected(code: String, need: int)
|
||||
|
||||
# 若设置:右键道具时先调它 (window, cell) -> bool;返回 true 表示已处理,不再 use_item。
|
||||
var context_consumer: Callable = Callable()
|
||||
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
const EquipRules = preload("res://equip_rules.gd")
|
||||
const ItemTooltip = preload("res://ui/item_tooltip.gd")
|
||||
|
||||
const EQUIP_BASE := 90 # uiscript EQUIPMENT_START_INDEX
|
||||
const BELT_BASE := 200 # 仅 UI 内部索引,避免与旧 slot_index 相撞
|
||||
const BELT_SLOT_START := 152 # GameType.c_Belt_Inventory_Slot_Start
|
||||
const BELT_CELL_COUNT := 16
|
||||
const WEAR_COUNT := 24
|
||||
const WEAR_BELT := 23
|
||||
const WINDOW_INVENTORY := 1 # mtnet::WINDOW_*
|
||||
const WEAR_COUNT := 24 # get_equipment() 数组上限(server wear 保留位;实际用到 0..23)
|
||||
|
||||
# wear 位置编号(GameType.h `EWearPositions` / `c_Equipment_*`,wear = 全局 cell - c_Equipment_Start(90))。
|
||||
# 旧 11 格(BODY..SHIELD)在客户端走 EQUIPMENT 窗(window=2, wear cell);
|
||||
# ENABLE_NEW_EQUIPMENT_SYSTEM 的 costume/ring/belt 仍留在 INVENTORY 窗(window=1)的
|
||||
# 全局 cell 命名空间里 —— c_Costume_Slot_Start = 90+19, c_New_Equipment_Start = 90+21。
|
||||
const WEAR_MAX_NUM := 11 # ItemData.h EWearPositions::WEAR_MAX_NUM
|
||||
const WEAR_COSTUME_BODY := 19 # GameType.h c_Costume_Slot_Body - c_Equipment_Start
|
||||
const WEAR_COSTUME_HAIR := 20 # c_Costume_Slot_Hair - c_Equipment_Start
|
||||
const WEAR_RING1 := 21 # c_Equipment_Ring1 - c_Equipment_Start
|
||||
const WEAR_RING2 := 22 # c_Equipment_Ring2 - c_Equipment_Start
|
||||
const WEAR_BELT := 23 # c_Equipment_Belt - c_Equipment_Start
|
||||
const NEW_EQUIP_WEAR_START := WEAR_COSTUME_BODY # >= 此值的 wear 位置走 INVENTORY 全局 cell
|
||||
const WINDOW_INVENTORY := 1 # mtnet::WINDOW_INVENTORY
|
||||
const WINDOW_EQUIPMENT := 2 # mtnet::WINDOW_EQUIPMENT(旧 11 格 wear)
|
||||
const WINDOW_SAFEBOX := 3 # global mouse payload source, not an inventory window
|
||||
const WINDOW_MALL := 4 # global mouse payload source, not an inventory window
|
||||
const BELT_RULES := [1, 2, 4, 6, 3, 3, 4, 6, 5, 5, 5, 6, 7, 7, 7, 7]
|
||||
|
||||
var ui: CanvasLayer # UiManager
|
||||
var client: Node # M2Client
|
||||
var proto: Node # Metin2Proto
|
||||
var item_list: RefCounted # ItemList(可空;给了就用它的 icon 路径)
|
||||
var item_tooltip: RefCounted
|
||||
var assets_root := ""
|
||||
var uiscript_dir := ""
|
||||
var item_mouse: Node # MouseController(可空,兼容旧测试桩)
|
||||
|
||||
var _win: Dictionary = {} # { root, nodes }
|
||||
var _cells := {} # ui_index:int -> Panel
|
||||
var _drag_from := -1
|
||||
var _combine_from := -1 # Shift 选中的“使用到物品”来源格
|
||||
var _hint: Label
|
||||
var _money_button: BaseButton
|
||||
|
||||
func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: String,
|
||||
il: RefCounted = null) -> void:
|
||||
@@ -51,6 +75,8 @@ func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: St
|
||||
proto = proto_node
|
||||
item_list = il
|
||||
assets_root = assets
|
||||
item_tooltip = ItemTooltip.new()
|
||||
item_tooltip.setup(assets_root, "en", proto)
|
||||
uiscript_dir = assets.path_join("uiscript/uiscript")
|
||||
if not DirAccess.dir_exists_absolute(uiscript_dir):
|
||||
uiscript_dir = assets.path_join("uiscript")
|
||||
@@ -65,6 +91,10 @@ func toggle() -> void:
|
||||
else: open()
|
||||
|
||||
func close() -> void:
|
||||
if item_mouse and item_mouse.has_method("unregister_owner"):
|
||||
item_mouse.unregister_owner(self)
|
||||
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
|
||||
item_mouse.cancel()
|
||||
if is_open():
|
||||
ui.close(_win["root"])
|
||||
_win = {}
|
||||
@@ -83,6 +113,7 @@ func open() -> void:
|
||||
_index_cells(_win["root"])
|
||||
_add_extended_cells(_win["root"])
|
||||
_wire_cells()
|
||||
_wire_money()
|
||||
refresh()
|
||||
|
||||
# --- 填充 ---------------------------------------------------------------
|
||||
@@ -95,34 +126,40 @@ func refresh() -> void:
|
||||
for d in client.get_inventory():
|
||||
var cell: Panel = _cells.get(int(d["cell"]), null)
|
||||
if cell:
|
||||
_fill_cell(cell, int(d["vnum"]), int(d["count"]))
|
||||
_fill_cell(cell, int(d["vnum"]), int(d["count"]), d)
|
||||
var eq: Array = client.get_equipment()
|
||||
for i in eq.size():
|
||||
var it: Dictionary = eq[i]
|
||||
var cell: Panel = _cells.get(EQUIP_BASE + i, null)
|
||||
if cell and int(it.get("vnum", 0)) != 0:
|
||||
_fill_cell(cell, int(it["vnum"]), int(it.get("count", 1)))
|
||||
for d in client.get_belt_inventory():
|
||||
_fill_cell(cell, int(it["vnum"]), int(it.get("count", 1)), it)
|
||||
var belt_items: Array = client.get_belt_inventory() if client.has_method("get_belt_inventory") else []
|
||||
for d in belt_items:
|
||||
var cell: Panel = _cells.get(BELT_BASE + int(d["cell"]), null)
|
||||
if cell:
|
||||
_fill_cell(cell, int(d["vnum"]), int(d.get("count", 1)))
|
||||
_fill_cell(cell, int(d["vnum"]), int(d.get("count", 1)), d)
|
||||
if is_instance_valid(_money_button):
|
||||
_money_button.text = _format_gold(_current_gold())
|
||||
_apply_belt_locks(eq)
|
||||
|
||||
func _on_inv_changed(_window: int, _cell: int) -> void:
|
||||
refresh()
|
||||
|
||||
func _fill_cell(cell: Panel, vnum: int, count: int) -> void:
|
||||
func _fill_cell(cell: Panel, vnum: int, count: int, instance_data: Dictionary = {}) -> void:
|
||||
_clear_cell(cell)
|
||||
cell.set_meta("vnum", vnum)
|
||||
cell.set_meta("count", count)
|
||||
cell.set_meta("item_data", instance_data.duplicate(true))
|
||||
var name := "item %d" % vnum
|
||||
var itype := 0
|
||||
var proto_data: Dictionary = {}
|
||||
if proto:
|
||||
var pd: Dictionary = proto.item(vnum)
|
||||
if not pd.is_empty():
|
||||
name = String(pd.get("locale_name", pd.get("name", name)))
|
||||
itype = int(pd.get("type", 0))
|
||||
cell.tooltip_text = "%s\n#%d%s" % [name, vnum, (" x%d" % count) if count > 1 else ""]
|
||||
proto_data = proto.item(vnum)
|
||||
if not proto_data.is_empty():
|
||||
name = String(proto_data.get("locale_name", proto_data.get("name", name)))
|
||||
if item_tooltip:
|
||||
cell.tooltip_text = item_tooltip.format(vnum, count, proto_data, instance_data)
|
||||
else:
|
||||
cell.tooltip_text = "%s\n#%d%s" % [name, vnum, (" x%d" % count) if count > 1 else ""]
|
||||
var tex := _icon(vnum)
|
||||
if tex:
|
||||
var tr := TextureRect.new()
|
||||
@@ -152,6 +189,7 @@ func _fill_cell(cell: Panel, vnum: int, count: int) -> void:
|
||||
func _clear_cell(cell: Panel) -> void:
|
||||
cell.set_meta("vnum", 0)
|
||||
cell.set_meta("count", 0)
|
||||
cell.set_meta("item_data", {})
|
||||
cell.tooltip_text = ""
|
||||
for c in cell.get_children():
|
||||
c.queue_free()
|
||||
@@ -175,7 +213,33 @@ func _index_cells(root: Node) -> void:
|
||||
func _wire_cells() -> void:
|
||||
for idx in _cells:
|
||||
var cell: Panel = _cells[idx]
|
||||
cell.gui_input.connect(func(e: InputEvent): _on_cell_input(int(idx), e))
|
||||
var cell_idx: int = int(idx)
|
||||
cell.gui_input.connect(func(e: InputEvent): _on_cell_input(cell_idx, e))
|
||||
if item_mouse and item_mouse.has_method("register_target"):
|
||||
item_mouse.register_target(cell, func(payload: Dictionary): return _drop_mouse_item(payload, cell_idx), self)
|
||||
|
||||
func _wire_money() -> void:
|
||||
_money_button = _win.get("nodes", {}).get("Money_Slot", null)
|
||||
if _money_button is BaseButton:
|
||||
_money_button.pressed.connect(_on_money_pressed)
|
||||
else:
|
||||
_money_button = null
|
||||
|
||||
func _current_gold() -> int:
|
||||
if client and client.has_method("get_points"):
|
||||
return maxi(0, int(client.get_points().get("gold", 0)))
|
||||
return 0
|
||||
|
||||
func _format_gold(gold: int) -> String:
|
||||
return str(gold)
|
||||
|
||||
func _on_money_pressed() -> void:
|
||||
if item_mouse == null or not item_mouse.has_method("attach_money"):
|
||||
return
|
||||
var gold := mini(_current_gold(), 9999999)
|
||||
if gold <= 0:
|
||||
return
|
||||
_ask_count("金币数量", gold, func(amount: int): item_mouse.attach_money(amount, "inventory"))
|
||||
|
||||
func _add_extended_cells(root: Control) -> void:
|
||||
# 旧 inventorywindow.py 只排出前 11 个传统装备位。剩余能力 / 时装 /
|
||||
@@ -190,7 +254,9 @@ func _add_extended_cells(root: Control) -> void:
|
||||
bg.set_border_width_all(1)
|
||||
panel.add_theme_stylebox_override("panel", bg)
|
||||
root.add_child(panel)
|
||||
_add_grid(panel, "扩展装备", 11, WEAR_COUNT, EQUIP_BASE, Vector2(10, 30))
|
||||
# 旧 uiscript 只排 BODY..SHIELD(wear 0..10)。EWearPositions 里 11..18 无位置,
|
||||
# 扩展面板只补 costume(19/20) / ring(21/22) / belt(23) 这 5 个真实新装备位。
|
||||
_add_grid(panel, "时装 / 戒指 / 腰带", WEAR_COSTUME_BODY, WEAR_BELT - WEAR_COSTUME_BODY + 1, EQUIP_BASE, Vector2(10, 30))
|
||||
_add_grid(panel, "腰带背包", 0, BELT_CELL_COUNT, BELT_BASE, Vector2(10, 242))
|
||||
_hint = Label.new()
|
||||
_hint.position = Vector2(10, 430)
|
||||
@@ -270,12 +336,24 @@ func _on_cell_input(ui_idx: int, e: InputEvent) -> void:
|
||||
elif e.button_index == MOUSE_BUTTON_LEFT:
|
||||
if e.pressed and vnum != 0:
|
||||
if e.shift_pressed:
|
||||
_begin_combine(ui_idx)
|
||||
if _item_count(ui_idx) > 1:
|
||||
_split_stack(ui_idx)
|
||||
else:
|
||||
_begin_combine(ui_idx)
|
||||
return
|
||||
if _combine_from != -1:
|
||||
if _combine_from != ui_idx:
|
||||
_use_to_item(_combine_from, ui_idx)
|
||||
return
|
||||
if item_mouse and item_mouse.has_method("attach_item"):
|
||||
var wire := _to_wire(ui_idx)
|
||||
var metadata: Dictionary = {}
|
||||
if client.has_method("get_item"):
|
||||
metadata = client.get_item(wire[0], wire[1])
|
||||
if item_mouse.attach_item(wire[0], wire[1], vnum, _item_count(ui_idx),
|
||||
_icon(vnum), "inventory", metadata):
|
||||
_drag_from = -1
|
||||
return
|
||||
_drag_from = ui_idx
|
||||
elif not e.pressed and _drag_from != -1:
|
||||
var over := _cell_under_mouse()
|
||||
@@ -283,6 +361,35 @@ func _on_cell_input(ui_idx: int, e: InputEvent) -> void:
|
||||
move_to(_drag_from, over, 1)
|
||||
_drag_from = -1
|
||||
|
||||
func _drop_mouse_item(payload: Dictionary, target_ui: int) -> bool:
|
||||
if client == null or payload.is_empty() or target_ui < 0:
|
||||
return false
|
||||
if bool(_cells.get(target_ui, null).get_meta("locked", false)) if _cells.get(target_ui, null) else true:
|
||||
return false
|
||||
var source_window := int(payload.get("window", -1))
|
||||
var source_cell := int(payload.get("cell", -1))
|
||||
if source_cell < 0:
|
||||
return false
|
||||
var target := _to_wire(target_ui)
|
||||
if source_cell == int(target[1]) and source_window == int(target[0]):
|
||||
return true
|
||||
if source_window == WINDOW_SAFEBOX:
|
||||
return target[0] == WINDOW_INVENTORY and client.has_method("safebox_checkout") \
|
||||
and client.safebox_checkout(source_cell, target[0], target[1])
|
||||
if source_window == WINDOW_MALL:
|
||||
return target[0] == WINDOW_INVENTORY and client.has_method("mall_checkout") \
|
||||
and client.mall_checkout(source_cell, target[0], target[1])
|
||||
if source_window not in [WINDOW_INVENTORY, 2]:
|
||||
return false
|
||||
return move_wire(source_window, source_cell, int(target[0]), int(target[1]),
|
||||
maxi(1, int(payload.get("count", 1))))
|
||||
|
||||
func move_wire(source_window: int, source_cell: int, target_window: int,
|
||||
target_cell: int, count := 1) -> bool:
|
||||
if client == null or not client.has_method("move_item"):
|
||||
return false
|
||||
return client.move_item(source_window, source_cell, target_window, target_cell, count)
|
||||
|
||||
# 把 ui 格子 from 的物品挪到 ui 格子 to(发 CG_ITEM_MOVE)
|
||||
func move_to(from_ui: int, to_ui: int, count := 1) -> bool:
|
||||
if client == null:
|
||||
@@ -291,13 +398,63 @@ func move_to(from_ui: int, to_ui: int, count := 1) -> bool:
|
||||
var b := _to_wire(to_ui)
|
||||
return client.move_item(a[0], a[1], b[0], b[1], count)
|
||||
|
||||
# 用掉 ui 格子里的物品(发 CG_ITEM_USE)
|
||||
# 用掉 ui 格子里的物品(发 CG_ITEM_USE)。
|
||||
# 对背包里的可穿戴物品(右键 = 穿),先跑 EquipRules.can_equip 前置门(§4.1 修改 2):
|
||||
# 校验不过 → 发 equip_rejected 并不发包(服务器仍是最终仲裁)。
|
||||
func use(ui_idx: int) -> bool:
|
||||
if client == null:
|
||||
return false
|
||||
if ui_idx < EQUIP_BASE:
|
||||
var cell: Panel = _cells.get(ui_idx, null)
|
||||
var vnum := int(cell.get_meta("vnum", 0)) if cell else 0
|
||||
if vnum != 0 and proto:
|
||||
var pi: Dictionary = proto.item(vnum)
|
||||
if EquipRules.is_equippable(pi):
|
||||
var verdict := EquipRules.can_equip(pi, -1, _equip_ctx())
|
||||
if not bool(verdict["ok"]):
|
||||
_set_hint(_equip_reject_hint(verdict))
|
||||
equip_rejected.emit(String(verdict["code"]), int(verdict["need"]))
|
||||
return false
|
||||
var w := _to_wire(ui_idx)
|
||||
return client.use_item(w[0], w[1])
|
||||
|
||||
# 公开校验入口(tooltip / 拖放目标预览可用)。wear 传 -1 = 用物品默认槽。
|
||||
func can_equip_item(vnum: int, wear := -1) -> Dictionary:
|
||||
if vnum == 0 or proto == null:
|
||||
return {"ok": false, "code": "NO_ITEM", "need": 0}
|
||||
return EquipRules.can_equip(proto.item(vnum), wear, _equip_ctx())
|
||||
|
||||
# 组装 EquipRules.can_equip 需要的运行期上下文。属性点客户端未按名透出 → 传 -1(seam)。
|
||||
func _equip_ctx() -> Dictionary:
|
||||
var ctx := {"race": 0, "empire": 0, "level": 0, "st": -1, "dx": -1, "ht": -1, "iq": -1}
|
||||
if client == null:
|
||||
return ctx
|
||||
if client.has_method("get_points"):
|
||||
var p: Dictionary = client.get_points()
|
||||
ctx["level"] = int(p.get("level", 0))
|
||||
if client.has_method("get_entities"):
|
||||
for e in client.get_entities():
|
||||
if bool(e.get("is_main", false)):
|
||||
ctx["race"] = int(e.get("race", 0))
|
||||
ctx["empire"] = int(e.get("empire", 0))
|
||||
if int(e.get("level", 0)) > 0:
|
||||
ctx["level"] = int(e.get("level", 0))
|
||||
break
|
||||
return ctx
|
||||
|
||||
func _equip_reject_hint(verdict: Dictionary) -> String:
|
||||
match String(verdict.get("code", "")):
|
||||
"ANTI_SEX": return "该装备不符合你的性别"
|
||||
"ANTI_JOB": return "该装备不符合你的职业"
|
||||
"ANTI_EMPIRE": return "该装备不符合你的帝国"
|
||||
"NOT_WEARABLE_HERE": return "该装备不能穿在这个位置"
|
||||
"LIMIT_LEVEL": return "需要等级 %d" % int(verdict.get("need", 0))
|
||||
"LIMIT_STR": return "需要力量 %d" % int(verdict.get("need", 0))
|
||||
"LIMIT_DEX": return "需要敏捷 %d" % int(verdict.get("need", 0))
|
||||
"LIMIT_INT": return "需要智力 %d" % int(verdict.get("need", 0))
|
||||
"LIMIT_CON": return "需要体力 %d" % int(verdict.get("need", 0))
|
||||
return "无法装备"
|
||||
|
||||
# Shift+左键(或 Shift+右键)选来源,再左键另一个非空格。
|
||||
func _begin_combine(ui_idx: int) -> void:
|
||||
if _combine_from == ui_idx:
|
||||
@@ -308,6 +465,16 @@ func _begin_combine(ui_idx: int) -> void:
|
||||
_drag_from = -1
|
||||
_set_hint("请选择目标物品(左键);再次 Shift 点击来源格可取消")
|
||||
|
||||
func _split_stack(ui_idx: int) -> void:
|
||||
var count := _item_count(ui_idx)
|
||||
if count <= 1 or item_mouse == null or not item_mouse.has_method("attach_item"):
|
||||
return
|
||||
_ask_count("拾取数量", count, func(amount: int):
|
||||
var wire := _to_wire(ui_idx)
|
||||
var metadata: Dictionary = client.get_item(wire[0], wire[1]) if client.has_method("get_item") else {}
|
||||
item_mouse.attach_item(wire[0], wire[1], int(_cells[ui_idx].get_meta("vnum", 0)),
|
||||
amount, _icon(int(_cells[ui_idx].get_meta("vnum", 0))), "inventory", metadata))
|
||||
|
||||
func _use_to_item(source_ui: int, target_ui: int) -> bool:
|
||||
var target: Panel = _cells.get(target_ui, null)
|
||||
if target == null or int(target.get_meta("vnum", 0)) == 0:
|
||||
@@ -399,5 +566,11 @@ func _to_wire(ui_idx: int) -> Array:
|
||||
if ui_idx >= BELT_BASE:
|
||||
return [WINDOW_INVENTORY, BELT_SLOT_START + ui_idx - BELT_BASE]
|
||||
if ui_idx >= EQUIP_BASE:
|
||||
return [WINDOW_INVENTORY, ui_idx]
|
||||
var wear := ui_idx - EQUIP_BASE
|
||||
# ENABLE_NEW_EQUIPMENT_SYSTEM 的 costume(19/20) / ring(21/22) / belt(23)
|
||||
# 在客户端里没有独立窗,仍用 INVENTORY 窗的全局 cell(109..113,见 GameType.h)。
|
||||
if wear >= NEW_EQUIP_WEAR_START:
|
||||
return [WINDOW_INVENTORY, EQUIP_BASE + wear]
|
||||
# 旧 11 格(BODY..SHIELD)走 EQUIPMENT 窗(window=2)+ 零基 wear cell。
|
||||
return [WINDOW_EQUIPMENT, wear]
|
||||
return [WINDOW_INVENTORY, ui_idx]
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# ItemTooltip —— item_proto + itemdesc + 物品实例状态的统一文字提示。
|
||||
#
|
||||
# 参考:ClientVS22/POC/assets/root/uitooltip.py::ItemToolTip.AddItemData。
|
||||
# Godot 的原生 tooltip 负责显示位置与换行;本类只负责把同一份物品快照
|
||||
# 组织成标题、描述、限制、基础数值、附加属性、魂石槽和精炼关联。
|
||||
extends RefCounted
|
||||
|
||||
const ITEM_TYPE_WEAPON := 1
|
||||
const ITEM_TYPE_ARMOR := 2
|
||||
const ITEM_TYPE_LOTTERY := 8
|
||||
const ITEM_TYPE_FISH := 12
|
||||
const ITEM_TYPE_ROD := 13
|
||||
const ITEM_TYPE_UNIQUE := 16
|
||||
const ITEM_TYPE_PICK := 24
|
||||
const ITEM_TYPE_BLEND := 27
|
||||
const ITEM_TYPE_DS := 29
|
||||
const ITEM_TYPE_RING := 33
|
||||
const ITEM_TYPE_BELT := 34
|
||||
|
||||
const LIMIT_NAMES := {
|
||||
1: "等级要求",
|
||||
2: "力量要求",
|
||||
3: "敏捷要求",
|
||||
4: "智力要求",
|
||||
5: "体力要求",
|
||||
6: "网吧要求",
|
||||
7: "实时期限",
|
||||
8: "首次使用期限",
|
||||
9: "穿戴计时",
|
||||
}
|
||||
|
||||
const TYPE_NAMES := {
|
||||
1: "武器",
|
||||
2: "防具",
|
||||
3: "使用品",
|
||||
4: "自动使用品",
|
||||
5: "材料",
|
||||
6: "特殊物品",
|
||||
7: "工具",
|
||||
8: "抽奖品",
|
||||
9: "金币",
|
||||
10: "魔石",
|
||||
11: "容器",
|
||||
12: "鱼",
|
||||
13: "鱼竿",
|
||||
14: "资源",
|
||||
16: "独特物品",
|
||||
17: "技能书",
|
||||
18: "任务物品",
|
||||
19: "变身物品",
|
||||
20: "宝箱",
|
||||
21: "宝箱钥匙",
|
||||
22: "遗忘书",
|
||||
23: "礼盒",
|
||||
24: "镐",
|
||||
25: "发型",
|
||||
26: "图腾",
|
||||
27: "混合物",
|
||||
28: "时装",
|
||||
29: "龙魂石",
|
||||
30: "特殊龙魂石",
|
||||
31: "提取物",
|
||||
32: "副货币",
|
||||
33: "戒指",
|
||||
34: "腰带",
|
||||
}
|
||||
|
||||
# APPLY_* 编号来自 ClientVS22/source/GameLib/ItemData.h::EApplyTypes。
|
||||
const APPLY_NAMES := {
|
||||
1: "最大生命",
|
||||
2: "最大法力",
|
||||
3: "体质",
|
||||
4: "智力",
|
||||
5: "力量",
|
||||
6: "敏捷",
|
||||
7: "攻击速度",
|
||||
8: "移动速度",
|
||||
9: "施法速度",
|
||||
10: "生命恢复",
|
||||
11: "法力恢复",
|
||||
12: "中毒概率",
|
||||
13: "眩晕概率",
|
||||
14: "缓慢概率",
|
||||
15: "暴击概率",
|
||||
16: "穿透概率",
|
||||
17: "对人族伤害",
|
||||
18: "对动物伤害",
|
||||
19: "对兽人伤害",
|
||||
20: "对秘境伤害",
|
||||
21: "对不死族伤害",
|
||||
22: "对恶魔伤害",
|
||||
23: "生命吸收",
|
||||
24: "法力吸收",
|
||||
25: "法力燃烧",
|
||||
26: "伤害转法力",
|
||||
27: "格挡",
|
||||
28: "闪避",
|
||||
29: "剑防御",
|
||||
30: "双手防御",
|
||||
31: "匕首防御",
|
||||
32: "铃防御",
|
||||
33: "扇防御",
|
||||
34: "弓防御",
|
||||
35: "火抗性",
|
||||
36: "雷抗性",
|
||||
37: "魔法抗性",
|
||||
38: "风抗性",
|
||||
39: "近战反射",
|
||||
40: "诅咒反射",
|
||||
41: "中毒抵抗",
|
||||
42: "击杀恢复法力",
|
||||
43: "经验加成",
|
||||
44: "金币加成",
|
||||
45: "掉落加成",
|
||||
46: "药水效果",
|
||||
47: "击杀恢复生命",
|
||||
48: "免疫眩晕",
|
||||
49: "免疫缓慢",
|
||||
50: "免疫跌落",
|
||||
51: "技能",
|
||||
52: "弓箭距离",
|
||||
53: "攻击等级",
|
||||
54: "防御等级",
|
||||
55: "魔法攻击等级",
|
||||
56: "魔法防御等级",
|
||||
57: "诅咒概率",
|
||||
58: "最大耐力",
|
||||
59: "对战士伤害",
|
||||
60: "对刺客伤害",
|
||||
61: "对武士伤害",
|
||||
62: "对萨满伤害",
|
||||
63: "对怪物伤害",
|
||||
64: "商城攻击加成",
|
||||
65: "商城防御加成",
|
||||
66: "商城经验加成",
|
||||
67: "商城掉落加成",
|
||||
68: "商城金币加成",
|
||||
69: "最大生命百分比",
|
||||
70: "最大法力百分比",
|
||||
71: "技能伤害",
|
||||
72: "普通攻击伤害",
|
||||
73: "技能防御",
|
||||
74: "普通攻击防御",
|
||||
75: "生命提取",
|
||||
76: "网吧经验加成",
|
||||
77: "网吧掉落加成",
|
||||
78: "战士抗性",
|
||||
79: "刺客抗性",
|
||||
80: "武士抗性",
|
||||
81: "萨满抗性",
|
||||
82: "能量",
|
||||
83: "防御",
|
||||
84: "时装属性加成",
|
||||
85: "魔法攻击百分比",
|
||||
86: "近战魔法攻击百分比",
|
||||
87: "冰抗性",
|
||||
88: "土抗性",
|
||||
89: "暗抗性",
|
||||
90: "抗暴击",
|
||||
91: "抗穿透",
|
||||
}
|
||||
|
||||
var _descriptions: Dictionary = {}
|
||||
var _skill_names: Dictionary = {}
|
||||
var _mob_proto: Object = null
|
||||
|
||||
func setup(assets_root: String, lang := "en", proto_node: Object = null) -> bool:
|
||||
_descriptions.clear()
|
||||
_skill_names.clear()
|
||||
_mob_proto = proto_node
|
||||
var path := assets_root.path_join("locale/locale/%s/itemdesc.txt" % lang)
|
||||
if FileAccess.file_exists(path):
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file:
|
||||
while not file.eof_reached():
|
||||
var parts := file.get_line().split("\t", true)
|
||||
if parts.size() < 2:
|
||||
continue
|
||||
var vnum := int(parts[0].strip_edges())
|
||||
if vnum <= 0:
|
||||
continue
|
||||
_descriptions[vnum] = {
|
||||
"name": parts[1].strip_edges(),
|
||||
"description": parts[2].strip_edges() if parts.size() > 2 else "",
|
||||
}
|
||||
_load_skill_names(assets_root, lang)
|
||||
return not _descriptions.is_empty() or not _skill_names.is_empty()
|
||||
|
||||
func format(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {}) -> String:
|
||||
if vnum <= 0:
|
||||
return ""
|
||||
var name := String(proto_data.get("locale_name", proto_data.get("name", "物品 #%d" % vnum)))
|
||||
if name.is_empty():
|
||||
name = "物品 #%d" % vnum
|
||||
var sockets: Array = instance_data.get("sockets", proto_data.get("sockets", []))
|
||||
name = _special_title(vnum, name, sockets)
|
||||
var lines: Array[String] = [name]
|
||||
if count > 1:
|
||||
lines.append("数量:%d" % count)
|
||||
var desc: Dictionary = _descriptions.get(vnum, {})
|
||||
if not String(desc.get("description", "")).is_empty():
|
||||
lines.append(String(desc["description"]))
|
||||
|
||||
var item_type := int(proto_data.get("type", 0))
|
||||
var type_name: String = TYPE_NAMES.get(item_type, "")
|
||||
if not type_name.is_empty():
|
||||
lines.append("类型:" + type_name)
|
||||
var values: Array = proto_data.get("values", [])
|
||||
if item_type == ITEM_TYPE_WEAPON and values.size() >= 6:
|
||||
var min_attack := int(values[3]) + int(values[5])
|
||||
var max_attack := int(values[4]) + int(values[5])
|
||||
if min_attack > 0 or max_attack > 0:
|
||||
lines.append("攻击力:%d - %d" % [min_attack, max_attack])
|
||||
if int(values[1]) > 0 or int(values[2]) > 0:
|
||||
lines.append("魔法攻击:%d - %d" % [int(values[1]), int(values[2])])
|
||||
elif item_type == ITEM_TYPE_ARMOR and values.size() >= 6:
|
||||
if int(values[1]) > 0:
|
||||
lines.append("防御力:%d" % (int(values[1]) + int(values[5]) * 2))
|
||||
if int(values[0]) > 0:
|
||||
lines.append("魔法防御:%d" % int(values[0]))
|
||||
_append_special_item_data(lines, item_type, vnum, values, proto_data, instance_data)
|
||||
|
||||
for limit in proto_data.get("limits", []):
|
||||
var limit_type := int(limit.get("type", 0))
|
||||
var limit_value := int(limit.get("value", 0))
|
||||
if limit_type != 0 and limit_value > 0:
|
||||
lines.append("%s:%d" % [LIMIT_NAMES.get(limit_type, "限制 %d" % limit_type), limit_value])
|
||||
for apply in proto_data.get("applies", []):
|
||||
_append_bonus(lines, int(apply.get("type", 0)), int(apply.get("value", 0)))
|
||||
for attr in instance_data.get("attrs", proto_data.get("attrs", [])):
|
||||
_append_bonus(lines, int(attr.get("type", 0)), int(attr.get("value", 0)), "附加")
|
||||
|
||||
var has_socket := false
|
||||
for socket in sockets:
|
||||
if int(socket) != 0:
|
||||
has_socket = true
|
||||
break
|
||||
if has_socket:
|
||||
for i in sockets.size():
|
||||
var socket_value := int(sockets[i])
|
||||
lines.append("孔 %d:%s" % [i + 1, "空" if socket_value == 0 else ("损坏" if socket_value < 0 else "#%d" % socket_value)])
|
||||
|
||||
var refined_vnum := int(proto_data.get("refined_vnum", 0))
|
||||
if refined_vnum > 0:
|
||||
lines.append("精炼后:#%d" % refined_vnum)
|
||||
var refine_set := int(proto_data.get("refine_set", 0))
|
||||
if refine_set > 0:
|
||||
lines.append("精炼套件:%d" % refine_set)
|
||||
_append_realtime_limit(lines, proto_data, sockets, instance_data)
|
||||
return "\n".join(lines)
|
||||
|
||||
func _load_skill_names(assets_root: String, lang: String) -> void:
|
||||
var path := assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang)
|
||||
if not FileAccess.file_exists(path):
|
||||
return
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return
|
||||
while not file.eof_reached():
|
||||
var parts := file.get_line().split("\t", false)
|
||||
if parts.size() < 3:
|
||||
continue
|
||||
var skill_id := int(parts[0])
|
||||
var skill_name := parts[2].strip_edges()
|
||||
if skill_id > 0 and skill_name != "":
|
||||
_skill_names[skill_id] = skill_name
|
||||
|
||||
func _special_title(vnum: int, item_name: String, sockets: Array) -> String:
|
||||
if sockets.is_empty() or int(sockets[0]) <= 0:
|
||||
return item_name
|
||||
if vnum == 50300 or vnum == 70037 or vnum == 70055:
|
||||
var skill_name := String(_skill_names.get(int(sockets[0]), ""))
|
||||
return skill_name + " " + item_name if skill_name != "" else item_name
|
||||
if vnum >= 70103 and vnum <= 70106 and _mob_proto and _mob_proto.has_method("mob"):
|
||||
var mob: Dictionary = _mob_proto.mob(int(sockets[0]))
|
||||
var mob_name := String(mob.get("locale_name", mob.get("name", "")))
|
||||
return mob_name + " " + item_name if mob_name != "" else item_name
|
||||
return item_name
|
||||
|
||||
func _append_realtime_limit(lines: Array[String], proto_data: Dictionary, sockets: Array,
|
||||
instance_data: Dictionary) -> void:
|
||||
if bool(instance_data.get("realtime_rendered", false)) or sockets.is_empty():
|
||||
return
|
||||
var limit_type := 0
|
||||
for limit in proto_data.get("limits", []):
|
||||
var candidate := int(limit.get("type", 0))
|
||||
if candidate == 7 or candidate == 8 or candidate == 9:
|
||||
limit_type = candidate
|
||||
break
|
||||
if limit_type == 0:
|
||||
return
|
||||
var value := int(sockets[0])
|
||||
var remaining := 0
|
||||
if limit_type == 7 or limit_type == 8:
|
||||
remaining = maxi(0, value - int(Time.get_unix_time_from_system()))
|
||||
else:
|
||||
remaining = maxi(0, value)
|
||||
if remaining <= 0:
|
||||
lines.append("剩余时间:已超时")
|
||||
return
|
||||
var minutes := remaining / 60
|
||||
var seconds := remaining % 60
|
||||
var text := "%d秒" % seconds
|
||||
if minutes > 0:
|
||||
text = "%d分%s" % [minutes, text] if seconds > 0 else "%d分" % minutes
|
||||
lines.append("剩余时间:%s" % text)
|
||||
|
||||
func _append_special_item_data(lines: Array[String], item_type: int, vnum: int,
|
||||
values: Array, proto_data: Dictionary, instance_data: Dictionary) -> void:
|
||||
var sockets: Array = instance_data.get("sockets", [])
|
||||
if item_type == ITEM_TYPE_ROD or item_type == ITEM_TYPE_PICK:
|
||||
if values.size() >= 3 and not sockets.is_empty():
|
||||
var level := int(values[0]) / 10
|
||||
var current_exp := int(sockets[0])
|
||||
var max_exp := int(values[2])
|
||||
lines.append("等级:%d" % level)
|
||||
lines.append("经验:%d / %d" % [current_exp, max_exp])
|
||||
if max_exp > 0 and current_exp >= max_exp:
|
||||
lines.append("经验已满,可升级")
|
||||
elif item_type == ITEM_TYPE_FISH:
|
||||
if not sockets.is_empty() and int(sockets[0]) > 0:
|
||||
lines.append("长度:%.2f cm" % (float(sockets[0]) / 100.0))
|
||||
elif item_type == ITEM_TYPE_LOTTERY:
|
||||
if sockets.size() >= 2 and (int(sockets[0]) != 0 or int(sockets[1]) != 0):
|
||||
lines.append("彩票号码:%d" % int(sockets[0]))
|
||||
lines.append("彩票步数:%d" % int(sockets[1]))
|
||||
elif item_type == ITEM_TYPE_BLEND:
|
||||
if sockets.size() >= 3 and int(sockets[0]) != 0:
|
||||
var affect_type := int(sockets[0])
|
||||
var affect_value := int(sockets[1])
|
||||
_append_bonus(lines, affect_type, affect_value, "效果:")
|
||||
var duration := int(sockets[2])
|
||||
lines.append("持续时间:%d 秒" % duration if duration > 0 else "无持续时间")
|
||||
elif item_type == ITEM_TYPE_UNIQUE:
|
||||
_append_item_remaining(lines, proto_data, sockets, instance_data)
|
||||
elif item_type == ITEM_TYPE_DS:
|
||||
var step := (vnum / 100) % 10
|
||||
var refine := (vnum / 10) % 10
|
||||
if step >= 0 and step <= 4:
|
||||
lines.append("龙魂石阶段:%d" % (step + 1))
|
||||
lines.append("龙魂石强化:%d" % refine)
|
||||
|
||||
func _append_item_remaining(lines: Array[String], proto_data: Dictionary, sockets: Array,
|
||||
instance_data: Dictionary) -> void:
|
||||
var remaining := int(instance_data.get("remaining_seconds", instance_data.get("duration", 0)))
|
||||
if remaining <= 0 and not sockets.is_empty():
|
||||
var limit_type := int(instance_data.get("limit_type", 0))
|
||||
if limit_type == 0:
|
||||
for limit in proto_data.get("limits", []):
|
||||
if int(limit.get("type", 0)) != 0:
|
||||
limit_type = int(limit.get("type", 0))
|
||||
break
|
||||
if limit_type == 7 or limit_type == 8:
|
||||
remaining = maxi(0, int(sockets[0]) - int(Time.get_unix_time_from_system()))
|
||||
elif limit_type == 9:
|
||||
remaining = int(sockets[0])
|
||||
elif sockets.size() >= 3:
|
||||
remaining = int(sockets[2]) * 60
|
||||
if remaining > 0:
|
||||
lines.append("剩余时间:%d 秒" % remaining)
|
||||
|
||||
func _append_bonus(lines: Array[String], apply_type: int, value: int, prefix := "") -> void:
|
||||
if apply_type == 0 or value == 0:
|
||||
return
|
||||
var label: String = APPLY_NAMES.get(apply_type, "属性 %d" % apply_type)
|
||||
if not prefix.is_empty():
|
||||
label = prefix + label
|
||||
lines.append("%s:%+d" % [label, value])
|
||||
+112
-3
@@ -7,17 +7,29 @@
|
||||
# `mall_opened` → 显示;`mall_changed` → 刷新。列出商城道具 + [取出](放进背包第一个空格)。
|
||||
extends Node
|
||||
|
||||
const ItemTooltip = preload("res://ui/item_tooltip.gd")
|
||||
|
||||
var client: Node
|
||||
var proto: Node
|
||||
var _tooltip_builder: RefCounted
|
||||
var _root: Control
|
||||
var _list: VBoxContainer
|
||||
var _title: Label
|
||||
var _status: Label
|
||||
var _password_dialog: ConfirmationDialog
|
||||
var item_mouse: Node
|
||||
const MALL_WINDOW := 4
|
||||
const MALL_PAGE_SLOTS := 45
|
||||
var _grid: GridContainer
|
||||
var _cells: Dictionary = {}
|
||||
var _page_label: Label
|
||||
var _page := 0
|
||||
|
||||
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
|
||||
client = m2client
|
||||
proto = proto_node
|
||||
_tooltip_builder = ItemTooltip.new()
|
||||
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
|
||||
_build(parent)
|
||||
if client.has_signal("mall_opened"):
|
||||
client.mall_opened.connect(func(_s): refresh())
|
||||
@@ -30,8 +42,13 @@ func is_open() -> bool:
|
||||
return _root != null and _root.visible
|
||||
|
||||
func close() -> void:
|
||||
if item_mouse and item_mouse.has_method("unregister_owner"):
|
||||
item_mouse.unregister_owner(self)
|
||||
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
|
||||
item_mouse.cancel()
|
||||
if _root:
|
||||
_root.visible = false
|
||||
_page = 0
|
||||
if is_instance_valid(_password_dialog):
|
||||
_password_dialog.queue_free()
|
||||
_password_dialog = null
|
||||
@@ -44,6 +61,13 @@ func _name_of(vnum: int) -> String:
|
||||
return n
|
||||
return "#%d" % vnum
|
||||
|
||||
func _tooltip_for(item: Dictionary) -> String:
|
||||
var vnum := int(item.get("vnum", 0))
|
||||
if _tooltip_builder:
|
||||
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
|
||||
return _tooltip_builder.format(vnum, int(item.get("count", 1)), pd, item) + "\n拖到背包取出"
|
||||
return "%s\n#%d ×%d\n拖到背包取出" % [_name_of(vnum), vnum, int(item.get("count", 1))]
|
||||
|
||||
func _first_free_inv() -> int:
|
||||
var used := {}
|
||||
for it in client.get_inventory():
|
||||
@@ -60,9 +84,13 @@ func refresh() -> void:
|
||||
if not _root.visible:
|
||||
return
|
||||
_status.text = ""
|
||||
_title.text = "道具商城仓库(%d 格)" % client.get_mall_size()
|
||||
var pages := maxi(1, int(ceil(float(client.get_mall_size()) / float(MALL_PAGE_SLOTS))))
|
||||
_page = clampi(_page, 0, pages - 1)
|
||||
_title.text = "道具商城仓库(第 %d/%d 页)" % [_page + 1, pages]
|
||||
_page_label.text = "%d / %d" % [_page + 1, pages]
|
||||
for c in _list.get_children():
|
||||
c.queue_free()
|
||||
_render_grid()
|
||||
var items: Array = client.get_mall_items()
|
||||
if items.is_empty():
|
||||
var e := Label.new()
|
||||
@@ -72,6 +100,47 @@ func refresh() -> void:
|
||||
for it in items:
|
||||
_list.add_child(_row(it))
|
||||
|
||||
func _render_grid() -> void:
|
||||
if _grid == null:
|
||||
return
|
||||
for pos in _cells:
|
||||
var cell: Button = _cells[pos]
|
||||
cell.text = ""
|
||||
cell.tooltip_text = ""
|
||||
cell.set_meta("vnum", 0)
|
||||
cell.set_meta("count", 0)
|
||||
var items: Array = client.get_mall_items() if client and client.has_method("get_mall_items") else []
|
||||
for it in items:
|
||||
var absolute_pos := int(it.get("cell", -1))
|
||||
var pos := absolute_pos - _page * MALL_PAGE_SLOTS
|
||||
if not _cells.has(pos):
|
||||
continue
|
||||
var vnum := int(it.get("vnum", 0))
|
||||
var count := int(it.get("count", 1))
|
||||
var cell: Button = _cells[pos]
|
||||
cell.text = "%s%s" % [_name_of(vnum).substr(0, 7), (" ×%d" % count) if count > 1 else ""]
|
||||
cell.tooltip_text = _tooltip_for(it)
|
||||
cell.set_meta("vnum", vnum)
|
||||
cell.set_meta("count", count)
|
||||
|
||||
func _on_grid_input(local_pos: int, event: InputEvent) -> void:
|
||||
if not item_mouse or not item_mouse.has_method("attach_item"):
|
||||
return
|
||||
if not (event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT
|
||||
and event.pressed):
|
||||
return
|
||||
var cell: Button = _cells[local_pos]
|
||||
var vnum := int(cell.get_meta("vnum", 0))
|
||||
if vnum == 0:
|
||||
return
|
||||
item_mouse.attach_item(MALL_WINDOW, _page * MALL_PAGE_SLOTS + local_pos, vnum,
|
||||
int(cell.get_meta("count", 1)), null, "mall")
|
||||
|
||||
func _change_page(delta: int) -> void:
|
||||
var pages := maxi(1, int(ceil(float(client.get_mall_size()) / float(MALL_PAGE_SLOTS))))
|
||||
_page = clampi(_page + delta, 0, pages - 1)
|
||||
refresh()
|
||||
|
||||
func _ask_password() -> void:
|
||||
if is_instance_valid(_password_dialog):
|
||||
_password_dialog.queue_free()
|
||||
@@ -100,14 +169,22 @@ func _ask_password() -> void:
|
||||
func _row(it: Dictionary) -> Control:
|
||||
var row := HBoxContainer.new()
|
||||
row.custom_minimum_size = Vector2(300, 0)
|
||||
var vnum := int(it.get("vnum", 0))
|
||||
var count := int(it.get("count", 1))
|
||||
var cell := int(it.get("cell", 0))
|
||||
var nm := Label.new()
|
||||
nm.text = "%s ×%d" % [_name_of(int(it.get("vnum", 0))), int(it.get("count", 1))]
|
||||
nm.text = "%s ×%d" % [_name_of(vnum), count]
|
||||
nm.custom_minimum_size = Vector2(220, 0)
|
||||
nm.add_theme_font_size_override("font_size", 12)
|
||||
row.add_child(nm)
|
||||
if item_mouse:
|
||||
nm.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
nm.gui_input.connect(func(e: InputEvent):
|
||||
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed \
|
||||
and item_mouse.has_method("attach_item"):
|
||||
item_mouse.attach_item(MALL_WINDOW, cell, vnum, count, null, "mall"))
|
||||
var out := Button.new()
|
||||
out.text = "取出"
|
||||
var cell := int(it.get("cell", 0))
|
||||
out.pressed.connect(func() -> void:
|
||||
var target := _first_free_inv()
|
||||
if target < 0:
|
||||
@@ -135,6 +212,24 @@ func _build(parent: Node) -> void:
|
||||
_title.text = "道具商城仓库"
|
||||
_title.add_theme_font_size_override("font_size", 16)
|
||||
box.add_child(_title)
|
||||
var pager := HBoxContainer.new()
|
||||
pager.position = Vector2(230, 0)
|
||||
pager.size = Vector2(90, 24)
|
||||
var prev := Button.new()
|
||||
prev.text = "‹"
|
||||
prev.custom_minimum_size = Vector2(28, 24)
|
||||
prev.pressed.connect(func(): _change_page(-1))
|
||||
pager.add_child(prev)
|
||||
_page_label = Label.new()
|
||||
_page_label.custom_minimum_size = Vector2(40, 24)
|
||||
_page_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
pager.add_child(_page_label)
|
||||
var next := Button.new()
|
||||
next.text = "›"
|
||||
next.custom_minimum_size = Vector2(28, 24)
|
||||
next.pressed.connect(func(): _change_page(1))
|
||||
pager.add_child(next)
|
||||
_root.add_child(pager)
|
||||
_status = Label.new()
|
||||
_status.add_theme_font_size_override("font_size", 11)
|
||||
_status.add_theme_color_override("font_color", Color(1, 0.65, 0.55))
|
||||
@@ -145,6 +240,20 @@ func _build(parent: Node) -> void:
|
||||
_list = VBoxContainer.new()
|
||||
_list.add_theme_constant_override("separation", 4)
|
||||
sc.add_child(_list)
|
||||
_list.visible = false
|
||||
_grid = GridContainer.new()
|
||||
_grid.columns = 5
|
||||
_grid.position = Vector2(16, 58)
|
||||
_grid.add_theme_constant_override("h_separation", 4)
|
||||
_grid.add_theme_constant_override("v_separation", 4)
|
||||
_root.add_child(_grid)
|
||||
for pos in MALL_PAGE_SLOTS:
|
||||
var cell := Button.new()
|
||||
cell.custom_minimum_size = Vector2(58, 30)
|
||||
cell.add_theme_font_size_override("font_size", 9)
|
||||
cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e))
|
||||
_grid.add_child(cell)
|
||||
_cells[pos] = cell
|
||||
var close := Button.new()
|
||||
close.text = "关闭"
|
||||
close.pressed.connect(func() -> void: _root.visible = false)
|
||||
|
||||
+138
-2
@@ -11,9 +11,16 @@ extends Node
|
||||
|
||||
const RADIUS := 70.0
|
||||
const SCALE := 0.25 # 像素/米(越大越放大)
|
||||
# CPythonMiniMap 的 waypoint 夹边留边:c_fMiniMapWindowRadius = min(w,h)/2 - 9.0f
|
||||
# (m2dev PythonMiniMap.cpp:441)。信标越出此半径时,标记贴到圆周并改画方向箭头。
|
||||
const SIGNAL_RIM_INSET := 9.0
|
||||
const NPC_WARP_TYPE := 2 # NPCMark.type: 2 = 传送
|
||||
const MIN_SCALE := 0.10
|
||||
const MAX_SCALE := 1.25
|
||||
const OBSERVER_ADD := 0
|
||||
const OBSERVER_REMOVE := 1
|
||||
const OBSERVER_MOVE := 2
|
||||
const OBSERVER_INTERPOLATION_MS := 1000.0
|
||||
|
||||
var client: Node
|
||||
var player_getter: Callable
|
||||
@@ -21,6 +28,8 @@ var _root: Control
|
||||
var _view: Control
|
||||
var _ch_label: Label
|
||||
var _scale := SCALE
|
||||
var _signal_points: Array[Vector2] = []
|
||||
var _observer_tracks: Dictionary = {}
|
||||
|
||||
func setup(m2client: Node, parent: Node, get_player: Callable) -> void:
|
||||
client = m2client
|
||||
@@ -28,6 +37,11 @@ func setup(m2client: Node, parent: Node, get_player: Callable) -> void:
|
||||
_build(parent)
|
||||
if client and client.has_signal("channel_changed"):
|
||||
client.channel_changed.connect(func(c): _ch_label.text = "CH %d" % c)
|
||||
if client and client.has_signal("observer_event"):
|
||||
client.observer_event.connect(_on_observer_event)
|
||||
if client and client.has_signal("world_reset"):
|
||||
client.world_reset.connect(_clear_observers)
|
||||
_sync_observers_snapshot()
|
||||
|
||||
func _build(parent: Node) -> void:
|
||||
_root = Control.new()
|
||||
@@ -63,6 +77,105 @@ func set_scale(pixels_per_meter: float) -> void:
|
||||
func get_scale() -> float:
|
||||
return _scale
|
||||
|
||||
# CPythonMiniMap::AddSignalPoint stores quest-script waypoints separately from
|
||||
# server target markers. EventManager supplies absolute server centimetres.
|
||||
func add_signal_point(x: float, y: float) -> void:
|
||||
_signal_points.append(Vector2(x, y))
|
||||
if _view:
|
||||
_view.queue_redraw()
|
||||
|
||||
func clear_signal_points() -> void:
|
||||
_signal_points.clear()
|
||||
if _view:
|
||||
_view.queue_redraw()
|
||||
|
||||
func signal_point_count() -> int:
|
||||
return _signal_points.size()
|
||||
|
||||
# 1:1 复刻 m2dev CPythonMiniMap::Render 的 waypoint 夹边分支(PythonMiniMap.cpp:445-462):
|
||||
# fDistanceFromCenter = sqrt(dx*dx + dy*dy)
|
||||
# if fDistanceFromCenter >= radius:
|
||||
# fRadian = atan2f(dy, dx); dx = radius*cos; dy = radius*sin
|
||||
# 返回 {clamped, offset(相对圆心的像素偏移), angle(指向真实目标的弧度)}。
|
||||
static func signal_marker_geometry(delta_px: Vector2, rim_radius: float) -> Dictionary:
|
||||
var angle := atan2(delta_px.y, delta_px.x)
|
||||
if delta_px.length() >= rim_radius:
|
||||
return {
|
||||
"clamped": true,
|
||||
"offset": Vector2(rim_radius * cos(angle), rim_radius * sin(angle)),
|
||||
"angle": angle,
|
||||
}
|
||||
return {"clamped": false, "offset": delta_px, "angle": angle}
|
||||
|
||||
func _signal_offset(world: Vector3, origin: Vector3) -> Dictionary:
|
||||
var d := world - origin
|
||||
# 与 _to_map 相同的世界→图轴向:world +x = 东(右),world −z = 北(上)。
|
||||
var delta_px := Vector2(d.x, d.z) * _scale
|
||||
return signal_marker_geometry(delta_px, RADIUS - SIGNAL_RIM_INSET)
|
||||
|
||||
# CPythonMiniMap::AddObserver / MoveObserver / RemoveObserver keep observer
|
||||
# positions separate from ordinary character instances. The reference client
|
||||
# interpolates each move over one second, so a packet burst does not make the
|
||||
# purple observer marker teleport across the minimap.
|
||||
func _observer_track(pos: Vector3, now_ms: int) -> Dictionary:
|
||||
return {"cur": pos, "src": pos, "dst": pos, "started_ms": now_ms}
|
||||
|
||||
func _set_observer_track(vid: int, pos: Vector3) -> void:
|
||||
_observer_tracks[vid] = _observer_track(pos, Time.get_ticks_msec())
|
||||
|
||||
func _on_observer_event(kind: int, vid: int, pos: Vector2) -> void:
|
||||
var net_pos := Vector3(pos.x, 0.0, pos.y)
|
||||
match kind:
|
||||
OBSERVER_ADD:
|
||||
# AddObserver resets an existing observer to the packet position too.
|
||||
_set_observer_track(vid, net_pos)
|
||||
OBSERVER_REMOVE:
|
||||
_observer_tracks.erase(vid)
|
||||
OBSERVER_MOVE:
|
||||
# Match MoveObserver: a move without a prior add is ignored. The
|
||||
# snapshot sync below covers observers present before setup.
|
||||
if not _observer_tracks.has(vid):
|
||||
return
|
||||
var cur := _observer_position(vid, net_pos)
|
||||
var track: Dictionary = _observer_tracks[vid]
|
||||
track["src"] = cur
|
||||
track["dst"] = net_pos
|
||||
track["cur"] = cur
|
||||
track["started_ms"] = Time.get_ticks_msec()
|
||||
_observer_tracks[vid] = track
|
||||
|
||||
func _clear_observers() -> void:
|
||||
_observer_tracks.clear()
|
||||
|
||||
func _sync_observers_snapshot() -> void:
|
||||
if client == null or not client.has_method("get_observers"):
|
||||
return
|
||||
# This seeds observers that arrived before the minimap connected. Once an
|
||||
# observer_event stream exists, its transitions own the track's destination.
|
||||
var has_events := client.has_signal("observer_event")
|
||||
for o in client.get_observers():
|
||||
var vid := int(o.get("vid", 0))
|
||||
if vid == 0:
|
||||
continue
|
||||
var pos: Vector3 = o.get("pos", Vector3.ZERO)
|
||||
if not has_events:
|
||||
_set_observer_track(vid, pos)
|
||||
elif not _observer_tracks.has(vid):
|
||||
_set_observer_track(vid, pos)
|
||||
|
||||
func _observer_position(vid: int, fallback: Vector3 = Vector3.ZERO) -> Vector3:
|
||||
if not _observer_tracks.has(vid):
|
||||
_set_observer_track(vid, fallback)
|
||||
var track: Dictionary = _observer_tracks[vid]
|
||||
var src: Vector3 = track.get("src", fallback)
|
||||
var dst: Vector3 = track.get("dst", src)
|
||||
var started_ms := float(track.get("started_ms", Time.get_ticks_msec()))
|
||||
var f := clampf((float(Time.get_ticks_msec()) - started_ms) / OBSERVER_INTERPOLATION_MS, 0.0, 1.0)
|
||||
var cur := src.lerp(dst, f)
|
||||
track["cur"] = cur
|
||||
_observer_tracks[vid] = track
|
||||
return cur
|
||||
|
||||
func _on_gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed:
|
||||
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
@@ -83,6 +196,16 @@ func _to_map(world: Vector3, origin: Vector3) -> Vector2:
|
||||
p = p.normalized() * (RADIUS - 4.0)
|
||||
return Vector2(RADIUS, RADIUS) + p
|
||||
|
||||
# 圆周方向箭头(对应 __RenderTargetMark 的边缘指示;本工程无 .sub 资源,用三角形代替)。
|
||||
func _draw_signal_arrow(pos: Vector2, angle: float) -> void:
|
||||
var dir := Vector2(cos(angle), sin(angle))
|
||||
var perp := Vector2(-dir.y, dir.x)
|
||||
var tip := pos + dir * 7.0
|
||||
var base := pos - dir * 3.0
|
||||
_view.draw_colored_polygon(
|
||||
PackedVector2Array([tip, base + perp * 4.0, base - perp * 4.0]),
|
||||
Color(1.0, 0.6, 0.1, 0.95))
|
||||
|
||||
func _draw_map() -> void:
|
||||
var c := Vector2(RADIUS, RADIUS)
|
||||
_view.draw_circle(c, RADIUS, Color(0.05, 0.06, 0.08, 0.82))
|
||||
@@ -120,8 +243,11 @@ func _draw_map() -> void:
|
||||
|
||||
# 观战者:原版在小地图上显示独立的观察点(紫色菱形)。
|
||||
if client.has_method("get_observers"):
|
||||
_sync_observers_snapshot()
|
||||
for o in client.get_observers():
|
||||
var op := _to_map(MapCoord.to_world(o.get("pos", Vector3.ZERO)), origin)
|
||||
var vid := int(o.get("vid", 0))
|
||||
var observer_world := _observer_position(vid, o.get("pos", Vector3.ZERO))
|
||||
var op := _to_map(MapCoord.to_world(observer_world), origin)
|
||||
var diamond := PackedVector2Array([op + Vector2(0, -4), op + Vector2(4, 0),
|
||||
op + Vector2(0, 4), op + Vector2(-4, 0)])
|
||||
_view.draw_colored_polygon(diamond, Color(0.8, 0.35, 1.0, 0.95))
|
||||
@@ -142,7 +268,17 @@ func _draw_map() -> void:
|
||||
else Color(1.0, 0.75, 0.2, 0.85)
|
||||
_view.draw_rect(rect, col, false, 1.0)
|
||||
|
||||
# 任务标记:橙星(小方块代替)
|
||||
# 任务标记(EventManager quest signals,本地 waypoint,不是 GC_TARGET):
|
||||
# 圆内画橙星;越出小地图半径则贴到圆周画指向目标的方向箭头(世界箭头 / 屏幕边缘指示)。
|
||||
for signal_point in _signal_points:
|
||||
var signal_pos := MapCoord.to_world(Vector3(signal_point.x * 0.01, 0, -signal_point.y * 0.01))
|
||||
var geo := _signal_offset(signal_pos, origin)
|
||||
var smp: Vector2 = c + geo["offset"]
|
||||
if geo["clamped"]:
|
||||
_draw_signal_arrow(smp, geo["angle"])
|
||||
else:
|
||||
_view.draw_circle(smp, 5.0, Color(1.0, 0.75, 0.15, 0.95))
|
||||
_view.draw_arc(smp, 8.0, 0.0, TAU, 16, Color(1.0, 0.4, 0.1, 0.9), 1.5)
|
||||
if client.has_method("get_world_markers"):
|
||||
for m in client.get_world_markers():
|
||||
var mp := _to_map(MapCoord.to_world(m.get("pos", Vector3.ZERO)), origin)
|
||||
|
||||
@@ -28,6 +28,9 @@ var anim: Node # Metin2AnimPlayer
|
||||
var _dir := ""
|
||||
var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径
|
||||
var _state := ""
|
||||
var _audio: Node
|
||||
var _sound_instances: Array = []
|
||||
var _sound_frame := -1
|
||||
|
||||
# root/npclist.txt: vnum -> 模型代号(bear_brown / tiger_big / stray_dog…)。
|
||||
# 翻译版 mob_proto 把 szName 也本地化了("Brown Bear"),解不出目录,必须靠这张表。
|
||||
@@ -109,6 +112,10 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
|
||||
CharShadow.attach(self, 1.6) # 怪脚印大一点
|
||||
return true
|
||||
|
||||
func set_audio(audio_node: Node) -> void:
|
||||
_audio = audio_node
|
||||
_refresh_sound_script(String(anim.get("anim_path")) if anim else "")
|
||||
|
||||
func set_anim_state(s: String) -> void:
|
||||
if s == _state or anim == null:
|
||||
return
|
||||
@@ -118,6 +125,27 @@ func set_anim_state(s: String) -> void:
|
||||
return
|
||||
anim.set("loop", s in ["wait", "walk", "run"])
|
||||
anim.set("anim_path", msa)
|
||||
_refresh_sound_script(msa)
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
|
||||
or not bool(anim.get("playing")):
|
||||
return
|
||||
var current_time: float = anim.call("get_time")
|
||||
var frame := int(floor(current_time * 60.0))
|
||||
if frame == _sound_frame:
|
||||
return
|
||||
_sound_frame = frame
|
||||
_audio.update_sound_instances_3d(global_position.x, global_position.y, global_position.z,
|
||||
frame, _sound_instances, true)
|
||||
|
||||
func _refresh_sound_script(motion_path: String) -> void:
|
||||
_sound_instances = []
|
||||
_sound_frame = -1
|
||||
if _audio == null or motion_path == "" or not motion_path.to_lower().ends_with(".msa"):
|
||||
return
|
||||
if _audio.has_method("load_mss_for_motion"):
|
||||
_sound_instances = _audio.load_mss_for_motion(motion_path)
|
||||
|
||||
# 供 net_world._on_info 用:真模型没有统一的名字节点时补一个。
|
||||
func set_display_name(n: String) -> void:
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# MouseController —— ClientVS22 mousemodule.py 的全局物品拖放层。
|
||||
#
|
||||
# 目标控件通过 register_target() 注册;物品源调用 attach_item() 后,控制器
|
||||
# 接管鼠标释放,命中目标时调用回调,释放到窗口外 / 右键 / ESC / 失焦则取消。
|
||||
# 光标状态由 CursorManager 管理,跟随图标不参与世界点击。
|
||||
extends Node
|
||||
|
||||
const CursorManager = preload("res://ui/cursor_manager.gd")
|
||||
|
||||
var cursor: Node
|
||||
var audio: Node
|
||||
var _overlay: Control
|
||||
var _icon: TextureRect
|
||||
var _label: Label
|
||||
var _attached: Dictionary = {}
|
||||
var _targets: Array[Dictionary] = []
|
||||
|
||||
func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -> void:
|
||||
cursor = cursor_manager
|
||||
audio = audio_node
|
||||
_overlay = Control.new()
|
||||
_overlay.name = "MouseItemOverlay"
|
||||
_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_overlay.z_index = 1000
|
||||
_overlay.visible = false
|
||||
_overlay.size = Vector2(72, 72)
|
||||
parent.add_child(_overlay)
|
||||
_icon = TextureRect.new()
|
||||
_icon.position = Vector2(0, 0)
|
||||
_icon.size = Vector2(48, 48)
|
||||
_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_overlay.add_child(_icon)
|
||||
_label = Label.new()
|
||||
_label.position = Vector2(2, 48)
|
||||
_label.size = Vector2(68, 20)
|
||||
_label.add_theme_font_size_override("font_size", 10)
|
||||
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_overlay.add_child(_label)
|
||||
set_process_input(true)
|
||||
|
||||
func attach_item(window: int, cell: int, vnum: int, count: int,
|
||||
texture: Texture2D = null, source := "inventory", metadata: Dictionary = {}) -> bool:
|
||||
if vnum <= 0 or window < 0 or cell < 0:
|
||||
return false
|
||||
_attached = {"window": window, "cell": cell, "vnum": vnum,
|
||||
"count": maxi(1, count), "source": source}
|
||||
# Keep the instance fields that the reference mouse module exposes to the
|
||||
# receiving window (anti flags, sockets, attributes, ...), while retaining
|
||||
# the canonical source coordinates supplied by the caller.
|
||||
for key in metadata:
|
||||
if key not in ["window", "cell", "vnum", "count", "source"]:
|
||||
_attached[key] = metadata[key]
|
||||
if _icon:
|
||||
_icon.texture = texture
|
||||
_icon.visible = texture != null
|
||||
if _label:
|
||||
_label.text = "#%d%s" % [vnum, (" ×%d" % count) if count > 1 else ""]
|
||||
_label.visible = true
|
||||
if _overlay:
|
||||
_overlay.visible = true
|
||||
if cursor and cursor.has_method("set_cursor"):
|
||||
cursor.set_cursor(CursorManager.ITEM)
|
||||
_play_ui("pick.wav")
|
||||
return true
|
||||
|
||||
func attach_money(amount: int, source := "inventory") -> bool:
|
||||
if amount <= 0:
|
||||
return false
|
||||
_attached = {"window": -1, "cell": -1, "vnum": -1, "count": amount,
|
||||
"source": source, "money": true}
|
||||
if _icon:
|
||||
_icon.texture = null
|
||||
_icon.visible = false
|
||||
if _label:
|
||||
_label.text = "金币 ×%d" % amount
|
||||
_label.visible = true
|
||||
if _overlay:
|
||||
_overlay.visible = true
|
||||
if cursor and cursor.has_method("set_cursor"):
|
||||
cursor.set_cursor(CursorManager.ITEM)
|
||||
_play_ui("pick.wav")
|
||||
return true
|
||||
|
||||
func cancel() -> void:
|
||||
_attached.clear()
|
||||
if _overlay:
|
||||
_overlay.visible = false
|
||||
if cursor and cursor.has_method("reset"):
|
||||
cursor.reset()
|
||||
|
||||
func is_attached() -> bool:
|
||||
return not _attached.is_empty()
|
||||
|
||||
func attached() -> Dictionary:
|
||||
return _attached.duplicate(true)
|
||||
|
||||
func register_target(control: Control, callback: Callable, owner: Node = null) -> void:
|
||||
if control == null or not callback.is_valid():
|
||||
return
|
||||
_targets.append({"control": control, "callback": callback, "owner": owner})
|
||||
|
||||
func unregister_owner(owner: Node) -> void:
|
||||
for i in range(_targets.size() - 1, -1, -1):
|
||||
if _targets[i].get("owner", null) == owner:
|
||||
_targets.remove_at(i)
|
||||
|
||||
func _process(_dt: float) -> void:
|
||||
if _overlay and _overlay.visible:
|
||||
_overlay.global_position = get_viewport().get_mouse_position() + Vector2(12, 12)
|
||||
# 清理已被窗口销毁的目标,防止拖放回调落到旧 UI。
|
||||
for i in range(_targets.size() - 1, -1, -1):
|
||||
var c: Control = _targets[i].get("control", null)
|
||||
if c == null or not is_instance_valid(c):
|
||||
_targets.remove_at(i)
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not is_attached():
|
||||
return
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
cancel()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
cancel()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if event is InputEventMouseButton and not event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_drop_at(event.position)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _drop_at(position: Vector2) -> void:
|
||||
var payload := attached()
|
||||
for i in range(_targets.size() - 1, -1, -1):
|
||||
var c: Control = _targets[i].get("control", null)
|
||||
if c == null or not is_instance_valid(c):
|
||||
_targets.remove_at(i)
|
||||
continue
|
||||
if not c.visible or not c.get_global_rect().has_point(position):
|
||||
continue
|
||||
var cb: Callable = _targets[i].get("callback", Callable())
|
||||
if cb.is_valid() and bool(cb.call(payload)):
|
||||
_play_ui("drop.wav")
|
||||
cancel()
|
||||
return
|
||||
# 与参考端释放到无效区域的取消语义一致。
|
||||
_play_ui("loginfail.wav")
|
||||
cancel()
|
||||
|
||||
func _play_ui(name: String) -> void:
|
||||
if audio and audio.has_method("play_ui"):
|
||||
audio.play_ui(name)
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_WM_WINDOW_FOCUS_OUT:
|
||||
cancel()
|
||||
@@ -19,11 +19,16 @@ var anim: Node # Metin2AnimPlayer
|
||||
var motion_dir := ""
|
||||
var action_dir := ""
|
||||
var _state := ""
|
||||
var _assets_root := ""
|
||||
var _audio: Node
|
||||
var _sound_instances: Array = []
|
||||
var _sound_frame := -1
|
||||
var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"]
|
||||
|
||||
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
|
||||
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
|
||||
func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||
_assets_root = assets_root
|
||||
if not ClassDB.class_exists("Metin2Model"):
|
||||
return false
|
||||
var cls: String = CLASS_OF[race & 3]
|
||||
@@ -64,6 +69,10 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||
CharShadow.attach(self, 1.3) # 脚下接触阴影 + 强制 cast_shadow
|
||||
return true
|
||||
|
||||
func set_audio(audio_node: Node) -> void:
|
||||
_audio = audio_node
|
||||
_refresh_sound_script(String(anim.get("anim_path")) if anim else "")
|
||||
|
||||
func set_anim_state(s: String) -> void:
|
||||
if s == _state or anim == null or motion_dir == "":
|
||||
return
|
||||
@@ -74,6 +83,7 @@ func set_anim_state(s: String) -> void:
|
||||
if FileAccess.file_exists(msa):
|
||||
anim.set("loop", s in ["wait", "walk", "run"])
|
||||
anim.set("anim_path", msa)
|
||||
_refresh_sound_script(msa)
|
||||
|
||||
# Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends
|
||||
# the numeric motion id; paired emotions use the other entity's race to select
|
||||
@@ -148,13 +158,41 @@ func set_motion_id(motion: int, target_race: int = -1) -> bool:
|
||||
if String(anim.get("anim_path")) == msa:
|
||||
anim.set("anim_path", "")
|
||||
anim.set("anim_path", msa)
|
||||
_refresh_sound_script(msa)
|
||||
return true
|
||||
|
||||
func _on_playback_finished() -> void:
|
||||
if _state == "__motion":
|
||||
# CLIENT-GAP §3.7: the hit reaction ("damage") is a one-shot knockback clip.
|
||||
# When it ends the actor leaves the pushed state (CActorInstance::IsPushing),
|
||||
# so fall back to idle — net_play polls is_in_hit_reaction() to drop its gate.
|
||||
if _state == "__motion" or _state == "damage":
|
||||
_state = ""
|
||||
set_anim_state("wait")
|
||||
|
||||
# True while the one-shot hit/knockback clip is still playing.
|
||||
func is_in_hit_reaction() -> bool:
|
||||
return _state == "damage"
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
|
||||
or not bool(anim.get("playing")):
|
||||
return
|
||||
var current_time: float = anim.call("get_time")
|
||||
var frame := int(floor(current_time * 60.0))
|
||||
if frame == _sound_frame:
|
||||
return
|
||||
_sound_frame = frame
|
||||
_audio.update_sound_instances_3d(global_position.x, global_position.y, global_position.z,
|
||||
frame, _sound_instances, true)
|
||||
|
||||
func _refresh_sound_script(motion_path: String) -> void:
|
||||
_sound_instances = []
|
||||
_sound_frame = -1
|
||||
if _audio == null or motion_path == "" or not motion_path.to_lower().ends_with(".msa"):
|
||||
return
|
||||
if _audio.has_method("load_mss_for_motion"):
|
||||
_sound_instances = _audio.load_mss_for_motion(motion_path)
|
||||
|
||||
func _set(prop: StringName, val: Variant) -> bool:
|
||||
if String(prop) in _forward and model:
|
||||
model.set(prop, val)
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
# M2Client classic 分支按该语义发送。
|
||||
extends Node
|
||||
|
||||
const ItemTooltip = preload("res://ui/item_tooltip.gd")
|
||||
|
||||
class PrivateShopDragButton extends Button:
|
||||
var drag_entry: Dictionary = {}
|
||||
|
||||
@@ -32,10 +34,13 @@ class PrivateShopDragButton extends Button:
|
||||
|
||||
const PRIVATE_SHOP_ITEM_MAX := 39 # PRIVATE_SHOP_ITEM_MAX_NUM (TPacketCGMyShop::bCount 上限)
|
||||
const SIGN_MAX := 25 # NameLine input_limit
|
||||
const ITEM_ANTIFLAG_GIVE := 1 << 13
|
||||
const ITEM_ANTIFLAG_MYSHOP := 1 << 16
|
||||
|
||||
var client: Node
|
||||
var ui: CanvasLayer # UiManager
|
||||
var proto: Node
|
||||
var _tooltip_builder: RefCounted
|
||||
var assets_root := ""
|
||||
var uiscript_dir := ""
|
||||
|
||||
@@ -48,6 +53,8 @@ var _stock: Dictionary = {} # 格号:int -> {cell, vnum, count, price}
|
||||
var _picked = null # {cell, vnum, count} 等待落位
|
||||
var _price_dialog: Control = null
|
||||
var _server_prices: Dictionary = {} # vnum -> last price returned by MyShopPriceList
|
||||
var item_mouse: Node
|
||||
const INVENTORY_WINDOW := 1
|
||||
|
||||
func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, assets := "") -> void:
|
||||
client = m2client
|
||||
@@ -56,6 +63,8 @@ func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, ass
|
||||
assets_root = assets
|
||||
if assets_root == "" and ui and "assets_root" in ui:
|
||||
assets_root = ui.assets_root
|
||||
_tooltip_builder = ItemTooltip.new()
|
||||
_tooltip_builder.setup(assets_root if assets_root != "" else AssetRoot.path(), "en", proto)
|
||||
uiscript_dir = assets_root.path_join("uiscript/uiscript")
|
||||
if not DirAccess.dir_exists_absolute(uiscript_dir):
|
||||
uiscript_dir = assets_root.path_join("uiscript")
|
||||
@@ -77,6 +86,10 @@ func toggle() -> void:
|
||||
|
||||
func close() -> void:
|
||||
_dismiss_price_dialog()
|
||||
if item_mouse and item_mouse.has_method("unregister_owner"):
|
||||
item_mouse.unregister_owner(self)
|
||||
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
|
||||
item_mouse.cancel()
|
||||
if is_open():
|
||||
ui.close(_win["root"])
|
||||
_win = {}
|
||||
@@ -124,6 +137,28 @@ func _index_cells(root: Node) -> void:
|
||||
n.set_drag_forwarding(Callable(self, "_no_drag_data"),
|
||||
Callable(self, "_can_drop_slot").bind(idx),
|
||||
Callable(self, "_drop_slot").bind(idx))
|
||||
if item_mouse and item_mouse.has_method("register_target"):
|
||||
item_mouse.register_target(n,
|
||||
func(payload: Dictionary): return _drop_mouse_item(payload, idx), self)
|
||||
|
||||
func _drop_mouse_item(payload: Dictionary, slot: int) -> bool:
|
||||
if payload.is_empty() or slot < 0 or slot >= 40 or _stock.has(slot):
|
||||
return false
|
||||
if int(payload.get("window", -1)) != INVENTORY_WINDOW:
|
||||
return false
|
||||
var cell := int(payload.get("cell", -1))
|
||||
var vnum := int(payload.get("vnum", 0))
|
||||
if cell < 0 or vnum <= 0:
|
||||
return false
|
||||
var entry := payload.duplicate(true)
|
||||
entry["cell"] = cell
|
||||
entry["vnum"] = vnum
|
||||
entry["count"] = maxi(1, int(payload.get("count", 1)))
|
||||
if not _can_stock(entry):
|
||||
return false
|
||||
_picked = entry
|
||||
_ask_price(slot)
|
||||
return true
|
||||
|
||||
# NameLine 在 uiscript 里是 text(Label)—— 盖一个 LineEdit 上去
|
||||
func _overlay_name_edit(root: Control) -> void:
|
||||
@@ -219,10 +254,17 @@ func _refresh_inv() -> void:
|
||||
var count := int(it.get("count", 1))
|
||||
var b := PrivateShopDragButton.new()
|
||||
b.text = "%s ×%d" % [_name_of(vnum), count]
|
||||
if _tooltip_builder:
|
||||
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
|
||||
b.tooltip_text = _tooltip_builder.format(vnum, count, pd, it)
|
||||
b.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
b.custom_minimum_size = Vector2(196, 22)
|
||||
b.toggle_mode = true
|
||||
var entry := {"cell": cell, "vnum": vnum, "count": count}
|
||||
var entry: Dictionary = it.duplicate(true)
|
||||
entry["cell"] = cell
|
||||
entry["vnum"] = vnum
|
||||
entry["count"] = count
|
||||
entry["anti_flags"] = int(it.get("anti_flags", 0))
|
||||
b.drag_entry = entry
|
||||
b.pressed.connect(func() -> void: _pick(entry, b))
|
||||
if _picked != null and int(_picked["cell"]) == cell:
|
||||
@@ -256,7 +298,13 @@ func _refresh_grid() -> void:
|
||||
pr.add_theme_color_override("font_color", Color(0.95, 0.85, 0.5))
|
||||
pr.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
cell.add_child(pr)
|
||||
cell.tooltip_text = "%s ×%d\n%d 냥" % [_name_of(int(s["vnum"])), int(s["count"]), int(s["price"])]
|
||||
var tooltip_item := s.duplicate(true)
|
||||
tooltip_item["count"] = int(s["count"])
|
||||
if _tooltip_builder:
|
||||
var pd: Dictionary = proto.item(int(s["vnum"])) if proto and proto.has_method("item") else {}
|
||||
cell.tooltip_text = _tooltip_builder.format(int(s["vnum"]), int(s["count"]), pd, tooltip_item)
|
||||
else:
|
||||
cell.tooltip_text = "%s ×%d\n%d 냥" % [_name_of(int(s["vnum"])), int(s["count"]), int(s["price"])]
|
||||
cell.modulate = Color(1, 1, 1)
|
||||
else:
|
||||
cell.modulate = Color(0.72, 0.72, 0.82) if _picked != null else Color(1, 1, 1)
|
||||
@@ -264,6 +312,8 @@ func _refresh_grid() -> void:
|
||||
# --- 상호작용 --------------------------------------------------------
|
||||
|
||||
func _pick(entry: Dictionary, _btn: Button) -> void:
|
||||
if not _can_stock(entry):
|
||||
return
|
||||
if _picked != null and int(_picked["cell"]) == int(entry["cell"]):
|
||||
_picked = null
|
||||
else:
|
||||
@@ -281,9 +331,36 @@ func _drop_slot(_at_position: Vector2, data, slot: int) -> void:
|
||||
if not _can_drop_slot(Vector2.ZERO, data, slot):
|
||||
return
|
||||
var entry: Dictionary = data["private_shop_entry"] as Dictionary
|
||||
if not _can_stock(entry):
|
||||
return
|
||||
_picked = entry.duplicate(true)
|
||||
_ask_price(slot)
|
||||
|
||||
func _source_item(cell: int) -> Dictionary:
|
||||
if client == null:
|
||||
return {}
|
||||
if client.has_method("get_item"):
|
||||
var direct: Dictionary = client.get_item(INVENTORY_WINDOW, cell)
|
||||
if not direct.is_empty():
|
||||
return direct
|
||||
if client.has_method("get_inventory"):
|
||||
for item in client.get_inventory():
|
||||
if int(item.get("cell", -1)) == cell:
|
||||
return item
|
||||
return {}
|
||||
|
||||
func _can_stock(entry: Dictionary) -> bool:
|
||||
var item_data := _source_item(int(entry.get("cell", -1)))
|
||||
var vnum := int(entry.get("vnum", item_data.get("vnum", 0)))
|
||||
var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
|
||||
var flags := int(entry.get("anti_flags", 0)) | int(item_data.get("anti_flags", 0)) \
|
||||
| int(proto_data.get("anti_flags", 0))
|
||||
if (flags & (ITEM_ANTIFLAG_GIVE | ITEM_ANTIFLAG_MYSHOP)) != 0:
|
||||
if is_instance_valid(_status):
|
||||
_status.text = "该物品不可摆摊"
|
||||
return false
|
||||
return true
|
||||
|
||||
func _on_slot_clicked(slot: int) -> void:
|
||||
if _stock.has(slot):
|
||||
_stock.erase(slot)
|
||||
@@ -343,6 +420,10 @@ func _place(slot: int, price: int) -> void:
|
||||
if slot < 0 or slot >= 40 or price <= 0 or price > 2000000000 or _picked == null:
|
||||
_dismiss_price_dialog()
|
||||
return
|
||||
if not _can_stock(_picked):
|
||||
_dismiss_price_dialog()
|
||||
_picked = null
|
||||
return
|
||||
_stock[slot] = {"cell": int(_picked["cell"]), "vnum": int(_picked["vnum"]),
|
||||
"count": int(_picked["count"]), "price": price}
|
||||
_picked = null
|
||||
|
||||
+678
-34
@@ -17,19 +17,37 @@
|
||||
# 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)
|
||||
|
||||
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
|
||||
|
||||
func setup(m2client: Node, parent: Node) -> void:
|
||||
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)
|
||||
_build(parent)
|
||||
if client.has_signal("script_dialog"):
|
||||
@@ -45,14 +63,45 @@ func is_open() -> bool:
|
||||
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)
|
||||
close()
|
||||
return
|
||||
_text.text = parsed.body
|
||||
_fill_buttons(parsed.choices, parsed.has_next, parsed.has_input)
|
||||
_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, _confirm_wait)
|
||||
_root.visible = true
|
||||
opened.emit()
|
||||
_emit_side_effects(parsed)
|
||||
|
||||
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:
|
||||
camera_event_requested.emit(String(camera.get("kind", "")), camera.get("values", []))
|
||||
for fade in parsed.fade_events:
|
||||
fade_event_requested.emit(String(fade.get("kind", "")), float(fade.get("speed", 0.0)))
|
||||
if String(parsed.get("cinema_file", "")) != "":
|
||||
cinema_requested.emit(String(parsed.cinema_file))
|
||||
|
||||
func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void:
|
||||
_confirm_pid = request_pid
|
||||
@@ -64,7 +113,8 @@ func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void:
|
||||
func close() -> void:
|
||||
if _root:
|
||||
_root.visible = false
|
||||
closed.emit()
|
||||
_confirm_wait = false
|
||||
closed.emit()
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not is_open() or not (event is InputEventKey):
|
||||
@@ -78,15 +128,224 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
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],裸文本是本客户端的扩展,逐字符断句会
|
||||
# 破坏服务器写好的句子)。
|
||||
#
|
||||
# seam(无逐帧 Update 循环):isLock/isWaitFlag/lLastDelayTime/lWaitingTime 作为 EventSet
|
||||
# 状态保留并透出,但一次性把剩余 token 跑完(保证按钮 / 选项 / 副作用仍然落地);Skip /
|
||||
# EndEventProcess / iConfirmEndTime 的 timeGetTime 走 seam。
|
||||
|
||||
# -> { body: String, choices: [String], has_next: bool }
|
||||
func parse_script(src: String) -> Dictionary:
|
||||
var body := ""
|
||||
var choices := []
|
||||
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 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,
|
||||
"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,
|
||||
}
|
||||
|
||||
# 把混排文本 / [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:
|
||||
@@ -94,36 +353,370 @@ func parse_script(src: String) -> Dictionary:
|
||||
if c == "[":
|
||||
var close_i := src.find("]", i)
|
||||
if close_i == -1:
|
||||
body += src.substr(i)
|
||||
out.append({"literal": src.substr(i)})
|
||||
break
|
||||
var tok := src.substr(i + 1, close_i - i - 1).strip_edges()
|
||||
i = close_i + 1
|
||||
var name := tok.split(" ", false)[0].to_upper() if tok != "" else ""
|
||||
match name:
|
||||
"ENTER":
|
||||
body += "\n"
|
||||
"CLEAR", "CLEAR_TEXT":
|
||||
body = ""
|
||||
"NEXT", "DONE":
|
||||
has_next = true
|
||||
"QUESTION":
|
||||
for m in _rx_args(tok):
|
||||
choices.append(m)
|
||||
"INPUT":
|
||||
has_input = true
|
||||
"SELECT_ITEM":
|
||||
has_select_item = true
|
||||
"LETTER":
|
||||
var vv := _rx_args(tok)
|
||||
if vv.size() > 0:
|
||||
body += vv[0]
|
||||
_:
|
||||
pass # COLOR/DELAY/WAIT/IMAGE/... 忽略
|
||||
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:
|
||||
body += c
|
||||
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.iAdjustLine += 2
|
||||
EVT_DONE:
|
||||
# MakeNextButton(BUTTON_TYPE_DONE); DoneEvent; iAdjustLine += 2。
|
||||
es.has_next = true
|
||||
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
|
||||
es.iConfirmEndTime = es.confirm_timeout # timeGetTime()/1000 + timeout(seam)
|
||||
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
|
||||
return {"body": body.strip_edges(), "choices": choices, "has_next": has_next,
|
||||
"has_input": has_input, "has_select_item": has_select_item}
|
||||
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:
|
||||
@@ -159,6 +752,10 @@ func _build(parent: Node) -> void:
|
||||
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)
|
||||
@@ -171,11 +768,51 @@ func _build(parent: Node) -> void:
|
||||
_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) -> void:
|
||||
func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false) -> void:
|
||||
_clear_buttons()
|
||||
if has_input:
|
||||
var input := LineEdit.new()
|
||||
@@ -186,6 +823,13 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false) -> void:
|
||||
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 b := _mkbtn("继续" if has_next else "关闭")
|
||||
b.pressed.connect(func():
|
||||
|
||||
+346
-12
@@ -9,15 +9,59 @@
|
||||
# `quest_info` 信号来时刷新。空标题的条目视为「已完成 / 移除」。
|
||||
extends Node
|
||||
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
const QUEST_MAX_NUM := 5
|
||||
const QUEST_EVENT_BUTTON_BASE := -2147483648
|
||||
|
||||
# interfacemodule.__ArrangeQuestButton(interfacemodule.py:1357-1386)1:1 布局参数:
|
||||
# xPos = 20(无组队窗)/ 130 = 100 + 30(组队窗显示时);(+15 RTL,本迁移不做 RTL)
|
||||
# yPos = 170 * screenHeight // 600
|
||||
# yCount = (screenHeight - 330) // 63 每列容纳的按钮数
|
||||
# 第 i 个: x = xPos + (i // yCount) * 100 ; y = yPos + (i % yCount) * 63
|
||||
const QUEST_BTN_X := 20
|
||||
const QUEST_BTN_X_PARTY := 130
|
||||
const QUEST_BTN_COL_W := 100
|
||||
const QUEST_BTN_ROW_H := 63
|
||||
const QUEST_BTN_SIZE := Vector2(198, 48)
|
||||
|
||||
signal quest_selected(index: int)
|
||||
|
||||
var client: Node
|
||||
var _root: Control
|
||||
var _list: VBoxContainer
|
||||
var _quest_scrollbar: VScrollBar
|
||||
var _quests: Array = []
|
||||
var _quest_start_index := 0
|
||||
var _visible_quest_indices: Array[int] = []
|
||||
var _button_root: Control
|
||||
var _button_list: Control
|
||||
var _quest_buttons_visible := true
|
||||
var _quest_buttons_suppressed := false
|
||||
var _quest_button_data: Dictionary = {}
|
||||
var _quest_button_order: Array[int] = [] # 最新在前:BINARY_RecvQuest 的 questButtonList.insert(0, btn)
|
||||
var _party_shown := false
|
||||
var _party_probe: Callable = Callable()
|
||||
var _screen_size_override := Vector2.ZERO # >0 时覆盖 viewport 尺寸(离线回归用)
|
||||
var _clock_remaining: Dictionary = {}
|
||||
var _clock_labels: Dictionary = {}
|
||||
var _clock_accumulator := 0.0
|
||||
var _proto: Node
|
||||
var _item_list: RefCounted
|
||||
var _assets_root := ""
|
||||
|
||||
func setup(m2client: Node, parent: Node) -> void:
|
||||
client = m2client
|
||||
set_process(true)
|
||||
_build(parent)
|
||||
if client.has_signal("quest_info"):
|
||||
client.quest_info.connect(func(_i): refresh())
|
||||
_refresh_quest_buttons()
|
||||
|
||||
func set_item_sources(proto_node: Node, item_list: RefCounted, assets_root: String) -> void:
|
||||
_proto = proto_node
|
||||
_item_list = item_list
|
||||
_assets_root = assets_root
|
||||
_refresh_quest_buttons()
|
||||
|
||||
func is_open() -> bool:
|
||||
return _root != null and _root.visible
|
||||
@@ -31,44 +75,251 @@ func toggle() -> void:
|
||||
if _root.visible:
|
||||
refresh()
|
||||
|
||||
# game.py Ctrl+Q toggles the quest-button strip. This migration has the quest
|
||||
# entries in the quest-log surface, so keep the same state boundary explicit;
|
||||
# a future HUD strip can bind this without changing the input contract.
|
||||
func toggle_buttons() -> void:
|
||||
_quest_buttons_visible = not _quest_buttons_visible
|
||||
_update_button_visibility()
|
||||
|
||||
func quest_buttons_visible() -> bool:
|
||||
return _quest_buttons_visible
|
||||
|
||||
func set_buttons_suppressed(suppressed: bool) -> void:
|
||||
_quest_buttons_suppressed = suppressed
|
||||
_update_button_visibility()
|
||||
|
||||
# EventManager QUESTBUTTON / QUESTBUTTON_CLOSE interface. The quest-log
|
||||
# snapshot and the transient quest-letter strip are separate in ClientVS22.
|
||||
func recv_quest(index: int, title: String, icon_type := "file", icon_name := "") -> void:
|
||||
if index < 0:
|
||||
return
|
||||
# BINARY_RecvQuest:同 index 先 __DestroyQuestButton 旧的,再 questButtonList.insert(0, btn)
|
||||
# —— 即无论新旧都移到最前。
|
||||
_quest_button_data[index] = {"title": title, "icon_type": icon_type, "icon_name": icon_name}
|
||||
_quest_button_order.erase(index)
|
||||
_quest_button_order.push_front(index)
|
||||
_refresh_quest_buttons()
|
||||
|
||||
func clear_quest(index: int) -> void:
|
||||
_quest_button_data.erase(index)
|
||||
_quest_button_order.erase(index)
|
||||
_refresh_quest_buttons()
|
||||
|
||||
func quest_button_order() -> Array[int]:
|
||||
return _quest_button_order.duplicate()
|
||||
|
||||
# __ArrangeQuestButton 的 xPos 取决于 self.wndParty.IsShow()。组队窗可见性在本迁移里
|
||||
# 由 party_ui 的成员数驱动(无信号),故用一个探针回调,在 _process 里轮询变化。
|
||||
func set_party_probe(probe: Callable) -> void:
|
||||
_party_probe = probe
|
||||
|
||||
func set_party_shown(shown: bool) -> void:
|
||||
if shown == _party_shown:
|
||||
return
|
||||
_party_shown = shown
|
||||
_refresh_quest_buttons()
|
||||
|
||||
func set_screen_size_override(size: Vector2) -> void:
|
||||
_screen_size_override = size
|
||||
_refresh_quest_buttons()
|
||||
|
||||
func _screen_size() -> Vector2:
|
||||
if _screen_size_override.x > 0.0 and _screen_size_override.y > 0.0:
|
||||
return _screen_size_override
|
||||
var vp := get_viewport()
|
||||
if vp:
|
||||
return vp.get_visible_rect().size
|
||||
return Vector2(800, 600)
|
||||
|
||||
# interfacemodule.py:1371 yCount = (screenHeight - 330) // 63
|
||||
# 参考端无下限,screenHeight < 393 会 ZeroDivision;这里夹到 1(防御性偏离,seam)。
|
||||
static func quest_button_y_count(screen_height: float) -> int:
|
||||
return maxi(1, int(screen_height - 330.0) / 63)
|
||||
|
||||
# interfacemodule.py:1370 yPos = 170 * screenHeight // 600
|
||||
static func quest_button_y_pos(screen_height: float) -> int:
|
||||
return int(170.0 * screen_height) / 600
|
||||
|
||||
# interfacemodule.py:1375
|
||||
# btn.SetPosition(xPos + (int(count // yCount) * 100), yPos + (count % yCount * 63))
|
||||
static func quest_button_slot_pos(slot: int, y_count: int, x_pos: int, y_pos: int) -> Vector2:
|
||||
var col := slot / y_count
|
||||
var row := slot % y_count
|
||||
return Vector2(x_pos + col * QUEST_BTN_COL_W, y_pos + row * QUEST_BTN_ROW_H)
|
||||
|
||||
func quest_button_position(index: int) -> Vector2:
|
||||
var slot := _quest_button_order.find(index)
|
||||
if slot < 0:
|
||||
return Vector2(-1, -1)
|
||||
var screen := _screen_size()
|
||||
return quest_button_slot_pos(slot, quest_button_y_count(screen.y),
|
||||
QUEST_BTN_X_PARTY if _party_shown else QUEST_BTN_X, quest_button_y_pos(screen.y))
|
||||
|
||||
func quest_button_count() -> int:
|
||||
return _quest_button_data.size()
|
||||
|
||||
func quest_count() -> int:
|
||||
return _quests.size()
|
||||
|
||||
func quest_start_index() -> int:
|
||||
return _quest_start_index
|
||||
|
||||
func visible_quest_indices() -> Array[int]:
|
||||
return _visible_quest_indices.duplicate()
|
||||
|
||||
func scroll_ratio() -> float:
|
||||
if _quest_scrollbar == null or _quest_scrollbar.max_value <= 0.0:
|
||||
return 0.0
|
||||
return _quest_scrollbar.value / _quest_scrollbar.max_value
|
||||
|
||||
func set_scroll_ratio(ratio: float) -> void:
|
||||
if _quest_scrollbar == null:
|
||||
return
|
||||
_quest_scrollbar.value = clampf(ratio, 0.0, 1.0) * _quest_scrollbar.max_value
|
||||
|
||||
func select_quest(index: int) -> void:
|
||||
if index < 0 or index >= _quests.size():
|
||||
return
|
||||
var quest_index := int(_quests[index].get("index", -1))
|
||||
if quest_index < 0:
|
||||
return
|
||||
if client and client.has_method("script_button"):
|
||||
client.script_button(QUEST_EVENT_BUTTON_BASE + quest_index)
|
||||
quest_selected.emit(quest_index)
|
||||
|
||||
func refresh() -> void:
|
||||
if not is_open() or client == null:
|
||||
return
|
||||
_quests.clear()
|
||||
for q in client.get_quests():
|
||||
if String(q.get("title", "")).strip_edges() != "":
|
||||
_quests.append(q)
|
||||
_quest_start_index = mini(_quest_start_index, maxi(0, _quests.size() - QUEST_MAX_NUM))
|
||||
if _quest_scrollbar:
|
||||
_quest_scrollbar.max_value = maxi(0, _quests.size() - QUEST_MAX_NUM)
|
||||
_quest_scrollbar.value = _quest_start_index
|
||||
_quest_scrollbar.visible = _quests.size() > QUEST_MAX_NUM
|
||||
_render_quests()
|
||||
|
||||
func _render_quests() -> void:
|
||||
for c in _list.get_children():
|
||||
c.queue_free()
|
||||
var quests: Array = client.get_quests()
|
||||
if quests.is_empty():
|
||||
_clock_labels.clear()
|
||||
_clock_remaining.clear()
|
||||
_clock_accumulator = 0.0
|
||||
_visible_quest_indices.clear()
|
||||
if _quests.is_empty():
|
||||
var e := Label.new()
|
||||
e.text = "(无进行中的任务)"
|
||||
e.add_theme_font_size_override("font_size", 12)
|
||||
_list.add_child(e)
|
||||
return
|
||||
for q in quests:
|
||||
if String(q.get("title", "")).strip_edges() == "":
|
||||
continue
|
||||
var end_index := mini(_quests.size(), _quest_start_index + QUEST_MAX_NUM)
|
||||
for data_index in range(_quest_start_index, end_index):
|
||||
var q: Dictionary = _quests[data_index]
|
||||
_visible_quest_indices.append(data_index)
|
||||
var quest_index := int(q.get("index", -1))
|
||||
var clock_name := String(q.get("clock_name", ""))
|
||||
if quest_index >= 0 and clock_name != "":
|
||||
_clock_remaining[quest_index] = maxi(0, int(q.get("clock_value", 0)))
|
||||
var box := VBoxContainer.new()
|
||||
box.add_theme_constant_override("separation", 1)
|
||||
box.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
box.gui_input.connect(func(event):
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||
select_quest(data_index)
|
||||
)
|
||||
var heading := HBoxContainer.new()
|
||||
heading.add_theme_constant_override("separation", 6)
|
||||
var icon_path := String(q.get("icon", ""))
|
||||
if icon_path == "":
|
||||
icon_path = "season1/icon/scroll_open.tga"
|
||||
var icon_tex: Texture2D = UiAssets.load_tex(AssetRoot.path(), icon_path)
|
||||
if icon_tex:
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = icon_tex
|
||||
icon.custom_minimum_size = Vector2(24, 24)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
heading.add_child(icon)
|
||||
var text_column := VBoxContainer.new()
|
||||
text_column.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var t := Label.new()
|
||||
t.text = "◆ " + String(q.get("title", ""))
|
||||
t.add_theme_font_size_override("font_size", 13)
|
||||
box.add_child(t)
|
||||
t.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
text_column.add_child(t)
|
||||
var cn := String(q.get("counter_name", ""))
|
||||
if cn != "":
|
||||
var cl := Label.new()
|
||||
cl.text = " %s: %d" % [cn, int(q.get("counter_value", 0))]
|
||||
cl.add_theme_font_size_override("font_size", 11)
|
||||
cl.modulate = Color(0.8, 0.85, 0.7)
|
||||
box.add_child(cl)
|
||||
var kn := String(q.get("clock_name", ""))
|
||||
if kn != "":
|
||||
cl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
text_column.add_child(cl)
|
||||
if clock_name != "":
|
||||
var kl := Label.new()
|
||||
kl.text = " %s: %d" % [kn, int(q.get("clock_value", 0))]
|
||||
kl.add_theme_font_size_override("font_size", 11)
|
||||
kl.modulate = Color(0.7, 0.8, 0.9)
|
||||
box.add_child(kl)
|
||||
kl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
text_column.add_child(kl)
|
||||
_clock_labels[quest_index] = kl
|
||||
_update_clock_label(quest_index, kl, clock_name)
|
||||
heading.add_child(text_column)
|
||||
heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
box.add_child(heading)
|
||||
_list.add_child(box)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# 组队窗可见性变化 → 重排(__ArrangeQuestButton 每帧读 wndParty.IsShow())。
|
||||
if _party_probe.is_valid():
|
||||
var shown := bool(_party_probe.call())
|
||||
if shown != _party_shown:
|
||||
_party_shown = shown
|
||||
_refresh_quest_buttons()
|
||||
if not is_open() or _clock_remaining.is_empty():
|
||||
return
|
||||
_clock_accumulator += delta
|
||||
if _clock_accumulator < 1.0:
|
||||
return
|
||||
var elapsed := int(_clock_accumulator)
|
||||
_clock_accumulator -= float(elapsed)
|
||||
for quest_index in _clock_remaining.keys():
|
||||
_clock_remaining[quest_index] = maxi(0, int(_clock_remaining[quest_index]) - elapsed)
|
||||
if _clock_labels.has(quest_index):
|
||||
_update_clock_label(quest_index, _clock_labels[quest_index], "")
|
||||
|
||||
func _update_clock_label(quest_index: int, label: Label, name: String) -> void:
|
||||
var remaining := int(_clock_remaining.get(quest_index, 0))
|
||||
var prefix := name
|
||||
if prefix == "":
|
||||
var old := String(label.text)
|
||||
prefix = old.get_slice(":", 0) if old.contains(":") else "时间"
|
||||
if remaining <= 0:
|
||||
label.text = " %s:已超时" % prefix
|
||||
return
|
||||
var minutes := remaining / 60
|
||||
var seconds := remaining % 60
|
||||
var value := "%d秒" % seconds
|
||||
if minutes > 0:
|
||||
value = "%d分%s" % [minutes, value] if seconds > 0 else "%d分" % minutes
|
||||
label.text = " %s:%s" % [prefix, value]
|
||||
|
||||
func _build(parent: Node) -> void:
|
||||
# 任务信件按钮条:绝对定位以 1:1 复刻 __ArrangeQuestButton 的多列布局,
|
||||
# 因此 root 落在屏幕原点,按钮坐标里已含 xPos / yPos。
|
||||
_button_root = Control.new()
|
||||
_button_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_button_root.position = Vector2.ZERO
|
||||
_button_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
parent.add_child(_button_root)
|
||||
_button_list = Control.new()
|
||||
_button_list.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_button_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_button_root.add_child(_button_list)
|
||||
|
||||
_root = Control.new()
|
||||
_root.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
_root.position = Vector2(-320, 60)
|
||||
@@ -89,6 +340,89 @@ func _build(parent: Node) -> void:
|
||||
_root.add_child(title)
|
||||
_list = VBoxContainer.new()
|
||||
_list.position = Vector2(12, 34)
|
||||
_list.custom_minimum_size = Vector2(276, 0)
|
||||
_list.size = Vector2(260, 310)
|
||||
_list.custom_minimum_size = Vector2(260, 310)
|
||||
_list.add_theme_constant_override("separation", 8)
|
||||
_root.add_child(_list)
|
||||
_quest_scrollbar = VScrollBar.new()
|
||||
_quest_scrollbar.position = Vector2(278, 34)
|
||||
_quest_scrollbar.size = Vector2(14, 310)
|
||||
_quest_scrollbar.step = 1.0
|
||||
_quest_scrollbar.page = 1.0
|
||||
_quest_scrollbar.visible = false
|
||||
_quest_scrollbar.value_changed.connect(_on_quest_scroll)
|
||||
_root.add_child(_quest_scrollbar)
|
||||
|
||||
func _on_quest_scroll(value: float) -> void:
|
||||
var next_start := clampi(roundi(value), 0, maxi(0, _quests.size() - QUEST_MAX_NUM))
|
||||
if next_start == _quest_start_index:
|
||||
return
|
||||
_quest_start_index = next_start
|
||||
_render_quests()
|
||||
|
||||
func _refresh_quest_buttons() -> void:
|
||||
if _button_list == null:
|
||||
return
|
||||
for child in _button_list.get_children():
|
||||
child.queue_free()
|
||||
# 顺序 = questButtonList(最新在前)。清掉 order 里 data 已删的残留 index。
|
||||
var order: Array[int] = []
|
||||
for idx in _quest_button_order:
|
||||
if _quest_button_data.has(idx):
|
||||
order.append(int(idx))
|
||||
_quest_button_order = order
|
||||
var screen := _screen_size()
|
||||
var x_pos := QUEST_BTN_X_PARTY if _party_shown else QUEST_BTN_X
|
||||
var y_pos := quest_button_y_pos(screen.y)
|
||||
var y_count := quest_button_y_count(screen.y)
|
||||
var slot := 0
|
||||
for index in _quest_button_order:
|
||||
var spec: Dictionary = _quest_button_data[index]
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size = QUEST_BTN_SIZE
|
||||
button.size = QUEST_BTN_SIZE
|
||||
button.position = quest_button_slot_pos(slot, y_count, x_pos, y_pos)
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.text = " " + String(spec.get("title", ""))
|
||||
button.tooltip_text = String(spec.get("title", ""))
|
||||
button.add_theme_font_size_override("font_size", 12)
|
||||
if String(spec.get("icon_type", "")) == "highlight":
|
||||
button.modulate = Color(1.0, 0.85, 0.35)
|
||||
var tex := _quest_icon(spec)
|
||||
if tex:
|
||||
button.icon = tex
|
||||
button.expand_icon = true
|
||||
button.icon_max_width = 32
|
||||
var captured := int(index)
|
||||
button.pressed.connect(func(): _start_quest(captured))
|
||||
_button_list.add_child(button)
|
||||
slot += 1
|
||||
_update_button_visibility()
|
||||
|
||||
func _quest_icon(spec: Dictionary) -> Texture2D:
|
||||
var icon_type := String(spec.get("icon_type", ""))
|
||||
var icon_name := String(spec.get("icon_name", ""))
|
||||
if icon_type == "item":
|
||||
var vnum := int(icon_name)
|
||||
var path := ""
|
||||
if _item_list and _item_list.has_method("icon"):
|
||||
path = String(_item_list.icon(vnum))
|
||||
if path == "":
|
||||
path = "icon/item/%05d.tga" % ((vnum / 10) * 10)
|
||||
return UiAssets.load_tex(_assets_root if _assets_root != "" else AssetRoot.path(), path)
|
||||
if icon_name != "":
|
||||
return UiAssets.load_tex(_assets_root if _assets_root != "" else AssetRoot.path(), icon_name)
|
||||
return null
|
||||
|
||||
func _start_quest(index: int) -> void:
|
||||
if client and client.has_method("script_button"):
|
||||
client.script_button(index)
|
||||
# ClientVS22 removes the letter as soon as it is clicked. The server can
|
||||
# still re-send it if the quest remains actionable.
|
||||
_quest_button_data.erase(index)
|
||||
_quest_button_order.erase(index)
|
||||
_refresh_quest_buttons()
|
||||
|
||||
func _update_button_visibility() -> void:
|
||||
if _button_root:
|
||||
_button_root.visible = _quest_buttons_visible and not _quest_buttons_suppressed and not _quest_button_data.is_empty()
|
||||
|
||||
+110
-13
@@ -1,29 +1,36 @@
|
||||
# Quickbar (P6) —— 快捷栏:36 槽(4 页 × 9 格),数字键 1-9 施放技能 / 用物品。
|
||||
# Quickbar (P6) —— 服务端 36 槽;参考客户端本地显示 4 页 × 8 格(32 格)。
|
||||
#
|
||||
# var qb := preload("res://ui/quickbar.gd").new()
|
||||
# add_child(qb)
|
||||
# qb.setup(m2client, skill_table, canvas_parent, player_getter)
|
||||
# qb.assign(0, "skill", 1) # 当前页 0 号格放技能 id 1
|
||||
# qb.assign(1, "item", 5) # 当前页 1 号格放背包 5 格的药水
|
||||
# # 输入: game_scene 里 KEY_1..KEY_9 -> qb.activate(n),F1..F4 切页
|
||||
# # 输入:数字 1..4 -> 本页 0..3,F1..F4 -> 本页 4..7。
|
||||
#
|
||||
# 技能施放:先发送 M2Client.use_skill(skill_id, target_vid),再同步
|
||||
# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)(朝向 = 玩家 yaw)。
|
||||
# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。
|
||||
extends Node
|
||||
|
||||
const PlayerSkill := preload("res://player_skill.gd")
|
||||
|
||||
signal skill_activated(skill_id: int) # game_scene 接它播技能特效
|
||||
# §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案
|
||||
signal skill_rejected(skill_id: int, code: String)
|
||||
|
||||
const CM := 100.0
|
||||
const DEFAULT_CD := 2.0
|
||||
const SLOTS_PER_PAGE := 9
|
||||
const SLOTS_PER_PAGE := 8
|
||||
const PAGE_COUNT := 4
|
||||
const SLOT_COUNT := SLOTS_PER_PAGE * PAGE_COUNT
|
||||
const SLOT_COUNT := 36 # QUICKSLOT_MAX_NUM;最后 4 格没有本地显示页
|
||||
|
||||
var client: Node
|
||||
var table: RefCounted # SkillTable
|
||||
var net_play: Node # NetPlay(可空)——技能三层校验的运行期上下文来源
|
||||
var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1)
|
||||
var _player_getter: Callable
|
||||
var _root: Control
|
||||
var item_mouse: Node
|
||||
var _slots := [] # 当前页 UI:[{btn, cd, lbl}]
|
||||
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
|
||||
var _page := 0
|
||||
@@ -33,6 +40,8 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter:
|
||||
client = m2client
|
||||
table = skill_table
|
||||
_player_getter = player_getter
|
||||
_skill_gate = PlayerSkill.new()
|
||||
_skill_gate.setup(client, table)
|
||||
for _i in SLOT_COUNT:
|
||||
_state.append({"kind": "", "id": 0, "cd_end": 0.0})
|
||||
_build(parent)
|
||||
@@ -43,7 +52,7 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter:
|
||||
restore_from_server()
|
||||
|
||||
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
|
||||
# 服务器 type:1 道具 / 2 技能 / 3 命令 / 4 表情;ref = 道具格 / 技能 id。
|
||||
# 参考 ESlotType:1 道具 / 2 技能 / 3 表情 / 4 商店(不可执行)。
|
||||
func restore_from_server() -> void:
|
||||
if client == null or not client.has_method("get_quickslots"):
|
||||
return
|
||||
@@ -57,19 +66,23 @@ func restore_from_server() -> void:
|
||||
match int(qs.get("type", 0)):
|
||||
1: restored[pos] = {"kind": "item", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
|
||||
2: restored[pos] = {"kind": "skill", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
|
||||
3: restored[pos] = {"kind": "emote", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
|
||||
_state = restored
|
||||
_refresh_page()
|
||||
|
||||
func assign(slot: int, kind: String, id: int, persist := true) -> void:
|
||||
func assign(slot: int, kind: String, id: int, persist := true) -> bool:
|
||||
if slot < 0 or slot >= SLOTS_PER_PAGE:
|
||||
return
|
||||
return false
|
||||
if kind not in ["skill", "item", "emote"] or id <= 0:
|
||||
return false
|
||||
var global := _global_slot(slot)
|
||||
_state[global] = {"kind": kind, "id": id, "cd_end": 0.0}
|
||||
if persist and client and client.has_method("quickslot_add"):
|
||||
var type := 2 if kind == "skill" else 1 if kind == "item" else 0
|
||||
var type := 2 if kind == "skill" else 1 if kind == "item" else 3 if kind == "emote" else 0
|
||||
if type == 0 or not client.quickslot_add(global, type, id):
|
||||
return
|
||||
return false
|
||||
_state[global] = {"kind": kind, "id": id, "cd_end": 0.0}
|
||||
_refresh_slot(slot)
|
||||
return true
|
||||
|
||||
func activate(slot: int) -> void:
|
||||
if slot < 0 or slot >= SLOTS_PER_PAGE:
|
||||
@@ -91,6 +104,38 @@ func activate(slot: int) -> void:
|
||||
var target_vid := 0
|
||||
if client.has_method("get_target"):
|
||||
target_vid = int(client.get_target().get("vid", 0))
|
||||
# §3.8 修改 1:三层合法性校验(ClickSkillSlot -> __CanUseSkill /
|
||||
# __CheckSkillUsable / __UseSkill)。挡下就发 skill_rejected(静默码除外),
|
||||
# TOGGLE_OFF 只发 use_skill 关掉开关技、不施法。
|
||||
if _skill_gate:
|
||||
if net_play and net_play.has_method("skill_context"):
|
||||
var ctx: Dictionary = net_play.skill_context()
|
||||
for k in ctx:
|
||||
_skill_gate.set(k, ctx[k])
|
||||
_skill_gate.slot_cd_end = s.cd_end
|
||||
var gate: Dictionary = _skill_gate.click_skill_slot(int(s.id))
|
||||
if not bool(gate.get("ok", false)):
|
||||
var code := String(gate.get("code", ""))
|
||||
# PythonPlayerSkill::__CheckSpecialSkill(123) 不是失败:参考端
|
||||
# 直接进入 NEW_Fishing,并把 EQUIP_FISHING_ROD 作为唯一错误反馈。
|
||||
if code == "SPECIAL_SKILL" and int(s.id) == PlayerSkill.SKILL_INDEX_FISHING \
|
||||
and net_play and net_play.has_method("activate_fishing"):
|
||||
var fishing_result := String(net_play.activate_fishing())
|
||||
if fishing_result == "OK":
|
||||
skill_activated.emit(int(s.id))
|
||||
elif not PlayerSkill.is_silent_code(fishing_result):
|
||||
skill_rejected.emit(int(s.id), fishing_result)
|
||||
return
|
||||
if code == "TOGGLE_OFF":
|
||||
if client.has_method("use_skill"):
|
||||
client.use_skill(int(s.id), 0)
|
||||
skill_activated.emit(int(s.id))
|
||||
elif not PlayerSkill.is_silent_code(code):
|
||||
skill_rejected.emit(int(s.id), code)
|
||||
return
|
||||
var resolved := int(gate.get("target_vid", 0))
|
||||
if resolved != 0:
|
||||
target_vid = resolved
|
||||
# use_skill is the authoritative server intent. Keep the CG_MOVE skill
|
||||
# state packet as a separate visual/action-state sync, just like the
|
||||
# original client did.
|
||||
@@ -100,9 +145,42 @@ func activate(slot: int) -> void:
|
||||
if intent_sent and client.cast_skill(mi, yaw, int(xy.x), int(xy.y)):
|
||||
s.cd_end = _now() + _skill_cooldown(int(s.id))
|
||||
_state[global] = s
|
||||
# §3.4:扇形 / 圆形技能的附加 fly-targeting(主目标已在 use_skill 里发过;
|
||||
# PythonPlayerSkill.cpp:684 的 `if (dwTargetMaxCount>0 …)` 补目标块)。
|
||||
if target_vid != 0 and net_play and table \
|
||||
and net_play.has_method("send_fly_targeting") \
|
||||
and (table.is_fan_range(int(s.id)) or table.is_circle_range(int(s.id))):
|
||||
var lvl := _skill_level(int(s.id))
|
||||
net_play.send_fly_targeting(target_vid, float(table.target_range(int(s.id))),
|
||||
table.target_count(int(s.id), lvl), table.fly_shape(int(s.id)))
|
||||
skill_activated.emit(int(s.id))
|
||||
elif s.kind == "item":
|
||||
client.use_item(1, s.id) # WINDOW_INVENTORY
|
||||
elif s.kind == "emote" and client.has_method("send_emoticon"):
|
||||
client.send_emoticon(s.id)
|
||||
|
||||
# net_play 的 MODE_USE_SKILL 预约进射程后回调(use_skill_hook)——slot 为全局快捷栏
|
||||
# 索引。返回是否确实发起了施法(对齐参考端 `if (__UseSkill(slot)) __ClearReservedAction()`)。
|
||||
func activate_reserved(global_slot: int) -> bool:
|
||||
if global_slot < 0 or global_slot >= SLOT_COUNT:
|
||||
return false
|
||||
var local := global_slot - _page * SLOTS_PER_PAGE
|
||||
if local < 0 or local >= SLOTS_PER_PAGE:
|
||||
return false
|
||||
var s: Dictionary = _state[global_slot]
|
||||
if s.kind != "skill" or s.id == 0 or _now() < s.cd_end:
|
||||
return false
|
||||
activate(local)
|
||||
return true
|
||||
|
||||
# client.get_skills() 里该技能的当前等级(quickbar 施法时算 target_count 用)。
|
||||
func _skill_level(sid: int) -> int:
|
||||
if client == null or not client.has_method("get_skills"):
|
||||
return 0
|
||||
for sk in client.get_skills():
|
||||
if int(sk.get("id", 0)) == sid:
|
||||
return int(sk.get("level", 0))
|
||||
return 0
|
||||
|
||||
func set_page(page: int) -> void:
|
||||
if page < 0 or page >= PAGE_COUNT or page == _page:
|
||||
@@ -111,6 +189,13 @@ func set_page(page: int) -> void:
|
||||
_move_from = -1
|
||||
_refresh_page()
|
||||
|
||||
func toggle_visible() -> void:
|
||||
if _root:
|
||||
_root.visible = not _root.visible
|
||||
|
||||
func is_visible() -> bool:
|
||||
return _root != null and _root.visible
|
||||
|
||||
func _global_slot(local_slot: int) -> int:
|
||||
return _page * SLOTS_PER_PAGE + local_slot
|
||||
|
||||
@@ -141,7 +226,7 @@ func _swap(local_a: int, local_b: int) -> void:
|
||||
func _build(parent: Node) -> void:
|
||||
_root = Control.new()
|
||||
_root.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||
_root.position = Vector2(-9 * 23, -54)
|
||||
_root.position = Vector2(-SLOTS_PER_PAGE * 23, -54)
|
||||
parent.add_child(_root)
|
||||
var prev := Button.new()
|
||||
prev.text = "‹"
|
||||
@@ -150,12 +235,12 @@ func _build(parent: Node) -> void:
|
||||
_root.add_child(prev)
|
||||
var next := Button.new()
|
||||
next.text = "›"
|
||||
next.position = Vector2(9 * 44 + 2, 7)
|
||||
next.position = Vector2(SLOTS_PER_PAGE * 44 + 2, 7)
|
||||
next.pressed.connect(func(): set_page(posmod(_page + 1, PAGE_COUNT)))
|
||||
_root.add_child(next)
|
||||
var page_label := Label.new()
|
||||
page_label.name = "page"
|
||||
page_label.position = Vector2(9 * 44 + 7, 38)
|
||||
page_label.position = Vector2(SLOTS_PER_PAGE * 44 + 7, 38)
|
||||
page_label.add_theme_font_size_override("font_size", 9)
|
||||
_root.add_child(page_label)
|
||||
var row := HBoxContainer.new()
|
||||
@@ -194,8 +279,20 @@ func _build(parent: Node) -> void:
|
||||
_clear(idx))
|
||||
row.add_child(slot)
|
||||
_slots.append({"btn": slot, "cd": cd, "lbl": lbl})
|
||||
if item_mouse and item_mouse.has_method("register_target"):
|
||||
var local_idx: int = i
|
||||
item_mouse.register_target(slot,
|
||||
func(payload: Dictionary): return _drop_mouse_item(payload, local_idx), self)
|
||||
_refresh_page()
|
||||
|
||||
func _drop_mouse_item(payload: Dictionary, slot: int) -> bool:
|
||||
if payload.is_empty() or String(payload.get("source", "")) != "inventory":
|
||||
return false
|
||||
if int(payload.get("window", -1)) != 1 or int(payload.get("cell", -1)) < 0:
|
||||
return false
|
||||
# QuickSlot inventory entries store the source inventory cell, not the vnum.
|
||||
return assign(slot, "item", int(payload.get("cell", -1)))
|
||||
|
||||
func _try_drop(slot: int) -> void:
|
||||
# skill_ui 把待拖技能放在 drag_skill_id;这里落点接
|
||||
var sk_ui := _find_skill_ui()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# RemotePlayerView —— ClientVS22 的远端 PC 表现层。
|
||||
#
|
||||
# 远端 PC 与本地玩家共用 PlayerView 的 race / GR2 / MSA 装载,但装备来源
|
||||
# 必须是 awPart[CHR_EQUIPPART_ARMOR..HAIR],不能读取本地 get_equipment()。
|
||||
extends "res://ui/player_view.gd"
|
||||
|
||||
const EquipModel = preload("res://ui/equip_model.gd")
|
||||
|
||||
var remote_equip: Node
|
||||
var _remote_parts: Array = []
|
||||
|
||||
func build_remote(assets_root: String, race: int, parts: Array, item_list: RefCounted,
|
||||
proto: Node = null, pump := Callable()) -> bool:
|
||||
if not build(assets_root, race, pump):
|
||||
return false
|
||||
_remote_parts = parts.duplicate()
|
||||
while _remote_parts.size() < 4:
|
||||
_remote_parts.append(0)
|
||||
if _remote_parts.size() > 4:
|
||||
_remote_parts = _remote_parts.slice(0, 4)
|
||||
remote_equip = EquipModel.new()
|
||||
remote_equip.name = "RemoteEquipModel"
|
||||
add_child(remote_equip)
|
||||
remote_equip.setup_remote(item_list, func() -> Node: return self, assets_root, race,
|
||||
_remote_parts, proto)
|
||||
return true
|
||||
|
||||
# net_world 按 NetworkActorManager.cpp:473 的顺序调用这四个字段。
|
||||
func set_armor(vnum: int) -> void:
|
||||
_set_remote_part(EquipModel.PART_ARMOR, vnum)
|
||||
|
||||
func set_weapon(vnum: int) -> void:
|
||||
_set_remote_part(EquipModel.PART_WEAPON, vnum)
|
||||
|
||||
func set_head(vnum: int) -> void:
|
||||
_set_remote_part(EquipModel.PART_HEAD, vnum)
|
||||
|
||||
func set_hair(vnum: int) -> void:
|
||||
_set_remote_part(EquipModel.PART_HAIR, vnum)
|
||||
|
||||
func _set_remote_part(index: int, vnum: int) -> void:
|
||||
while _remote_parts.size() <= index:
|
||||
_remote_parts.append(0)
|
||||
_remote_parts[index] = vnum
|
||||
if remote_equip:
|
||||
remote_equip._set_remote_part(index, vnum)
|
||||
remote_equip.refresh()
|
||||
+123
-1
@@ -9,18 +9,31 @@
|
||||
# 取出/存入用第一个空位(简版:deposit 用 safe_pos = 下一个空格;checkout 用道具 cell)。
|
||||
extends Node
|
||||
|
||||
const ItemTooltip = preload("res://ui/item_tooltip.gd")
|
||||
|
||||
var client: Node
|
||||
var proto: Node
|
||||
var _tooltip_builder: RefCounted
|
||||
var _root: Control
|
||||
var _list: VBoxContainer
|
||||
var _gold: Label
|
||||
var _title: Label
|
||||
var _status: Label
|
||||
var _password_dialog: ConfirmationDialog
|
||||
var _grid: GridContainer
|
||||
var _cells: Dictionary = {}
|
||||
var _page_label: Label
|
||||
var _page := 0
|
||||
var item_mouse: Node
|
||||
|
||||
const SAFE_PAGE_SLOTS := 45
|
||||
const SAFE_WINDOW := 3
|
||||
|
||||
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
|
||||
client = m2client
|
||||
proto = proto_node
|
||||
_tooltip_builder = ItemTooltip.new()
|
||||
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
|
||||
_build(parent)
|
||||
if client.has_signal("safebox_changed"):
|
||||
client.safebox_changed.connect(refresh)
|
||||
@@ -35,8 +48,13 @@ func is_open() -> bool:
|
||||
return _root != null and _root.visible
|
||||
|
||||
func close() -> void:
|
||||
if item_mouse and item_mouse.has_method("unregister_owner"):
|
||||
item_mouse.unregister_owner(self)
|
||||
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
|
||||
item_mouse.cancel()
|
||||
if _root:
|
||||
_root.visible = false
|
||||
_page = 0
|
||||
if is_instance_valid(_password_dialog):
|
||||
_password_dialog.queue_free()
|
||||
_password_dialog = null
|
||||
@@ -69,6 +87,13 @@ func _name_of(vnum: int) -> String:
|
||||
return n
|
||||
return "#%d" % vnum
|
||||
|
||||
func _tooltip_for(item: Dictionary) -> String:
|
||||
var vnum := int(item.get("vnum", 0))
|
||||
if _tooltip_builder:
|
||||
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
|
||||
return _tooltip_builder.format(vnum, int(item.get("count", 1)), pd, item)
|
||||
return "%s\n#%d ×%d" % [_name_of(vnum), vnum, int(item.get("count", 1))]
|
||||
|
||||
func refresh() -> void:
|
||||
if client == null:
|
||||
return
|
||||
@@ -76,10 +101,14 @@ func refresh() -> void:
|
||||
if not _root.visible:
|
||||
return
|
||||
_status.text = ""
|
||||
_title.text = "仓库(%d 页)" % client.get_safebox_size()
|
||||
var pages := maxi(1, int(client.get_safebox_size()))
|
||||
_page = clampi(_page, 0, pages - 1)
|
||||
_title.text = "仓库(第 %d/%d 页)" % [_page + 1, pages]
|
||||
_page_label.text = "%d / %d" % [_page + 1, pages]
|
||||
_gold.text = "仓库金币: %d" % client.get_safebox_gold()
|
||||
for c in _list.get_children():
|
||||
c.queue_free()
|
||||
_render_grid()
|
||||
var items: Array = client.get_safebox_items()
|
||||
if items.is_empty():
|
||||
var e := Label.new()
|
||||
@@ -89,6 +118,64 @@ func refresh() -> void:
|
||||
for it in items:
|
||||
_list.add_child(_row(it))
|
||||
|
||||
func _render_grid() -> void:
|
||||
if _grid == null:
|
||||
return
|
||||
for pos in _cells:
|
||||
var cell: Button = _cells[pos]
|
||||
cell.text = ""
|
||||
cell.tooltip_text = ""
|
||||
cell.set_meta("vnum", 0)
|
||||
cell.set_meta("count", 0)
|
||||
var items: Array = client.get_safebox_items() if client and client.has_method("get_safebox_items") else []
|
||||
for it in items:
|
||||
var absolute_pos := int(it.get("cell", -1))
|
||||
var pos := absolute_pos - _page * SAFE_PAGE_SLOTS
|
||||
if not _cells.has(pos):
|
||||
continue
|
||||
var cell: Button = _cells[pos]
|
||||
var vnum := int(it.get("vnum", 0))
|
||||
var count := int(it.get("count", 1))
|
||||
cell.text = "%s%s" % [_name_of(vnum).substr(0, 7), (" ×%d" % count) if count > 1 else ""]
|
||||
cell.tooltip_text = _tooltip_for(it)
|
||||
cell.set_meta("vnum", vnum)
|
||||
cell.set_meta("count", count)
|
||||
|
||||
func _drop_to_slot(payload: Dictionary, local_pos: int) -> bool:
|
||||
if payload.is_empty() or not _cells.has(local_pos):
|
||||
return false
|
||||
var safe_pos := _page * SAFE_PAGE_SLOTS + local_pos
|
||||
var cell: Button = _cells[local_pos]
|
||||
if int(cell.get_meta("vnum", 0)) != 0:
|
||||
return false
|
||||
var source_window := int(payload.get("window", -1))
|
||||
var source_cell := int(payload.get("cell", -1))
|
||||
if source_cell < 0:
|
||||
return false
|
||||
if source_window in [1, 2] and client.has_method("safebox_checkin"):
|
||||
return client.safebox_checkin(safe_pos, source_window, source_cell)
|
||||
if source_window == SAFE_WINDOW and client.has_method("safebox_move"):
|
||||
return client.safebox_move(source_cell, safe_pos, 1)
|
||||
return false
|
||||
|
||||
func _on_grid_input(local_pos: int, event: InputEvent) -> void:
|
||||
if not item_mouse or not item_mouse.has_method("attach_item"):
|
||||
return
|
||||
if not (event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT
|
||||
and event.pressed):
|
||||
return
|
||||
var cell: Button = _cells[local_pos]
|
||||
var vnum := int(cell.get_meta("vnum", 0))
|
||||
if vnum == 0:
|
||||
return
|
||||
item_mouse.attach_item(SAFE_WINDOW, _page * SAFE_PAGE_SLOTS + local_pos, vnum,
|
||||
int(cell.get_meta("count", 1)), null, "safebox")
|
||||
|
||||
func _change_page(delta: int) -> void:
|
||||
var pages := maxi(1, int(client.get_safebox_size()))
|
||||
_page = clampi(_page + delta, 0, pages - 1)
|
||||
refresh()
|
||||
|
||||
func _ask_password(kind: String) -> void:
|
||||
if is_instance_valid(_password_dialog):
|
||||
_password_dialog.queue_free()
|
||||
@@ -164,6 +251,23 @@ func _build(parent: Node) -> void:
|
||||
_title.text = "仓库"
|
||||
_title.position = Vector2(12, 8)
|
||||
_root.add_child(_title)
|
||||
var prev := Button.new()
|
||||
prev.text = "‹"
|
||||
prev.position = Vector2(235, 6)
|
||||
prev.size = Vector2(28, 24)
|
||||
prev.pressed.connect(func(): _change_page(-1))
|
||||
_root.add_child(prev)
|
||||
_page_label = Label.new()
|
||||
_page_label.position = Vector2(266, 10)
|
||||
_page_label.size = Vector2(60, 18)
|
||||
_page_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_root.add_child(_page_label)
|
||||
var next := Button.new()
|
||||
next.text = "›"
|
||||
next.position = Vector2(302, 6)
|
||||
next.size = Vector2(28, 24)
|
||||
next.pressed.connect(func(): _change_page(1))
|
||||
_root.add_child(next)
|
||||
_gold = Label.new()
|
||||
_gold.text = "仓库金币: 0"
|
||||
_gold.position = Vector2(12, 30)
|
||||
@@ -179,3 +283,21 @@ func _build(parent: Node) -> void:
|
||||
_list.position = Vector2(12, 54)
|
||||
_list.add_theme_constant_override("separation", 4)
|
||||
_root.add_child(_list)
|
||||
_list.visible = false
|
||||
_grid = GridContainer.new()
|
||||
_grid.columns = 5
|
||||
_grid.position = Vector2(12, 54)
|
||||
_grid.add_theme_constant_override("h_separation", 4)
|
||||
_grid.add_theme_constant_override("v_separation", 4)
|
||||
_root.add_child(_grid)
|
||||
for pos in SAFE_PAGE_SLOTS:
|
||||
var cell := Button.new()
|
||||
cell.custom_minimum_size = Vector2(58, 30)
|
||||
cell.add_theme_font_size_override("font_size", 9)
|
||||
cell.set_meta("safe_pos", pos)
|
||||
cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e))
|
||||
if item_mouse and item_mouse.has_method("register_target"):
|
||||
item_mouse.register_target(cell,
|
||||
func(payload: Dictionary): return _drop_to_slot(payload, pos), self)
|
||||
_grid.add_child(cell)
|
||||
_cells[pos] = cell
|
||||
|
||||
+229
-45
@@ -9,22 +9,38 @@
|
||||
# 卖:inventory_ui 在商店开着时右键道具 → 调 shop_ui.sell(cell)。
|
||||
extends Node
|
||||
|
||||
const CursorManager = preload("res://ui/cursor_manager.gd")
|
||||
const ItemTooltip = preload("res://ui/item_tooltip.gd")
|
||||
const SHOP_SLOT_COUNT := 40 # shop.SHOP_SLOT_COUNT (== SHOP_HOST_ITEM_MAX_NUM)
|
||||
const ITEM_ANTIFLAG_SELL := 1 << 8
|
||||
const ITEM_FLAG_COUNT_PER_1GOLD := 1 << 3
|
||||
|
||||
var client: Node
|
||||
var proto: Node
|
||||
var item_list # ItemListDB (RefCounted)
|
||||
var _tooltip_builder: RefCounted
|
||||
var _root: Control
|
||||
var _tabbar: HBoxContainer
|
||||
var _list: VBoxContainer
|
||||
var _grid: GridContainer
|
||||
var _err: Label
|
||||
var _sell_quantity: SpinBox
|
||||
var _active_tab := 0
|
||||
var item_mouse: Node
|
||||
var cursor_manager: Node
|
||||
var audio: Node
|
||||
var _mode := 1 # 1=BUY, 2=SELL
|
||||
var _buy_mode_button: Button
|
||||
var _sell_mode_button: Button
|
||||
var _sell_drop_target: Button
|
||||
var _sell_confirm: ConfirmationDialog
|
||||
var _buy_confirm: ConfirmationDialog
|
||||
|
||||
func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCounted = null) -> void:
|
||||
client = m2client
|
||||
proto = proto_node
|
||||
item_list = ilist
|
||||
_tooltip_builder = ItemTooltip.new()
|
||||
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
|
||||
_build(parent)
|
||||
if client.has_signal("shop_opened"):
|
||||
client.shop_opened.connect(func(_v): open())
|
||||
@@ -44,13 +60,26 @@ func open() -> void:
|
||||
_err.text = ""
|
||||
_sell_quantity.value = 1
|
||||
_active_tab = 0
|
||||
_set_mode(1)
|
||||
refresh()
|
||||
|
||||
func _close() -> void:
|
||||
if item_mouse and item_mouse.has_method("unregister_owner"):
|
||||
item_mouse.unregister_owner(self)
|
||||
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
|
||||
item_mouse.cancel()
|
||||
_root.visible = false
|
||||
_err.text = ""
|
||||
if _sell_quantity:
|
||||
_sell_quantity.value = 1
|
||||
if is_instance_valid(_sell_confirm):
|
||||
_sell_confirm.queue_free()
|
||||
_sell_confirm = null
|
||||
if is_instance_valid(_buy_confirm):
|
||||
_buy_confirm.hide()
|
||||
_buy_confirm.queue_free()
|
||||
_buy_confirm = null
|
||||
_set_mode(1)
|
||||
|
||||
func close_and_leave() -> void:
|
||||
client.shop_close()
|
||||
@@ -59,10 +88,119 @@ func close_and_leave() -> void:
|
||||
func sell(inv_cell: int, count: int = -1) -> void:
|
||||
if not is_open() or inv_cell < 0:
|
||||
return
|
||||
var item := _inventory_item(inv_cell)
|
||||
var amount := count if count > 0 else int(_sell_quantity.value)
|
||||
amount = clampi(amount, 1, 200)
|
||||
if client.has_method("shop_sell") and not client.shop_sell(inv_cell, amount):
|
||||
if not item.is_empty():
|
||||
amount = clampi(amount, 1, int(item.get("count", amount)))
|
||||
else:
|
||||
amount = clampi(amount, 1, 200)
|
||||
var payload := item.duplicate(true)
|
||||
payload["window"] = 1
|
||||
payload["cell"] = inv_cell
|
||||
payload["vnum"] = int(item.get("vnum", 0))
|
||||
payload["count"] = int(item.get("count", amount))
|
||||
_request_sell(payload, amount)
|
||||
|
||||
func _drop_to_sell(payload: Dictionary) -> bool:
|
||||
if _mode != 2 or payload.is_empty() or int(payload.get("window", -1)) != 1:
|
||||
return false
|
||||
var cell := int(payload.get("cell", -1))
|
||||
if cell < 0 or not client.has_method("shop_sell"):
|
||||
return false
|
||||
var source_count := maxi(1, int(payload.get("count", 1)))
|
||||
var amount := clampi(int(_sell_quantity.value), 1, mini(200, source_count))
|
||||
return _request_sell(payload, amount)
|
||||
|
||||
func _inventory_item(cell: int) -> Dictionary:
|
||||
if client == null or not client.has_method("get_inventory"):
|
||||
return {}
|
||||
for item in client.get_inventory():
|
||||
if int(item.get("cell", -1)) == cell:
|
||||
return item
|
||||
return {}
|
||||
|
||||
func _proto_item(vnum: int) -> Dictionary:
|
||||
if vnum > 0 and proto and proto.has_method("item"):
|
||||
return proto.item(vnum)
|
||||
return {}
|
||||
|
||||
func _sell_price(vnum: int, count: int, item_data: Dictionary = {}) -> int:
|
||||
var d := item_data if not item_data.is_empty() else _proto_item(vnum)
|
||||
var unit := maxi(0, int(d.get("sell_price", 0)))
|
||||
var flags := int(d.get("flags", 0))
|
||||
var raw := (count / maxi(1, unit)) if (flags & ITEM_FLAG_COUNT_PER_1GOLD) != 0 else unit * count
|
||||
return maxi(0, int(raw) / 5)
|
||||
|
||||
func _is_valuable(payload: Dictionary, item_data: Dictionary) -> bool:
|
||||
if int(item_data.get("sell_price", 0)) > 5000:
|
||||
return true
|
||||
var sockets: Array = payload.get("sockets", [])
|
||||
for value in sockets:
|
||||
if int(value) != 0:
|
||||
return true
|
||||
return false
|
||||
|
||||
func _request_sell(payload: Dictionary, amount: int) -> bool:
|
||||
var vnum := int(payload.get("vnum", 0))
|
||||
var d := _proto_item(vnum)
|
||||
var anti_flags := int(payload.get("anti_flags", 0)) | int(d.get("anti_flags", 0))
|
||||
if (anti_flags & ITEM_ANTIFLAG_SELL) != 0:
|
||||
_on_error("CANNOT_SELL")
|
||||
_play_ui("loginfail.wav")
|
||||
return false
|
||||
if _is_valuable(payload, d):
|
||||
_ask_sell_confirmation(payload, amount, _sell_price(vnum, amount, d))
|
||||
return true
|
||||
return _send_sell(int(payload.get("cell", -1)), amount)
|
||||
|
||||
func _send_sell(cell: int, amount: int) -> bool:
|
||||
if cell < 0 or not client.has_method("shop_sell"):
|
||||
return false
|
||||
if not client.shop_sell(cell, amount):
|
||||
_on_error("SEND_FAILED")
|
||||
return false
|
||||
_play_ui("money.wav")
|
||||
return true
|
||||
|
||||
func _ask_sell_confirmation(payload: Dictionary, amount: int, price: int) -> void:
|
||||
if is_instance_valid(_sell_confirm):
|
||||
_sell_confirm.hide()
|
||||
_sell_confirm.queue_free()
|
||||
_sell_confirm = ConfirmationDialog.new()
|
||||
_sell_confirm.title = "确认出售"
|
||||
_sell_confirm.dialog_text = "出售 %d 个物品,获得 %d 金币?" % [amount, price]
|
||||
_sell_confirm.ok_button_text = "出售"
|
||||
_sell_confirm.cancel_button_text = "取消"
|
||||
var dialog := _sell_confirm
|
||||
dialog.confirmed.connect(func() -> void:
|
||||
_sell_confirm = null
|
||||
dialog.hide()
|
||||
dialog.queue_free()
|
||||
_send_sell(int(payload.get("cell", -1)), amount))
|
||||
dialog.canceled.connect(func() -> void:
|
||||
_sell_confirm = null
|
||||
dialog.hide()
|
||||
dialog.queue_free())
|
||||
_root.add_child(dialog)
|
||||
if dialog.is_inside_tree():
|
||||
dialog.popup_centered()
|
||||
else:
|
||||
dialog.call_deferred("popup_centered")
|
||||
|
||||
func _play_ui(name: String) -> void:
|
||||
if audio and audio.has_method("play_ui"):
|
||||
audio.play_ui(name)
|
||||
|
||||
func _set_mode(mode: int) -> void:
|
||||
_mode = 2 if mode == 2 else 1
|
||||
if _buy_mode_button:
|
||||
_buy_mode_button.button_pressed = _mode == 1
|
||||
if _sell_mode_button:
|
||||
_sell_mode_button.button_pressed = _mode == 2
|
||||
if _sell_drop_target:
|
||||
_sell_drop_target.visible = _mode == 2
|
||||
if cursor_manager and cursor_manager.has_method("set_cursor"):
|
||||
cursor_manager.set_cursor(CursorManager.SELL if _mode == 2 else CursorManager.BUY)
|
||||
|
||||
func _name_of(vnum: int) -> String:
|
||||
if proto and proto.has_method("item"):
|
||||
@@ -78,8 +216,8 @@ func refresh() -> void:
|
||||
for c in _tabbar.get_children():
|
||||
_tabbar.remove_child(c)
|
||||
c.queue_free()
|
||||
for c in _list.get_children():
|
||||
_list.remove_child(c)
|
||||
for c in _grid.get_children():
|
||||
_grid.remove_child(c)
|
||||
c.queue_free()
|
||||
|
||||
# START_EX 商店有多个货架(shop.GetTabCount);普通 START 商店 tabs 只 1 个。
|
||||
@@ -108,47 +246,66 @@ func refresh() -> void:
|
||||
_tabbar.visible = false
|
||||
|
||||
var items: Array = tabs[_active_tab].get("items", [])
|
||||
if items.is_empty():
|
||||
var e := Label.new()
|
||||
e.text = "(无货物)"
|
||||
_list.add_child(e)
|
||||
return
|
||||
# uishop.py: 买位置 = tabIdx * SHOP_SLOT_COUNT + slotPos
|
||||
var base := _active_tab * SHOP_SLOT_COUNT
|
||||
var by_pos := {}
|
||||
for it in items:
|
||||
_list.add_child(_row(it, base))
|
||||
var slot := int(it.get("pos", 0))
|
||||
if slot >= 0 and slot < SHOP_SLOT_COUNT:
|
||||
by_pos[slot] = it
|
||||
for slot in SHOP_SLOT_COUNT:
|
||||
var it: Dictionary = by_pos.get(slot, {})
|
||||
_grid.add_child(_slot(it, base + slot))
|
||||
|
||||
func _row(it: Dictionary, pos_base := 0) -> Control:
|
||||
var row := HBoxContainer.new()
|
||||
row.custom_minimum_size = Vector2(320, 0)
|
||||
var nm := Label.new()
|
||||
nm.text = _name_of(int(it.get("vnum", 0)))
|
||||
nm.custom_minimum_size = Vector2(170, 0)
|
||||
nm.add_theme_font_size_override("font_size", 12)
|
||||
row.add_child(nm)
|
||||
var pr := Label.new()
|
||||
pr.text = "%d 金" % int(it.get("price", 0))
|
||||
pr.custom_minimum_size = Vector2(90, 0)
|
||||
pr.modulate = Color(0.95, 0.85, 0.5)
|
||||
row.add_child(pr)
|
||||
var buy := Button.new()
|
||||
buy.text = "买"
|
||||
var pos := pos_base + int(it.get("pos", 0))
|
||||
var stock := int(it.get("count", 0))
|
||||
var quantity := SpinBox.new()
|
||||
quantity.name = "Quantity"
|
||||
quantity.min_value = 1
|
||||
quantity.max_value = clampi(stock if stock > 0 else 200, 1, 200)
|
||||
quantity.step = 1
|
||||
quantity.value = 1
|
||||
quantity.custom_minimum_size = Vector2(64, 0)
|
||||
row.add_child(quantity)
|
||||
buy.pressed.connect(func() -> void:
|
||||
var amount := clampi(int(quantity.value), 1, int(quantity.max_value))
|
||||
if not client.shop_buy(pos, amount):
|
||||
func _slot(it: Dictionary, pos: int) -> Button:
|
||||
var slot := Button.new()
|
||||
slot.custom_minimum_size = Vector2(66, 30)
|
||||
slot.clip_text = true
|
||||
var vnum := int(it.get("vnum", 0))
|
||||
if vnum > 0:
|
||||
var count := int(it.get("count", 0))
|
||||
slot.text = _name_of(vnum).substr(0, 7)
|
||||
if count > 1:
|
||||
slot.text += " ×%d" % count
|
||||
if _tooltip_builder:
|
||||
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
|
||||
slot.tooltip_text = _tooltip_builder.format(vnum, maxi(1, count), pd, it)
|
||||
slot.tooltip_text += "\n价格:%d" % int(it.get("price", 0))
|
||||
slot.pressed.connect(func() -> void: _buy_slot(pos, it))
|
||||
else:
|
||||
slot.disabled = true
|
||||
return slot
|
||||
|
||||
func _buy_slot(pos: int, item: Dictionary) -> void:
|
||||
if _mode != 1:
|
||||
_set_mode(1)
|
||||
return
|
||||
if item.is_empty() or pos < 0:
|
||||
return
|
||||
if is_instance_valid(_buy_confirm):
|
||||
_buy_confirm.hide()
|
||||
_buy_confirm.queue_free()
|
||||
_buy_confirm = ConfirmationDialog.new()
|
||||
_buy_confirm.title = "确认购买"
|
||||
_buy_confirm.dialog_text = "购买 %s?\n价格:%d 金币" % [_name_of(int(item.get("vnum", 0))), int(item.get("price", 0))]
|
||||
_buy_confirm.ok_button_text = "购买"
|
||||
_buy_confirm.cancel_button_text = "取消"
|
||||
var dialog := _buy_confirm
|
||||
dialog.confirmed.connect(func() -> void:
|
||||
_buy_confirm = null
|
||||
dialog.hide()
|
||||
dialog.queue_free()
|
||||
if not client.shop_buy(pos, 1):
|
||||
_on_error("SEND_FAILED"))
|
||||
row.add_child(buy)
|
||||
return row
|
||||
dialog.canceled.connect(func() -> void:
|
||||
_buy_confirm = null
|
||||
dialog.hide()
|
||||
dialog.queue_free())
|
||||
_root.add_child(dialog)
|
||||
if dialog.is_inside_tree():
|
||||
dialog.popup_centered()
|
||||
else:
|
||||
dialog.call_deferred("popup_centered")
|
||||
|
||||
func _on_error(kind: String) -> void:
|
||||
var tbl := {
|
||||
@@ -202,15 +359,42 @@ func _build(parent: Node) -> void:
|
||||
_sell_quantity.position = Vector2(160, 356)
|
||||
_sell_quantity.size = Vector2(70, 26)
|
||||
_root.add_child(_sell_quantity)
|
||||
_sell_drop_target = Button.new()
|
||||
_sell_drop_target.name = "SellDropTarget"
|
||||
_sell_drop_target.text = "拖动物品到此出售"
|
||||
_sell_drop_target.position = Vector2(12, 325)
|
||||
_sell_drop_target.size = Vector2(210, 26)
|
||||
_sell_drop_target.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_sell_drop_target.visible = false
|
||||
_root.add_child(_sell_drop_target)
|
||||
if item_mouse and item_mouse.has_method("register_target"):
|
||||
item_mouse.register_target(_sell_drop_target, _drop_to_sell, self)
|
||||
_buy_mode_button = Button.new()
|
||||
_buy_mode_button.text = "购买"
|
||||
_buy_mode_button.toggle_mode = true
|
||||
_buy_mode_button.position = Vector2(235, 325)
|
||||
_buy_mode_button.size = Vector2(62, 26)
|
||||
_buy_mode_button.pressed.connect(func() -> void: _set_mode(1))
|
||||
_root.add_child(_buy_mode_button)
|
||||
_sell_mode_button = Button.new()
|
||||
_sell_mode_button.text = "出售"
|
||||
_sell_mode_button.toggle_mode = true
|
||||
_sell_mode_button.position = Vector2(300, 325)
|
||||
_sell_mode_button.size = Vector2(62, 26)
|
||||
_sell_mode_button.pressed.connect(func() -> void: _set_mode(2))
|
||||
_root.add_child(_sell_mode_button)
|
||||
_tabbar = HBoxContainer.new()
|
||||
_tabbar.position = Vector2(12, 30)
|
||||
_tabbar.add_theme_constant_override("separation", 4)
|
||||
_tabbar.visible = false
|
||||
_root.add_child(_tabbar)
|
||||
_list = VBoxContainer.new()
|
||||
_list.position = Vector2(12, 58)
|
||||
_list.add_theme_constant_override("separation", 4)
|
||||
_root.add_child(_list)
|
||||
_grid = GridContainer.new()
|
||||
_grid.columns = 5
|
||||
_grid.position = Vector2(12, 58)
|
||||
_grid.size = Vector2(350, 252)
|
||||
_grid.add_theme_constant_override("h_separation", 3)
|
||||
_grid.add_theme_constant_override("v_separation", 2)
|
||||
_root.add_child(_grid)
|
||||
var close_btn := Button.new()
|
||||
close_btn.text = "离开"
|
||||
close_btn.position = Vector2(300, 360)
|
||||
|
||||
+324
-22
@@ -11,6 +11,12 @@
|
||||
# st.for_job("WARRIOR") # 主动职业技能 [id, …]
|
||||
# st.for_category("SUPPORT") # 辅助 / 被动树
|
||||
# st.is_passive(121) # true
|
||||
#
|
||||
# §3.8 TSkillData 判定位:第 11 列 attributes / 第 12 列 weapon_limit 是 `|` 分隔的
|
||||
# 记号串,原客户端 RegisterSkillDesc(PythonSkill.cpp:280)把它们经
|
||||
# m_SkillAttributeIndexMap / m_SkillNeedWeaponIndexMap 折成 dwSkillAttribute /
|
||||
# dwNeedWeapon 两个位掩码,CPythonSkill 的一众 IsXxx() 谓词就是对这两个掩码取位。
|
||||
# 这里在 load_file 时做同样的折叠,谓词逐条对齐 PythonSkill.cpp。
|
||||
extends RefCounted
|
||||
|
||||
var _by_id := {}
|
||||
@@ -23,6 +29,87 @@ const JOB_DIR := {
|
||||
"SHAMAN": "shaman",
|
||||
}
|
||||
|
||||
# --- SKILL_ATTRIBUTE_*(PythonSkill.h:92-115,记号名同 m_SkillAttributeIndexMap)---
|
||||
const ATTR := {
|
||||
"NEED_TARGET": 1 << 0,
|
||||
"TOGGLE": 1 << 1,
|
||||
"WEAPON_LIMITATION": 1 << 2,
|
||||
"MELEE_ATTACK": 1 << 3,
|
||||
"USE_HP": 1 << 4,
|
||||
"CAN_CHANGE_DIRECTION": 1 << 5,
|
||||
"STANDING_SKILL": 1 << 6,
|
||||
"ONLY_FOR_ALLIANCE": 1 << 7,
|
||||
"CAN_USE_FOR_ME": 1 << 8,
|
||||
"NEED_CORPSE": 1 << 9,
|
||||
"FAN_RANGE": 1 << 10,
|
||||
"CAN_USE_IF_NOT_ENOUGH": 1 << 11,
|
||||
"NEED_EMPTY_BOTTLE": 1 << 12,
|
||||
"NEED_POISON_BOTTLE": 1 << 13,
|
||||
"ATTACK_SKILL": 1 << 14,
|
||||
"TIME_INCREASE_SKILL": 1 << 15,
|
||||
"CHARGE_ATTACK": 1 << 16,
|
||||
"PASSIVE": 1 << 17,
|
||||
"CANNOT_LEVEL_UP": 1 << 18,
|
||||
"ONLY_FOR_GUILD_WAR": 1 << 19,
|
||||
"MOVING_SKILL": 1 << 20,
|
||||
"HORSE_SKILL": 1 << 21,
|
||||
"CIRCLE_RANGE": 1 << 22,
|
||||
"SEARCH_TARGET": 1 << 23,
|
||||
}
|
||||
|
||||
# --- CItemData::EWeaponSubTypes(GameLib/ItemData.h:63-75)---
|
||||
const WEAPON_SWORD := 0
|
||||
const WEAPON_DAGGER := 1
|
||||
const WEAPON_BOW := 2
|
||||
const WEAPON_TWO_HANDED := 3
|
||||
const WEAPON_BELL := 4
|
||||
const WEAPON_FAN := 5
|
||||
const WEAPON_ARROW := 6
|
||||
const WEAPON_NONE := 8 # WEAPON_NUM_TYPES(7) + 1
|
||||
|
||||
# --- SKILL_NEED_WEAPON_*(PythonSkill.h:120-128,位 = 1 << CItemData::WEAPON_*)---
|
||||
# 记号名同 m_SkillNeedWeaponIndexMap;DOUBLE_SWORD 复用 DAGGER 位。
|
||||
const NEED_WEAPON := {
|
||||
"SWORD": 1 << WEAPON_SWORD,
|
||||
"DAGGER": 1 << WEAPON_DAGGER,
|
||||
"BOW": 1 << WEAPON_BOW,
|
||||
"TWO_HANDED": 1 << WEAPON_TWO_HANDED,
|
||||
"DOUBLE_SWORD": 1 << WEAPON_DAGGER,
|
||||
"BELL": 1 << WEAPON_BELL,
|
||||
"FAN": 1 << WEAPON_FAN,
|
||||
"ARROW": 1 << WEAPON_ARROW,
|
||||
"EMPTY_HAND": 1 << WEAPON_NONE,
|
||||
}
|
||||
|
||||
# --- CPythonSkill::ESkillType(PythonSkill.h)+ m_SkillTypeIndexMap(PythonSkill.cpp:909-918)---
|
||||
const SKILL_TYPE_NONE := 0
|
||||
const SKILL_TYPE_ACTIVE := 1
|
||||
const SKILL_TYPE_SUPPORT := 2
|
||||
const SKILL_TYPE_GUILD := 3
|
||||
const SKILL_TYPE_HORSE := 4
|
||||
const _SKILL_TYPE_MAP := {
|
||||
"none": SKILL_TYPE_NONE,
|
||||
"active": SKILL_TYPE_ACTIVE,
|
||||
"support": SKILL_TYPE_SUPPORT,
|
||||
"guild": SKILL_TYPE_GUILD,
|
||||
"horse": SKILL_TYPE_HORSE,
|
||||
"warrior": SKILL_TYPE_ACTIVE,
|
||||
"assassin": SKILL_TYPE_ACTIVE,
|
||||
"sura": SKILL_TYPE_ACTIVE,
|
||||
"shaman": SKILL_TYPE_ACTIVE,
|
||||
"passive": SKILL_TYPE_ACTIVE,
|
||||
}
|
||||
|
||||
# 把 `|`(或旧数据里的空格 / 逗号)分隔的记号串折成位掩码。未知记号忽略,
|
||||
# 对齐原版“map 里查不到就跳过该记号”的行为(PythonSkill.cpp:374-378 / 393-397)。
|
||||
static func _fold_bits(tokens: String, name_to_bit: Dictionary) -> int:
|
||||
var bits := 0
|
||||
for raw in tokens.replace(",", "|").replace(" ", "|").split("|", false):
|
||||
var key := raw.strip_edges().to_upper()
|
||||
if name_to_bit.has(key):
|
||||
bits |= int(name_to_bit[key])
|
||||
return bits
|
||||
|
||||
func load_file(path: String) -> bool:
|
||||
_by_id.clear()
|
||||
count = 0
|
||||
@@ -43,19 +130,34 @@ func load_file(path: String) -> bool:
|
||||
continue
|
||||
# 0-indexed: id=0 job=1 name=2..4 desc=5..8 (9 空) attrs=10 weapon=11
|
||||
# motion_name=12 motion_idx=13 grades=14
|
||||
var job := t[1].strip_edges()
|
||||
var attrs := t[10].strip_edges() if t.size() > 10 else ""
|
||||
var weapon := t[11].strip_edges() if t.size() > 11 else ""
|
||||
var entry := {
|
||||
"id": id,
|
||||
"job": t[1].strip_edges(),
|
||||
"job": job,
|
||||
"name": t[2].strip_edges(),
|
||||
"name2": t[3].strip_edges(),
|
||||
"name3": t[4].strip_edges(),
|
||||
"attrs": t[10].strip_edges() if t.size() > 10 else "",
|
||||
"weapon": t[11].strip_edges() if t.size() > 11 else "",
|
||||
"attrs": attrs,
|
||||
"weapon": weapon,
|
||||
"motion": t[12].strip_edges() if t.size() > 12 else "",
|
||||
"motion_idx": int(t[13]) if t.size() > 13 else 0,
|
||||
"grades": int(t[14]) if t.size() > 14 else 1,
|
||||
# §3.8:预折叠的 TSkillData 判定位(对齐 dwSkillAttribute / dwNeedWeapon)
|
||||
"attr_bits": _fold_bits(attrs, ATTR),
|
||||
"need_weapon_bits": _fold_bits(weapon, NEED_WEAPON),
|
||||
"skill_type": int(_SKILL_TYPE_MAP.get(job.to_lower(), SKILL_TYPE_NONE)),
|
||||
}
|
||||
_load_cooltime(entry)
|
||||
_load_msk(entry)
|
||||
# RegisterSkillDesc(PythonSkill.cpp:280):DESC_TOKEN_TYPE LEVEL_LIMIT=26 /
|
||||
# MAX_LEVEL=27 只在 TSV 列数够时解析。本 40250 shipped skilldesc.txt 只有 22 列,
|
||||
# 这两列都缺,逻辑保留以对齐参考端、并兼容带这两列的第三方数据。
|
||||
# .msk 的 maxlevel(_load_msk 已处理)优先于 skilldesc MAX_LEVEL。
|
||||
if not entry.has("max_level") and t.size() > 27 and int(t[27]) > 0:
|
||||
entry["max_level"] = max(1, int(t[27]))
|
||||
if t.size() > 26 and int(t[26]) > 0:
|
||||
entry["level_limit"] = int(t[26])
|
||||
_by_id[id] = entry
|
||||
count = _by_id.size()
|
||||
return count > 0
|
||||
@@ -75,27 +177,171 @@ func motion_idx_of(id: int) -> int:
|
||||
func _attrs(id: int) -> String:
|
||||
return String(_by_id.get(id, {}).get("attrs", ""))
|
||||
|
||||
func is_attack(id: int) -> bool:
|
||||
return _attrs(id).contains("ATTACK_SKILL")
|
||||
# §3.8:折叠后的判定位掩码。取位即原版一众 IsXxx() 谓词(PythonSkill.cpp)。
|
||||
func attr_bits(id: int) -> int:
|
||||
return int(_by_id.get(id, {}).get("attr_bits", 0))
|
||||
|
||||
# 原版只有弓类飞行攻击在动作事件中发送 CG_SHOOT;把 weapon_limit 保留下来,
|
||||
# 让上层可以只为远程技能安排 shoot,而不会给近战技能误发该包。
|
||||
func need_weapon_bits(id: int) -> int:
|
||||
return int(_by_id.get(id, {}).get("need_weapon_bits", 0))
|
||||
|
||||
func _has_attr(id: int, name: String) -> bool:
|
||||
return (attr_bits(id) & int(ATTR[name])) != 0
|
||||
|
||||
# --- 逐条对齐 CPythonSkill 谓词(PythonSkill.cpp)---
|
||||
func is_attack(id: int) -> bool: # IsAttackSkill
|
||||
return _has_attr(id, "ATTACK_SKILL")
|
||||
|
||||
func is_passive(id: int) -> bool: # SKILL_ATTRIBUTE_PASSIVE
|
||||
return _has_attr(id, "PASSIVE")
|
||||
|
||||
func is_toggle(id: int) -> bool: # IsToggleSkill
|
||||
return _has_attr(id, "TOGGLE")
|
||||
|
||||
func is_standing(id: int) -> bool: # IsStandingSkill
|
||||
return _has_attr(id, "STANDING_SKILL")
|
||||
|
||||
func is_need_target(id: int) -> bool: # IsNeedTarget
|
||||
return _has_attr(id, "NEED_TARGET")
|
||||
|
||||
func is_need_corpse(id: int) -> bool: # IsNeedCorpse
|
||||
return _has_attr(id, "NEED_CORPSE")
|
||||
|
||||
func is_auto_search_target(id: int) -> bool: # IsAutoSearchTarget
|
||||
return _has_attr(id, "SEARCH_TARGET")
|
||||
|
||||
func can_change_direction(id: int) -> bool: # CanChangeDirection
|
||||
return _has_attr(id, "CAN_CHANGE_DIRECTION")
|
||||
|
||||
func is_horse_skill(id: int) -> bool: # IsHorseSkill
|
||||
return _has_attr(id, "HORSE_SKILL")
|
||||
|
||||
func is_moving_skill(id: int) -> bool: # IsMovingSkill
|
||||
return _has_attr(id, "MOVING_SKILL")
|
||||
|
||||
func is_melee(id: int) -> bool: # IsMeleeSkill
|
||||
return _has_attr(id, "MELEE_ATTACK")
|
||||
|
||||
func is_charge_skill(id: int) -> bool: # IsChargeSkill
|
||||
return _has_attr(id, "CHARGE_ATTACK")
|
||||
|
||||
func is_use_hp(id: int) -> bool: # IsUseHPSkill
|
||||
return _has_attr(id, "USE_HP")
|
||||
|
||||
func is_time_increase(id: int) -> bool: # IsTimeIncreaseSkill
|
||||
return _has_attr(id, "TIME_INCREASE_SKILL")
|
||||
|
||||
func is_need_empty_bottle(id: int) -> bool: # IsNeedEmptyBottle
|
||||
return _has_attr(id, "NEED_EMPTY_BOTTLE")
|
||||
|
||||
func is_need_poison_bottle(id: int) -> bool: # IsNeedPoisonBottle
|
||||
return _has_attr(id, "NEED_POISON_BOTTLE")
|
||||
|
||||
func can_use_for_me(id: int) -> bool: # CanUseForMe
|
||||
return _has_attr(id, "CAN_USE_FOR_ME")
|
||||
|
||||
func is_only_for_alliance(id: int) -> bool: # IsOnlyForAlliance
|
||||
return _has_attr(id, "ONLY_FOR_ALLIANCE")
|
||||
|
||||
func can_use_if_not_enough(id: int) -> bool: # CanUseIfNotEnough
|
||||
return _has_attr(id, "CAN_USE_IF_NOT_ENOUGH")
|
||||
|
||||
func is_only_for_guild_war(id: int) -> bool: # IsOnlyForGuildWar
|
||||
return _has_attr(id, "ONLY_FOR_GUILD_WAR")
|
||||
|
||||
func is_fan_range(id: int) -> bool: # IsFanRange
|
||||
return _has_attr(id, "FAN_RANGE")
|
||||
|
||||
func is_circle_range(id: int) -> bool: # IsCircleRange
|
||||
return _has_attr(id, "CIRCLE_RANGE")
|
||||
|
||||
# GetTargetCount(fSkillPoint)(PythonSkill.cpp:1361):strTargetCountFormula 为空 → 0,
|
||||
# 否则把 SkillPoint(= LocaleService_GetSkillPower/100)代入公式后向下取整。
|
||||
# 扇形/圆形补目标(PythonPlayerSkill.cpp:684 `if (dwTargetMaxCount>0 …)`)就靠这个 > 0。
|
||||
func target_count(id: int, level: int) -> int:
|
||||
var formula := String(_by_id.get(id, {}).get("target_count_formula", ""))
|
||||
if formula == "":
|
||||
return 0
|
||||
var expression := Expression.new()
|
||||
if expression.parse(formula, ["SkillPoint"]) != OK:
|
||||
return 0
|
||||
var value: Variant = expression.execute([_skill_point(level)])
|
||||
if expression.has_execute_failed() or not (value is float or value is int):
|
||||
return 0
|
||||
return int(floor(float(value)))
|
||||
|
||||
# __GetSkillTargetRange(PythonPlayerSkill.cpp:391)= rkSkillData.GetTargetRange()
|
||||
# + GetStatus(POINT_BOW_DISTANCE)*100。这里只给 .msk 的基础射程(cm);主角弓距加成
|
||||
# 由 net_play(有玩家状态)叠加。
|
||||
func target_range(id: int) -> int:
|
||||
return int(_by_id.get(id, {}).get("target_range", 0))
|
||||
|
||||
# skilldesc / .msk 的 FAN_RANGE / CIRCLE_RANGE → NetPlay.FlyShape(SINGLE/FAN/CIRCLE)。
|
||||
func fly_shape(id: int) -> int:
|
||||
if is_fan_range(id):
|
||||
return 1
|
||||
if is_circle_range(id):
|
||||
return 2
|
||||
return 0
|
||||
|
||||
func has_weapon_limitation(id: int) -> bool: # SKILL_ATTRIBUTE_WEAPON_LIMITATION
|
||||
return _has_attr(id, "WEAPON_LIMITATION")
|
||||
|
||||
# IsCanUseSkill()(PythonSkill.cpp:1407):被动技一律不可主动施放,其余可。
|
||||
func is_can_use_skill(id: int) -> bool:
|
||||
return not _has_attr(id, "PASSIVE")
|
||||
|
||||
# CanUseWeaponType(dwWeaponType)(PythonSkill.cpp:1143):无武器限制恒真,
|
||||
# 否则 dwNeedWeapon & (1 << dwWeaponType)。
|
||||
func can_use_weapon_type(id: int, weapon_type: int) -> bool:
|
||||
if not has_weapon_limitation(id):
|
||||
return true
|
||||
return (need_weapon_bits(id) & (1 << weapon_type)) != 0
|
||||
|
||||
# IsNeedBow():dwNeedWeapon & SKILL_NEED_WEAPON_BOW。
|
||||
func is_need_bow(id: int) -> bool:
|
||||
return (need_weapon_bits(id) & int(NEED_WEAPON["BOW"])) != 0
|
||||
|
||||
# 保留旧名:原本靠 weapon 列字符串判远程,现改为查 need-weapon 位(等价,含 ARROW)。
|
||||
func is_ranged(id: int) -> bool:
|
||||
return String(_by_id.get(id, {}).get("weapon", "")).to_upper().contains("BOW")
|
||||
return (need_weapon_bits(id) & (int(NEED_WEAPON["BOW"]) | int(NEED_WEAPON["ARROW"]))) != 0
|
||||
|
||||
func is_passive(id: int) -> bool:
|
||||
return _attrs(id).contains("PASSIVE")
|
||||
# CPythonSkill::ESkillType:3=SKILL_TYPE_GUILD,4=SKILL_TYPE_HORSE,1=主动,2=辅助。
|
||||
func skill_type_of(id: int) -> int:
|
||||
return int(_by_id.get(id, {}).get("skill_type", SKILL_TYPE_NONE))
|
||||
|
||||
func is_toggle(id: int) -> bool:
|
||||
return _attrs(id).contains("TOGGLE")
|
||||
func is_guild_skill(id: int) -> bool:
|
||||
return skill_type_of(id) == SKILL_TYPE_GUILD
|
||||
|
||||
func can_level_up(id: int) -> bool:
|
||||
return _by_id.has(id) and not _attrs(id).contains("CANNOT_LEVEL_UP")
|
||||
func can_level_up(id: int) -> bool: # !CANNOT_LEVEL_UP
|
||||
return _by_id.has(id) and not _has_attr(id, "CANNOT_LEVEL_UP")
|
||||
|
||||
# 读取对应 .msk 中的 CoolTimeFormula 和 MaxLevel。原客户端用同一份公式
|
||||
# 按技能效率百分比计算本地冷却;这里在发送后进行同样的本地预测,GC_SKILL_COOLTIME_END
|
||||
# 仍是服务器的提前解锁信号。
|
||||
func _load_cooltime(entry: Dictionary) -> void:
|
||||
# SSkillData::GetMaxLevel()(PythonSkill.cpp:1392)= byMaxLevel。优先 .msk 的
|
||||
# maxlevel(_load_msk),其次 skilldesc MAX_LEVEL 列,都没有则构造默认 20
|
||||
# (SSkillData 构造,PythonSkill.cpp:1462)。
|
||||
func max_level_of(id: int) -> int:
|
||||
return int(_by_id.get(id, {}).get("max_level", 20))
|
||||
|
||||
# skillGetSkillLevelLimit(PythonSkill.cpp:1741)= byLevelLimit。本 40250 数据里
|
||||
# skilldesc 无 LEVEL_LIMIT 列、.msk 也无 levellimit 记号,恒 0;参考端马术页门
|
||||
# `GetStatus(LEVEL) >= GetSkillLevelLimit()` 因而恒真。
|
||||
func skill_level_limit(id: int) -> int:
|
||||
return int(_by_id.get(id, {}).get("level_limit", 0))
|
||||
|
||||
# skillCanLevelUpSkill(PythonSkill.cpp:1833)。本 build 里 isRequirement /
|
||||
# strRequireSkillName / byRequireSkillLevel / RequireStatDataVector 从不被填充
|
||||
# (构造默认 isRequirement=FALSE、空 vector,PythonSkill.cpp:1471),
|
||||
# .msk / skilldesc 也没有任何前置技能 / 前置属性字段,故 1:1 化简为:
|
||||
# 未满级(level < GetMaxLevel())且 非 SKILL_ATTRIBUTE_CANNOT_LEVEL_UP。
|
||||
func can_level_up_skill(id: int, level: int) -> bool:
|
||||
return _by_id.has(id) and level < max_level_of(id) and not _has_attr(id, "CANNOT_LEVEL_UP")
|
||||
|
||||
# 读取对应 .msk 中的 CoolTimeFormula / MaxLevel / NeedSPFormula。原客户端用同一份公式
|
||||
# 按技能效率百分比计算本地冷却与消耗;这里在发送前 / 后做同样的本地预测,
|
||||
# GC_SKILL_COOLTIME_END 仍是服务器的提前解锁信号。
|
||||
# NeedSPFormula 供 §3.8 修改 3 的 __CheckShortMana / __CheckShortLife
|
||||
# (参考 GetNeedSP,PythonSkill.cpp:1426;__CheckShortLife 同样取 GetNeedSP,
|
||||
# PythonPlayerSkill.cpp:359)。
|
||||
func _load_msk(entry: Dictionary) -> void:
|
||||
var job_dir := String(JOB_DIR.get(String(entry.get("job", "")), ""))
|
||||
var motion := String(entry.get("motion", ""))
|
||||
if job_dir == "" or motion == "":
|
||||
@@ -115,6 +361,62 @@ func _load_cooltime(entry: Dictionary) -> void:
|
||||
var max_level := _ascii_field(bytes, "MaxLevel")
|
||||
if max_level != "":
|
||||
entry["max_level"] = max(1, int(max_level.trim_prefix("\"").trim_suffix("\"")))
|
||||
var need_sp := _ascii_field(bytes, "NeedSPFormula")
|
||||
if need_sp != "":
|
||||
entry["need_sp_formula"] = need_sp.trim_prefix("\"").trim_suffix("\"")
|
||||
# GetTargetCount / __GetSkillTargetRange 的数据源(.msk 的 TargetCountFormula / Range,
|
||||
# CMotionSkillData 里对应 strTargetCountFormula / dwTargetRange)。
|
||||
var tcf := _ascii_field(bytes, "TargetCountFormula")
|
||||
if tcf != "":
|
||||
entry["target_count_formula"] = tcf.trim_prefix("\"").trim_suffix("\"")
|
||||
var trange := _ascii_field(bytes, "Range")
|
||||
if trange != "":
|
||||
entry["target_range"] = max(0, int(trange.trim_prefix("\"").trim_suffix("\"")))
|
||||
|
||||
# LocaleService_GetSkillPower(Locale.cpp:126,INTERNATIONAL 表)——
|
||||
# 原客户端 fcurEfficientPercentage = GetSkillPower(level) / 100,公式里的
|
||||
# SkillPoint / k 变量取这个值(ProcessFormula,PythonSkill.cpp:1258)。
|
||||
const _SKILL_POWER := [
|
||||
0,
|
||||
5, 6, 8, 10, 12,
|
||||
14, 16, 18, 20, 22,
|
||||
24, 26, 28, 30, 32,
|
||||
34, 36, 38, 40, 50, # 20 = master
|
||||
52, 54, 56, 58, 60,
|
||||
63, 66, 69, 72, 82, # 30 = grand master
|
||||
85, 88, 91, 94, 98,
|
||||
102, 106, 110, 115, 125, # 40 = perfect master
|
||||
125,
|
||||
]
|
||||
|
||||
# fSkillPoint(= fcurEfficientPercentage):level 越界(>= 50,参考 SKILL_POWER_NUM)→ 0。
|
||||
func _skill_point(level: int) -> float:
|
||||
if level < 0 or level >= 50:
|
||||
return 0.0
|
||||
var idx: int = min(level, _SKILL_POWER.size() - 1)
|
||||
return float(_SKILL_POWER[idx]) / 100.0
|
||||
|
||||
# GetNeedSP(fSkillPoint)(PythonSkill.cpp:1426):无公式 → 返回 -1(POC 约定:
|
||||
# 「未知」,调用方按参考里的 `if (-1 != iNeedSP)` 分支不拦截;参考端此处返回 0,
|
||||
# 但 POC 的 skilldesc/.msk 里不少主动技确实没有 NeedSPFormula,硬套 0 会误挡,
|
||||
# 故这里显式区分「解析不出」= -1)。公式引用了非 SkillPoint 的状态变量时同样退化 -1。
|
||||
func need_sp(id: int, level: int) -> int:
|
||||
var data := entry(id)
|
||||
var formula := String(data.get("need_sp_formula", ""))
|
||||
if formula == "":
|
||||
return -1
|
||||
var expression := Expression.new()
|
||||
if expression.parse(formula, ["SkillPoint"]) != OK:
|
||||
return -1
|
||||
var value: Variant = expression.execute([_skill_point(level)])
|
||||
if expression.has_execute_failed() or not (value is float or value is int):
|
||||
return -1
|
||||
return int(value)
|
||||
|
||||
# __CheckShortLife(PythonPlayerSkill.cpp:359)里 dwNeedHP 也是取 GetNeedSP,
|
||||
# 不是单独的 HP 公式——照抄这个怪癖。
|
||||
func need_hp(id: int, level: int) -> int:
|
||||
return need_sp(id, level)
|
||||
|
||||
func _ascii_field(bytes: PackedByteArray, field: String) -> String:
|
||||
var needle := field.to_ascii_buffer()
|
||||
@@ -142,12 +444,12 @@ func cooldown_of(id: int, level: int) -> float:
|
||||
var formula := String(data.get("cooltime_formula", ""))
|
||||
if formula == "":
|
||||
return 0.0
|
||||
var max_level: int = max(1, int(data.get("max_level", 20)))
|
||||
var skill_point: float = clampf(float(level) / float(max_level), 0.0, 1.0)
|
||||
# §3.8 修改 3:SkillPoint 统一走 LocaleService_GetSkillPower(与 GetNeedSP 同一份
|
||||
# fcurEfficientPercentage),不再用 level/max_level 近似。
|
||||
var expression := Expression.new()
|
||||
if expression.parse(formula, ["SkillPoint"]) != OK:
|
||||
return 0.0
|
||||
var value: Variant = expression.execute([skill_point])
|
||||
var value: Variant = expression.execute([_skill_point(level)])
|
||||
if expression.has_execute_failed() or not (value is float or value is int):
|
||||
return 0.0
|
||||
return maxf(0.0, float(value))
|
||||
|
||||
@@ -8,11 +8,20 @@
|
||||
#
|
||||
# 每行:名字 + Lv X(/M/G/P) + [+](被动 / CANNOT_LEVEL_UP 无加点)+ [被动]/[切换] 标签。
|
||||
# 点名字 -> drag_skill_id(quickbar 落点读)。`skills_changed` 刷新。
|
||||
# §3.10:[+] 的显隐逐行对齐 uicharacter.py RefreshSkillPlusButtonList /
|
||||
# __RefreshSkillPlusButton / CanShowPlusButton —— 技能组门(get_skill_group()==0)、
|
||||
# 三个技能点来源分开取、skillGrade==0、马术页 skillLevel<20、主动页 CanLevelUpSkill、
|
||||
# 辅助页 HIDE_SUPPORT_SKILL_POINT 整页隐藏。升级结果仍由 GC_SKILL_LEVEL 回包驱动。
|
||||
extends Node
|
||||
|
||||
const TABS := ["主动", "辅助", "坐骑"]
|
||||
const MASTER_SUFFIX := ["", " M", " G", " P"] # master_type 0..3
|
||||
|
||||
# uicharacter.py:21 HIDE_SUPPORT_SKILL_POINT —— 本 migration 资源为 True,辅助页整页不出加号。
|
||||
const HIDE_SUPPORT_SKILL_POINT := true
|
||||
# uicharacter.py __RefreshSkillPlusButton 的 HORSE 分支硬编码 `if skillLevel < 20`。
|
||||
const RIDING_SKILL_MAX_LEVEL := 20
|
||||
|
||||
var client: Node
|
||||
var table: RefCounted # SkillTable
|
||||
var ui: CanvasLayer # UiManager
|
||||
@@ -161,7 +170,59 @@ func refresh() -> void:
|
||||
for s in client.get_skills():
|
||||
lv[int(s["id"])] = int(s["level"])
|
||||
ms[int(s["id"])] = int(s.get("master", 0))
|
||||
# RefreshSkillPlusButtonList(uicharacter.py:984)→ __RefreshSkillPlusButton(:937):
|
||||
# 每页的技能点来源分开取,技能组未选时整列隐藏加号。
|
||||
var page := _cat_key()
|
||||
var can_use_now := _can_use_skill_now()
|
||||
var stat_point := _stat_point_for(page)
|
||||
var plv := _player_level()
|
||||
for id in _rows:
|
||||
var lvl: int = lv.get(id, 0)
|
||||
var m: int = clampi(ms.get(id, 0), 0, 3)
|
||||
_rows[id]["lv"].text = "Lv %d%s" % [lvl, MASTER_SUFFIX[m]]
|
||||
var up: Button = _rows[id]["up"]
|
||||
if up != null:
|
||||
up.visible = _can_show_plus(id, lvl, m, page, can_use_now, stat_point, plv)
|
||||
|
||||
# __CanUseSkillNow(uicharacter.py:1270):net.GetMainActorSkillGroup()==0 -> False。
|
||||
func _can_use_skill_now() -> bool:
|
||||
if client.has_method("get_skill_group"):
|
||||
return int(client.get_skill_group()) != 0
|
||||
return true
|
||||
|
||||
# skillPageStatDict(uicharacter.py:225):SUPPORT=POINT_SUB_SKILL(27) /
|
||||
# ACTIVE=POINT_SKILL(28) / HORSE=POINT_HORSE_SKILL(113)。三个来源分开取,不合并成一个。
|
||||
func _stat_point_for(page: String) -> int:
|
||||
if not client.has_method("get_points"):
|
||||
return 1
|
||||
var p: Dictionary = client.get_points()
|
||||
match page:
|
||||
"SUPPORT": return int(p.get("skill_support", 0))
|
||||
"HORSE": return int(p.get("skill_horse", 0))
|
||||
_: return int(p.get("skill_active", 0))
|
||||
|
||||
func _player_level() -> int:
|
||||
if not client.has_method("get_points"):
|
||||
return 1
|
||||
return int(client.get_points().get("level", 1))
|
||||
|
||||
# __RefreshSkillPlusButton 每槽体(uicharacter.py:937)+ CanShowPlusButton(:925):
|
||||
# 技能组未选 -> 全隐藏;辅助页 HIDE_SUPPORT_SKILL_POINT -> 整页隐藏;
|
||||
# statPoint <= 0 -> 不显示;skillGrade != 0(已过 0 级,走大师书)-> 跳过;
|
||||
# HORSE 页:GetStatus(LEVEL) >= GetSkillLevelLimit(idx) 时,skillLevel < 20 才显示;
|
||||
# 其余页:skill.CanLevelUpSkill(idx, level)。
|
||||
func _can_show_plus(id: int, level: int, grade: int, page: String,
|
||||
can_use_now: bool, stat_point: int, plv: int) -> bool:
|
||||
if not can_use_now:
|
||||
return false
|
||||
if page == "SUPPORT" and HIDE_SUPPORT_SKILL_POINT:
|
||||
return false
|
||||
if stat_point <= 0:
|
||||
return false
|
||||
if grade != 0:
|
||||
return false
|
||||
if page == "HORSE":
|
||||
if plv >= table.skill_level_limit(id):
|
||||
return level < RIDING_SKILL_MAX_LEVEL
|
||||
return false
|
||||
return table.can_level_up_skill(id, level)
|
||||
|
||||
@@ -35,6 +35,7 @@ func _ready() -> void:
|
||||
_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_dim.visible = false
|
||||
_root.add_child(_dim)
|
||||
set_process_input(true)
|
||||
set_process_unhandled_input(true)
|
||||
|
||||
# --- open / close --------------------------------------------------------
|
||||
@@ -85,6 +86,32 @@ func close_top() -> bool:
|
||||
func top() -> Control:
|
||||
return _stack[-1] if not _stack.is_empty() else null
|
||||
|
||||
# wndMgr 的输入边界:打开的窗口先拿到键盘 / 鼠标语义,世界控制不能从窗口
|
||||
# 下方穿透。非模态窗口保留原版常用的开关键,便于 I/K/V/N 等键关闭当前窗;
|
||||
# 模态确认框只允许 ESC 回到窗口栈。
|
||||
func blocks_game_input(event: InputEvent) -> bool:
|
||||
if _stack.is_empty():
|
||||
return false
|
||||
var top_window: Control = _stack[-1]
|
||||
if top_window == null or not is_instance_valid(top_window) or not top_window.visible:
|
||||
return false
|
||||
if bool(top_window.get_meta("modal", false)):
|
||||
return not (event is InputEventKey and event.keycode == KEY_ESCAPE)
|
||||
if event is InputEventMouse:
|
||||
return top_window.get_global_rect().has_point(event.position)
|
||||
if event is InputEventKey:
|
||||
if event.keycode == KEY_ESCAPE:
|
||||
return false
|
||||
return event.keycode not in [KEY_I, KEY_K, KEY_V, KEY_N, KEY_B, KEY_L, KEY_M, KEY_O]
|
||||
return false
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
# Keyboard events do not automatically enter Control.gui_input. Consume
|
||||
# blocked keys here, before PlayerController/GameCamera receive them through
|
||||
# _unhandled_input; mouse events remain available to child controls.
|
||||
if event is InputEventKey and blocks_game_input(event):
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _restack() -> void:
|
||||
var any_modal := false
|
||||
for i in _stack.size():
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
extends Node
|
||||
|
||||
const WEAR_NAMES := ["身体", "头部", "鞋子", "护腕", "武器", "项链", "耳环", "盾牌", "饰品 1", "饰品 2", "箭矢"]
|
||||
const ItemTooltip = preload("res://ui/item_tooltip.gd")
|
||||
|
||||
var client: Node
|
||||
var ui: CanvasLayer
|
||||
var proto: Node
|
||||
var item_list: RefCounted
|
||||
var _tooltip_builder: RefCounted
|
||||
var _window: Control
|
||||
|
||||
func setup(m2client: Node, manager: CanvasLayer, proto_node: Node = null, il: RefCounted = null) -> void:
|
||||
@@ -15,6 +17,8 @@ func setup(m2client: Node, manager: CanvasLayer, proto_node: Node = null, il: Re
|
||||
ui = manager
|
||||
proto = proto_node
|
||||
item_list = il
|
||||
_tooltip_builder = ItemTooltip.new()
|
||||
_tooltip_builder.setup(AssetRoot.path() if ClassDB.class_exists("AssetRoot") else "", "en", proto)
|
||||
if client.has_signal("view_equipment"):
|
||||
client.view_equipment.connect(show_for)
|
||||
|
||||
@@ -94,6 +98,9 @@ func _item_name(vnum: int, count: int) -> String:
|
||||
return "%s x%d" % [name, count] if count > 1 else name
|
||||
|
||||
func _item_tooltip(d: Dictionary) -> String:
|
||||
var vnum := int(d.get("vnum", 0))
|
||||
if _tooltip_builder and proto and proto.has_method("item"):
|
||||
return _tooltip_builder.format(vnum, int(d.get("count", 1)), proto.item(vnum), d)
|
||||
var attrs: Array = d.get("attrs", [])
|
||||
if attrs.is_empty():
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user