Implement 40250 classic client port

This commit is contained in:
shenlei
2026-09-01 18:49:28 +09:00
parent 2277b1dd60
commit 8f61990a83
481 changed files with 169826 additions and 293 deletions
+112 -4
View File
@@ -19,6 +19,7 @@ const PlayerView = preload("res://ui/player_view.gd")
signal select_requested(index: int)
signal delete_requested(index: int, private_code: String)
signal create_requested(spec: Dictionary) # {slot, name, job, shape, con, int, str, dex}
signal change_name_requested(index: int, name: String)
signal back_requested()
const CLASS_CN := ["猛将", "刺客", "术士", "巫女"]
@@ -55,6 +56,8 @@ var _class_lbl: Label
var _status: Label
var _slot_lbl: Label
var _spin := 0.0
var _last_empire := -1
var _rename_pending := false
func setup(client: Node, assets_root: String, chars: Array) -> void:
_client = client
@@ -66,6 +69,7 @@ func setup(client: Node, assets_root: String, chars: Array) -> void:
_build_class_name()
_build_panel()
_build_status()
_last_empire = _empire_id()
set_process(true)
_select(_first_occupied())
@@ -107,9 +111,33 @@ func on_char_created(slot: int) -> void:
_set_status("角色已创建(槽位 %d" % slot)
func on_char_create_failed(reason_type: int) -> void:
if _rename_pending:
_rename_pending = false
const RENAME_R := {0: "名称不合法", 1: "该名称已被使用", 100: "角色槽位无效"}
_set_status("改名失败:%s" % RENAME_R.get(reason_type, "错误码 %d" % reason_type))
return
const R := {1: "该名称已被使用", 2: "名称不合法", 3: "该槽位已有角色", 4: "权限不足"}
_set_status("创建失败:%s" % R.get(reason_type, "错误码 %d" % reason_type))
func on_char_name_send_failed() -> void:
_rename_pending = false
_set_status("改名请求发送失败")
func on_char_name_changed(pid: int, name: String) -> void:
var matched := false
for c in _chars:
if int(c.get("id", 0)) == pid:
matched = true
c["name"] = name
c["change_name"] = false
if not matched and _rename_pending:
return
_rename_pending = false
_close_dialog()
_refresh_class_name()
_refresh_panel()
_set_status("角色已改名:%s" % name)
func on_char_deleted(slot: int) -> void:
_close_dialog()
_set_status("角色已删除(槽位 %d" % slot)
@@ -342,6 +370,32 @@ func _refresh_panel() -> void:
_panel_box.add_child(_sep())
# A fresh account has no empire yet. 40250 keeps the connection in the
# select phase and expects CG_EMPIRE before character creation/selection;
# expose that real protocol step instead of leaving the page apparently
# idle with a permanently disabled start button.
var empire_required := _empire_id() == 0
if empire_required:
_panel_box.add_child(_plain("请选择阵营", 14, Color(0.95, 0.85, 0.55)))
var erow := HBoxContainer.new()
erow.add_theme_constant_override("separation", 6)
_panel_box.add_child(erow)
for eid in [1, 2, 3]:
var empire_id: int = eid
var eb := Button.new()
eb.text = _empire_name_for(empire_id)
eb.custom_minimum_size = Vector2(82, 30)
eb.pressed.connect(func():
if _client == null or not _client.has_method("select_empire"):
_set_status("当前网络后端不支持选择阵营")
return
if bool(_client.call("select_empire", empire_id)):
_set_status("已发送阵营选择:%s" % _empire_name_for(empire_id))
else:
_set_status("阵营选择发送失败")
)
erow.add_child(eb)
# 槽位切换
if _chars.size() > 1:
var nav := HBoxContainer.new()
@@ -366,19 +420,19 @@ func _refresh_panel() -> void:
var start := Button.new()
start.text = "开始"
start.custom_minimum_size = Vector2(122, 34)
start.disabled = not has_char
start.disabled = not has_char or empire_required
start.pressed.connect(_do_start)
brow.add_child(start)
var create := Button.new()
create.text = "创建"
create.custom_minimum_size = Vector2(122, 34)
create.disabled = has_char
create.disabled = has_char or empire_required
create.pressed.connect(_open_create_dialog)
brow.add_child(create)
var del := Button.new()
del.text = "删除"
del.custom_minimum_size = Vector2(122, 34)
del.disabled = not has_char
del.disabled = not has_char or empire_required
del.pressed.connect(_open_delete_dialog)
brow.add_child(del)
var quit := Button.new()
@@ -432,6 +486,11 @@ func _do_start() -> void:
if d.is_empty() or String(d.get("name", "")) == "":
_set_status("空槽位不能进入")
return
# 40250's original select screen forces a character marked with
# change_name to complete the rename flow before entering the world.
if bool(d.get("change_name", false)):
_open_change_name_dialog()
return
select_requested.emit(_slot_index())
_set_status("进入游戏:%s" % d.get("name"))
@@ -546,6 +605,49 @@ func _open_create_dialog() -> void:
cancel.pressed.connect(_close_dialog)
brow.add_child(cancel)
func _open_change_name_dialog() -> void:
var d := _cur()
if String(d.get("name", "")) == "":
_set_status("空槽位不能改名")
return
var slot := _slot_index()
var box := _modal_root("修改角色名称", 400, 250)
box.add_child(_plain("该角色需要先修改名称才能进入游戏。", 13,
Color(0.95, 0.75, 0.7)))
var nrow := HBoxContainer.new()
nrow.add_theme_constant_override("separation", 8)
box.add_child(nrow)
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.custom_minimum_size = Vector2(220, 0)
name_edit.placeholder_text = "角色名(最多 12"
nrow.add_child(name_edit)
box.add_child(_sep())
var brow := HBoxContainer.new()
brow.alignment = BoxContainer.ALIGNMENT_CENTER
brow.add_theme_constant_override("separation", 16)
box.add_child(brow)
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("名称太短")
return
_rename_pending = true
_close_dialog()
_set_status("改名中…")
change_name_requested.emit(slot, nm))
brow.add_child(ok)
var cancel := Button.new()
cancel.text = "取消"
cancel.custom_minimum_size = Vector2(120, 34)
cancel.pressed.connect(_close_dialog)
brow.add_child(cancel)
func _open_delete_dialog() -> void:
var d := _cur()
if String(d.get("name", "")) == "":
@@ -587,7 +689,9 @@ func _empire_id() -> int:
return 0
func _empire_name() -> String:
var e := _empire_id()
return _empire_name_for(_empire_id())
func _empire_name_for(e: int) -> String:
# 优先 locale_game.txt 的 EMPIRE_A/B/C
var key: String = EMPIRE_KEY.get(e, "")
if key != "":
@@ -625,6 +729,10 @@ func _empire_flag_tex() -> Texture2D:
func _process(dt: float) -> void:
_spin += dt
var empire := _empire_id()
if empire != _last_empire:
_last_empire = empire
_refresh_panel()
if _pivot:
# 正面朝相机 + 轻微来回摆
_pivot.rotation.y = PI + sin(_spin * 0.3) * 0.35
+158 -17
View File
@@ -9,7 +9,9 @@
# cube_changed → 刷新配方 / 材料 / 金币
# cube_result(vnum,count,ok) → 结果提示
# cube_closed → 隐藏
# 选中一个配方 → [制作] → M2Client.cube_make(index)。
# 选中一个配方、把背包物品放入材料槽 → [制作] → M2Client.cube_make(index)。
# 40250 服务端按材料槽匹配配方,`result_index` 只保留给 m2dev 后端;classic
# 会安全地忽略它并执行一次 `/cube make`。
extends Node
var client: Node
@@ -21,6 +23,9 @@ var _list: VBoxContainer
var _mat: VBoxContainer
var _make_btn: Button
var _sel := -1
var _cube_slots: Dictionary = {} # cube index:int -> inventory cell:int
var _craft_in_flight := false
var _cube_npc_vnum := 0
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
@@ -34,12 +39,25 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client.cube_changed.connect(refresh)
if client.has_signal("cube_result"):
client.cube_result.connect(_on_result)
if client.has_signal("inventory_changed"):
client.inventory_changed.connect(func(_window, _cell): refresh())
func is_open() -> bool:
return _root != null and _root.visible
func _on_open(npc_vnum: int) -> void:
if npc_vnum <= 0:
return
var current: Dictionary = client.get_cube() if client.has_method("get_cube") else {}
if current.has("open") and not bool(current.get("open", false)):
return
var current_npc := int(current.get("npc_vnum", 0))
if current_npc > 0 and current_npc != npc_vnum:
return
_sel = -1
_cube_slots.clear()
_craft_in_flight = false
_cube_npc_vnum = npc_vnum
_root.visible = true
_title.text = "제작 (Cube) · NPC #%d" % npc_vnum
_status.text = ""
@@ -48,10 +66,15 @@ func _on_open(npc_vnum: int) -> void:
refresh()
func _on_result(vnum: int, count: int, ok: bool) -> void:
_craft_in_flight = false
if ok:
_status.text = "제작 성공: %s ×%d" % [_name_of(vnum), count]
# 40250 在成功后消耗材料;等待 inventory/cube_changed 的最终状态前,
# 先清掉本地映射,避免旧背包格继续显示为可用材料。
_cube_slots.clear()
else:
_status.text = "제작 실패"
refresh()
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
@@ -65,6 +88,9 @@ func refresh() -> void:
if client == null or _root == null or not _root.visible:
return
var cube: Dictionary = client.get_cube()
if not cube.get("open", true):
_root.visible = false
return
var recipes: Array = cube.get("recipes", [])
var results: Array = cube.get("results", [])
for c in _list.get_children():
@@ -105,21 +131,123 @@ func _refresh_materials() -> void:
c.queue_free()
var cube: Dictionary = client.get_cube()
var recipes: Array = cube.get("recipes", [])
_make_btn.disabled = _sel < 0
var recipe: Dictionary = _selected_recipe()
_make_btn.disabled = _sel < 0 or recipe.is_empty() or _craft_in_flight \
or not _recipe_can_craft(recipe)
if _sel < 0 or _sel >= recipes.size():
_mat.add_child(_lbl("재료 정보를 불러오는 중…", 12))
return
var r: Dictionary = recipes[_sel]
_mat.add_child(_lbl("필요 골드: %d" % int(r.get("gold", 0)), 12))
var groups: Array = r.get("material_groups", [])
if groups.is_empty():
_mat.add_child(_lbl("(재료 없음)", 12))
for g in groups:
var parts: Array = []
for s in (g as Array):
parts.append("%s ×%d" % [_name_of(int((s as Dictionary).get("vnum", 0))),
int((s as Dictionary).get("count", 1))])
_mat.add_child(_lbl("" + " / ".join(parts), 12))
else:
var r: Dictionary = recipe
_mat.add_child(_lbl("필요 골드: %d" % int(r.get("gold", 0)), 12))
var groups: Array = r.get("material_groups", [])
if groups.is_empty():
_mat.add_child(_lbl("(재료 없음)", 12))
for g in groups:
var parts: Array = []
for s in (g as Array):
parts.append("%s ×%d" % [_name_of(int((s as Dictionary).get("vnum", 0))),
int((s as Dictionary).get("count", 1))])
_mat.add_child(_lbl("" + " / ".join(parts), 12))
if not _recipe_can_craft(r):
_mat.add_child(_lbl("(材料不足或未放入材料槽)", 11))
elif client.has_method("get_points") and int(r.get("gold", 0)) > int(client.get_points().get("gold", 0)):
_make_btn.disabled = true
_mat.add_child(_lbl("(金币不足)", 11))
_mat.add_child(HSeparator.new())
_mat.add_child(_lbl("재료 슬롯 (클릭하면 추가/삭제)", 12))
for slot in _cube_slots.keys():
var cell := int(_cube_slots[slot])
var row := HBoxContainer.new()
var label := _lbl("cube[%d] ← bag[%d]" % [int(slot), cell], 11)
row.add_child(label)
var remove := Button.new()
remove.text = "삭제"
remove.pressed.connect(func() -> void:
if client.has_method("cube_delete_item") and client.cube_delete_item(int(slot)):
_cube_slots.erase(slot)
refresh())
row.add_child(remove)
_mat.add_child(row)
_mat.add_child(_lbl("背包物品:", 12))
var used_cells := {}
for cell in _cube_slots.values():
used_cells[int(cell)] = true
var inventory: Array = client.get_inventory() if client.has_method("get_inventory") else []
var added := 0
for item in inventory:
var inv_cell := int(item.get("cell", -1))
var item_vnum := int(item.get("vnum", 0))
if used_cells.has(inv_cell):
continue
if not _recipe_accepts_vnum(recipe, item_vnum):
continue
var free_slot := _first_free_cube_slot()
if free_slot < 0:
break
var add := Button.new()
add.text = "+ bag[%d] %s ×%d" % [inv_cell, _name_of(item_vnum),
int(item.get("count", 1))]
add.alignment = HORIZONTAL_ALIGNMENT_LEFT
add.pressed.connect(func() -> void:
var target := _first_free_cube_slot()
if target >= 0 and client.has_method("cube_add_item") and client.cube_add_item(target, inv_cell):
_cube_slots[target] = inv_cell
refresh())
_mat.add_child(add)
added += 1
if added == 0:
_mat.add_child(_lbl("(可用背包物品 없음)", 11))
func _selected_recipe() -> Dictionary:
if client == null or not client.has_method("get_cube"):
return {}
var recipes: Array = client.get_cube().get("recipes", [])
if _sel < 0 or _sel >= recipes.size():
return {}
return recipes[_sel] as Dictionary
func _recipe_accepts_vnum(recipe: Dictionary, vnum: int) -> bool:
if vnum <= 0:
return false
for group in recipe.get("material_groups", []):
for option in group as Array:
if int((option as Dictionary).get("vnum", 0)) == vnum:
return true
return false
func _recipe_can_craft(recipe: Dictionary) -> bool:
if recipe.is_empty():
return false
var provided := {}
if client.has_method("get_inventory"):
var inventory: Array = client.get_inventory()
for cell in _cube_slots.values():
for item in inventory:
if int(item.get("cell", -1)) == int(cell):
var vnum := int(item.get("vnum", 0))
provided[vnum] = int(provided.get(vnum, 0)) + maxi(1, int(item.get("count", 1)))
break
for group in recipe.get("material_groups", []):
var matched := false
for option in group as Array:
var material: Dictionary = option as Dictionary
var vnum := int(material.get("vnum", 0))
var need := maxi(1, int(material.get("count", 1)))
if int(provided.get(vnum, 0)) >= need:
provided[vnum] = int(provided[vnum]) - need
matched = true
break
if not matched:
return false
return true
func _first_free_cube_slot() -> int:
for i in 24:
if not _cube_slots.has(i):
return i
return -1
func _lbl(t: String, sz: int) -> Label:
var l := Label.new()
@@ -165,10 +293,23 @@ func _build(parent: Node) -> void:
_make_btn.text = "제작"
_make_btn.disabled = true
_make_btn.pressed.connect(func() -> void:
if _sel >= 0 and client.has_method("cube_make"):
client.cube_make(_sel))
var recipe := _selected_recipe()
if _sel < 0 or recipe.is_empty():
_status.text = "请先选择配方"
return
if not _recipe_can_craft(recipe):
_status.text = "材料不足或未放入材料槽"
refresh()
return
if client.has_method("cube_make") and client.cube_make(_sel):
_craft_in_flight = true
_status.text = "制作中…"
_make_btn.disabled = true)
brow.add_child(_make_btn)
var close := Button.new()
close.text = "닫기"
close.pressed.connect(func() -> void: _root.visible = false)
close.pressed.connect(func() -> void:
if client.has_method("cube_close"):
client.cube_close()
_root.visible = false)
brow.add_child(close)
+47 -6
View File
@@ -16,9 +16,11 @@ var _self_box: VBoxContainer
var _peer_box: VBoxContainer
var _self_gold: Label
var _peer_gold: Label
var _status: Label
var _accept_btn: Button
var _gold_input: LineEdit
var _next_display := 0
var _offered_cells := {}
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
@@ -31,9 +33,29 @@ func is_open() -> bool:
return _root != null and _root.visible
func offer(inv_window: int, inv_cell: int) -> void:
if is_open():
client.exchange_add_item(inv_window, inv_cell, _next_display)
if not is_open() or inv_window < 0 or inv_cell < 0:
return
var key := "%d:%d" % [inv_window, inv_cell]
if _offered_cells.has(key):
_status.text = "该物品已经放入交易"
return
var x: Dictionary = client.get_exchange()
var used := {}
for item in x.get("self_items", []):
used[int(item.get("slot", -1))] = true
for i in 12:
if not used.has(i):
_next_display = i
break
if used.size() >= 12:
_status.text = "交易物品栏已满"
return
if client.exchange_add_item(inv_window, inv_cell, _next_display):
_offered_cells[key] = true
_next_display = (_next_display + 1) % 12
_status.text = ""
else:
_status.text = "交易请求发送失败"
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
@@ -50,6 +72,9 @@ func refresh() -> void:
if not x.get("active", false):
_root.visible = false
_next_display = 0
_offered_cells.clear()
_gold_input.text = ""
_status.text = ""
return
_root.visible = true
_fill(_self_box, x.get("self_items", []))
@@ -61,6 +86,7 @@ func refresh() -> void:
_accept_btn.text = "已接受 ✓" if me else "接受"
_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 _fill(box: VBoxContainer, rows: Array) -> void:
for c in box.get_children():
@@ -72,9 +98,19 @@ func _fill(box: VBoxContainer, rows: Array) -> void:
box.add_child(l)
func _on_put_gold() -> void:
var g := int(_gold_input.text)
if g > 0:
client.exchange_add_gold(g)
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 之间"
return
if client.exchange_add_gold(g):
_gold_input.text = ""
_status.text = ""
else:
_status.text = "金币请求发送失败"
func _build(parent: Node) -> void:
_root = Control.new()
@@ -121,6 +157,11 @@ func _build(parent: Node) -> void:
_peer_gold.text = "金币: 0"
_peer_gold.position = Vector2(240, 250)
_root.add_child(_peer_gold)
_status = Label.new()
_status.position = Vector2(20, 276)
_status.add_theme_font_size_override("font_size", 11)
_status.add_theme_color_override("font_color", Color(1, 0.65, 0.55))
_root.add_child(_status)
var pa := Label.new()
pa.name = "PeerAccept"
@@ -130,7 +171,7 @@ func _build(parent: Node) -> void:
_root.add_child(pa)
var bottom := HBoxContainer.new()
bottom.position = Vector2(20, 300)
bottom.position = Vector2(20, 310)
_root.add_child(bottom)
_gold_input = LineEdit.new()
_gold_input.placeholder_text = "金币"
+33 -1
View File
@@ -15,12 +15,16 @@ var client: Node
var _root: Control
var _list: VBoxContainer
var _name_edit: LineEdit
var _invite_dialog: ConfirmationDialog
var _pending_invite := ""
func setup(m2client: Node, parent: Node) -> void:
client = m2client
_build(parent)
if client.has_signal("friends_changed"):
client.friends_changed.connect(refresh)
if client.has_signal("friend_invite_ask"):
client.friend_invite_ask.connect(_on_friend_invite)
func is_open() -> bool:
return _root != null and _root.visible
@@ -55,9 +59,15 @@ func _row(f: Dictionary) -> Control:
var nm := Button.new()
nm.text = String(f.get("name", "?"))
nm.flat = true
nm.custom_minimum_size = Vector2(180, 0)
nm.custom_minimum_size = Vector2(150, 0)
nm.pressed.connect(func() -> void: whisper_to.emit(String(f.get("name", ""))))
row.add_child(nm)
if f.get("mobile", false):
var mobile := Label.new()
mobile.text = "手机"
mobile.modulate = Color(0.45, 0.8, 1.0)
mobile.add_theme_font_size_override("font_size", 10)
row.add_child(mobile)
var del := Button.new()
del.text = "×"
del.pressed.connect(func() -> void: client.remove_friend(String(f.get("name", ""))))
@@ -70,6 +80,19 @@ func _on_add() -> void:
client.add_friend(nm)
_name_edit.clear()
func _on_friend_invite(name: String) -> void:
if client == null or name.strip_edges() == "":
return
_pending_invite = name
_invite_dialog.dialog_text = "接受“%s”的好友请求?" % name
_invite_dialog.popup_centered()
func _answer_invite(accept: bool) -> void:
var name := _pending_invite
_pending_invite = ""
if name != "" and client != null and client.has_method("friend_answer"):
client.friend_answer(name, accept)
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
@@ -105,3 +128,12 @@ func _build(parent: Node) -> void:
add_btn.text = "添加"
add_btn.pressed.connect(_on_add)
bottom.add_child(add_btn)
_invite_dialog = ConfirmationDialog.new()
_invite_dialog.title = "好友请求"
_invite_dialog.ok_button_text = "接受"
_invite_dialog.cancel_button_text = "拒绝"
_invite_dialog.confirmed.connect(func() -> void: _answer_invite(true))
_invite_dialog.canceled.connect(func() -> void: _answer_invite(false))
# Keep the dialog outside the toggleable friend panel: requests must still
# be visible when the friend list window itself is closed.
parent.add_child(_invite_dialog)
+24 -5
View File
@@ -10,7 +10,7 @@
# `/setblockmode <mask ^ bit>`EBlockAction 位:1<<0..1<<5)—— 真聊天命令
# pvp_{peace,revenge,guild,free} (radio) → `/pkmode {0,1,4,2}` —— 真聊天命令
# name_color / target_board / view_chat / always_show_name / show_damage / salestext (radio)
# —— 客户端显示开关,持久化到 user://system_option.cfg [gameopt];渲染侧钩子待补
# —— 客户端显示开关,持久化到 user://system_option.cfg [gameopt];渲染节点按需读取这些设置
extends Node
# Packet.h EBlockAction
@@ -67,9 +67,16 @@ var uiscript_dir := ""
var _win: Dictionary = {}
var _cfg := ConfigFile.new()
var _block_mode := 0 # blockMode(本地跟踪服务回包同步待补
var _block_mode := 0 # blockMode(本地跟踪 + 服务回包同步)
var _display := {} # cfg [gameopt] 快照
static func config_path() -> String:
var override_dir := OS.get_environment("MT_CONFIG_DIR")
if override_dir != "":
return override_dir.path_join("system_option.cfg")
return OS.get_temp_dir().path_join("mtgodot_system_option.cfg") \
if DisplayServer.get_name() == "headless" else CFG_PATH
func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
ui = ui_manager
client = m2client
@@ -79,10 +86,12 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
uiscript_dir = assets_root.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets_root.path_join("uiscript")
_cfg.load(CFG_PATH)
_cfg.load(config_path())
for k in ["name_color", "target_board", "view_chat", "always_show_name", "show_damage", "salestext"]:
_display[k] = int(_cfg.get_value("gameopt", k, 1 if k in ["view_chat", "always_show_name", "show_damage"] else 0))
_block_mode = int(_cfg.get_value("gameopt", "block_mode", 0))
if client and client.has_signal("block_mode_changed"):
client.block_mode_changed.connect(_on_server_block_mode)
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
@@ -166,7 +175,9 @@ func _toggle_block(bit: int) -> void:
if client and client.has_method("say"):
client.say(0, "/setblockmode %d" % _block_mode)
_cfg.set_value("gameopt", "block_mode", _block_mode)
_cfg.save(CFG_PATH)
var err := _cfg.save(config_path())
if err != OK:
push_warning("GameOptionUI: save failed (%s): %s" % [err, config_path()])
_sync_block()
# 开窗时把状态回填到控件
@@ -186,8 +197,16 @@ func _sync_block() -> void:
if b is BaseButton:
b.set_pressed_no_signal((_block_mode & int(BLOCK_BITS[nm])) != 0)
func _on_server_block_mode(mask: int) -> void:
_block_mode = maxi(0, mask)
_cfg.set_value("gameopt", "block_mode", _block_mode)
_cfg.save(config_path())
_sync_block()
func _save() -> void:
for k in _display:
_cfg.set_value("gameopt", k, int(_display[k]))
_cfg.set_value("gameopt", "block_mode", _block_mode)
_cfg.save(CFG_PATH)
var err := _cfg.save(config_path())
if err != OK:
push_warning("GameOptionUI: save failed (%s): %s" % [err, config_path()])
+135 -6
View File
@@ -1,4 +1,4 @@
# GuildUI (P8/M4) —— 公会窗(G 键):成员 / 技能 / 公会战 页。
# GuildUI (P8/M4) —— 公会窗(G 键):成员 / 技能 / 公会战 / 公告 四页。
#
# var gu := preload("res://ui/guild_ui.gd").new()
# add_child(gu)
@@ -9,12 +9,13 @@
# 到时刷会徽图。⬜ 等级页、日志页。
extends Node
const TABS := ["成员", "技能", "公会战"]
const TABS := ["成员", "技能", "公会战", "公告"]
# EGuildWarState (wire.h)
const WAR_STATE_NAMES := ["", "已宣战", "被拒", "收到宣战", "待开始", "取消", "交战中", "结束"]
var client: Node
var skill_table: RefCounted # SkillTable
var skill_table: Object # SkillTable (tests may provide a Node stub)
var _ui_parent: Node
# 返回一张 Image(16×12,或任意会被转换/缩放)用作会徽上传源;null = 不显示上传按钮。
var mark_image_provider: Callable
var _root: Control
@@ -25,14 +26,20 @@ var _tab_btns: Array[Button] = []
var _pages: Array[VBoxContainer] = []
var _war_name_edit: LineEdit
var _upload_status: Label
var _create_dialog: ConfirmationDialog
var _create_name_edit: LineEdit
var _create_status: Label
func setup(m2client: Node, parent: Node, table: RefCounted = null) -> void:
func setup(m2client: Node, parent: Node, table: Object = null) -> void:
client = m2client
skill_table = table
_ui_parent = parent
_build(parent)
for sig in ["guild_changed", "guild_skill_changed", "guild_war_changed"]:
for sig in ["guild_changed", "guild_skill_changed", "guild_war_changed", "guild_comments_changed"]:
if client.has_signal(sig):
client.connect(sig, refresh)
if client.has_signal("guild_make_requested"):
client.guild_make_requested.connect(_on_guild_make_requested)
if client.has_signal("guild_marks_ready"):
client.guild_marks_ready.connect(func(_n): refresh())
if client.has_signal("guild_mark_uploaded"):
@@ -73,6 +80,7 @@ func refresh() -> void:
0: _fill_members()
1: _fill_skills()
2: _fill_war(int(g.get("id", 0)))
3: _fill_comments()
func _fill_members() -> void:
var page := _pages[0]
@@ -109,9 +117,63 @@ func _fill_members() -> void:
String(m.get("name", "?")), int(m.get("level", 0)),
gname if gname != "" else str(gi),
int(m.get("offer", 0)),
"" if m.get("general", false) else ""]
(" 在线" if m.get("online", false) else " 离线") + ("" if m.get("general", false) else "")]
page.add_child(row)
func _fill_comments() -> void:
var page := _pages[3]
for c in page.get_children():
c.queue_free()
if not client.has_method("get_guild_comments"):
var missing := Label.new()
missing.text = "(当前协议没有公告接口)"
page.add_child(missing)
return
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 5)
page.add_child(actions)
var edit := LineEdit.new()
edit.placeholder_text = "公告内容(最多 50 字)"
edit.custom_minimum_size = Vector2(245, 26)
actions.add_child(edit)
var post := Button.new()
post.text = "发布"
post.custom_minimum_size = Vector2(52, 26)
post.pressed.connect(func():
var text := edit.text.strip_edges()
if text != "" and client.has_method("guild_post_comment"):
client.guild_post_comment(text))
actions.add_child(post)
var refresh_btn := Button.new()
refresh_btn.text = "刷新"
refresh_btn.custom_minimum_size = Vector2(52, 26)
refresh_btn.pressed.connect(func():
if client.has_method("guild_refresh_comments"):
client.guild_refresh_comments())
actions.add_child(refresh_btn)
var comments: Array = client.get_guild_comments()
if comments.is_empty():
var none := Label.new()
none.text = "(暂无公告)"
page.add_child(none)
return
for item in comments:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
page.add_child(row)
var label := Label.new()
label.custom_minimum_size = Vector2(286, 0)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.text = "%s%s" % [String(item.get("name", "?")), String(item.get("content", ""))]
row.add_child(label)
var delete := Button.new()
delete.text = ""
delete.custom_minimum_size = Vector2(32, 24)
delete.pressed.connect(func():
if client.has_method("guild_delete_comment"):
client.guild_delete_comment(int(item.get("id", 0))))
row.add_child(delete)
func _guild_skill_rows() -> Array:
# skill_table 的 GUILD 分类给 id(升序);按顺序配 levels[i]。返回 [{id, name}]。
var out := []
@@ -236,6 +298,73 @@ func _on_mark_uploaded(ok: bool) -> void:
if _upload_status:
_upload_status.text = "✔ 已上传(等服务器刷新)" if ok else "✘ 上传失败"
# 40250 原版在收到 GC_REQUEST_MAKE_GUILD 后弹 InputDialog,再由玩家回送
# CG_ANSWER_MAKE_GUILD。这里保留同样的“服务端请求 -> 输入 -> 应答”边界,
# 让名称校验发生在发包前,同时把最终权限 / 敏感词判断留给服务端。
func _on_guild_make_requested() -> void:
if is_instance_valid(_create_dialog):
_create_dialog.popup_centered()
_create_name_edit.grab_focus()
return
if _ui_parent == null or client == null:
return
_create_dialog = ConfirmationDialog.new()
_create_dialog.name = "GuildCreateDialog"
_create_dialog.title = "创建公会"
_create_dialog.dialog_text = "请输入公会名称(最多 12 字)"
_create_dialog.ok_button_text = "创建"
_create_dialog.cancel_button_text = "取消"
_create_name_edit = LineEdit.new()
_create_name_edit.name = "GuildName"
_create_name_edit.max_length = 12
_create_name_edit.placeholder_text = "公会名称"
_create_name_edit.custom_minimum_size = Vector2(250, 28)
_create_dialog.add_child(_create_name_edit)
_create_status = Label.new()
_create_status.name = "Status"
_create_status.add_theme_font_size_override("font_size", 11)
_create_status.modulate = Color(1.0, 0.7, 0.6)
_create_dialog.add_child(_create_status)
_ui_parent.add_child(_create_dialog)
_create_dialog.confirmed.connect(_on_guild_create_confirmed)
_create_dialog.canceled.connect(_close_guild_create_dialog)
_create_dialog.popup_centered()
_create_name_edit.grab_focus()
func _on_guild_create_confirmed() -> void:
if not is_instance_valid(_create_dialog) or _create_name_edit == null:
return
var name := _create_name_edit.text.strip_edges()
var error := ""
if name.is_empty():
error = "公会名称不能为空"
elif name.length() > 12:
error = "公会名称最多 12 字"
else:
for ch in name:
if ch.unicode_at(0) < 32:
error = "公会名称包含非法字符"
break
if error != "":
_create_status.text = error
_create_dialog.call_deferred("popup_centered")
_create_name_edit.call_deferred("grab_focus")
return
if not client.has_method("guild_answer_make") or not bool(client.call("guild_answer_make", name)):
_create_status.text = "创建请求发送失败"
_create_dialog.call_deferred("popup_centered")
_create_name_edit.call_deferred("grab_focus")
return
_close_guild_create_dialog()
func _close_guild_create_dialog() -> void:
var dialog := _create_dialog
_create_dialog = null
_create_name_edit = null
_create_status = null
if is_instance_valid(dialog):
dialog.queue_free()
# 公会 id 变了就重新取会徽图(16x12,放大 3x 显示)。
func _refresh_mark(guild_id: int) -> void:
if _mark == null:
+42 -2
View File
@@ -12,6 +12,8 @@ var proto: Node
var _root: Control
var _list: VBoxContainer
var _title: Label
var _status: Label
var _password_dialog: ConfirmationDialog
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
@@ -21,6 +23,8 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client.mall_opened.connect(func(_s): refresh())
if client.has_signal("mall_changed"):
client.mall_changed.connect(refresh)
if client.has_signal("mall_password_required"):
client.mall_password_required.connect(_ask_password)
func is_open() -> bool:
return _root != null and _root.visible
@@ -40,7 +44,7 @@ func _first_free_inv() -> int:
for i in range(90):
if not used.has(i):
return i
return 0
return -1
func refresh() -> void:
if client == null or _root == null:
@@ -48,6 +52,7 @@ func refresh() -> void:
_root.visible = client.is_mall_open()
if not _root.visible:
return
_status.text = ""
_title.text = "道具商城仓库(%d 格)" % client.get_mall_size()
for c in _list.get_children():
c.queue_free()
@@ -60,6 +65,31 @@ func refresh() -> void:
for it in items:
_list.add_child(_row(it))
func _ask_password() -> void:
if is_instance_valid(_password_dialog):
_password_dialog.queue_free()
_password_dialog = ConfirmationDialog.new()
_password_dialog.title = "商城密码"
_password_dialog.dialog_text = "请输入 16 位密码"
_password_dialog.ok_button_text = "确认"
_password_dialog.cancel_button_text = "取消"
var edit := LineEdit.new()
edit.secret = true
edit.max_length = 6
edit.placeholder_text = "密码"
edit.custom_minimum_size = Vector2(220, 28)
_password_dialog.add_child(edit)
_password_dialog.confirmed.connect(func() -> void:
if not client.mall_password(edit.text) and is_instance_valid(_status):
_status.text = "密码格式无效"
_password_dialog.queue_free()
_password_dialog = null)
_password_dialog.canceled.connect(func() -> void:
_password_dialog.queue_free()
_password_dialog = null)
_root.get_parent().add_child(_password_dialog)
_password_dialog.popup_centered()
func _row(it: Dictionary) -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(300, 0)
@@ -71,7 +101,13 @@ func _row(it: Dictionary) -> Control:
var out := Button.new()
out.text = "取出"
var cell := int(it.get("cell", 0))
out.pressed.connect(func() -> void: client.mall_checkout(cell, 1, _first_free_inv()))
out.pressed.connect(func() -> void:
var target := _first_free_inv()
if target < 0:
_status.text = "背包已满"
return
if not client.mall_checkout(cell, 1, target):
_status.text = "取出请求发送失败")
row.add_child(out)
return row
@@ -92,6 +128,10 @@ func _build(parent: Node) -> void:
_title.text = "道具商城仓库"
_title.add_theme_font_size_override("font_size", 16)
box.add_child(_title)
_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))
box.add_child(_status)
var sc := ScrollContainer.new()
sc.custom_minimum_size = Vector2(308, 280)
box.add_child(sc)
+12 -3
View File
@@ -119,9 +119,18 @@ func set_anim_state(s: String) -> void:
anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", msa)
# 供 net_world._on_info 用(如果它调 set_display_name
func set_display_name(_n: String) -> void:
pass
# 供 net_world._on_info 用:真模型没有统一的名字节点时补一个。
func set_display_name(n: String) -> void:
var tag := get_node_or_null("Label3D") as Label3D
if tag == null:
tag = Label3D.new()
tag.name = "Label3D"
tag.position.y = 2.1
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.006
add_child(tag)
tag.text = n
# --- 内部 -------------------------------------------------------------------
+51 -4
View File
@@ -18,14 +18,14 @@ const ROLE_ATTACKER := 2
const ROLE_TANKER := 3
const ROLE_BUFFER := 4
const ROLE_SKILL_MASTER := 5
const ROLE_BERSERKER := 6
const ROLE_HASTE := 6
const ROLE_DEFENDER := 7
const ROLE_LABEL := {
0: "", 1: "", 2: "", 3: "", 4: "", 5: "", 6: "", 7: "",
0: "", 1: "", 2: "", 3: "", 4: "", 5: "", 6: "", 7: "",
}
const ROLE_MENU := [
[ROLE_NORMAL, "普通"], [ROLE_ATTACKER, "攻击"], [ROLE_TANKER, "坦克"],
[ROLE_BERSERKER, "狂战"], [ROLE_BUFFER, "辅助"], [ROLE_SKILL_MASTER, "宗师"],
[ROLE_HASTE, "迅捷"], [ROLE_BUFFER, "辅助"], [ROLE_SKILL_MASTER, "宗师"],
[ROLE_DEFENDER, "防御"],
]
# Packet.h EPartyExpDistributionType
@@ -34,13 +34,15 @@ const EXP_PARITY := 1 # 均分
# uiparty.PartyMemberInfoBoard.PARTY_SKILL_*
const PARTY_SKILL_HEAL := 1
# affects[7] 槽位含义(暂定,按 partymemberinfoboard.py 图标顺序)
const AFFECT_LABEL := ["经验", "攻击", "防御", "辅助", "宗师", "时间", "回复"]
const AFFECT_LABEL := ["经验", "攻击", "坦克", "辅助", "宗师", "迅捷", "防御"]
var client: Node
var dialogs: Node
var _root: Control
var _list: VBoxContainer
var _dist_btn: Button
var _heal_btn: Button
var _notice: Label
var _role_popup: Control = null
func setup(m2client: Node, parent: Node, dlg: Node = null) -> void:
@@ -51,6 +53,8 @@ func setup(m2client: Node, parent: Node, dlg: Node = null) -> void:
client.party_changed.connect(refresh)
if client.has_signal("party_invite_ask"):
client.party_invite_ask.connect(_on_invite)
if client.has_signal("party_request_denied"):
client.party_request_denied.connect(_on_request_denied)
if client.has_signal("vitals_changed"):
client.vitals_changed.connect(func(_v): if is_open(): refresh())
refresh()
@@ -91,6 +95,37 @@ func _refresh_dist_btn() -> void:
var mode := int(client.get_party_distribute_mode()) if client.has_method("get_party_distribute_mode") else 0
_dist_btn.text = "EXP:均分" if mode == EXP_PARITY else "EXP:不均分"
_dist_btn.disabled = not _local_is_leader()
if is_instance_valid(_heal_btn):
var leadership := _leadership_level()
_heal_btn.disabled = not _local_is_leader() or (leadership >= 0 and leadership < 18)
_heal_btn.tooltip_text = "队长 Leadership 达到 18 级后可用" if _heal_btn.disabled and leadership >= 0 and leadership < 18 else "组队治疗"
func _leadership_level() -> int:
# The server's CParty::Update gates party roles/heal using the leader's
# Leadership skill. -1 means that the snapshot has not arrived yet; in
# that case the server remains authoritative and the controls stay usable.
if client == null or not client.has_method("get_skills"):
return -1
var skills: Array = client.get_skills()
for skill in skills:
if int(skill.get("id", -1)) == 121: # SKILL_LEADERSHIP in 40250
return int(skill.get("level", 0))
return -1
func _role_unlock_level(role: int) -> int:
match role:
ROLE_ATTACKER: return 10
ROLE_TANKER, ROLE_HASTE: return 20
ROLE_BUFFER: return 25
ROLE_SKILL_MASTER: return 35
ROLE_DEFENDER: return 40
_: return 0
func _role_allowed(role: int, current_role: int = -1) -> bool:
if role == ROLE_NORMAL or role == current_role:
return true
var leadership := _leadership_level()
return leadership < 0 or leadership >= _role_unlock_level(role)
# --- 한 명 strip ------------------------------------------------------
@@ -178,6 +213,9 @@ func _open_role_popup(anchor: Control, pid: int, cur_role: int) -> void:
var b := Button.new()
b.text = entry[1] + ("" if r == cur_role else "")
b.custom_minimum_size = Vector2(84, 22)
b.disabled = not _role_allowed(r, cur_role)
if b.disabled:
b.tooltip_text = "Leadership %d 级解锁" % _role_unlock_level(r)
b.pressed.connect(func() -> void:
if r == ROLE_NORMAL:
# OnSelectState(-1): 清掉当前角色
@@ -213,6 +251,10 @@ func _on_invite(leader_pid: int) -> void:
else:
accept.call()
func _on_request_denied() -> void:
if is_instance_valid(_notice):
_notice.text = "组队请求被拒绝"
# --- EXP 분배 / 组队治疗 --------------------------------------------
func _toggle_distribute() -> void:
@@ -255,7 +297,12 @@ func _build(parent: Node) -> void:
heal.text = "组队治疗"
heal.add_theme_font_size_override("font_size", 11)
heal.pressed.connect(_party_heal)
_heal_btn = heal
header.add_child(heal)
_notice = Label.new()
_notice.add_theme_font_size_override("font_size", 10)
_notice.add_theme_color_override("font_color", Color(1, 0.65, 0.55))
header.add_child(_notice)
_list = VBoxContainer.new()
_list.position = Vector2(8, 32)
+84
View File
@@ -17,6 +17,7 @@ const FEMALE_RACES := [1, 3, 4, 6]
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
var motion_dir := ""
var action_dir := ""
var _state := ""
var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"]
@@ -33,6 +34,7 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
if base == "":
return false
motion_dir = base.path_join("general")
action_dir = base.path_join("action")
model = ClassDB.instantiate("Metin2Model")
model.name = "Metin2Model"
@@ -54,6 +56,8 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
anim.set("loop", true)
anim.set("blend_time", 0.15)
add_child(anim)
if anim.has_signal("playback_finished"):
anim.playback_finished.connect(_on_playback_finished)
if pump.is_valid(): pump.call()
set_anim_state("wait") # 重:解 .msa
if pump.is_valid(): pump.call()
@@ -71,6 +75,86 @@ func set_anim_state(s: String) -> void:
anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", 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
# kiss/french-kiss variants, exactly like ActDualEmotion in the native client.
func set_motion_id(motion: int, target_race: int = -1) -> bool:
if anim == null:
return false
var name := ""
if motion == 305:
name = "clap"
elif motion == 306:
name = "cheers_1"
elif motion == 307:
name = "cheers_2"
elif motion >= 308 and motion <= 311:
var job := motion - 308
if motion == 308 and target_race >= 0:
job = target_race & 3
name = "kiss_with_" + CLASS_OF[clampi(job, 0, 3)]
elif motion >= 312 and motion <= 315:
var french_job := motion - 312
if motion == 312 and target_race >= 0:
french_job = target_race & 3
name = "french_kiss_with_" + CLASS_OF[clampi(french_job, 0, 3)]
elif motion >= 316 and motion <= 319:
name = "slap_hit"
elif motion >= 320 and motion <= 323:
name = "slap_hurt"
elif motion == 324:
name = "dig"
elif motion >= 325 and motion <= 340:
name = "dance_%d" % (motion - 324)
elif motion == 341:
name = "congratulation"
elif motion == 342:
name = "forgive"
elif motion == 343:
name = "angry"
elif motion == 344:
name = "attractive"
elif motion == 345:
name = "sad"
elif motion == 346:
name = "shy"
elif motion == 347:
name = "cheerup"
elif motion == 348:
name = "banter"
elif motion == 349:
name = "joy"
elif motion == 5 or motion == 6 or motion == 8 or motion == 9:
set_anim_state("damage")
return true
elif motion == 11 or motion == 12:
set_anim_state("dead")
return true
elif motion >= 13 and motion <= 21:
set_anim_state("attack")
return true
else:
return false
var msa := action_dir.path_join(name + ".msa")
if not FileAccess.file_exists(msa):
msa = motion_dir.path_join(name + ".msa")
if not FileAccess.file_exists(msa):
return false
_state = "__motion"
anim.set("loop", false)
# A repeated command can name the same clip; clear first because the native
# player treats assigning the current anim_path as a no-op.
if String(anim.get("anim_path")) == msa:
anim.set("anim_path", "")
anim.set("anim_path", msa)
return true
func _on_playback_finished() -> void:
if _state == "__motion":
_state = ""
set_anim_state("wait")
func _set(prop: StringName, val: Variant) -> bool:
if String(prop) in _forward and model:
model.set(prop, val)
+58 -6
View File
@@ -13,9 +13,23 @@
# `OnSelectItemSlot` / `DelPrivateShopItemStock`)。OkButton(`BuildPrivateShop`)stock 按
# 格号排序、`display_pos = 格号`、上限 39`PRIVATE_SHOP_ITEM_MAX_NUM`)打包成
# `M2Client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])`
# = `TPacketCGMyShop` + `TShopItemTable`×N)。철수 `close_private_shop()` = SHOP_CG_END。
# = `TPacketCGMyShop` + `TShopItemTable`×N)。40250 收摊用空的 `CG_MYSHOP`
# (服务端 `OpenMyShop` 会先关闭已有的个人摊位);`close_private_shop()` 已在
# M2Client classic 分支按该语义发送。
extends Node
class PrivateShopDragButton extends Button:
var drag_entry: Dictionary = {}
func _get_drag_data(_at_position: Vector2):
if drag_entry.is_empty():
return null
var preview := Label.new()
preview.text = String(text)
preview.add_theme_color_override("font_color", Color(1, 0.9, 0.55))
set_drag_preview(preview)
return {"private_shop_entry": drag_entry.duplicate(true)}
const PRIVATE_SHOP_ITEM_MAX := 39 # PRIVATE_SHOP_ITEM_MAX_NUM (TPacketCGMyShop::bCount 上限)
const SIGN_MAX := 25 # NameLine input_limit
@@ -33,6 +47,7 @@ var _status: Label
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
func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, assets := "") -> void:
client = m2client
@@ -46,6 +61,10 @@ func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, ass
uiscript_dir = assets_root.path_join("uiscript")
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(func(_w, _c): if is_open(): _refresh())
if client and client.has_signal("my_shop_price_list"):
client.my_shop_price_list.connect(_on_server_price)
if client and client.has_signal("private_shop_open_requested"):
client.private_shop_open_requested.connect(open)
# --- open / close --------------------------------------------------
@@ -101,6 +120,10 @@ func _index_cells(root: Node) -> void:
n.gui_input.connect(func(e: InputEvent) -> void:
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
_on_slot_clicked(idx))
# 同时支持从右侧背包候选区拖到空货位;点击选取仍保留给触屏/无鼠标场景。
n.set_drag_forwarding(Callable(self, "_no_drag_data"),
Callable(self, "_can_drop_slot").bind(idx),
Callable(self, "_drop_slot").bind(idx))
# NameLine 在 uiscript 里是 textLabel)—— 盖一个 LineEdit 上去
func _overlay_name_edit(root: Control) -> void:
@@ -194,12 +217,13 @@ func _refresh_inv() -> void:
continue
var vnum := int(it.get("vnum", 0))
var count := int(it.get("count", 1))
var b := Button.new()
var b := PrivateShopDragButton.new()
b.text = "%s ×%d" % [_name_of(vnum), count]
b.alignment = HORIZONTAL_ALIGNMENT_LEFT
b.custom_minimum_size = Vector2(196, 22)
b.toggle_mode = true
var entry := {"cell": cell, "vnum": vnum, "count": count}
b.drag_entry = entry
b.pressed.connect(func() -> void: _pick(entry, b))
if _picked != null and int(_picked["cell"]) == cell:
b.button_pressed = true
@@ -246,6 +270,20 @@ func _pick(entry: Dictionary, _btn: Button) -> void:
_picked = entry
_refresh_grid()
func _no_drag_data(_at_position: Vector2):
return null
func _can_drop_slot(_at_position: Vector2, data, slot: int) -> bool:
return slot >= 0 and slot < 40 and not _stock.has(slot) \
and data is Dictionary and data.has("private_shop_entry")
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
_picked = entry.duplicate(true)
_ask_price(slot)
func _on_slot_clicked(slot: int) -> void:
if _stock.has(slot):
_stock.erase(slot)
@@ -276,7 +314,10 @@ func _ask_price(slot: int) -> void:
spin.min_value = 1
spin.max_value = 2000000000
spin.step = 1
spin.value = int(_stock.get(slot, {}).get("price", 1))
var default_price := int(_server_prices.get(int(_picked["vnum"]), 1))
if _stock.has(slot):
default_price = int(_stock[slot].get("price", default_price))
spin.value = default_price
spin.position = Vector2(12, 40)
spin.size = Vector2(216, 28)
dlg.add_child(spin)
@@ -299,7 +340,7 @@ func _dismiss_price_dialog() -> void:
_price_dialog = null
func _place(slot: int, price: int) -> void:
if price <= 0 or _picked == null:
if slot < 0 or slot >= 40 or price <= 0 or price > 2000000000 or _picked == null:
_dismiss_price_dialog()
return
_stock[slot] = {"cell": int(_picked["cell"]), "vnum": int(_picked["vnum"]),
@@ -308,7 +349,16 @@ func _place(slot: int, price: int) -> void:
_dismiss_price_dialog()
_refresh()
func _on_server_price(vnum: int, price: int) -> void:
if vnum > 0 and price >= 0:
_server_prices[vnum] = price
func _ok() -> void:
var sign := _sign.text.strip_edges()
if sign.is_empty() or sign.length() > SIGN_MAX:
if is_instance_valid(_status):
_status.text = "请输入 1%d 个字符的摊位名称" % SIGN_MAX
return
if _stock.is_empty():
if is_instance_valid(_status):
_status.text = "판매할 아이템을 올리세요"
@@ -323,8 +373,10 @@ func _ok() -> void:
items.append({"vnum": s["vnum"], "count": s["count"], "inv_cell": s["cell"],
"price": s["price"], "display_pos": slot})
if client and client.has_method("open_private_shop"):
client.open_private_shop(_sign.text.strip_edges(), items)
close()
if client.open_private_shop(sign, items):
close()
elif is_instance_valid(_status):
_status.text = "摆摊请求发送失败"
func _close_shop() -> void:
if client and client.has_method("close_private_shop"):
+7
View File
@@ -20,6 +20,8 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
_build(parent)
if client.has_signal("refine_ask"):
client.refine_ask.connect(_on_ask)
if client.has_signal("refine_result"):
client.refine_result.connect(_on_result)
func is_open() -> bool:
return _root != null and _root.visible
@@ -53,6 +55,11 @@ func _do_refine() -> void:
client.refine(int(_cur.get("pos", 0)), int(_cur.get("type", 0)))
_root.visible = false
func _on_result(ok: bool) -> void:
_cur = {}
_text.text = "[b]精炼成功[/b]" if ok else "[b]精炼失败[/b]"
_root.visible = true
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
+57 -5
View File
@@ -15,6 +15,8 @@ var _root: Control
var _list: VBoxContainer
var _gold: Label
var _title: Label
var _status: Label
var _password_dialog: ConfirmationDialog
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
@@ -22,13 +24,25 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
_build(parent)
if client.has_signal("safebox_changed"):
client.safebox_changed.connect(refresh)
if client.has_signal("safebox_password_required"):
client.safebox_password_required.connect(func(): _ask_password("safebox"))
if client.has_signal("safebox_wrong_password"):
client.safebox_wrong_password.connect(func():
if is_instance_valid(_status):
_status.text = "仓库密码错误")
func is_open() -> bool:
return _root != null and _root.visible
func deposit(inv_window: int, inv_cell: int) -> void:
if is_open():
client.safebox_checkin(_next_free_slot(), inv_window, inv_cell)
if not is_open() or inv_window < 0 or inv_cell < 0:
return
var safe_pos := _next_free_slot()
if safe_pos < 0:
_status.text = "仓库已满"
return
if client.has_method("safebox_checkin") and not client.safebox_checkin(safe_pos, inv_window, inv_cell):
_status.text = "存入请求发送失败"
func _next_free_slot() -> int:
var used := {}
@@ -38,7 +52,7 @@ func _next_free_slot() -> int:
for i in range(cap):
if not used.has(i):
return i
return 0
return -1
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
@@ -54,6 +68,7 @@ func refresh() -> void:
_root.visible = client.is_safebox_open()
if not _root.visible:
return
_status.text = ""
_title.text = "仓库(%d 页)" % client.get_safebox_size()
_gold.text = "仓库金币: %d" % client.get_safebox_gold()
for c in _list.get_children():
@@ -67,6 +82,32 @@ func refresh() -> void:
for it in items:
_list.add_child(_row(it))
func _ask_password(kind: String) -> void:
if is_instance_valid(_password_dialog):
_password_dialog.queue_free()
_password_dialog = ConfirmationDialog.new()
_password_dialog.title = "商城密码" if kind == "mall" else "仓库密码"
_password_dialog.dialog_text = "请输入 16 位密码"
_password_dialog.ok_button_text = "确认"
_password_dialog.cancel_button_text = "取消"
var edit := LineEdit.new()
edit.secret = true
edit.max_length = 6
edit.placeholder_text = "密码"
edit.custom_minimum_size = Vector2(220, 28)
_password_dialog.add_child(edit)
_password_dialog.confirmed.connect(func() -> void:
var sent: bool = client.safebox_password(edit.text) if kind == "safebox" else client.mall_password(edit.text)
if not sent and is_instance_valid(_status):
_status.text = "密码格式无效"
_password_dialog.queue_free()
_password_dialog = null)
_password_dialog.canceled.connect(func() -> void:
_password_dialog.queue_free()
_password_dialog = null)
_root.get_parent().add_child(_password_dialog)
_password_dialog.popup_centered()
func _row(it: Dictionary) -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(300, 0)
@@ -78,7 +119,13 @@ func _row(it: Dictionary) -> Control:
var out := Button.new()
out.text = "取出"
var cell := int(it.get("cell", 0))
out.pressed.connect(func() -> void: client.safebox_checkout(cell, 1, _first_free_inv()))
out.pressed.connect(func() -> void:
var target := _first_free_inv()
if target < 0:
_status.text = "背包已满"
return
if not client.safebox_checkout(cell, 1, target):
_status.text = "取出请求发送失败")
row.add_child(out)
return row
@@ -89,7 +136,7 @@ func _first_free_inv() -> int:
for i in range(90):
if not used.has(i):
return i
return 0
return -1
func _build(parent: Node) -> void:
_root = Control.new()
@@ -116,6 +163,11 @@ func _build(parent: Node) -> void:
_gold.modulate = Color(0.95, 0.85, 0.5)
_gold.add_theme_font_size_override("font_size", 12)
_root.add_child(_gold)
_status = Label.new()
_status.position = Vector2(12, 382)
_status.add_theme_font_size_override("font_size", 11)
_status.add_theme_color_override("font_color", Color(1, 0.65, 0.55))
_root.add_child(_status)
_list = VBoxContainer.new()
_list.position = Vector2(12, 54)
_list.add_theme_constant_override("separation", 4)
+44 -4
View File
@@ -18,6 +18,7 @@ var _root: Control
var _tabbar: HBoxContainer
var _list: VBoxContainer
var _err: Label
var _sell_quantity: SpinBox
var _active_tab := 0
func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCounted = null) -> void:
@@ -38,19 +39,27 @@ func is_open() -> bool:
func open() -> void:
_root.visible = true
_err.text = ""
_sell_quantity.value = 1
_active_tab = 0
refresh()
func _close() -> void:
_root.visible = false
_err.text = ""
if _sell_quantity:
_sell_quantity.value = 1
func close_and_leave() -> void:
client.shop_close()
_close()
func sell(inv_cell: int, count: int = 1) -> void:
if is_open():
client.shop_sell(inv_cell, count)
func sell(inv_cell: int, count: int = -1) -> void:
if not is_open() or inv_cell < 0:
return
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):
_on_error("SEND_FAILED")
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
@@ -122,7 +131,19 @@ func _row(it: Dictionary, pos_base := 0) -> Control:
var buy := Button.new()
buy.text = ""
var pos := pos_base + int(it.get("pos", 0))
buy.pressed.connect(func() -> void: client.shop_buy(pos, 1))
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):
_on_error("SEND_FAILED"))
row.add_child(buy)
return row
@@ -132,6 +153,11 @@ func _on_error(kind: String) -> void:
"SOLDOUT": "已售罄",
"INVENTORY_FULL": "背包已满",
"INVALID_POS": "位置无效",
"NOT_ENOUGH_ITEM": "物品数量不足",
"CANNOT_BUY": "当前物品不可购买",
"CANNOT_SELL": "当前物品不可出售",
"SHOP_BUSY": "商店正在处理上一笔交易",
"SEND_FAILED": "交易请求发送失败",
}
_err.text = str(tbl.get(kind, kind))
@@ -159,6 +185,20 @@ func _build(parent: Node) -> void:
_err.modulate = Color(1, 0.4, 0.4)
_err.add_theme_font_size_override("font_size", 12)
_root.add_child(_err)
var sell_hint := Label.new()
sell_hint.text = "出售数量(背包右键):"
sell_hint.position = Vector2(12, 360)
sell_hint.add_theme_font_size_override("font_size", 11)
_root.add_child(sell_hint)
_sell_quantity = SpinBox.new()
_sell_quantity.name = "SellQuantity"
_sell_quantity.min_value = 1
_sell_quantity.max_value = 200
_sell_quantity.value = 1
_sell_quantity.step = 1
_sell_quantity.position = Vector2(160, 356)
_sell_quantity.size = Vector2(70, 26)
_root.add_child(_sell_quantity)
_tabbar = HBoxContainer.new()
_tabbar.position = Vector2(12, 30)
_tabbar.add_theme_constant_override("separation", 4)
+13 -2
View File
@@ -45,6 +45,15 @@ var camera_mode := 1 # 0 近, 1 远
var fog_level := 1 # 0 浓, 1 中, 2 淡
var tiling_mode := 1 # 0 CPU, 1 GPU(占位)
static func config_path() -> String:
var override_dir := OS.get_environment("MT_CONFIG_DIR")
if override_dir != "":
return override_dir.path_join("system_option.cfg")
# Godot headless 在受限执行环境里可能无法写 user://;测试配置放在系统
# 临时目录,正常图形客户端仍使用 user://。
return OS.get_temp_dir().path_join("mtgodot_system_option.cfg") \
if DisplayServer.get_name() == "headless" else CFG_PATH
func setup(ui_manager: CanvasLayer, assets: String, audio_node: Node = null,
cam_getter := Callable(), env_getter := Callable()) -> void:
ui = ui_manager
@@ -228,7 +237,7 @@ func _apply_fog() -> void:
# --- persistence (= systemSetting 配置文件) --------------------------
func _load_cfg() -> void:
if _cfg.load(CFG_PATH) != OK:
if _cfg.load(config_path()) != OK:
return
music_volume = float(_cfg.get_value("audio", "music_volume", music_volume))
sound_volume = float(_cfg.get_value("audio", "sound_volume", sound_volume))
@@ -242,4 +251,6 @@ func _save() -> void:
_cfg.set_value("video", "camera_mode", camera_mode)
_cfg.set_value("video", "fog_level", fog_level)
_cfg.set_value("video", "tiling_mode", tiling_mode)
_cfg.save(CFG_PATH)
var err := _cfg.save(config_path())
if err != OK:
push_warning("SystemOptionUI: save failed (%s): %s" % [err, config_path()])
+6
View File
@@ -68,6 +68,10 @@ static func _load_uncached(assets_root: String, vpath: String) -> Texture2D:
return _load_image_file(real)
static func _load_image_file(path: String) -> Texture2D:
# .sub 文件可能引用了未随当前资源包发布的共享贴图(例如 Public.tga)。
# 先做存在性检查,避免 headless/UI fallback 因缺失可选贴图刷错误日志。
if not FileAccess.file_exists(path):
return null
var ext := path.get_extension().to_lower()
if ext == "dds":
var di := load_dds_image(path)
@@ -99,6 +103,8 @@ static func _load_sub(path: String) -> Texture2D:
var img_path := path.get_base_dir().path_join(image_name)
if not FileAccess.file_exists(img_path):
img_path = path.get_base_dir().path_join(image_name.get_basename() + ".tga")
if not FileAccess.file_exists(img_path):
return null
var base := _load_image_file(img_path)
if base == null:
return null