完善客户端功能并同步差距文档

This commit is contained in:
shenlei
2026-09-03 18:40:01 +09:00
parent f93a172296
commit 13ccb8d02d
135 changed files with 21881 additions and 1259 deletions
+12 -1
View File
@@ -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
+27 -8
View File
@@ -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
View File
@@ -9,6 +9,8 @@
# 消费 M2Clientchat / 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)
# --- 追加行 --------------------------------------------------------------
+45
View File
@@ -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
View File
@@ -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]
# (对齐客户端 __ArmorVnumToShapeSHAPE_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(对齐 __ArmorVnumToShapeproto 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 seam40250 实体表暂无变身字段(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
View File
@@ -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 = "金币数量必须在 12000000000 之间"
if g <= 0 or g > 9999999:
_status.text = "金币数量必须在 19999999 之间"
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 = "取消"
+4
View File
@@ -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)
+10 -1
View File
@@ -60,6 +60,10 @@ const LABELS := {
const CFG_PATH := "user://system_option.cfg"
# uigameoption RefreshAlwaysShowName / 名字色 / 伤害数字等:radio 选中后广播给渲染层。
# key ∈ DISPLAY_RADIOS 的 cfg keyname_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):
+18
View File
@@ -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
View File
@@ -10,39 +10,63 @@
# 拖到别的格=move_item。inventory_changed 信号来时刷新。
#
# 格子编号约定(uiscript):0..89 背包,90+ 装备(wear = index-90)。
# 新装备 / 腰带没有可靠的旧 uiscript 布局时,在背包右侧生成扩展面板发包
# 使用原客户端的 INVENTORY 全局 cell90+wear / 152+belt cell
# 新装备 / 腰带没有可靠的旧 uiscript 布局时,在背包右侧生成扩展面板发包坐标见
# `_to_wire()`:旧 11 格 wearBODY..SHIELD)走 EQUIPMENT 窗(window=2, wear cell
# costume/ring/beltwear >= 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.gdneed = 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..SHIELDwear 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 窗的全局 cell109..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]
+368
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+157
View File
@@ -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()
+39 -1
View File
@@ -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)
+83 -2
View File
@@ -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 里是 textLabel)—— 盖一个 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
View File
@@ -17,19 +17,37 @@
# quest_confirm_askGC_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 —— EventTypeMapPythonEventManager.cpp:1148 起 42 条)
# · _get_script_event_index —— GetScriptEventIndex:1093,前导 '/' = EVENT_POSITION_END
# · EventSet —— SEventSetPythonEventManager.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 里的裸文本被折成合成 LETTERis_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,
}
# EventTypeMapPythonEventManager.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
# SEventSetPythonEventManager.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: # __InitEventSetPythonEventManager.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
# __InsertLineinit 时 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()
# GetScriptEventIndexPythonEventManager.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
# ProcessEventSetPythonEventManager.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 # 等 EndEventProcessseam:无逐帧循环,不真正阻塞)。
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 + timeoutseam
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)
# MakeQuestionPythonEventManager.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
# GetCameraSettingFromArgListPythonEventManager.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
View File
@@ -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.__ArrangeQuestButtoninterfacemodule.py:1357-13861: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
View File
@@ -1,29 +1,36 @@
# Quickbar (P6) —— 快捷栏:36 槽(4 页 × 9 格),数字键 1-9 施放技能 / 用物品
# Quickbar (P6) —— 服务端 36 槽;参考客户端本地显示 4 页 × 832 格)。
#
# 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..3F1..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 个快捷栏槽位。
# 服务器 type1 道具 / 2 技能 / 3 命令 / 4 表情;ref = 道具格 / 技能 id
# 参考 ESlotType1 道具 / 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()
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 是 `|` 分隔的
# 记号串,原客户端 RegisterSkillDescPythonSkill.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::EWeaponSubTypesGameLib/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_SkillNeedWeaponIndexMapDOUBLE_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::ESkillTypePythonSkill.h+ m_SkillTypeIndexMapPythonSkill.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)
# RegisterSkillDescPythonSkill.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)))
# __GetSkillTargetRangePythonPlayerSkill.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.FlyShapeSINGLE/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::ESkillType3=SKILL_TYPE_GUILD4=SKILL_TYPE_HORSE1=主动,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))
# skillGetSkillLevelLimitPythonSkill.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))
# skillCanLevelUpSkillPythonSkill.cpp:1833)。本 build 里 isRequirement /
# strRequireSkillName / byRequireSkillLevel / RequireStatDataVector 从不被填充
# (构造默认 isRequirement=FALSE、空 vectorPythonSkill.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
# (参考 GetNeedSPPythonSkill.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_GetSkillPowerLocale.cpp:126INTERNATIONAL 表)——
# 原客户端 fcurEfficientPercentage = GetSkillPower(level) / 100,公式里的
# SkillPoint / k 变量取这个值(ProcessFormulaPythonSkill.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)
# __CheckShortLifePythonPlayerSkill.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 修改 3SkillPoint 统一走 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))
+61
View File
@@ -8,11 +8,20 @@
#
# 每行:名字 + Lv X(/M/G/P) + [](被动 / CANNOT_LEVEL_UP 无加点)+ [被动]/[切换] 标签。
# 点名字 -> drag_skill_idquickbar 落点读)。`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))
# RefreshSkillPlusButtonListuicharacter.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)
# __CanUseSkillNowuicharacter.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
# skillPageStatDictuicharacter.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)
+27
View File
@@ -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():
+7
View File
@@ -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 ""