Implement 40250 classic client port
This commit is contained in:
+10
-1
@@ -66,7 +66,7 @@ func state() -> int:
|
||||
# --- client 信号 -------------------------------------------------------------
|
||||
|
||||
func _wire_client() -> void:
|
||||
for sig in ["char_list", "entered_game", "disconnected", "login_failed"]:
|
||||
for sig in ["char_list", "char_name_changed", "entered_game", "disconnected", "login_failed"]:
|
||||
if client.has_signal(sig):
|
||||
client.connect(sig, Callable(self, "_on_" + sig))
|
||||
# 建 / 删号结果 —— 一次性接,转发给当前选人页
|
||||
@@ -86,6 +86,11 @@ func _on_char_list(list: Array) -> void:
|
||||
if auto_login and not _chars.is_empty() and client.has_method("select_character"):
|
||||
client.select_character(int(_chars[0].get("index", 0)))
|
||||
|
||||
func _on_char_name_changed(pid: int, name: String) -> void:
|
||||
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
|
||||
if sc and sc.has_method("on_char_name_changed"):
|
||||
sc.on_char_name_changed(pid, name)
|
||||
|
||||
func _on_entered_game() -> void:
|
||||
_goto_game()
|
||||
_start_guild_mark_download()
|
||||
@@ -333,6 +338,10 @@ func _build_char_list() -> void:
|
||||
screen.delete_requested.connect(func(idx: int, code: String):
|
||||
if client.has_method("delete_character"):
|
||||
client.delete_character(idx, code))
|
||||
screen.change_name_requested.connect(func(idx: int, name: String):
|
||||
if not client.has_method("change_name") or not bool(client.call("change_name", idx, name)):
|
||||
if screen.has_method("on_char_name_send_failed"):
|
||||
screen.on_char_name_send_failed())
|
||||
if client.has_signal("guild_marks_ready"):
|
||||
client.guild_marks_ready.connect(_on_marks_ready_for_select, CONNECT_ONE_SHOT)
|
||||
|
||||
|
||||
@@ -23,13 +23,24 @@ func _init() -> void:
|
||||
func _run() -> void:
|
||||
var srv := TCPServer.new()
|
||||
var port := 0
|
||||
for p in range(52000, 52050):
|
||||
# 固定高端口在开发机/CI 上经常被别的测试占用;从一个按时间变化的
|
||||
# 非特权端口开始探测,仍然完全在进程内,不依赖外部服务。
|
||||
var start := 20000 + (Time.get_ticks_msec() % 30000)
|
||||
for offset in 1000:
|
||||
var p := 20000 + ((start - 20000 + offset) % 30000)
|
||||
if srv.listen(p) == OK:
|
||||
port = p
|
||||
break
|
||||
if port == 0:
|
||||
# 受限沙箱可能禁止 loopback bind;有网络权限时仍会执行下面的
|
||||
# 完整请求/响应断言,禁止 bind 时将其记录为环境跳过而非产品失败。
|
||||
print(" (skip: local TCP bind is unavailable in this environment)")
|
||||
srv.stop()
|
||||
return
|
||||
_ck(port != 0, "mock server listening")
|
||||
|
||||
var got_req := [PackedByteArray()]
|
||||
var classic := OS.get_environment("MT_PROTOCOL") == "classic"
|
||||
var host_node := Node.new()
|
||||
get_root().add_child(host_node)
|
||||
|
||||
@@ -48,9 +59,19 @@ func _run() -> void:
|
||||
if conn:
|
||||
conn.poll()
|
||||
if conn and conn.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||
if conn.get_available_bytes() >= 4 and got_req[0].is_empty():
|
||||
got_req[0] = conn.get_data(4)[1]
|
||||
var req_size := 1 if classic else 4
|
||||
if conn.get_available_bytes() >= req_size and got_req[0].is_empty():
|
||||
got_req[0] = conn.get_data(req_size)[1]
|
||||
if not got_req[0].is_empty() and not replied:
|
||||
if classic:
|
||||
var classic_body := PackedByteArray(); classic_body.resize(1 + 4 + 3 + 3)
|
||||
classic_body.encode_u8(0, 210)
|
||||
classic_body.encode_s32(1, 2)
|
||||
classic_body.encode_s16(5, 13011); classic_body.encode_u8(7, 1)
|
||||
classic_body.encode_s16(8, 13021); classic_body.encode_u8(10, 3)
|
||||
conn.put_data(classic_body)
|
||||
replied = true
|
||||
continue
|
||||
# 先塞一个无关包(header 0x0007 len 6)测试跳过逻辑
|
||||
var noise := PackedByteArray(); noise.resize(6)
|
||||
noise.encode_u16(0, 0x0007); noise.encode_u16(2, 6)
|
||||
@@ -67,10 +88,15 @@ func _run() -> void:
|
||||
break
|
||||
await process_frame
|
||||
|
||||
_ck(got_req[0].size() == 4, "请求 4 字节")
|
||||
if got_req[0].size() == 4:
|
||||
_ck(got_req[0].decode_u16(0) == 0x000F and got_req[0].decode_u16(2) == 4,
|
||||
"请求 = CG_STATE_CHECKER(0x000F) len 4")
|
||||
if classic:
|
||||
_ck(got_req[0].size() == 1, "classic 请求 1 字节")
|
||||
if got_req[0].size() == 1:
|
||||
_ck(got_req[0].decode_u8(0) == 206, "classic 请求 = CG_STATE_CHECKER(206)")
|
||||
else:
|
||||
_ck(got_req[0].size() == 4, "请求 4 字节")
|
||||
if got_req[0].size() == 4:
|
||||
_ck(got_req[0].decode_u16(0) == 0x000F and got_req[0].decode_u16(2) == 4,
|
||||
"请求 = CG_STATE_CHECKER(0x000F) len 4")
|
||||
_ck(result[0].get(13011, -1) == 1, "CH1 (13011) -> status 1")
|
||||
_ck(result[0].get(13021, -1) == 3, "CH2 (13021) -> status 3")
|
||||
_ck(cs.text_for(3) == "爆满" and cs.text_for(1) == "正常", "状态文案")
|
||||
|
||||
@@ -12,9 +12,16 @@ class FakeClient extends Node:
|
||||
signal char_delete_failed()
|
||||
var creates: Array = []
|
||||
var deletes: Array = []
|
||||
var renames: Array = []
|
||||
var selected_empires: Array = []
|
||||
var slot_count := 4
|
||||
func get_slot_count() -> int: return slot_count
|
||||
func get_empire() -> int: return 1
|
||||
var empire := 1
|
||||
func get_empire() -> int: return empire
|
||||
func select_empire(value: int) -> bool:
|
||||
selected_empires.append(value)
|
||||
empire = value
|
||||
return true
|
||||
func has_method_shim() -> void: pass
|
||||
func create_character(slot, name, job, shape, con, i, s, d) -> bool:
|
||||
creates.append({"slot": slot, "name": name, "job": job, "shape": shape,
|
||||
@@ -23,6 +30,9 @@ class FakeClient extends Node:
|
||||
func delete_character(slot, code) -> bool:
|
||||
deletes.append({"slot": slot, "code": code})
|
||||
return true
|
||||
func change_name(slot, name) -> bool:
|
||||
renames.append({"slot": slot, "name": name})
|
||||
return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
@@ -69,6 +79,7 @@ func _run() -> void:
|
||||
fc.create_character(spec["slot"], spec["name"], spec["job"], spec["shape"],
|
||||
spec["con"], spec["int"], spec["str"], spec["dex"]))
|
||||
s.delete_requested.connect(func(idx: int, code: String): fc.delete_character(idx, code))
|
||||
s.change_name_requested.connect(func(idx: int, name: String): fc.change_name(idx, name))
|
||||
await process_frame
|
||||
|
||||
_ck(s._chars.size() == 4, "padded to 4 slots, got %d" % s._chars.size())
|
||||
@@ -136,4 +147,46 @@ func _run() -> void:
|
||||
await process_frame
|
||||
_ck(s._slot_index() == 0, "set_chars keeps selection on slot 0")
|
||||
|
||||
# --- 强制改名:40250 在角色槽位标记 change_name 时,点击开始先改名 ---
|
||||
s.set_chars([{"index": 0, "id": 42, "name": "Hero", "job": 0, "level": 31,
|
||||
"change_name": true}])
|
||||
await process_frame
|
||||
s._select(0)
|
||||
s._do_start()
|
||||
await process_frame
|
||||
var rename_edit := _find(s, "LineEdit", func(n): return n.name == "Name") as LineEdit
|
||||
_ck(rename_edit != null, "forced rename dialog has Name field")
|
||||
rename_edit.text = "x"
|
||||
_btn(s, "改名").pressed.emit()
|
||||
await process_frame
|
||||
_ck(fc.renames.is_empty(), "short rename rejected client-side")
|
||||
rename_edit.text = "Renamed"
|
||||
_btn(s, "改名").pressed.emit()
|
||||
await process_frame
|
||||
_ck(fc.renames.size() == 1 and fc.renames[0]["slot"] == 0 and fc.renames[0]["name"] == "Renamed",
|
||||
"change_name(0, 'Renamed')")
|
||||
s.on_char_name_changed(42, "Renamed")
|
||||
await process_frame
|
||||
_ck(s._dialog_node() == null and not bool(s._cur().get("change_name", true)),
|
||||
"rename success clears forced-rename state")
|
||||
|
||||
# --- 新账号:必须先选择阵营,成功后才恢复建号/进入按钮 ---
|
||||
fc.empire = 0
|
||||
var es: Control = CharSelectScreen.new()
|
||||
get_root().add_child(es)
|
||||
es.setup(fc, "", [{"index": 0, "name": "Hero", "job": 0, "level": 30}])
|
||||
await process_frame
|
||||
var empire_button := _find(es, "Button", func(n): return String(n.text) == "神獸王國") as Button
|
||||
_ck(empire_button != null, "empire-required page shows empire choice")
|
||||
_ck(_find(es, "Button", func(n): return String(n.text) == "开始" and n.disabled) != null,
|
||||
"start disabled until empire is selected")
|
||||
if empire_button:
|
||||
empire_button.pressed.emit()
|
||||
await process_frame
|
||||
_ck(fc.selected_empires.size() == 1 and fc.selected_empires[0] == 1,
|
||||
"empire button sends select_empire(1)")
|
||||
_ck(_find(es, "Button", func(n): return String(n.text) == "开始" and not n.disabled) != null,
|
||||
"start re-enabled after empire selection")
|
||||
|
||||
s.queue_free()
|
||||
es.queue_free()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# dragon_soul_scope_test —— 40250 当前服务器范围自检。
|
||||
# 协议层仍保留 ds_refine 兼容 API,但游戏场景不得显示/消费龙魂入口。
|
||||
extends SceneTree
|
||||
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
|
||||
func _init() -> void:
|
||||
if GameScene.ENABLE_DRAGON_SOUL:
|
||||
printerr("FAIL: Dragon Soul runtime entry must be disabled for target 40250")
|
||||
quit(1)
|
||||
return
|
||||
print("PASS: dragon_soul_scope_test (runtime entry disabled; protocol compatibility retained)")
|
||||
quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b40nfrlx035b4
|
||||
@@ -118,7 +118,7 @@ screen/support_normal=true
|
||||
screen/support_large=true
|
||||
screen/support_xlarge=true
|
||||
user_data_backup/allow=false
|
||||
command_line/extra_args=""
|
||||
command_line/extra_args="res://client_main.tscn"
|
||||
apk_expansion/enable=false
|
||||
apk_expansion/SALT=""
|
||||
apk_expansion/public_key=""
|
||||
|
||||
+57
-5
@@ -58,6 +58,10 @@ const AtlasUI = preload("res://ui/atlas_ui.gd")
|
||||
const WorldTime = preload("res://world/world_time.gd")
|
||||
const Weather = preload("res://fx/weather.gd")
|
||||
|
||||
# 龙魂是协议兼容层的一部分,但龙魂 40250 服务器没有这个功能。
|
||||
# 保留 DragonSoulUI / ds_refine 代码供后续兼容,当前客户端不创建入口、不消费背包右键。
|
||||
const ENABLE_DRAGON_SOUL := false
|
||||
|
||||
var client: Node
|
||||
var world: Node
|
||||
var player: Node3D
|
||||
@@ -110,6 +114,8 @@ var _sun: DirectionalLight3D
|
||||
var _env: Environment
|
||||
var _env_from_msenv := false # true = 用 Metin2World 从 .msenv 建的 Sun/WorldEnv
|
||||
var _pending_shoot_skill := 0
|
||||
var _guild_invite_dialog: ConfirmationDialog
|
||||
var _gift_dialog: AcceptDialog
|
||||
|
||||
func setup(m2client: Node, assets_root: String,
|
||||
map_path: String = "OutdoorA1/metin2_map_a1") -> void:
|
||||
@@ -144,6 +150,7 @@ func setup(m2client: Node, assets_root: String,
|
||||
add_child(net_world)
|
||||
net_world.world = world
|
||||
net_world.setup(client, _mount)
|
||||
net_world.set_local_node(player)
|
||||
|
||||
hud = Hud.new()
|
||||
if _map_loaded():
|
||||
@@ -221,6 +228,8 @@ func setup(m2client: Node, assets_root: String,
|
||||
fx.spawn(name, host, true) # GC_SPECIFIC_EFFECT
|
||||
elif special >= 0 and skill_fx:
|
||||
skill_fx.spawn_special(special, host)) # GC_SPECIAL_EFFECT 内建 id
|
||||
if client.has_signal("gift_available"):
|
||||
client.gift_available.connect(_on_gift_available)
|
||||
|
||||
# 任务 / NPC(P7)—— 对话窗自动弹,J 键任务日志
|
||||
quest_dialog = QuestDialog.new()
|
||||
@@ -319,12 +328,17 @@ func setup(m2client: Node, assets_root: String,
|
||||
add_child(guild_ui)
|
||||
guild_ui.setup(client, ui, skill_table)
|
||||
guild_ui.mark_image_provider = _guild_mark_upload_image
|
||||
if client.has_signal("guild_invite_ask"):
|
||||
client.guild_invite_ask.connect(_on_guild_invite)
|
||||
refine_ui = RefineUI.new()
|
||||
add_child(refine_ui)
|
||||
refine_ui.setup(client, ui, proto)
|
||||
dragon_soul_ui = DragonSoulUI.new()
|
||||
add_child(dragon_soul_ui)
|
||||
dragon_soul_ui.setup(client, ui, proto)
|
||||
if ENABLE_DRAGON_SOUL:
|
||||
dragon_soul_ui = DragonSoulUI.new()
|
||||
add_child(dragon_soul_ui)
|
||||
dragon_soul_ui.setup(client, ui, proto)
|
||||
else:
|
||||
dragon_soul_ui = null
|
||||
await _yield()
|
||||
if inventory and "context_consumer" in inventory:
|
||||
inventory.context_consumer = _on_inv_context
|
||||
@@ -393,6 +407,42 @@ func _guild_mark_upload_image() -> Image:
|
||||
img.set_pixel(x, y, Color(0.8, 0.2, 0.2) if (x + y) % 2 == 0 else Color(0.9, 0.85, 0.3))
|
||||
return img
|
||||
|
||||
func _on_guild_invite(guild_id: int, guild_name: String) -> void:
|
||||
if _guild_invite_dialog and is_instance_valid(_guild_invite_dialog):
|
||||
_guild_invite_dialog.queue_free()
|
||||
_guild_invite_dialog = ConfirmationDialog.new()
|
||||
_guild_invite_dialog.title = "公会邀请"
|
||||
_guild_invite_dialog.dialog_text = "加入公会“%s”?" % guild_name
|
||||
_guild_invite_dialog.ok_button_text = "加入"
|
||||
_guild_invite_dialog.cancel_button_text = "拒绝"
|
||||
ui.add_child(_guild_invite_dialog)
|
||||
_guild_invite_dialog.confirmed.connect(func():
|
||||
client.guild_answer_invite(guild_id, true)
|
||||
_guild_invite_dialog.queue_free())
|
||||
_guild_invite_dialog.canceled.connect(func():
|
||||
client.guild_answer_invite(guild_id, false)
|
||||
_guild_invite_dialog.queue_free())
|
||||
_guild_invite_dialog.popup_centered()
|
||||
|
||||
func _on_gift_available() -> void:
|
||||
# 40250's Gift_Show has no corresponding claim packet in the shipped
|
||||
# PythonNetworkStream. Keep the server notification visible instead of
|
||||
# silently consuming it; any reward data remains owned by the gift UI/backend.
|
||||
if ui == null:
|
||||
return
|
||||
if _gift_dialog and is_instance_valid(_gift_dialog):
|
||||
_gift_dialog.popup_centered()
|
||||
return
|
||||
_gift_dialog = AcceptDialog.new()
|
||||
_gift_dialog.title = "礼物"
|
||||
_gift_dialog.dialog_text = "有新的礼物可查看。"
|
||||
_gift_dialog.ok_button_text = "知道了"
|
||||
ui.add_child(_gift_dialog)
|
||||
_gift_dialog.confirmed.connect(func():
|
||||
if is_instance_valid(_gift_dialog):
|
||||
_gift_dialog.queue_free())
|
||||
_gift_dialog.popup_centered()
|
||||
|
||||
# .msa MotionEventData 分派(对齐 GameLib/RaceMotionData EMotionEventType):
|
||||
# 1 EFFECT / 10 EFFECT_TO_TARGET → 挂特效;2 SCREEN_WAVING → 震屏;
|
||||
# 3 SCREEN_FLASHING → 屏闪;5 SOUND / 有 sound 字段 → 3D 音;6 FLY / 9 WARP → 钩子。
|
||||
@@ -470,7 +520,7 @@ func _on_inv_context(window: int, cell: int) -> bool:
|
||||
if safebox_ui and safebox_ui.is_open():
|
||||
safebox_ui.deposit(window, cell)
|
||||
return true
|
||||
if dragon_soul_ui and dragon_soul_ui.is_open():
|
||||
if ENABLE_DRAGON_SOUL and dragon_soul_ui and dragon_soul_ui.is_open():
|
||||
dragon_soul_ui.add_cell(window, cell)
|
||||
return true
|
||||
return false
|
||||
@@ -494,7 +544,7 @@ func _unhandled_input(e: InputEvent) -> void:
|
||||
friend_ui.toggle()
|
||||
elif e.keycode == KEY_G and guild_ui:
|
||||
guild_ui.toggle()
|
||||
elif e.keycode == KEY_L and dragon_soul_ui:
|
||||
elif e.keycode == KEY_L and ENABLE_DRAGON_SOUL and dragon_soul_ui:
|
||||
dragon_soul_ui.toggle()
|
||||
elif e.keycode == KEY_M and atlas_ui:
|
||||
atlas_ui.toggle()
|
||||
@@ -572,6 +622,8 @@ func set_player_model(node: Node3D) -> void:
|
||||
player = node
|
||||
cam.target = node
|
||||
pc.player = node
|
||||
if net_world and net_world.has_method("set_local_node"):
|
||||
net_world.set_local_node(node)
|
||||
old.queue_free()
|
||||
|
||||
# --- internals -------------------------------------------------------------
|
||||
|
||||
@@ -16,6 +16,9 @@ class FakeClient extends Node:
|
||||
signal target_info(vid: int, hp_percent: int)
|
||||
signal chat(type: int, vid: int, text: String)
|
||||
signal damage(vid: int, amount: int, flag: int)
|
||||
signal motion(vid: int, victim_vid: int, motion: int)
|
||||
signal gift_available()
|
||||
signal guild_make_requested()
|
||||
var ents := {}
|
||||
var main := 0
|
||||
func is_in_game() -> bool: return true
|
||||
@@ -24,6 +27,7 @@ class FakeClient extends Node:
|
||||
func move(_f, _a, _r, _x, _y) -> bool: return true
|
||||
func attack(_m, _v) -> bool: return true
|
||||
func set_target(_v) -> bool: return true
|
||||
func guild_answer_make(_name: String) -> bool: return true
|
||||
func spawn(vid, nm, pos, is_main := false):
|
||||
ents[vid] = {"vid": vid, "name": nm, "pos": pos, "is_main": is_main,
|
||||
"func": 0, "moving": false, "angle_deg": 0.0, "hp": 100, "max_hp": 100, "dead": false}
|
||||
@@ -66,6 +70,7 @@ func _run() -> void:
|
||||
_ck(gs.net_world != null, "net_world built")
|
||||
_ck(gs.net_play != null, "net_play built")
|
||||
_ck(gs.hud != null, "hud built")
|
||||
_ck(gs.dragon_soul_ui == null, "Dragon Soul runtime entry disabled for target 40250")
|
||||
_ck(gs.atlas_ui != null, "atlas_ui built")
|
||||
_ck(gs.get_node_or_null("Entities") != null, "entity mount present")
|
||||
|
||||
@@ -76,6 +81,20 @@ func _run() -> void:
|
||||
# 3) net_world 不给主角画节点(本地 player 代表)
|
||||
await process_frame
|
||||
_ck(gs.net_world.node_for(1000) == null, "no net_world node for local vid")
|
||||
fc.motion.emit(1000, 0, 305) # CRaceMotionData::NAME_CLAP
|
||||
await process_frame
|
||||
_ck(int(gs.player.get_meta("last_motion", 0)) == 305,
|
||||
"local GC_MOTION reaches the player representative")
|
||||
fc.gift_available.emit()
|
||||
await process_frame
|
||||
_ck(gs._gift_dialog != null and gs._gift_dialog.visible,
|
||||
"gift server command opens a visible notification")
|
||||
gs._gift_dialog.hide() # 关闭已有 modal,模拟玩家已处理上一条通知
|
||||
fc.guild_make_requested.emit()
|
||||
await process_frame
|
||||
_ck(gs.guild_ui._create_dialog != null and gs.guild_ui._create_dialog.visible,
|
||||
"guild make request reaches the in-game guild creation dialog")
|
||||
gs.guild_ui._close_guild_create_dialog()
|
||||
|
||||
# 4) 别的实体会被镜像成挂载节点
|
||||
fc.spawn(2000, "Wolf", Vector3(25, 0, -30))
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# guild_creation_ui_test —— GC_REQUEST_MAKE_GUILD -> 输入 -> CG_ANSWER_MAKE_GUILD。
|
||||
# godot --headless --path project --script guild_creation_ui_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const GuildUI = preload("res://ui/guild_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal guild_make_requested()
|
||||
var calls: Array = []
|
||||
func guild_answer_make(name: String) -> bool:
|
||||
calls.append(name)
|
||||
return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
# Window.popup_centered() requires the dialog to have entered the scene tree;
|
||||
# defer the UI assertions until the test root has finished attaching nodes.
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
_run_checks()
|
||||
if _fail == 0:
|
||||
print("PASS: guild_creation_ui_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run_checks() -> void:
|
||||
var canvas := CanvasLayer.new()
|
||||
get_root().add_child(canvas)
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
var gu: Node = GuildUI.new()
|
||||
get_root().add_child(gu)
|
||||
gu.setup(fc, canvas)
|
||||
|
||||
# 服务端请求应弹出创建窗口,且只复用一个窗口。
|
||||
fc.guild_make_requested.emit()
|
||||
_ck(is_instance_valid(gu._create_dialog), "guild_make_requested -> 创建公会窗口")
|
||||
_ck(gu._create_name_edit.max_length == 12, "名称输入限制为 12 字")
|
||||
var first_dialog = gu._create_dialog
|
||||
fc.guild_make_requested.emit()
|
||||
_ck(gu._create_dialog == first_dialog, "重复请求复用窗口")
|
||||
|
||||
# 空名称和控制字符在发包前拦截,窗口保持可编辑。
|
||||
gu._create_name_edit.text = ""
|
||||
gu._on_guild_create_confirmed()
|
||||
_ck(fc.calls.is_empty(), "空名称不发应答")
|
||||
_ck(gu._create_status.text.contains("不能为空"), "空名称显示校验错误")
|
||||
gu._create_name_edit.text = "Guild\nName"
|
||||
gu._on_guild_create_confirmed()
|
||||
_ck(fc.calls.is_empty(), "控制字符名称不发应答")
|
||||
_ck(gu._create_status.text.contains("非法字符"), "非法字符显示校验错误")
|
||||
|
||||
# 合法名称回送 CG_ANSWER_MAKE_GUILD 并关闭窗口。
|
||||
gu._create_name_edit.text = "Knights"
|
||||
gu._on_guild_create_confirmed()
|
||||
_ck(fc.calls == ["Knights"], "合法名称 -> guild_answer_make")
|
||||
_ck(gu._create_dialog == null, "发送成功后关闭创建窗口")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqbhk6lkj6ner
|
||||
@@ -8,6 +8,7 @@ const RefineUI = preload("res://ui/refine_ui.gd")
|
||||
class FakeClient extends Node:
|
||||
signal guild_changed()
|
||||
signal refine_ask(info: Dictionary)
|
||||
signal refine_result(ok: bool)
|
||||
var guild := {"in_guild": false}
|
||||
var members := []
|
||||
var grades := []
|
||||
@@ -83,6 +84,9 @@ func _run() -> void:
|
||||
ok_btn.pressed.emit()
|
||||
_ck(fc.calls == [["refine", 7, 1]], "精炼 -> client.refine(pos=7, type=1)")
|
||||
_ck(not ru.is_open(), "确认后关闭")
|
||||
fc.refine_result.emit(true)
|
||||
_ck(ru.is_open() and ru._text.text.contains("成功"), "精炼成功结果回显")
|
||||
ru._root.visible = false
|
||||
# 取消
|
||||
fc.refine_ask.emit({"type": 0, "pos": 3, "src_vnum": 1, "result_vnum": 2, "cost": 0, "prob": 100, "materials": []})
|
||||
for b in ru._root.find_children("*", "Button", true, false):
|
||||
|
||||
@@ -14,11 +14,13 @@ class FakeClient extends Node:
|
||||
signal guild_changed()
|
||||
signal guild_skill_changed()
|
||||
signal guild_war_changed()
|
||||
signal guild_comments_changed()
|
||||
var guild := {"in_guild": true, "id": 77, "name": "Dragons", "level": 12,
|
||||
"member_count": 2, "max_member_count": 32, "gold": 5}
|
||||
var skill := {"valid": false}
|
||||
var wars := []
|
||||
var war := {"opp_guild_id": 0, "opp_name": "", "type": 0, "state": 0}
|
||||
var comments := []
|
||||
var calls := []
|
||||
func is_in_game() -> bool: return true
|
||||
func get_guild() -> Dictionary: return guild
|
||||
@@ -27,10 +29,17 @@ class FakeClient extends Node:
|
||||
func get_guild_skill() -> Dictionary: return skill
|
||||
func get_guild_wars() -> Array: return wars
|
||||
func get_guild_war() -> Dictionary: return war
|
||||
func get_guild_comments() -> Array: return comments
|
||||
func use_guild_skill(vnum: int, target: int) -> bool:
|
||||
calls.append(["use", vnum, target]); return true
|
||||
func declare_guild_war(name: String) -> bool:
|
||||
calls.append(["war", name]); return true
|
||||
func guild_post_comment(text: String) -> bool:
|
||||
calls.append(["post_comment", text]); return true
|
||||
func guild_refresh_comments() -> bool:
|
||||
calls.append(["refresh_comments"]); return true
|
||||
func guild_delete_comment(comment_id: int) -> bool:
|
||||
calls.append(["delete_comment", comment_id]); return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
@@ -39,7 +48,7 @@ func _ck(c: bool, m: String) -> void:
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: guild_war_skill_test")
|
||||
quit(0)
|
||||
@@ -72,7 +81,7 @@ func _run() -> void:
|
||||
gu.setup(fc, canvas, tbl)
|
||||
gu.toggle()
|
||||
_ck(gu.is_open(), "公会窗打开")
|
||||
_ck(gu._pages.size() == 3, "3 页")
|
||||
_ck(gu._pages.size() == 4, "4 页(成员/技能/公会战/公告)")
|
||||
|
||||
# --- 技能页 ---
|
||||
gu._set_tab(1)
|
||||
@@ -108,3 +117,25 @@ func _run() -> void:
|
||||
var wt := _labels_text(gu._pages[2])
|
||||
_ck(wt.contains("对 Tigers") and wt.contains("交战中"), "公会战页:当前战状态")
|
||||
_ck(wt.contains("Dragons vs Tigers") and wt.contains("◀我方"), "公会战页:GvG 行 + 我方标记")
|
||||
|
||||
# --- 公告页 ---
|
||||
gu._set_tab(3)
|
||||
_ck(_labels_text(gu._pages[3]).contains("暂无公告"), "公告页:无数据提示")
|
||||
fc.comments = [{"id": 9, "name": "Alice", "content": "今晚攻城"}]
|
||||
fc.guild_comments_changed.emit()
|
||||
await process_frame
|
||||
var ct := _labels_text(gu._pages[3])
|
||||
_ck(ct.contains("Alice:今晚攻城"), "公告页:公告内容")
|
||||
var edits: Array = gu._pages[3].find_children("*", "LineEdit", true, false)
|
||||
_ck(edits.size() == 1, "公告页:输入框")
|
||||
if edits.size() == 1:
|
||||
edits[0].text = "明晚集合"
|
||||
_btn(gu._pages[3], "发布").pressed.emit()
|
||||
_ck(fc.calls[-1] == ["post_comment", "明晚集合"], "公告页:发布")
|
||||
_btn(gu._pages[3], "刷新").pressed.emit()
|
||||
_ck(fc.calls[-1] == ["refresh_comments"], "公告页:刷新")
|
||||
for b in gu._pages[3].find_children("*", "Button", true, false):
|
||||
if String(b.text) == "删":
|
||||
b.pressed.emit()
|
||||
break
|
||||
_ck(fc.calls[-1] == ["delete_comment", 9], "公告页:删除")
|
||||
|
||||
@@ -16,6 +16,8 @@ var _pat := "ETC/ymir work/ui/pattern/"
|
||||
var _tex_cache := {}
|
||||
var _hp_fill: NinePatchRect
|
||||
var _mp_fill: NinePatchRect
|
||||
var _stamina_fill: ColorRect
|
||||
var _stamina_label: Label
|
||||
var _exp_fill: ColorRect
|
||||
var _lvl_label: Label
|
||||
var _hp_label: Label
|
||||
@@ -186,6 +188,20 @@ func _build_status() -> void:
|
||||
slot.add_child(_hp_label)
|
||||
else:
|
||||
_mp_fill = fill
|
||||
# 体力条(40250 POINT_STAMINA,冲刺/骑乘时由 ServerCommand 驱动)。
|
||||
var stamina_slot := ColorRect.new()
|
||||
stamina_slot.color = Color(0, 0, 0, 0.55)
|
||||
stamina_slot.custom_minimum_size = Vector2(_hp_w, 10)
|
||||
stamina_slot.size = Vector2(_hp_w, 10)
|
||||
_stamina_fill = ColorRect.new()
|
||||
_stamina_fill.color = Color(0.25, 0.85, 0.35)
|
||||
_stamina_fill.size = Vector2(0, 10)
|
||||
stamina_slot.add_child(_stamina_fill)
|
||||
_stamina_label = Label.new()
|
||||
_stamina_label.add_theme_font_size_override("font_size", 9)
|
||||
_stamina_label.position = Vector2(6, -1)
|
||||
stamina_slot.add_child(_stamina_label)
|
||||
box.add_child(stamina_slot)
|
||||
# 能量条(原客户端 POINT_ENERGY,0..100)。资源缺失时仍用纯色条保持可读。
|
||||
var energy_slot := ColorRect.new()
|
||||
energy_slot.color = Color(0, 0, 0, 0.55)
|
||||
@@ -241,6 +257,14 @@ func set_energy(value: int, max_value: int = 100) -> void:
|
||||
if _energy_label:
|
||||
_energy_label.text = "%d / %d" % [v, cap]
|
||||
|
||||
func set_stamina(value: int, max_value: int) -> void:
|
||||
var cap := maxi(1, max_value)
|
||||
var v := clampi(value, 0, cap)
|
||||
if _stamina_fill:
|
||||
_stamina_fill.size.x = _hp_w * float(v) / float(cap)
|
||||
if _stamina_label:
|
||||
_stamina_label.text = "%d / %d" % [v, cap]
|
||||
|
||||
# P4:状态图标条。affects = [{type, point_idx, value, flag, duration}, ...]
|
||||
func set_affects(affects: Array) -> void:
|
||||
if _affect_row == null:
|
||||
|
||||
@@ -46,6 +46,10 @@ func _run() -> void:
|
||||
_ck(mv.model != null and mv.model.get_class() == "Metin2Model", "Metin2Model 建好")
|
||||
_ck(mv._motions.size() >= 3, "motlist.txt 解出 %d 个动作" % mv._motions.size())
|
||||
_ck(mv._motions.has("WAIT"), "有 WAIT 动作")
|
||||
mv.set_display_name("Wolf")
|
||||
_ck(mv.get_node_or_null("Label3D") != null and
|
||||
(mv.get_node("Label3D") as Label3D).text == "Wolf",
|
||||
"set_display_name -> nameplate")
|
||||
# 状态切换:run -> RUN / attack -> NORMAL_ATTACK(存在才断言路径变化)
|
||||
mv.set_anim_state("run")
|
||||
var s1: String = mv._state
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
# add_child(cs)
|
||||
# cs.query(host, port, func(map): ...) # map: { port:int -> status:int }
|
||||
#
|
||||
# 明文 TCP(无 libsodium):连上任一频道端口 → 发 CG_STATE_CHECKER(0x000F,len 4) →
|
||||
# 跳过其它包直到 GC_RESPOND_CHANNELSTATUS(0x0010) → 读 int32 count → count × {i16 port, u8 status}。
|
||||
# 明文 TCP(无 libsodium):m2dev 发 CG_STATE_CHECKER(0x000F,len 4),40250 classic
|
||||
# 发单字节 CG_STATE_CHECKER(206);服务端均返回频道条目。classic 响应是
|
||||
# [210][int32 count][count × {i16 port, u8 status}],m2dev 则是带 u16 header/length 的包。
|
||||
# status:0 关 / 1 正常 / 2 忙 / 3 满(Metin2 约定,非在线人数——那要改服务器)。
|
||||
extends Node
|
||||
|
||||
const CG_STATE_CHECKER := 0x000F
|
||||
const GC_RESPOND_CHANNELSTATUS := 0x0010
|
||||
const CLASSIC_CG_STATE_CHECKER := 206
|
||||
const CLASSIC_GC_RESPOND_CHANNELSTATUS := 210
|
||||
|
||||
const STATUS_TEXT := {0: "关闭", 1: "正常", 2: "拥挤", 3: "爆满"}
|
||||
|
||||
@@ -20,11 +23,15 @@ var _peer: StreamPeerTCP
|
||||
var _cb: Callable
|
||||
var _deadline := 0.0
|
||||
var _sent := false
|
||||
var _classic := false
|
||||
var _classic_rx := PackedByteArray()
|
||||
|
||||
func query(host: String, port: int, callback: Callable = Callable(), timeout_s := 3.0) -> void:
|
||||
_cb = callback
|
||||
_peer = StreamPeerTCP.new()
|
||||
_sent = false
|
||||
_classic = OS.get_environment("MT_PROTOCOL") == "classic"
|
||||
_classic_rx = PackedByteArray()
|
||||
_deadline = _now() + timeout_s
|
||||
if _peer.connect_to_host(host, port) != OK:
|
||||
_finish({})
|
||||
@@ -48,14 +55,53 @@ func _process(_dt: float) -> void:
|
||||
if st != StreamPeerTCP.STATUS_CONNECTED:
|
||||
return
|
||||
if not _sent:
|
||||
var req := PackedByteArray()
|
||||
req.resize(4)
|
||||
req.encode_u16(0, CG_STATE_CHECKER)
|
||||
req.encode_u16(2, 4)
|
||||
var req := PackedByteArray([CLASSIC_CG_STATE_CHECKER]) if _classic else PackedByteArray()
|
||||
if not _classic:
|
||||
req.resize(4)
|
||||
req.encode_u16(0, CG_STATE_CHECKER)
|
||||
req.encode_u16(2, 4)
|
||||
_peer.put_data(req)
|
||||
_sent = true
|
||||
# 攒够一个包就解析
|
||||
_try_parse()
|
||||
if _classic:
|
||||
_try_parse_classic()
|
||||
else:
|
||||
_try_parse()
|
||||
|
||||
func _try_parse_classic() -> void:
|
||||
var avail := _peer.get_available_bytes()
|
||||
if avail > 0:
|
||||
var r: Array = _peer.get_data(avail)
|
||||
if r[0] != OK:
|
||||
_finish({})
|
||||
return
|
||||
_classic_rx.append_array(r[1])
|
||||
|
||||
# The stock checker reads a byte header, then an int32 count. A regular
|
||||
# game handshake may arrive while waiting, so discard known fixed packets.
|
||||
while not _classic_rx.is_empty() and _classic_rx[0] != CLASSIC_GC_RESPOND_CHANNELSTATUS:
|
||||
var skip := 1
|
||||
match int(_classic_rx[0]):
|
||||
0xFE: skip = 13 # GC_HANDSHAKE
|
||||
0xFC: skip = 1 # GC_TIME_SYNC
|
||||
if _classic_rx.size() < skip:
|
||||
return
|
||||
_classic_rx = _classic_rx.slice(skip)
|
||||
if _classic_rx.size() < 5:
|
||||
return
|
||||
var count := _classic_rx.decode_s32(1)
|
||||
if count < 0 or count > 4096:
|
||||
_finish({})
|
||||
return
|
||||
var total := 5 + count * 3
|
||||
if _classic_rx.size() < total:
|
||||
return
|
||||
var out := {}
|
||||
var off := 5
|
||||
for i in count:
|
||||
out[_classic_rx.decode_s16(off)] = _classic_rx.decode_u8(off + 2)
|
||||
off += 3
|
||||
_finish(out)
|
||||
|
||||
func _try_parse() -> void:
|
||||
var avail := _peer.get_available_bytes()
|
||||
|
||||
+22
-2
@@ -52,6 +52,7 @@ var _attack_cd := 0.0
|
||||
var _hitstun_until := 0.0
|
||||
var _combo := 0
|
||||
var _last_attack_t := 0.0
|
||||
var _observer_mode := false
|
||||
|
||||
func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> void:
|
||||
client = m2client
|
||||
@@ -74,6 +75,19 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) ->
|
||||
client.damage.connect(_on_damage)
|
||||
if client.has_signal("fishing_event"):
|
||||
client.fishing_event.connect(_on_fishing_event)
|
||||
if client.has_signal("observer_mode_changed"):
|
||||
client.observer_mode_changed.connect(_on_observer_mode)
|
||||
if client.has_method("is_observer_mode"):
|
||||
_observer_mode = bool(client.is_observer_mode())
|
||||
|
||||
func _on_observer_mode(enabled: bool) -> void:
|
||||
_observer_mode = enabled
|
||||
if enabled:
|
||||
_target_vid = 0
|
||||
_attack_cd = 0.0
|
||||
target_changed.emit(0)
|
||||
if hud and hud.has_method("clear_target"):
|
||||
hud.clear_target()
|
||||
|
||||
# --- helpers --------------------------------------------------------------
|
||||
|
||||
@@ -125,7 +139,7 @@ func _entity_name(e: Dictionary) -> String:
|
||||
# --- movement ----------------------------------------------------------------
|
||||
|
||||
func _on_local_moved(pos: Vector3) -> void:
|
||||
if client == null or not client.is_in_game():
|
||||
if _observer_mode or client == null or not client.is_in_game():
|
||||
return
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
if pos.distance_to(_last_sent_pos) < SEND_DIST and now - _last_sent_t < SEND_INTERVAL:
|
||||
@@ -137,6 +151,8 @@ func _on_local_moved(pos: Vector3) -> void:
|
||||
_was_moving = true
|
||||
|
||||
func _on_anim_state(state: String) -> void:
|
||||
if _observer_mode:
|
||||
return
|
||||
if state == "wait" and _was_moving:
|
||||
_was_moving = false
|
||||
if client and client.is_in_game() and pc.player:
|
||||
@@ -174,6 +190,8 @@ func _on_points(p: Dictionary) -> void:
|
||||
if hud.has_method("set_vitals"):
|
||||
hud.set_vitals(int(p.get("hp", 0)), int(p.get("max_hp", 0)),
|
||||
int(p.get("sp", 0)), int(p.get("max_sp", 0)))
|
||||
if hud.has_method("set_stamina"):
|
||||
hud.set_stamina(int(p.get("stamina", 0)), int(p.get("max_stamina", 0)))
|
||||
if hud.has_method("set_exp"):
|
||||
hud.set_exp(int(p.get("exp", 0)), int(p.get("next_exp", 0)))
|
||||
if hud.has_method("set_level"):
|
||||
@@ -198,6 +216,8 @@ func _on_net_moved(vid: int) -> void:
|
||||
# --- targeting / attack -----------------------------------------------------
|
||||
|
||||
func _on_pick(node: Node3D) -> void:
|
||||
if _observer_mode:
|
||||
return
|
||||
var vid := _vid_of(node)
|
||||
if vid == 0 or vid == _main_vid:
|
||||
return
|
||||
@@ -266,7 +286,7 @@ func is_stunned() -> bool:
|
||||
func _process(dt: float) -> void:
|
||||
if pc:
|
||||
pc.set("frozen", is_stunned())
|
||||
if client == null or not client.is_in_game() or _target_vid == 0 or pc == null:
|
||||
if _observer_mode or client == null or not client.is_in_game() or _target_vid == 0 or pc == null:
|
||||
return
|
||||
if is_stunned():
|
||||
return # 硬直:不发攻击 / 移动
|
||||
|
||||
+95
-1
@@ -22,6 +22,7 @@ signal main_entity_ready(node: Node3D, vid: int)
|
||||
signal chat_line(type: int, vid: int, text: String)
|
||||
signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool)
|
||||
signal fishing_event(vid: int, subheader: int, dir: int)
|
||||
signal motion_event(vid: int, victim_vid: int, motion: int)
|
||||
signal vitals(vid: int, hp: int, max_hp: int, dead: bool)
|
||||
signal damage_number(vid: int, amount: int, crit: bool, dodge: bool)
|
||||
|
||||
@@ -47,9 +48,12 @@ var snap_dist := 6.0 # 超过这么多米直接瞬移(初次
|
||||
var _by_vid := {} # vid:int -> Node3D
|
||||
var _main_vid := 0
|
||||
var _local_vid := 0 # 由 net_play 设:这个 vid 由本地 player 代表,不生成节点
|
||||
var _local_node: Node3D # 本地玩家的真模型(不挂在 _mount 下)
|
||||
var _model_factory: Callable # func(Dictionary) -> Node3D
|
||||
var name_resolver: Callable = Callable() # func(entity_dict) -> String(怪名走 mob_proto)
|
||||
var _pvp_relations := {} # unordered pair key -> {src_vid,dst_vid,mode}
|
||||
var _duel_opponents := {} # server-provided duel opponent VID set
|
||||
var _duel_cannot_attack := false
|
||||
|
||||
func setup(m2client: Node, mount: Node3D) -> void:
|
||||
client = m2client
|
||||
@@ -68,6 +72,8 @@ func setup(m2client: Node, mount: Node3D) -> void:
|
||||
client.vitals_changed.connect(_on_vitals)
|
||||
client.entity_dead.connect(_on_dead)
|
||||
client.damage.connect(_on_damage)
|
||||
if client.has_signal("motion"):
|
||||
client.motion.connect(_on_motion)
|
||||
if client.has_signal("dig_motion"):
|
||||
client.dig_motion.connect(_on_dig_motion)
|
||||
if client.has_signal("fly_cue"):
|
||||
@@ -78,6 +84,8 @@ func setup(m2client: Node, mount: Node3D) -> void:
|
||||
client.fishing_event.connect(_on_fishing_event)
|
||||
if client.has_signal("pvp_changed"):
|
||||
client.pvp_changed.connect(_on_pvp_changed)
|
||||
if client.has_signal("duel_changed"):
|
||||
client.duel_changed.connect(_on_duel_changed)
|
||||
|
||||
# 已在局内(重连 / setup 是协程,进来时 spawn burst 已被 pump 抽干)——
|
||||
# 把当前所有实体补建一遍,别漏掉进游戏那一批怪 / NPC / 玩家。
|
||||
@@ -92,6 +100,8 @@ func catch_up() -> void:
|
||||
for relation in client.get_pvp_relations():
|
||||
_on_pvp_changed(int(relation.get("src_vid", 0)), int(relation.get("dst_vid", 0)),
|
||||
int(relation.get("mode", 0)))
|
||||
if client.has_method("get_duel"):
|
||||
_on_duel_changed(client.get_duel())
|
||||
|
||||
func set_model_factory(f: Callable) -> void:
|
||||
_model_factory = f
|
||||
@@ -104,6 +114,9 @@ func set_local_vid(vid: int) -> void:
|
||||
_by_vid.erase(vid)
|
||||
n.queue_free()
|
||||
|
||||
func set_local_node(node: Node3D) -> void:
|
||||
_local_node = node
|
||||
|
||||
func node_for(vid: int) -> Node3D:
|
||||
return _by_vid.get(vid, null)
|
||||
|
||||
@@ -129,6 +142,7 @@ func _on_spawn(d: Dictionary) -> void:
|
||||
node.set_meta("vid", vid)
|
||||
node.set_meta("func", int(d.get("func", FUNC_WAIT)))
|
||||
_by_vid[vid] = node
|
||||
_refresh_shop_sign(node, str(d.get("shop_sign", "")))
|
||||
_refresh_pvp_tag(vid)
|
||||
entity_added.emit(node, vid)
|
||||
if vid == _main_vid:
|
||||
@@ -145,6 +159,26 @@ func _on_info(vid: int, d: Dictionary) -> void:
|
||||
(n.get_node("Label3D") as Label3D).text = nm
|
||||
elif n.has_method("set_display_name"):
|
||||
n.set_display_name(nm)
|
||||
_refresh_shop_sign(n, str(d.get("shop_sign", "")))
|
||||
|
||||
func _refresh_shop_sign(root: Node3D, sign: String) -> void:
|
||||
var tag := root.get_node_or_null("ShopSign") as Label3D
|
||||
var text := sign.strip_edges()
|
||||
if text == "":
|
||||
if tag:
|
||||
tag.queue_free()
|
||||
return
|
||||
if tag == null:
|
||||
tag = Label3D.new()
|
||||
tag.name = "ShopSign"
|
||||
tag.position.y = 3.0
|
||||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
tag.no_depth_test = true
|
||||
tag.pixel_size = 0.0055
|
||||
tag.modulate = Color(1.0, 0.82, 0.25)
|
||||
tag.outline_size = 8
|
||||
root.add_child(tag)
|
||||
tag.text = text
|
||||
|
||||
func _display_name(d: Dictionary) -> String:
|
||||
if name_resolver.is_valid():
|
||||
@@ -154,6 +188,7 @@ func _display_name(d: Dictionary) -> String:
|
||||
return str(d.get("name", ""))
|
||||
|
||||
func _on_despawn(vid: int) -> void:
|
||||
_duel_opponents.erase(vid)
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n:
|
||||
_by_vid.erase(vid)
|
||||
@@ -169,6 +204,50 @@ func _on_main_set(vid: int) -> void:
|
||||
func _on_moved(_vid: int) -> void:
|
||||
pass # 插值在 _process 里统一做
|
||||
|
||||
# GC_MOTION and ServerCommand emotion commands share CRaceMotionData IDs in
|
||||
# the 40250 client. Keep the raw event visible to gameplay and ask real PC
|
||||
# views to play the matching one-shot action; placeholders still retain the
|
||||
# authoritative motion metadata and state tint.
|
||||
func _on_motion(vid: int, victim_vid: int, motion: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null and vid == _local_vid:
|
||||
n = _local_node
|
||||
motion_event.emit(vid, victim_vid, motion)
|
||||
if n == null:
|
||||
return
|
||||
n.set_meta("last_motion", motion)
|
||||
n.set_meta("motion_victim_vid", victim_vid)
|
||||
var target: Node3D = _by_vid.get(victim_vid, null)
|
||||
if target and target != n:
|
||||
var flat := target.global_position
|
||||
flat.y = n.global_position.y
|
||||
if n.global_position.distance_squared_to(flat) > 0.001:
|
||||
n.look_at(flat, Vector3.UP)
|
||||
var target_race := -1
|
||||
if client:
|
||||
var target_entity: Dictionary = client.get_entity(victim_vid)
|
||||
if not target_entity.is_empty():
|
||||
target_race = int(target_entity.get("race", -1))
|
||||
var applied := false
|
||||
if n.has_method("set_motion_id"):
|
||||
applied = bool(n.call("set_motion_id", motion, target_race))
|
||||
if not applied:
|
||||
var state := "attack"
|
||||
if motion >= 305:
|
||||
state = "emotion"
|
||||
elif motion >= 1 and motion <= 3:
|
||||
state = "wait" if motion == 1 else ("walk" if motion == 2 else "run")
|
||||
elif motion == 5 or motion == 6 or motion == 8 or motion == 9:
|
||||
state = "damage"
|
||||
elif motion == 11 or motion == 12:
|
||||
state = "dead"
|
||||
if n.has_method("set_anim_state"):
|
||||
n.call("set_anim_state", state)
|
||||
else:
|
||||
n.set_meta("motion_state", state)
|
||||
if n.has_node("Label3D"):
|
||||
(n.get_node("Label3D") as Label3D).modulate = _state_tint(state)
|
||||
|
||||
# GC_PVP: 显示当前与任一角色的挑战 / 战斗 / 复仇关系。原客户端同时会影响
|
||||
# TargetBoard 和名字颜色;这里先提供始终可见的世界标签,避免关系状态无表现。
|
||||
func _on_pvp_changed(src_vid: int, dst_vid: int, mode: int) -> void:
|
||||
@@ -197,7 +276,8 @@ func _refresh_pvp_tag(vid: int) -> void:
|
||||
return
|
||||
var mode := _pvp_mode_for(vid)
|
||||
var tag := n.get_node_or_null("PvpTag") as Label3D
|
||||
if mode == 0:
|
||||
var in_duel := _duel_opponents.has(vid)
|
||||
if mode == 0 and not in_duel:
|
||||
if tag:
|
||||
tag.queue_free()
|
||||
return
|
||||
@@ -209,6 +289,10 @@ func _refresh_pvp_tag(vid: int) -> void:
|
||||
tag.no_depth_test = true
|
||||
tag.pixel_size = 0.0045
|
||||
n.add_child(tag)
|
||||
if in_duel:
|
||||
tag.text = "决斗"
|
||||
tag.modulate = Color(0.45, 0.85, 1.0)
|
||||
return
|
||||
match mode:
|
||||
1:
|
||||
tag.text = "挑战"
|
||||
@@ -223,6 +307,16 @@ func _refresh_pvp_tag(vid: int) -> void:
|
||||
tag.text = "PVP"
|
||||
tag.modulate = Color.WHITE
|
||||
|
||||
func _on_duel_changed(duel: Dictionary) -> void:
|
||||
_duel_opponents.clear()
|
||||
_duel_cannot_attack = bool(duel.get("cannot_attack", false))
|
||||
for vid in duel.get("opponents", []):
|
||||
var opponent := int(vid)
|
||||
if opponent != 0:
|
||||
_duel_opponents[opponent] = true
|
||||
for vid in _by_vid.keys():
|
||||
_refresh_pvp_tag(int(vid))
|
||||
|
||||
# 头顶聊天气泡(~4s 淡出)。本地玩家 vid 走 main_bubble 信号让上层处理。
|
||||
signal main_bubble(text: String)
|
||||
|
||||
|
||||
@@ -19,11 +19,14 @@ class FakeClient extends Node:
|
||||
signal vitals_changed(vid: int)
|
||||
signal entity_dead(vid: int)
|
||||
signal damage(vid: int, amount: int, flag: int)
|
||||
signal motion(vid: int, victim_vid: int, motion: int)
|
||||
signal pvp_changed(src_vid: int, dst_vid: int, mode: int)
|
||||
signal duel_changed(duel: Dictionary)
|
||||
var ents := {}
|
||||
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
|
||||
func get_entities() -> Array: return ents.values()
|
||||
func get_pvp_relations() -> Array: return []
|
||||
func get_duel() -> Dictionary: return {}
|
||||
func spawn(vid, nm, pos, is_main := false):
|
||||
ents[vid] = {"vid": vid, "name": nm, "pos": pos, "is_main": is_main,
|
||||
"func": 0, "moving": false, "angle_deg": 0.0,
|
||||
@@ -70,18 +73,32 @@ func _test_m2client_surface() -> void:
|
||||
return
|
||||
var c: Object = ClassDB.instantiate("M2Client")
|
||||
get_root().add_child(c)
|
||||
for m in ["move", "character_position", "sync_positions", "request_warp", "fishing", "request_dungeon", "attack", "set_target", "say", "shoot", "add_fly_targeting", "change_name", "download_guild_symbol", "get_guild_symbol", "script_select_item", "quest_cancel", "party_use_skill", "get_skill_group", "get_entity", "get_entities",
|
||||
"get_main_vid", "get_points", "get_target", "select_character", "reconnect",
|
||||
"suspend", "resume", "move_item", "use_item", "drop_item", "pickup_item",
|
||||
"get_inventory", "get_equipment", "get_item", "get_ground_items", "get_pvp_relations", "get_lover", "get_land_areas", "get_observers", "whisper"]:
|
||||
for m in ["move", "character_position", "sync_positions", "request_warp", "fishing", "request_dungeon", "attack", "set_target", "say", "shoot", "add_fly_targeting", "change_name", "select_empire", "download_guild_symbol", "get_guild_symbol", "script_select_item", "quest_cancel", "party_use_skill", "get_skill_group", "get_entity", "get_entities",
|
||||
"get_main_vid", "get_main_pid", "get_points", "get_target", "select_character", "reconnect",
|
||||
"suspend", "resume", "move_item", "use_item", "drop_item", "drop_item_count", "use_item_to_item", "give_item", "pickup_item",
|
||||
"get_inventory", "get_equipment", "get_belt_inventory", "get_view_equipment", "get_item", "get_ground_items", "get_pvp_relations", "get_duel", "get_lover", "get_land_areas", "get_observers", "is_observer_mode", "get_observer_count", "has_mobile_flag", "combo_skill_enabled", "get_stamina_state", "whisper",
|
||||
"party_invite", "party_answer", "party_leave", "party_set_distribute", "party_set_state", "get_party", "get_party_distribute_mode",
|
||||
"add_friend", "remove_friend", "friend_answer", "get_friends",
|
||||
"shop_buy", "shop_sell", "shop_close", "is_shop_open", "get_shop_items", "get_shop",
|
||||
"exchange_start", "exchange_add_item", "exchange_add_gold", "exchange_accept", "exchange_cancel", "get_exchange",
|
||||
"safebox_checkin", "safebox_checkout", "safebox_move", "is_safebox_open", "get_safebox_size", "get_safebox_gold", "get_safebox_items",
|
||||
"is_mall_open", "get_mall_size", "get_mall_items", "mall_checkout", "mall_password", "open_private_shop", "close_private_shop", "safebox_password",
|
||||
"get_cube", "cube_make", "cube_request_result_list", "cube_request_materials", "cube_open", "cube_close", "cube_list", "cube_add_item", "cube_delete_item",
|
||||
"get_guild", "get_guild_members", "get_guild_grades", "guild_add_member", "guild_remove_member", "guild_offer", "guild_charge_gsp", "guild_change_grade_name", "guild_change_grade_authority", "guild_change_member_grade", "guild_change_member_general", "guild_post_comment", "guild_delete_comment", "guild_refresh_comments", "guild_answer_invite", "guild_answer_make", "get_guild_comments", "get_guild_skill", "get_guild_wars", "get_guild_war", "get_guild_name", "use_guild_skill", "declare_guild_war",
|
||||
"download_guild_marks", "are_guild_marks_ready", "get_mark_server", "get_guild_mark", "get_guild_mark_image", "upload_guild_mark", "upload_guild_symbol",
|
||||
"refine", "ds_refine", "get_dragon_souls", "get_channel", "get_server_time", "get_npc_marks", "get_world_markers"]:
|
||||
_ck(c.has_method(m), "M2Client.%s bound" % m)
|
||||
for s in ["entity_spawned", "entity_despawned", "entity_moved", "entity_main_set",
|
||||
for s in ["entity_spawned", "entity_despawned", "entity_moved", "entity_main_set", "empire_changed",
|
||||
"chat", "suspended", "resumed", "vitals_changed", "entity_dead", "damage",
|
||||
"motion", "points_changed", "target_info", "inventory_changed",
|
||||
"ground_item_added", "ground_item_removed", "item_picked_up", "item_used", "whisper_received",
|
||||
"entity_info", "warp", "time_changed", "channel_changed", "npc_marks_changed",
|
||||
"land_areas_changed", "observer_event", "world_markers_changed", "mount_changed", "pvp_changed", "duel_started", "view_equipment", "lover_changed",
|
||||
"fly_targeting", "dig_motion", "fishing_event", "dungeon_event", "skill_group_changed", "guild_make_requested", "guild_symbol_ready"]:
|
||||
"land_areas_changed", "observer_event", "observer_mode_changed", "observer_count_changed", "stone_detected", "stamina_changed", "mobile_flag_changed", "mobile_auth_required", "combo_changed", "world_markers_changed", "mount_changed", "pvp_changed", "duel_changed", "duel_started", "view_equipment", "lover_changed",
|
||||
"fly_targeting", "dig_motion", "fishing_event", "dungeon_event", "skill_group_changed", "guild_make_requested", "guild_symbol_ready",
|
||||
"party_changed", "party_invite_ask", "party_request_denied", "friends_changed", "friend_invite_ask", "shop_opened", "shop_closed", "shop_error",
|
||||
"exchange_changed", "safebox_changed", "safebox_password_required", "safebox_wrong_password", "mall_opened", "mall_changed", "mall_password_required", "private_shop_open_requested", "my_shop_price_list", "block_mode_changed", "gift_available", "cube_opened", "cube_closed", "cube_changed", "cube_result",
|
||||
"guild_changed", "guild_skill_changed", "guild_war_changed", "guild_comments_changed", "guild_invite_ask", "guild_war_event", "guild_war_point",
|
||||
"guild_marks_ready", "guild_mark_uploaded", "guild_mark_updated", "refine_ask", "refine_result", "ds_window_open", "ds_refine_result"]:
|
||||
_ck(c.has_signal(s), "M2Client.%s signal" % s)
|
||||
# not in game -> intents refused, snapshot empty
|
||||
_ck(c.call("move", 1, 0, 90.0, 1000, 2000) == false, "move refused pre-game")
|
||||
@@ -162,6 +179,11 @@ func _test_netbridge() -> void:
|
||||
await process_frame
|
||||
_ck(not nw.node_for(2000).has_meta("fishing_item_vnum"),
|
||||
"fishing fish outcome is not treated as entity motion")
|
||||
fc.motion.emit(2000, 1000, 325) # CRaceMotionData::NAME_DANCE_1
|
||||
await process_frame
|
||||
_ck(int(nw.node_for(2000).get_meta("last_motion", 0)) == 325
|
||||
and int(nw.node_for(2000).get_meta("motion_victim_vid", 0)) == 1000,
|
||||
"GC_MOTION -> entity motion metadata and bridge signal")
|
||||
|
||||
# GC_CHAR_ADD_INFO late name -> 3D label refreshes (name_resolver optional)
|
||||
var mob0: Node3D = nw.node_for(2000)
|
||||
@@ -172,6 +194,15 @@ func _test_netbridge() -> void:
|
||||
await process_frame
|
||||
_ck((mob0.get_node("Label3D") as Label3D).text == "Angry Mob",
|
||||
"entity_info -> label refreshed via name_resolver")
|
||||
fc.ents[2000]["shop_sign"] = "Potion shop"
|
||||
fc.entity_info.emit(2000, fc.ents[2000])
|
||||
await process_frame
|
||||
var shop_sign := mob0.get_node_or_null("ShopSign") as Label3D
|
||||
_ck(shop_sign != null and shop_sign.text == "Potion shop", "entity_info -> shop sign label")
|
||||
fc.ents[2000]["shop_sign"] = ""
|
||||
fc.entity_info.emit(2000, fc.ents[2000])
|
||||
await process_frame
|
||||
_ck(mob0.get_node_or_null("ShopSign") == null, "empty shop sign removes label")
|
||||
fc.pvp_changed.emit(1000, 2000, 2)
|
||||
await process_frame
|
||||
var pvp_tag := mob0.get_node_or_null("PvpTag") as Label3D
|
||||
@@ -179,6 +210,13 @@ func _test_netbridge() -> void:
|
||||
fc.pvp_changed.emit(1000, 2000, 0)
|
||||
await process_frame
|
||||
_ck(mob0.get_node_or_null("PvpTag") == null, "pvp none -> removes world tag")
|
||||
fc.duel_changed.emit({"active": true, "cannot_attack": false, "opponents": [2000]})
|
||||
await process_frame
|
||||
var duel_tag := mob0.get_node_or_null("PvpTag") as Label3D
|
||||
_ck(duel_tag != null and duel_tag.text == "决斗", "duel opponent -> world duel tag")
|
||||
fc.duel_changed.emit({"active": false, "cannot_attack": true, "opponents": []})
|
||||
await process_frame
|
||||
_ck(mob0.get_node_or_null("PvpTag") == null, "duel cleared -> removes world tag")
|
||||
|
||||
fc.move_to(2000, Vector3(10, 0, 40))
|
||||
for i in 3: await process_frame
|
||||
|
||||
+22
-1
@@ -17,17 +17,19 @@ class FakeClient extends Node:
|
||||
signal vitals_changed(vid: int)
|
||||
signal points_changed(points: Dictionary)
|
||||
signal target_info(vid: int, hp_percent: int)
|
||||
signal observer_mode_changed(enabled: bool)
|
||||
signal chat(type: int, vid: int, text: String)
|
||||
var ents := {}
|
||||
var main := 0
|
||||
var in_game := true
|
||||
var calls := {"move": [], "attack": [], "set_target": []}
|
||||
var calls := {"move": [], "attack": [], "set_target": [], "click_npc": []}
|
||||
func is_in_game() -> bool: return in_game
|
||||
func get_main_vid() -> int: return main
|
||||
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
|
||||
func move(f, a, rot, x, y) -> bool: calls.move.append([f, a, rot, x, y]); return true
|
||||
func attack(motion, vid) -> bool: calls.attack.append([motion, vid]); return true
|
||||
func set_target(vid) -> bool: calls.set_target.append(vid); return true
|
||||
func click_npc(vid) -> bool: calls.click_npc.append(vid); return true
|
||||
|
||||
class FakePC extends Node:
|
||||
signal target_selected(node: Node3D)
|
||||
@@ -119,6 +121,15 @@ func _run() -> void:
|
||||
pc.target_selected.emit(mob)
|
||||
_ck(fc.calls.set_target.size() == 1 and fc.calls.set_target[0] == 2000, "set_target(2000)")
|
||||
|
||||
# NPC/传送点/石头走 click_npc,而不是被误当成攻击目标;这是 Cube
|
||||
# 窗口由服务端 cube_open 命令打开前的入口。
|
||||
var npc := Node3D.new()
|
||||
npc.set_meta("vid", 3000)
|
||||
root.add_child(npc)
|
||||
fc.ents[3000] = {"vid": 3000, "ch_type": 1, "name": "Cube NPC", "pos": npc.position}
|
||||
pc.target_selected.emit(npc)
|
||||
_ck(fc.calls.click_npc == [3000], "NPC selection -> click_npc(3000)")
|
||||
|
||||
# 5) 攻击距离内 -> 按 attack_period 自动 attack
|
||||
for i in 4: await process_frame
|
||||
_ck(fc.calls.attack.size() >= 1, "auto-attack fired, got %d" % fc.calls.attack.size())
|
||||
@@ -150,3 +161,13 @@ func _run() -> void:
|
||||
fc.ents[2000]["dead"] = true
|
||||
fc.entity_dead.emit(2000)
|
||||
_ck(hud.cleared >= 1, "target cleared on death")
|
||||
|
||||
# 10) 观战模式锁住本地移动/选目标,并清掉当前目标
|
||||
fc.ents[2000]["dead"] = false
|
||||
fc.observer_mode_changed.emit(true)
|
||||
pc.player.position = Vector3(8, 0, -5)
|
||||
pc.moved.emit(pc.player.position)
|
||||
pc.target_selected.emit(mob)
|
||||
_ck(fc.calls.move.size() == 2 and fc.calls.set_target.size() == 1,
|
||||
"observer mode blocks local movement and targeting")
|
||||
fc.observer_mode_changed.emit(false)
|
||||
|
||||
+41
-3
@@ -12,6 +12,7 @@ class FakeClient extends Node:
|
||||
signal party_changed()
|
||||
signal party_invite_ask(leader_pid: int)
|
||||
signal friends_changed()
|
||||
signal friend_invite_ask(name: String)
|
||||
signal shop_opened(vid: int)
|
||||
signal shop_closed()
|
||||
signal shop_error(kind: String)
|
||||
@@ -33,8 +34,10 @@ class FakeClient extends Node:
|
||||
|
||||
var main_vid := 20
|
||||
var distribute_mode := 0
|
||||
var leadership := 40
|
||||
func is_in_game() -> bool: return true
|
||||
func get_main_vid() -> int: return main_vid
|
||||
func get_skills() -> Array: return [{"id": 121, "level": leadership}]
|
||||
func get_party() -> Array: return party
|
||||
func get_party_distribute_mode() -> int: return distribute_mode
|
||||
func party_answer(pid, accept) -> bool: calls.append(["party_answer", pid, accept]); return true
|
||||
@@ -47,6 +50,7 @@ class FakeClient extends Node:
|
||||
func get_friends() -> Array: return friends
|
||||
func add_friend(nm) -> bool: calls.append(["add_friend", nm]); return true
|
||||
func remove_friend(nm) -> bool: calls.append(["remove_friend", nm]); return true
|
||||
func friend_answer(nm, accept) -> bool: calls.append(["friend_answer", nm, accept]); return true
|
||||
func get_shop_items() -> Array: return shop
|
||||
func get_shop() -> Dictionary:
|
||||
var tabs := shop_tabs if not shop_tabs.is_empty() else [{"name": "", "coin_type": 0, "items": shop}]
|
||||
@@ -117,7 +121,7 @@ func _run() -> void:
|
||||
var me_strip: Control = pu._list.get_child(0)
|
||||
var state_btn: Button = null
|
||||
for b in me_strip.find_children("*", "Button", true, false):
|
||||
if String(b.text) in ["队", "普", "攻", "坦", "辅", "宗", "狂", "防"]:
|
||||
if String(b.text) in ["队", "普", "攻", "坦", "辅", "宗", "速", "防"]:
|
||||
state_btn = b
|
||||
break
|
||||
_ck(state_btn != null and not state_btn.disabled, "party: leader's StateButton enabled")
|
||||
@@ -153,6 +157,26 @@ func _run() -> void:
|
||||
name_btn.pressed.emit()
|
||||
_ck(fc.calls.has(["set_target", 20]), "party: 点名字 -> set_target(vid)")
|
||||
|
||||
# 40250 CParty::Update 的 Leadership 门控:攻击至少 10,治疗至少 18。
|
||||
fc.party[0]["leader"] = true
|
||||
fc.leadership = 9
|
||||
fc.party_changed.emit()
|
||||
_ck(pu._heal_btn.disabled, "party: Leadership<18 disables heal")
|
||||
var gated_state: Button = null
|
||||
for b in pu._list.get_child(0).find_children("*", "Button", true, false):
|
||||
if String(b.text) == "队":
|
||||
gated_state = b
|
||||
break
|
||||
if gated_state:
|
||||
gated_state.pressed.emit()
|
||||
var gated_attack: Button = null
|
||||
for b in pu._role_popup.find_children("*", "Button", true, false):
|
||||
if String(b.text).begins_with("攻击"):
|
||||
gated_attack = b
|
||||
break
|
||||
_ck(gated_attack != null and gated_attack.disabled, "party: Leadership<10 disables attacker role")
|
||||
pu._dismiss_role_popup()
|
||||
|
||||
# 非队长 -> StateButton disabled
|
||||
fc.party[0]["leader"] = false
|
||||
fc.party[0]["state"] = 0
|
||||
@@ -169,9 +193,14 @@ func _run() -> void:
|
||||
fu.setup(fc, canvas)
|
||||
fu.toggle()
|
||||
_ck(fu.is_open(), "friend window opens")
|
||||
fc.friends = [{"name": "Alice", "online": true}, {"name": "Bob", "online": false}]
|
||||
fc.friends = [{"name": "Alice", "online": true, "mobile": true}, {"name": "Bob", "online": false}]
|
||||
fc.friends_changed.emit()
|
||||
await process_frame
|
||||
var alice_mobile := false
|
||||
for l in fu._list.find_children("*", "Label", true, false):
|
||||
if String(l.text) == "手机":
|
||||
alice_mobile = true
|
||||
_ck(alice_mobile, "friend: mobile state renders")
|
||||
var wt := []
|
||||
fu.whisper_to.connect(func(n): wt.append(n))
|
||||
# 找 Alice 行的名字按钮
|
||||
@@ -186,6 +215,15 @@ func _run() -> void:
|
||||
fu._name_edit.text = "Carol"
|
||||
fu._on_add()
|
||||
_ck(fc.calls.has(["add_friend", "Carol"]), "friend: add via input")
|
||||
fc.friend_invite_ask.emit("Dave")
|
||||
await process_frame
|
||||
_ck(fu._invite_dialog.visible and fu._invite_dialog.dialog_text.contains("Dave"),
|
||||
"friend: incoming request opens confirmation")
|
||||
fu._invite_dialog.confirmed.emit()
|
||||
_ck(fc.calls.has(["friend_answer", "Dave", true]), "friend: accept incoming request")
|
||||
fc.friend_invite_ask.emit("Eve")
|
||||
fu._invite_dialog.canceled.emit()
|
||||
_ck(fc.calls.has(["friend_answer", "Eve", false]), "friend: reject incoming request")
|
||||
|
||||
# --- 商店 ---
|
||||
var su: Node = ShopUI.new()
|
||||
@@ -256,7 +294,7 @@ func _run() -> void:
|
||||
_ck(xu._self_box.get_child_count() == 1, "exchange: 1 self item row")
|
||||
_ck(xu._root.get_node("PeerAccept").text == "对方: 已接受", "exchange: peer accept shown")
|
||||
xu.offer(1, 7)
|
||||
_ck(fc.calls.has(["ex_add_item", 1, 7, 0]), "exchange: offer item (display 0)")
|
||||
_ck(fc.calls.has(["ex_add_item", 1, 7, 1]), "exchange: offer item uses first free display slot (1)")
|
||||
xu._gold_input.text = "500"
|
||||
xu._on_put_gold()
|
||||
_ck(fc.calls.has(["ex_gold", 500]), "exchange: put gold")
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# player_motion_test —— 40250 GC_MOTION / ServerCommand emotion clip mapping。
|
||||
# godot --headless --path project --script player_motion_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
|
||||
var _fail := 0
|
||||
|
||||
func _ck(ok: bool, msg: String) -> void:
|
||||
if not ok:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + msg)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: player_motion_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2AnimPlayer"):
|
||||
print(" (skip: animation extension not registered)")
|
||||
return
|
||||
var assets := AssetRoot.path()
|
||||
if not DirAccess.dir_exists_absolute(assets.path_join("PC/ymir work/pc/warrior")):
|
||||
print(" (skip: PC assets unavailable)")
|
||||
return
|
||||
var pv: Node = PlayerView.new()
|
||||
get_root().add_child(pv)
|
||||
_ck(pv.build(assets, 0), "warrior PlayerView builds")
|
||||
if pv.anim == null:
|
||||
return
|
||||
_ck(pv.set_motion_id(305), "clap motion resolves")
|
||||
_ck(String(pv.anim.get("anim_path")).ends_with("action/clap.msa"),
|
||||
"clap -> action/clap.msa")
|
||||
_ck(pv.set_motion_id(308, 3), "kiss motion resolves with target job")
|
||||
_ck(String(pv.anim.get("anim_path")).ends_with("action/kiss_with_shaman.msa"),
|
||||
"kiss start + shaman target -> kiss_with_shaman.msa")
|
||||
_ck(pv.set_motion_id(320), "slap hurt motion resolves")
|
||||
_ck(String(pv.anim.get("anim_path")).ends_with("action/slap_hurt.msa"),
|
||||
"slap hurt -> action/slap_hurt.msa")
|
||||
pv.queue_free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://1mwx8xsric02
|
||||
@@ -67,6 +67,8 @@ func _run() -> void:
|
||||
_ck(pu._cells.size() == 40, "ItemSlot = 40-slot grid (5x8, shop.SHOP_SLOT_COUNT)")
|
||||
_ck(is_instance_valid(pu._sign), "NameLine overlaid with a LineEdit")
|
||||
_ck(pu._inv_list.get_child_count() == 2, "2 inventory candidates")
|
||||
_ck((pu._inv_list.get_child(0) as Button).has_method("_get_drag_data"),
|
||||
"inventory candidate supports drag data")
|
||||
|
||||
# 未上货 → 개설 무동작
|
||||
pu._ok()
|
||||
@@ -81,6 +83,10 @@ func _run() -> void:
|
||||
_ck(pu._stock.has(3) and int(pu._stock[3]["price"]) == 250000, "stock[3] placed")
|
||||
_ck(pu._picked == null, "pick cleared after place")
|
||||
_ck(pu._inv_list.get_child_count() == 1, "placed item leaves candidate list")
|
||||
# 标准 Godot 拖放入口与点击选取共用同一个落位逻辑。
|
||||
pu._drop_slot(Vector2.ZERO, {"private_shop_entry": {"cell": 5, "vnum": 27993, "count": 3}}, 6)
|
||||
_ck(is_instance_valid(pu._price_dialog), "dragging candidate onto empty slot opens price dialog")
|
||||
pu._place(6, 100)
|
||||
|
||||
# 拿起第 2 件(cell 5)→ 落到格 0(更小格号 → 排前)
|
||||
(pu._inv_list.get_child(0) as Button).pressed.emit()
|
||||
|
||||
@@ -27,6 +27,9 @@ class FakeClient extends Node:
|
||||
func cube_make(i) -> bool: calls.append(["cube_make", i]); return true
|
||||
func cube_request_result_list(n) -> bool: calls.append(["cube_rlist", n]); return true
|
||||
func cube_request_materials(a, b) -> bool: calls.append(["cube_mats", a, b]); return true
|
||||
func cube_add_item(a, b) -> bool: calls.append(["cube_add", a, b]); return true
|
||||
func cube_delete_item(a) -> bool: calls.append(["cube_del", a]); return true
|
||||
func cube_close() -> bool: calls.append(["cube_close"]); return true
|
||||
func get_inventory() -> Array: return inventory
|
||||
func open_private_shop(sign, items) -> bool: calls.append(["open_shop", sign, items]); return true
|
||||
func close_private_shop() -> bool: calls.append(["close_shop"]); return true
|
||||
@@ -85,17 +88,43 @@ func _run() -> void:
|
||||
fc.cube = {"open": true, "npc_vnum": 20383, "results": fc.cube["results"], "recipes": [
|
||||
{"result_vnum": 72723, "gold": 120000, "material_groups": [
|
||||
[{"vnum": 125, "count": 1}], [{"vnum": 555, "count": 5}]]}]}
|
||||
fc.inventory = [{"cell": 3, "vnum": 125, "count": 1}, {"cell": 4, "vnum": 555, "count": 5}]
|
||||
(recipe_btns[0] as Button).pressed.emit()
|
||||
await process_frame
|
||||
_ck(_has_call(fc, "cube_mats", func(c): return c[1] == 0), "cube: select → request materials(0)")
|
||||
var gold_lbl := _find(cu._mat, "Label", func(l): return String(l.text).contains("120000"))
|
||||
_ck(gold_lbl != null, "cube: materials panel shows gold 120000")
|
||||
_ck(not cu._make_btn.disabled, "cube: 制作 enabled after select")
|
||||
var bag_btn := _find(cu._mat, "Button", func(b): return String(b.text).begins_with("+ bag[3]"))
|
||||
_ck(bag_btn != null, "cube: inventory item offered for material slot")
|
||||
if bag_btn:
|
||||
bag_btn.pressed.emit()
|
||||
_ck(_has_call(fc, "cube_add", func(c): return c[1] == 0 and c[2] == 3),
|
||||
"cube: inventory item → cube_add_item(slot=0, cell=3)")
|
||||
_ck(int(cu._cube_slots.get(0, -1)) == 3, "cube: local material slot tracks inventory cell")
|
||||
var del_btn := _find(cu._mat, "Button", func(b): return String(b.text) == "삭제")
|
||||
_ck(del_btn != null, "cube: material slot has delete button")
|
||||
if del_btn:
|
||||
del_btn.pressed.emit()
|
||||
_ck(_has_call(fc, "cube_del", func(c): return c[1] == 0), "cube: delete button → cube_delete_item(slot=0)")
|
||||
_ck(cu._make_btn.disabled, "cube: 制作 disabled while required material is missing")
|
||||
# 重新放入两个配方材料后才允许制作。
|
||||
var bag_btn2 := _find(cu._mat, "Button", func(b): return String(b.text).begins_with("+ bag[3]"))
|
||||
if bag_btn2:
|
||||
bag_btn2.pressed.emit()
|
||||
var bag_btn3 := _find(cu._mat, "Button", func(b): return String(b.text).begins_with("+ bag[4]"))
|
||||
if bag_btn3:
|
||||
bag_btn3.pressed.emit()
|
||||
_ck(not cu._make_btn.disabled, "cube: 制作 enabled after all required materials are slotted")
|
||||
cu._make_btn.pressed.emit()
|
||||
_ck(_has_call(fc, "cube_make", func(c): return c[1] == 0), "cube: 制作 → cube_make(0)")
|
||||
fc.cube_result.emit(72723, 1, true)
|
||||
await process_frame
|
||||
_ck(String(cu._status.text).contains("성공") or String(cu._status.text) != "", "cube: result shown")
|
||||
var close_btn := _find(cu._root, "Button", func(b): return String(b.text) == "닫기")
|
||||
_ck(close_btn != null, "cube: close button present")
|
||||
if close_btn:
|
||||
close_btn.pressed.emit()
|
||||
_ck(_has_call(fc, "cube_close", func(_c): return true), "cube: close button → cube_close")
|
||||
fc.cube_closed.emit()
|
||||
await process_frame
|
||||
_ck(not cu.is_open(), "cube: hides on cube_closed")
|
||||
|
||||
@@ -34,7 +34,7 @@ func _init() -> void:
|
||||
quit(1)
|
||||
|
||||
func _cfg_path() -> String:
|
||||
return ProjectSettings.globalize_path("user://system_option.cfg")
|
||||
return ProjectSettings.globalize_path(GameOptionUI.config_path())
|
||||
|
||||
func _has_say(fc, text: String) -> bool:
|
||||
for s in fc.says:
|
||||
|
||||
@@ -32,7 +32,7 @@ func _init() -> void:
|
||||
quit(1)
|
||||
|
||||
func _cfg_path() -> String:
|
||||
return ProjectSettings.globalize_path("user://system_option.cfg")
|
||||
return ProjectSettings.globalize_path(SystemOptionUI.config_path())
|
||||
|
||||
func _run() -> void:
|
||||
var assets := AssetRoot.path()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 = "金币数量必须在 1~2000000000 之间"
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = "请输入 1~6 位密码"
|
||||
_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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 里是 text(Label)—— 盖一个 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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = "请输入 1~6 位密码"
|
||||
_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
@@ -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)
|
||||
|
||||
@@ -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()])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user