Metin2 game client (P0–P11) + mobile asset pipeline
Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
# AppFlow (P10) —— 登录 → 选人 → 进游戏 的串场控制器。
|
||||
#
|
||||
# 把它做场景根:一个 M2Client 贯穿全程,LoadingScreen / ReconnectUI 常驻。
|
||||
# var af := preload("res://app_flow.gd").new()
|
||||
# add_child(af)
|
||||
# af.start(assets_root) # assets_root 传给 game_scene
|
||||
#
|
||||
# 状态机:LOGIN → SELECT → GAME。M2Client 信号驱动切屏。
|
||||
# 断线:在 GAME 里保留场景交给 ReconnectUI 自动重连;在 LOGIN/SELECT 回登录。
|
||||
extends Node
|
||||
|
||||
const ServerInfoRes = preload("res://net/serverinfo.gd")
|
||||
const ChannelStatus = preload("res://net/channel_status.gd")
|
||||
const LoadingScreen = preload("res://ui/loading_screen.gd")
|
||||
const CharSelectScreen = preload("res://ui/char_select_screen.gd")
|
||||
const ReconnectUI = preload("res://ui/reconnect_ui.gd")
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
|
||||
enum { LOGIN, SELECT, GAME }
|
||||
|
||||
var client: Node
|
||||
var serverinfo: RefCounted
|
||||
var _state := LOGIN
|
||||
var _assets := ""
|
||||
var _ui: CanvasLayer # 登录 / 选人的临时界面层
|
||||
var _loading: CanvasLayer
|
||||
var _reconnect: CanvasLayer
|
||||
var _game: Node
|
||||
var build_game_scene := true # 测试里置 false,只跑状态机不建重场景
|
||||
var _sel_server := 0
|
||||
var _sel_channel := 1
|
||||
var _id := "admin"
|
||||
var _pw := "123456789"
|
||||
var _chars: Array = []
|
||||
var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec)
|
||||
# MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。
|
||||
var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1"
|
||||
|
||||
func start(assets_root: String = "", injected_client: Node = null) -> void:
|
||||
_assets = assets_root
|
||||
serverinfo = ServerInfoRes.new()
|
||||
serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置
|
||||
if injected_client != null:
|
||||
client = injected_client
|
||||
elif ClassDB.class_exists("M2Client"):
|
||||
client = ClassDB.instantiate("M2Client")
|
||||
if client == null:
|
||||
push_error("AppFlow: M2Client 未注册且未注入 client")
|
||||
return
|
||||
if client.get_parent() == null:
|
||||
add_child(client)
|
||||
_wire_client()
|
||||
_loading = LoadingScreen.new()
|
||||
add_child(_loading)
|
||||
_loading.setup(client)
|
||||
_reconnect = ReconnectUI.new()
|
||||
add_child(_reconnect)
|
||||
_reconnect.setup(client)
|
||||
_goto_login()
|
||||
if auto_login:
|
||||
call_deferred("_do_connect")
|
||||
|
||||
func state() -> int:
|
||||
return _state
|
||||
|
||||
# --- client 信号 -------------------------------------------------------------
|
||||
|
||||
func _wire_client() -> void:
|
||||
for sig in ["char_list", "entered_game", "disconnected", "login_failed"]:
|
||||
if client.has_signal(sig):
|
||||
client.connect(sig, Callable(self, "_on_" + sig))
|
||||
# 建 / 删号结果 —— 一次性接,转发给当前选人页
|
||||
for sig in ["char_created", "char_create_failed", "char_deleted", "char_delete_failed"]:
|
||||
if client.has_signal(sig):
|
||||
client.connect(sig, Callable(self, "_forward_char_evt").bind(sig))
|
||||
|
||||
func _on_char_list(list: Array) -> void:
|
||||
_chars = list
|
||||
# 已在选人页(建号 / 删号后 M2Client 会重发 char_list)——只刷数据,别整屏重建
|
||||
if _state == SELECT and _ui:
|
||||
var sc: Node = _ui.get_node_or_null("CharSelect")
|
||||
if sc and sc.has_method("set_chars"):
|
||||
sc.set_chars(list)
|
||||
return
|
||||
_goto_select()
|
||||
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_entered_game() -> void:
|
||||
_goto_game()
|
||||
_start_guild_mark_download()
|
||||
|
||||
# 会徽下载:serverlist 第 8 列配了 mark_port 才连(0 = 跳过)。
|
||||
# `guild_mark_updated` 时按服务器 1s 冷却重连一次。
|
||||
func _start_guild_mark_download() -> void:
|
||||
if client == null or not client.has_method("download_guild_marks"):
|
||||
return
|
||||
var addr: Dictionary = serverinfo.address(_sel_server, _sel_channel)
|
||||
var mp: int = int(addr.get("mark_port", 0))
|
||||
if mp <= 0:
|
||||
return
|
||||
client.download_guild_marks(String(addr.get("mark_host", addr["game_host"])), mp)
|
||||
if client.has_signal("guild_mark_updated") \
|
||||
and not client.guild_mark_updated.is_connected(_on_guild_mark_updated):
|
||||
client.guild_mark_updated.connect(_on_guild_mark_updated)
|
||||
|
||||
func _on_guild_mark_updated(_guild_id: int, _img_idx: int) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
if now < _mark_redl_after:
|
||||
return
|
||||
_mark_redl_after = now + 1000
|
||||
_start_guild_mark_download()
|
||||
|
||||
func _on_login_failed(reason: String) -> void:
|
||||
if _state != GAME:
|
||||
_goto_login()
|
||||
_set_status("登录失败:%s" % reason)
|
||||
|
||||
func _on_disconnected(_reason: String) -> void:
|
||||
# GAME 里:保留 game_scene,ReconnectUI 负责重连遮罩
|
||||
# LOGIN/SELECT 里:回登录
|
||||
if _state != GAME:
|
||||
_goto_login()
|
||||
|
||||
# --- 切屏 ------------------------------------------------------------------
|
||||
|
||||
func _clear_ui() -> void:
|
||||
if _ui:
|
||||
_ui.queue_free()
|
||||
_ui = null
|
||||
|
||||
func _goto_login() -> void:
|
||||
_state = LOGIN
|
||||
if _game:
|
||||
_game.queue_free()
|
||||
_game = null
|
||||
if _loading:
|
||||
_loading.hide_screen()
|
||||
_clear_ui()
|
||||
_ui = CanvasLayer.new()
|
||||
_ui.layer = 5
|
||||
add_child(_ui)
|
||||
_build_login_form()
|
||||
|
||||
func _goto_select() -> void:
|
||||
_state = SELECT
|
||||
if _loading:
|
||||
_loading.hide_screen() # 选人界面要露出来,别被读取遮罩盖住
|
||||
_clear_ui()
|
||||
_ui = CanvasLayer.new()
|
||||
_ui.layer = 5
|
||||
add_child(_ui)
|
||||
_build_char_list()
|
||||
_start_guild_mark_download() # 选人页也想显示帮会徽
|
||||
|
||||
func _goto_game() -> void:
|
||||
_state = GAME
|
||||
_clear_ui()
|
||||
if _game == null and build_game_scene:
|
||||
if _loading:
|
||||
_loading.show_for("载入地图…") # 场景分帧组装期间挡一下
|
||||
_game = GameScene.new()
|
||||
add_child(_game)
|
||||
if _game.has_method("setup"):
|
||||
await _game.setup(client, _assets) # 协程:分帧,不阻塞 socket
|
||||
if _loading:
|
||||
_loading.hide_screen()
|
||||
|
||||
# --- 登录表单 ------------------------------------------------------------
|
||||
|
||||
func _build_login_form() -> void:
|
||||
var panel := Panel.new()
|
||||
panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
panel.position = Vector2(-180, -140)
|
||||
panel.custom_minimum_size = Vector2(360, 280)
|
||||
panel.size = Vector2(360, 280)
|
||||
_ui.add_child(panel)
|
||||
var box := VBoxContainer.new()
|
||||
box.position = Vector2(20, 18)
|
||||
box.custom_minimum_size = Vector2(320, 0)
|
||||
box.add_theme_constant_override("separation", 8)
|
||||
panel.add_child(box)
|
||||
var title := Label.new()
|
||||
title.text = "登录"
|
||||
title.add_theme_font_size_override("font_size", 18)
|
||||
box.add_child(title)
|
||||
|
||||
var srv := OptionButton.new()
|
||||
for s in serverinfo.servers():
|
||||
srv.add_item(String(s.get("name", "?")))
|
||||
srv.selected = 0
|
||||
srv.item_selected.connect(func(i): _sel_server = i; _refresh_channels())
|
||||
box.add_child(srv)
|
||||
|
||||
var ch := OptionButton.new()
|
||||
ch.name = "ChannelOpt"
|
||||
box.add_child(ch)
|
||||
ch.item_selected.connect(func(i):
|
||||
var chans: Array = serverinfo.server(_sel_server).get("channels", [1])
|
||||
_sel_channel = int(chans[i]) if i < chans.size() else 1)
|
||||
|
||||
var id_edit := LineEdit.new()
|
||||
id_edit.text = _id
|
||||
id_edit.placeholder_text = "账号"
|
||||
id_edit.text_changed.connect(func(t): _id = t)
|
||||
box.add_child(id_edit)
|
||||
var pw_edit := LineEdit.new()
|
||||
pw_edit.text = _pw
|
||||
pw_edit.secret = true
|
||||
pw_edit.placeholder_text = "密码"
|
||||
pw_edit.text_changed.connect(func(t): _pw = t)
|
||||
box.add_child(pw_edit)
|
||||
|
||||
var btnrow := HBoxContainer.new()
|
||||
box.add_child(btnrow)
|
||||
var connect_btn := Button.new()
|
||||
connect_btn.text = "连接"
|
||||
connect_btn.pressed.connect(_do_connect)
|
||||
btnrow.add_child(connect_btn)
|
||||
var probe_btn := Button.new()
|
||||
probe_btn.text = "检测频道"
|
||||
probe_btn.pressed.connect(_probe_channels)
|
||||
btnrow.add_child(probe_btn)
|
||||
var status := Label.new()
|
||||
status.name = "Status"
|
||||
status.add_theme_font_size_override("font_size", 11)
|
||||
box.add_child(status)
|
||||
_refresh_channels()
|
||||
|
||||
func _refresh_channels() -> void:
|
||||
if _ui == null:
|
||||
return
|
||||
var ch: OptionButton = _ui.find_child("ChannelOpt", true, false)
|
||||
if ch == null:
|
||||
return
|
||||
ch.clear()
|
||||
var chans: Array = serverinfo.server(_sel_server).get("channels", [1])
|
||||
for c in chans:
|
||||
ch.add_item("CH %d" % int(c))
|
||||
ch.selected = 0
|
||||
_sel_channel = int(chans[0]) if not chans.is_empty() else 1
|
||||
|
||||
# 频道负载 —— 先问 CServerStateChecker(一个连接拿全部频道状态:正常/拥挤/爆满/关闭),
|
||||
# 没答上的频道回退纯 TCP 连通性(● 通 / ○ 不通)。
|
||||
func _probe_channels() -> void:
|
||||
var s: Dictionary = serverinfo.server(_sel_server)
|
||||
if s.is_empty():
|
||||
return
|
||||
var chans: Array = s.get("channels", [1])
|
||||
var ch_opt: OptionButton = _ui.find_child("ChannelOpt", true, false)
|
||||
_set_status("检测中…")
|
||||
|
||||
# 1) 状态查询(连第一个频道端口,服务器回全部频道的 status_by_port)
|
||||
var addr0: Dictionary = serverinfo.address(_sel_server, int(chans[0]))
|
||||
var cs: Node = ChannelStatus.new()
|
||||
add_child(cs)
|
||||
var status_map: Dictionary = {}
|
||||
cs.done.connect(func(m): status_map = m)
|
||||
cs.query(addr0["game_host"], int(addr0["game_port"]), Callable(), 3.0)
|
||||
while cs.is_processing():
|
||||
await get_tree().process_frame
|
||||
cs.queue_free()
|
||||
|
||||
# 2) 逐频道标注
|
||||
for i in chans.size():
|
||||
var addr: Dictionary = serverinfo.address(_sel_server, int(chans[i]))
|
||||
var port := int(addr["game_port"])
|
||||
var label := ""
|
||||
if status_map.has(port):
|
||||
var st := int(status_map[port])
|
||||
label = "%s CH %d" % [["○", "●", "◐", "✕"][clampi(st, 0, 3)], int(chans[i])]
|
||||
else:
|
||||
var up: bool = await _probe_tcp(addr["game_host"], port, 2.0)
|
||||
label = "%s CH %d" % ["●" if up else "○", int(chans[i])]
|
||||
if ch_opt and i < ch_opt.item_count:
|
||||
ch_opt.set_item_text(i, label)
|
||||
_set_status("检测完成")
|
||||
|
||||
func _probe_tcp(host: String, port: int, timeout_s: float) -> bool:
|
||||
var peer := StreamPeerTCP.new()
|
||||
if peer.connect_to_host(host, port) != OK:
|
||||
return false
|
||||
var t := 0.0
|
||||
while t < timeout_s:
|
||||
peer.poll()
|
||||
match peer.get_status():
|
||||
StreamPeerTCP.STATUS_CONNECTED:
|
||||
peer.disconnect_from_host()
|
||||
return true
|
||||
StreamPeerTCP.STATUS_ERROR:
|
||||
return false
|
||||
if is_inside_tree():
|
||||
await get_tree().process_frame
|
||||
t += 0.016
|
||||
return false
|
||||
|
||||
func _set_status(text: String) -> void:
|
||||
if _ui:
|
||||
var s: Label = _ui.find_child("Status", true, false)
|
||||
if s:
|
||||
s.text = text
|
||||
|
||||
func _do_connect() -> void:
|
||||
var addr: Dictionary = serverinfo.address(_sel_server, _sel_channel)
|
||||
if addr.is_empty():
|
||||
_set_status("没有服务器")
|
||||
return
|
||||
_set_status("连接 %s:%d …" % [addr["game_host"], addr["game_port"]])
|
||||
if client.has_method("connect_to_server"):
|
||||
client.connect_to_server(addr["auth_host"], addr["auth_port"],
|
||||
addr["game_host"], addr["game_port"], _id, _pw)
|
||||
|
||||
# --- 选人列表 ------------------------------------------------------------
|
||||
|
||||
func _build_char_list() -> void:
|
||||
var screen := CharSelectScreen.new()
|
||||
screen.name = "CharSelect"
|
||||
_ui.add_child(screen)
|
||||
screen.setup(client, _assets, _chars)
|
||||
screen.select_requested.connect(func(idx: int):
|
||||
if client.has_method("select_character"):
|
||||
client.select_character(idx))
|
||||
screen.back_requested.connect(func():
|
||||
if client.has_method("disconnect_from_server"):
|
||||
client.disconnect_from_server()
|
||||
_goto_login())
|
||||
screen.create_requested.connect(func(spec: Dictionary):
|
||||
if client.has_method("create_character"):
|
||||
client.create_character(int(spec.get("slot", 0)), String(spec.get("name", "")),
|
||||
int(spec.get("job", 0)), int(spec.get("shape", 0)),
|
||||
int(spec.get("con", 0)), int(spec.get("int", 0)),
|
||||
int(spec.get("str", 0)), int(spec.get("dex", 0))))
|
||||
screen.delete_requested.connect(func(idx: int, code: String):
|
||||
if client.has_method("delete_character"):
|
||||
client.delete_character(idx, code))
|
||||
if client.has_signal("guild_marks_ready"):
|
||||
client.guild_marks_ready.connect(_on_marks_ready_for_select, CONNECT_ONE_SHOT)
|
||||
|
||||
func _forward_char_evt(a = null, sig := "") -> void:
|
||||
# 兼容 0/1 参数的信号:char_delete_failed 无参,其它带一个 int
|
||||
if typeof(a) == TYPE_STRING and sig == "":
|
||||
sig = a
|
||||
a = null
|
||||
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
|
||||
if sc == null:
|
||||
return
|
||||
match sig:
|
||||
"char_created": sc.on_char_created(int(a))
|
||||
"char_create_failed": sc.on_char_create_failed(int(a))
|
||||
"char_deleted": sc.on_char_deleted(int(a))
|
||||
"char_delete_failed": sc.on_char_delete_failed()
|
||||
|
||||
func _on_marks_ready_for_select() -> void:
|
||||
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui else null
|
||||
if sc and sc.has_method("refresh_crest"):
|
||||
sc.refresh_crest()
|
||||
@@ -0,0 +1 @@
|
||||
uid://prh3okf1n2lk
|
||||
@@ -0,0 +1,85 @@
|
||||
# AppLifecycle (F5) —— 移动端应用生命周期集中处理。
|
||||
#
|
||||
# 把 SceneTree 转发来的 MainLoop 通知收成信号,并做默认动作:后台时暂停游戏
|
||||
# 逻辑 / 降帧 / 停 BGM,恢复时还原;iOS 内存告警时广播让各缓存自清。渲染上下文
|
||||
# 丢失(Android Vulkan surface)由 Godot 自己重建,我们持有的 GPU 资源都由
|
||||
# RenderingServer 托管,无需干预。
|
||||
#
|
||||
# 用法:
|
||||
# var life := preload("res://app_lifecycle.gd").new()
|
||||
# add_child(life) # 越早越好(autoload 亦可)
|
||||
# life.bind(m2client, audio) # 可选:自动接常见消费方
|
||||
# life.paused.connect(_on_bg); life.resumed.connect(_on_fg)
|
||||
extends Node
|
||||
|
||||
signal paused # 进入后台(NOTIFICATION_APPLICATION_PAUSED)
|
||||
signal resumed # 回到前台
|
||||
signal focus_changed(focused: bool)
|
||||
signal memory_warning # iOS 内存压力
|
||||
signal back_requested # Android 返回键
|
||||
signal close_requested # 窗口关闭请求(桌面)
|
||||
|
||||
## 后台时是否 get_tree().paused = true(默认开;纯观战/录像可关)
|
||||
var pause_tree_on_background := true
|
||||
## 后台时把 max_fps 压到这个值省电(0 = 不改)
|
||||
var background_max_fps := 8
|
||||
|
||||
var _bound_client: Node
|
||||
var _bound_audio: Node
|
||||
var _saved_max_fps := 0
|
||||
var _bg := false
|
||||
|
||||
func _ready() -> void:
|
||||
# 通知在暂停树时也要能收到
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
|
||||
func bind(m2client: Node = null, audio: Node = null) -> void:
|
||||
_bound_client = m2client
|
||||
_bound_audio = audio
|
||||
|
||||
func is_backgrounded() -> bool:
|
||||
return _bg
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
match what:
|
||||
NOTIFICATION_APPLICATION_PAUSED:
|
||||
_enter_background()
|
||||
NOTIFICATION_APPLICATION_RESUMED:
|
||||
_exit_background()
|
||||
NOTIFICATION_APPLICATION_FOCUS_IN, NOTIFICATION_WM_WINDOW_FOCUS_IN:
|
||||
focus_changed.emit(true)
|
||||
NOTIFICATION_APPLICATION_FOCUS_OUT, NOTIFICATION_WM_WINDOW_FOCUS_OUT:
|
||||
focus_changed.emit(false)
|
||||
NOTIFICATION_OS_MEMORY_WARNING:
|
||||
memory_warning.emit()
|
||||
NOTIFICATION_WM_GO_BACK_REQUEST:
|
||||
back_requested.emit()
|
||||
NOTIFICATION_WM_CLOSE_REQUEST:
|
||||
close_requested.emit()
|
||||
|
||||
func _enter_background() -> void:
|
||||
if _bg:
|
||||
return
|
||||
_bg = true
|
||||
if background_max_fps > 0:
|
||||
_saved_max_fps = Engine.max_fps
|
||||
Engine.max_fps = background_max_fps
|
||||
if _bound_audio and _bound_audio.has_method("stop_bgm"):
|
||||
_bound_audio.stop_bgm()
|
||||
if _bound_client and _bound_client.has_method("suspend"):
|
||||
_bound_client.suspend()
|
||||
if pause_tree_on_background:
|
||||
get_tree().paused = true
|
||||
paused.emit()
|
||||
|
||||
func _exit_background() -> void:
|
||||
if not _bg:
|
||||
return
|
||||
_bg = false
|
||||
if pause_tree_on_background:
|
||||
get_tree().paused = false
|
||||
if background_max_fps > 0:
|
||||
Engine.max_fps = _saved_max_fps
|
||||
if _bound_client and _bound_client.has_method("resume"):
|
||||
_bound_client.resume()
|
||||
resumed.emit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwgs1qcu80qfm
|
||||
@@ -0,0 +1,60 @@
|
||||
# AssetPack —— 把 Metin2 资源(assets/ + bgm/ + asset_index.txt 打成的 assets.zip)
|
||||
# 挂载到 res://,供 AssetRoot / C++ 的 FileAccess 读取。
|
||||
#
|
||||
# 桌面开发:仓库旁的散 assets/ 直接可用 -> ensure() 什么都不做。
|
||||
# 移动端:APK/IPA 里没有资源;assets.zip 由外部提供:
|
||||
# Android —— adb push build/export/assets.zip /sdcard/Android/data/<pkg>/files/
|
||||
# iOS —— ios-deploy --upload assets.zip --to Documents/
|
||||
# 找到就 ProjectSettings.load_resource_pack() 挂到 res://。
|
||||
#
|
||||
# 候选路径:MT_ASSETS_ZIP 环境变量 -> user://assets.zip ->
|
||||
# <可执行文件目录>/assets.zip -> res://../assets.zip(开发)
|
||||
#
|
||||
# FUTURE(下载版):_find_local_zip() 失败时,改为 HTTPRequest 从 ASSET_PACK_URL
|
||||
# 下载到 user://assets.zip(断点续传 + sha256 校验 + 进度 UI),再走同一个 mount。
|
||||
class_name AssetPack
|
||||
extends RefCounted
|
||||
|
||||
const ZIP_NAME := "assets.zip"
|
||||
# const ASSET_PACK_URL := "" # FUTURE: 下载版填这里
|
||||
|
||||
static var mounted := false
|
||||
static var last_source := ""
|
||||
|
||||
# 返回 true = 资源可用(散文件 或 已挂载 zip)。同步;本地文件场景不阻塞。
|
||||
# force_zip=true:即使散 assets/ 可用也去挂 zip(测试 / 验证移动端路径用)。
|
||||
static func ensure(force_zip := false) -> bool:
|
||||
if mounted:
|
||||
return true
|
||||
if not force_zip and AssetRoot.available(): # 散 assets/(桌面开发)
|
||||
return true
|
||||
var zip := _find_local_zip()
|
||||
if zip == "":
|
||||
# FUTURE: 这里改成下载 ASSET_PACK_URL -> user://assets.zip -> 继续
|
||||
push_warning("[AssetPack] 找不到 %s(Android: adb push 到 files/;iOS: 上传到 Documents/)" % ZIP_NAME)
|
||||
return false
|
||||
if ProjectSettings.load_resource_pack(zip, false):
|
||||
mounted = true
|
||||
last_source = zip
|
||||
print("[AssetPack] mounted ", zip)
|
||||
return true
|
||||
push_warning("[AssetPack] load_resource_pack 失败: " + zip)
|
||||
return false
|
||||
|
||||
static func _find_local_zip() -> String:
|
||||
var cands: Array[String] = []
|
||||
var env := OS.get_environment("MT_ASSETS_ZIP")
|
||||
if env != "":
|
||||
cands.append(env)
|
||||
cands.append("user://" + ZIP_NAME)
|
||||
var data_dir := OS.get_user_data_dir()
|
||||
if data_dir != "":
|
||||
cands.append(data_dir.path_join(ZIP_NAME))
|
||||
var exe := OS.get_executable_path()
|
||||
if exe != "":
|
||||
cands.append(exe.get_base_dir().path_join(ZIP_NAME))
|
||||
cands.append("res://../" + ZIP_NAME) # 开发:仓库根旁
|
||||
for c in cands:
|
||||
if FileAccess.file_exists(c):
|
||||
return c
|
||||
return ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://f260rrsoxa0p
|
||||
@@ -0,0 +1,47 @@
|
||||
# AssetRoot —— Metin2 资源根目录的唯一解析点。
|
||||
#
|
||||
# 资源(`.gr2` / `.dds` / `locale` / `OutdoorA1` / `uiscript` …)不进版本库。
|
||||
# 解析顺序:
|
||||
# 1. 环境变量 MT_ASSETS(绝对路径)
|
||||
# 2. res://../assets —— 开发树(仓库根的 assets/)
|
||||
# 3. <可执行文件>/../Resources/assets —— 打包进 .app 内
|
||||
# 4. <.app 同级>/assets —— .app 旁边放一份
|
||||
# 都没有就返回 res://../assets(调用方自行 warn)。
|
||||
#
|
||||
# var root := AssetRoot.path()
|
||||
# var il := AssetRoot.sub("locale/locale/common/item_list.txt")
|
||||
# if AssetRoot.available(): ...
|
||||
class_name AssetRoot
|
||||
extends RefCounted
|
||||
|
||||
static func _candidates() -> Array:
|
||||
var out: Array = []
|
||||
var env := OS.get_environment("MT_ASSETS")
|
||||
if env != "":
|
||||
out.append(env)
|
||||
# 挂载的 assets.zip(AssetPack)把资源放到 res://assets —— 移动端主路径。
|
||||
# 用 asset_index.txt 作为「pack 已挂载」信号(空的 project/assets/ 占位目录不算)。
|
||||
if FileAccess.file_exists("res://assets/asset_index.txt"):
|
||||
out.append("res://assets")
|
||||
out.append(ProjectSettings.globalize_path("res://../assets"))
|
||||
var exe := OS.get_executable_path()
|
||||
if exe != "":
|
||||
var exe_dir := exe.get_base_dir() # .../mtgodot-poc.app/Contents/MacOS
|
||||
out.append(exe_dir.path_join("../Resources/assets").simplify_path())
|
||||
out.append(exe_dir.path_join("../../../assets").simplify_path()) # 与 .app 同级
|
||||
out.append(exe_dir.path_join("assets")) # 与裸可执行文件同级
|
||||
return out
|
||||
|
||||
static func path() -> String:
|
||||
for c in _candidates():
|
||||
if DirAccess.dir_exists_absolute(c):
|
||||
return c
|
||||
return ProjectSettings.globalize_path("res://../assets")
|
||||
|
||||
# 资源根下的子路径(用 path_join,跨平台)。
|
||||
static func sub(rel: String) -> String:
|
||||
return path().path_join(rel)
|
||||
|
||||
# 资源目录是否真的存在(缺资源的测试据此跳过)。
|
||||
static func available() -> bool:
|
||||
return DirAccess.dir_exists_absolute(path())
|
||||
@@ -0,0 +1 @@
|
||||
uid://debiskaw74tdw
|
||||
@@ -0,0 +1,76 @@
|
||||
# atlas_test —— Atlas 大地图窗口的无资源 headless 自检。
|
||||
# godot --headless --path project --script atlas_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const AtlasUI = preload("res://ui/atlas_ui.gd")
|
||||
const UiManager = preload("res://ui/ui_manager.gd")
|
||||
|
||||
class FakeWorld extends Node:
|
||||
func get_map_size_tiles() -> Vector2i: return Vector2i(2, 2)
|
||||
func chunk_dir(_x: int, _y: int) -> String: return "/no-such-chunk"
|
||||
func load_dds(_path: String): return null
|
||||
|
||||
class FakeClient extends Node:
|
||||
var entities := {9: {"vid": 9, "pos": Vector3(2, 0, -2)}}
|
||||
func get_party() -> Array: return [{"vid": 9, "leader": true}]
|
||||
func get_entity(vid: int) -> Dictionary: return entities.get(vid, {})
|
||||
func get_land_areas() -> Array:
|
||||
return [{"x": 0, "y": 0, "width": 5000, "height": 5000, "guild_id": 7}]
|
||||
func get_observers() -> Array:
|
||||
return [{"vid": 9, "pos": Vector3(2, 0, -2)}]
|
||||
func get_world_markers() -> Array:
|
||||
return [{"pos": Vector3(-2, 0, 2)}]
|
||||
|
||||
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: atlas_test (window + drag + coordinate query)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var world := FakeWorld.new()
|
||||
get_root().add_child(world)
|
||||
var ui: CanvasLayer = UiManager.new()
|
||||
get_root().add_child(ui)
|
||||
await process_frame
|
||||
var player := Node3D.new()
|
||||
player.position = Vector3(128, 0, 256)
|
||||
get_root().add_child(player)
|
||||
var client := FakeClient.new()
|
||||
get_root().add_child(client)
|
||||
var atlas: Node = AtlasUI.new()
|
||||
get_root().add_child(atlas)
|
||||
atlas.setup(world, ui, func() -> Node3D: return player, "test_map", client)
|
||||
atlas.open()
|
||||
_ck(atlas.is_open(), "atlas opens")
|
||||
_ck(atlas._map != null and atlas._map.size == Vector2(256, 256), "atlas builds map texture")
|
||||
_ck(String(atlas._coord.text).contains("X 12800") and String(atlas._coord.text).contains("Y 25600"),
|
||||
"atlas reports player server-centimetre coordinates")
|
||||
atlas._overlay.queue_redraw()
|
||||
_ck(atlas._overlay.is_inside_tree(), "atlas world marker overlay is live")
|
||||
var old_pos: Vector2 = atlas._map.position
|
||||
var down := InputEventMouseButton.new()
|
||||
down.button_index = MOUSE_BUTTON_LEFT
|
||||
down.pressed = true
|
||||
down.position = Vector2(100, 100)
|
||||
atlas._on_map_input(down)
|
||||
var drag := InputEventMouseMotion.new()
|
||||
drag.position = Vector2(140, 120)
|
||||
atlas._on_map_input(drag)
|
||||
_ck(atlas._map.position != old_pos, "atlas left drag moves map")
|
||||
var reset := InputEventMouseButton.new()
|
||||
reset.button_index = MOUSE_BUTTON_RIGHT
|
||||
reset.pressed = true
|
||||
atlas._on_map_input(reset)
|
||||
_ck(atlas._map.position == atlas._map_origin, "atlas right click recenters map")
|
||||
atlas.close()
|
||||
_ck(not atlas.is_open(), "atlas closes")
|
||||
@@ -0,0 +1 @@
|
||||
uid://k51m5hekg71a
|
||||
@@ -0,0 +1,163 @@
|
||||
# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。
|
||||
# 对标客户端 AudioLib:BGM(`bgm/*.mp3`)、UI wav(`**/sound/ui/`)、
|
||||
# 3D wav(`**/sound/<rel>`)。纯资产接线,无解码器移植(`.mss` 不用)。
|
||||
# 用 preload("res://audio.gd").new() 造一个,add_child,再 setup()。
|
||||
extends Node
|
||||
|
||||
var _assets: String
|
||||
var _bgm_root: String
|
||||
var _sound_roots: PackedStringArray = []
|
||||
var _cache := {} # 绝对路径 -> AudioStream
|
||||
|
||||
var _bgm := [null, null] # 两个 AudioStreamPlayer 交叉淡
|
||||
var _bgm_cur := 0
|
||||
var _bgm_name := ""
|
||||
var _ui_pool: Array[AudioStreamPlayer] = []
|
||||
var _ui_next := 0
|
||||
|
||||
var master_bgm := 0.6
|
||||
var master_sfx := 0.9
|
||||
var _queued_bgm := ""
|
||||
|
||||
func _ready() -> void:
|
||||
if _queued_bgm != "":
|
||||
var n := _queued_bgm
|
||||
_queued_bgm = ""
|
||||
_bgm_name = ""
|
||||
play_bgm(n)
|
||||
|
||||
func setup(assets_root: String) -> void:
|
||||
_assets = assets_root
|
||||
# bgm/ 与 assets/ 同级(<repo>/bgm,见 AssetRoot)
|
||||
_bgm_root = _assets.get_base_dir().path_join("bgm")
|
||||
# 已知声音根 + 各 patch 的 sound/
|
||||
for r in ["Sound/sound", "sound2/sound", "sound_m/sound"]:
|
||||
var p := _assets.path_join(r)
|
||||
if DirAccess.dir_exists_absolute(p):
|
||||
_sound_roots.append(p)
|
||||
var da := DirAccess.open(_assets)
|
||||
if da:
|
||||
for sub in da.get_directories():
|
||||
var p := _assets.path_join(sub).path_join("sound")
|
||||
if DirAccess.dir_exists_absolute(p):
|
||||
_sound_roots.append(p)
|
||||
|
||||
for i in 2:
|
||||
var pl := AudioStreamPlayer.new()
|
||||
pl.bus = "Master"
|
||||
add_child(pl)
|
||||
_bgm[i] = pl
|
||||
for i in 8:
|
||||
var pl := AudioStreamPlayer.new()
|
||||
add_child(pl)
|
||||
_ui_pool.append(pl)
|
||||
|
||||
# --- resolution -----------------------------------------------------------
|
||||
|
||||
func _load_stream(path: String) -> AudioStream:
|
||||
if _cache.has(path):
|
||||
return _cache[path]
|
||||
var s: AudioStream = null
|
||||
var ext := path.get_extension().to_lower()
|
||||
if ext == "mp3":
|
||||
var m := AudioStreamMP3.new()
|
||||
m.data = FileAccess.get_file_as_bytes(path)
|
||||
s = m if m.data.size() > 0 else null
|
||||
elif ext == "ogg":
|
||||
s = AudioStreamOggVorbis.load_from_file(path)
|
||||
elif ext == "wav":
|
||||
s = AudioStreamWAV.load_from_file(path)
|
||||
_cache[path] = s
|
||||
return s
|
||||
|
||||
func _find_sound(rel: String) -> String:
|
||||
rel = rel.replace("\\", "/")
|
||||
var cands := PackedStringArray([rel])
|
||||
if rel.get_extension().is_empty():
|
||||
cands = PackedStringArray([rel + ".wav", rel + ".ogg"])
|
||||
for root in _sound_roots:
|
||||
for c in cands:
|
||||
var p := root.path_join(c)
|
||||
if FileAccess.file_exists(p):
|
||||
return p
|
||||
# also try under sound/ui/ and sound/common/ for bare names
|
||||
if not rel.contains("/"):
|
||||
for d in ["ui", "common"]:
|
||||
for c in cands:
|
||||
var p := root.path_join(d).path_join(c)
|
||||
if FileAccess.file_exists(p):
|
||||
return p
|
||||
return ""
|
||||
|
||||
# --- BGM ---------------------------------------------------------------
|
||||
|
||||
func play_bgm(name: String, fade := 1.2) -> void:
|
||||
if name == _bgm_name:
|
||||
return
|
||||
if not is_node_ready() or not is_inside_tree():
|
||||
_queued_bgm = name # flushed in _ready()
|
||||
return
|
||||
_bgm_name = name
|
||||
var path := name
|
||||
if path.get_extension().is_empty():
|
||||
path = _bgm_root.path_join(name + ".mp3")
|
||||
elif not path.is_absolute_path():
|
||||
path = _bgm_root.path_join(name)
|
||||
var stream := _load_stream(path)
|
||||
if stream == null:
|
||||
push_warning("[audio] bgm not found: " + path)
|
||||
return
|
||||
if stream is AudioStreamMP3:
|
||||
stream.loop = true
|
||||
var nxt := 1 - _bgm_cur
|
||||
_bgm[nxt].stream = stream
|
||||
_bgm[nxt].volume_db = -40.0
|
||||
_bgm[nxt].play()
|
||||
_fade(_bgm[nxt], linear_to_db(master_bgm), fade)
|
||||
_fade(_bgm[_bgm_cur], -40.0, fade, true)
|
||||
_bgm_cur = nxt
|
||||
|
||||
func stop_bgm(fade := 1.0) -> void:
|
||||
_bgm_name = ""
|
||||
_fade(_bgm[_bgm_cur], -40.0, fade, true)
|
||||
|
||||
func _fade(pl: AudioStreamPlayer, to_db: float, dur: float, stop_after := false) -> void:
|
||||
var tw := create_tween()
|
||||
tw.tween_property(pl, "volume_db", to_db, dur)
|
||||
if stop_after:
|
||||
tw.tween_callback(pl.stop)
|
||||
|
||||
# --- SFX -------------------------------------------------------------------
|
||||
|
||||
func play_ui(name: String) -> void:
|
||||
var path := _find_sound(name if name.contains("/") else "ui/" + name)
|
||||
if path.is_empty():
|
||||
path = _find_sound(name)
|
||||
if path.is_empty():
|
||||
push_warning("[audio] ui sfx not found: " + name)
|
||||
return
|
||||
var s := _load_stream(path)
|
||||
if s == null:
|
||||
return
|
||||
var pl := _ui_pool[_ui_next]
|
||||
_ui_next = (_ui_next + 1) % _ui_pool.size()
|
||||
pl.stream = s
|
||||
pl.volume_db = linear_to_db(master_sfx)
|
||||
pl.play()
|
||||
|
||||
func play_at(rel: String, world_pos: Vector3, parent: Node = null) -> void:
|
||||
var path := _find_sound(rel)
|
||||
if path.is_empty():
|
||||
return
|
||||
var s := _load_stream(path)
|
||||
if s == null:
|
||||
return
|
||||
var pl := AudioStreamPlayer3D.new()
|
||||
pl.stream = s
|
||||
pl.unit_size = 6.0
|
||||
pl.max_distance = 60.0
|
||||
pl.volume_db = linear_to_db(master_sfx)
|
||||
(parent if parent else self).add_child(pl)
|
||||
pl.global_position = world_pos
|
||||
pl.play()
|
||||
pl.finished.connect(pl.queue_free)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ke6yfuy7eum2
|
||||
@@ -0,0 +1,22 @@
|
||||
# bake_asset_index —— 构建期跑一次:扫 assets_root 建 AssetResolver 索引,
|
||||
# 写到 <assets_root>/asset_index.txt。打进 PCK 后移动端 build_or_load() 直接装载。
|
||||
# godot --headless --path project --script bake_asset_index.gd
|
||||
extends SceneTree
|
||||
|
||||
func _init() -> void:
|
||||
var root := AssetRoot.path()
|
||||
if not AssetRoot.available():
|
||||
printerr("no assets at ", root)
|
||||
quit(1)
|
||||
return
|
||||
var w = ClassDB.instantiate("Metin2World")
|
||||
w.set("assets_root", root)
|
||||
var out := root.path_join("asset_index.txt")
|
||||
var ok: bool = w.call("bake_asset_index", out)
|
||||
if ok:
|
||||
var sz := FileAccess.get_file_as_bytes(out).size()
|
||||
print("PASS: baked asset_index.txt (%d bytes) at %s" % [sz, out])
|
||||
quit(0)
|
||||
else:
|
||||
printerr("bake failed")
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyvg0v8ntldha
|
||||
@@ -8,3 +8,29 @@ reloadable = true
|
||||
|
||||
macos.debug = "res://bin/libmtgodot.macos.template_debug.dylib"
|
||||
macos.release = "res://bin/libmtgodot.macos.template_release.dylib"
|
||||
|
||||
ios.debug = "res://bin/libmtgodot.ios.template_debug.a"
|
||||
ios.release = "res://bin/libmtgodot.ios.template_release.a"
|
||||
|
||||
android.debug.arm64 = "res://bin/libmtgodot.android.template_debug.arm64.so"
|
||||
android.release.arm64 = "res://bin/libmtgodot.android.template_release.arm64.so"
|
||||
|
||||
[dependencies]
|
||||
|
||||
; iOS can't dlopen: the extension archive above is linked into the app at export
|
||||
; time, and it needs every static lib it was built against on the link line.
|
||||
; `./build-ios.sh` stages these into project/bin/ios/. See docs/PLATFORMS.md (F1).
|
||||
ios.debug = {
|
||||
"res://bin/ios/libgodot-cpp.ios.template_debug.arm64.a" : "",
|
||||
"res://bin/ios/liblibgr2.a" : "",
|
||||
"res://bin/ios/libxr_formats.a" : "",
|
||||
"res://bin/ios/libmtnet.a" : "",
|
||||
"res://bin/ios/libsodium.a" : ""
|
||||
}
|
||||
ios.release = {
|
||||
"res://bin/ios/libgodot-cpp.ios.template_release.arm64.a" : "",
|
||||
"res://bin/ios/liblibgr2.a" : "",
|
||||
"res://bin/ios/libxr_formats.a" : "",
|
||||
"res://bin/ios/libmtnet.a" : "",
|
||||
"res://bin/ios/libsodium.a" : ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# channel_status_test —— 频道负载查询(ChannelStatus)headless 自检。
|
||||
# godot --headless --path project --script channel_status_test.gd
|
||||
# 起一个进程内 TCP mock 状态服务器,验证请求字节 + 响应解析。
|
||||
extends SceneTree
|
||||
|
||||
const ChannelStatus = preload("res://net/channel_status.gd")
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: channel_status_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var srv := TCPServer.new()
|
||||
var port := 0
|
||||
for p in range(52000, 52050):
|
||||
if srv.listen(p) == OK:
|
||||
port = p
|
||||
break
|
||||
_ck(port != 0, "mock server listening")
|
||||
|
||||
var got_req := [PackedByteArray()]
|
||||
var host_node := Node.new()
|
||||
get_root().add_child(host_node)
|
||||
|
||||
var cs: Node = ChannelStatus.new()
|
||||
get_root().add_child(cs)
|
||||
var result := [{}]
|
||||
cs.done.connect(func(m): result[0] = m)
|
||||
cs.query("127.0.0.1", port, Callable(), 3.0)
|
||||
|
||||
# mock 服务器:等连接 → 收请求 → 回 RESPOND_CHANNELSTATUS
|
||||
var conn: StreamPeerTCP = null
|
||||
var replied := false
|
||||
for i in 300:
|
||||
if srv.is_connection_available() and conn == null:
|
||||
conn = srv.take_connection()
|
||||
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]
|
||||
if not got_req[0].is_empty() and not replied:
|
||||
# 先塞一个无关包(header 0x0007 len 6)测试跳过逻辑
|
||||
var noise := PackedByteArray(); noise.resize(6)
|
||||
noise.encode_u16(0, 0x0007); noise.encode_u16(2, 6)
|
||||
conn.put_data(noise)
|
||||
# 再回 RESPOND_CHANNELSTATUS: [0x0010][len=4][int32 count=2][{i16 port,u8 st}×2]
|
||||
var body := PackedByteArray(); body.resize(4 + 4 + 3 + 3)
|
||||
body.encode_u16(0, 0x0010); body.encode_u16(2, 4)
|
||||
body.encode_s32(4, 2)
|
||||
body.encode_s16(8, 13011); body.encode_u8(10, 1) # CH1 正常
|
||||
body.encode_s16(11, 13021); body.encode_u8(13, 3) # CH2 爆满
|
||||
conn.put_data(body)
|
||||
replied = true
|
||||
if not result[0].is_empty():
|
||||
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")
|
||||
_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) == "正常", "状态文案")
|
||||
|
||||
# 连不上的端口 -> 空 map,不卡死
|
||||
var cs2: Node = ChannelStatus.new()
|
||||
get_root().add_child(cs2)
|
||||
var r2 := [{"x": 1}]
|
||||
cs2.query("127.0.0.1", 1, func(m): r2[0] = m, 1.0)
|
||||
for i in 120:
|
||||
if not cs2.is_processing():
|
||||
break
|
||||
await process_frame
|
||||
_ck(r2[0].is_empty(), "不可达 -> 空 map")
|
||||
|
||||
srv.stop()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bo1041j7pwnra
|
||||
@@ -0,0 +1,139 @@
|
||||
# char_create_delete_test —— 选人页建号 / 删号 UI + M2Client 封包对接 headless 自检。
|
||||
# godot --headless --path project --script char_create_delete_test.gd
|
||||
# 用假 client 记录 create_character / delete_character 调用;不需要资产 / 服务器。
|
||||
extends SceneTree
|
||||
|
||||
const CharSelectScreen = preload("res://ui/char_select_screen.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal char_created(slot: int)
|
||||
signal char_create_failed(reason_type: int)
|
||||
signal char_deleted(slot: int)
|
||||
signal char_delete_failed()
|
||||
var creates: Array = []
|
||||
var deletes: Array = []
|
||||
var slot_count := 4
|
||||
func get_slot_count() -> int: return slot_count
|
||||
func get_empire() -> int: return 1
|
||||
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,
|
||||
"con": con, "int": i, "str": s, "dex": d})
|
||||
return true
|
||||
func delete_character(slot, code) -> bool:
|
||||
deletes.append({"slot": slot, "code": code})
|
||||
return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: char_create_delete_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
# 有弹窗时只在弹窗子树里找,避免撞到背后面板上的同名按钮
|
||||
func _scope(s: Node) -> Node:
|
||||
var d = s._dialog_node()
|
||||
return d if d != null else s
|
||||
|
||||
func _find(node: Node, cls: String, pred := Callable()) -> Node:
|
||||
for n in node.find_children("*", cls, true, false):
|
||||
if not pred.is_valid() or pred.call(n):
|
||||
return n
|
||||
return null
|
||||
|
||||
func _btn(root: Node, text: String) -> Button:
|
||||
return _find(_scope(root), "Button", func(b): return String(b.text) == text)
|
||||
|
||||
func _run() -> void:
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
|
||||
var s: Control = CharSelectScreen.new()
|
||||
s.name = "CharSelect"
|
||||
get_root().add_child(s)
|
||||
# 1 个已有角色(槽 0),slot_count=4 -> 补 3 个空槽
|
||||
s.setup(fc, "", [{"index": 0, "name": "Hero", "job": 0, "level": 30,
|
||||
"guild_id": 0, "guild_name": "", "play_minutes": 90,
|
||||
"st": 10, "ht": 9, "dx": 8, "iq": 7}])
|
||||
# app_flow 平时做的接线(这里手接)
|
||||
s.create_requested.connect(func(spec: Dictionary):
|
||||
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))
|
||||
await process_frame
|
||||
|
||||
_ck(s._chars.size() == 4, "padded to 4 slots, got %d" % s._chars.size())
|
||||
_ck(s._first_occupied() == 0, "first occupied slot = 0")
|
||||
|
||||
# --- 建号:走到空槽 -> 开创建弹窗 -> 填名 -> 点“创建” ---
|
||||
s._select(1) # 槽 1(空)
|
||||
await process_frame
|
||||
s._open_create_dialog()
|
||||
await process_frame
|
||||
var name_edit := _find(s, "LineEdit", func(n): return n.name == "Name") as LineEdit
|
||||
_ck(name_edit != null, "create dialog has Name field")
|
||||
var job_opt := _find(s, "OptionButton", func(n): return n.name == "Job") as OptionButton
|
||||
_ck(job_opt != null and job_opt.get_selected_id() == 1, "job defaults to slot index (assassin)")
|
||||
name_edit.text = "Newbie"
|
||||
var ok := _btn(s, "创建")
|
||||
_ck(ok != null, "create dialog has 创建 button")
|
||||
ok.pressed.emit()
|
||||
await process_frame
|
||||
_ck(fc.creates.size() == 1, "create_character called once")
|
||||
if fc.creates.size() == 1:
|
||||
var c: Dictionary = fc.creates[0]
|
||||
_ck(c["slot"] == 1 and c["name"] == "Newbie" and c["job"] == 1, "create args: slot/name/job")
|
||||
# assassin 基础四维 con3 int3 str4 dex6
|
||||
_ck(c["con"] == 3 and c["int"] == 3 and c["str"] == 4 and c["dex"] == 6,
|
||||
"create sends assassin base stats (got %s)" % c)
|
||||
|
||||
# 太短的名字不发
|
||||
s._open_create_dialog()
|
||||
await process_frame
|
||||
(_find(s, "LineEdit", func(n): return n.name == "Name") as LineEdit).text = "x"
|
||||
_btn(s, "创建").pressed.emit()
|
||||
await process_frame
|
||||
_ck(fc.creates.size() == 1, "short name rejected client-side")
|
||||
|
||||
# 服务器回 char_created -> 弹窗关闭
|
||||
fc.char_created.emit(1)
|
||||
# app_flow 正常会转发;这里直接调
|
||||
s.on_char_created(1)
|
||||
await process_frame
|
||||
_ck(s._dialog_node() == null, "dialog closed on char_created")
|
||||
|
||||
# --- 删号:选已有角色 -> 删除弹窗 -> 输码 -> 点“删除” ---
|
||||
s._select(0)
|
||||
await process_frame
|
||||
s._open_delete_dialog()
|
||||
await process_frame
|
||||
var code := _find(s, "LineEdit", func(n): return n.name == "Code") as LineEdit
|
||||
_ck(code != null and code.secret, "delete dialog has secret Code field")
|
||||
code.text = "1234567"
|
||||
_btn(s, "删除").pressed.emit()
|
||||
await process_frame
|
||||
_ck(fc.deletes.size() == 1 and fc.deletes[0]["slot"] == 0 and fc.deletes[0]["code"] == "1234567",
|
||||
"delete_character(0, '1234567')")
|
||||
|
||||
# 服务器拒绝 -> 清空输入框
|
||||
s.on_char_delete_failed()
|
||||
await process_frame
|
||||
var code2 := _find(s, "LineEdit", func(n): return n.name == "Code") as LineEdit
|
||||
_ck(code2 == null or code2.text == "", "delete code cleared on failure")
|
||||
|
||||
# set_chars 保留当前选中槽
|
||||
s._select(0)
|
||||
s.set_chars([{"index": 0, "name": "Hero", "job": 0, "level": 31}])
|
||||
await process_frame
|
||||
_ck(s._slot_index() == 0, "set_chars keeps selection on slot 0")
|
||||
|
||||
s.queue_free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b43adptpq1ua4
|
||||
@@ -0,0 +1,152 @@
|
||||
# char_status_ui_test —— P11 角色状态窗 headless 自检(假 client + 真 uiscript)。
|
||||
# godot --headless --path project --script char_status_ui_test.gd
|
||||
# 校验 characterwindow.py 装载、RefreshStatus 数值绑定(逐字对照 uicharacter.py)、
|
||||
# /stat 加点命令、页签切换。缺 uiscript 资产时优雅跳过。
|
||||
extends SceneTree
|
||||
|
||||
const UiManager = preload("res://ui/ui_manager.gd")
|
||||
const CharStatusUI = preload("res://ui/char_status_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal points_changed(points: Dictionary)
|
||||
signal entity_main_set(vid: int)
|
||||
var pts: Array = []
|
||||
var says: Array = []
|
||||
var _main_vid := 7
|
||||
func _init() -> void:
|
||||
pts.resize(255)
|
||||
pts.fill(0)
|
||||
pts[1] = 42 # LEVEL
|
||||
pts[3] = 123456 # EXP
|
||||
pts[4] = 200000 # NEXT_EXP
|
||||
pts[5] = 850 # HP
|
||||
pts[6] = 900 # MAX_HP
|
||||
pts[7] = 120 # SP
|
||||
pts[8] = 150 # MAX_SP
|
||||
pts[12] = 33 # ST
|
||||
pts[13] = 27 # HT
|
||||
pts[14] = 18 # DX
|
||||
pts[15] = 12 # IQ
|
||||
pts[16] = 250 # ATT_POWER
|
||||
pts[17] = 145 # ATT_SPEED
|
||||
pts[18] = 40 # EVADE_RATE
|
||||
pts[19] = 165 # MOV_SPEED
|
||||
pts[20] = 88 # DEF_GRADE
|
||||
pts[21] = 100 # CASTING_SPEED
|
||||
pts[22] = 55 # MAGIC_ATT_GRADE
|
||||
pts[23] = 44 # MAGIC_DEF_GRADE
|
||||
pts[26] = 5 # STAT (남은 포인트)
|
||||
pts[29] = 210 # MIN_ATK
|
||||
pts[30] = 210 # MAX_ATK
|
||||
pts[95] = 15 # ATT_GRADE_BONUS
|
||||
func get_main_vid() -> int: return _main_vid
|
||||
func get_points() -> Dictionary:
|
||||
return {
|
||||
"points": pts, "hp": pts[5], "max_hp": pts[6], "sp": pts[7], "max_sp": pts[8],
|
||||
"level": pts[1], "exp": pts[3], "next_exp": pts[4], "gold": 0,
|
||||
}
|
||||
func get_entity(vid: int) -> Dictionary:
|
||||
if vid != _main_vid:
|
||||
return {}
|
||||
return {"name": "TestHero", "guild": 9, "race": 5} # race 5 -> job 1 (assassin)
|
||||
func get_guild_name(gid: int) -> String:
|
||||
return "銀河聯盟" if gid == 9 else ""
|
||||
func say(type: int, text: String) -> bool:
|
||||
says.append([type, text]); return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: char_status_ui_test (layout + RefreshStatus + /stat + tabs)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _txt(cs: Node, nm: String) -> String:
|
||||
var n: Control = cs._node(nm)
|
||||
return String(n.text) if n and ("text" in n) else "<missing:%s>" % nm
|
||||
|
||||
func _run() -> void:
|
||||
var assets := AssetRoot.path()
|
||||
if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/characterwindow.py")):
|
||||
print(" (skip: no characterwindow.py — assets checkout missing)")
|
||||
return
|
||||
|
||||
var ui: CanvasLayer = UiManager.new()
|
||||
get_root().add_child(ui)
|
||||
ui._ready()
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
|
||||
var cs: Node = CharStatusUI.new()
|
||||
get_root().add_child(cs)
|
||||
cs.setup(ui, fc, assets)
|
||||
cs.open()
|
||||
_ck(cs.is_open(), "character window opened")
|
||||
if not cs.is_open():
|
||||
return
|
||||
|
||||
# --- RefreshStatus 数值绑定 ---
|
||||
_ck(_txt(cs, "Level_Value") == "42", "Level_Value = 42 (got %s)" % _txt(cs, "Level_Value"))
|
||||
_ck(_txt(cs, "Exp_Value") == "123456", "Exp_Value = 123456 (got %s)" % _txt(cs, "Exp_Value"))
|
||||
_ck(_txt(cs, "RestExp_Value") == "76544", "RestExp_Value = next-exp (got %s)" % _txt(cs, "RestExp_Value"))
|
||||
_ck(_txt(cs, "HP_Value") == "850/900", "HP_Value = 850/900 (got %s)" % _txt(cs, "HP_Value"))
|
||||
_ck(_txt(cs, "SP_Value") == "120/150", "SP_Value = 120/150 (got %s)" % _txt(cs, "SP_Value"))
|
||||
_ck(_txt(cs, "STR_Value") == "33", "STR_Value = 33 (got %s)" % _txt(cs, "STR_Value"))
|
||||
_ck(_txt(cs, "DEX_Value") == "18", "DEX_Value = 18 (got %s)" % _txt(cs, "DEX_Value"))
|
||||
_ck(_txt(cs, "HTH_Value") == "27", "HTH_Value = 27 (got %s)" % _txt(cs, "HTH_Value"))
|
||||
_ck(_txt(cs, "INT_Value") == "12", "INT_Value = 12 (got %s)" % _txt(cs, "INT_Value"))
|
||||
# ATT: min==max==210, bonus = 95(15) + 91(0) -> 225
|
||||
_ck(_txt(cs, "ATT_Value") == "225", "ATT_Value = 210+15 (got %s)" % _txt(cs, "ATT_Value"))
|
||||
_ck(_txt(cs, "DEF_Value") == "88", "DEF_Value = 88 (got %s)" % _txt(cs, "DEF_Value"))
|
||||
_ck(_txt(cs, "MATT_Value") == "55", "MATT_Value = 55 (no magic-wep) (got %s)" % _txt(cs, "MATT_Value"))
|
||||
_ck(_txt(cs, "MDEF_Value") == "44", "MDEF_Value = 44 (got %s)" % _txt(cs, "MDEF_Value"))
|
||||
_ck(_txt(cs, "ASPD_Value") == "145", "ASPD_Value = 145 (got %s)" % _txt(cs, "ASPD_Value"))
|
||||
_ck(_txt(cs, "MSPD_Value") == "165", "MSPD_Value = 165 (got %s)" % _txt(cs, "MSPD_Value"))
|
||||
_ck(_txt(cs, "CSPD_Value") == "100", "CSPD_Value = 100 (got %s)" % _txt(cs, "CSPD_Value"))
|
||||
_ck(_txt(cs, "ER_Value") == "40", "ER_Value = 40 (got %s)" % _txt(cs, "ER_Value"))
|
||||
_ck(_txt(cs, "Status_Plus_Value") == "5", "Status_Plus_Value = 5 (got %s)" % _txt(cs, "Status_Plus_Value"))
|
||||
|
||||
# --- identity ---
|
||||
_ck(_txt(cs, "Character_Name") == "TestHero", "Character_Name = TestHero (got %s)" % _txt(cs, "Character_Name"))
|
||||
_ck(_txt(cs, "Guild_Name") == "銀河聯盟", "Guild_Name resolved (got %s)" % _txt(cs, "Guild_Name"))
|
||||
|
||||
# --- +/- 按钮:STAT>0 时可见,命令与 uicharacter.statusPlusCommandDict 一致 ---
|
||||
var hth_plus: Control = cs._node("HTH_Plus")
|
||||
_ck(hth_plus != null and hth_plus.visible, "HTH_Plus visible while STAT>0")
|
||||
if hth_plus is BaseButton:
|
||||
hth_plus.pressed.emit()
|
||||
_ck(fc.says.size() == 1 and fc.says[0][0] == 0 and fc.says[0][1] == "/stat ht",
|
||||
"HTH_Plus -> say(0, '/stat ht') (got %s)" % str(fc.says))
|
||||
var int_minus: Control = cs._node("INT_Minus")
|
||||
if int_minus is BaseButton:
|
||||
int_minus.pressed.emit()
|
||||
_ck(fc.says.size() == 2 and fc.says[1][1] == "/stat- iq",
|
||||
"INT_Minus -> say(0, '/stat- iq') (got %s)" % str(fc.says))
|
||||
|
||||
# --- STAT == 0 -> 加点按钮隐藏 + 刷新走 points_changed ---
|
||||
fc.pts[26] = 0
|
||||
fc.points_changed.emit(fc.get_points())
|
||||
_ck(_txt(cs, "Status_Plus_Value") == "0", "Status_Plus_Value = 0 after change")
|
||||
var hth_plus2: Control = cs._node("HTH_Plus")
|
||||
_ck(hth_plus2 != null and not hth_plus2.visible, "HTH_Plus hidden while STAT==0")
|
||||
|
||||
# --- 页签切换(uicharacter.SetState) ---
|
||||
var char_page: Control = cs._node("Character_Page")
|
||||
var skill_page: Control = cs._node("Skill_Page")
|
||||
_ck(char_page != null and char_page.visible, "STATUS page visible by default")
|
||||
cs._set_state("SKILL")
|
||||
_ck(char_page != null and not char_page.visible, "Character_Page hidden after SetState(SKILL)")
|
||||
_ck(skill_page != null and skill_page.visible, "Skill_Page visible after SetState(SKILL)")
|
||||
cs._set_state("STATUS")
|
||||
_ck(char_page.visible, "Character_Page visible again after SetState(STATUS)")
|
||||
|
||||
cs.close()
|
||||
_ck(not cs.is_open(), "character window closed")
|
||||
@@ -0,0 +1 @@
|
||||
uid://berxs4vw0s7vd
|
||||
@@ -0,0 +1,240 @@
|
||||
# 选人界面 —— 3 个旋转台角色 + 九宫格信息面板 + 选择/创建按钮。
|
||||
# 数据来自 M2Client.char_list(联机)或 --mock 合成列表(离线开发)。
|
||||
# 选中 -> M2Client.select_character(index) -> entered_game。
|
||||
# 用法(离线预览):godot --path project --script charselect.gd --mock --quit-after 120
|
||||
extends SceneTree
|
||||
|
||||
const UiKit = preload("res://ui_kit.gd")
|
||||
const Audio = preload("res://audio.gd")
|
||||
const Locale = preload("res://locale.gd")
|
||||
const CLASS_DIR := ["warrior", "assassin", "sura", "shaman"]
|
||||
const CLASS_KEY := ["JOB_WARRIOR", "JOB_ASSASSIN", "JOB_SURA", "JOB_SHAMAN"]
|
||||
|
||||
var _audio: Node
|
||||
var _loc: RefCounted
|
||||
|
||||
var _assets: String
|
||||
var _client: Node # M2Client(联机时)
|
||||
var _slots: Array[Dictionary] = [] # {index,name,job,level,x,y}
|
||||
var _pedestals: Array[Node3D] = []
|
||||
var _models: Array[Node3D] = []
|
||||
var _sel := 0
|
||||
var _t := 0.0
|
||||
var _panel: Control
|
||||
var _status: Label
|
||||
var _mock := false
|
||||
|
||||
func _init() -> void:
|
||||
_assets = AssetRoot.path()
|
||||
_mock = "--mock" in OS.get_cmdline_user_args() or "--mock" in OS.get_cmdline_args()
|
||||
|
||||
_loc = Locale.new()
|
||||
_loc.setup(_assets, "en")
|
||||
|
||||
_audio = Audio.new()
|
||||
get_root().add_child(_audio)
|
||||
_audio.setup(_assets)
|
||||
_audio.play_bgm("characterselect")
|
||||
|
||||
_build_stage()
|
||||
_build_ui()
|
||||
|
||||
if _mock:
|
||||
_slots = [
|
||||
{"index": 0, "name": "Baalthazar", "job": 0, "level": 42, "x": 0, "y": 0},
|
||||
{"index": 1, "name": "Nyx", "job": 1, "level": 7, "x": 0, "y": 0},
|
||||
{"index": 2, "name": "", "job": 0, "level": 0, "x": 0, "y": 0}, # 空槽
|
||||
]
|
||||
_populate()
|
||||
else:
|
||||
if ClassDB.class_exists("M2Client"):
|
||||
_client = ClassDB.instantiate("M2Client")
|
||||
get_root().add_child(_client)
|
||||
_client.connect("char_list", _on_char_list)
|
||||
_client.connect("stage_changed", func(s): _set_status("stage: " + s))
|
||||
_client.connect("login_failed", func(r): _set_status("login failed: " + r))
|
||||
_client.connect("entered_game", func(): _set_status("entered game"))
|
||||
var host := "192.168.21.203"
|
||||
_client.call("connect_to_server", host, 11000, host, 11011, "admin", "123456789")
|
||||
_set_status("connecting to " + host + " ...")
|
||||
else:
|
||||
_set_status("M2Client 未注册 —— 用 --mock")
|
||||
|
||||
# --- 3D 舞台 ---------------------------------------------------------------
|
||||
|
||||
func _build_stage() -> void:
|
||||
var root := Node3D.new()
|
||||
root.name = "Stage"
|
||||
get_root().add_child(root)
|
||||
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-35, 20, 0)
|
||||
sun.light_energy = 2.4
|
||||
root.add_child(sun)
|
||||
var fill := DirectionalLight3D.new() # 正面补光
|
||||
fill.rotation_degrees = Vector3(-10, 180, 0)
|
||||
fill.light_energy = 0.9
|
||||
fill.light_color = Color(0.8, 0.85, 1.0)
|
||||
root.add_child(fill)
|
||||
var we := WorldEnvironment.new()
|
||||
var e := Environment.new()
|
||||
e.background_mode = Environment.BG_COLOR
|
||||
e.background_color = Color(0.10, 0.11, 0.14)
|
||||
e.ambient_light_color = Color(0.5, 0.52, 0.62)
|
||||
e.ambient_light_energy = 1.1
|
||||
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||
we.environment = e
|
||||
root.add_child(we)
|
||||
|
||||
# 三个台座,绕中心排成弧
|
||||
for i in 3:
|
||||
var a := deg_to_rad(-28.0 + i * 28.0)
|
||||
var ped := Node3D.new()
|
||||
ped.name = "Pedestal%d" % i
|
||||
ped.position = Vector3(sin(a) * 2.3, 0.0, -cos(a) * 2.3 + 2.3)
|
||||
root.add_child(ped)
|
||||
var disc := MeshInstance3D.new()
|
||||
var cyl := CylinderMesh.new()
|
||||
cyl.top_radius = 0.62
|
||||
cyl.bottom_radius = 0.7
|
||||
cyl.height = 0.14
|
||||
disc.mesh = cyl
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = Color(0.18, 0.19, 0.24)
|
||||
m.metallic = 0.5
|
||||
m.roughness = 0.4
|
||||
disc.material_override = m
|
||||
disc.position.y = 0.07
|
||||
ped.add_child(disc)
|
||||
_pedestals.append(ped)
|
||||
_models.append(null)
|
||||
|
||||
var cam := Camera3D.new()
|
||||
cam.name = "Cam"
|
||||
root.add_child(cam)
|
||||
cam.look_at_from_position(Vector3(0.0, 1.5, 5.4), Vector3(0.0, 1.15, 1.8), Vector3.UP)
|
||||
cam.fov = 44.0
|
||||
cam.make_current()
|
||||
|
||||
func _slot_model(job: int) -> Node3D:
|
||||
var cls: String = CLASS_DIR[clampi(job, 0, 3)]
|
||||
var m: Node3D = ClassDB.instantiate("Metin2Model")
|
||||
m.name = "Model"
|
||||
m.set("texture_dir", _assets.path_join("PC/ymir work/pc/" + cls))
|
||||
m.set("gr2_path", _assets.path_join("PC/ymir work/pc/" + cls + "/" + cls + "_novice.gr2"))
|
||||
if ClassDB.class_exists("Metin2AnimPlayer"):
|
||||
var anim: Node = ClassDB.instantiate("Metin2AnimPlayer")
|
||||
anim.set("model_path", NodePath("../Model"))
|
||||
anim.set("loop", true)
|
||||
anim.set("anim_path", _assets.path_join("PC/ymir work/pc/" + cls + "/general/wait.msa"))
|
||||
m.add_child.call_deferred(anim)
|
||||
return m
|
||||
|
||||
func _populate() -> void:
|
||||
for i in range(min(_slots.size(), _pedestals.size())):
|
||||
var s := _slots[i]
|
||||
if _models[i] != null:
|
||||
_models[i].queue_free()
|
||||
_models[i] = null
|
||||
if String(s.get("name", "")).is_empty():
|
||||
continue
|
||||
var mdl := _slot_model(int(s.get("job", 0)))
|
||||
_pedestals[i].add_child(mdl)
|
||||
_models[i] = mdl
|
||||
_refresh_panel()
|
||||
|
||||
# --- 2D 信息面板 --------------------------------------------------------
|
||||
|
||||
func _build_ui() -> void:
|
||||
var layer := CanvasLayer.new()
|
||||
get_root().add_child(layer)
|
||||
|
||||
_panel = UiKit.board(_assets, "board", 32, 128)
|
||||
_panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||
_panel.position = Vector2(-280, -172)
|
||||
_panel.size = Vector2(560, 160)
|
||||
layer.add_child(_panel)
|
||||
|
||||
_status = Label.new()
|
||||
_status.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
||||
_status.position = Vector2(20, 16)
|
||||
_status.add_theme_font_size_override("font_size", 16)
|
||||
layer.add_child(_status)
|
||||
|
||||
func _set_status(t: String) -> void:
|
||||
if _status:
|
||||
_status.text = t
|
||||
print("[charselect] ", t)
|
||||
|
||||
func _refresh_panel() -> void:
|
||||
for c in _panel.get_children():
|
||||
c.queue_free()
|
||||
var row := HBoxContainer.new()
|
||||
row.position = Vector2(24, 20)
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
_panel.add_child(row)
|
||||
for i in range(_slots.size()):
|
||||
var s := _slots[i]
|
||||
var b := Button.new()
|
||||
var nm := String(s.get("name", ""))
|
||||
var job := clampi(int(s.get("job", 0)), 0, 3)
|
||||
b.text = ("[ %s ]" % _loc.t("SELECT_CREATE") if nm.is_empty()
|
||||
else "%s\n%s %s %d" % [nm, _loc.t(CLASS_KEY[job]),
|
||||
_loc.t("SELECT_LEVEL"), int(s.get("level", 0))])
|
||||
b.custom_minimum_size = Vector2(150, 70)
|
||||
b.toggle_mode = true
|
||||
b.button_pressed = (i == _sel)
|
||||
var idx := i
|
||||
b.pressed.connect(func(): _select_slot(idx))
|
||||
row.add_child(b)
|
||||
|
||||
var enter := Button.new()
|
||||
enter.text = _loc.t("SELECT_SELECT") # "Start"
|
||||
enter.position = Vector2(24, 118)
|
||||
enter.custom_minimum_size = Vector2(150, 40)
|
||||
enter.pressed.connect(_enter)
|
||||
_panel.add_child(enter)
|
||||
|
||||
func _select_slot(i: int) -> void:
|
||||
_sel = i
|
||||
_set_status("selected slot %d" % i)
|
||||
if _audio and i < _slots.size():
|
||||
var job := clampi(int(_slots[i].get("job", 0)), 0, 3)
|
||||
_audio.play_ui(CLASS_DIR[job] + "_select")
|
||||
_refresh_panel()
|
||||
|
||||
func _enter() -> void:
|
||||
var s := _slots[_sel] if _sel < _slots.size() else {}
|
||||
if String(s.get("name", "")).is_empty():
|
||||
_set_status("空槽不能进入(创建角色未实现)")
|
||||
return
|
||||
if _client:
|
||||
_client.call("select_character", int(s.get("index", _sel)))
|
||||
_set_status("select_character(%d) sent" % int(s.get("index", _sel)))
|
||||
else:
|
||||
_set_status("[mock] 进入游戏:%s" % s.get("name"))
|
||||
|
||||
func _on_char_list(list: Array) -> void:
|
||||
_slots.clear()
|
||||
for d in list:
|
||||
_slots.append(d)
|
||||
while _slots.size() < 3:
|
||||
_slots.append({"index": _slots.size(), "name": "", "job": 0, "level": 0})
|
||||
_set_status("char_list: %d 槽" % list.size())
|
||||
_populate()
|
||||
|
||||
# --- 循环:旋转选中角色,截图 -------------------------------------------
|
||||
|
||||
func _process(dt: float) -> bool:
|
||||
_t += dt
|
||||
for i in range(_pedestals.size()):
|
||||
var spd := 0.6 if i == _sel else 0.15
|
||||
_pedestals[i].rotate_y(spd * dt)
|
||||
if _mock and _t > 3.0:
|
||||
var img := get_root().get_texture().get_image()
|
||||
var out := ProjectSettings.globalize_path("res://../test/golden/charselect.png")
|
||||
DirAccess.make_dir_recursive_absolute(out.get_base_dir())
|
||||
img.save_png(out)
|
||||
print("[charselect] saved ", out)
|
||||
quit(0)
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://qe5xev5hp4l4
|
||||
@@ -0,0 +1,116 @@
|
||||
# chat_test —— P3:聊天窗路由 + 前缀解析 + 气泡 headless 自检。
|
||||
# godot --headless --path project --script chat_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const ChatUI = preload("res://ui/chat_ui.gd")
|
||||
const NetWorld = preload("res://net_world.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal chat(type: int, vid: int, text: String)
|
||||
# 注:GDScript 不允许 signal 和 func 同名;真 M2Client 是 GDExtension,两者可共存。
|
||||
# 这里 whisper 用作方法(chat_ui 调 client.whisper(...));入站 whisper 直接调 chat._on_whisper。
|
||||
signal item_picked_up(vnum: int, count: int, from: String)
|
||||
signal fishing_event(subheader: int, info: int, dir: int)
|
||||
# net_world 也连这些:
|
||||
signal entity_spawned(entity: Dictionary)
|
||||
signal entity_despawned(vid: int)
|
||||
signal entity_moved(vid: int)
|
||||
signal entity_main_set(vid: int)
|
||||
signal entity_dead(vid: int)
|
||||
signal vitals_changed(vid: int)
|
||||
signal damage(vid: int, amount: int, flag: int)
|
||||
var ents := {1000: {"vid": 1000, "name": "Me"}, 2000: {"vid": 2000, "name": "Bob"}}
|
||||
var said := []
|
||||
var whispered := []
|
||||
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
|
||||
func get_entities() -> Array: return ents.values()
|
||||
func say(t, s) -> bool: said.append([t, s]); return true
|
||||
func whisper(to, s) -> bool: whispered.append([to, s]); return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: chat_test (routing + prefixes + bubble)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _last(rt: RichTextLabel) -> String:
|
||||
return rt.get_parsed_text().strip_edges().split("\n")[-1]
|
||||
|
||||
func _run() -> void:
|
||||
var canvas := CanvasLayer.new()
|
||||
get_root().add_child(canvas)
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
var chat: Node = ChatUI.new()
|
||||
get_root().add_child(chat)
|
||||
chat.setup(fc, canvas)
|
||||
|
||||
_ck(chat._log.size() == 4, "4 tabs built")
|
||||
var tAll: RichTextLabel = chat._log[0]
|
||||
var tWhisper: RichTextLabel = chat._log[1]
|
||||
var tSys: RichTextLabel = chat._log[2]
|
||||
var tBattle: RichTextLabel = chat._log[3]
|
||||
|
||||
# 普通聊天 -> 全部标签,带名字
|
||||
fc.chat.emit(0, 2000, "hi all")
|
||||
_ck(_last(tAll).contains("Bob: hi all"), "talking -> All tab with name")
|
||||
_ck(chat._buffers[2].size() == 0, "talking not in System tab")
|
||||
|
||||
# info -> 系统 + 全部
|
||||
fc.chat.emit(1, 0, "you leveled up")
|
||||
_ck(_last(tSys).contains("you leveled up"), "info -> System tab")
|
||||
_ck(_last(tAll).contains("you leveled up"), "info -> All tab too")
|
||||
|
||||
# whisper -> 私聊 + 全部(入站直接调 handler,见 FakeClient 注释)
|
||||
chat._on_whisper(0, "Alice", "secret")
|
||||
_ck(_last(tWhisper).contains("Alice: secret"), "whisper -> Whisper tab")
|
||||
_ck(_last(tAll).contains("Alice: secret"), "whisper -> All tab")
|
||||
chat._on_whisper(1, "", "server notice")
|
||||
_ck(_last(tWhisper).contains("[系统]: server notice"), "system whisper -> [系统]")
|
||||
|
||||
# 拾取 -> 系统 + 战斗
|
||||
fc.item_picked_up.emit(27, 3, "")
|
||||
_ck(_last(tSys).contains("拾取 #27 x3"), "pickup -> System")
|
||||
_ck(_last(tBattle).contains("拾取 #27 x3"), "pickup -> Battle")
|
||||
# 钓鱼服务端事件:成功/鱼获进入系统和战斗日志。
|
||||
fc.fishing_event.emit(3, 1000, 0)
|
||||
_ck(_last(tSys).contains("鱼钩命中"), "fishing success -> System")
|
||||
fc.fishing_event.emit(5, 30001, 0)
|
||||
_ck(_last(tBattle).contains("捕获物品 #30001"), "fishing item -> Battle")
|
||||
|
||||
# --- 前缀解析 ---
|
||||
chat._on_submit("hello world")
|
||||
_ck(fc.said.size() == 1 and fc.said[0] == [0, "hello world"], "plain -> say(TALKING)")
|
||||
chat._on_submit("/g guild msg")
|
||||
_ck(fc.said.size() == 2 and fc.said[1] == [4, "guild msg"], "/g -> say(GUILD)")
|
||||
chat._on_submit("/s shout!")
|
||||
_ck(fc.said[2] == [6, "shout!"], "/s -> say(SHOUT)")
|
||||
chat._on_submit("/w Bob psst hey")
|
||||
_ck(fc.whispered.size() == 1 and fc.whispered[0] == ["Bob", "psst hey"], "/w Bob ... -> whisper('Bob','psst hey')")
|
||||
_ck(_last(tWhisper).contains("→ Bob: psst hey"), "/w -> local echo in Whisper tab")
|
||||
|
||||
# --- 气泡 ---
|
||||
var nw: Node = NetWorld.new()
|
||||
get_root().add_child(nw)
|
||||
var mount := Node3D.new()
|
||||
get_root().add_child(mount)
|
||||
nw.setup(fc, mount)
|
||||
fc.entity_spawned.emit({"vid": 2000, "name": "Bob", "pos": Vector3.ZERO, "func": 0, "angle_deg": 0.0})
|
||||
await process_frame
|
||||
var got_main := [""]
|
||||
nw.main_bubble.connect(func(t): got_main[0] = t)
|
||||
nw._bubble(2000, "hi there")
|
||||
var bob: Node3D = nw.node_for(2000)
|
||||
_ck(bob != null and bob.get_node_or_null("bubble") != null, "entity 2000 got a bubble node")
|
||||
nw.set_local_vid(1000)
|
||||
nw._bubble(1000, "my line")
|
||||
_ck(got_main[0] == "my line", "local vid bubble -> main_bubble signal")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwoq34nldm7x3
|
||||
@@ -0,0 +1,19 @@
|
||||
# client_main —— 打包客户端的入口场景根(main_scene = res://client_main.tscn)。
|
||||
#
|
||||
# 只做一件事:起 AppFlow(登录 → 选人 → 进游戏 串场控制器)。
|
||||
# 资源目录由 AssetRoot 决定:默认 res://../assets(开发树),打包后设环境变量
|
||||
# MT_ASSETS 指向真实资源目录即可(见 project/asset_root.gd)。
|
||||
extends Node
|
||||
|
||||
const AppFlow = preload("res://app_flow.gd")
|
||||
|
||||
func _ready() -> void:
|
||||
# 移动端:APK/IPA 里没有资源 —— 挂载外部的 assets.zip(adb push / ios-deploy 上传)。
|
||||
AssetPack.ensure()
|
||||
var root := AssetRoot.path()
|
||||
if not AssetRoot.available():
|
||||
push_warning("[client] 资源目录不存在:%s —— MT_ASSETS 环境变量 / assets.zip(adb push 到 files/)" % root)
|
||||
var af := AppFlow.new()
|
||||
af.name = "AppFlow"
|
||||
add_child(af)
|
||||
af.start(root)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d384dorgv24yv
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://b0mtgdclientmain0"]
|
||||
|
||||
[ext_resource type="Script" path="res://client_main.gd" id="1_client"]
|
||||
|
||||
[node name="ClientMain" type="Node"]
|
||||
script = ExtResource("1_client")
|
||||
@@ -0,0 +1,156 @@
|
||||
# combat_fx_test —— P4:受击硬直 / combo / 抖屏 / 状态图标 / 死亡窗 headless 自检。
|
||||
# godot --headless --path project --script combat_fx_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const NetPlay = preload("res://net_play.gd")
|
||||
const GameCamera = preload("res://game_camera.gd")
|
||||
const DeathUI = preload("res://ui/death_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal entity_main_set(vid: int)
|
||||
signal entity_moved(vid: int)
|
||||
signal entity_despawned(vid: int)
|
||||
signal entity_dead(vid: int)
|
||||
signal vitals_changed(vid: int)
|
||||
signal points_changed(points: Dictionary)
|
||||
signal target_info(vid: int, hp_percent: int)
|
||||
signal damage(vid: int, amount: int, flag: int)
|
||||
signal fishing_event(subheader: int, info: int, dir: int)
|
||||
signal affect_added(affect: Dictionary)
|
||||
signal affect_removed(type: int)
|
||||
signal phase_changed(phase: String)
|
||||
var main := 1000
|
||||
var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}}
|
||||
var attacks := []
|
||||
var said := []
|
||||
var affects := []
|
||||
func is_in_game() -> bool: return true
|
||||
func get_main_vid() -> int: return main
|
||||
func get_entity(vid) -> Dictionary: return ents.get(vid, {})
|
||||
func get_affects() -> Array: return affects
|
||||
func attack(motion, vid) -> bool: attacks.append([motion, vid]); return true
|
||||
func set_target(v) -> bool: return true
|
||||
func move(a, b, c, d, e) -> bool: return true
|
||||
func say(t, s) -> bool: said.append([t, s]); return true
|
||||
|
||||
class FakePC extends Node:
|
||||
signal target_selected(node: Node3D)
|
||||
signal moved(pos: Vector3)
|
||||
signal anim_state(state: String)
|
||||
var player: Node3D
|
||||
var frozen := false
|
||||
|
||||
class FakeView extends Node3D:
|
||||
var states := []
|
||||
func set_anim_state(s): states.append(s)
|
||||
|
||||
class FakeHud extends Node:
|
||||
var affects := []
|
||||
func set_affects(a): affects = a
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: combat_fx_test (hitstun / combo / shake / affects / death)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var fc := FakeClient.new()
|
||||
var pc := FakePC.new()
|
||||
var view: Node3D = FakeView.new()
|
||||
var hud := FakeHud.new()
|
||||
var cam: Camera3D = GameCamera.new()
|
||||
pc.player = Node3D.new()
|
||||
for n in [fc, pc, view, hud, cam, pc.player]:
|
||||
get_root().add_child(n)
|
||||
cam.target = pc.player
|
||||
|
||||
var np: Node = NetPlay.new()
|
||||
get_root().add_child(np)
|
||||
np.setup(fc, pc, null, hud)
|
||||
np.camera = cam
|
||||
np.player_view = view
|
||||
np._main_vid = 1000
|
||||
|
||||
# --- 受击硬直 + 抖屏 ---
|
||||
fc.damage.emit(1000, 20, 0) # 命中(非闪避)
|
||||
_ck(np.is_stunned(), "hit -> stunned")
|
||||
_ck(cam._shake > 0.0, "hit -> camera shake queued")
|
||||
_ck("damage" in view.states, "hit -> view set_anim_state('damage')")
|
||||
np._process(0.016)
|
||||
_ck(pc.frozen == true, "stun -> pc.frozen = true (got %s)" % pc.frozen)
|
||||
|
||||
# 闪避不硬直
|
||||
np._hitstun_until = 0.0
|
||||
fc.damage.emit(1000, 0, 1 << 2) # DODGE
|
||||
_ck(not np.is_stunned(), "dodge -> not stunned")
|
||||
|
||||
# 别人受击不影响自己
|
||||
np._hitstun_until = 0.0
|
||||
fc.damage.emit(2000, 30, 0)
|
||||
_ck(not np.is_stunned(), "other's damage -> not stunned")
|
||||
|
||||
# Fishing motion subheaders drive the local player view; FISH item outcome is ignored.
|
||||
np.player_view = view
|
||||
fc.fishing_event.emit(0, 1000, 18)
|
||||
_ck(view.states.back() == "fishing", "fishing start -> local fishing animation")
|
||||
fc.fishing_event.emit(3, 1000, 0)
|
||||
_ck(view.states.back() == "fishing_catch", "fishing success -> catch animation")
|
||||
var before: int = view.states.size()
|
||||
fc.fishing_event.emit(5, 30001, 0)
|
||||
_ck(view.states.size() == before, "fishing fish item -> no local motion")
|
||||
|
||||
# --- combo:连续攻击 motion 递增 ---
|
||||
np._hitstun_until = 0.0
|
||||
np._target_vid = 2000
|
||||
np.attack_period = 0.0
|
||||
np.ATTACK_RANGE # (const, no-op)
|
||||
# 让目标在攻击距离内
|
||||
# net_world 为 null -> _process 里 node_for 走不到;直接测 combo 计数逻辑:
|
||||
np._last_attack_t = np._now()
|
||||
np._combo = 0
|
||||
# 模拟三次「窗口内攻击」
|
||||
for i in 3:
|
||||
np._combo = (np._combo + 1) % 3 if (np._now() - np._last_attack_t) < np.COMBO_WINDOW else 0
|
||||
np._last_attack_t = np._now()
|
||||
_ck(np._combo == 0, "combo cycles 1->2->0 over 3 hits, got %d" % np._combo)
|
||||
|
||||
# --- 状态图标条 ---
|
||||
fc.affects = [{"type": 7001, "point_idx": 19, "value": 25, "duration": 300}]
|
||||
# game_scene 里连的是 refresh lambda;这里直接调 hud
|
||||
hud.set_affects(fc.get_affects())
|
||||
_ck(hud.affects.size() == 1 and int(hud.affects[0]["type"]) == 7001, "hud.set_affects")
|
||||
|
||||
# --- 死亡窗 ---
|
||||
var canvas := CanvasLayer.new()
|
||||
get_root().add_child(canvas)
|
||||
var death: Node = DeathUI.new()
|
||||
get_root().add_child(death)
|
||||
death.setup(fc, canvas)
|
||||
_ck(not death._root.visible, "death dialog hidden initially")
|
||||
fc.ents[1000]["dead"] = true
|
||||
fc.entity_dead.emit(1000)
|
||||
_ck(death._root.visible, "entity_dead(main) -> death dialog shown")
|
||||
# 点「在此复活」
|
||||
var hb: Button = null
|
||||
for n in death._root.find_children("*", "Button", true, false):
|
||||
if n.text == "在此复活":
|
||||
hb = n
|
||||
_ck(hb != null, "restart-here button present")
|
||||
if hb:
|
||||
hb.pressed.emit()
|
||||
_ck(fc.said.size() == 1 and fc.said[0] == [0, "/restart_here"], "restart -> say(0, '/restart_here')")
|
||||
# 复活:hp 回来 -> 自动关
|
||||
fc.ents[1000]["dead"] = false
|
||||
fc.ents[1000]["hp"] = 100
|
||||
fc.vitals_changed.emit(1000)
|
||||
_ck(not death._root.visible, "revive (hp>0) -> dialog auto-hides")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bisseuypqt88s
|
||||
@@ -0,0 +1,105 @@
|
||||
# dragon_soul_test —— 龙魂精炼窗 headless 自检。
|
||||
# godot --headless --path project --script dragon_soul_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const DragonSoulUI = preload("res://ui/dragon_soul_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal ds_window_open()
|
||||
signal ds_refine_result(ok: bool, sub_type: int, cell: int)
|
||||
var items := {} # cell -> {vnum, count}
|
||||
var calls := []
|
||||
func is_in_game() -> bool: return true
|
||||
func get_item(window: int, cell: int) -> Dictionary:
|
||||
return items.get(cell, {"vnum": 0, "count": 0})
|
||||
func ds_refine(mode: int, cells: Array) -> bool:
|
||||
calls.append(["ds_refine", mode, cells.duplicate()])
|
||||
return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: dragon_soul_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var canvas := CanvasLayer.new()
|
||||
get_root().add_child(canvas)
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
fc.items = {
|
||||
3: {"vnum": 71001, "count": 1}, # 龙魂
|
||||
5: {"vnum": 72001, "count": 5}, # 材料
|
||||
8: {"vnum": 72002, "count": 2},
|
||||
}
|
||||
|
||||
var du: Node = DragonSoulUI.new()
|
||||
get_root().add_child(du)
|
||||
du.setup(fc, canvas, null)
|
||||
_ck(not du.is_open(), "初始隐藏")
|
||||
|
||||
# ds_window_open -> 弹出
|
||||
fc.ds_window_open.emit()
|
||||
_ck(du.is_open(), "ds_window_open -> 弹出")
|
||||
_ck(du._mode == 2, "默认模式 = 精炼(2)")
|
||||
|
||||
# 切模式
|
||||
du._set_mode(0)
|
||||
_ck(du._mode == 0, "切到升级(0)")
|
||||
_ck(du._mode_btns[0].disabled and not du._mode_btns[2].disabled, "当前模式按钮禁用")
|
||||
|
||||
# 右键背包 -> 填格
|
||||
du.add_cell(1, 3)
|
||||
du.add_cell(1, 5)
|
||||
du.add_cell(1, 8)
|
||||
_ck(du._cells == [3, 5, 8], "3 个 cell 按序放入")
|
||||
_ck(du._slot_labels[0].text.contains("龙魂") and du._slot_labels[0].text.contains("格3"), "槽0 = 龙魂")
|
||||
_ck(du._slot_labels[1].text.contains("材料"), "槽1 = 材料")
|
||||
|
||||
# 非背包窗忽略
|
||||
du.add_cell(2, 1)
|
||||
_ck(du._cells == [3, 5, 8], "非 WINDOW_INVENTORY 忽略")
|
||||
|
||||
# 再次右键同 cell = 取出
|
||||
du.add_cell(1, 5)
|
||||
_ck(du._cells == [3, 8], "再次右键 -> 取出")
|
||||
du.add_cell(1, 5)
|
||||
_ck(du._cells == [3, 8, 5], "第三次右键 -> 重新放入末尾")
|
||||
|
||||
# 执行
|
||||
for b in du._root.find_children("*", "Button", true, false):
|
||||
if b.text == "执行":
|
||||
b.pressed.emit()
|
||||
_ck(fc.calls == [["ds_refine", 0, [3, 8, 5]]], "执行 -> client.ds_refine(mode=0, cells)")
|
||||
|
||||
# 结果:成功清空
|
||||
fc.ds_refine_result.emit(true, 11, 3)
|
||||
_ck(du._cells.is_empty(), "成功 -> 清空格子")
|
||||
_ck(du._status.text.contains("成功"), "状态行显示成功")
|
||||
|
||||
# 结果:失败保留提示
|
||||
du.add_cell(1, 3)
|
||||
fc.ds_refine_result.emit(false, 8, 3)
|
||||
_ck(du._status.text.contains("失败") and du._status.text.contains("8"), "失败 -> 原因码")
|
||||
_ck(du._cells == [3], "失败不清空")
|
||||
|
||||
# 清空按钮
|
||||
for b in du._root.find_children("*", "Button", true, false):
|
||||
if b.text == "清空":
|
||||
b.pressed.emit()
|
||||
_ck(du._cells.is_empty(), "清空按钮")
|
||||
|
||||
# 关闭
|
||||
for b in du._root.find_children("*", "Button", true, false):
|
||||
if b.text == "关闭":
|
||||
b.pressed.emit()
|
||||
_ck(not du.is_open(), "关闭按钮")
|
||||
@@ -0,0 +1 @@
|
||||
uid://c5xpqyrnavvjx
|
||||
@@ -0,0 +1,165 @@
|
||||
# equip_model_test —— P2 装备→模型 headless 自检(假 client + 真 item_list.txt + 桩 model)。
|
||||
# godot --headless --path project --script equip_model_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const ItemListDB = preload("res://ui/item_list.gd")
|
||||
const EquipModel = preload("res://ui/equip_model.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal inventory_changed(window: int, cell: int)
|
||||
signal entity_main_set(vid: int)
|
||||
signal entity_info(vid: int, entity: Dictionary)
|
||||
var equip := []
|
||||
var main_parts := [0, 0, 0, 5] # parts[3] = hair 5
|
||||
func _init():
|
||||
for i in 11: equip.append({"vnum": 0, "count": 0, "wear": i})
|
||||
func get_equipment() -> Array: return equip
|
||||
func get_main_vid() -> int: return 1000
|
||||
func get_entity(_v) -> Dictionary: return {"race": 0, "parts": main_parts}
|
||||
func set_wear(idx, vnum):
|
||||
equip[idx] = {"vnum": vnum, "count": 1, "wear": idx}
|
||||
inventory_changed.emit(2, 90 + idx) # window EQUIPMENT
|
||||
|
||||
# Node3D 桩:记录 weapon_gr2 / shield_gr2 / gr2_path / hair_gr2 被设成什么
|
||||
class StubModel extends Node3D:
|
||||
var weapon_gr2 := ""
|
||||
var shield_gr2 := ""
|
||||
var gr2_path := ""
|
||||
var hair_gr2 := ""
|
||||
var hair_skin := "-"
|
||||
func _set(prop, val):
|
||||
match String(prop):
|
||||
"weapon_gr2": weapon_gr2 = val; return true
|
||||
"shield_gr2": shield_gr2 = val; return true
|
||||
"gr2_path": gr2_path = val; return true
|
||||
"hair_gr2": hair_gr2 = val; return true
|
||||
"hair_skin": hair_skin = val; return true
|
||||
return false
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: equip_model_test (item_list + weapon swap)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var assets := AssetRoot.path()
|
||||
var il_path := assets.path_join("locale/locale/common/item_list.txt")
|
||||
|
||||
# item_list.txt 解析
|
||||
var il := ItemListDB.new()
|
||||
if not FileAccess.file_exists(il_path):
|
||||
print(" (skip: no item_list.txt)")
|
||||
return
|
||||
_ck(il.load_file(il_path), "item_list loaded")
|
||||
_ck(il.count > 1000, "item_list count > 1000 (%d)" % il.count)
|
||||
_ck(il.model(19) == "d:/ymir work/item/weapon/00010.gr2", "item_list.model(19), got '%s'" % il.model(19))
|
||||
_ck(il.icon(19) == "icon/item/00010.tga", "item_list.icon(19)")
|
||||
_ck(il.type_of(19) == "WEAPON", "item_list.type_of(19)")
|
||||
|
||||
# EquipModel:装备武器 vnum 19 -> model.weapon_gr2 = 解析后的真实路径
|
||||
var fc := FakeClient.new()
|
||||
var model := StubModel.new()
|
||||
get_root().add_child(fc)
|
||||
get_root().add_child(model)
|
||||
var em: Node = EquipModel.new()
|
||||
get_root().add_child(em)
|
||||
em.setup(fc, il, func() -> Node: return model, assets)
|
||||
em.main_getter = func() -> int: return 1000
|
||||
|
||||
fc.set_wear(4, 19) # WEAR_WEAPON
|
||||
await process_frame
|
||||
var wp: String = model.weapon_gr2
|
||||
print(" weapon_gr2 -> '", wp, "'")
|
||||
if wp == "":
|
||||
# 资产里可能没有这把武器的 gr2 文件;至少确认解析逻辑跑到了(basevnum 约定)
|
||||
_ck(true, "weapon resolved to '' (gr2 not in assets — resolution logic still exercised)")
|
||||
else:
|
||||
_ck(wp.ends_with("00010.gr2") and FileAccess.file_exists(wp), "weapon_gr2 points at a real 00010.gr2")
|
||||
|
||||
# 拆下武器 -> weapon_gr2 = ""
|
||||
fc.set_wear(4, 0)
|
||||
await process_frame
|
||||
_ck(model.weapon_gr2 == "", "unequip -> weapon_gr2 cleared")
|
||||
|
||||
# armor_model_map 注入 -> gr2_path 切换
|
||||
em.armor_model_map = {11209: "d:/ymir work/pc/warrior/warrior.gr2"}
|
||||
fc.set_wear(0, 11209)
|
||||
await process_frame
|
||||
_ck(model.gr2_path == "d:/ymir work/pc/warrior/warrior.gr2", "armor_model_map -> gr2_path set")
|
||||
|
||||
# 盾(WEAR_SHIELD=10)-> shield_gr2 = item_list.model 解析
|
||||
# 用一个真存在的武器 vnum 当盾(资产里 00010.gr2 存在)
|
||||
fc.set_wear(10, 19)
|
||||
await process_frame
|
||||
print(" shield_gr2 -> '", model.shield_gr2, "'")
|
||||
_ck(model.shield_gr2 == "" or model.shield_gr2.ends_with("00010.gr2"), "WEAR_SHIELD -> shield_gr2")
|
||||
fc.set_wear(10, 0)
|
||||
await process_frame
|
||||
_ck(model.shield_gr2 == "", "unequip shield -> shield_gr2 cleared")
|
||||
|
||||
# 头盔(WEAR_HEAD=1)有模型 -> 覆盖 hair_gr2;拆下回角色发型(parts[3]=5)
|
||||
var em2: Node = EquipModel.new()
|
||||
get_root().add_child(em2)
|
||||
em2.armor_model_map = {}
|
||||
em2.setup(fc, il, func() -> Node: return model, assets)
|
||||
em2.main_getter = func() -> int: return 1000
|
||||
model.hair_gr2 = ""
|
||||
# 头盔 vnum 用一个 item_list 里有 model 的(19 有)
|
||||
fc.set_wear(1, 19)
|
||||
await process_frame
|
||||
_ck(model.hair_gr2 == "" or model.hair_gr2.ends_with(".gr2"), "WEAR_HEAD 有模型 -> 覆盖 hair 槽")
|
||||
# 拆头盔:应尝试从 parts[3] 恢复发型(有 race_spec 才成,无则保持不炸)
|
||||
fc.set_wear(1, 0)
|
||||
await process_frame
|
||||
_ck(true, "拆头盔 -> 回 parts 发型(不炸)")
|
||||
|
||||
# armor shape 走 item_proto values[3](对齐 __ArmorVnumToShape)
|
||||
if ClassDB.class_exists("Metin2Proto"):
|
||||
var pr: Object = ClassDB.instantiate("Metin2Proto")
|
||||
get_root().add_child(pr)
|
||||
var ip := assets.path_join("locale/locale/en/item_proto")
|
||||
if FileAccess.file_exists(ip) and pr.call("load_item_proto", ip):
|
||||
var em3: Node = EquipModel.new()
|
||||
get_root().add_child(em3)
|
||||
em3.setup(fc, il, func() -> Node: return model, assets)
|
||||
em3.proto = pr
|
||||
em3.race = 0 # warrior
|
||||
# 11200 "Monk Plate Armour+0" -> values[3] == 3
|
||||
_ck(em3._armor_shape_default(11200) == 3, "armor 11200 -> shape 3 (via proto values[3])")
|
||||
# 11000 "Wolf Armour" -> values[3] == 0 -> 回退 =vnum
|
||||
_ck(em3._armor_shape_default(11000) == 11000, "armor 11000 -> values[3]=0 回退 =vnum")
|
||||
_ck(em3._armor_specular(11209) == 100, "armor 11209(+9) -> specular 100")
|
||||
|
||||
# 时装 / parts[] 优先:服务器把 costume vnum 写进主角 parts[ARMOR],压过装备槽
|
||||
var seen := {"body": -1, "hair": ""}
|
||||
var em4: Node = EquipModel.new()
|
||||
get_root().add_child(em4)
|
||||
# 用一个记录用的桩:_apply_body / hair 走真逻辑但我们看最终 vnum
|
||||
em4.setup(fc, il, func() -> Node: return model, assets)
|
||||
em4.main_getter = func() -> int: return 1000
|
||||
em4.armor_model_map = {77777: "d:/x/costume.gr2", 11111: "d:/x/armor.gr2"}
|
||||
# 装备槽是普通盔甲,但 parts[ARMOR] 是时装 -> 用时装
|
||||
fc.equip[0] = {"vnum": 11111, "count": 1, "wear": 0}
|
||||
fc.main_parts = [77777, 0, 0, 5]
|
||||
em4._last_body_vnum = -1
|
||||
em4.refresh()
|
||||
_ck(model.gr2_path == "d:/x/costume.gr2", "parts[ARMOR] 时装压过装备槽 (%s)" % model.gr2_path)
|
||||
# parts[ARMOR] 清 0 -> 回退装备槽
|
||||
fc.main_parts = [0, 0, 0, 5]
|
||||
em4._last_body_vnum = -1
|
||||
em4.refresh()
|
||||
_ck(model.gr2_path == "d:/x/armor.gr2", "parts[ARMOR]=0 -> 回退 WEAR_BODY (%s)" % model.gr2_path)
|
||||
# _eff 直接测
|
||||
_ck(em4._eff([9, 0, 0, 0], 0, [{"vnum": 5}], 0) == 9, "_eff: parts 非 0 优先")
|
||||
_ck(em4._eff([0, 0, 0, 0], 0, [{"vnum": 5}], 0) == 5, "_eff: parts 0 -> 装备槽")
|
||||
_ck(em4._eff([], 1, [{"vnum": 1}, {"vnum": 2}], 1) == 2, "_eff: 无 parts -> 装备槽")
|
||||
@@ -0,0 +1 @@
|
||||
uid://chnd4ymmuptf0
|
||||
@@ -0,0 +1,141 @@
|
||||
# eterngrn_polish_test —— EterGrnLib 对齐打磨(项 3/5/6)的 headless 自检。
|
||||
# godot --headless --path project --script eterngrn_polish_test.gd
|
||||
# 项 1/2/4(材质 flag / LOD 淡入 / ease 曲线)是 C++ 内部 + 渲染,离线看不出,走目视。
|
||||
extends SceneTree
|
||||
|
||||
const CharShadow = preload("res://ui/char_shadow.gd")
|
||||
const PlayerCtl = preload("res://player_controller.gd")
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: eterngrn_polish_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
# ---- 5. CharShadow:Decal + 强制 cast_shadow ----
|
||||
var root := Node3D.new()
|
||||
get_root().add_child(root)
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = BoxMesh.new()
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF # 模拟被 world OFF 波及
|
||||
root.add_child(mi)
|
||||
CharShadow.attach(root, 1.4)
|
||||
_ck(mi.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_ON,
|
||||
"CharShadow 把 mesh cast_shadow 拉回 ON")
|
||||
var blob := root.get_node_or_null("BlobShadow")
|
||||
_ck(blob != null and blob is Decal, "CharShadow 加了 BlobShadow Decal")
|
||||
_ck(blob != null and blob.texture_albedo != null, "Decal 有径向渐变贴图")
|
||||
_ck(blob != null and absf(blob.size.x - 1.4) < 0.01, "Decal 足印尺寸 = footprint")
|
||||
CharShadow.attach(root, 1.4) # 幂等
|
||||
var n_blob := 0
|
||||
for c in root.get_children():
|
||||
if c.name == "BlobShadow": n_blob += 1
|
||||
_ck(n_blob == 1, "CharShadow.attach 幂等,不重复加 Decal")
|
||||
|
||||
# ---- 6. player_controller._ray_pick_t:射线 vs 网格世界 AABB ----
|
||||
var pc = PlayerCtl.new()
|
||||
get_root().add_child(pc)
|
||||
var ent := Node3D.new()
|
||||
ent.position = Vector3(0, 0, -10)
|
||||
get_root().add_child(ent)
|
||||
var emi := MeshInstance3D.new()
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = Vector3(2, 2, 2)
|
||||
emi.mesh = bm
|
||||
emi.position = Vector3(0, 1, 0) # 盒中心 (0,1,-10)
|
||||
ent.add_child(emi)
|
||||
await process_frame
|
||||
# 从原点朝 -Z 打,正中盒子
|
||||
var t_hit: float = pc._ray_pick_t(Vector3.ZERO, Vector3(0, 0, -1), ent)
|
||||
_ck(t_hit > 8.0 and t_hit < 10.0, "_ray_pick_t 命中:进入 t≈9 (got %.2f)" % t_hit)
|
||||
# 朝 +X 打,不该命中
|
||||
var t_miss: float = pc._ray_pick_t(Vector3.ZERO, Vector3(1, 0, 0), ent)
|
||||
_ck(t_miss < 0.0, "_ray_pick_t 未命中返回 -1 (got %.2f)" % t_miss)
|
||||
# 擦边未命中(偏 5m)
|
||||
var t_edge: float = pc._ray_pick_t(Vector3(5, 1, 0), Vector3(0, 0, -1), ent)
|
||||
_ck(t_edge < 0.0, "_ray_pick_t 偏 5m 不命中")
|
||||
# 无网格 → 回退胶囊近似(PICK_RADIUS 内命中)
|
||||
var ent2 := Node3D.new()
|
||||
ent2.position = Vector3(0, 0, -6)
|
||||
get_root().add_child(ent2)
|
||||
var t_cap: float = pc._ray_pick_t(Vector3.ZERO, Vector3(0, 0, -1), ent2)
|
||||
_ck(t_cap > 0.0, "_ray_pick_t 无网格 → 胶囊近似仍能命中 (got %.2f)" % t_cap)
|
||||
|
||||
# ---- 3. game_scene._on_local_motion_event 分派 ----
|
||||
var GS = load("res://game_scene.gd")
|
||||
var gs = GS.new()
|
||||
get_root().add_child(gs)
|
||||
# 桩:cam.shake / fx.spawn_at / _audio.play_at / ui
|
||||
var calls := {"shake": 0, "spawn": [], "sound": []}
|
||||
var fake_cam := Camera3D.new()
|
||||
fake_cam.set_script(_inline_stub("""
|
||||
extends Camera3D
|
||||
var log
|
||||
func shake(s, d=8.0): log["shake"] += 1
|
||||
"""))
|
||||
fake_cam.set("log", calls)
|
||||
var fake_fx = RefCounted.new()
|
||||
fake_fx.set_script(_inline_stub("""
|
||||
extends RefCounted
|
||||
var log
|
||||
func spawn_at(n, p, gp, o=true): log["spawn"].append(n); return null
|
||||
"""))
|
||||
fake_fx.set("log", calls)
|
||||
var fake_audio := Node.new()
|
||||
fake_audio.set_script(_inline_stub("""
|
||||
extends Node
|
||||
var log
|
||||
func play_at(rel, pos, parent=null): log["sound"].append(rel)
|
||||
"""))
|
||||
fake_audio.set("log", calls)
|
||||
gs.cam = fake_cam
|
||||
gs.fx = fake_fx
|
||||
gs._audio = fake_audio
|
||||
gs.player = Node3D.new()
|
||||
gs._mount = Node3D.new()
|
||||
var fake_client := Node.new()
|
||||
fake_client.set_script(_inline_stub("""
|
||||
extends Node
|
||||
var shots := []
|
||||
func shoot(skill): shots.append(skill); return true
|
||||
"""))
|
||||
get_root().add_child(fake_client)
|
||||
gs.client = fake_client
|
||||
get_root().add_child(gs._mount)
|
||||
gs.ui = CanvasLayer.new()
|
||||
get_root().add_child(gs.ui)
|
||||
gs.hud = Node.new()
|
||||
|
||||
gs._on_local_motion_event(5, "", "sfx/hit.wav", Vector3.ZERO)
|
||||
_ck(calls["sound"] == ["sfx/hit.wav"], "motion_event: sound → play_at")
|
||||
gs._on_local_motion_event(1, "effect/sword_trail.mse", "", Vector3(0, 100, 0))
|
||||
_ck(calls["spawn"] == ["sword_trail"], "motion_event: effect → fx.spawn_at(basename)")
|
||||
gs._on_local_motion_event(2, "", "", Vector3.ZERO) # SCREEN_WAVING
|
||||
_ck(calls["shake"] == 1, "motion_event: type 2 → cam.shake")
|
||||
gs._on_local_motion_event(3, "", "", Vector3.ZERO) # SCREEN_FLASHING
|
||||
await process_frame
|
||||
_ck(gs.ui.get_node_or_null("ColorRect") != null or gs._flash_rect != null,
|
||||
"motion_event: type 3 → 屏闪 ColorRect")
|
||||
gs._pending_shoot_skill = 77
|
||||
gs._on_local_motion_event(6, "", "", Vector3.ZERO)
|
||||
_ck(fake_client.shots == [77], "motion_event: FLY → sends pending CG_SHOOT once")
|
||||
gs._on_local_motion_event(6, "", "", Vector3.ZERO)
|
||||
_ck(fake_client.shots == [77], "motion_event: FLY clears pending shoot")
|
||||
|
||||
gs.queue_free()
|
||||
|
||||
func _inline_stub(src: String) -> GDScript:
|
||||
var s := GDScript.new()
|
||||
s.source_code = src
|
||||
s.reload()
|
||||
return s
|
||||
@@ -0,0 +1 @@
|
||||
uid://4h1l6dtrxggf
|
||||
@@ -59,3 +59,70 @@ privacy/downloads_folder_usage_description=""
|
||||
privacy/network_volumes_usage_description=""
|
||||
privacy/removable_volumes_usage_description=""
|
||||
ssh_remote_deploy/enabled=false
|
||||
|
||||
[preset.1]
|
||||
|
||||
name="Android"
|
||||
platform="Android"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="../build/export/mtgodot-poc.apk"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
seed=0
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_export_mode=2
|
||||
|
||||
[preset.1.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
gradle_build/use_gradle_build=true
|
||||
gradle_build/gradle_build_directory="res://android"
|
||||
gradle_build/android_source_template=""
|
||||
gradle_build/compress_native_libraries=false
|
||||
gradle_build/export_format=0
|
||||
gradle_build/min_sdk=""
|
||||
gradle_build/target_sdk=""
|
||||
architectures/armeabi-v7a=false
|
||||
architectures/arm64-v8a=true
|
||||
architectures/x86=false
|
||||
architectures/x86_64=false
|
||||
version/code=1
|
||||
version/name="1.0"
|
||||
package/unique_name="org.internal.mtgodotpoc"
|
||||
package/name=""
|
||||
package/signed=true
|
||||
package/app_category=2
|
||||
package/retain_data_on_uninstall=false
|
||||
package/exclude_from_recents=false
|
||||
package/show_in_android_tv=false
|
||||
package/show_in_app_library=true
|
||||
package/show_as_launcher_app=false
|
||||
launcher_icons/main_192x192=""
|
||||
launcher_icons/adaptive_foreground_432x432=""
|
||||
launcher_icons/adaptive_background_432x432=""
|
||||
launcher_icons/adaptive_monochrome_432x432=""
|
||||
graphics/opengl_debug=false
|
||||
xr_features/xr_mode=0
|
||||
screen/immersive_mode=true
|
||||
screen/support_small=true
|
||||
screen/support_normal=true
|
||||
screen/support_large=true
|
||||
screen/support_xlarge=true
|
||||
user_data_backup/allow=false
|
||||
command_line/extra_args=""
|
||||
apk_expansion/enable=false
|
||||
apk_expansion/SALT=""
|
||||
apk_expansion/public_key=""
|
||||
permissions/internet=true
|
||||
permissions/access_network_state=true
|
||||
permissions/access_wifi_state=true
|
||||
permissions/wake_lock=true
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# EffectPlayer (P5) —— 解析后的 `.mse` spec → Godot 节点树(GPUParticles3D 为主)。
|
||||
#
|
||||
# var fx := preload("res://fx/effect_player.gd").new()
|
||||
# fx.build(spec, assets_root) # spec 来自 fx/mse.gd
|
||||
# add_child(fx) # 挂到要出特效的位置(可 reparent 到骨骼)
|
||||
# fx.play() # 或 fx.play(true) 一次性
|
||||
#
|
||||
# 覆盖 Particle 组:发射形状 / 速率 / 寿命 / 方向 / 重力 / 缩放曲线 / 颜色渐变 /
|
||||
# billboard / 加法混合 / 旋转。纹理(.dds)暂用程序化径向渐变代替(GDScript 不解 DDS)。
|
||||
# Mesh 组:占位(MeshFileName 是 .mde,另需解码器)。SimpleLight:本资产集未用。
|
||||
extends Node3D
|
||||
|
||||
var spec := {}
|
||||
var assets_root := ""
|
||||
var one_shot := false
|
||||
var _emitters: Array[GPUParticles3D] = []
|
||||
var _mesh_nodes: Array[Node3D] = []
|
||||
var _bsphere_r := 0.0
|
||||
|
||||
static var _glow_tex: Texture2D
|
||||
|
||||
func build(mse_spec: Dictionary, assets := "") -> void:
|
||||
spec = mse_spec
|
||||
assets_root = assets
|
||||
_bsphere_r = float(spec.get("bsphere_r", 0.0))
|
||||
for p in spec.get("particles", []):
|
||||
var e := _build_particle(p)
|
||||
if e:
|
||||
add_child(e)
|
||||
_emitters.append(e)
|
||||
for m in spec.get("meshes", []):
|
||||
var mn := _build_mesh(m)
|
||||
if mn:
|
||||
add_child(mn)
|
||||
_mesh_nodes.append(mn)
|
||||
|
||||
func play(force_one_shot := false) -> void:
|
||||
for i in _emitters.size():
|
||||
var e := _emitters[i]
|
||||
var st := float(_emitters[i].get_meta("start_time", 0.0))
|
||||
if force_one_shot:
|
||||
e.one_shot = true
|
||||
var tree := get_tree()
|
||||
if st <= 0.0 or tree == null:
|
||||
e.restart()
|
||||
e.emitting = true
|
||||
else:
|
||||
tree.create_timer(st).timeout.connect(func():
|
||||
if is_instance_valid(e):
|
||||
e.restart()
|
||||
e.emitting = true)
|
||||
var tr := get_tree()
|
||||
if (force_one_shot or one_shot) and tr:
|
||||
var total := _longest_life() + 0.5
|
||||
tr.create_timer(maxf(total, 1.5)).timeout.connect(queue_free)
|
||||
|
||||
func stop() -> void:
|
||||
for e in _emitters:
|
||||
e.emitting = false
|
||||
|
||||
func _longest_life() -> float:
|
||||
var m := 1.0
|
||||
for e in _emitters:
|
||||
m = maxf(m, e.lifetime + float(e.get_meta("start_time", 0.0)))
|
||||
return m
|
||||
|
||||
# --- particle ---------------------------------------------------------
|
||||
|
||||
func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
var emit: Dictionary = p.get("emitter", {})
|
||||
var prop: Dictionary = p.get("particle", {})
|
||||
var g := GPUParticles3D.new()
|
||||
g.set_meta("start_time", float(p.get("start_time", 0.0)))
|
||||
g.amount = maxi(1, int(_n(emit.get("MaxEmissionCount", 16))))
|
||||
g.explosiveness = 0.0
|
||||
|
||||
var life := _last_val(emit.get("TimeEventLifeTime", []), 1.0)
|
||||
g.lifetime = clampf(life, 0.05, 12.0)
|
||||
var cycle := _n(emit.get("CycleLength", 0.0))
|
||||
var loop := int(_n(emit.get("CycleLoopEnable", 0)))
|
||||
g.one_shot = (loop == 0)
|
||||
if cycle > 0.0 and loop != 0:
|
||||
g.lifetime = maxf(g.lifetime, cycle)
|
||||
|
||||
var pm := ParticleProcessMaterial.new()
|
||||
# 发射形状
|
||||
var shape := int(_n(emit.get("EmitterShape", 0)))
|
||||
if shape == 3:
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
||||
pm.emission_sphere_radius = maxf(0.01, _n(emit.get("EmittingRadius", 10.0)) * 0.01)
|
||||
else:
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT
|
||||
# 方向 / 速度
|
||||
var vel := _last_val(emit.get("TimeEventEmittingVelocity", []), 0.0) * 0.01
|
||||
pm.direction = Vector3(0, 1, 0)
|
||||
pm.spread = 25.0
|
||||
pm.initial_velocity_min = vel * 0.6
|
||||
pm.initial_velocity_max = maxf(vel, 0.05)
|
||||
# 重力
|
||||
var grav := _last_val(p.get("particle", {}).get("TimeEventGravity", []), 0.0)
|
||||
pm.gravity = Vector3(0, -grav * 0.01, 0)
|
||||
# 大小
|
||||
var sx := _last_val(emit.get("TimeEventSizeX", []), 32.0) * 0.01
|
||||
pm.scale_min = maxf(0.02, sx * 0.7)
|
||||
pm.scale_max = maxf(0.03, sx)
|
||||
pm.scale_curve = _curve_tex(prop.get("TimeEventScaleX", [[0, 1], [1, 0]]))
|
||||
# 颜色渐变(RGB 曲线各取末值 + Alpha 曲线)
|
||||
pm.color = _rgb(prop)
|
||||
var ramp := _alpha_ramp(prop.get("TimeEventAlpha", []), pm.color)
|
||||
if ramp:
|
||||
pm.color_ramp = ramp
|
||||
# 旋转
|
||||
var rspeed := _n(prop.get("RotationSpeed", 0.0))
|
||||
if rspeed != 0.0:
|
||||
pm.angular_velocity_min = deg_to_rad(rspeed) * 0.3
|
||||
pm.angular_velocity_max = deg_to_rad(rspeed)
|
||||
g.process_material = pm
|
||||
|
||||
# 绘制网格 + 材质(billboard + 混合)
|
||||
var qm := QuadMesh.new()
|
||||
qm.size = Vector2(1, 1)
|
||||
g.draw_pass_1 = qm
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.billboard_mode = _billboard(int(_n(prop.get("BillboardType", 1))))
|
||||
mat.billboard_keep_scale = true
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.blend_mode = _blend(int(_n(prop.get("SrcBlendType", 5))), int(_n(prop.get("DestBlendType", 2))))
|
||||
mat.albedo_texture = _glow()
|
||||
mat.vertex_color_use_as_albedo = true
|
||||
g.material_override = mat
|
||||
return g
|
||||
|
||||
func _build_mesh(m: Dictionary) -> Node3D:
|
||||
# .mde 需要专门解码器;先占位一个小 box,位置对
|
||||
var n := Node3D.new()
|
||||
n.name = "mesh_" + String(m.get("mesh_file", "?")).get_basename()
|
||||
var mi := MeshInstance3D.new()
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = Vector3(0.1, 0.1, 0.4)
|
||||
mi.mesh = bm
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(1, 0.9, 0.5, 0.6)
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mi.material_override = mat
|
||||
n.add_child(mi)
|
||||
return n
|
||||
|
||||
# --- helpers ---------------------------------------------------------
|
||||
|
||||
func _n(v) -> float:
|
||||
if v is Array:
|
||||
return float(v[0]) if v.size() > 0 else 0.0
|
||||
if v is float or v is int:
|
||||
return float(v)
|
||||
return 0.0
|
||||
|
||||
# List 表末行的值(列 idx,默认 col 1 = 时间后第一个数)
|
||||
func _last_val(rows, def: float, col := 1) -> float:
|
||||
if rows is Array and rows.size() > 0:
|
||||
var r = rows[-1]
|
||||
if r is Array and r.size() > col and (r[col] is float or r[col] is int):
|
||||
return float(r[col])
|
||||
return def
|
||||
|
||||
func _rgb(prop: Dictionary) -> Color:
|
||||
return Color(
|
||||
_last_val(prop.get("TimeEventColorRed", []), 1.0),
|
||||
_last_val(prop.get("TimeEventColorGreen", []), 1.0),
|
||||
_last_val(prop.get("TimeEventColorBlue", []), 1.0),
|
||||
1.0)
|
||||
|
||||
# TimeEventScaleX rows [[t,v],...] -> CurveTexture
|
||||
func _curve_tex(rows) -> CurveTexture:
|
||||
var c := Curve.new()
|
||||
c.min_value = 0.0
|
||||
c.max_value = 2.0
|
||||
if rows is Array and rows.size() > 0:
|
||||
for r in rows:
|
||||
if r is Array and r.size() >= 2:
|
||||
c.add_point(Vector2(clampf(float(r[0]), 0, 1), float(r[1])))
|
||||
else:
|
||||
c.add_point(Vector2(0, 1))
|
||||
c.add_point(Vector2(1, 0))
|
||||
var t := CurveTexture.new()
|
||||
t.curve = c
|
||||
return t
|
||||
|
||||
func _alpha_ramp(rows, base: Color) -> GradientTexture1D:
|
||||
if not (rows is Array) or rows.size() < 1:
|
||||
return null
|
||||
var grad := Gradient.new()
|
||||
grad.offsets = PackedFloat32Array()
|
||||
grad.colors = PackedColorArray()
|
||||
var pts := []
|
||||
for r in rows:
|
||||
if r is Array and r.size() >= 2:
|
||||
pts.append([clampf(float(r[0]), 0, 1), float(r[1])])
|
||||
if pts.is_empty():
|
||||
return null
|
||||
pts.sort_custom(func(a, b): return a[0] < b[0])
|
||||
if pts[0][0] > 0.0:
|
||||
pts.push_front([0.0, pts[0][1]])
|
||||
if pts[-1][0] < 1.0:
|
||||
pts.append([1.0, pts[-1][1]])
|
||||
for pt in pts:
|
||||
grad.add_point(pt[0], Color(base.r, base.g, base.b, clampf(pt[1], 0, 1)))
|
||||
grad.remove_point(0)
|
||||
grad.remove_point(0)
|
||||
var gt := GradientTexture1D.new()
|
||||
gt.gradient = grad
|
||||
return gt
|
||||
|
||||
func _billboard(bt: int) -> int:
|
||||
# 0 = none/local, 1 = 面向相机, 4 = Y 轴
|
||||
if bt == 4:
|
||||
return BaseMaterial3D.BILLBOARD_FIXED_Y
|
||||
if bt == 0:
|
||||
return BaseMaterial3D.BILLBOARD_DISABLED
|
||||
return BaseMaterial3D.BILLBOARD_ENABLED
|
||||
|
||||
func _blend(src: int, dst: int) -> int:
|
||||
# D3D blend: 5=SRCALPHA 2=ONE 4=INVSRCALPHA 3=SRCCOLOR
|
||||
if dst == 2:
|
||||
return BaseMaterial3D.BLEND_MODE_ADD
|
||||
if dst == 4:
|
||||
return BaseMaterial3D.BLEND_MODE_MIX
|
||||
return BaseMaterial3D.BLEND_MODE_ADD
|
||||
|
||||
# 程序化径向渐变(代替 .dds 粒子纹理)
|
||||
static func _glow() -> Texture2D:
|
||||
if _glow_tex == null:
|
||||
var s := 48
|
||||
var img := Image.create(s, s, false, Image.FORMAT_RGBA8)
|
||||
for y in s:
|
||||
for x in s:
|
||||
var d := Vector2(x - s / 2.0, y - s / 2.0).length() / (s / 2.0)
|
||||
var a := clampf(1.0 - d, 0.0, 1.0)
|
||||
a = a * a
|
||||
img.set_pixel(x, y, Color(1, 1, 1, a))
|
||||
_glow_tex = ImageTexture.create_from_image(img)
|
||||
return _glow_tex
|
||||
@@ -0,0 +1 @@
|
||||
uid://dusxpcvp74to5
|
||||
@@ -0,0 +1,99 @@
|
||||
# EffectRegistry (P5) —— 按名字 / 路径找 `.mse`,缓存解析结果,生成 EffectPlayer。
|
||||
#
|
||||
# var fxr := preload("res://fx/effect_registry.gd").new()
|
||||
# fxr.setup(assets_root)
|
||||
# fxr.spawn("geompung_3_sword", weapon_bone_node) # 挂到某节点下播一次性
|
||||
# var pv := fxr.spawn_at("hit_spark", world, global_pos)
|
||||
extends RefCounted
|
||||
|
||||
const Mse = preload("res://fx/mse.gd")
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
|
||||
var assets_root := ""
|
||||
var _path_cache := {} # name -> abs .mse path ("" = not found)
|
||||
var _spec_cache := {} # abs path -> parsed spec
|
||||
|
||||
func setup(assets: String) -> void:
|
||||
assets_root = assets
|
||||
|
||||
# name 可为: 裸名 "xxx"(扫 **/effect/ 下 xxx.mse)· 相对 "effect/xxx.mse" · "d:/ymir work/.../xxx.mse"
|
||||
func resolve(name: String) -> String:
|
||||
if _path_cache.has(name):
|
||||
return _path_cache[name]
|
||||
var p := _resolve_uncached(name)
|
||||
_path_cache[name] = p
|
||||
return p
|
||||
|
||||
func _resolve_uncached(name: String) -> String:
|
||||
if assets_root == "":
|
||||
return ""
|
||||
var rel := name.replace("\\", "/")
|
||||
if rel.length() >= 2 and rel[1] == ":":
|
||||
rel = rel.substr(2)
|
||||
rel = rel.lstrip("/")
|
||||
if not rel.ends_with(".mse"):
|
||||
rel += ".mse"
|
||||
# 直接 / 散包相对
|
||||
for cand in [assets_root.path_join(rel)]:
|
||||
if FileAccess.file_exists(cand):
|
||||
return cand
|
||||
var da := DirAccess.open(assets_root)
|
||||
if da:
|
||||
for sub in da.get_directories():
|
||||
var c := assets_root.path_join(sub).path_join(rel)
|
||||
if FileAccess.file_exists(c):
|
||||
return c
|
||||
# 裸名:按 basename 递归找(限定 effect 目录,限量)
|
||||
if not name.contains("/"):
|
||||
var bn := rel.get_file()
|
||||
var hit := _scan_for(assets_root, bn, 7)
|
||||
if hit != "":
|
||||
return hit
|
||||
return ""
|
||||
|
||||
func _scan_for(dir: String, basename: String, depth: int) -> String:
|
||||
if depth < 0:
|
||||
return ""
|
||||
var da := DirAccess.open(dir)
|
||||
if da == null:
|
||||
return ""
|
||||
for f in da.get_files():
|
||||
if f == basename:
|
||||
return dir.path_join(f)
|
||||
for sub in da.get_directories():
|
||||
if sub.begins_with("."):
|
||||
continue
|
||||
var r := _scan_for(dir.path_join(sub), basename, depth - 1)
|
||||
if r != "":
|
||||
return r
|
||||
return ""
|
||||
|
||||
func spec_for(name: String) -> Dictionary:
|
||||
var p := resolve(name)
|
||||
if p == "":
|
||||
return {}
|
||||
if _spec_cache.has(p):
|
||||
return _spec_cache[p]
|
||||
var s := Mse.new().parse_file(p)
|
||||
_spec_cache[p] = s
|
||||
return s
|
||||
|
||||
# 挂到 parent 下(跟随 parent 变换),播一次性
|
||||
func spawn(name: String, parent: Node3D, one_shot := true) -> Node3D:
|
||||
var s := spec_for(name)
|
||||
if s.is_empty() or parent == null:
|
||||
return null
|
||||
var fx: Node3D = EffectPlayer.new()
|
||||
fx.name = "fx_" + name.get_file().get_basename()
|
||||
fx.one_shot = one_shot
|
||||
fx.build(s, assets_root)
|
||||
parent.add_child(fx)
|
||||
fx.play(one_shot)
|
||||
return fx
|
||||
|
||||
# 在世界某点播一次性
|
||||
func spawn_at(name: String, world_parent: Node3D, global_pos: Vector3, one_shot := true) -> Node3D:
|
||||
var fx := spawn(name, world_parent, one_shot)
|
||||
if fx:
|
||||
fx.global_position = global_pos
|
||||
return fx
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyu4mphxwm5ti
|
||||
@@ -0,0 +1,235 @@
|
||||
# Mse (P5) —— 解析 Metin2 `.mse` 特效脚本(EffectLib,非逆向,有完整源码)。
|
||||
#
|
||||
# `.mse` 是 CTextFileLoader 花括号文本树:
|
||||
# BoundingSphereRadius 120.0
|
||||
# Group Particle {
|
||||
# StartTime 0.0
|
||||
# List TimeEventPosition { 0.0 "MOVING_TYPE_DIRECT" 8.0 0.0 25.0 }
|
||||
# Group EmitterProperty { MaxEmissionCount 5 CycleLength 0.4 List TimeEventSizeX { 0.0 64.0 } ... }
|
||||
# Group ParticleProperty { SrcBlendType 5 BillboardType 1 List TimeEventAlpha { 0.0 0.71 0.86 0.0 } List TextureFiles { "x.dds" } }
|
||||
# }
|
||||
# Group Mesh { MeshFileName "a.mde" MeshElementCount 1 Group MeshElement00 { ... } }
|
||||
#
|
||||
# var m := preload("res://fx/mse.gd").new()
|
||||
# var spec := m.parse_file(path)
|
||||
# # spec = { bsphere_r, bsphere_pos:Vector3, particles:[Dict], meshes:[Dict], dir }
|
||||
#
|
||||
# 每个 particle dict 保留原始键(大小写不变):
|
||||
# start_time:float, position: [ [t, "MOVING_TYPE", x,y,z], ... ],
|
||||
# emitter: Dict, particle: Dict (List 键 -> Array[Array],标量键 -> float/String/Array)
|
||||
extends RefCounted
|
||||
|
||||
var last_error := ""
|
||||
|
||||
var _toks: Array = []
|
||||
var _p := 0
|
||||
|
||||
func parse_file(path: String) -> Dictionary:
|
||||
last_error = ""
|
||||
if not FileAccess.file_exists(path):
|
||||
last_error = "no such file: " + path
|
||||
return {}
|
||||
var spec := parse_text(FileAccess.get_file_as_string(path))
|
||||
spec["dir"] = path.get_base_dir()
|
||||
return spec
|
||||
|
||||
func parse_text(src: String) -> Dictionary:
|
||||
_toks = _tokenize(src)
|
||||
_p = 0
|
||||
var top := _parse_block(true)
|
||||
var out := {
|
||||
"bsphere_r": float(_scalar(top.get("BoundingSphereRadius", 0.0))),
|
||||
"bsphere_pos": _vec3(top.get("BoundingSpherePosition", [0, 0, 0])),
|
||||
"particles": [],
|
||||
"meshes": [],
|
||||
"dir": "",
|
||||
}
|
||||
for g in top.get("__group_Particle", []):
|
||||
out.particles.append(_norm_particle(g))
|
||||
for g in top.get("__group_Mesh", []):
|
||||
out.meshes.append(_norm_mesh(g))
|
||||
return out
|
||||
|
||||
# --- 归一化 -----------------------------------------------------------
|
||||
|
||||
func _norm_particle(g: Dictionary) -> Dictionary:
|
||||
return {
|
||||
"start_time": float(_scalar(g.get("StartTime", 0.0))),
|
||||
"position": g.get("TimeEventPosition", []),
|
||||
"emitter": _first(g.get("__group_EmitterProperty", [])),
|
||||
"particle": _first(g.get("__group_ParticleProperty", [])),
|
||||
}
|
||||
|
||||
func _norm_mesh(g: Dictionary) -> Dictionary:
|
||||
var elems := []
|
||||
for k in g:
|
||||
if String(k).begins_with("__group_MeshElement"):
|
||||
for e in g[k]:
|
||||
elems.append(e)
|
||||
return {
|
||||
"start_time": float(_scalar(g.get("StartTime", 0.0))),
|
||||
"position": g.get("TimeEventPosition", []),
|
||||
"mesh_file": String(_scalar(g.get("MeshFileName", ""))),
|
||||
"frame_delay": float(_scalar(g.get("MeshAnimationFrameDelay", 0.0))),
|
||||
"elements": elems,
|
||||
}
|
||||
|
||||
func _first(a) -> Dictionary:
|
||||
return a[0] if a is Array and a.size() > 0 else {}
|
||||
|
||||
func _scalar(v):
|
||||
if v is Array:
|
||||
return v[0] if v.size() > 0 else 0.0
|
||||
return v
|
||||
|
||||
func _vec3(v) -> Vector3:
|
||||
if v is Array and v.size() >= 3:
|
||||
return Vector3(float(v[0]), float(v[1]), float(v[2]))
|
||||
return Vector3.ZERO
|
||||
|
||||
# --- 词法 -----------------------------------------------------------
|
||||
# tokens: "{" "}" "\n" · {"s": <quoted string>} · 裸词/数字(String)
|
||||
|
||||
func _tokenize(src: String) -> Array:
|
||||
var out := []
|
||||
var i := 0
|
||||
var n := src.length()
|
||||
while i < n:
|
||||
var c := src[i]
|
||||
if c == "\n":
|
||||
out.append("\n")
|
||||
i += 1
|
||||
elif c in [" ", "\t", "\r"]:
|
||||
i += 1
|
||||
elif c == '"':
|
||||
var j := i + 1
|
||||
var s := ""
|
||||
while j < n and src[j] != '"':
|
||||
s += src[j]
|
||||
j += 1
|
||||
out.append({"q": s}) # 带引号的字符串(q 键区分裸词)
|
||||
i = j + 1
|
||||
elif c == "{" or c == "}":
|
||||
out.append(c)
|
||||
i += 1
|
||||
elif c == "#" or (c == "/" and i + 1 < n and src[i + 1] == "/"):
|
||||
while i < n and src[i] != "\n":
|
||||
i += 1
|
||||
else:
|
||||
var j := i
|
||||
while j < n and not (src[j] in [" ", "\t", "\r", "\n", "{", "}", '"']):
|
||||
j += 1
|
||||
out.append(src.substr(i, j - i))
|
||||
i = j
|
||||
return out
|
||||
|
||||
func _eq(v, ch) -> bool:
|
||||
return v is String and v == ch
|
||||
|
||||
func _val(t):
|
||||
if t is Dictionary:
|
||||
return t.get("q", "")
|
||||
var s := String(t)
|
||||
# 数字?
|
||||
if s.is_valid_float():
|
||||
return float(s)
|
||||
if s.is_valid_int():
|
||||
return int(s)
|
||||
return s
|
||||
|
||||
# 解析一个 { } 块(top=true 时是隐式顶层)。
|
||||
func _parse_block(top := false) -> Dictionary:
|
||||
var d := {}
|
||||
var guard := 0
|
||||
while _p < _toks.size():
|
||||
guard += 1
|
||||
if guard > 500000:
|
||||
last_error = "runaway"
|
||||
break
|
||||
var t = _toks[_p]
|
||||
if _eq(t, "}"):
|
||||
_p += 1
|
||||
return d
|
||||
if _eq(t, "\n"):
|
||||
_p += 1
|
||||
continue
|
||||
if _eq(t, "{"):
|
||||
_p += 1
|
||||
continue
|
||||
var word := "" if (t is Dictionary) else String(t)
|
||||
if word == "Group":
|
||||
_p += 1
|
||||
var name := _next_word()
|
||||
_skip_newlines()
|
||||
if _eq(_cur(), "{"):
|
||||
_p += 1
|
||||
var sub := _parse_block()
|
||||
var key := "__group_" + name
|
||||
if not d.has(key):
|
||||
d[key] = []
|
||||
d[key].append(sub)
|
||||
continue
|
||||
if word == "List":
|
||||
_p += 1
|
||||
var lname := _next_word()
|
||||
_skip_newlines()
|
||||
var rows := []
|
||||
if _eq(_cur(), "{"):
|
||||
_p += 1
|
||||
rows = _parse_list_rows()
|
||||
d[lname] = rows
|
||||
continue
|
||||
# KEY v1 v2 ... (到行尾)
|
||||
var key := word
|
||||
_p += 1
|
||||
var vals := []
|
||||
while _p < _toks.size() and not _eq(_toks[_p], "\n") and not _eq(_toks[_p], "{") and not _eq(_toks[_p], "}"):
|
||||
vals.append(_val(_toks[_p]))
|
||||
_p += 1
|
||||
d[key] = vals[0] if vals.size() == 1 else vals
|
||||
if top and _p >= _toks.size():
|
||||
break
|
||||
return d
|
||||
|
||||
func _parse_list_rows() -> Array:
|
||||
var rows := []
|
||||
var row := []
|
||||
var guard := 0
|
||||
while _p < _toks.size():
|
||||
guard += 1
|
||||
if guard > 200000:
|
||||
break
|
||||
var t = _toks[_p]
|
||||
if _eq(t, "}"):
|
||||
_p += 1
|
||||
if not row.is_empty():
|
||||
rows.append(row)
|
||||
break
|
||||
if _eq(t, "\n"):
|
||||
_p += 1
|
||||
if not row.is_empty():
|
||||
rows.append(row)
|
||||
row = []
|
||||
continue
|
||||
if _eq(t, "{"):
|
||||
_p += 1
|
||||
continue
|
||||
row.append(_val(t))
|
||||
_p += 1
|
||||
return rows
|
||||
|
||||
func _cur():
|
||||
return _toks[_p] if _p < _toks.size() else null
|
||||
|
||||
func _next_word() -> String:
|
||||
while _p < _toks.size() and _eq(_toks[_p], "\n"):
|
||||
_p += 1
|
||||
if _p < _toks.size() and not (_toks[_p] is Dictionary):
|
||||
var w := String(_toks[_p])
|
||||
_p += 1
|
||||
return w
|
||||
return "?"
|
||||
|
||||
func _skip_newlines() -> void:
|
||||
while _p < _toks.size() and _eq(_toks[_p], "\n"):
|
||||
_p += 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://c728y7nra8uhh
|
||||
@@ -0,0 +1,74 @@
|
||||
# SkillFx (P6 收尾) —— 技能 id / 内建特效 id → .mse 文件,接 P5 EffectRegistry。
|
||||
#
|
||||
# var sfx := preload("res://fx/skill_fx.gd").new()
|
||||
# sfx.setup(effect_registry, skill_table)
|
||||
# sfx.spawn_skill(1, 3, caster_node) # 技能 1(samyeon) grade→_4 挂 caster 播
|
||||
# sfx.spawn_special(19, node) # GC_SPECIAL_EFFECT type 19 = SE_AUTO_HPUP -> drugup_red
|
||||
#
|
||||
# 技能特效表:skilldesc 的 motion_name(col13)就是特效基名;等级后缀 = 2 + master_type(夹 2..4)。
|
||||
# effect/pc/<class>/effect/<motion>_<N>.mse(EffectRegistry 的 basename 扫描能找到散包 / PC / season1)
|
||||
# 少数技能有 _blow / _head / _foot / _yong 子特效;这里只放主特效,缺就跳过。
|
||||
extends RefCounted
|
||||
|
||||
# GC_SPECIAL_EFFECT 的 type(ItemData.h / Packet.h 的 SE_* enum,值 = 下标)-> etc 特效基名。
|
||||
# 不全 —— 只填常见的(真机 GM 号狂刷 19/20 自动喝药)。
|
||||
const SPECIAL_FX := {
|
||||
1: "drugup_red", # SE_HPUP_RED
|
||||
2: "drugup_blue", # SE_SPUP_BLUE
|
||||
3: "drugup_green", # SE_SPEEDUP_GREEN
|
||||
4: "drugup_purple", # SE_DXUP_PURPLE
|
||||
9: "firecracker_1", # SE_CHINA_FIREWORK
|
||||
11: "firecracker_2", # SE_SUCCESS(凑合)
|
||||
12: "fail", # SE_FAIL
|
||||
19: "drugup_red", # SE_AUTO_HPUP
|
||||
20: "drugup_blue", # SE_AUTO_SPUP
|
||||
}
|
||||
|
||||
var fxr: RefCounted # EffectRegistry
|
||||
var table: RefCounted # SkillTable
|
||||
# 一个 motion 常见的子特效后缀(有就一起播)
|
||||
const SUB_SUFFIX := ["", "_blow", "_hand", "_head", "_foot", "_yong"]
|
||||
|
||||
func setup(effect_registry: RefCounted, skill_table: RefCounted) -> void:
|
||||
fxr = effect_registry
|
||||
table = skill_table
|
||||
|
||||
# skill_id + master_type(0..3) -> 主特效基名(<motion>_<N>)。查不到 motion 返回 ""。
|
||||
func skill_effect_name(skill_id: int, master: int = 0) -> String:
|
||||
if table == null:
|
||||
return ""
|
||||
var mn := String(table.entry(skill_id).get("motion", ""))
|
||||
if mn == "":
|
||||
return ""
|
||||
var n := clampi(2 + master, 2, 4)
|
||||
return "%s_%d" % [mn, n]
|
||||
|
||||
# 在 host 下播技能特效(主 + 已存在的子特效各一份)。返回播了几个。
|
||||
func spawn_skill(skill_id: int, master: int, host: Node3D) -> int:
|
||||
if fxr == null or host == null:
|
||||
return 0
|
||||
var base := skill_effect_name(skill_id, master)
|
||||
if base == "":
|
||||
return 0
|
||||
var parts := base.rsplit("_", false, 1) # "samyeon_4" -> ["samyeon", "4"]
|
||||
var stem: String = parts[0]
|
||||
var num: String = parts[1] if parts.size() > 1 else ""
|
||||
var played := 0
|
||||
for suf in SUB_SUFFIX:
|
||||
var name: String = base
|
||||
if suf != "":
|
||||
name = "%s%s_%s" % [stem, suf, num] if num != "" else stem + suf
|
||||
if fxr.resolve(name) != "":
|
||||
fxr.spawn(name, host, true)
|
||||
played += 1
|
||||
return played
|
||||
|
||||
# GC_SPECIAL_EFFECT type -> 内建特效。返回是否播了。
|
||||
func spawn_special(special_id: int, host: Node3D) -> bool:
|
||||
if fxr == null or host == null or not SPECIAL_FX.has(special_id):
|
||||
return false
|
||||
var name: String = SPECIAL_FX[special_id]
|
||||
if fxr.resolve(name) == "":
|
||||
return false
|
||||
fxr.spawn(name, host, true)
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://dit70gssglg2b
|
||||
@@ -0,0 +1,83 @@
|
||||
# Weather (P9) —— 雪 / 雨 环境粒子。跟随相机,在其上方一片区域内下落。
|
||||
#
|
||||
# var w := preload("res://fx/weather.gd").new()
|
||||
# add_child(w)
|
||||
# w.setup(camera) # camera: Camera3D / Node3D
|
||||
# w.set_weather("snow") # "snow" | "rain" | "none"
|
||||
#
|
||||
# 用 GPUParticles3D + ParticleProcessMaterial 的盒子发射体,粒子朝下。
|
||||
# 由地图属性或调试驱动;没有专门封包。
|
||||
extends Node3D
|
||||
|
||||
var _cam: Node3D
|
||||
var _p: GPUParticles3D
|
||||
var _kind := "none"
|
||||
|
||||
func setup(camera: Node3D) -> void:
|
||||
_cam = camera
|
||||
_p = GPUParticles3D.new()
|
||||
_p.amount = 800
|
||||
_p.lifetime = 3.0
|
||||
_p.visibility_aabb = AABB(Vector3(-30, -30, -30), Vector3(60, 60, 60))
|
||||
_p.local_coords = false
|
||||
_p.emitting = false
|
||||
add_child(_p)
|
||||
_build_material()
|
||||
set_weather("none")
|
||||
|
||||
func kind() -> String:
|
||||
return _kind
|
||||
|
||||
func set_weather(k: String) -> void:
|
||||
_kind = k
|
||||
if _p == null:
|
||||
return
|
||||
if k == "none":
|
||||
_p.emitting = false
|
||||
return
|
||||
_p.emitting = true
|
||||
var m: ParticleProcessMaterial = _p.process_material
|
||||
if k == "snow":
|
||||
_p.amount = 700
|
||||
_p.lifetime = 6.0
|
||||
m.gravity = Vector3(0, -2.0, 0)
|
||||
m.initial_velocity_min = 0.5
|
||||
m.initial_velocity_max = 1.5
|
||||
m.scale_min = 0.03
|
||||
m.scale_max = 0.08
|
||||
m.color = Color(1, 1, 1, 0.9)
|
||||
m.turbulence_enabled = true
|
||||
m.turbulence_noise_strength = 0.6
|
||||
elif k == "rain":
|
||||
_p.amount = 1200
|
||||
_p.lifetime = 1.2
|
||||
m.gravity = Vector3(0, -30.0, 0)
|
||||
m.initial_velocity_min = 12.0
|
||||
m.initial_velocity_max = 18.0
|
||||
m.scale_min = 0.015
|
||||
m.scale_max = 0.03
|
||||
m.color = Color(0.7, 0.8, 0.95, 0.5)
|
||||
m.turbulence_enabled = false
|
||||
|
||||
func _build_material() -> void:
|
||||
var m := ParticleProcessMaterial.new()
|
||||
m.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
m.emission_box_extents = Vector3(25, 1, 25)
|
||||
m.direction = Vector3(0, -1, 0)
|
||||
m.spread = 8.0
|
||||
m.gravity = Vector3(0, -2.0, 0)
|
||||
_p.process_material = m
|
||||
var mesh := QuadMesh.new()
|
||||
mesh.size = Vector2(0.1, 0.1)
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
mat.albedo_color = Color(1, 1, 1, 0.85)
|
||||
mesh.material = mat
|
||||
_p.draw_pass_1 = mesh
|
||||
|
||||
func _process(_dt: float) -> void:
|
||||
if _cam and _p:
|
||||
# 发射体盒子锚在相机上方 20m
|
||||
_p.global_position = _cam.global_position + Vector3(0, 20, 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj5x183tcq55n
|
||||
@@ -0,0 +1,138 @@
|
||||
# fx_test —— P5:.mse 解析 + EffectPlayer 构建 headless 自检。
|
||||
# godot --headless --path project --script fx_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const Mse = preload("res://fx/mse.gd")
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
const EffectRegistry = preload("res://fx/effect_registry.gd")
|
||||
|
||||
const FIXTURE := """
|
||||
BoundingSphereRadius 120.000000
|
||||
BoundingSpherePosition 0.000000 0.000000 100.000000
|
||||
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.000000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 8.000000 0.000000 25.000000
|
||||
}
|
||||
Group EmitterProperty
|
||||
{
|
||||
MaxEmissionCount 5
|
||||
CycleLength 0.400000
|
||||
CycleLoopEnable 1
|
||||
EmitterShape 3
|
||||
EmittingRadius 10.000000
|
||||
List TimeEventLifeTime
|
||||
{
|
||||
0.000000 0.077320
|
||||
}
|
||||
List TimeEventSizeX
|
||||
{
|
||||
0.000000 64.000000
|
||||
}
|
||||
}
|
||||
Group ParticleProperty
|
||||
{
|
||||
SrcBlendType 5
|
||||
DestBlendType 2
|
||||
BillboardType 1
|
||||
RotationSpeed 500.000000
|
||||
List TimeEventScaleX
|
||||
{
|
||||
0.000000 1.000000
|
||||
1.000000 0.000000
|
||||
}
|
||||
List TimeEventColorRed { 0.000000 0.886275 }
|
||||
List TimeEventColorGreen { 0.000000 0.086275 }
|
||||
List TimeEventColorBlue { 0.000000 0.239216 }
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.000000 0.710000
|
||||
0.864103 0.000000
|
||||
}
|
||||
List TextureFiles { "bottom_under.dds" }
|
||||
}
|
||||
}
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.200000
|
||||
Group EmitterProperty { MaxEmissionCount 10 EmitterShape 0 }
|
||||
Group ParticleProperty { SrcBlendType 5 DestBlendType 4 BillboardType 4 }
|
||||
}
|
||||
"""
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_test_parser()
|
||||
_test_builder()
|
||||
_test_registry()
|
||||
if _fail == 0:
|
||||
print("PASS: fx_test (.mse parser + EffectPlayer + registry)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _test_parser() -> void:
|
||||
var m := Mse.new()
|
||||
var s := m.parse_text(FIXTURE)
|
||||
_ck(m.last_error == "", "parse: no error (%s)" % m.last_error)
|
||||
_ck(abs(float(s.bsphere_r) - 120.0) < 0.01, "bsphere radius")
|
||||
_ck(s.bsphere_pos == Vector3(0, 0, 100), "bsphere pos = (0,0,100)")
|
||||
_ck(s.particles.size() == 2, "2 Particle groups (%d)" % s.particles.size())
|
||||
var p0: Dictionary = s.particles[0]
|
||||
_ck(abs(p0.start_time) < 0.01, "p0 start_time 0")
|
||||
_ck(p0.position.size() == 1 and String(p0.position[0][1]) == "MOVING_TYPE_DIRECT", "p0 TimeEventPosition row parsed with quoted enum")
|
||||
_ck(int(p0.emitter.get("MaxEmissionCount")) == 5, "p0 emitter MaxEmissionCount 5")
|
||||
_ck(int(p0.emitter.get("EmitterShape")) == 3, "p0 EmitterShape 3")
|
||||
var scale_rows = p0.particle.get("TimeEventScaleX")
|
||||
_ck(scale_rows is Array and scale_rows.size() == 2 and float(scale_rows[1][1]) == 0.0, "p0 ScaleX list = 2 rows, last v=0")
|
||||
var tex = p0.particle.get("TextureFiles")
|
||||
_ck(tex is Array and tex.size() == 1 and String(tex[0][0]) == "bottom_under.dds", "p0 TextureFiles")
|
||||
_ck(abs(float(s.particles[1].start_time) - 0.2) < 0.01, "p1 start_time 0.2")
|
||||
|
||||
func _test_builder() -> void:
|
||||
var m := Mse.new()
|
||||
var s := m.parse_text(FIXTURE)
|
||||
var fx: Node3D = EffectPlayer.new()
|
||||
get_root().add_child(fx)
|
||||
fx.build(s, "")
|
||||
_ck(fx._emitters.size() == 2, "EffectPlayer built 2 GPUParticles3D")
|
||||
var g0: GPUParticles3D = fx._emitters[0]
|
||||
_ck(g0.amount == 5, "emitter 0 amount = 5")
|
||||
_ck(g0.process_material is ParticleProcessMaterial, "emitter 0 has process material")
|
||||
var pm: ParticleProcessMaterial = g0.process_material
|
||||
_ck(pm.emission_shape == ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, "emitter 0 sphere emission (shape 3)")
|
||||
_ck(g0.material_override is StandardMaterial3D, "emitter 0 draw material")
|
||||
_ck((g0.material_override as StandardMaterial3D).blend_mode == BaseMaterial3D.BLEND_MODE_ADD, "src5/dst2 -> additive blend")
|
||||
_ck(float(fx._emitters[1].get_meta("start_time")) == 0.2, "emitter 1 start_time meta = 0.2")
|
||||
fx.play()
|
||||
_ck(g0.emitting, "emitter 0 emitting after play() (start_time 0)")
|
||||
fx.queue_free()
|
||||
|
||||
func _test_registry() -> void:
|
||||
var assets := AssetRoot.path()
|
||||
if not DirAccess.dir_exists_absolute(assets):
|
||||
print(" (skip registry: no assets)")
|
||||
return
|
||||
var fxr := EffectRegistry.new()
|
||||
fxr.setup(assets)
|
||||
# 已知存在: PC/ymir work/pc/warrior/effect/geompung_3_sword.mse
|
||||
var p := fxr.resolve("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse")
|
||||
if p == "":
|
||||
p = fxr.resolve("geompung_3_sword")
|
||||
_ck(p != "" and FileAccess.file_exists(p), "registry resolved a real .mse (%s)" % p)
|
||||
if p != "":
|
||||
var s := fxr.spec_for("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse")
|
||||
_ck(s.get("particles", []).size() > 0, "real .mse parsed to >=1 particle (%d)" % s.get("particles", []).size())
|
||||
var parent := Node3D.new()
|
||||
get_root().add_child(parent)
|
||||
var fx := fxr.spawn("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse", parent, true)
|
||||
_ck(fx != null and fx.get_parent() == parent, "spawn attached EffectPlayer")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bporisusalpvj
|
||||
@@ -0,0 +1,191 @@
|
||||
# GameCamera —— Metin2 风第三人称轨道相机。
|
||||
# 桌面:右键拖拽 -> 环绕(yaw/pitch) 滚轮 -> 缩放
|
||||
# 触屏(F7):单指拖拽 -> 环绕 双指捏合 -> 缩放
|
||||
# 跟随 target,地形/建筑防穿。
|
||||
# 用法:var c = preload("res://game_camera.gd").new(); c.target = player;
|
||||
# c.world = metin2_world; add_child(c); c.make_current()
|
||||
extends Camera3D
|
||||
|
||||
var target: Node3D
|
||||
var world: Node # Metin2World,用 sample_height 防穿(可空)
|
||||
var head_offset := Vector3(0, 1.15, 0)
|
||||
|
||||
var yaw := 0.7
|
||||
var pitch := 0.55
|
||||
var dist := 8.0
|
||||
var min_pitch := deg_to_rad(12.0)
|
||||
var max_pitch := deg_to_rad(78.0)
|
||||
var min_dist := 3.5
|
||||
var max_dist := 20.0
|
||||
var sensitivity := 0.006
|
||||
var zoom_step := 1.4
|
||||
var follow_lerp := 12.0
|
||||
|
||||
# F7 触屏手势
|
||||
var touch_sensitivity := 0.005 # 单指拖拽 -> yaw/pitch
|
||||
var pinch_zoom_gain := 0.03 # 捏合像素差 -> dist 变化
|
||||
const _ORBIT_DEADZONE := 8.0 # 单指移动超过这么多像素才算「拖拽环绕」(低于视作点击)
|
||||
|
||||
var _dragging := false
|
||||
var _pos_ready := false
|
||||
var _touches := {} # index:int -> position:Vector2(当前按下的手指)
|
||||
var _orbit_touch := -1 # 正在环绕的手指 index,-1 = 无
|
||||
var _orbit_active := false # 已越过 deadzone
|
||||
var _orbit_press := Vector2.ZERO
|
||||
var _pinch_last := -1.0 # 上一帧两指间距,<0 = 未在捏合
|
||||
|
||||
const OCCLUDER_MASK := 1 << 1 # 静态遮挡物层(Metin2World 给建筑的盒碰撞)
|
||||
const FADE_ALPHA := 0.72 # 挡住玩家时的透明度
|
||||
var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D
|
||||
|
||||
func _ready() -> void:
|
||||
fov = 55.0
|
||||
far = 4000.0
|
||||
if target:
|
||||
global_position = _desired_pos(true)
|
||||
_pos_ready = true
|
||||
|
||||
func _unhandled_input(e: InputEvent) -> void:
|
||||
if e is InputEventMouseButton:
|
||||
if e.button_index == MOUSE_BUTTON_RIGHT:
|
||||
_dragging = e.pressed
|
||||
elif e.button_index == MOUSE_BUTTON_WHEEL_UP and e.pressed:
|
||||
dist = maxf(min_dist, dist - zoom_step)
|
||||
elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN and e.pressed:
|
||||
dist = minf(max_dist, dist + zoom_step)
|
||||
elif e is InputEventMouseMotion and _dragging:
|
||||
yaw -= e.relative.x * sensitivity
|
||||
pitch = clampf(pitch + e.relative.y * sensitivity, min_pitch, max_pitch)
|
||||
elif e is InputEventScreenTouch:
|
||||
_on_touch(e)
|
||||
elif e is InputEventScreenDrag:
|
||||
_on_drag(e)
|
||||
|
||||
func _on_touch(e: InputEventScreenTouch) -> void:
|
||||
if e.pressed:
|
||||
_touches[e.index] = e.position
|
||||
if _touches.size() == 1:
|
||||
_orbit_touch = e.index
|
||||
_orbit_active = false
|
||||
_orbit_press = e.position
|
||||
elif _touches.size() == 2:
|
||||
_orbit_touch = -1 # 第二指落下 -> 转捏合,取消环绕
|
||||
_pinch_last = _pinch_dist()
|
||||
else:
|
||||
_touches.erase(e.index)
|
||||
if _touches.size() < 2:
|
||||
_pinch_last = -1.0
|
||||
if _touches.size() == 1:
|
||||
_orbit_touch = _touches.keys()[0] # 抬起一指后,剩下的接管环绕
|
||||
_orbit_active = false
|
||||
_orbit_press = _touches[_orbit_touch]
|
||||
elif _touches.is_empty():
|
||||
_orbit_touch = -1
|
||||
|
||||
func _on_drag(e: InputEventScreenDrag) -> void:
|
||||
if _touches.has(e.index):
|
||||
_touches[e.index] = e.position
|
||||
if _touches.size() >= 2:
|
||||
var d := _pinch_dist()
|
||||
if _pinch_last > 0.0:
|
||||
# 两指张开(d 变大)-> 拉近;捏拢 -> 推远
|
||||
dist = clampf(dist + (_pinch_last - d) * pinch_zoom_gain, min_dist, max_dist)
|
||||
_pinch_last = d
|
||||
elif e.index == _orbit_touch:
|
||||
if not _orbit_active and e.position.distance_to(_orbit_press) > _ORBIT_DEADZONE:
|
||||
_orbit_active = true
|
||||
if _orbit_active:
|
||||
yaw -= e.relative.x * touch_sensitivity
|
||||
pitch = clampf(pitch + e.relative.y * touch_sensitivity, min_pitch, max_pitch)
|
||||
|
||||
func _pinch_dist() -> float:
|
||||
var ks := _touches.keys()
|
||||
if ks.size() < 2:
|
||||
return 0.0
|
||||
return (_touches[ks[0]] as Vector2).distance_to(_touches[ks[1]] as Vector2)
|
||||
|
||||
func heading() -> float:
|
||||
# 相机看向的水平方向(供角色移动「相对相机」用)
|
||||
return yaw
|
||||
|
||||
func _desired_pos(snap := false) -> Vector3:
|
||||
var head := target.global_position + head_offset
|
||||
var off := Vector3(
|
||||
sin(yaw) * cos(pitch),
|
||||
sin(pitch),
|
||||
cos(yaw) * cos(pitch)) * dist
|
||||
var want := head + off
|
||||
# 地形防穿:沿 head->want 采样,低于地表就把相机拉回
|
||||
if world and world.has_method("sample_height"):
|
||||
var d := want - head
|
||||
var steps := 8
|
||||
for i in range(1, steps + 1):
|
||||
var p := head + d * (float(i) / steps)
|
||||
var gy: float = world.call("sample_height", p.x, p.z) + 0.4
|
||||
if p.y < gy:
|
||||
want = head + d * (float(i - 1) / steps)
|
||||
break
|
||||
# §8.3 建筑防穿:head->want 射线撞静态遮挡物就把相机拉到撞点前
|
||||
var world3d := get_world_3d()
|
||||
if world3d:
|
||||
var q := PhysicsRayQueryParameters3D.create(head, want, OCCLUDER_MASK)
|
||||
var hit := world3d.direct_space_state.intersect_ray(q)
|
||||
if hit:
|
||||
var n := (want - head).normalized()
|
||||
want = hit.position - n * 0.3
|
||||
return want
|
||||
|
||||
var _shake := 0.0 # 当前抖动强度(米),指数衰减
|
||||
var _shake_decay := 8.0
|
||||
|
||||
# P4:受击 / 暴击轻抖。strength 米,decay 越大越快停。
|
||||
func shake(strength: float, decay := 8.0) -> void:
|
||||
_shake = maxf(_shake, strength)
|
||||
_shake_decay = decay
|
||||
|
||||
func _process(dt: float) -> void:
|
||||
if target == null:
|
||||
return
|
||||
var want := _desired_pos()
|
||||
if _pos_ready:
|
||||
global_position = global_position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0))
|
||||
else:
|
||||
global_position = want
|
||||
_pos_ready = true
|
||||
if _shake > 0.001:
|
||||
global_position += Vector3(randf_range(-1, 1), randf_range(-1, 1), randf_range(-1, 1)) * _shake
|
||||
_shake = lerpf(_shake, 0.0, clampf(_shake_decay * dt, 0.0, 1.0))
|
||||
else:
|
||||
_shake = 0.0
|
||||
look_at(target.global_position + head_offset, Vector3.UP)
|
||||
_fade_occluders()
|
||||
|
||||
# §8.2 相机与玩家之间的建筑半透明淡出。沿 cam->head 逐段射线,逐个 hit 淡出,
|
||||
# 移过撞点继续;本帧没被挡到的恢复不透明。
|
||||
func _fade_occluders() -> void:
|
||||
var world3d := get_world_3d()
|
||||
if world3d == null:
|
||||
return
|
||||
var head := target.global_position + head_offset
|
||||
var from := global_position
|
||||
var this_frame: Array[Node] = []
|
||||
var ss := world3d.direct_space_state
|
||||
var exclude: Array[RID] = []
|
||||
for _i in 4:
|
||||
var q := PhysicsRayQueryParameters3D.create(from, head, OCCLUDER_MASK)
|
||||
q.exclude = exclude
|
||||
var hit := ss.intersect_ray(q)
|
||||
if hit.is_empty():
|
||||
break
|
||||
exclude.append(hit.rid)
|
||||
# StaticBody3D 上的 meta 指向它的 MeshInstance3D(Metin2World 设的)
|
||||
var body: Object = hit.collider
|
||||
var gi: Node = body.get_meta("occ_mesh", null) if body and body.has_meta("occ_mesh") else null
|
||||
if gi and gi is GeometryInstance3D and not this_frame.has(gi):
|
||||
(gi as GeometryInstance3D).transparency = FADE_ALPHA
|
||||
this_frame.append(gi)
|
||||
from = hit.position + (head - from).normalized() * 0.05
|
||||
for g in _faded:
|
||||
if is_instance_valid(g) and not this_frame.has(g):
|
||||
g.transparency = 0.0
|
||||
_faded = this_frame
|
||||
@@ -0,0 +1 @@
|
||||
uid://s4yeb23vdw3i
|
||||
@@ -0,0 +1,686 @@
|
||||
# GameScene (P0,简版) —— 进游戏后的场景装配。
|
||||
#
|
||||
# 把已有的件拼成一个能玩的场景:
|
||||
# Metin2World + 本地玩家 + GameCamera + PlayerController + NetWorld + HUD
|
||||
# + NetPlay(胶水)+ Audio + AppLifecycle
|
||||
#
|
||||
# 由 login.gd 在 entered_game 时实例化:
|
||||
# var gs := preload("res://game_scene.gd").new()
|
||||
# get_root().add_child(gs)
|
||||
# gs.setup(m2client, assets_root) # map_path 可选,默认 a1
|
||||
#
|
||||
# P0 本地玩家用占位胶囊;P2 装备系统落地后换真 Metin2Model(见 set_player_model)。
|
||||
extends Node3D
|
||||
|
||||
const GameCamera = preload("res://game_camera.gd")
|
||||
const PlayerCtl = preload("res://player_controller.gd")
|
||||
const NetWorld = preload("res://net_world.gd")
|
||||
const NetPlay = preload("res://net_play.gd")
|
||||
const Hud = preload("res://hud.gd")
|
||||
const AppLifecycle = preload("res://app_lifecycle.gd")
|
||||
const Audio = preload("res://audio.gd")
|
||||
const UiManager = preload("res://ui/ui_manager.gd")
|
||||
const InventoryUI = preload("res://ui/inventory_ui.gd")
|
||||
const ItemListDB = preload("res://ui/item_list.gd")
|
||||
const EquipModel = preload("res://ui/equip_model.gd")
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
const GroundItems = preload("res://ui/ground_items.gd")
|
||||
const ViewEquipmentUI = preload("res://ui/view_equipment_ui.gd")
|
||||
const ChatUI = preload("res://ui/chat_ui.gd")
|
||||
const DeathUI = preload("res://ui/death_ui.gd")
|
||||
const EffectRegistry = preload("res://fx/effect_registry.gd")
|
||||
const SkillFx = preload("res://fx/skill_fx.gd")
|
||||
const SkillTable = preload("res://ui/skill_table.gd")
|
||||
const SkillUI = preload("res://ui/skill_ui.gd")
|
||||
const CharStatusUI = preload("res://ui/char_status_ui.gd")
|
||||
const SystemOptionUI = preload("res://ui/system_option_ui.gd")
|
||||
const GameOptionUI = preload("res://ui/game_option_ui.gd")
|
||||
const SystemMenuUI = preload("res://ui/system_menu_ui.gd")
|
||||
const Quickbar = preload("res://ui/quickbar.gd")
|
||||
const QuestDialog = preload("res://ui/quest_dialog.gd")
|
||||
const QuestLog = preload("res://ui/quest_log.gd")
|
||||
const SelectItemUI = preload("res://ui/select_item_ui.gd")
|
||||
const PartyUI = preload("res://ui/party_ui.gd")
|
||||
const FriendUI = preload("res://ui/friend_ui.gd")
|
||||
const LoveUI = preload("res://ui/love_ui.gd")
|
||||
const ShopUI = preload("res://ui/shop_ui.gd")
|
||||
const ExchangeUI = preload("res://ui/exchange_ui.gd")
|
||||
const SafeboxUI = preload("res://ui/safebox_ui.gd")
|
||||
const MallUI = preload("res://ui/mall_ui.gd")
|
||||
const CubeUI = preload("res://ui/cube_ui.gd")
|
||||
const PrivateShopUI = preload("res://ui/private_shop_ui.gd")
|
||||
const GuildUI = preload("res://ui/guild_ui.gd")
|
||||
const RefineUI = preload("res://ui/refine_ui.gd")
|
||||
const DragonSoulUI = preload("res://ui/dragon_soul_ui.gd")
|
||||
const Minimap = preload("res://ui/minimap.gd")
|
||||
const AtlasUI = preload("res://ui/atlas_ui.gd")
|
||||
const WorldTime = preload("res://world/world_time.gd")
|
||||
const Weather = preload("res://fx/weather.gd")
|
||||
|
||||
var client: Node
|
||||
var world: Node
|
||||
var player: Node3D
|
||||
var cam: Camera3D
|
||||
var pc: Node
|
||||
var net_world: Node
|
||||
var net_play: Node
|
||||
var hud: Node
|
||||
var ui: CanvasLayer # UiManager
|
||||
var proto: Node # Metin2Proto
|
||||
var inventory: Node # InventoryUI
|
||||
var item_list: RefCounted # ItemList
|
||||
var equip_model: Node # EquipModel
|
||||
var ground_items: Node # GroundItems
|
||||
var view_equipment_ui: Node # ViewEquipmentUI
|
||||
var chat: Node # ChatUI
|
||||
var fx: RefCounted # EffectRegistry
|
||||
var skill_fx: RefCounted # SkillFx
|
||||
var skill_table: RefCounted # SkillTable
|
||||
var skills: Node # SkillUI
|
||||
var char_status_ui: Node # CharStatusUI
|
||||
var system_option_ui: Node # SystemOptionUI
|
||||
var game_option_ui: Node # GameOptionUI
|
||||
var system_menu_ui: Node # SystemMenuUI
|
||||
var quickbar: Node # Quickbar
|
||||
var quest_dialog: Node # QuestDialog
|
||||
var quest_log: Node # QuestLog
|
||||
var select_item_ui: Node # SelectItemUI
|
||||
var party_ui: Node # PartyUI
|
||||
var friend_ui: Node # FriendUI
|
||||
var love_ui: Node # LoveUI
|
||||
var shop_ui: Node # ShopUI
|
||||
var exchange_ui: Node # ExchangeUI
|
||||
var safebox_ui: Node # SafeboxUI
|
||||
var mall_ui: Node # MallUI
|
||||
var cube_ui: Node # CubeUI
|
||||
var private_shop_ui: Node # PrivateShopUI
|
||||
var guild_ui: Node # GuildUI
|
||||
var refine_ui: Node # RefineUI
|
||||
var dragon_soul_ui: Node # DragonSoulUI
|
||||
var minimap: Node # Minimap
|
||||
var atlas_ui: Node # AtlasUI
|
||||
var world_time: Node # WorldTime
|
||||
var weather: Node3D # Weather
|
||||
var _assets := ""
|
||||
var _model_built := false
|
||||
var _audio: Node
|
||||
var _mount: Node3D
|
||||
var _sun: DirectionalLight3D
|
||||
var _env: Environment
|
||||
var _env_from_msenv := false # true = 用 Metin2World 从 .msenv 建的 Sun/WorldEnv
|
||||
var _pending_shoot_skill := 0
|
||||
|
||||
func setup(m2client: Node, assets_root: String,
|
||||
map_path: String = "OutdoorA1/metin2_map_a1") -> void:
|
||||
client = m2client
|
||||
_assets = assets_root
|
||||
_build_lighting()
|
||||
# 关键:setup 是协程。每个重活之间让出一帧,好让 M2Client._process 抽 socket
|
||||
# (否则整段 ~5s 同步阻塞会漏 PONG → 服务器 10s 后 "peer closed")。
|
||||
await _yield()
|
||||
_build_world(assets_root, map_path)
|
||||
await _yield()
|
||||
|
||||
player = _make_placeholder_player()
|
||||
add_child(player)
|
||||
|
||||
cam = GameCamera.new()
|
||||
cam.target = player
|
||||
cam.world = world
|
||||
add_child(cam)
|
||||
cam.make_current()
|
||||
|
||||
pc = PlayerCtl.new()
|
||||
pc.player = player
|
||||
pc.camera = cam
|
||||
pc.world = world
|
||||
add_child(pc)
|
||||
|
||||
_mount = Node3D.new()
|
||||
_mount.name = "Entities"
|
||||
add_child(_mount)
|
||||
net_world = NetWorld.new()
|
||||
add_child(net_world)
|
||||
net_world.world = world
|
||||
net_world.setup(client, _mount)
|
||||
|
||||
hud = Hud.new()
|
||||
if _map_loaded():
|
||||
hud.setup(world, player) # 含小地图 / 快捷栏 / 背包
|
||||
else:
|
||||
add_child(hud)
|
||||
hud.call("_build_status") # 无地图兜底:只搭状态栏(血 / 蓝 / 经验 / 等级)
|
||||
|
||||
net_play = NetPlay.new()
|
||||
add_child(net_play)
|
||||
net_play.setup(client, pc, net_world, hud)
|
||||
net_play.camera = cam
|
||||
|
||||
await _yield()
|
||||
_audio = Audio.new()
|
||||
add_child(_audio)
|
||||
if assets_root != "":
|
||||
_audio.setup(assets_root)
|
||||
await _yield()
|
||||
var life := AppLifecycle.new()
|
||||
add_child(life)
|
||||
life.bind(client, _audio)
|
||||
|
||||
# UI 层 + 物品 proto + 背包窗(I 键开关)
|
||||
ui = UiManager.new()
|
||||
add_child(ui)
|
||||
|
||||
# 聊天窗(Enter 聚焦输入)
|
||||
chat = ChatUI.new()
|
||||
add_child(chat)
|
||||
chat.setup(client, ui)
|
||||
if net_world and net_world.has_signal("main_bubble"):
|
||||
net_world.main_bubble.connect(_player_bubble)
|
||||
|
||||
# 特效注册表(P5)—— GC_SPECIFIC_EFFECT(.mse 路径) / GC_SPECIAL_EFFECT(内建 id) 在实体上播
|
||||
fx = EffectRegistry.new()
|
||||
fx.setup(_assets)
|
||||
|
||||
# 技能(P6)—— K 键技能窗,数字键 1-9 快捷栏,F1-F4 切页,+ 技能特效表
|
||||
if assets_root != "":
|
||||
skill_table = SkillTable.new()
|
||||
for lang in ["en", "common"]:
|
||||
if skill_table.load_file(assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang)):
|
||||
break
|
||||
skill_fx = SkillFx.new()
|
||||
skill_fx.setup(fx, skill_table)
|
||||
skills = SkillUI.new()
|
||||
add_child(skills)
|
||||
skills.setup(client, skill_table, ui)
|
||||
char_status_ui = CharStatusUI.new()
|
||||
add_child(char_status_ui)
|
||||
char_status_ui.setup(ui, client, assets_root)
|
||||
quickbar = Quickbar.new()
|
||||
add_child(quickbar)
|
||||
quickbar.setup(client, skill_table, ui, func() -> Node: return player)
|
||||
if quickbar.has_signal("skill_activated"):
|
||||
quickbar.skill_activated.connect(func(sid: int):
|
||||
if skill_fx and player:
|
||||
skill_fx.spawn_skill(sid, _skill_master(sid), player)
|
||||
# Bow skills send CG_SHOOT from the motion event, matching
|
||||
# CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot.
|
||||
if skill_table and skill_table.has_method("is_ranged") and skill_table.is_ranged(sid):
|
||||
_pending_shoot_skill = sid
|
||||
get_tree().create_timer(1.5).timeout.connect(func():
|
||||
if _pending_shoot_skill == sid:
|
||||
_pending_shoot_skill = 0)
|
||||
)
|
||||
|
||||
if client.has_signal("effect_cue"):
|
||||
client.effect_cue.connect(func(vid: int, name: String, special: int):
|
||||
var host: Node3D = _fx_host(vid)
|
||||
if host == null:
|
||||
return
|
||||
if name != "":
|
||||
fx.spawn(name, host, true) # GC_SPECIFIC_EFFECT
|
||||
elif special >= 0 and skill_fx:
|
||||
skill_fx.spawn_special(special, host)) # GC_SPECIAL_EFFECT 内建 id
|
||||
|
||||
# 任务 / NPC(P7)—— 对话窗自动弹,J 键任务日志
|
||||
quest_dialog = QuestDialog.new()
|
||||
add_child(quest_dialog)
|
||||
quest_dialog.setup(client, ui)
|
||||
quest_log = QuestLog.new()
|
||||
add_child(quest_log)
|
||||
quest_log.setup(client, ui)
|
||||
|
||||
# 死亡窗 + 状态图标条(P4)
|
||||
var death := DeathUI.new()
|
||||
add_child(death)
|
||||
death.setup(client, ui)
|
||||
if client.has_signal("affect_added"):
|
||||
var refresh_affects := func(_a = null): if hud and hud.has_method("set_affects"): hud.set_affects(client.get_affects())
|
||||
client.affect_added.connect(refresh_affects)
|
||||
client.affect_removed.connect(refresh_affects)
|
||||
await _yield()
|
||||
if ClassDB.class_exists("Metin2Proto") and assets_root != "":
|
||||
proto = ClassDB.instantiate("Metin2Proto")
|
||||
add_child(proto)
|
||||
var ip := assets_root.path_join("locale/locale/en/item_proto")
|
||||
if FileAccess.file_exists(ip):
|
||||
proto.call("load_item_proto", ip)
|
||||
await _yield()
|
||||
var mp := assets_root.path_join("locale/locale/en/mob_proto")
|
||||
if FileAccess.file_exists(mp):
|
||||
proto.call("load_mob_proto", mp)
|
||||
await _yield()
|
||||
net_play.proto = proto # 按 race 分类 NPC/怪(bType 在本 fork 不可靠)
|
||||
if net_world and "name_resolver" in net_world:
|
||||
net_world.name_resolver = net_play._entity_name # 头顶名字:怪走 mob_proto
|
||||
# 怪 / NPC 真模型:race -> mob_proto.name -> monster/npc 目录
|
||||
net_world.set_model_factory(_make_entity_model)
|
||||
if assets_root != "":
|
||||
item_list = ItemListDB.new()
|
||||
item_list.load_file(assets_root.path_join("locale/locale/common/item_list.txt"))
|
||||
await _yield()
|
||||
inventory = InventoryUI.new()
|
||||
add_child(inventory)
|
||||
inventory.setup(ui, client, proto, assets_root, item_list)
|
||||
# 装备 → 模型部件:武器 / 盾 / 身体 / 头盔 / 头发
|
||||
equip_model = EquipModel.new()
|
||||
add_child(equip_model)
|
||||
equip_model.setup(client, item_list, func() -> Node: return player, assets_root)
|
||||
equip_model.main_getter = func() -> int: return client.get_main_vid()
|
||||
equip_model.proto = proto # armor shape 走 item_proto values[3] + specular
|
||||
# 地面掉落物
|
||||
ground_items = GroundItems.new()
|
||||
add_child(ground_items)
|
||||
ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list)
|
||||
view_equipment_ui = ViewEquipmentUI.new()
|
||||
add_child(view_equipment_ui)
|
||||
view_equipment_ui.setup(client, ui, proto, item_list)
|
||||
|
||||
# 选魔石窗(EventManager [SELECT_ITEM] -> interfacemodule.BINARY_OpenSelectItemWindow)
|
||||
select_item_ui = SelectItemUI.new()
|
||||
add_child(select_item_ui)
|
||||
select_item_ui.setup(ui, client, proto, assets_root, item_list)
|
||||
if quest_dialog and quest_dialog.has_signal("select_item_requested"):
|
||||
quest_dialog.select_item_requested.connect(select_item_ui.open)
|
||||
|
||||
# 社交 / 商店 / 仓库(P8)
|
||||
party_ui = PartyUI.new()
|
||||
add_child(party_ui)
|
||||
party_ui.setup(client, ui)
|
||||
friend_ui = FriendUI.new()
|
||||
add_child(friend_ui)
|
||||
friend_ui.setup(client, ui)
|
||||
love_ui = LoveUI.new()
|
||||
add_child(love_ui)
|
||||
love_ui.setup(client, ui)
|
||||
if chat and friend_ui.has_signal("whisper_to"):
|
||||
friend_ui.whisper_to.connect(func(nm: String):
|
||||
if chat.has_method("start_whisper"):
|
||||
chat.start_whisper(nm))
|
||||
shop_ui = ShopUI.new()
|
||||
add_child(shop_ui)
|
||||
shop_ui.setup(client, ui, proto, item_list)
|
||||
exchange_ui = ExchangeUI.new()
|
||||
add_child(exchange_ui)
|
||||
exchange_ui.setup(client, ui, proto)
|
||||
safebox_ui = SafeboxUI.new()
|
||||
add_child(safebox_ui)
|
||||
safebox_ui.setup(client, ui, proto)
|
||||
mall_ui = MallUI.new()
|
||||
add_child(mall_ui)
|
||||
mall_ui.setup(client, ui, proto)
|
||||
cube_ui = CubeUI.new()
|
||||
add_child(cube_ui)
|
||||
cube_ui.setup(client, ui, proto)
|
||||
private_shop_ui = PrivateShopUI.new()
|
||||
add_child(private_shop_ui)
|
||||
private_shop_ui.setup(client, ui, proto, assets_root)
|
||||
guild_ui = GuildUI.new()
|
||||
add_child(guild_ui)
|
||||
guild_ui.setup(client, ui, skill_table)
|
||||
guild_ui.mark_image_provider = _guild_mark_upload_image
|
||||
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)
|
||||
await _yield()
|
||||
if inventory and "context_consumer" in inventory:
|
||||
inventory.context_consumer = _on_inv_context
|
||||
|
||||
# 世界系统(P9)—— 小地图、昼夜、天气、传送
|
||||
minimap = Minimap.new()
|
||||
add_child(minimap)
|
||||
minimap.setup(client, ui, func() -> Node3D: return player)
|
||||
atlas_ui = AtlasUI.new()
|
||||
add_child(atlas_ui)
|
||||
atlas_ui.setup(world, ui, func() -> Node3D: return player, map_path.get_file(), client)
|
||||
world_time = WorldTime.new()
|
||||
add_child(world_time)
|
||||
# .msenv 已经给了固定的白天关照(A1 没有昼夜预设)——别让 world_time 每帧覆盖它。
|
||||
# 只在走兜底关照时驱动昼夜。
|
||||
world_time.setup(client, _sun if not _env_from_msenv else null,
|
||||
_env if not _env_from_msenv else null)
|
||||
weather = Weather.new()
|
||||
add_child(weather)
|
||||
weather.setup(cam)
|
||||
# 系统设置 / 游戏设置 / ESC 系统菜单(uisystem.SystemDialog + uiSystemOption + uiGameOption)
|
||||
system_option_ui = SystemOptionUI.new()
|
||||
add_child(system_option_ui)
|
||||
system_option_ui.setup(ui, assets_root, _audio,
|
||||
func() -> Camera3D: return cam,
|
||||
func() -> Environment: return _env)
|
||||
game_option_ui = GameOptionUI.new()
|
||||
add_child(game_option_ui)
|
||||
game_option_ui.setup(ui, client, assets_root)
|
||||
system_menu_ui = SystemMenuUI.new()
|
||||
add_child(system_menu_ui)
|
||||
system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui)
|
||||
if client.has_signal("warp"):
|
||||
client.warp.connect(_on_warp)
|
||||
if client.has_signal("channel_changed") and hud and hud.has_method("set_channel"):
|
||||
client.channel_changed.connect(func(c): hud.set_channel(c))
|
||||
|
||||
client.entity_main_set.connect(_on_main_set)
|
||||
if client.get_main_vid() != 0: # 重连 / 已在局内
|
||||
# net_play._on_main_set 的信号早在它连上前就发过了 —— 手动补上关键那步:
|
||||
# 告诉 net_world 主角 vid 由本地 player 代表,别给它生成节点(否则 catch_up 会
|
||||
# 在玩家身上叠一个大蓝胶囊)。
|
||||
if net_world and net_world.has_method("set_local_vid"):
|
||||
net_world.set_local_vid(client.get_main_vid())
|
||||
if net_play and net_play.has_method("_on_main_set"):
|
||||
net_play._on_main_set(client.get_main_vid())
|
||||
_on_main_set(client.get_main_vid())
|
||||
|
||||
# setup() 是协程 —— 进来时进游戏的 spawn burst 早被 M2Client.pump_game 抽干、
|
||||
# net_world 还没连上信号。把当前所有实体(怪 / NPC / 别的玩家)补建一遍。
|
||||
if net_world and net_world.has_method("catch_up"):
|
||||
net_world.catch_up()
|
||||
|
||||
set_process_unhandled_input(true)
|
||||
|
||||
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
|
||||
func _guild_mark_upload_image() -> Image:
|
||||
var p := "res://ui/default_guild_mark.png"
|
||||
if ResourceLoader.exists(p):
|
||||
var tex: Texture2D = load(p)
|
||||
if tex:
|
||||
return tex.get_image()
|
||||
var img := Image.create(16, 12, false, Image.FORMAT_RGBA8)
|
||||
for y in 12:
|
||||
for x in 16:
|
||||
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
|
||||
|
||||
# .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 → 钩子。
|
||||
# 数字在 .msa 文本里与运行时枚举有历史偏差,故先按「字段是否有值」再按 type 兜底。
|
||||
func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vector3) -> void:
|
||||
var anchor: Node3D = player
|
||||
if sound != "" and _audio:
|
||||
_audio.play_at(sound, anchor.global_position, anchor)
|
||||
if effect != "" and fx:
|
||||
# EffectPosition 是骨骼本地锚点(cm);简版直接挂角色根 + y 偏移
|
||||
var gp := anchor.global_position + Vector3(pos.x, pos.y, pos.z) * 0.01
|
||||
fx.spawn_at(effect.get_file().get_basename(), _mount, gp, true)
|
||||
match type:
|
||||
2: # SCREEN_WAVING
|
||||
if cam and cam.has_method("shake"):
|
||||
cam.shake(0.06, 9.0)
|
||||
3: # SCREEN_FLASHING
|
||||
_screen_flash(0.12)
|
||||
6: # FLY —— 弓技能在动画飞行点发送 CG_SHOOT
|
||||
if _pending_shoot_skill != 0 and client and client.has_method("shoot"):
|
||||
client.shoot(_pending_shoot_skill)
|
||||
_pending_shoot_skill = 0
|
||||
9: # WARP —— 传送起点,不是投射物
|
||||
pass
|
||||
|
||||
var _flash_rect: ColorRect
|
||||
func _screen_flash(secs: float) -> void:
|
||||
if hud == null:
|
||||
return
|
||||
if _flash_rect == null or not is_instance_valid(_flash_rect):
|
||||
_flash_rect = ColorRect.new()
|
||||
_flash_rect.color = Color(1, 1, 1, 0)
|
||||
_flash_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
ui.add_child(_flash_rect)
|
||||
_flash_rect.color.a = 0.5
|
||||
var tw := create_tween()
|
||||
tw.tween_property(_flash_rect, "color:a", 0.0, secs)
|
||||
|
||||
# skill id -> master_type(0 普通 / 1 M / 2 G / 3 P),给 skill_fx 选特效档。
|
||||
func _skill_master(sid: int) -> int:
|
||||
if client and client.has_method("get_skills"):
|
||||
for s in client.get_skills():
|
||||
if int(s.get("id", -1)) == sid:
|
||||
return int(s.get("master", 0))
|
||||
return 0
|
||||
|
||||
# GC_WARP —— 同服传送:直接把玩家挪到新坐标(Y 交给地形贴地)。
|
||||
# 跨服(same_server=false)留给 P10 的重连串场。
|
||||
func _on_warp(pos: Vector3, same_server: bool) -> void:
|
||||
if not same_server:
|
||||
push_warning("GC_WARP 到别的服务器 %s —— 跨服重连归 P10" % pos)
|
||||
return
|
||||
if player:
|
||||
var wp := MapCoord.to_world(pos)
|
||||
if world and world.has_method("set_focus_position"):
|
||||
world.call("set_focus_position", wp.x, wp.z)
|
||||
if world and world.has_method("sample_height"):
|
||||
wp.y = float(world.call("sample_height", wp.x, wp.z))
|
||||
player.global_position = wp
|
||||
if net_play and "_last_sent_pos" in net_play:
|
||||
net_play._last_sent_pos = player.global_position
|
||||
if cam and cam.has_method("snap_to_target"):
|
||||
cam.snap_to_target()
|
||||
|
||||
# 背包道具右键:按当前打开的窗决定动作(商店卖 / 交易放 / 仓库存)。
|
||||
# 返回 true = 已消费,inventory_ui 不再 use_item。
|
||||
func _on_inv_context(window: int, cell: int) -> bool:
|
||||
if shop_ui and shop_ui.is_open():
|
||||
shop_ui.sell(cell)
|
||||
return true
|
||||
if exchange_ui and exchange_ui.is_open():
|
||||
exchange_ui.offer(window, cell)
|
||||
return true
|
||||
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():
|
||||
dragon_soul_ui.add_cell(window, cell)
|
||||
return true
|
||||
return false
|
||||
|
||||
func _unhandled_input(e: InputEvent) -> void:
|
||||
if not (e is InputEventKey and e.pressed and not e.echo):
|
||||
return
|
||||
if e.keycode in [KEY_ENTER, KEY_KP_ENTER] and chat and not chat.is_typing():
|
||||
chat.focus_input()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif e.keycode == KEY_I and inventory:
|
||||
inventory.toggle()
|
||||
elif e.keycode == KEY_K and skills:
|
||||
skills.set_job(_job_name())
|
||||
skills.toggle()
|
||||
elif e.keycode in [KEY_V, KEY_C] and char_status_ui:
|
||||
char_status_ui.toggle()
|
||||
elif e.keycode == KEY_J and quest_log:
|
||||
quest_log.toggle()
|
||||
elif e.keycode == KEY_O and friend_ui:
|
||||
friend_ui.toggle()
|
||||
elif e.keycode == KEY_G and guild_ui:
|
||||
guild_ui.toggle()
|
||||
elif e.keycode == KEY_L and dragon_soul_ui:
|
||||
dragon_soul_ui.toggle()
|
||||
elif e.keycode == KEY_M and atlas_ui:
|
||||
atlas_ui.toggle()
|
||||
elif e.keycode == KEY_Z and ground_items:
|
||||
ground_items.try_pickup()
|
||||
elif e.keycode >= KEY_F1 and e.keycode <= KEY_F4 and quickbar:
|
||||
quickbar.set_page(e.keycode - KEY_F1)
|
||||
elif e.keycode >= KEY_1 and e.keycode <= KEY_9 and quickbar:
|
||||
quickbar.activate(e.keycode - KEY_1)
|
||||
elif e.keycode == KEY_ESCAPE and system_menu_ui:
|
||||
# ESC:ui_manager 先关最顶层窗口并吃掉事件;到这里说明没有窗口打开。
|
||||
system_menu_ui.toggle()
|
||||
|
||||
func _job_name() -> String:
|
||||
var r := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3
|
||||
return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][r]
|
||||
|
||||
func _fx_host(vid: int) -> Node3D:
|
||||
if vid == client.get_main_vid():
|
||||
return player
|
||||
return net_world.node_for(vid) if net_world else null
|
||||
|
||||
func _player_bubble(text: String) -> void:
|
||||
if player == null:
|
||||
return
|
||||
var old := player.get_node_or_null("bubble")
|
||||
if old:
|
||||
old.queue_free()
|
||||
var lbl := Label3D.new()
|
||||
lbl.name = "bubble"
|
||||
lbl.text = text
|
||||
lbl.position.y = 2.35
|
||||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
lbl.no_depth_test = true
|
||||
lbl.pixel_size = 0.0055
|
||||
lbl.outline_size = 6
|
||||
player.add_child(lbl)
|
||||
var tw := create_tween()
|
||||
tw.tween_interval(3.0)
|
||||
tw.tween_property(lbl, "modulate:a", 0.0, 1.0)
|
||||
tw.tween_callback(lbl.queue_free)
|
||||
|
||||
# --- 允许 P2 之后注入真模型 --------------------------------------------------
|
||||
|
||||
# factory: func(entity_dict) -> Node3D(有 set_anim_state(String) 就被驱动)
|
||||
func set_entity_model_factory(factory: Callable) -> void:
|
||||
if net_world:
|
||||
net_world.set_model_factory(factory)
|
||||
|
||||
var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试)
|
||||
|
||||
# 默认工厂:怪 / NPC -> MobView(race = mob_proto vnum)。失败返回 null 用占位胶囊。
|
||||
func _make_entity_model(d: Dictionary) -> Node3D:
|
||||
if bool(d.get("is_main", false)) or proto == null or _assets == "":
|
||||
return null
|
||||
var race := int(d.get("race", 0))
|
||||
if race < 1 or _mob_view_cache.get(race, true) == false:
|
||||
return null
|
||||
var mv := MobView.new()
|
||||
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
|
||||
if mv.build(_assets, proto, race, pump):
|
||||
_mob_view_cache[race] = true
|
||||
return mv
|
||||
_mob_view_cache[race] = false
|
||||
mv.free()
|
||||
return null
|
||||
|
||||
func set_player_model(node: Node3D) -> void:
|
||||
if node == null or player == null:
|
||||
return
|
||||
var old := player
|
||||
node.position = old.position
|
||||
node.name = "LocalPlayer"
|
||||
add_child(node)
|
||||
player = node
|
||||
cam.target = node
|
||||
pc.player = node
|
||||
old.queue_free()
|
||||
|
||||
# --- internals -------------------------------------------------------------
|
||||
|
||||
# setup() 协程用:让出一帧(在树里才 await,测试里直接返回)。
|
||||
func _yield() -> void:
|
||||
if is_inside_tree() and get_tree() != null:
|
||||
await get_tree().process_frame
|
||||
|
||||
func _build_world(assets_root: String, map_path: String) -> void:
|
||||
if not ClassDB.class_exists("Metin2World"):
|
||||
push_warning("GameScene: Metin2World 未注册(扩展没编?)")
|
||||
return
|
||||
world = ClassDB.instantiate("Metin2World")
|
||||
world.set("auto_load", false) # 别让 _ready() 自动加载一遍,下面显式调一次拿返回值
|
||||
world.set("assets_root", assets_root)
|
||||
world.set("map_path", map_path)
|
||||
add_child(world)
|
||||
var ok: bool = world.call("load_map")
|
||||
if not ok:
|
||||
push_warning("GameScene: load_map 失败:" + map_path + "(在平地上继续)")
|
||||
# 网络实体坐标要对齐地图本地帧(否则角色飘在几公里外的黑 void)
|
||||
if world.has_method("get_map_base_cm"):
|
||||
MapCoord.set_base(world.get_map_base_cm())
|
||||
# Metin2World 会从 .msenv 建自己的 Sun / WorldEnv(真实关照参数)。有就用它,
|
||||
# 丢掉 _build_lighting() 的兜底 —— 两套光 + 两个 WorldEnvironment 会互相打架、
|
||||
# 也让 world_time 每帧把 .msenv 的色调冲掉。
|
||||
var msenv_sun := world.get_node_or_null("Sun") as DirectionalLight3D
|
||||
var msenv_we := world.get_node_or_null("WorldEnv")
|
||||
if msenv_sun != null and msenv_we != null and msenv_we.environment != null:
|
||||
if is_instance_valid(_sun):
|
||||
_sun.queue_free()
|
||||
for c in get_children():
|
||||
if c is WorldEnvironment: # 兜底那个(.msenv 的挂在 world 下,不是这里的直接子)
|
||||
c.queue_free()
|
||||
_sun = msenv_sun
|
||||
_env = msenv_we.environment
|
||||
_env_from_msenv = true
|
||||
# .msenv 换过 Environment 后,把持久化的雾等设置重新贴上去
|
||||
if system_option_ui and system_option_ui.has_method("_apply_all"):
|
||||
system_option_ui._apply_all()
|
||||
|
||||
func _map_loaded() -> bool:
|
||||
if world == null:
|
||||
return false
|
||||
var rep: Dictionary = world.call("get_load_report")
|
||||
return int(rep.get("map_size_x", 0)) > 0
|
||||
|
||||
func _build_lighting() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.name = "FallbackSun"
|
||||
sun.rotation = Vector3(deg_to_rad(-50), deg_to_rad(40), 0)
|
||||
sun.light_energy = 1.1
|
||||
sun.shadow_enabled = true
|
||||
add_child(sun)
|
||||
_sun = sun
|
||||
var we := WorldEnvironment.new()
|
||||
var e := Environment.new()
|
||||
e.background_mode = Environment.BG_COLOR
|
||||
e.background_color = Color(0.55, 0.62, 0.72)
|
||||
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
e.ambient_light_color = Color(0.5, 0.55, 0.62)
|
||||
e.ambient_light_energy = 0.6
|
||||
we.environment = e
|
||||
add_child(we)
|
||||
_env = e
|
||||
|
||||
func _on_main_set(vid: int) -> void:
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty() or player == null:
|
||||
return
|
||||
# 首次拿到 race -> 换成真 Metin2Model
|
||||
if not _model_built and _assets != "" and int(e.get("race", 0)) >= 0:
|
||||
var pv := PlayerView.new()
|
||||
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
|
||||
if pv.build(_assets, int(e.get("race", 0)), pump):
|
||||
set_player_model(pv)
|
||||
_model_built = true
|
||||
if net_play:
|
||||
net_play.player_view = pv
|
||||
if pv.anim and pv.anim.has_signal("motion_event"):
|
||||
pv.anim.motion_event.connect(_on_local_motion_event)
|
||||
if pc and pc.has_signal("anim_state"):
|
||||
pc.anim_state.connect(func(s: String):
|
||||
if is_instance_valid(player) and player.has_method("set_anim_state"):
|
||||
player.set_anim_state(s))
|
||||
if equip_model:
|
||||
equip_model.set_race(int(e.get("race", 0)))
|
||||
equip_model.refresh()
|
||||
else:
|
||||
pv.queue_free()
|
||||
var p: Vector3 = MapCoord.to_world(e.get("pos", Vector3.ZERO))
|
||||
if world and world.has_method("set_focus_position"):
|
||||
world.call("set_focus_position", p.x, p.z) # 先对准,才建得出脚下的区块
|
||||
if world and world.has_method("sample_height"):
|
||||
p.y = float(world.call("sample_height", p.x, p.z))
|
||||
player.position = p
|
||||
|
||||
func _make_placeholder_player() -> Node3D:
|
||||
var root := Node3D.new()
|
||||
root.name = "LocalPlayer"
|
||||
var mesh := MeshInstance3D.new()
|
||||
var cap := CapsuleMesh.new()
|
||||
cap.radius = 0.35
|
||||
cap.height = 1.8
|
||||
mesh.mesh = cap
|
||||
mesh.position.y = 0.9
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(0.35, 0.6, 1.0)
|
||||
mesh.material_override = mat
|
||||
root.add_child(mesh)
|
||||
return root
|
||||
@@ -0,0 +1 @@
|
||||
uid://blsfh7xqpjfp5
|
||||
@@ -0,0 +1,92 @@
|
||||
# gamescene_test —— game_scene.gd 装配的 headless 自检(无服务器 / 无地图)。
|
||||
# godot --headless --path project --script gamescene_test.gd
|
||||
# 假 M2Client 驱动:断言场景装出来、子系统接线、实体镜像、主角定位。
|
||||
extends SceneTree
|
||||
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal entity_spawned(entity: Dictionary)
|
||||
signal entity_despawned(vid: int)
|
||||
signal entity_moved(vid: int)
|
||||
signal entity_main_set(vid: int)
|
||||
signal entity_dead(vid: int)
|
||||
signal vitals_changed(vid: int)
|
||||
signal points_changed(points: Dictionary)
|
||||
signal target_info(vid: int, hp_percent: int)
|
||||
signal chat(type: int, vid: int, text: String)
|
||||
signal damage(vid: int, amount: int, flag: int)
|
||||
var ents := {}
|
||||
var main := 0
|
||||
func is_in_game() -> bool: return true
|
||||
func get_main_vid() -> int: return main
|
||||
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
|
||||
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 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}
|
||||
if is_main:
|
||||
main = vid
|
||||
entity_main_set.emit(vid)
|
||||
entity_spawned.emit(ents[vid])
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: gamescene_test (assembly + wiring + entity mirror + spawn locate)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
|
||||
# 主角先于场景就位(模拟重连 / 已在局内)
|
||||
fc.spawn(1000, "Me", Vector3(20, 0, -30), true)
|
||||
|
||||
var gs: Node3D = GameScene.new()
|
||||
get_root().add_child(gs)
|
||||
await gs.setup(fc, "", "no_such_map") # setup 现在是协程(分帧避免阻塞漏 PONG)
|
||||
await process_frame
|
||||
|
||||
# 1) 子系统都装出来了
|
||||
_ck(gs.player != null, "player built")
|
||||
_ck(gs.cam != null and gs.cam.current, "camera current")
|
||||
_ck(gs.pc != null, "player_controller built")
|
||||
_ck(gs.net_world != null, "net_world built")
|
||||
_ck(gs.net_play != null, "net_play built")
|
||||
_ck(gs.hud != null, "hud built")
|
||||
_ck(gs.atlas_ui != null, "atlas_ui built")
|
||||
_ck(gs.get_node_or_null("Entities") != null, "entity mount present")
|
||||
|
||||
# 2) 主角定位到出生点(load_map 失败 -> sample_height 返回 0 -> y=0)
|
||||
_ck(gs.player.position.distance_to(Vector3(20, 0, 30)) < 0.01, # MapCoord.to_world 翻 Z
|
||||
"local player placed at spawn (world 帧), got %s" % gs.player.position)
|
||||
|
||||
# 3) net_world 不给主角画节点(本地 player 代表)
|
||||
await process_frame
|
||||
_ck(gs.net_world.node_for(1000) == null, "no net_world node for local vid")
|
||||
|
||||
# 4) 别的实体会被镜像成挂载节点
|
||||
fc.spawn(2000, "Wolf", Vector3(25, 0, -30))
|
||||
await process_frame
|
||||
var mob: Node3D = gs.net_world.node_for(2000)
|
||||
_ck(mob != null and mob.get_parent() == gs.get_node("Entities"), "remote entity mirrored under mount")
|
||||
|
||||
# 5) HUD 活着,points 驱动不报错
|
||||
fc.points_changed.emit({"hp": 60, "max_hp": 100, "sp": 20, "max_sp": 50,
|
||||
"exp": 10, "next_exp": 100, "level": 3})
|
||||
await process_frame
|
||||
_ck(true, "points_changed handled without error")
|
||||
|
||||
gs.queue_free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dgk3ohpwqq1h3
|
||||
@@ -0,0 +1,133 @@
|
||||
# guild_mark_test —— 会徽下载:guild_ui 会徽图 + app_flow 触发 / 冷却 headless 自检。
|
||||
# godot --headless --path project --script guild_mark_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const GuildUI = preload("res://ui/guild_ui.gd")
|
||||
const AppFlow = preload("res://app_flow.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal guild_changed()
|
||||
signal guild_marks_ready(mark_count: int)
|
||||
signal guild_mark_updated(guild_id: int, img_idx: int)
|
||||
signal guild_mark_uploaded(ok: bool)
|
||||
signal char_list(list: Array)
|
||||
signal entered_game()
|
||||
signal disconnected(reason: String)
|
||||
signal login_failed(reason: String)
|
||||
|
||||
var guild := {"in_guild": true, "id": 88, "name": "Wolves", "level": 9,
|
||||
"member_count": 3, "max_member_count": 32, "gold": 1000}
|
||||
var members := []
|
||||
var grades := []
|
||||
var mark_img: Image = null
|
||||
var dl_calls := []
|
||||
var up_calls := []
|
||||
var mark_srv := {"host": "", "port": 0}
|
||||
var connects := []
|
||||
var selects := []
|
||||
|
||||
func is_in_game() -> bool: return true
|
||||
func get_guild() -> Dictionary: return guild
|
||||
func get_guild_members() -> Array: return members
|
||||
func get_guild_grades() -> Array: return grades
|
||||
func get_guild_mark_image(_gid: int) -> Image: return mark_img
|
||||
func get_mark_server() -> Dictionary: return mark_srv
|
||||
func download_guild_marks(host: String, port: int) -> bool:
|
||||
dl_calls.append([host, port]); return true
|
||||
func upload_guild_mark(host: String, port: int, gid: int, img: Image) -> bool:
|
||||
up_calls.append([host, port, gid, img != null]); return true
|
||||
func connect_to_server(ah, ap, gh, gp, id, pw) -> void:
|
||||
connects.append([ah, ap, gh, gp, id, pw])
|
||||
func select_character(i) -> void: selects.append(i)
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: guild_mark_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _btn(root: Node, label: String) -> Button:
|
||||
for b in root.find_children("*", "Button", true, false):
|
||||
if String(b.text) == label:
|
||||
return b
|
||||
return null
|
||||
|
||||
func _make_mark() -> Image:
|
||||
var b := PackedByteArray()
|
||||
b.resize(16 * 12 * 4)
|
||||
for i in range(0, b.size(), 4):
|
||||
b[i] = 0x33; b[i + 1] = 0x99; b[i + 2] = 0xCC; b[i + 3] = 0xFF
|
||||
return Image.create_from_data(16, 12, false, Image.FORMAT_RGBA8, b)
|
||||
|
||||
func _run() -> void:
|
||||
var canvas := CanvasLayer.new()
|
||||
get_root().add_child(canvas)
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
|
||||
# --- guild_ui 会徽图 ---
|
||||
var gu: Node = GuildUI.new()
|
||||
get_root().add_child(gu)
|
||||
gu.setup(fc, canvas)
|
||||
gu.toggle()
|
||||
_ck(gu.is_open(), "公会窗打开")
|
||||
_ck(gu._mark != null and not gu._mark.visible, "无会徽图 -> TextureRect 隐藏")
|
||||
|
||||
# 会徽下载完成 -> 刷新出图
|
||||
fc.mark_img = _make_mark()
|
||||
fc.guild_marks_ready.emit(2)
|
||||
_ck(gu._mark.visible and gu._mark.texture != null, "guild_marks_ready -> 会徽显示")
|
||||
_ck(gu._mark.texture.get_width() == 16 and gu._mark.texture.get_height() == 12, "会徽 16x12")
|
||||
|
||||
# --- 会徽上传按钮 ---
|
||||
# 没配 mark server -> 无按钮
|
||||
gu._set_tab(0)
|
||||
_ck(_btn(gu._pages[0], "上传会徽") == null, "未配 mark server -> 无上传按钮")
|
||||
# 配上 server + 图源 -> 出按钮
|
||||
fc.mark_srv = {"host": "1.2.3.4", "port": 11002}
|
||||
gu.mark_image_provider = _make_mark
|
||||
gu._set_tab(0)
|
||||
var ub := _btn(gu._pages[0], "上传会徽")
|
||||
_ck(ub != null, "配好后 -> 有上传按钮")
|
||||
ub.pressed.emit()
|
||||
_ck(fc.up_calls == [["1.2.3.4", 11002, 88, true]], "上传 -> upload_guild_mark(host, port, gid, img)")
|
||||
_ck(gu._upload_status.text.contains("上传中"), "上传中状态")
|
||||
fc.guild_mark_uploaded.emit(true)
|
||||
_ck(gu._upload_status.text.contains("已上传"), "guild_mark_uploaded(true) -> 状态更新")
|
||||
|
||||
# 无公会 -> 收起会徽
|
||||
fc.guild = {"in_guild": false}
|
||||
fc.guild_changed.emit()
|
||||
_ck(not gu._mark.visible, "离开公会 -> 会徽隐藏")
|
||||
|
||||
# --- app_flow 触发下载 + 冷却 ---
|
||||
var fc2 := FakeClient.new()
|
||||
var af: Node = AppFlow.new()
|
||||
af.build_game_scene = false
|
||||
get_root().add_child(af)
|
||||
af.start("", fc2)
|
||||
# mark_port 默认 0 -> 不下载
|
||||
fc2.entered_game.emit()
|
||||
_ck(fc2.dl_calls.is_empty(), "mark_port=0 -> 不下载会徽")
|
||||
|
||||
# 配上 mark_port 再进游戏
|
||||
af.serverinfo._servers[0]["mark_port"] = 11002
|
||||
af._start_guild_mark_download()
|
||||
_ck(fc2.dl_calls.size() == 1 and fc2.dl_calls[0][1] == 11002, "配置后 -> download_guild_marks(host, 11002)")
|
||||
|
||||
# guild_mark_updated:冷却过后重下一次
|
||||
af._mark_redl_after = 0
|
||||
fc2.guild_mark_updated.emit(88, 1)
|
||||
_ck(fc2.dl_calls.size() == 2, "guild_mark_updated -> 重新下载")
|
||||
# 立刻再来一次 -> 冷却内不重下
|
||||
fc2.guild_mark_updated.emit(88, 1)
|
||||
_ck(fc2.dl_calls.size() == 2, "1s 冷却内 -> 不重复下载")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ympbq4jbv85r
|
||||
@@ -0,0 +1,91 @@
|
||||
# guild_refine_test —— 公会窗 + 精炼对话框 headless 自检。
|
||||
# godot --headless --path project --script guild_refine_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const GuildUI = preload("res://ui/guild_ui.gd")
|
||||
const RefineUI = preload("res://ui/refine_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal guild_changed()
|
||||
signal refine_ask(info: Dictionary)
|
||||
var guild := {"in_guild": false}
|
||||
var members := []
|
||||
var grades := []
|
||||
var calls := []
|
||||
func is_in_game() -> bool: return true
|
||||
func get_guild() -> Dictionary: return guild
|
||||
func get_guild_members() -> Array: return members
|
||||
func get_guild_grades() -> Array: return grades
|
||||
func refine(pos, type) -> bool: calls.append(["refine", pos, type]); return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: guild_refine_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> 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)
|
||||
gu.toggle()
|
||||
_ck(gu.is_open(), "公会窗打开")
|
||||
_ck(gu._info.text.contains("未加入"), "无公会 -> 提示")
|
||||
fc.guild = {"in_guild": true, "name": "Dragons", "level": 12, "member_count": 2,
|
||||
"max_member_count": 32, "gold": 500000}
|
||||
fc.grades = [{"name": "会长", "auth": 15}, {"name": "官员", "auth": 7}, {"name": "会员", "auth": 0}]
|
||||
fc.members = [
|
||||
{"pid": 1, "name": "Master", "grade": 1, "level": 90, "offer": 99999, "general": true},
|
||||
{"pid": 2, "name": "Newbie", "grade": 3, "level": 5, "offer": 10, "general": false},
|
||||
]
|
||||
fc.guild_changed.emit()
|
||||
_ck(gu._info.text.contains("Dragons") and gu._info.text.contains("Lv.12"), "公会信息行")
|
||||
_ck(gu._pages[0].get_child_count() == 2, "2 个成员行")
|
||||
# 会长排前(grade 1 < grade 3)
|
||||
_ck(String((gu._pages[0].get_child(0) as Label).text).begins_with("Master"), "按 grade 排序")
|
||||
_ck(String((gu._pages[0].get_child(0) as Label).text).contains("[会长]"), "grade 名解析")
|
||||
|
||||
# --- 精炼对话框 ---
|
||||
var ru: Node = RefineUI.new()
|
||||
get_root().add_child(ru)
|
||||
ru.setup(fc, canvas)
|
||||
_ck(not ru.is_open(), "精炼框初始隐藏")
|
||||
fc.refine_ask.emit({
|
||||
"type": 1, "pos": 7, "src_vnum": 11209, "result_vnum": 11219,
|
||||
"cost": 200000, "prob": 45,
|
||||
"materials": [{"vnum": 30001, "count": 3}, {"vnum": 30002, "count": 1}],
|
||||
})
|
||||
_ck(ru.is_open(), "refine_ask -> 弹出")
|
||||
_ck(ru._text.text.contains("45%") and ru._text.text.contains("200000"), "成功率 + 费用")
|
||||
_ck(ru._text.text.contains("#30001 ×3"), "材料行")
|
||||
# 找「精炼」按钮
|
||||
var ok_btn: Button = null
|
||||
for b in ru._root.find_children("*", "Button", true, false):
|
||||
if b.text == "精炼":
|
||||
ok_btn = b
|
||||
_ck(ok_btn != null, "有「精炼」按钮")
|
||||
if ok_btn:
|
||||
ok_btn.pressed.emit()
|
||||
_ck(fc.calls == [["refine", 7, 1]], "精炼 -> client.refine(pos=7, type=1)")
|
||||
_ck(not ru.is_open(), "确认后关闭")
|
||||
# 取消
|
||||
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):
|
||||
if b.text == "取消":
|
||||
b.pressed.emit()
|
||||
_ck(not ru.is_open() and fc.calls.size() == 1, "取消 -> 关闭且不发包")
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7y3tetalldlq
|
||||
@@ -0,0 +1,110 @@
|
||||
# guild_war_skill_test —— 公会窗「技能 / 公会战」两页 headless 自检。
|
||||
# godot --headless --path project --script guild_war_skill_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const GuildUI = preload("res://ui/guild_ui.gd")
|
||||
|
||||
class FakeTable extends Node:
|
||||
func for_category(cat: String) -> Array:
|
||||
return [4051, 4052, 4053] if cat == "GUILD" else []
|
||||
func name_of(id: int) -> String:
|
||||
return {4051: "축복", 4052: "미리내", 4053: "무장"}.get(id, "skill %d" % id)
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal guild_changed()
|
||||
signal guild_skill_changed()
|
||||
signal guild_war_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 calls := []
|
||||
func is_in_game() -> bool: return true
|
||||
func get_guild() -> Dictionary: return guild
|
||||
func get_guild_members() -> Array: return []
|
||||
func get_guild_grades() -> Array: return []
|
||||
func get_guild_skill() -> Dictionary: return skill
|
||||
func get_guild_wars() -> Array: return wars
|
||||
func get_guild_war() -> Dictionary: return war
|
||||
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
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: guild_war_skill_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _btn(root: Node, label: String) -> Button:
|
||||
for b in root.find_children("*", "Button", true, false):
|
||||
if String(b.text) == label:
|
||||
return b
|
||||
return null
|
||||
|
||||
func _labels_text(page: Node) -> String:
|
||||
var s := ""
|
||||
for c in page.find_children("*", "Label", true, false):
|
||||
s += String(c.text) + "\n"
|
||||
return s
|
||||
|
||||
func _run() -> void:
|
||||
var canvas := CanvasLayer.new()
|
||||
get_root().add_child(canvas)
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
var tbl := FakeTable.new()
|
||||
get_root().add_child(tbl)
|
||||
|
||||
var gu: Node = GuildUI.new()
|
||||
get_root().add_child(gu)
|
||||
gu.setup(fc, canvas, tbl)
|
||||
gu.toggle()
|
||||
_ck(gu.is_open(), "公会窗打开")
|
||||
_ck(gu._pages.size() == 3, "3 页")
|
||||
|
||||
# --- 技能页 ---
|
||||
gu._set_tab(1)
|
||||
_ck(_labels_text(gu._pages[1]).contains("还没收到"), "技能页:无数据提示")
|
||||
|
||||
fc.skill = {"valid": true, "skill_point": 5, "guild_point": 1200, "max_guild_point": 5000,
|
||||
"levels": [3, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 7]}
|
||||
fc.guild_skill_changed.emit()
|
||||
var st := _labels_text(gu._pages[1])
|
||||
_ck(st.contains("技能点 5") and st.contains("1200 / 5000"), "技能页:点数行")
|
||||
_ck(st.contains("축복") and st.contains("Lv 3"), "技能页:GUILD 技能名 + 等级")
|
||||
_ck(st.contains("무장") and st.contains("Lv 7"), "技能页:第 12 个技能配到 levels[11]")
|
||||
# 축복 lv3 -> 有「施放」按钮;미리내 lv0 -> 无
|
||||
var cast := _btn(gu._pages[1], "施放")
|
||||
_ck(cast != null, "技能页:可施放技能有按钮")
|
||||
cast.pressed.emit()
|
||||
_ck(fc.calls == [["use", 4051, 0]], "施放 -> use_guild_skill(4051, 0)")
|
||||
|
||||
# --- 公会战页 ---
|
||||
gu._set_tab(2)
|
||||
_ck(_labels_text(gu._pages[2]).contains("当前无公会战"), "公会战页:无战提示")
|
||||
_ck(_labels_text(gu._pages[2]).contains("无"), "公会战页:进行中列表为空")
|
||||
|
||||
# 宣战
|
||||
gu._war_name_edit.text = "Tigers"
|
||||
_btn(gu._pages[2], "宣战").pressed.emit()
|
||||
_ck(fc.calls[-1] == ["war", "Tigers"], "宣战 -> declare_guild_war(\"Tigers\")")
|
||||
|
||||
# 收到 WAR ON_WAR + WAR_LIST
|
||||
fc.war = {"opp_guild_id": 88, "opp_name": "Tigers", "type": 1, "state": 6}
|
||||
fc.wars = [{"src": 77, "dst": 88, "src_name": "Dragons", "dst_name": "Tigers"}]
|
||||
fc.guild_war_changed.emit()
|
||||
var wt := _labels_text(gu._pages[2])
|
||||
_ck(wt.contains("对 Tigers") and wt.contains("交战中"), "公会战页:当前战状态")
|
||||
_ck(wt.contains("Dragons vs Tigers") and wt.contains("◀我方"), "公会战页:GvG 行 + 我方标记")
|
||||
@@ -0,0 +1 @@
|
||||
uid://xbhde2rhxvgy
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
# W7 HUD —— 小地图(拼 minimap.dds)+ 状态栏 / 快捷栏 / 背包。
|
||||
# §6.1:窗口/血条/快捷栏用真实 `ETC/ymir work/ui/pattern/` 九宫格贴图(AssetResolver 机制),
|
||||
# 精确布局仍待 §0 的 ingame-shinsoo-ui.json。SHINSOO §9-W7 / PARITY §6.1。
|
||||
# 用法:var hud := preload("res://hud.gd").new(); hud.setup(world, player); add_child(hud)
|
||||
extends CanvasLayer
|
||||
|
||||
var _world: Node
|
||||
var _player: Node3D
|
||||
var _minimap_rect: TextureRect
|
||||
var _dot: ColorRect
|
||||
var _map_w_m := 1024.0
|
||||
var _map_h_m := 1280.0
|
||||
var _inv: Control
|
||||
var _assets: String
|
||||
var _pat := "ETC/ymir work/ui/pattern/"
|
||||
var _tex_cache := {}
|
||||
var _hp_fill: NinePatchRect
|
||||
var _mp_fill: NinePatchRect
|
||||
var _exp_fill: ColorRect
|
||||
var _lvl_label: Label
|
||||
var _hp_label: Label
|
||||
var _hp_w := 240.0
|
||||
var _energy_fill: ColorRect
|
||||
var _energy_label: Label
|
||||
# 目标血条(P0)
|
||||
var _target_panel: Control
|
||||
var _target_name: Label
|
||||
var _target_fill: ColorRect
|
||||
# 状态图标条(P4)
|
||||
var _affect_row: HBoxContainer
|
||||
|
||||
func setup(world: Node, player: Node3D) -> void:
|
||||
_world = world
|
||||
_player = player
|
||||
_assets = String(world.get("assets_root"))
|
||||
var rep: Dictionary = _world.call("get_load_report")
|
||||
_map_w_m = float(rep["map_size_x"]) * 256.0
|
||||
_map_h_m = float(rep["map_size_y"]) * 256.0
|
||||
_build_minimap(int(rep["map_size_x"]), int(rep["map_size_y"]))
|
||||
_build_status()
|
||||
_build_hotbar()
|
||||
_build_inventory()
|
||||
|
||||
# --- real UI texture loading (§6.1) -------------------------------------------
|
||||
|
||||
func _ui_tex(name: String) -> Texture2D:
|
||||
if _tex_cache.has(name):
|
||||
return _tex_cache[name]
|
||||
var img := Image.new()
|
||||
var tex: Texture2D = null
|
||||
var cand := _assets.path_join(_pat + name + ".tga")
|
||||
if not FileAccess.file_exists(cand):
|
||||
# fall back: scan asset packs for "<pack>/ymir work/ui/pattern/<name>.tga"
|
||||
var da := DirAccess.open(_assets)
|
||||
if da:
|
||||
for sub in da.get_directories():
|
||||
var p := _assets.path_join(sub).path_join("ymir work/ui/pattern/" + name + ".tga")
|
||||
if FileAccess.file_exists(p):
|
||||
cand = p
|
||||
break
|
||||
if FileAccess.file_exists(cand) and img.load(cand) == OK:
|
||||
tex = ImageTexture.create_from_image(img)
|
||||
_tex_cache[name] = tex
|
||||
return tex
|
||||
|
||||
# Composite a set of pattern pieces into one atlas and return a NinePatchRect.
|
||||
# pieces = {corner_lt, corner_rt, corner_lb, corner_rb, line_t, line_l, base}
|
||||
func _nine(prefix: String, m: int, base_size: int) -> NinePatchRect:
|
||||
var total := m + base_size + m
|
||||
var atlas := Image.create_empty(total, total, false, Image.FORMAT_RGBA8)
|
||||
atlas.fill(Color(0, 0, 0, 0))
|
||||
var put := func(name: String, x: int, y: int) -> void:
|
||||
var t := _ui_tex(name)
|
||||
if t == null:
|
||||
return
|
||||
var im := t.get_image()
|
||||
im.convert(Image.FORMAT_RGBA8)
|
||||
atlas.blit_rect(im, Rect2i(Vector2i.ZERO, im.get_size()), Vector2i(x, y))
|
||||
put.call(prefix + "_corner_lefttop", 0, 0)
|
||||
put.call(prefix + "_corner_righttop", m + base_size, 0)
|
||||
put.call(prefix + "_corner_leftbottom", 0, m + base_size)
|
||||
put.call(prefix + "_corner_rightbottom", m + base_size, m + base_size)
|
||||
# edges: stretch the piece across the middle band
|
||||
var edge := func(name: String, r: Rect2i) -> void:
|
||||
var t := _ui_tex(name)
|
||||
if t == null:
|
||||
return
|
||||
var im := t.get_image()
|
||||
im.convert(Image.FORMAT_RGBA8)
|
||||
im.resize(r.size.x, r.size.y, Image.INTERPOLATE_BILINEAR)
|
||||
atlas.blit_rect(im, Rect2i(Vector2i.ZERO, r.size), r.position)
|
||||
edge.call(prefix + "_line_top", Rect2i(m, 0, base_size, m))
|
||||
edge.call(prefix + "_line_bottom", Rect2i(m, m + base_size, base_size, m))
|
||||
edge.call(prefix + "_line_left", Rect2i(0, m, m, base_size))
|
||||
edge.call(prefix + "_line_right", Rect2i(m + base_size, m, m, base_size))
|
||||
var b := _ui_tex(prefix + "_base")
|
||||
if b:
|
||||
var bi := b.get_image()
|
||||
bi.convert(Image.FORMAT_RGBA8)
|
||||
bi.resize(base_size, base_size, Image.INTERPOLATE_BILINEAR)
|
||||
atlas.blit_rect(bi, Rect2i(Vector2i.ZERO, Vector2i(base_size, base_size)), Vector2i(m, m))
|
||||
var np := NinePatchRect.new()
|
||||
np.texture = ImageTexture.create_from_image(atlas)
|
||||
np.patch_margin_left = m
|
||||
np.patch_margin_right = m
|
||||
np.patch_margin_top = m
|
||||
np.patch_margin_bottom = m
|
||||
return np
|
||||
|
||||
# horizontal 3-slice gauge (left cap | tiled centre | right cap) as a NinePatchRect
|
||||
func _gauge(center_name: String, h: int) -> NinePatchRect:
|
||||
var pieces := [center_name + "_left" if _ui_tex(center_name + "_left") else center_name,
|
||||
center_name + "_center" if _ui_tex(center_name + "_center") else center_name,
|
||||
center_name + "_right" if _ui_tex(center_name + "_right") else center_name]
|
||||
var seg := 16
|
||||
var atlas := Image.create_empty(seg * 3, h, false, Image.FORMAT_RGBA8)
|
||||
atlas.fill(Color(0, 0, 0, 0))
|
||||
for i in 3:
|
||||
var t := _ui_tex(pieces[i])
|
||||
if t == null:
|
||||
continue
|
||||
var im := t.get_image()
|
||||
im.convert(Image.FORMAT_RGBA8)
|
||||
im.resize(seg, h, Image.INTERPOLATE_BILINEAR)
|
||||
atlas.blit_rect(im, Rect2i(Vector2i.ZERO, Vector2i(seg, h)), Vector2i(i * seg, 0))
|
||||
var np := NinePatchRect.new()
|
||||
np.texture = ImageTexture.create_from_image(atlas)
|
||||
np.patch_margin_left = seg
|
||||
np.patch_margin_right = seg
|
||||
return np
|
||||
|
||||
# --- widgets ----------------------------------------------------------------
|
||||
|
||||
func _build_minimap(nx: int, ny: int) -> void:
|
||||
var cell := 64
|
||||
var big := Image.create_empty(nx * cell, ny * cell, false, Image.FORMAT_RGBA8)
|
||||
big.fill(Color(0.08, 0.09, 0.11, 1.0))
|
||||
for tx in nx:
|
||||
for ty in ny:
|
||||
var p: String = _world.call("chunk_dir", tx, ty) + "/minimap.dds"
|
||||
if not FileAccess.file_exists(p):
|
||||
continue
|
||||
var img: Image = _world.call("load_dds", p)
|
||||
if img == null:
|
||||
continue
|
||||
img.resize(cell, cell, Image.INTERPOLATE_BILINEAR)
|
||||
big.blit_rect(img, Rect2i(0, 0, cell, cell), Vector2i(tx * cell, ty * cell))
|
||||
var mm_h := 192 * ny / nx if nx > 0 else 192
|
||||
var frame := _nine("board", 32, 128)
|
||||
frame.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
frame.position = Vector2(-192 - 24 - 12, 12)
|
||||
frame.size = Vector2(192 + 24, mm_h + 24)
|
||||
add_child(frame)
|
||||
_minimap_rect = TextureRect.new()
|
||||
_minimap_rect.texture = ImageTexture.create_from_image(big)
|
||||
_minimap_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
_minimap_rect.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
_minimap_rect.position = Vector2(12, 12)
|
||||
_minimap_rect.size = Vector2(192, mm_h)
|
||||
frame.add_child(_minimap_rect)
|
||||
_dot = ColorRect.new()
|
||||
_dot.color = Color(1, 0.9, 0.2)
|
||||
_dot.size = Vector2(6, 6)
|
||||
_minimap_rect.add_child(_dot)
|
||||
|
||||
func _build_status() -> void:
|
||||
var box := VBoxContainer.new()
|
||||
box.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
box.position = Vector2(18, -122)
|
||||
box.add_theme_constant_override("separation", 6)
|
||||
add_child(box)
|
||||
for spec in [["gauge_red", 0.82], ["gauge_blue", 0.60]]:
|
||||
var slot := _gauge("gauge_slot", 24)
|
||||
slot.custom_minimum_size = Vector2(_hp_w, 24)
|
||||
slot.size = Vector2(_hp_w, 24)
|
||||
var fill := _gauge(spec[0], 24)
|
||||
fill.position = Vector2(0, 0)
|
||||
fill.size = Vector2(_hp_w * float(spec[1]), 24)
|
||||
slot.add_child(fill)
|
||||
box.add_child(slot)
|
||||
if spec[0] == "gauge_red":
|
||||
_hp_fill = fill
|
||||
_hp_label = Label.new()
|
||||
_hp_label.add_theme_font_size_override("font_size", 11)
|
||||
_hp_label.position = Vector2(6, 5)
|
||||
slot.add_child(_hp_label)
|
||||
else:
|
||||
_mp_fill = fill
|
||||
# 能量条(原客户端 POINT_ENERGY,0..100)。资源缺失时仍用纯色条保持可读。
|
||||
var energy_slot := ColorRect.new()
|
||||
energy_slot.color = Color(0, 0, 0, 0.55)
|
||||
energy_slot.custom_minimum_size = Vector2(_hp_w, 10)
|
||||
energy_slot.size = Vector2(_hp_w, 10)
|
||||
_energy_fill = ColorRect.new()
|
||||
_energy_fill.color = Color(0.95, 0.65, 0.18)
|
||||
_energy_fill.size = Vector2(0, 10)
|
||||
energy_slot.add_child(_energy_fill)
|
||||
_energy_label = Label.new()
|
||||
_energy_label.add_theme_font_size_override("font_size", 9)
|
||||
_energy_label.position = Vector2(6, -1)
|
||||
energy_slot.add_child(_energy_label)
|
||||
box.add_child(energy_slot)
|
||||
# 经验条(细)+ 等级
|
||||
var exp_slot := ColorRect.new()
|
||||
exp_slot.color = Color(0, 0, 0, 0.5)
|
||||
exp_slot.custom_minimum_size = Vector2(_hp_w, 6)
|
||||
exp_slot.size = Vector2(_hp_w, 6)
|
||||
_exp_fill = ColorRect.new()
|
||||
_exp_fill.color = Color(0.9, 0.8, 0.2)
|
||||
_exp_fill.size = Vector2(0, 6)
|
||||
exp_slot.add_child(_exp_fill)
|
||||
box.add_child(exp_slot)
|
||||
_lvl_label = Label.new()
|
||||
_lvl_label.add_theme_font_size_override("font_size", 12)
|
||||
_lvl_label.text = "Lv 1"
|
||||
box.add_child(_lvl_label)
|
||||
|
||||
# --- P0: live data setters -------------------------------------------------
|
||||
|
||||
func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void:
|
||||
if _hp_fill:
|
||||
_hp_fill.size.x = _hp_w * clampf(float(hp) / maxf(1.0, max_hp), 0.0, 1.0)
|
||||
if _mp_fill:
|
||||
_mp_fill.size.x = _hp_w * clampf(float(sp) / maxf(1.0, max_sp), 0.0, 1.0)
|
||||
if _hp_label:
|
||||
_hp_label.text = "%d / %d" % [hp, max_hp]
|
||||
|
||||
func set_exp(xp: int, next_xp: int) -> void:
|
||||
if _exp_fill:
|
||||
_exp_fill.size.x = _hp_w * clampf(float(xp) / maxf(1.0, next_xp), 0.0, 1.0)
|
||||
|
||||
func set_level(lv: int) -> void:
|
||||
if _lvl_label:
|
||||
_lvl_label.text = "Lv %d" % lv
|
||||
|
||||
func set_energy(value: int, max_value: int = 100) -> void:
|
||||
var cap := maxi(1, max_value)
|
||||
var v := clampi(value, 0, cap)
|
||||
if _energy_fill:
|
||||
_energy_fill.size.x = _hp_w * float(v) / float(cap)
|
||||
if _energy_label:
|
||||
_energy_label.text = "%d / %d" % [v, cap]
|
||||
|
||||
# P4:状态图标条。affects = [{type, point_idx, value, flag, duration}, ...]
|
||||
func set_affects(affects: Array) -> void:
|
||||
if _affect_row == null:
|
||||
_affect_row = HBoxContainer.new()
|
||||
_affect_row.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
_affect_row.position = Vector2(18, 18)
|
||||
_affect_row.add_theme_constant_override("separation", 3)
|
||||
add_child(_affect_row)
|
||||
for c in _affect_row.get_children():
|
||||
c.queue_free()
|
||||
for a in affects:
|
||||
var sq := ColorRect.new()
|
||||
sq.custom_minimum_size = Vector2(22, 22)
|
||||
var t := int(a.get("type", 0))
|
||||
sq.color = Color.from_hsv(fmod(t * 0.13, 1.0), 0.55, 0.9, 0.95)
|
||||
sq.tooltip_text = "affect #%d pt%d %+d %ds" % [t, int(a.get("point_idx", 0)),
|
||||
int(a.get("value", 0)), int(a.get("duration", 0))]
|
||||
_affect_row.add_child(sq)
|
||||
|
||||
func set_target(nm: String, hp_pct: int) -> void:
|
||||
if _target_panel == null:
|
||||
_build_target_panel()
|
||||
_target_panel.visible = true
|
||||
_target_name.text = nm if nm != "" else "Target"
|
||||
_target_fill.size.x = 180.0 * clampf(hp_pct / 100.0, 0.0, 1.0)
|
||||
|
||||
func clear_target() -> void:
|
||||
if _target_panel:
|
||||
_target_panel.visible = false
|
||||
|
||||
func _build_target_panel() -> void:
|
||||
_target_panel = Control.new()
|
||||
_target_panel.set_anchors_preset(Control.PRESET_CENTER_TOP)
|
||||
_target_panel.position = Vector2(-100, 16)
|
||||
_target_panel.size = Vector2(200, 40)
|
||||
add_child(_target_panel)
|
||||
_target_name = Label.new()
|
||||
_target_name.add_theme_font_size_override("font_size", 12)
|
||||
_target_name.position = Vector2(10, 0)
|
||||
_target_panel.add_child(_target_name)
|
||||
var slot := ColorRect.new()
|
||||
slot.color = Color(0, 0, 0, 0.55)
|
||||
slot.position = Vector2(10, 18)
|
||||
slot.size = Vector2(180, 12)
|
||||
_target_panel.add_child(slot)
|
||||
_target_fill = ColorRect.new()
|
||||
_target_fill.color = Color(0.85, 0.2, 0.2)
|
||||
_target_fill.size = Vector2(180, 12)
|
||||
slot.add_child(_target_fill)
|
||||
|
||||
func _build_hotbar() -> void:
|
||||
var row := HBoxContainer.new()
|
||||
row.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||
row.position = Vector2(-8 * 23, -56)
|
||||
row.add_theme_constant_override("separation", 4)
|
||||
add_child(row)
|
||||
for i in 8:
|
||||
var slot := _nine("thinboard", 16, 16)
|
||||
slot.custom_minimum_size = Vector2(42, 42)
|
||||
var n := Label.new()
|
||||
n.text = str(i + 1)
|
||||
n.position = Vector2(4, 2)
|
||||
n.add_theme_font_size_override("font_size", 11)
|
||||
slot.add_child(n)
|
||||
row.add_child(slot)
|
||||
|
||||
func _build_inventory() -> void:
|
||||
_inv = _nine("board", 32, 128)
|
||||
_inv.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
|
||||
_inv.position = Vector2(-280, -200)
|
||||
_inv.size = Vector2(256, 384)
|
||||
_inv.visible = false
|
||||
var t := Label.new()
|
||||
t.text = " Inventory (I to close)"
|
||||
t.position = Vector2(16, 12)
|
||||
_inv.add_child(t)
|
||||
add_child(_inv)
|
||||
|
||||
func _input(ev: InputEvent) -> void:
|
||||
if ev is InputEventKey and ev.pressed and ev.keycode == KEY_I:
|
||||
_inv.visible = not _inv.visible
|
||||
|
||||
func _process(_dt: float) -> void:
|
||||
if _player == null or _minimap_rect == null:
|
||||
return
|
||||
var u := clampf(_player.position.x / _map_w_m, 0.0, 1.0)
|
||||
var v := clampf(_player.position.z / _map_h_m, 0.0, 1.0)
|
||||
_dot.position = Vector2(u * _minimap_rect.size.x - 3, v * _minimap_rect.size.y - 3)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cs3i75781lp3u
|
||||
@@ -0,0 +1,66 @@
|
||||
# inventory_test — 扩展装备位和腰带背包的协议坐标 / 锁格规则。
|
||||
extends SceneTree
|
||||
|
||||
const InventoryUI = preload("res://ui/inventory_ui.gd")
|
||||
|
||||
class FakeProto extends Node:
|
||||
func item(_vnum: int) -> Dictionary:
|
||||
return {"values": [4]}
|
||||
|
||||
class FakeClient extends Node:
|
||||
var item_ops := []
|
||||
func use_item_to_item(sw: int, sc: int, tw: int, tc: int) -> bool:
|
||||
item_ops.append([sw, sc, tw, tc])
|
||||
return true
|
||||
|
||||
var failed := 0
|
||||
|
||||
func _check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failed += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
var inv := InventoryUI.new()
|
||||
_check(inv._to_wire(0) == [1, 0], "normal inventory uses INVENTORY cell")
|
||||
_check(inv._to_wire(113) == [1, 113], "wear belt uses legacy global cell 113")
|
||||
_check(inv._to_wire(200) == [1, 152], "belt UI cell 0 uses global cell 152")
|
||||
_check(inv._to_wire(215) == [1, 167], "belt UI cell 15 uses global cell 167")
|
||||
|
||||
inv.proto = FakeProto.new()
|
||||
inv.add_child(inv.proto)
|
||||
for i in 16:
|
||||
var cell := Panel.new()
|
||||
inv.add_child(cell)
|
||||
inv._cells[200 + i] = cell
|
||||
var eq := []
|
||||
for _i in 24:
|
||||
eq.append({"vnum": 0, "count": 0})
|
||||
eq[23] = {"vnum": 12345, "count": 1}
|
||||
inv._apply_belt_locks(eq)
|
||||
_check(not bool(inv._cells[200].get_meta("locked", true)), "grade 4 unlocks belt cell 0")
|
||||
_check(not bool(inv._cells[202].get_meta("locked", true)), "grade 4 unlocks belt cell 2")
|
||||
_check(bool(inv._cells[203].get_meta("locked", false)), "grade 4 locks belt cell 3 (needs 6)")
|
||||
_check(not bool(inv._cells[206].get_meta("locked", true)), "grade 4 unlocks belt cell 6")
|
||||
_check(bool(inv._cells[207].get_meta("locked", false)), "grade 4 locks belt cell 7 (needs 6)")
|
||||
var fake_client := FakeClient.new()
|
||||
inv.add_child(fake_client)
|
||||
inv.client = fake_client
|
||||
for index in [0, 1]:
|
||||
var item_cell := Panel.new()
|
||||
item_cell.set_meta("vnum", 100 + index)
|
||||
item_cell.set_meta("count", 2)
|
||||
inv.add_child(item_cell)
|
||||
inv._cells[index] = item_cell
|
||||
_check(inv._use_to_item(0, 1), "item-to-item intent accepted")
|
||||
_check(fake_client.item_ops == [[1, 0, 1, 1]], "item-to-item uses original inventory positions")
|
||||
inv._begin_combine(0)
|
||||
_check(inv._combine_from == 0, "shift selects combine source")
|
||||
inv._begin_combine(0)
|
||||
_check(inv._combine_from == -1, "shift same source cancels combine")
|
||||
inv.free()
|
||||
if failed == 0:
|
||||
print("PASS: inventory_test (extended equipment + belt mapping)")
|
||||
quit(0)
|
||||
else:
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://76o26fdux541
|
||||
@@ -0,0 +1,94 @@
|
||||
# inventory_ui_test —— P2 背包/装备窗 headless 自检(假 client + 假 proto + 真 uiscript)。
|
||||
# godot --headless --path project --script inventory_ui_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const UiManager = preload("res://ui/ui_manager.gd")
|
||||
const InventoryUI = preload("res://ui/inventory_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal inventory_changed(window: int, cell: int)
|
||||
var inv := {} # cell -> {vnum,count}
|
||||
var equip := [] # 11 x {vnum,count}
|
||||
var used := []
|
||||
var moved := []
|
||||
func _init():
|
||||
for i in 11: equip.append({"vnum": 0, "count": 0, "wear": i})
|
||||
func get_inventory() -> Array:
|
||||
var out := []
|
||||
for c in inv: out.append({"cell": c, "vnum": inv[c].vnum, "count": inv[c].count})
|
||||
return out
|
||||
func get_equipment() -> Array: return equip
|
||||
func use_item(w, c) -> bool: used.append([w, c]); return true
|
||||
func move_item(fw, fc, tw, tc, n) -> bool: moved.append([fw, fc, tw, tc, n]); return true
|
||||
func set_inv(cell, vnum, count):
|
||||
inv[cell] = {"vnum": vnum, "count": count}
|
||||
inventory_changed.emit(1, cell)
|
||||
|
||||
class FakeProto extends Node:
|
||||
func item(vnum: int) -> Dictionary:
|
||||
return {"vnum": vnum, "name": "Item%d" % vnum, "locale_name": "物品%d" % vnum, "type": 1}
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: inventory_ui_test (window + fill + refresh + use + move)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var assets := AssetRoot.path()
|
||||
if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/inventorywindow.py")):
|
||||
print(" (skip: no inventorywindow.py — assets checkout missing)")
|
||||
return
|
||||
|
||||
var ui: CanvasLayer = UiManager.new()
|
||||
get_root().add_child(ui)
|
||||
ui._ready()
|
||||
var fc := FakeClient.new()
|
||||
var fp := FakeProto.new()
|
||||
get_root().add_child(fc)
|
||||
get_root().add_child(fp)
|
||||
fc.inv[5] = {"vnum": 19, "count": 1}
|
||||
fc.inv[6] = {"vnum": 27, "count": 3}
|
||||
fc.equip[1] = {"vnum": 11209, "count": 1, "wear": 1}
|
||||
|
||||
var iv: Node = InventoryUI.new()
|
||||
get_root().add_child(iv)
|
||||
iv.setup(ui, fc, fp, assets)
|
||||
iv.open()
|
||||
_ck(iv.is_open(), "inventory window opened")
|
||||
# 45 背包格 + 12 装备格(equipment 元组)
|
||||
_ck(iv._cells.size() >= 45, "at least 45 inventory cells indexed (%d)" % iv._cells.size())
|
||||
_ck(iv._cells.has(90), "equipment cell 90 present")
|
||||
|
||||
# 填充
|
||||
var c5: Panel = iv._cells[5]
|
||||
_ck(int(c5.get_meta("vnum", 0)) == 19, "cell 5 filled vnum 19")
|
||||
_ck(c5.tooltip_text.contains("物品19"), "cell 5 tooltip has locale name")
|
||||
var c6: Panel = iv._cells[6]
|
||||
_ck(c6.has_node("count") and (c6.get_node("count") as Label).text == "3", "cell 6 shows count 3")
|
||||
_ck(int(iv._cells[91].get_meta("vnum", 0)) == 11209, "equip wear1 -> ui cell 91 filled")
|
||||
|
||||
# inventory_changed 刷新
|
||||
fc.set_inv(8, 100, 1)
|
||||
await process_frame
|
||||
_ck(int(iv._cells[8].get_meta("vnum", 0)) == 100, "cell 8 filled after inventory_changed")
|
||||
|
||||
# 右键用
|
||||
iv.use(5)
|
||||
_ck(fc.used.size() == 1 and fc.used[0] == [1, 5], "use(5) -> use_item(1,5)")
|
||||
# 拖 5 -> 91(背包 -> 装备)
|
||||
iv.move_to(5, 91, 1)
|
||||
_ck(fc.moved.size() == 1 and fc.moved[0] == [1, 5, 2, 1, 1], "move_to(5,91) -> move_item(1,5,2,1,1)")
|
||||
|
||||
iv.close()
|
||||
_ck(not iv.is_open(), "window closed")
|
||||
ui.queue_free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ce8ey8voa2h7l
|
||||
@@ -0,0 +1,66 @@
|
||||
# Locale —— EterLocale 字符串表(`locale/locale/<lang>/*.txt`,KEY\tVALUE,UTF-8)。
|
||||
# Godot 原生处理 UTF-8 + 拉丁/西里尔/希腊/阿拉伯;CJK 需外挂字体(apply_font)。
|
||||
# 用 preload("res://locale.gd").new();setup() 后 t("KEY", [args]).
|
||||
extends RefCounted
|
||||
|
||||
var _lang := "en"
|
||||
var _tab := {} # KEY -> value
|
||||
var _base := ""
|
||||
|
||||
func setup(assets_root: String, lang := "en") -> void:
|
||||
_base = assets_root.path_join("locale/locale")
|
||||
set_lang(lang)
|
||||
|
||||
func set_lang(lang: String) -> void:
|
||||
_lang = lang
|
||||
_tab.clear()
|
||||
var dir := _base.path_join(lang)
|
||||
if not DirAccess.dir_exists_absolute(dir):
|
||||
push_warning("[locale] no such lang: " + dir)
|
||||
return
|
||||
for f in ["locale_interface.txt", "locale_game.txt", "itemdesc.txt", "skilldesc.txt"]:
|
||||
_load_table(dir.path_join(f))
|
||||
|
||||
func _load_table(path: String) -> void:
|
||||
if not FileAccess.file_exists(path):
|
||||
return
|
||||
var fa := FileAccess.open(path, FileAccess.READ)
|
||||
if fa == null:
|
||||
return
|
||||
while not fa.eof_reached():
|
||||
var line := fa.get_line()
|
||||
if line.is_empty() or line.begins_with("#"):
|
||||
continue
|
||||
var tab := line.find("\t")
|
||||
if tab < 0:
|
||||
continue
|
||||
var key := line.substr(0, tab).strip_edges()
|
||||
var val := line.substr(tab + 1).strip_edges()
|
||||
if not key.is_empty():
|
||||
_tab[key] = val
|
||||
|
||||
func lang() -> String:
|
||||
return _lang
|
||||
|
||||
func has(key: String) -> bool:
|
||||
return _tab.has(key)
|
||||
|
||||
# t("SELECT_LEVEL") -> "Level";t("AFF_LOVE_POINT", [42]) -> "Love points: 42%"
|
||||
# 缺失时返回 "<KEY>"(开发期一眼可见)。
|
||||
func t(key: String, args: Array = []) -> String:
|
||||
if not _tab.has(key):
|
||||
return "<" + key + ">"
|
||||
var s: String = _tab[key]
|
||||
if args.is_empty():
|
||||
return s
|
||||
return s % args if args.size() > 1 else (s % args[0])
|
||||
|
||||
# 给一个 Control / Window 挂 CJK(或其它)字体。ttf_path 为空则不动。
|
||||
func apply_font(node: Control, ttf_path: String, size := 0) -> void:
|
||||
if ttf_path.is_empty() or not FileAccess.file_exists(ttf_path):
|
||||
return
|
||||
var f := FontFile.new()
|
||||
f.load_dynamic_font(ttf_path)
|
||||
node.add_theme_font_override("font", f)
|
||||
if size > 0:
|
||||
node.add_theme_font_size_override("font_size", size)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmkg44eg1hfh0
|
||||
@@ -0,0 +1,156 @@
|
||||
# 登录界面 —— 服务器/账号/密码输入 + 连接,接 M2Client。
|
||||
# 成功(char_list)后打印槽位;实际项目里在此切到 charselect.gd。
|
||||
# 用法:godot --path project --script login.gd (--auto 用默认值直接连)
|
||||
extends SceneTree
|
||||
|
||||
const UiKit = preload("res://ui_kit.gd")
|
||||
const Audio = preload("res://audio.gd")
|
||||
const Locale = preload("res://locale.gd")
|
||||
const AppLifecycle = preload("res://app_lifecycle.gd")
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
|
||||
var _assets: String
|
||||
var _client: Node
|
||||
var _audio: Node
|
||||
var _life: Node
|
||||
var _layer: CanvasLayer
|
||||
var _game: Node3D
|
||||
var _auto_select := false
|
||||
var _loc: RefCounted
|
||||
var _host: LineEdit
|
||||
var _id: LineEdit
|
||||
var _pw: LineEdit
|
||||
var _status: Label
|
||||
var _t := 0.0
|
||||
|
||||
func _init() -> void:
|
||||
_assets = AssetRoot.path()
|
||||
_loc = Locale.new()
|
||||
_loc.setup(_assets, "en")
|
||||
_audio = Audio.new()
|
||||
get_root().add_child(_audio)
|
||||
_audio.setup(_assets)
|
||||
# F5:登录界面没有游戏逻辑要暂停,但后台时仍停 BGM / 挂起连接
|
||||
_life = AppLifecycle.new()
|
||||
_life.pause_tree_on_background = false
|
||||
get_root().add_child(_life)
|
||||
_life.bind(null, _audio)
|
||||
_layer = CanvasLayer.new()
|
||||
var layer := _layer
|
||||
get_root().add_child(layer)
|
||||
|
||||
var bg := ColorRect.new()
|
||||
bg.color = Color(0.08, 0.09, 0.12)
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
layer.add_child(bg)
|
||||
|
||||
var panel := UiKit.board(_assets, "board", 32, 128)
|
||||
panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
panel.position = Vector2(-220, -150)
|
||||
panel.size = Vector2(440, 300)
|
||||
layer.add_child(panel)
|
||||
|
||||
var v := VBoxContainer.new()
|
||||
v.position = Vector2(40, 36)
|
||||
v.custom_minimum_size = Vector2(360, 0)
|
||||
v.add_theme_constant_override("separation", 12)
|
||||
panel.add_child(v)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = " " + _loc.t("LOGIN_CONNECT")
|
||||
title.add_theme_font_size_override("font_size", 20)
|
||||
v.add_child(title)
|
||||
|
||||
_host = _field(v, _loc.t("LOGIN_DEFAULT_SERVERADDR").split(",")[0], "192.168.21.203")
|
||||
_id = _field(v, _loc.t("LOGIN_ID"), "admin")
|
||||
_pw = _field(v, _loc.t("LOGIN_PASSWORD"), "123456789")
|
||||
_pw.secret = true
|
||||
|
||||
var btn := Button.new()
|
||||
btn.text = _loc.t("LOGIN_CONNECT")
|
||||
btn.custom_minimum_size = Vector2(0, 40)
|
||||
btn.pressed.connect(_connect)
|
||||
v.add_child(btn)
|
||||
|
||||
_status = Label.new()
|
||||
_status.text = _loc.t("LOGIN_CONNECTING")
|
||||
v.add_child(_status)
|
||||
|
||||
if "--auto" in OS.get_cmdline_user_args() or "--auto" in OS.get_cmdline_args():
|
||||
_connect()
|
||||
|
||||
func _field(parent: Control, label: String, val: String) -> LineEdit:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
var l := Label.new()
|
||||
l.text = label
|
||||
l.custom_minimum_size = Vector2(64, 0)
|
||||
row.add_child(l)
|
||||
var le := LineEdit.new()
|
||||
le.text = val
|
||||
le.custom_minimum_size = Vector2(260, 0)
|
||||
row.add_child(le)
|
||||
parent.add_child(row)
|
||||
return le
|
||||
|
||||
func _connect() -> void:
|
||||
if not ClassDB.class_exists("M2Client"):
|
||||
_status.text = "M2Client 未注册(扩展没编?)"
|
||||
return
|
||||
if _client:
|
||||
_client.call("disconnect_from_server")
|
||||
_client.queue_free()
|
||||
_client = ClassDB.instantiate("M2Client")
|
||||
get_root().add_child(_client)
|
||||
_client.connect("stage_changed", func(s): _msg("阶段: " + s))
|
||||
_client.connect("auth_ok", func(k):
|
||||
_msg("认证成功 login_key=0x%08X" % k)
|
||||
_audio.play_ui("loginok"))
|
||||
_client.connect("login_failed", func(r):
|
||||
_msg("失败: " + r)
|
||||
_audio.play_ui("loginfail"))
|
||||
_client.connect("phase_changed", func(p): _msg("游戏相位: " + p))
|
||||
_client.connect("char_list", _on_chars)
|
||||
_client.connect("entered_game", _on_entered_game)
|
||||
_client.connect("disconnected", func(r): _msg("断开: " + r))
|
||||
_client.connect("resumed", func():
|
||||
# 回前台:若后台期间 OS 掐了 TCP,下一次 pump 会发 disconnected;
|
||||
# 曾在游戏里就自动重连。
|
||||
if _client.call("is_in_game"):
|
||||
_msg("回前台,尝试重连…")
|
||||
_client.call("reconnect"))
|
||||
_life.bind(_client, _audio)
|
||||
var h := _host.text
|
||||
_client.call("connect_to_server", h, 11000, h, 11011, _id.text, _pw.text)
|
||||
_msg("连接 %s ..." % h)
|
||||
|
||||
func _on_chars(list: Array) -> void:
|
||||
_msg("角色列表: %d 个" % list.size())
|
||||
for d in list:
|
||||
print("[login] slot %d: %s (job %d, Lv.%d) @ (%d,%d)" % [
|
||||
d.get("index", -1), d.get("name", "?"), d.get("job", 0),
|
||||
d.get("level", 0), d.get("x", 0), d.get("y", 0)])
|
||||
# 简版:自动选第一个非空槽进游戏。正式版切到 charselect.gd。
|
||||
if list.size() > 0 and (_auto_select or "--auto" in OS.get_cmdline_args()):
|
||||
var idx: int = int(list[0].get("index", 0))
|
||||
_msg("自动选择槽位 %d ..." % idx)
|
||||
_client.call("select_character", idx)
|
||||
|
||||
func _on_entered_game() -> void:
|
||||
_msg("已进入游戏 —— 加载场景")
|
||||
if _layer:
|
||||
_layer.visible = false
|
||||
if _life: # 让 game_scene 自己的 AppLifecycle 接管
|
||||
_life.queue_free()
|
||||
_life = null
|
||||
_game = GameScene.new()
|
||||
get_root().add_child(_game)
|
||||
_game.setup(_client, _assets)
|
||||
|
||||
func _msg(t: String) -> void:
|
||||
if _status:
|
||||
_status.text = t
|
||||
print("[login] ", t)
|
||||
|
||||
func _process(_dt: float) -> bool:
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://6u27nmch6k60
|
||||
@@ -0,0 +1,44 @@
|
||||
# love_ui_test — 情侣状态窗随 GC_LOVER_INFO / LOVE_POINT_UPDATE 的桥接信号更新。
|
||||
extends SceneTree
|
||||
|
||||
const LoveUI = preload("res://ui/love_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal lover_changed(lover: Dictionary)
|
||||
var lover := {"valid": false, "name": "", "love_point": 0}
|
||||
func get_lover() -> Dictionary:
|
||||
return lover
|
||||
|
||||
var failed := 0
|
||||
func _check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failed += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if failed == 0:
|
||||
print("PASS: love_ui_test (lover state panel)")
|
||||
quit(0)
|
||||
else:
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var canvas := CanvasLayer.new()
|
||||
var client := FakeClient.new()
|
||||
var love := LoveUI.new()
|
||||
get_root().add_child(canvas)
|
||||
get_root().add_child(client)
|
||||
get_root().add_child(love)
|
||||
love.setup(client, canvas)
|
||||
_check(not love._root.visible, "no lover keeps panel hidden")
|
||||
client.lover = {"valid": true, "name": "Partner", "love_point": 63}
|
||||
client.lover_changed.emit(client.lover)
|
||||
await process_frame
|
||||
_check(love._root.visible, "lover info shows panel")
|
||||
_check(love._name.text == "♥ Partner", "lover name rendered")
|
||||
_check(int(love._bar.value) == 63, "lover point rendered")
|
||||
client.lover["love_point"] = 88
|
||||
client.lover_changed.emit(client.lover)
|
||||
await process_frame
|
||||
_check(int(love._bar.value) == 88, "love point update refreshes bar")
|
||||
@@ -0,0 +1 @@
|
||||
uid://7w5o7rxr2wmk
|
||||
+79
-13
@@ -5,8 +5,8 @@ extends Node3D
|
||||
## Godot's Forward+ renderer, and (if an anim .gr2 is given) drives it with
|
||||
## Metin2AnimPlayer.
|
||||
##
|
||||
## Asset paths default to the sibling m2dev-client-main checkout; override with
|
||||
## MTGODOT_MODEL / MTGODOT_ANIM / MTGODOT_TEXDIR env vars.
|
||||
## Asset root resolves via AssetRoot (res://../assets, or the MT_ASSETS env var).
|
||||
## Per-file overrides: MTGODOT_MODEL / MTGODOT_ANIM / MTGODOT_TEXDIR env vars.
|
||||
##
|
||||
## Keys: drag = orbit wheel = zoom Space = play/pause B = bind pose
|
||||
## [ / ] = scrub R = reload F2 = screenshot
|
||||
@@ -27,7 +27,7 @@ var _dragging := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_assets_root = ProjectSettings.globalize_path("res://../../m2dev-client-main/assets")
|
||||
_assets_root = AssetRoot.path()
|
||||
_build_environment()
|
||||
_build_sun()
|
||||
_build_camera()
|
||||
@@ -106,11 +106,12 @@ func _load_content() -> void:
|
||||
_assets_root.path_join("PC/ymir work/pc/warrior/warrior_cheongrin.gr2"))
|
||||
var tex_dir := _env("MTGODOT_TEXDIR",
|
||||
_assets_root.path_join("PC/ymir work/pc/warrior"))
|
||||
# Default to an idle: dance_1 (and other emotes) hit a libgr2 curve-decode
|
||||
# bug that collapses the head/neck — reproduced in xrender-poc's bgfx demo
|
||||
# too, i.e. upstream, not a Godot-route issue. See docs/MIDREVIEW.md.
|
||||
# dance_1 and other emotes are fine now that CPU skinning is the default
|
||||
# (GPU/Skeleton3D dropped the shear Granny bakes onto the shoulder/front-robe
|
||||
# bones → head/neck collapse; see docs/MIDREVIEW.md §4). Force GPU with
|
||||
# MTGODOT_GPUSKIN=1 for perf testing.
|
||||
var anim_path := _env("MTGODOT_ANIM",
|
||||
_assets_root.path_join("PC/ymir work/pc/warrior/general/wait.gr2"))
|
||||
_assets_root.path_join("PC/ymir work/pc/warrior/action/dance_1.msa"))
|
||||
|
||||
_model = ClassDB.instantiate("Metin2Model")
|
||||
_model.name = "Metin2Model"
|
||||
@@ -119,15 +120,33 @@ func _load_content() -> void:
|
||||
add_child(_model)
|
||||
if _model.has_method("get_info"):
|
||||
print("[mtgodot] model: ", _model.call("get_info"))
|
||||
if _model.has_method("get_hair_options"):
|
||||
var hs: Array = _model.call("get_hair_options")
|
||||
if not hs.is_empty():
|
||||
print("[mtgodot] .msm hair options: %d (e.g. %s)" % [hs.size(), hs[0]])
|
||||
|
||||
if anim_path != "-" and FileAccess.file_exists(anim_path) and ClassDB.class_exists("Metin2AnimPlayer"):
|
||||
_anim = ClassDB.instantiate("Metin2AnimPlayer")
|
||||
_anim.name = "Metin2AnimPlayer"
|
||||
_anim.set("model_path", _anim.get_path_to(_model) if false else NodePath("../Metin2Model"))
|
||||
_anim.set("anim_path", anim_path)
|
||||
var loop_env := OS.get_environment("MTGODOT_LOOP")
|
||||
if loop_env != "":
|
||||
_anim.set("loop", loop_env != "0")
|
||||
var speed_env := OS.get_environment("MTGODOT_TIME_SCALE")
|
||||
if speed_env != "":
|
||||
_anim.set("time_scale", float(speed_env))
|
||||
add_child(_anim)
|
||||
if _anim.has_signal("motion_event"):
|
||||
_anim.connect("motion_event", _on_motion_event)
|
||||
if _anim.has_signal("playback_finished"):
|
||||
_anim.connect("playback_finished", _on_playback_finished)
|
||||
if _anim.has_method("get_info"):
|
||||
print("[mtgodot] anim: ", _anim.call("get_info"))
|
||||
if _anim.has_method("get_events"):
|
||||
var evs: Array = _anim.call("get_events")
|
||||
if not evs.is_empty():
|
||||
print("[mtgodot] msa events: ", evs, " accum=", _anim.call("get_accumulation"))
|
||||
if _anim.has_method("selfcheck"):
|
||||
print("[mtgodot] ", _anim.call("selfcheck", 24))
|
||||
_anim.call("reload")
|
||||
@@ -139,10 +158,15 @@ func _load_content() -> void:
|
||||
|
||||
|
||||
func _frame_model() -> void:
|
||||
# Place the orbit target/dist from the model's converted world-space AABB.
|
||||
# Use trimmed vertex percentiles: a single malformed/far-away vertex (or a
|
||||
# distant attachment bone) must not make the character microscopic.
|
||||
var mi := _model.get_node_or_null("MeshInstance3D") if _model else null
|
||||
if mi and mi is VisualInstance3D:
|
||||
var aabb: AABB = _model.transform * (mi as VisualInstance3D).get_aabb()
|
||||
var aabb := _robust_mesh_frame_aabb(mi as MeshInstance3D) if mi is MeshInstance3D else AABB()
|
||||
var frame_source := "trimmed-mesh"
|
||||
if aabb.size.y <= 0.01 and mi and mi is VisualInstance3D:
|
||||
aabb = _model.transform * (mi as VisualInstance3D).get_aabb()
|
||||
frame_source = "mesh"
|
||||
if aabb.size.y > 0.01:
|
||||
var h := maxf(aabb.size.y, 0.5)
|
||||
_target = Vector3(aabb.get_center().x, aabb.position.y + h * 0.5, aabb.get_center().z)
|
||||
_dist = h * 3.2
|
||||
@@ -150,10 +174,41 @@ func _frame_model() -> void:
|
||||
var yenv := OS.get_environment("MTGODOT_YAW")
|
||||
if yenv != "":
|
||||
_yaw = float(yenv)
|
||||
print("[mtgodot] frame: aabb=", aabb, " target=", _target, " dist=", _dist)
|
||||
print("[mtgodot] frame[", frame_source, "]: aabb=", aabb,
|
||||
" target=", _target, " dist=", _dist)
|
||||
_update_camera()
|
||||
|
||||
|
||||
func _robust_mesh_frame_aabb(mi: MeshInstance3D) -> AABB:
|
||||
if not _model or not mi or not mi.mesh:
|
||||
return AABB()
|
||||
var xs := PackedFloat32Array()
|
||||
var ys := PackedFloat32Array()
|
||||
var zs := PackedFloat32Array()
|
||||
for surface in range(mi.mesh.get_surface_count()):
|
||||
var arrays := mi.mesh.surface_get_arrays(surface)
|
||||
if arrays.size() <= Mesh.ARRAY_VERTEX:
|
||||
continue
|
||||
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
|
||||
for local_pos in vertices:
|
||||
var p: Vector3 = _model.transform * local_pos
|
||||
if p.is_finite():
|
||||
xs.append(p.x)
|
||||
ys.append(p.y)
|
||||
zs.append(p.z)
|
||||
if xs.size() < 8:
|
||||
return AABB()
|
||||
xs.sort()
|
||||
ys.sort()
|
||||
zs.sort()
|
||||
var trim := int(xs.size() * 0.01)
|
||||
var hi := xs.size() - trim - 1
|
||||
var lo_p := Vector3(xs[trim], ys[trim], zs[trim])
|
||||
var hi_p := Vector3(xs[hi], ys[hi], zs[hi])
|
||||
var bounds := AABB(lo_p, hi_p - lo_p)
|
||||
return bounds.grow(maxf(bounds.size.y * 0.08, 0.05))
|
||||
|
||||
|
||||
func _update_camera() -> void:
|
||||
var offset := Vector3(
|
||||
cos(_pitch) * sin(_yaw),
|
||||
@@ -211,7 +266,7 @@ func _on_key(kc: int) -> void:
|
||||
|
||||
## MTGODOT_STRESS=N : spawn N animated warriors in a grid, measure frame time
|
||||
## over ~180 frames, write test/godot-macos-stress.json, quit.
|
||||
## Feeds the Phase-2 animation A/B (per-frame set_bone_global_pose vs baked).
|
||||
## Baseline for Phase-2 perf: default CPU LBS vs MTGODOT_GPUSKIN=1 vertex shader.
|
||||
func _run_stress(n: int) -> void:
|
||||
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
|
||||
Engine.max_fps = 0
|
||||
@@ -270,7 +325,9 @@ func _run_stress(n: int) -> void:
|
||||
"cpu_process_ms_avg": psum / proc.size(),
|
||||
"cpu_process_ms_p95": proc[int(proc.size() * 0.95)],
|
||||
"renderer": RenderingServer.get_video_adapter_name(),
|
||||
"method": "per-frame set_bone_global_pose (B)",
|
||||
"method": ("per-frame GPU LBS via bone-texture vertex shader (full affine)"
|
||||
if OS.get_environment("MTGODOT_GPUSKIN") == "1"
|
||||
else "per-frame CPU LBS, mesh rebuilt each frame (B, default)"),
|
||||
}
|
||||
var out := OS.get_environment("MTGODOT_STRESS_OUT")
|
||||
if out == "":
|
||||
@@ -283,6 +340,15 @@ func _run_stress(n: int) -> void:
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
func _on_motion_event(type: int, effect: String, sound: String, pos: Vector3) -> void:
|
||||
print("[mtgodot] motion_event t=%.3f type=%d effect=%s sound=%s pos=%s"
|
||||
% [_anim.call("get_time") if _anim else 0.0, type, effect, sound, pos])
|
||||
|
||||
|
||||
func _on_playback_finished() -> void:
|
||||
print("[mtgodot] playback finished at t=", _anim.call("get_time") if _anim else -1.0)
|
||||
|
||||
|
||||
func _screenshot() -> void:
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
var path := "user://shot_%d.png" % Time.get_ticks_msec()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# mob_view_test —— 怪 / NPC 真模型(MobView)headless 自检。
|
||||
# godot --headless --path project --script mob_view_test.gd
|
||||
# 需要 Metin2Model 扩展 + Metin2 资产(AssetRoot.path());缺就跳过对应检查。
|
||||
extends SceneTree
|
||||
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: mob_view_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("Metin2Proto"):
|
||||
print(" (skip: 扩展未注册)")
|
||||
return
|
||||
var assets := AssetRoot.path()
|
||||
if not DirAccess.dir_exists_absolute(assets.path_join("Monster")):
|
||||
print(" (skip: 无 Monster 资产)")
|
||||
return
|
||||
var proto: Object = ClassDB.instantiate("Metin2Proto")
|
||||
get_root().add_child(proto)
|
||||
var mp := assets.path_join("locale/locale/en/mob_proto")
|
||||
if not FileAccess.file_exists(mp) or not proto.call("load_mob_proto", mp):
|
||||
print(" (skip: 无 mob_proto)")
|
||||
return
|
||||
|
||||
# race 102 = Wolf -> assets/*/ymir work/monster/wolf/
|
||||
var mv: Node3D = MobView.new()
|
||||
get_root().add_child(mv)
|
||||
var ok: bool = mv.build(assets, proto, 102)
|
||||
_ck(ok, "MobView.build(102 = Wolf) 成功")
|
||||
if not ok:
|
||||
return
|
||||
_ck(mv._dir.get_file() == "wolf", "解到 wolf 目录(%s)" % mv._dir)
|
||||
_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 动作")
|
||||
# 状态切换:run -> RUN / attack -> NORMAL_ATTACK(存在才断言路径变化)
|
||||
mv.set_anim_state("run")
|
||||
var s1: String = mv._state
|
||||
mv.set_anim_state("attack")
|
||||
_ck(mv._state == "attack" and s1 == "run", "set_anim_state 切换记录")
|
||||
|
||||
# 未知 race -> build 失败(返回 null 用占位)
|
||||
var mv2: Node3D = MobView.new()
|
||||
get_root().add_child(mv2)
|
||||
_ck(not mv2.build(assets, proto, 999999), "未知 race -> build 失败")
|
||||
|
||||
# race 20009 = Old Man(NPC)——若有目录就该建成
|
||||
var mv3: Node3D = MobView.new()
|
||||
get_root().add_child(mv3)
|
||||
var npc_ok: bool = mv3.build(assets, proto, 20009)
|
||||
print(" NPC 20009 (Old Man) build = %s (%s)" % [npc_ok, mv3._dir])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5dp6gbugrc1t
|
||||
@@ -0,0 +1,103 @@
|
||||
# ChannelStatus (P10 收尾) —— 频道负载查询(对齐 CServerStateChecker)。
|
||||
#
|
||||
# var cs := preload("res://net/channel_status.gd").new()
|
||||
# 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}。
|
||||
# status:0 关 / 1 正常 / 2 忙 / 3 满(Metin2 约定,非在线人数——那要改服务器)。
|
||||
extends Node
|
||||
|
||||
const CG_STATE_CHECKER := 0x000F
|
||||
const GC_RESPOND_CHANNELSTATUS := 0x0010
|
||||
|
||||
const STATUS_TEXT := {0: "关闭", 1: "正常", 2: "拥挤", 3: "爆满"}
|
||||
|
||||
signal done(status_by_port: Dictionary)
|
||||
|
||||
var _peer: StreamPeerTCP
|
||||
var _cb: Callable
|
||||
var _deadline := 0.0
|
||||
var _sent := false
|
||||
|
||||
func query(host: String, port: int, callback: Callable = Callable(), timeout_s := 3.0) -> void:
|
||||
_cb = callback
|
||||
_peer = StreamPeerTCP.new()
|
||||
_sent = false
|
||||
_deadline = _now() + timeout_s
|
||||
if _peer.connect_to_host(host, port) != OK:
|
||||
_finish({})
|
||||
return
|
||||
set_process(true)
|
||||
|
||||
func text_for(status: int) -> String:
|
||||
return STATUS_TEXT.get(status, "?")
|
||||
|
||||
func _now() -> float:
|
||||
return Time.get_ticks_msec() / 1000.0
|
||||
|
||||
func _process(_dt: float) -> void:
|
||||
if _peer == null:
|
||||
return
|
||||
_peer.poll()
|
||||
var st := _peer.get_status()
|
||||
if st == StreamPeerTCP.STATUS_ERROR or _now() > _deadline:
|
||||
_finish({})
|
||||
return
|
||||
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)
|
||||
_peer.put_data(req)
|
||||
_sent = true
|
||||
# 攒够一个包就解析
|
||||
_try_parse()
|
||||
|
||||
func _try_parse() -> void:
|
||||
var avail := _peer.get_available_bytes()
|
||||
if avail < 4:
|
||||
return
|
||||
# 逐包扫,跳到 RESPOND_CHANNELSTATUS
|
||||
while _peer.get_available_bytes() >= 4:
|
||||
var head: Array = _peer.get_partial_data(4)
|
||||
if head[0] != OK:
|
||||
return
|
||||
var hb: PackedByteArray = head[1]
|
||||
var header := hb.decode_u16(0)
|
||||
var length := hb.decode_u16(2)
|
||||
if header == GC_RESPOND_CHANNELSTATUS:
|
||||
_read_body()
|
||||
return
|
||||
# 别的包:按 length 跳过剩余(已吃掉 4 字节头)
|
||||
var rest := maxi(0, length - 4)
|
||||
if rest > 0:
|
||||
if _peer.get_available_bytes() < rest:
|
||||
return # 等更多数据(简化:不缓存半包,靠 timeout 兜底)
|
||||
_peer.get_data(rest)
|
||||
|
||||
func _read_body() -> void:
|
||||
# 已消费 4 字节头;接着 int32 count + count × {i16 port, u8 status}
|
||||
if _peer.get_available_bytes() < 4:
|
||||
return
|
||||
var cnt_r: Array = _peer.get_data(4)
|
||||
var count: int = (cnt_r[1] as PackedByteArray).decode_s32(0)
|
||||
var out := {}
|
||||
for i in count:
|
||||
if _peer.get_available_bytes() < 3:
|
||||
break
|
||||
var rec: PackedByteArray = _peer.get_data(3)[1]
|
||||
out[rec.decode_s16(0)] = rec.decode_u8(2)
|
||||
_finish(out)
|
||||
|
||||
func _finish(m: Dictionary) -> void:
|
||||
set_process(false)
|
||||
if _peer:
|
||||
_peer.disconnect_from_host()
|
||||
_peer = null
|
||||
done.emit(m)
|
||||
if _cb.is_valid():
|
||||
_cb.call(m)
|
||||
@@ -0,0 +1 @@
|
||||
uid://015nea7sbodm
|
||||
@@ -0,0 +1,39 @@
|
||||
# MapCoord —— 网络实体坐标 ↔ Metin2World 本地坐标。
|
||||
#
|
||||
# M2Client 给的实体 pos 是「全局服务器 cm」经 position_to_godot 后的结果:
|
||||
# net = (sx*0.01, h, -sy*0.01) (sx/sy 含地图 BasePosition)
|
||||
# 而 Metin2World 的地形/物件是「地图本地」帧(tile 0 在原点,+Z 朝南):
|
||||
# world = ((sx-bx)*0.01, h, (sy-by)*0.01)
|
||||
# 两者差一个 BasePosition 平移 + Z 轴翻向。没对齐时角色会飘在几公里外的黑void里。
|
||||
#
|
||||
# MapCoord.set_base(world.get_map_base_cm()) # Vector2,cm
|
||||
# var p := MapCoord.to_world(client.get_entity(vid).pos)
|
||||
# var sc := MapCoord.to_server_cm(player.position) # -> Vector2 (sx_cm, sy_cm)
|
||||
class_name MapCoord
|
||||
extends RefCounted
|
||||
|
||||
static var _bx := 0.0 # BasePosition.x, cm
|
||||
static var _by := 0.0
|
||||
|
||||
static func set_base(base_cm: Vector2) -> void:
|
||||
_bx = base_cm.x
|
||||
_by = base_cm.y
|
||||
|
||||
static func has_base() -> bool:
|
||||
return _bx != 0.0 or _by != 0.0
|
||||
|
||||
# net 帧 (sx*.01, h, -sy*.01) -> world 本地帧 ((sx-bx)*.01, h, (sy-by)*.01)
|
||||
static func to_world(net_pos: Vector3) -> Vector3:
|
||||
return Vector3(net_pos.x - _bx * 0.01, net_pos.y, -net_pos.z - _by * 0.01)
|
||||
|
||||
# world 本地帧 -> 服务器全局 cm (sx, sy)
|
||||
static func to_server_cm(world_pos: Vector3) -> Vector2:
|
||||
return Vector2(world_pos.x * 100.0 + _bx, world_pos.z * 100.0 + _by)
|
||||
|
||||
# 服务器 heading(度) -> world 帧 Godot yaw(弧度)。
|
||||
# net 帧里南=-Z、world 帧里南=+Z,Z 翻向 -> 相对 net 帧的换算符号取反。
|
||||
static func heading_to_yaw(angle_deg: float) -> float:
|
||||
return deg_to_rad(angle_deg + 90.0)
|
||||
|
||||
static func yaw_to_heading(yaw_rad: float) -> float:
|
||||
return fposmod(rad_to_deg(yaw_rad) - 90.0, 360.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c36ncgt70wf0a
|
||||
@@ -0,0 +1,91 @@
|
||||
# ServerInfo (P10) —— 服务器列表 + 频道 → (host, port)。
|
||||
#
|
||||
# var si := ServerInfo.new()
|
||||
# si.load_file("res://serverlist.txt") # 可选;没有就用内置默认
|
||||
# for s in si.servers(): ...
|
||||
# var addr := si.address(server_index, channel) # {auth_host,auth_port,game_host,game_port}
|
||||
#
|
||||
# 文件格式(TSV,# 注释):
|
||||
# name auth_host auth_port game_host game_port channels(csv) [port_step] [mark_port]
|
||||
# 测试服 192.168.21.203 11000 192.168.21.203 11011 1,2,3 1 0
|
||||
#
|
||||
# 频道 N 的 game 端口 = game_port + (N-1)*port_step。
|
||||
# 联调服实测端口是 11011/11012/11013(step=1);不同服可在文件第 7 列覆盖。
|
||||
# 第 8 列 mark_port = 公会会徽服端口(0 = 不下载会徽)。
|
||||
class_name ServerInfo
|
||||
extends RefCounted
|
||||
|
||||
const DEFAULT_PORT_STEP := 1
|
||||
|
||||
var _servers: Array = []
|
||||
|
||||
func _init() -> void:
|
||||
# 内置默认:当前联调服(CServerStateChecker 实测频道口 11011/12/13)
|
||||
_servers = [{
|
||||
"name": "测试服",
|
||||
"auth_host": "192.168.21.203", "auth_port": 11000,
|
||||
"game_host": "192.168.21.203", "game_port": 11011,
|
||||
"channels": [1, 2, 3], "port_step": 1, "mark_port": 0,
|
||||
}]
|
||||
|
||||
func load_file(path: String) -> bool:
|
||||
if not FileAccess.file_exists(path):
|
||||
return false
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return false
|
||||
var out: Array = []
|
||||
while not f.eof_reached():
|
||||
var line := f.get_line().strip_edges()
|
||||
if line == "" or line.begins_with("#"):
|
||||
continue
|
||||
var c := line.split("\t", false)
|
||||
if c.size() < 5:
|
||||
c = line.split(" ", false) # 容忍空格分隔
|
||||
if c.size() < 5:
|
||||
continue
|
||||
var chans: Array = []
|
||||
if c.size() >= 6:
|
||||
for t in String(c[5]).split(",", false):
|
||||
chans.append(int(t))
|
||||
if chans.is_empty():
|
||||
chans = [1]
|
||||
var step := DEFAULT_PORT_STEP
|
||||
if c.size() >= 7 and int(c[6]) > 0:
|
||||
step = int(c[6])
|
||||
var mark_port := 0
|
||||
if c.size() >= 8 and int(c[7]) > 0:
|
||||
mark_port = int(c[7])
|
||||
out.append({
|
||||
"name": String(c[0]),
|
||||
"auth_host": String(c[1]), "auth_port": int(c[2]),
|
||||
"game_host": String(c[3]), "game_port": int(c[4]),
|
||||
"channels": chans, "port_step": step, "mark_port": mark_port,
|
||||
})
|
||||
if out.is_empty():
|
||||
return false
|
||||
_servers = out
|
||||
return true
|
||||
|
||||
func servers() -> Array:
|
||||
return _servers
|
||||
|
||||
func count() -> int:
|
||||
return _servers.size()
|
||||
|
||||
func server(idx: int) -> Dictionary:
|
||||
return _servers[idx] if idx >= 0 and idx < _servers.size() else {}
|
||||
|
||||
# 返回 {auth_host, auth_port, game_host, game_port},game_port 已按频道偏移。
|
||||
func address(server_idx: int, channel: int = 1) -> Dictionary:
|
||||
var s := server(server_idx)
|
||||
if s.is_empty():
|
||||
return {}
|
||||
var ch: int = maxi(1, channel)
|
||||
var step: int = int(s.get("port_step", DEFAULT_PORT_STEP))
|
||||
return {
|
||||
"auth_host": s["auth_host"], "auth_port": s["auth_port"],
|
||||
"game_host": s["game_host"],
|
||||
"game_port": int(s["game_port"]) + (ch - 1) * step,
|
||||
"mark_host": s["game_host"], "mark_port": int(s.get("mark_port", 0)),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dgvdccv3267fh
|
||||
@@ -0,0 +1,319 @@
|
||||
# NetPlay (P0) —— 本地输入 ←→ 网络的闭环胶水。
|
||||
#
|
||||
# 点地移动 -> M2Client.move(FUNC_MOVE, …) (节流:>1m 位移或 >0.2s)
|
||||
# 停下 -> M2Client.move(FUNC_WAIT, …)
|
||||
# 点实体 -> M2Client.set_target(vid);进入攻击距离后按攻速自动 attack()
|
||||
# 收到自己的 GC_MOVE -> 位置校正(差 >snap 瞬移,否则 lerp)
|
||||
# points_changed / vitals_changed / target_info -> HUD
|
||||
#
|
||||
# 需要:M2Client、PlayerController(`player_controller.gd`)、NetWorld(`net_world.gd`)。
|
||||
# 可选:HUD(有 set_vitals / set_exp / set_level / set_target / clear_target 就调)。
|
||||
#
|
||||
# 用法:
|
||||
# var np := preload("res://net_play.gd").new()
|
||||
# add_child(np)
|
||||
# np.setup(m2client, player_ctl, net_world, hud)
|
||||
extends Node
|
||||
|
||||
signal target_changed(vid: int)
|
||||
|
||||
const FUNC_WAIT := 0
|
||||
const FUNC_MOVE := 1
|
||||
const CM := 100.0 # 米 -> 厘米
|
||||
const SEND_INTERVAL := 0.2 # 移动包最小间隔(秒)
|
||||
const SEND_DIST := 1.0 # 或位移超过这么多米立即发
|
||||
const ATTACK_RANGE := 2.5 # 米
|
||||
const CORRECTION_SNAP := 6.0 # 服务器位置差超过就瞬移
|
||||
const CORRECTION_LERP := 0.25 # 否则每次校正插值比例
|
||||
|
||||
var client: Node # M2Client
|
||||
var pc: Node # PlayerController
|
||||
var net_world: Node # NetWorld
|
||||
var hud: Node # 可空
|
||||
var attack_motion := 0 # 普通攻击 motion index(P4 再按 race 细分)
|
||||
const DEFAULT_ATTACK_PERIOD := 0.6 # 秒/次,100 = 原始动作速率
|
||||
var attack_period := DEFAULT_ATTACK_PERIOD
|
||||
|
||||
var camera: Node # GameCamera(可空,用于受击抖屏)
|
||||
var player_view: Node # PlayerView(可空,set_anim_state("damage"))
|
||||
var proto: Node # Metin2Proto(可空,按 race 分类 NPC/怪)
|
||||
|
||||
const DAMAGE_DODGE := 1 << 2
|
||||
const DAMAGE_CRITICAL := 1 << 5
|
||||
const HITSTUN := 0.32 # 受击硬直秒数(锁输入)
|
||||
const COMBO_WINDOW := 0.9 # 连击窗口
|
||||
|
||||
var _target_vid := 0
|
||||
var _main_vid := 0
|
||||
var _last_sent_pos := Vector3.ZERO
|
||||
var _last_sent_t := 0.0
|
||||
var _was_moving := false
|
||||
var _attack_cd := 0.0
|
||||
var _hitstun_until := 0.0
|
||||
var _combo := 0
|
||||
var _last_attack_t := 0.0
|
||||
|
||||
func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> void:
|
||||
client = m2client
|
||||
pc = player_ctl
|
||||
net_world = nw
|
||||
hud = hud_node
|
||||
pc.target_selected.connect(_on_pick)
|
||||
pc.moved.connect(_on_local_moved)
|
||||
pc.anim_state.connect(_on_anim_state)
|
||||
client.entity_main_set.connect(_on_main_set)
|
||||
client.entity_moved.connect(_on_net_moved)
|
||||
if client.has_signal("entity_info"):
|
||||
client.entity_info.connect(_on_entity_info)
|
||||
client.points_changed.connect(_on_points)
|
||||
client.vitals_changed.connect(_on_vitals)
|
||||
client.target_info.connect(_on_target_info)
|
||||
client.entity_despawned.connect(func(v): if v == _target_vid: _clear_target())
|
||||
client.entity_dead.connect(func(v): if v == _target_vid: _clear_target())
|
||||
if client.has_signal("damage"):
|
||||
client.damage.connect(_on_damage)
|
||||
if client.has_signal("fishing_event"):
|
||||
client.fishing_event.connect(_on_fishing_event)
|
||||
|
||||
# --- helpers --------------------------------------------------------------
|
||||
|
||||
func _vid_of(node: Object) -> int:
|
||||
if node and node.has_meta("vid"):
|
||||
return int(node.get_meta("vid"))
|
||||
return 0
|
||||
|
||||
# 玩家节点在 Metin2World 本地帧 -> 服务器全局 cm(含 BasePosition)
|
||||
func _server_xy(world_pos: Vector3) -> Vector2:
|
||||
return MapCoord.to_server_cm(world_pos)
|
||||
|
||||
func _heading_deg(yaw_rad: float) -> float:
|
||||
return MapCoord.yaw_to_heading(yaw_rad)
|
||||
|
||||
func _player_yaw() -> float:
|
||||
return pc.player.rotation.y if pc.player else 0.0
|
||||
|
||||
# CHRTYPE 分类:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP。
|
||||
# 本 fork 的 GC_CHARACTER_ADD.bType 对 NPC/怪都是 0,按 race 查 mob_proto 补。
|
||||
func _entity_kind(e: Dictionary) -> int:
|
||||
var ct := int(e.get("ch_type", 0))
|
||||
if ct != 0:
|
||||
return ct
|
||||
var race := int(e.get("race", 0))
|
||||
if race >= 1 and proto and proto.has_method("mob"):
|
||||
var m: Dictionary = proto.mob(race)
|
||||
if not m.is_empty():
|
||||
# mob_proto bType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR …
|
||||
match int(m.get("type", 0)):
|
||||
0: return 2
|
||||
1: return 1
|
||||
2: return 3
|
||||
3: return 4
|
||||
return 0
|
||||
|
||||
# 实体显示名:玩家 / NPC 走 GC_CHAR_ADD_INFO 的 name;怪没有名字包 -> 查 mob_proto。
|
||||
func _entity_name(e: Dictionary) -> String:
|
||||
var nm := String(e.get("name", ""))
|
||||
if nm != "":
|
||||
return nm
|
||||
var race := int(e.get("race", 0))
|
||||
if race >= 1 and proto and proto.has_method("mob"):
|
||||
var m: Dictionary = proto.mob(race)
|
||||
if not m.is_empty():
|
||||
return String(m.get("locale_name", m.get("name", "")))
|
||||
return ""
|
||||
|
||||
# --- movement ----------------------------------------------------------------
|
||||
|
||||
func _on_local_moved(pos: Vector3) -> void:
|
||||
if 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:
|
||||
return
|
||||
var xy := _server_xy(pos)
|
||||
client.move(FUNC_MOVE, 0, _heading_deg(_player_yaw()), int(xy.x), int(xy.y))
|
||||
_last_sent_pos = pos
|
||||
_last_sent_t = now
|
||||
_was_moving = true
|
||||
|
||||
func _on_anim_state(state: String) -> void:
|
||||
if state == "wait" and _was_moving:
|
||||
_was_moving = false
|
||||
if client and client.is_in_game() and pc.player:
|
||||
var xy := _server_xy(pc.player.position)
|
||||
client.move(FUNC_WAIT, 0, _heading_deg(_player_yaw()), int(xy.x), int(xy.y))
|
||||
|
||||
func _on_main_set(vid: int) -> void:
|
||||
_main_vid = vid
|
||||
if client and client.has_method("get_entity"):
|
||||
_on_entity_info(vid, client.get_entity(vid))
|
||||
# net_world 会给主角也生成一个节点;本地由 player_controller 的 player 代表,去掉重复的
|
||||
if net_world and net_world.has_method("set_local_vid"):
|
||||
net_world.set_local_vid(vid)
|
||||
|
||||
func _on_entity_info(vid: int, entity: Dictionary) -> void:
|
||||
if vid != _main_vid:
|
||||
return
|
||||
if pc and pc.has_method("set_server_speed"):
|
||||
var speed := int(entity.get("moving_speed", 0))
|
||||
if speed > 0:
|
||||
pc.set_server_speed(speed)
|
||||
_apply_attack_speed(int(entity.get("attack_speed", 0)))
|
||||
|
||||
func _apply_attack_speed(speed: int) -> void:
|
||||
if speed > 0:
|
||||
# m2dev applies bAttackSpeed / 100.0 to motion playback. Use the same
|
||||
# factor for the request cadence while keeping a playable safety range.
|
||||
var factor := clampf(float(speed) / 100.0, 0.25, 3.0)
|
||||
attack_period = DEFAULT_ATTACK_PERIOD / factor
|
||||
|
||||
func _on_points(p: Dictionary) -> void:
|
||||
_apply_attack_speed(int(p.get("attack_speed", 0)))
|
||||
if hud == null:
|
||||
return
|
||||
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_exp"):
|
||||
hud.set_exp(int(p.get("exp", 0)), int(p.get("next_exp", 0)))
|
||||
if hud.has_method("set_level"):
|
||||
hud.set_level(int(p.get("level", 0)))
|
||||
if hud.has_method("set_energy"):
|
||||
hud.set_energy(int(p.get("energy", 0)), 100)
|
||||
|
||||
func _on_net_moved(vid: int) -> void:
|
||||
if client == null or vid != _main_vid or pc.player == null:
|
||||
return
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty():
|
||||
return
|
||||
var sp: Vector3 = MapCoord.to_world(e.get("pos", pc.player.position))
|
||||
sp.y = pc.player.position.y # 高度本地贴地,不信服务器 z
|
||||
var d: float = pc.player.position.distance_to(sp)
|
||||
if d > CORRECTION_SNAP:
|
||||
pc.player.position = sp
|
||||
elif d > 0.3:
|
||||
pc.player.position = pc.player.position.lerp(sp, CORRECTION_LERP)
|
||||
|
||||
# --- targeting / attack -----------------------------------------------------
|
||||
|
||||
func _on_pick(node: Node3D) -> void:
|
||||
var vid := _vid_of(node)
|
||||
if vid == 0 or vid == _main_vid:
|
||||
return
|
||||
# NPC / 石头 / warp -> 交互,不是攻击目标
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if _entity_kind(e) in [1, 3, 4]:
|
||||
client.click_npc(vid)
|
||||
return
|
||||
_target_vid = vid
|
||||
_attack_cd = 0.0
|
||||
client.set_target(vid)
|
||||
target_changed.emit(vid)
|
||||
_refresh_target_hud()
|
||||
|
||||
func _on_target_info(vid: int, hp_pct: int) -> void:
|
||||
if vid == 0:
|
||||
_clear_target()
|
||||
return
|
||||
_target_vid = vid
|
||||
if hud and hud.has_method("set_target"):
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
var nm := _entity_name(e) if not e.is_empty() else ""
|
||||
hud.set_target(nm if nm != "" else "目标", hp_pct)
|
||||
|
||||
func _clear_target() -> void:
|
||||
if _target_vid == 0:
|
||||
return
|
||||
_target_vid = 0
|
||||
target_changed.emit(0)
|
||||
if hud and hud.has_method("clear_target"):
|
||||
hud.clear_target()
|
||||
|
||||
# 受击:自己是 victim 且非闪避 -> 硬直(锁输入)+ 播 damage + 抖屏
|
||||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
if vid != _main_vid:
|
||||
return
|
||||
var crit := (flag & DAMAGE_CRITICAL) != 0
|
||||
if camera and camera.has_method("shake"):
|
||||
camera.shake(0.08 if crit else 0.04, 10.0)
|
||||
if (flag & DAMAGE_DODGE) != 0:
|
||||
return
|
||||
_hitstun_until = _now() + HITSTUN
|
||||
if pc and "force_run" in pc:
|
||||
pass
|
||||
if player_view and player_view.has_method("set_anim_state"):
|
||||
player_view.set_anim_state("damage")
|
||||
|
||||
# GC_FISHING motion events for the local player. `info` is the fisher VID for
|
||||
# START/STOP/REACT/SUCCESS/FAIL; FISH carries an item vnum and is UI-only.
|
||||
func _on_fishing_event(subheader: int, info: int, _dir: int) -> void:
|
||||
if info != _main_vid or player_view == null or not player_view.has_method("set_anim_state"):
|
||||
return
|
||||
if subheader == 5:
|
||||
return
|
||||
var state := "fishing"
|
||||
match subheader:
|
||||
1: state = "wait"
|
||||
2: state = "fishing_react"
|
||||
3: state = "fishing_catch"
|
||||
4: state = "fishing_fail"
|
||||
player_view.set_anim_state(state)
|
||||
|
||||
func is_stunned() -> bool:
|
||||
return _now() < _hitstun_until
|
||||
|
||||
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:
|
||||
return
|
||||
if is_stunned():
|
||||
return # 硬直:不发攻击 / 移动
|
||||
var tnode: Node3D = net_world.node_for(_target_vid) if net_world else null
|
||||
if tnode == null or pc.player == null:
|
||||
return
|
||||
var te: Dictionary = client.get_entity(_target_vid)
|
||||
if te.get("dead", false):
|
||||
_clear_target()
|
||||
return
|
||||
_attack_cd -= dt
|
||||
var dist: float = pc.player.global_position.distance_to(tnode.global_position)
|
||||
if dist <= ATTACK_RANGE and _attack_cd <= 0.0:
|
||||
var to: Vector3 = tnode.global_position - pc.player.global_position
|
||||
if to.length() > 0.01:
|
||||
pc.player.rotation.y = atan2(to.x, to.z)
|
||||
# combo:连击窗口内递增 motion(0/1/2 循环),超时归零
|
||||
var now := _now()
|
||||
_combo = (_combo + 1) % 3 if (now - _last_attack_t) < COMBO_WINDOW else 0
|
||||
_last_attack_t = now
|
||||
client.attack(attack_motion + _combo, _target_vid)
|
||||
if player_view and player_view.has_method("set_anim_state"):
|
||||
player_view.set_anim_state("combo" if _combo > 0 else "attack")
|
||||
_attack_cd = attack_period
|
||||
|
||||
func _now() -> float:
|
||||
return Time.get_ticks_msec() / 1000.0
|
||||
|
||||
# --- HUD -----------------------------------------------------------------
|
||||
|
||||
|
||||
func _on_vitals(vid: int) -> void:
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty():
|
||||
return
|
||||
if vid == _main_vid and hud and hud.has_method("set_vitals"):
|
||||
hud.set_vitals(int(e.get("hp", 0)), int(e.get("max_hp", 0)),
|
||||
int(e.get("sp", 0)), int(e.get("max_sp", 0)))
|
||||
if vid == _target_vid:
|
||||
_refresh_target_hud()
|
||||
|
||||
func _refresh_target_hud() -> void:
|
||||
if hud == null or not hud.has_method("set_target"):
|
||||
return
|
||||
var e: Dictionary = client.get_entity(_target_vid)
|
||||
if e.is_empty():
|
||||
return
|
||||
var mh := int(e.get("max_hp", 0))
|
||||
var pct := int(100.0 * float(e.get("hp", 0)) / float(mh)) if mh > 0 else 0
|
||||
hud.set_target(_entity_name(e), pct)
|
||||
@@ -0,0 +1 @@
|
||||
uid://k8vtf51x130l
|
||||
@@ -0,0 +1,517 @@
|
||||
# NetWorld —— 把 M2Client 的网络实体镜像成 Godot 场景节点。
|
||||
#
|
||||
# 消费 M2Client 的 entity_spawned / entity_despawned / entity_moved /
|
||||
# entity_main_set / chat 信号:每个 vid 一个子节点,位置向 client.get_entity(vid).pos
|
||||
# 平滑逼近,rotation.y 取自 angle_deg,动画状态取自 func(FUNC_*)。
|
||||
#
|
||||
# 默认生成「占位胶囊 + 名字牌」(快、无依赖,先跑通同步/插值/状态循环)。
|
||||
# 要换成真模型:set_model_factory(func(entity_dict) -> Node3D),返回的节点若有
|
||||
# set_anim_state(name:String) 方法就会被驱动。
|
||||
#
|
||||
# 用法:
|
||||
# var nw := preload("res://net_world.gd").new()
|
||||
# add_child(nw)
|
||||
# nw.setup(m2client, self) # self = 实体挂载父节点(Node3D)
|
||||
# nw.world = metin2_world # 可选:贴地
|
||||
# nw.main_entity_ready.connect(func(node, vid): _cam.target = node)
|
||||
extends Node
|
||||
|
||||
signal entity_added(node: Node3D, vid: int)
|
||||
signal entity_removed(vid: int)
|
||||
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 vitals(vid: int, hp: int, max_hp: int, dead: bool)
|
||||
signal damage_number(vid: int, amount: int, crit: bool, dodge: bool)
|
||||
|
||||
# CInstanceBase::FUNC_*
|
||||
const FUNC_WAIT := 0
|
||||
const FUNC_MOVE := 1
|
||||
const FUNC_ATTACK := 2
|
||||
const FUNC_COMBO := 3
|
||||
const FUNC_MOB_SKILL := 4
|
||||
const FUNC_EMOTION := 5
|
||||
const FUNC_SKILL := 0x80
|
||||
|
||||
# EDamageFlag
|
||||
const DAMAGE_DODGE := 1 << 2
|
||||
const DAMAGE_CRITICAL := 1 << 5
|
||||
|
||||
var client: Node # M2Client
|
||||
var parent: Node3D # 实体挂这下面
|
||||
var world: Node # Metin2World(可空,用 sample_height 贴地)
|
||||
var follow_lerp := 14.0 # 位置逼近速度
|
||||
var snap_dist := 6.0 # 超过这么多米直接瞬移(初次出现/大跳)
|
||||
|
||||
var _by_vid := {} # vid:int -> Node3D
|
||||
var _main_vid := 0
|
||||
var _local_vid := 0 # 由 net_play 设:这个 vid 由本地 player 代表,不生成节点
|
||||
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}
|
||||
|
||||
func setup(m2client: Node, mount: Node3D) -> void:
|
||||
client = m2client
|
||||
parent = mount
|
||||
client.entity_spawned.connect(_on_spawn)
|
||||
client.entity_despawned.connect(_on_despawn)
|
||||
client.entity_main_set.connect(_on_main_set)
|
||||
if client.has_signal("entity_info"):
|
||||
client.entity_info.connect(_on_info)
|
||||
client.chat.connect(func(t, v, s):
|
||||
chat_line.emit(t, v, s)
|
||||
if t in [0, 3, 4, 6]: # 普通/队伍/公会/喊话 -> 头顶气泡
|
||||
_bubble(int(v), String(s)))
|
||||
# entity_moved 不强依赖:_process 每帧统一插值。留个钩子便于调试。
|
||||
client.entity_moved.connect(_on_moved)
|
||||
client.vitals_changed.connect(_on_vitals)
|
||||
client.entity_dead.connect(_on_dead)
|
||||
client.damage.connect(_on_damage)
|
||||
if client.has_signal("dig_motion"):
|
||||
client.dig_motion.connect(_on_dig_motion)
|
||||
if client.has_signal("fly_cue"):
|
||||
client.fly_cue.connect(_on_fly)
|
||||
if client.has_signal("fly_targeting"):
|
||||
client.fly_targeting.connect(_on_fly_targeting)
|
||||
if client.has_signal("fishing_event"):
|
||||
client.fishing_event.connect(_on_fishing_event)
|
||||
if client.has_signal("pvp_changed"):
|
||||
client.pvp_changed.connect(_on_pvp_changed)
|
||||
|
||||
# 已在局内(重连 / setup 是协程,进来时 spawn burst 已被 pump 抽干)——
|
||||
# 把当前所有实体补建一遍,别漏掉进游戏那一批怪 / NPC / 玩家。
|
||||
func catch_up() -> void:
|
||||
if client == null or not client.has_method("get_entities"):
|
||||
return
|
||||
for e in client.get_entities():
|
||||
var vid := int(e.get("vid", 0))
|
||||
if vid != 0 and not _by_vid.has(vid) and vid != _local_vid:
|
||||
_on_spawn(e)
|
||||
if client.has_method("get_pvp_relations"):
|
||||
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)))
|
||||
|
||||
func set_model_factory(f: Callable) -> void:
|
||||
_model_factory = f
|
||||
|
||||
# 本地玩家用 player_controller 的节点代表,这里就不要再画一个。
|
||||
func set_local_vid(vid: int) -> void:
|
||||
_local_vid = vid
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n:
|
||||
_by_vid.erase(vid)
|
||||
n.queue_free()
|
||||
|
||||
func node_for(vid: int) -> Node3D:
|
||||
return _by_vid.get(vid, null)
|
||||
|
||||
func main_node() -> Node3D:
|
||||
return _by_vid.get(_main_vid, null)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_spawn(d: Dictionary) -> void:
|
||||
var vid := int(d.get("vid", 0))
|
||||
if vid == 0 or _by_vid.has(vid) or vid == _local_vid:
|
||||
return
|
||||
var node: Node3D = null
|
||||
if _model_factory.is_valid():
|
||||
node = _model_factory.call(d)
|
||||
if node == null:
|
||||
node = _make_placeholder(d)
|
||||
elif not node.has_node("Label3D"):
|
||||
_attach_nameplate(node, d) # 真模型也要头顶名字 + HP 条
|
||||
node.name = "e_%d" % vid
|
||||
parent.add_child(node)
|
||||
node.position = _grounded(d.get("pos", Vector3.ZERO))
|
||||
node.set_meta("vid", vid)
|
||||
node.set_meta("func", int(d.get("func", FUNC_WAIT)))
|
||||
_by_vid[vid] = node
|
||||
_refresh_pvp_tag(vid)
|
||||
entity_added.emit(node, vid)
|
||||
if vid == _main_vid:
|
||||
main_entity_ready.emit(node, vid)
|
||||
|
||||
# GC_CHAR_ADD_INFO 到了:刷新头顶名字 / HP 上限(节点已存在)。
|
||||
func _on_info(vid: int, d: Dictionary) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null:
|
||||
return
|
||||
var nm := _display_name(d)
|
||||
if nm != "":
|
||||
if n.has_node("Label3D"):
|
||||
(n.get_node("Label3D") as Label3D).text = nm
|
||||
elif n.has_method("set_display_name"):
|
||||
n.set_display_name(nm)
|
||||
|
||||
func _display_name(d: Dictionary) -> String:
|
||||
if name_resolver.is_valid():
|
||||
var r: String = name_resolver.call(d)
|
||||
if r != "":
|
||||
return r
|
||||
return str(d.get("name", ""))
|
||||
|
||||
func _on_despawn(vid: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n:
|
||||
_by_vid.erase(vid)
|
||||
n.queue_free()
|
||||
entity_removed.emit(vid)
|
||||
|
||||
func _on_main_set(vid: int) -> void:
|
||||
_main_vid = vid
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n:
|
||||
main_entity_ready.emit(n, vid)
|
||||
|
||||
func _on_moved(_vid: int) -> void:
|
||||
pass # 插值在 _process 里统一做
|
||||
|
||||
# GC_PVP: 显示当前与任一角色的挑战 / 战斗 / 复仇关系。原客户端同时会影响
|
||||
# TargetBoard 和名字颜色;这里先提供始终可见的世界标签,避免关系状态无表现。
|
||||
func _on_pvp_changed(src_vid: int, dst_vid: int, mode: int) -> void:
|
||||
if src_vid == 0 or dst_vid == 0:
|
||||
return
|
||||
var key := _pvp_key(src_vid, dst_vid)
|
||||
if mode == 0:
|
||||
_pvp_relations.erase(key)
|
||||
else:
|
||||
_pvp_relations[key] = {"src_vid": src_vid, "dst_vid": dst_vid, "mode": mode}
|
||||
_refresh_pvp_tag(src_vid)
|
||||
_refresh_pvp_tag(dst_vid)
|
||||
|
||||
func _pvp_key(a: int, b: int) -> String:
|
||||
return "%d:%d" % [min(a, b), max(a, b)]
|
||||
|
||||
func _pvp_mode_for(vid: int) -> int:
|
||||
for relation in _pvp_relations.values():
|
||||
if int(relation.get("src_vid", 0)) == vid or int(relation.get("dst_vid", 0)) == vid:
|
||||
return int(relation.get("mode", 0))
|
||||
return 0
|
||||
|
||||
func _refresh_pvp_tag(vid: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null:
|
||||
return
|
||||
var mode := _pvp_mode_for(vid)
|
||||
var tag := n.get_node_or_null("PvpTag") as Label3D
|
||||
if mode == 0:
|
||||
if tag:
|
||||
tag.queue_free()
|
||||
return
|
||||
if tag == null:
|
||||
tag = Label3D.new()
|
||||
tag.name = "PvpTag"
|
||||
tag.position.y = 2.7
|
||||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
tag.no_depth_test = true
|
||||
tag.pixel_size = 0.0045
|
||||
n.add_child(tag)
|
||||
match mode:
|
||||
1:
|
||||
tag.text = "挑战"
|
||||
tag.modulate = Color(1.0, 0.85, 0.25)
|
||||
2:
|
||||
tag.text = "PVP"
|
||||
tag.modulate = Color(1.0, 0.25, 0.25)
|
||||
3:
|
||||
tag.text = "复仇"
|
||||
tag.modulate = Color(1.0, 0.45, 0.1)
|
||||
_:
|
||||
tag.text = "PVP"
|
||||
tag.modulate = Color.WHITE
|
||||
|
||||
# 头顶聊天气泡(~4s 淡出)。本地玩家 vid 走 main_bubble 信号让上层处理。
|
||||
signal main_bubble(text: String)
|
||||
|
||||
func _bubble(vid: int, text: String) -> void:
|
||||
if vid == _local_vid or vid == 0:
|
||||
main_bubble.emit(text)
|
||||
return
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null:
|
||||
return
|
||||
var old := n.get_node_or_null("bubble")
|
||||
if old:
|
||||
old.queue_free()
|
||||
var lbl := Label3D.new()
|
||||
lbl.name = "bubble"
|
||||
lbl.text = text
|
||||
lbl.position.y = 2.35
|
||||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
lbl.no_depth_test = true
|
||||
lbl.pixel_size = 0.0055
|
||||
lbl.modulate = Color(1, 1, 1)
|
||||
lbl.outline_size = 6
|
||||
n.add_child(lbl)
|
||||
var tw := create_tween()
|
||||
tw.tween_interval(3.0)
|
||||
tw.tween_property(lbl, "modulate:a", 0.0, 1.0)
|
||||
tw.tween_callback(lbl.queue_free)
|
||||
|
||||
func _on_vitals(vid: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty():
|
||||
return
|
||||
var hp := int(e.get("hp", 0))
|
||||
var max_hp := int(e.get("max_hp", 0))
|
||||
var dead := bool(e.get("dead", false))
|
||||
vitals.emit(vid, hp, max_hp, dead)
|
||||
if n and n.has_node("HpBar") and max_hp > 0:
|
||||
var bar := n.get_node("HpBar") as Sprite3D
|
||||
bar.scale.x = clampf(float(hp) / float(max_hp), 0.0, 1.0)
|
||||
bar.modulate = Color(0.9, 0.2, 0.2) if not dead else Color(0.3, 0.3, 0.3)
|
||||
|
||||
func _on_dead(vid: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null:
|
||||
return
|
||||
# 倒地 + 变灰(服务端随后会发 GC_CHARACTER_DEL 再真正移除)
|
||||
var t := create_tween()
|
||||
t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3)
|
||||
if n.has_method("set_anim_state"):
|
||||
n.call("set_anim_state", "dead")
|
||||
|
||||
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体。原客户端在
|
||||
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target VID=0 时是坐标),所以
|
||||
# 这里也消费 shooter 的目标队列,避免远端技能没有终点而被静默丢弃。
|
||||
func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
|
||||
var a: Vector3 = _entity_pos(start_vid)
|
||||
var b: Vector3 = _entity_pos(end_vid)
|
||||
var shooter: Node3D = _by_vid.get(start_vid, null)
|
||||
if b == Vector3.INF and shooter:
|
||||
var target: Dictionary = {}
|
||||
var queued: Variant = shooter.get_meta("fly_target_queue", [])
|
||||
if queued is Array and not queued.is_empty():
|
||||
target = queued.pop_front()
|
||||
shooter.set_meta("fly_target_queue", queued)
|
||||
var target_vid := int(target.get("vid", 0))
|
||||
if target_vid != 0:
|
||||
b = _entity_pos(target_vid)
|
||||
else:
|
||||
var target_cm: Variant = target.get("cm", null)
|
||||
if target_cm is Vector2:
|
||||
b = _server_cm_to_world(target_cm)
|
||||
if a == Vector3.INF or b == Vector3.INF or parent == null:
|
||||
return
|
||||
var proj := MeshInstance3D.new()
|
||||
var sm := SphereMesh.new()
|
||||
sm.radius = 0.08
|
||||
sm.height = 0.16
|
||||
proj.mesh = sm
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(1.0, 0.9, 0.4)
|
||||
mat.emission_enabled = true
|
||||
mat.emission = Color(0.9, 0.7, 0.2)
|
||||
proj.material_override = mat
|
||||
parent.add_child(proj)
|
||||
proj.global_position = a + Vector3(0, 1.0, 0)
|
||||
var dur := maxf(0.15, a.distance_to(b) / 40.0)
|
||||
var tw := create_tween()
|
||||
tw.tween_property(proj, "global_position", b + Vector3(0, 1.0, 0), dur)
|
||||
tw.tween_callback(proj.queue_free)
|
||||
|
||||
# GC_FLY_TARGETING / GC_ADD_FLY_TARGETING — retain the authoritative target on
|
||||
# the shooter node so a later projectile cue can use it even before a target
|
||||
# entity exists (target VID 0 carries an explicit coordinate).
|
||||
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void:
|
||||
var shooter: Node3D = _by_vid.get(shooter_vid, null)
|
||||
if shooter:
|
||||
var queued: Array = []
|
||||
var previous: Variant = shooter.get_meta("fly_target_queue", [])
|
||||
if append and previous is Array:
|
||||
queued = previous
|
||||
queued.append({"vid": target_vid, "cm": target_cm})
|
||||
shooter.set_meta("fly_target_queue", queued)
|
||||
shooter.set_meta("fly_target_vid", target_vid)
|
||||
shooter.set_meta("fly_target_cm", target_cm)
|
||||
shooter.set_meta("fly_target_append", append)
|
||||
fly_targeting.emit(shooter_vid, target_vid, target_cm, append)
|
||||
|
||||
func _server_cm_to_world(cm: Vector2) -> Vector3:
|
||||
var p := MapCoord.to_world(Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01))
|
||||
if world and world.has_method("sample_height"):
|
||||
p.y = float(world.call("sample_height", p.x, p.z)) + 0.6
|
||||
else:
|
||||
p.y = 0.6
|
||||
return p
|
||||
|
||||
# 实体世界坐标(Godot 米);找不到返回 Vector3.INF
|
||||
func _entity_pos(vid: int) -> Vector3:
|
||||
var e: Dictionary = client.get_entity(vid) if client else {}
|
||||
if not e.is_empty() and e.get("pos", null) is Vector3:
|
||||
return MapCoord.to_world(e["pos"])
|
||||
var n = _by_vid.get(vid, null)
|
||||
if is_instance_valid(n):
|
||||
return (n as Node3D).global_position
|
||||
return Vector3.INF
|
||||
|
||||
func _on_dig_motion(vid: int, target_vid: int, count: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null:
|
||||
return
|
||||
n.set_meta("dig_count", count)
|
||||
n.set_meta("dig_target_vid", target_vid)
|
||||
var target: Node3D = _by_vid.get(target_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)
|
||||
if n.has_method("set_anim_state"):
|
||||
n.call("set_anim_state", "dig")
|
||||
|
||||
# GC_FISHING: `info` is the fisher VID for motion subheaders. Keep the
|
||||
# authoritative direction and state on the entity; real PlayerView/MobView
|
||||
# assets may provide matching fishing animations, while placeholders still
|
||||
# expose the event to gameplay/UI code.
|
||||
func _on_fishing_event(subheader: int, info: int, dir: int) -> void:
|
||||
# FISH carries an item vnum in `info`, not a fisher VID; leave it to the
|
||||
# fishing UI / inventory event consumer.
|
||||
if info == 0 or subheader == 5:
|
||||
return
|
||||
var n: Node3D = _by_vid.get(info, null)
|
||||
if n == null:
|
||||
return
|
||||
n.set_meta("fishing_subheader", subheader)
|
||||
n.set_meta("fishing_dir", dir)
|
||||
var state := "fishing"
|
||||
match subheader:
|
||||
1: state = "wait" # STOP
|
||||
2: state = "fishing_react" # REACT
|
||||
3: state = "fishing_catch" # SUCCESS
|
||||
4: state = "fishing_fail" # FAIL
|
||||
if n.has_method("set_anim_state"):
|
||||
n.call("set_anim_state", state)
|
||||
fishing_event.emit(info, subheader, dir)
|
||||
|
||||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
var crit := (flag & DAMAGE_CRITICAL) != 0
|
||||
var dodge := (flag & DAMAGE_DODGE) != 0
|
||||
damage_number.emit(vid, amount, crit, dodge)
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null or parent == null:
|
||||
return
|
||||
var lbl := Label3D.new()
|
||||
lbl.text = "MISS" if dodge else str(amount)
|
||||
lbl.modulate = Color(1.0, 0.85, 0.2) if crit else Color(1, 1, 1)
|
||||
lbl.font_size = 48 if crit else 32
|
||||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
lbl.no_depth_test = true
|
||||
lbl.pixel_size = 0.007
|
||||
parent.add_child(lbl)
|
||||
lbl.global_position = n.global_position + Vector3(randf_range(-0.3, 0.3), 1.9, 0)
|
||||
var tw := create_tween()
|
||||
tw.set_parallel(true)
|
||||
tw.tween_property(lbl, "global_position:y", lbl.global_position.y + 1.2, 0.8)
|
||||
tw.tween_property(lbl, "modulate:a", 0.0, 0.8).set_delay(0.3)
|
||||
tw.chain().tween_callback(lbl.queue_free)
|
||||
|
||||
func _process(dt: float) -> void:
|
||||
if client == null or parent == null:
|
||||
return
|
||||
for vid in _by_vid.keys():
|
||||
var n: Node3D = _by_vid[vid]
|
||||
if not is_instance_valid(n):
|
||||
_by_vid.erase(vid)
|
||||
continue
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty():
|
||||
continue
|
||||
var want := _grounded(e.get("pos", n.position))
|
||||
if n.position.distance_to(want) > snap_dist:
|
||||
n.position = want
|
||||
else:
|
||||
n.position = n.position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0))
|
||||
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
|
||||
var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0)))
|
||||
n.rotation.y = lerp_angle(n.rotation.y, yaw, clampf(10.0 * dt, 0.0, 1.0))
|
||||
# 动画状态
|
||||
var f := int(e.get("func", FUNC_WAIT))
|
||||
var moving := bool(e.get("moving", false))
|
||||
var walk_mode := int(e.get("walk_mode", 1))
|
||||
if f != int(n.get_meta("func", -1)) or moving != bool(n.get_meta("moving", false)) \
|
||||
or walk_mode != int(n.get_meta("walk_mode", -1)):
|
||||
n.set_meta("func", f)
|
||||
n.set_meta("moving", moving)
|
||||
n.set_meta("walk_mode", walk_mode)
|
||||
_apply_anim(n, f, moving, walk_mode)
|
||||
|
||||
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void:
|
||||
var state := "wait"
|
||||
if f == FUNC_MOVE or (f == FUNC_WAIT and moving):
|
||||
state = "walk" if walk_mode == 0 else "run"
|
||||
elif f == FUNC_ATTACK or f == FUNC_COMBO:
|
||||
state = "attack"
|
||||
elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0:
|
||||
state = "skill"
|
||||
elif f == FUNC_EMOTION:
|
||||
state = "emotion"
|
||||
if n.has_method("set_anim_state"):
|
||||
n.call("set_anim_state", state)
|
||||
elif n.has_node("Label3D"):
|
||||
(n.get_node("Label3D") as Label3D).modulate = _state_tint(state)
|
||||
|
||||
func _state_tint(s: String) -> Color:
|
||||
match s:
|
||||
"walk": return Color(0.7, 0.9, 1.0)
|
||||
"run": return Color(0.6, 1.0, 0.6)
|
||||
"attack": return Color(1.0, 0.5, 0.4)
|
||||
"skill": return Color(1.0, 0.8, 0.3)
|
||||
_: return Color(1, 1, 1)
|
||||
|
||||
func _grounded(p: Variant) -> Vector3:
|
||||
# p 是 M2Client 的网络帧 pos —— 先转到 Metin2World 本地帧,再贴地。
|
||||
var v: Vector3 = MapCoord.to_world(p) if p is Vector3 else Vector3.ZERO
|
||||
if world and world.has_method("sample_height"):
|
||||
v.y = float(world.call("sample_height", v.x, v.z))
|
||||
return v
|
||||
|
||||
func _make_placeholder(d: Dictionary) -> Node3D:
|
||||
var root := Node3D.new()
|
||||
var mesh := MeshInstance3D.new()
|
||||
var cap := CapsuleMesh.new()
|
||||
cap.radius = 0.35
|
||||
cap.height = 1.8
|
||||
mesh.mesh = cap
|
||||
mesh.position.y = 0.9
|
||||
var mat := StandardMaterial3D.new()
|
||||
var is_main := bool(d.get("is_main", false))
|
||||
mat.albedo_color = Color(0.35, 0.6, 1.0) if is_main else Color(0.85, 0.4, 0.35)
|
||||
mesh.material_override = mat
|
||||
root.add_child(mesh)
|
||||
_attach_nameplate(root, d)
|
||||
return root
|
||||
|
||||
# 头顶名字 Label3D + HP 条 Sprite3D(占位胶囊和真模型共用)。
|
||||
func _attach_nameplate(root: Node3D, d: Dictionary) -> void:
|
||||
var tag := Label3D.new()
|
||||
tag.name = "Label3D"
|
||||
var nm := _display_name(d)
|
||||
tag.text = nm if nm != "" else "vid %d" % int(d.get("vid", 0))
|
||||
tag.position.y = 2.1
|
||||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
tag.no_depth_test = true
|
||||
tag.pixel_size = 0.006
|
||||
root.add_child(tag)
|
||||
var bar := Sprite3D.new()
|
||||
bar.name = "HpBar"
|
||||
bar.texture = _white_1px()
|
||||
bar.pixel_size = 0.01 # 100px -> 1m
|
||||
bar.position.y = 1.95
|
||||
bar.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
bar.no_depth_test = true
|
||||
bar.modulate = Color(0.9, 0.2, 0.2)
|
||||
bar.scale.x = 1.0
|
||||
root.add_child(bar)
|
||||
|
||||
static var _white_tex: Texture2D
|
||||
static func _white_1px() -> Texture2D:
|
||||
if _white_tex == null:
|
||||
var img := Image.create(100, 6, false, Image.FORMAT_RGBA8)
|
||||
img.fill(Color.WHITE)
|
||||
_white_tex = ImageTexture.create_from_image(img)
|
||||
return _white_tex
|
||||
@@ -0,0 +1 @@
|
||||
uid://mxr8pfgb5ysp
|
||||
@@ -0,0 +1,220 @@
|
||||
# netbridge_test —— M2Client 游戏内 API + NetWorld 场景桥的 headless 自检。
|
||||
# godot --headless --path project --script netbridge_test.gd
|
||||
# 无需服务器:用假 client 驱动 NetWorld;M2Client 部分只查绑定面 + 未入局时拒绝。
|
||||
# 退出码 0 = 全过。
|
||||
extends SceneTree
|
||||
|
||||
const NetWorld = preload("res://net_world.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal entity_spawned(entity: Dictionary)
|
||||
signal entity_despawned(vid: int)
|
||||
signal entity_moved(vid: int)
|
||||
signal entity_main_set(vid: int)
|
||||
signal entity_info(vid: int, entity: Dictionary)
|
||||
signal chat(type: int, vid: int, text: String)
|
||||
signal fly_cue(type: int, start_vid: int, end_vid: int)
|
||||
signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool)
|
||||
signal fishing_event(subheader: int, info: int, dir: int)
|
||||
signal vitals_changed(vid: int)
|
||||
signal entity_dead(vid: int)
|
||||
signal damage(vid: int, amount: int, flag: int)
|
||||
signal pvp_changed(src_vid: int, dst_vid: int, mode: int)
|
||||
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 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}
|
||||
if is_main: entity_main_set.emit(vid)
|
||||
entity_spawned.emit(ents[vid])
|
||||
func set_info(vid, nm):
|
||||
ents[vid]["name"] = nm
|
||||
entity_info.emit(vid, ents[vid])
|
||||
func move_to(vid, pos, f := 1):
|
||||
ents[vid].merge({"pos": pos, "func": f, "moving": true}, true)
|
||||
entity_moved.emit(vid)
|
||||
func hurt(vid, hp, amount, flag := 1):
|
||||
ents[vid]["hp"] = hp
|
||||
ents[vid]["dead"] = hp <= 0
|
||||
damage.emit(vid, amount, flag)
|
||||
vitals_changed.emit(vid)
|
||||
if hp <= 0: entity_dead.emit(vid)
|
||||
func despawn(vid):
|
||||
ents.erase(vid); entity_despawned.emit(vid)
|
||||
func set_fly_target(shooter_vid, target_vid, target_cm, append := false):
|
||||
fly_targeting.emit(shooter_vid, target_vid, target_cm, append)
|
||||
|
||||
var _fail := 0
|
||||
func _ck(cond: bool, msg: String) -> void:
|
||||
if not cond:
|
||||
_fail += 1
|
||||
push_error("FAIL: " + msg)
|
||||
printerr("FAIL: " + msg)
|
||||
|
||||
func _init() -> void:
|
||||
_test_m2client_surface()
|
||||
await _test_netbridge()
|
||||
if _fail == 0:
|
||||
print("PASS: netbridge_test (M2Client API + NetWorld bridge)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _test_m2client_surface() -> void:
|
||||
if not ClassDB.class_exists("M2Client"):
|
||||
_ck(false, "M2Client not registered (extension built?)")
|
||||
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"]:
|
||||
_ck(c.has_method(m), "M2Client.%s bound" % m)
|
||||
for s in ["entity_spawned", "entity_despawned", "entity_moved", "entity_main_set",
|
||||
"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"]:
|
||||
_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")
|
||||
_ck(c.call("character_position", 3) == false, "character_position refused pre-game")
|
||||
_ck(c.call("sync_positions", [{"vid": 1, "x": 10, "y": 20}]) == false, "sync_positions refused pre-game")
|
||||
_ck(c.call("request_warp") == false, "request_warp refused pre-game")
|
||||
_ck(c.call("fishing", 90.0) == false, "fishing refused pre-game")
|
||||
_ck(c.call("request_dungeon") == false, "request_dungeon refused pre-game")
|
||||
_ck(c.call("shoot", 1) == false, "shoot refused pre-game")
|
||||
_ck(c.call("script_select_item", 1) == false, "script_select_item refused pre-game")
|
||||
_ck(c.call("quest_cancel") == false, "quest_cancel refused pre-game")
|
||||
_ck(c.call("party_use_skill", 1, 1) == false, "party_use_skill refused pre-game")
|
||||
_ck(c.call("change_name", 0, "Renamed") == false, "change_name refused before connection")
|
||||
_ck(c.call("get_skill_group") == 0, "skill group defaults to 0 pre-game")
|
||||
_ck(c.call("attack", 0, 1) == false, "attack refused pre-game")
|
||||
_ck(c.call("get_entities").is_empty(), "no entities pre-game")
|
||||
_ck(c.call("get_main_vid") == 0, "no main vid pre-game")
|
||||
_ck(c.call("get_points").is_empty(), "no points pre-game")
|
||||
_ck(c.call("get_target").is_empty(), "no target pre-game")
|
||||
_ck(c.call("get_inventory").is_empty(), "no inventory pre-game")
|
||||
_ck(c.call("move_item", 1, 0, 2, 1, 1) == false, "move_item refused pre-game")
|
||||
c.queue_free()
|
||||
_test_proto()
|
||||
|
||||
func _test_proto() -> void:
|
||||
_ck(ClassDB.class_exists("Metin2Proto"), "Metin2Proto registered")
|
||||
if not ClassDB.class_exists("Metin2Proto"):
|
||||
return
|
||||
var p: Object = ClassDB.instantiate("Metin2Proto")
|
||||
get_root().add_child(p)
|
||||
var root := AssetRoot.sub("locale/locale/en")
|
||||
if not FileAccess.file_exists(root + "/item_proto"):
|
||||
print(" (skip Metin2Proto load: no item_proto)")
|
||||
p.queue_free()
|
||||
return
|
||||
_ck(p.call("load_item_proto", root + "/item_proto"), "load_item_proto: %s" % p.call("get_last_error"))
|
||||
_ck(p.call("item_count") > 1000, "item_count > 1000 (%d)" % p.call("item_count"))
|
||||
var yang: Dictionary = p.call("item", 1)
|
||||
_ck(yang.get("name", "") == "Yang", "item(1).name == Yang")
|
||||
_ck(p.call("item", 999999999).is_empty(), "unknown vnum -> {}")
|
||||
p.queue_free()
|
||||
|
||||
func _test_netbridge() -> void:
|
||||
var mount := Node3D.new()
|
||||
get_root().add_child(mount)
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
var nw: Node = NetWorld.new()
|
||||
get_root().add_child(nw)
|
||||
nw.setup(fc, mount)
|
||||
var added := [0]
|
||||
var removed := [0]
|
||||
var main_node := [null]
|
||||
nw.entity_added.connect(func(_n, _v): added[0] += 1)
|
||||
nw.entity_removed.connect(func(_v): removed[0] += 1)
|
||||
nw.main_entity_ready.connect(func(n, _v): main_node[0] = n)
|
||||
|
||||
fc.spawn(1000, "Me", Vector3.ZERO, true)
|
||||
fc.spawn(2000, "", Vector3(10, 0, 0)) # 名字晚点走 entity_info
|
||||
await process_frame
|
||||
_ck(added[0] == 2, "2 entity nodes added")
|
||||
_ck(mount.get_child_count() == 2, "2 children under mount")
|
||||
_ck(main_node[0] != null and int(main_node[0].get_meta("vid")) == 1000, "main entity surfaced")
|
||||
fc.set_fly_target(2000, 1000, Vector2(120, 340))
|
||||
await process_frame
|
||||
_ck(int(nw.node_for(2000).get_meta("fly_target_vid", 0)) == 1000,
|
||||
"fly_targeting -> shooter target metadata")
|
||||
var projectile_children := mount.get_child_count()
|
||||
fc.fly_cue.emit(1, 2000, 0) # coordinate/queued target fallback
|
||||
_ck(mount.get_child_count() == projectile_children + 1,
|
||||
"create_fly -> consumes queued target when end VID is absent")
|
||||
fc.fishing_event.emit(0, 2000, 18)
|
||||
await process_frame
|
||||
_ck(int(nw.node_for(2000).get_meta("fishing_subheader", -1)) == 0
|
||||
and int(nw.node_for(2000).get_meta("fishing_dir", -1)) == 18,
|
||||
"fishing start -> entity fishing metadata")
|
||||
fc.fishing_event.emit(5, 30001, 0) # item vnum, not a fisher VID
|
||||
await process_frame
|
||||
_ck(not nw.node_for(2000).has_meta("fishing_item_vnum"),
|
||||
"fishing fish outcome is not treated as entity motion")
|
||||
|
||||
# GC_CHAR_ADD_INFO late name -> 3D label refreshes (name_resolver optional)
|
||||
var mob0: Node3D = nw.node_for(2000)
|
||||
_ck(String((mob0.get_node("Label3D") as Label3D).text).begins_with("vid "),
|
||||
"placeholder label starts as 'vid N' (no name yet)")
|
||||
nw.name_resolver = func(d): return "Angry " + String(d.get("name", ""))
|
||||
fc.set_info(2000, "Mob")
|
||||
await process_frame
|
||||
_ck((mob0.get_node("Label3D") as Label3D).text == "Angry Mob",
|
||||
"entity_info -> label refreshed via name_resolver")
|
||||
fc.pvp_changed.emit(1000, 2000, 2)
|
||||
await process_frame
|
||||
var pvp_tag := mob0.get_node_or_null("PvpTag") as Label3D
|
||||
_ck(pvp_tag != null and pvp_tag.text == "PVP", "pvp fight -> world PVP tag")
|
||||
fc.pvp_changed.emit(1000, 2000, 0)
|
||||
await process_frame
|
||||
_ck(mob0.get_node_or_null("PvpTag") == null, "pvp none -> removes world tag")
|
||||
|
||||
fc.move_to(2000, Vector3(10, 0, 40))
|
||||
for i in 3: await process_frame
|
||||
var mob: Node3D = nw.node_for(2000)
|
||||
_ck(mob.position.distance_to(Vector3(10, 0, -40)) < 0.5, "far move -> snap") # to_world 翻 Z
|
||||
_ck(int(mob.get_meta("func")) == 1, "func -> FUNC_MOVE")
|
||||
|
||||
fc.move_to(2000, Vector3(11, 0, 41))
|
||||
await process_frame
|
||||
var d1 := mob.position.distance_to(Vector3(11, 0, -41))
|
||||
for i in 20: await process_frame
|
||||
_ck(mob.position.distance_to(Vector3(11, 0, -41)) < d1, "near move -> lerp closes in")
|
||||
|
||||
var got_chat := [""]
|
||||
nw.chat_line.connect(func(_t, _v, s): got_chat[0] = s)
|
||||
fc.chat.emit(1, 2000, "hi there")
|
||||
await process_frame
|
||||
_ck(got_chat[0] == "hi there", "chat line relayed")
|
||||
|
||||
# combat: damage number + vitals + death
|
||||
var dmg := [0, false]
|
||||
var vit := [0, 0, false]
|
||||
nw.damage_number.connect(func(_v, amt, crit, _dodge): dmg[0] = amt; dmg[1] = crit)
|
||||
nw.vitals.connect(func(_v, hp, mhp, dead): vit[0] = hp; vit[1] = mhp; vit[2] = dead)
|
||||
fc.hurt(2000, 60, 40)
|
||||
await process_frame
|
||||
_ck(dmg[0] == 40, "damage_number relayed (40)")
|
||||
_ck(vit[0] == 60 and vit[1] == 100 and not vit[2], "vitals relayed (60/100)")
|
||||
var bar := mob.get_node_or_null("HpBar")
|
||||
_ck(bar != null and absf(bar.scale.x - 0.6) < 0.01, "HP bar scaled to 0.6")
|
||||
fc.hurt(2000, 0, 60, 1 << 5) # crit, lethal
|
||||
await process_frame
|
||||
_ck(dmg[1] == true, "crit flag relayed")
|
||||
_ck(vit[2] == true, "vitals dead=true")
|
||||
|
||||
fc.despawn(2000)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_ck(removed[0] == 1 and nw.node_for(2000) == null, "despawn frees node")
|
||||
@@ -0,0 +1 @@
|
||||
uid://d4kjxl5d6lo6m
|
||||
@@ -0,0 +1,152 @@
|
||||
# netplay_test —— P0 闭环胶水(net_play.gd)的 headless 自检。
|
||||
# godot --headless --path project --script netplay_test.gd
|
||||
# 用假 M2Client / 假 PlayerController / 假 HUD 驱动,断言 net_play 产生的调用正确。
|
||||
# 退出码 0 = 全过。
|
||||
extends SceneTree
|
||||
|
||||
const NetPlay = preload("res://net_play.gd")
|
||||
const NetWorld = preload("res://net_world.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal entity_spawned(entity: Dictionary)
|
||||
signal entity_despawned(vid: int)
|
||||
signal entity_moved(vid: int)
|
||||
signal entity_info(vid: int, entity: Dictionary)
|
||||
signal entity_main_set(vid: int)
|
||||
signal entity_dead(vid: int)
|
||||
signal vitals_changed(vid: int)
|
||||
signal points_changed(points: Dictionary)
|
||||
signal target_info(vid: int, hp_percent: int)
|
||||
signal chat(type: int, vid: int, text: String)
|
||||
var ents := {}
|
||||
var main := 0
|
||||
var in_game := true
|
||||
var calls := {"move": [], "attack": [], "set_target": []}
|
||||
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
|
||||
|
||||
class FakePC extends Node:
|
||||
signal target_selected(node: Node3D)
|
||||
signal moved(pos: Vector3)
|
||||
signal anim_state(state: String)
|
||||
var player: Node3D
|
||||
var server_speed := 1.0
|
||||
func set_server_speed(moving_speed: int) -> void: server_speed = float(moving_speed) / 100.0
|
||||
|
||||
class FakeHud extends Node:
|
||||
var vitals := []
|
||||
var exp_v := []
|
||||
var level := 0
|
||||
var target := []
|
||||
var energy := []
|
||||
var cleared := 0
|
||||
func set_vitals(hp, mhp, sp, msp): vitals = [hp, mhp, sp, msp]
|
||||
func set_exp(xp, nxp): exp_v = [xp, nxp]
|
||||
func set_level(lv): level = lv
|
||||
func set_energy(value, maximum): energy = [value, maximum]
|
||||
func set_target(nm, pct): target = [nm, pct]
|
||||
func clear_target(): cleared += 1
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: netplay_test (P0 move/attack/target/HUD loop)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var root := get_root()
|
||||
var fc := FakeClient.new()
|
||||
var pc := FakePC.new()
|
||||
var hud := FakeHud.new()
|
||||
var nw: Node = NetWorld.new()
|
||||
pc.player = Node3D.new()
|
||||
root.add_child(fc); root.add_child(pc); root.add_child(hud); root.add_child(nw)
|
||||
root.add_child(pc.player)
|
||||
var np: Node = NetPlay.new()
|
||||
root.add_child(np)
|
||||
np.setup(fc, pc, nw, hud)
|
||||
|
||||
fc.main = 1000
|
||||
fc.ents[1000] = {"vid": 1000, "pos": Vector3.ZERO, "hp": 200, "max_hp": 200,
|
||||
"moving_speed": 150, "attack_speed": 150}
|
||||
fc.entity_main_set.emit(1000)
|
||||
fc.entity_info.emit(1000, fc.ents[1000])
|
||||
_ck(is_equal_approx(pc.server_speed, 1.5), "GC_CHANGE_SPEED -> local prediction speed scale")
|
||||
_ck(is_equal_approx(np.attack_period, 0.4), "entity attack_speed -> attack cadence")
|
||||
|
||||
# 1) 点地移动 -> move(FUNC_MOVE) 带 server cm + heading
|
||||
pc.player.position = Vector3(3, 0, -5) # to_server_cm -> (300, -500)
|
||||
pc.player.rotation.y = 0.0
|
||||
pc.moved.emit(pc.player.position)
|
||||
_ck(fc.calls.move.size() == 1, "one move sent")
|
||||
if fc.calls.move.size() == 1:
|
||||
var mv: Array = fc.calls.move[0]
|
||||
_ck(mv[0] == 1, "func == FUNC_MOVE")
|
||||
_ck(mv[3] == 300 and mv[4] == -500, "server cm (300,-500), got (%d,%d)" % [mv[3], mv[4]])
|
||||
_ck(mv[2] >= 0 and mv[2] < 360, "heading in [0,360)")
|
||||
|
||||
# 2) 节流:紧接着的小位移不发
|
||||
pc.player.position = Vector3(3.3, 0, -5)
|
||||
pc.moved.emit(pc.player.position)
|
||||
_ck(fc.calls.move.size() == 1, "small quick move throttled")
|
||||
|
||||
# 3) 停下 -> move(FUNC_WAIT) 一次
|
||||
pc.anim_state.emit("wait")
|
||||
_ck(fc.calls.move.size() == 2 and fc.calls.move[1][0] == 0, "stop -> FUNC_WAIT sent")
|
||||
pc.anim_state.emit("wait")
|
||||
_ck(fc.calls.move.size() == 2, "second wait not re-sent")
|
||||
|
||||
# 4) 点实体 -> set_target
|
||||
var mob := Node3D.new()
|
||||
mob.set_meta("vid", 2000)
|
||||
root.add_child(mob)
|
||||
mob.position = Vector3(4, 0, -5) # 距玩家(3.3,0,-5) 0.7m -> 攻击距离内
|
||||
fc.ents[2000] = {"vid": 2000, "name": "Wolf", "pos": mob.position, "hp": 50, "max_hp": 50, "dead": false}
|
||||
nw._by_vid[2000] = mob # 让 net_world.node_for(2000) 命中
|
||||
pc.target_selected.emit(mob)
|
||||
_ck(fc.calls.set_target.size() == 1 and fc.calls.set_target[0] == 2000, "set_target(2000)")
|
||||
|
||||
# 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())
|
||||
if fc.calls.attack.size() >= 1:
|
||||
_ck(fc.calls.attack[0][1] == 2000, "attack victim == 2000")
|
||||
var n1: int = fc.calls.attack.size()
|
||||
await process_frame
|
||||
_ck(fc.calls.attack.size() == n1, "attack throttled within period")
|
||||
|
||||
# 6) points_changed -> HUD
|
||||
fc.points_changed.emit({"hp": 120, "max_hp": 200, "sp": 30, "max_sp": 80,
|
||||
"exp": 50, "next_exp": 100, "level": 7, "energy": 75})
|
||||
_ck(hud.vitals == [120, 200, 30, 80], "hud.set_vitals from points")
|
||||
_ck(hud.exp_v == [50, 100], "hud.set_exp")
|
||||
_ck(hud.level == 7, "hud.set_level")
|
||||
_ck(hud.energy == [75, 100], "hud.set_energy from POINT_ENERGY")
|
||||
|
||||
# 7) 自己的 GC_MOVE 位置校正(远 -> 瞬移)
|
||||
fc.ents[1000]["pos"] = Vector3(50, 0, -5)
|
||||
fc.entity_moved.emit(1000)
|
||||
_ck(pc.player.position.distance_to(Vector3(50, 0, pc.player.position.z)) < 0.01,
|
||||
"far server pos -> snap, got %s" % pc.player.position)
|
||||
|
||||
# 8) target_info -> hud.set_target
|
||||
fc.target_info.emit(2000, 40)
|
||||
_ck(hud.target.size() == 2 and hud.target[1] == 40, "hud.set_target(pct=40)")
|
||||
|
||||
# 9) 目标死亡 -> 清目标
|
||||
fc.ents[2000]["dead"] = true
|
||||
fc.entity_dead.emit(2000)
|
||||
_ck(hud.cleared >= 1, "target cleared on death")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bsjyki4suspqp
|
||||
@@ -0,0 +1,126 @@
|
||||
# p10_test —— P10:serverinfo + loading 遮罩 + 断线重连 + 串场状态机 headless 自检。
|
||||
# godot --headless --path project --script p10_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const ServerInfoRes = preload("res://net/serverinfo.gd")
|
||||
const LoadingScreen = preload("res://ui/loading_screen.gd")
|
||||
const ReconnectUI = preload("res://ui/reconnect_ui.gd")
|
||||
const AppFlow = preload("res://app_flow.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal phase_changed(phase: String)
|
||||
signal char_list(characters: Array)
|
||||
signal entered_game()
|
||||
signal disconnected(reason: String)
|
||||
signal login_failed(reason: String)
|
||||
var reconnects := 0
|
||||
var connects := []
|
||||
var selects := []
|
||||
func connect_to_server(ah, ap, gh, gp, id, pw) -> void:
|
||||
connects.append([ah, ap, gh, gp, id, pw])
|
||||
func select_character(i) -> bool: selects.append(i); return true
|
||||
func reconnect() -> bool: reconnects += 1; return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: p10_test (serverinfo + loading + reconnect + app flow)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
# --- serverinfo ---
|
||||
var si: RefCounted = ServerInfoRes.new()
|
||||
_ck(si.count() == 1 and si.server(0)["auth_host"] == "192.168.21.203", "serverinfo: built-in default")
|
||||
var addr: Dictionary = si.address(0, 3)
|
||||
_ck(addr["game_port"] == 11013, "serverinfo: 内置 CH3 -> 11013 (step 1)")
|
||||
var tmp := "user://p10_servers.txt"
|
||||
var f := FileAccess.open(tmp, FileAccess.WRITE)
|
||||
f.store_line("# test")
|
||||
f.store_line("欧服\teu.example.com\t13000\teu.example.com\t13011\t1,2\t10")
|
||||
f.close()
|
||||
_ck(si.load_file(tmp) and si.count() == 1 and si.server(0)["name"] == "欧服", "serverinfo: parse file")
|
||||
_ck(si.address(0, 2)["game_port"] == 13021, "serverinfo: file port_step 10 覆盖 -> CH2 = 13021")
|
||||
|
||||
# --- loading screen ---
|
||||
var ls: Node = LoadingScreen.new()
|
||||
get_root().add_child(ls)
|
||||
ls.setup(null)
|
||||
_ck(not ls.is_showing(), "loading: hidden initially")
|
||||
ls._on_phase("loading")
|
||||
_ck(ls.is_showing() and ls._label.text.contains("地图"), "loading: shows on phase=loading")
|
||||
ls.set_progress(0.5)
|
||||
_ck(ls._bar.visible and ls._bar.value == 50.0, "loading: progress bar")
|
||||
ls._on_phase("select")
|
||||
_ck(not ls.is_showing(), "loading: hides on phase=select (选人界面要露出来)")
|
||||
ls._on_phase("loading")
|
||||
ls._on_phase("game")
|
||||
_ck(not ls.is_showing(), "loading: hides on phase=game")
|
||||
ls._on_phase("login")
|
||||
_ck(not ls.is_showing(), "loading: hides on phase=login")
|
||||
|
||||
# --- reconnect ---
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
var rc: Node = ReconnectUI.new()
|
||||
get_root().add_child(rc)
|
||||
rc.setup(fc)
|
||||
_ck(not rc.is_showing(), "reconnect: hidden initially")
|
||||
fc.disconnected.emit("peer closed")
|
||||
_ck(rc.is_showing() and rc._msg.text.contains("peer closed"), "reconnect: shows on disconnect")
|
||||
# 倒计时耗尽 -> reconnect()
|
||||
rc._left = 0.05
|
||||
rc._process(0.1)
|
||||
_ck(fc.reconnects == 1, "reconnect: auto-retry fired")
|
||||
# 手动
|
||||
rc._retry_now()
|
||||
_ck(fc.reconnects == 2, "reconnect: manual retry")
|
||||
# entered_game -> 收起
|
||||
fc.entered_game.emit()
|
||||
_ck(not rc.is_showing(), "reconnect: dismissed on entered_game")
|
||||
|
||||
# --- app flow 状态机 ---
|
||||
var fc2 := FakeClient.new()
|
||||
var af: Node = AppFlow.new()
|
||||
af.build_game_scene = false
|
||||
get_root().add_child(af)
|
||||
af.start("", fc2)
|
||||
_ck(af.state() == AppFlow.LOGIN, "flow: starts at LOGIN")
|
||||
# 检测频道按钮存在 + 探测不可达端口快速返回 false
|
||||
var has_probe := false
|
||||
for b in af._ui.find_children("*", "Button", true, false):
|
||||
if String(b.text) == "检测频道":
|
||||
has_probe = true
|
||||
_ck(has_probe, "flow: 检测频道 button present")
|
||||
var reachable: bool = await af._probe_tcp("127.0.0.1", 1, 0.3)
|
||||
_ck(reachable == false, "flow: _probe_tcp unreachable -> false")
|
||||
af._do_connect()
|
||||
_ck(fc2.connects.size() == 1 and fc2.connects[0][3] == 11011, "flow: connect uses serverinfo addr")
|
||||
if af._loading and af._loading.has_method("show_for"):
|
||||
af._loading.show_for("载入角色…") # 模拟 phase=select 时遮罩已亮
|
||||
fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}])
|
||||
_ck(af.state() == AppFlow.SELECT, "flow: char_list -> SELECT")
|
||||
_ck(af._loading == null or not af._loading.is_showing(),
|
||||
"flow: _goto_select 收起读取遮罩(选人界面不被盖)")
|
||||
# 新版选人页:默认已选 slot 0,点“开始”进游戏
|
||||
var picked := false
|
||||
for b in af._ui.find_children("*", "Button", true, false):
|
||||
if String(b.text) == "开始":
|
||||
b.pressed.emit()
|
||||
picked = true
|
||||
_ck(picked and fc2.selects == [0], "flow: pick char -> select_character(0)")
|
||||
fc2.entered_game.emit()
|
||||
_ck(af.state() == AppFlow.GAME, "flow: entered_game -> GAME")
|
||||
# 在 SELECT 时断线 -> 回 LOGIN
|
||||
fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}])
|
||||
_ck(af.state() == AppFlow.SELECT, "flow: back to SELECT")
|
||||
fc2.disconnected.emit("dropped")
|
||||
_ck(af.state() == AppFlow.LOGIN, "flow: disconnect in SELECT -> LOGIN")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bpoupm0awu3x1
|
||||
@@ -0,0 +1,97 @@
|
||||
# p2b_test —— P2 续:地面掉落物 + PlayerView(真模型)headless 自检。
|
||||
# godot --headless --path project --script p2b_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const GroundItems = preload("res://ui/ground_items.gd")
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal ground_item_added(item: Dictionary)
|
||||
signal ground_item_removed(vid: int)
|
||||
var ground := {}
|
||||
var picked := []
|
||||
func get_ground_items() -> Array: return ground.values()
|
||||
func pickup_item(vid) -> bool: picked.append(vid); return true
|
||||
func add_ground(vid, vnum, pos):
|
||||
ground[vid] = {"vid": vid, "vnum": vnum, "pos": pos}
|
||||
ground_item_added.emit(ground[vid])
|
||||
func del_ground(vid):
|
||||
ground.erase(vid); ground_item_removed.emit(vid)
|
||||
|
||||
class FakeProto extends Node:
|
||||
func item(vnum: int) -> Dictionary:
|
||||
return {"vnum": vnum, "name": "N%d" % vnum, "locale_name": "掉落%d" % vnum}
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: p2b_test (ground items + player view)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
# --- ground items ---
|
||||
var mount := Node3D.new()
|
||||
var fc := FakeClient.new()
|
||||
var fp := FakeProto.new()
|
||||
var pl := Node3D.new()
|
||||
get_root().add_child(mount)
|
||||
get_root().add_child(fc)
|
||||
get_root().add_child(fp)
|
||||
get_root().add_child(pl)
|
||||
pl.position = Vector3.ZERO
|
||||
|
||||
var gi: Node = GroundItems.new()
|
||||
get_root().add_child(gi)
|
||||
gi.setup(fc, mount, func() -> Node: return pl, fp, null)
|
||||
|
||||
fc.add_ground(101, 27, Vector3(1, 0, 1)) # 距玩家 ~1.4m(范围内)
|
||||
fc.add_ground(102, 28, Vector3(20, 0, 0)) # 远
|
||||
await process_frame
|
||||
_ck(mount.get_child_count() == 2, "2 drop nodes spawned")
|
||||
_ck(mount.get_node_or_null("drop_101") != null, "drop_101 present")
|
||||
var tag := mount.get_node("drop_101").get_node("tag") as Label3D
|
||||
_ck(tag.text == "掉落27", "drop name from proto")
|
||||
|
||||
# 拾取最近的(101 在范围,102 不在)
|
||||
var got: int = gi.try_pickup()
|
||||
_ck(got == 101, "try_pickup -> nearest 101, got %d" % got)
|
||||
_ck(fc.picked == [101], "client.pickup_item(101) called")
|
||||
|
||||
fc.del_ground(101)
|
||||
await process_frame
|
||||
_ck(mount.get_node_or_null("drop_101") == null, "drop_101 freed on removed")
|
||||
_ck(mount.get_child_count() == 1, "1 drop left")
|
||||
|
||||
# 远处的捡不到
|
||||
_ck(gi.try_pickup() == 0, "far item not picked")
|
||||
|
||||
# --- player view(有真资产才建)---
|
||||
var assets := AssetRoot.path()
|
||||
if not DirAccess.dir_exists_absolute(assets.path_join("PC/ymir work/pc/warrior")):
|
||||
print(" (skip PlayerView: no PC assets)")
|
||||
return
|
||||
if not ClassDB.class_exists("Metin2Model"):
|
||||
print(" (skip PlayerView: extension not built)")
|
||||
return
|
||||
var pv: Node3D = PlayerView.new()
|
||||
get_root().add_child(pv)
|
||||
var ok: bool = pv.build(assets, 0) # race 0 = warrior male
|
||||
_ck(ok, "PlayerView.build(race 0) ok")
|
||||
_ck(pv.model != null, "PlayerView has Metin2Model")
|
||||
_ck(pv.motion_dir.ends_with("warrior/general"), "motion_dir -> warrior/general (%s)" % pv.motion_dir)
|
||||
# 转发 set
|
||||
pv.set("weapon_gr2", "/tmp/fake_weapon.gr2")
|
||||
_ck(String(pv.model.get("weapon_gr2")) == "/tmp/fake_weapon.gr2", "PlayerView forwards weapon_gr2 to model")
|
||||
pv.set_anim_state("run")
|
||||
await process_frame
|
||||
if pv.anim:
|
||||
_ck(String(pv.anim.get("anim_path")).ends_with("run.msa"), "set_anim_state('run') -> run.msa")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgt65nkxb5gfr
|
||||
@@ -0,0 +1,289 @@
|
||||
# p8_test —— P8:组队 / 好友 / 商店 / 交易 / 仓库 UI + 胶水 headless 自检。
|
||||
# godot --headless --path project --script p8_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const PartyUI = preload("res://ui/party_ui.gd")
|
||||
const FriendUI = preload("res://ui/friend_ui.gd")
|
||||
const ShopUI = preload("res://ui/shop_ui.gd")
|
||||
const ExchangeUI = preload("res://ui/exchange_ui.gd")
|
||||
const SafeboxUI = preload("res://ui/safebox_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal party_changed()
|
||||
signal party_invite_ask(leader_pid: int)
|
||||
signal friends_changed()
|
||||
signal shop_opened(vid: int)
|
||||
signal shop_closed()
|
||||
signal shop_error(kind: String)
|
||||
signal exchange_changed()
|
||||
signal safebox_changed()
|
||||
|
||||
var calls := []
|
||||
var party := []
|
||||
var friends := []
|
||||
var shop := []
|
||||
var shop_tabs := [] # 非空 -> get_shop() 走多货架 (SHOP_GC_START_EX)
|
||||
var shop_open := false
|
||||
var exchange := {"active": false}
|
||||
var safebox := []
|
||||
var safebox_open := false
|
||||
var safebox_size := 0
|
||||
var safebox_gold := 0
|
||||
var inventory := []
|
||||
|
||||
var main_vid := 20
|
||||
var distribute_mode := 0
|
||||
func is_in_game() -> bool: return true
|
||||
func get_main_vid() -> int: return main_vid
|
||||
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
|
||||
func party_invite(vid) -> bool: calls.append(["party_invite", vid]); return true
|
||||
func party_leave(pid) -> bool: calls.append(["party_leave", pid]); return true
|
||||
func party_set_state(pid, role, on) -> bool: calls.append(["party_set_state", pid, role, on]); return true
|
||||
func party_set_distribute(mode) -> bool: calls.append(["party_set_distribute", mode]); return true
|
||||
func party_use_skill(idx, vid) -> bool: calls.append(["party_use_skill", idx, vid]); return true
|
||||
func set_target(vid) -> void: calls.append(["set_target", vid])
|
||||
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 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}]
|
||||
return {"vid": 8080, "open": shop_open, "tabs": tabs}
|
||||
func is_shop_open() -> bool: return shop_open
|
||||
func shop_buy(pos, cnt) -> bool: calls.append(["shop_buy", pos, cnt]); return true
|
||||
func shop_sell(cell, cnt) -> bool: calls.append(["shop_sell", cell, cnt]); return true
|
||||
func shop_close() -> bool: calls.append(["shop_close"]); return true
|
||||
func get_exchange() -> Dictionary: return exchange
|
||||
func exchange_add_item(w, c, d) -> bool: calls.append(["ex_add_item", w, c, d]); return true
|
||||
func exchange_add_gold(g) -> bool: calls.append(["ex_gold", g]); return true
|
||||
func exchange_accept() -> bool: calls.append(["ex_accept"]); return true
|
||||
func exchange_cancel() -> bool: calls.append(["ex_cancel"]); return true
|
||||
func get_safebox_items() -> Array: return safebox
|
||||
func is_safebox_open() -> bool: return safebox_open
|
||||
func get_safebox_size() -> int: return safebox_size
|
||||
func get_safebox_gold() -> int: return safebox_gold
|
||||
func get_inventory() -> Array: return inventory
|
||||
func safebox_checkin(sp, w, c) -> bool: calls.append(["sb_checkin", sp, w, c]); return true
|
||||
func safebox_checkout(sp, w, c) -> bool: calls.append(["sb_checkout", sp, w, c]); return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: p8_test (party + friends + shop + exchange + safebox)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var canvas := CanvasLayer.new()
|
||||
get_root().add_child(canvas)
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
|
||||
# --- 组队 ---
|
||||
var pu: Node = PartyUI.new()
|
||||
get_root().add_child(pu)
|
||||
pu.setup(fc, canvas) # 无 dialogs -> 邀请自动接受
|
||||
_ck(not pu.is_open(), "party panel hidden when empty")
|
||||
# 本地玩家 vid=20 是队长 -> 角色分配按钮可用
|
||||
fc.party = [
|
||||
{"pid": 2, "vid": 20, "name": "Me", "leader": true, "hp_pct": 90, "state": 1, "affects": [0, 0, 0, 0, 0, 0, 0]},
|
||||
{"pid": 1, "vid": 10, "name": "Bob", "leader": false, "hp_pct": 50, "state": 0, "affects": [12, 0, 0, 0, 0, 0, 0]},
|
||||
]
|
||||
fc.party_changed.emit()
|
||||
_ck(pu.is_open(), "party panel shows with members")
|
||||
_ck(pu._list.get_child_count() == 2, "party: 2 member strips")
|
||||
fc.party_invite_ask.emit(77)
|
||||
_ck(fc.calls.has(["party_answer", 77, true]), "party: auto-accept invite (no dialogs)")
|
||||
|
||||
# 附加효과 칩:Bob 的 affects[0]=12 -> 一个「经验」chip
|
||||
var bob_strip: Control = pu._list.get_child(1)
|
||||
var chip_found := false
|
||||
for l in bob_strip.find_children("*", "Label", true, false):
|
||||
if String(l.text) == "经验":
|
||||
chip_found = true
|
||||
_ck(chip_found, "party: non-zero affect renders a chip")
|
||||
|
||||
# 队长点 Me 的 StateButton -> 角色弹窗 -> 「攻击」-> party_set_state(pid, ROLE_ATTACKER, true)
|
||||
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 ["队", "普", "攻", "坦", "辅", "宗", "狂", "防"]:
|
||||
state_btn = b
|
||||
break
|
||||
_ck(state_btn != null and not state_btn.disabled, "party: leader's StateButton enabled")
|
||||
state_btn.pressed.emit()
|
||||
_ck(is_instance_valid(pu._role_popup), "party: role popup opens for leader")
|
||||
var atk_btn: Button = null
|
||||
var expel_btn: Button = null
|
||||
for b in pu._role_popup.find_children("*", "Button", true, false):
|
||||
if String(b.text).begins_with("攻击"): atk_btn = b
|
||||
if String(b.text) == "踢出队伍": expel_btn = b
|
||||
atk_btn.pressed.emit()
|
||||
_ck(fc.calls.has(["party_set_state", 2, 2, true]),
|
||||
"party: 攻击 -> party_set_state(pid, ATTACKER=2, true)")
|
||||
# 다시 열어 踢出
|
||||
state_btn.pressed.emit()
|
||||
for b in pu._role_popup.find_children("*", "Button", true, false):
|
||||
if String(b.text) == "踢出队伍": expel_btn = b
|
||||
expel_btn.pressed.emit()
|
||||
_ck(fc.calls.has(["party_leave", 2]), "party: 踢出队伍 -> party_leave(pid)")
|
||||
|
||||
# EXP 分配切换(当前 mode 0 -> 发 1)
|
||||
pu._toggle_distribute()
|
||||
_ck(fc.calls.has(["party_set_distribute", 1]), "party: 分配切换 -> party_set_distribute(PARITY)")
|
||||
# 组队治疗
|
||||
pu._party_heal()
|
||||
_ck(fc.calls.has(["party_use_skill", 1, 0]), "party: 组队治疗 -> party_use_skill(HEAL=1, 0)")
|
||||
|
||||
# 点名字 -> 选中该员 vid
|
||||
var name_btn: Button = null
|
||||
for b in me_strip.find_children("*", "Button", true, false):
|
||||
if String(b.text).ends_with("Me"): name_btn = b
|
||||
if name_btn:
|
||||
name_btn.pressed.emit()
|
||||
_ck(fc.calls.has(["set_target", 20]), "party: 点名字 -> set_target(vid)")
|
||||
|
||||
# 非队长 -> StateButton disabled
|
||||
fc.party[0]["leader"] = false
|
||||
fc.party[0]["state"] = 0
|
||||
fc.party_changed.emit()
|
||||
var me2: Control = pu._list.get_child(0)
|
||||
for b in me2.find_children("*", "Button", true, false):
|
||||
if String(b.text) in ["普", "队"]:
|
||||
_ck(b.disabled, "party: non-leader StateButton disabled")
|
||||
break
|
||||
|
||||
# --- 好友 ---
|
||||
var fu: Node = FriendUI.new()
|
||||
get_root().add_child(fu)
|
||||
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_changed.emit()
|
||||
await process_frame
|
||||
var wt := []
|
||||
fu.whisper_to.connect(func(n): wt.append(n))
|
||||
# 找 Alice 行的名字按钮
|
||||
var alice_btn: Button = null
|
||||
for b in fu._list.find_children("*", "Button", true, false):
|
||||
if (b as Button).text == "Alice":
|
||||
alice_btn = b
|
||||
_ck(alice_btn != null, "friend: Alice row present")
|
||||
if alice_btn:
|
||||
alice_btn.pressed.emit()
|
||||
_ck(wt == ["Alice"], "friend: click name -> whisper_to(Alice)")
|
||||
fu._name_edit.text = "Carol"
|
||||
fu._on_add()
|
||||
_ck(fc.calls.has(["add_friend", "Carol"]), "friend: add via input")
|
||||
|
||||
# --- 商店 ---
|
||||
var su: Node = ShopUI.new()
|
||||
get_root().add_child(su)
|
||||
su.setup(fc, canvas)
|
||||
fc.shop = [{"pos": 0, "vnum": 27, "price": 1500, "count": 1},
|
||||
{"pos": 1, "vnum": 19, "price": 800, "count": 1}]
|
||||
fc.shop_opened.emit(8080)
|
||||
_ck(su.is_open(), "shop opens on shop_opened")
|
||||
_ck(su._list.get_child_count() == 2, "shop: 2 item rows")
|
||||
# 第一行的「买」按钮
|
||||
var buy_btn: Button = null
|
||||
for b in su._list.get_child(0).find_children("*", "Button", true, false):
|
||||
buy_btn = b
|
||||
if buy_btn:
|
||||
buy_btn.pressed.emit()
|
||||
_ck(fc.calls.has(["shop_buy", 0, 1]), "shop: buy row 0")
|
||||
su.sell(5)
|
||||
_ck(fc.calls.has(["shop_sell", 5, 1]), "shop: sell inv cell 5")
|
||||
fc.shop_error.emit("NOT_ENOUGH_MONEY")
|
||||
_ck(su._err.text == "金币不足", "shop: error text localised")
|
||||
fc.shop_closed.emit()
|
||||
_ck(not su.is_open(), "shop closes on shop_closed")
|
||||
|
||||
# --- 商店 SHOP_GC_START_EX(多货架 + tabIdx*40+slot 买位置) ---
|
||||
fc.calls.clear()
|
||||
fc.shop_tabs = [
|
||||
{"name": "武器", "coin_type": 0, "items": [{"pos": 0, "vnum": 101, "price": 500, "count": 1},
|
||||
{"pos": 5, "vnum": 202, "price": 900, "count": 3}]},
|
||||
{"name": "防具", "coin_type": 1, "items": [{"pos": 3, "vnum": 303, "price": 12000, "count": 1}]},
|
||||
]
|
||||
fc.shop_opened.emit(8080)
|
||||
_ck(su.is_open(), "shop_ex: opens")
|
||||
_ck(su._tabbar.visible and su._tabbar.get_child_count() == 2, "shop_ex: 2 tab buttons")
|
||||
_ck(su._list.get_child_count() == 2, "shop_ex: tab 0 has 2 rows")
|
||||
# tab 0 第二行(slot 5)买 -> pos = 0*40 + 5
|
||||
var r1_buy: Button = null
|
||||
for b in su._list.get_child(1).find_children("*", "Button", true, false):
|
||||
r1_buy = b
|
||||
if r1_buy:
|
||||
r1_buy.pressed.emit()
|
||||
_ck(fc.calls.has(["shop_buy", 5, 1]), "shop_ex: tab0 slot5 -> buy pos 5")
|
||||
# 切到货架 2
|
||||
su._tabbar.get_child(1).pressed.emit()
|
||||
_ck(su._active_tab == 1, "shop_ex: switched to tab 1")
|
||||
_ck(su._list.get_child_count() == 1, "shop_ex: tab 1 has 1 row")
|
||||
var t1_buy: Button = null
|
||||
for b in su._list.get_child(0).find_children("*", "Button", true, false):
|
||||
t1_buy = b
|
||||
if t1_buy:
|
||||
t1_buy.pressed.emit()
|
||||
_ck(fc.calls.has(["shop_buy", 43, 1]), "shop_ex: tab1 slot3 -> buy pos 1*40+3=43")
|
||||
fc.shop_closed.emit()
|
||||
fc.shop_tabs = []
|
||||
|
||||
# --- 交易 ---
|
||||
var xu: Node = ExchangeUI.new()
|
||||
get_root().add_child(xu)
|
||||
xu.setup(fc, canvas)
|
||||
fc.exchange = {
|
||||
"active": true, "partner_vid": 4321,
|
||||
"self_items": [{"slot": 0, "vnum": 19, "count": 1}],
|
||||
"peer_items": [], "self_gold": 100, "peer_gold": 0,
|
||||
"self_accept": false, "peer_accept": true,
|
||||
}
|
||||
fc.exchange_changed.emit()
|
||||
_ck(xu.is_open(), "exchange window opens when active")
|
||||
_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)")
|
||||
xu._gold_input.text = "500"
|
||||
xu._on_put_gold()
|
||||
_ck(fc.calls.has(["ex_gold", 500]), "exchange: put gold")
|
||||
fc.exchange = {"active": false}
|
||||
fc.exchange_changed.emit()
|
||||
_ck(not xu.is_open(), "exchange closes when inactive")
|
||||
|
||||
# --- 仓库 ---
|
||||
var bu: Node = SafeboxUI.new()
|
||||
get_root().add_child(bu)
|
||||
bu.setup(fc, canvas)
|
||||
fc.safebox_open = true
|
||||
fc.safebox_size = 2
|
||||
fc.safebox_gold = 99999
|
||||
fc.safebox = [{"cell": 3, "vnum": 27, "count": 10}]
|
||||
fc.safebox_changed.emit()
|
||||
_ck(bu.is_open(), "safebox opens")
|
||||
_ck(bu._gold.text.contains("99999"), "safebox: gold shown")
|
||||
_ck(bu._list.get_child_count() == 1, "safebox: 1 item row")
|
||||
var out_btn: Button = null
|
||||
for b in bu._list.get_child(0).find_children("*", "Button", true, false):
|
||||
out_btn = b
|
||||
if out_btn:
|
||||
out_btn.pressed.emit()
|
||||
_ck(fc.calls.any(func(c): return c[0] == "sb_checkout" and c[1] == 3), "safebox: checkout cell 3")
|
||||
fc.inventory = [{"cell": 0}, {"cell": 1}]
|
||||
bu.deposit(1, 4)
|
||||
# 下一个空仓位应是 0(cell 3 已用)
|
||||
_ck(fc.calls.any(func(c): return c[0] == "sb_checkin" and c[2] == 1 and c[3] == 4),
|
||||
"safebox: deposit inv cell 4")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dseicj4ybu2qd
|
||||
@@ -0,0 +1,131 @@
|
||||
# p9_test —— P9:小地图 + 昼夜 + 天气 + 传送 headless 自检。
|
||||
# godot --headless --path project --script p9_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const Minimap = preload("res://ui/minimap.gd")
|
||||
const WorldTime = preload("res://world/world_time.gd")
|
||||
const Weather = preload("res://fx/weather.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal time_changed(server_epoch: int)
|
||||
signal channel_changed(channel: int)
|
||||
signal npc_marks_changed()
|
||||
signal land_areas_changed()
|
||||
signal observer_event(kind: int, vid: int, pos: Vector2)
|
||||
signal world_markers_changed()
|
||||
signal warp(pos: Vector3, same_server: bool)
|
||||
var _time := 0
|
||||
var entities := []
|
||||
var npc_marks := []
|
||||
var markers := []
|
||||
var observers := []
|
||||
var land_areas := []
|
||||
var party := []
|
||||
func get_server_time() -> int: return _time
|
||||
func get_entities() -> Array: return entities
|
||||
func get_npc_marks() -> Array: return npc_marks
|
||||
func get_world_markers() -> Array: return markers
|
||||
func get_observers() -> Array: return observers
|
||||
func get_land_areas() -> Array: return land_areas
|
||||
func get_party() -> Array: return party
|
||||
func get_channel() -> int: return 3
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: p9_test (minimap + world_time + weather + warp)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var canvas := CanvasLayer.new()
|
||||
get_root().add_child(canvas)
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
var player := Node3D.new()
|
||||
player.position = Vector3.ZERO
|
||||
get_root().add_child(player)
|
||||
|
||||
# --- 小地图 ---
|
||||
var mm: Node = Minimap.new()
|
||||
get_root().add_child(mm)
|
||||
mm.setup(fc, canvas, func() -> Node3D: return player)
|
||||
_ck(mm._view != null, "minimap: view built")
|
||||
fc.channel_changed.emit(7)
|
||||
_ck(mm._ch_label.text == "CH 7", "minimap: channel label")
|
||||
# _to_map: 北(−z 100m)应在圆心正上方附近;夹到半径内
|
||||
var north: Vector2 = mm._to_map(Vector3(0, 0, -1000), Vector3.ZERO)
|
||||
_ck(north.x == mm.RADIUS and north.y < mm.RADIUS, "minimap: north maps up, clamped")
|
||||
var east: Vector2 = mm._to_map(Vector3(10, 0, 0), Vector3.ZERO)
|
||||
_ck(east.x > mm.RADIUS and abs(east.y - mm.RADIUS) < 0.01, "minimap: east maps right")
|
||||
var base_scale: float = mm.get_scale()
|
||||
mm.set_scale(base_scale * 2.0)
|
||||
var zoomed_east: Vector2 = mm._to_map(Vector3(10, 0, 0), Vector3.ZERO)
|
||||
_ck(mm.get_scale() > base_scale and zoomed_east.x > east.x, "minimap: scale zooms world projection")
|
||||
var wheel_scale: float = mm.get_scale()
|
||||
var wheel := InputEventMouseButton.new()
|
||||
wheel.button_index = MOUSE_BUTTON_WHEEL_DOWN
|
||||
wheel.pressed = true
|
||||
mm._on_gui_input(wheel)
|
||||
_ck(mm.get_scale() < wheel_scale, "minimap: wheel down zooms out")
|
||||
fc.entities = [{"vid": 11, "is_main": false, "ch_type": 0, "pos": Vector3(5, 0, 5)}]
|
||||
fc.party = [{"vid": 11, "leader": true}]
|
||||
fc.npc_marks = [{"type": 2, "name": "Gate", "pos": Vector3(3, 0, 3)}]
|
||||
fc.markers = [{"id": 1, "name": "Quest", "pos": Vector3(-4, 0, -4)}]
|
||||
fc.observers = [{"vid": 9001, "pos": Vector3(2, 0, -2)}]
|
||||
fc.land_areas = [{"id": 42, "guild_id": 77, "x": -200, "y": -200, "width": 400, "height": 400}]
|
||||
mm._view.queue_redraw()
|
||||
await process_frame
|
||||
_ck(mm._view.is_visible_in_tree(), "minimap: redraw scheduled, view live")
|
||||
|
||||
# --- 昼夜 ---
|
||||
var sun := DirectionalLight3D.new()
|
||||
get_root().add_child(sun)
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
var wt: Node = WorldTime.new()
|
||||
get_root().add_child(wt)
|
||||
wt.setup(fc, sun, env)
|
||||
# 正午:2025-01-01 12:00:00 UTC = 1735732800
|
||||
fc._time = 1735732800
|
||||
fc.time_changed.emit(1735732800)
|
||||
_ck(abs(wt.day_fraction() - 0.5) < 0.01, "world_time: noon -> day_fraction ~0.5")
|
||||
wt._process(0.016)
|
||||
var noon_energy: float = sun.light_energy
|
||||
_ck(noon_energy > 0.9, "world_time: noon sun bright (%.2f)" % noon_energy)
|
||||
# 午夜
|
||||
fc.time_changed.emit(1735732800 - 43200)
|
||||
_ck(abs(wt.day_fraction()) < 0.01 or abs(wt.day_fraction() - 1.0) < 0.01,
|
||||
"world_time: midnight -> fraction ~0")
|
||||
wt._process(0.016)
|
||||
_ck(sun.light_energy < 0.2, "world_time: midnight sun dark (%.3f)" % sun.light_energy)
|
||||
|
||||
# --- 天气 ---
|
||||
var cam := Camera3D.new()
|
||||
get_root().add_child(cam)
|
||||
var w: Node3D = Weather.new()
|
||||
get_root().add_child(w)
|
||||
w.setup(cam)
|
||||
_ck(w.kind() == "none" and not w._p.emitting, "weather: starts off")
|
||||
w.set_weather("snow")
|
||||
_ck(w._p.emitting and w.kind() == "snow", "weather: snow on")
|
||||
var g_snow: Vector3 = w._p.process_material.gravity
|
||||
w.set_weather("rain")
|
||||
var g_rain: Vector3 = w._p.process_material.gravity
|
||||
_ck(g_rain.y < g_snow.y, "weather: rain falls faster than snow")
|
||||
w.set_weather("none")
|
||||
_ck(not w._p.emitting, "weather: off again")
|
||||
|
||||
# --- 传送信号(同服)---
|
||||
var got := []
|
||||
fc.warp.connect(func(pos, same): got.append([pos, same]))
|
||||
fc.warp.emit(Vector3(50, 0, -30), true)
|
||||
_ck(got.size() == 1 and got[0][1] == true, "warp: same-server signal received")
|
||||
@@ -0,0 +1 @@
|
||||
uid://4crf5im7shwi
|
||||
@@ -0,0 +1,242 @@
|
||||
# PlayerController —— Metin2 风移动 + 选目标(客户端预测,无服务端纠正)。
|
||||
# 左键点地面 -> 走/跑过去(沿途查 attr.atr 可行走,遇阻停)
|
||||
# 左键点实体 -> 选中(emit target_selected)
|
||||
# WASD/方向键 -> 直接移动(相对相机),覆盖点地
|
||||
# 触屏(F7):单指轻点 = 点地/点选(快速抬起且位移小);单指拖拽归相机环绕,
|
||||
# 双指手势时不产生点地。
|
||||
# 需要:player(Node3D)、camera(GameCamera)、world(Metin2World)。
|
||||
# 每帧 emit anim_state("wait"|"walk"|"run") 供上层切动画。
|
||||
extends Node
|
||||
|
||||
signal anim_state(state: String)
|
||||
signal target_selected(node: Node3D)
|
||||
signal moved(pos: Vector3)
|
||||
|
||||
var player: Node3D
|
||||
var camera: Camera3D
|
||||
var world: Node
|
||||
var pickables: Array[Node3D] = [] # 可点选实体(NPC/怪等)
|
||||
|
||||
const SPEED_WALK := 8.0
|
||||
const SPEED_RUN := 18.0
|
||||
const RUN_HOLD_KEY := KEY_SHIFT
|
||||
const ARRIVE_EPS := 0.4
|
||||
const PICK_RADIUS := 1.4 # 点选命中半径(米)
|
||||
|
||||
var force_run := false # 脚本化 / 自动跑
|
||||
var frozen := false # 受击硬直等:本帧不响应移动输入(net_play 设)
|
||||
var server_speed_scale := 1.0 # GC_CHANGE_SPEED moving_speed / 100
|
||||
|
||||
const _TAP_TRAVEL_MAX := 12.0 # 触点位移超过这么多像素 -> 视作拖拽(归相机),不是轻点
|
||||
const _TAP_TIME_MAX := 0.35 # 按下到抬起超过这么久 -> 不是轻点
|
||||
|
||||
var _move_target := Vector3.ZERO
|
||||
var _has_target := false
|
||||
var _run := false
|
||||
var _touch_count := 0
|
||||
var _multi_gesture := false # 曾有 ≥2 指同时按下 -> 本轮不产生点地
|
||||
var _tap_index := -1
|
||||
var _tap_press_pos := Vector2.ZERO
|
||||
var _tap_press_t := 0.0
|
||||
|
||||
func set_server_speed(moving_speed: int) -> void:
|
||||
# Metin2 POINT_MOV_SPEED uses 100 as the normal baseline. Keep malformed or
|
||||
# pre-spawn zero values from freezing local prediction.
|
||||
if moving_speed <= 0:
|
||||
return
|
||||
server_speed_scale = clampf(float(moving_speed) / 100.0, 0.25, 3.0)
|
||||
|
||||
# 程序化下发一个点地目标(脚本化截图 / AI)
|
||||
func walk_to(world_pos: Vector3) -> void:
|
||||
_move_target = Vector3(world_pos.x, 0.0, world_pos.z)
|
||||
_has_target = true
|
||||
|
||||
func _unhandled_input(e: InputEvent) -> void:
|
||||
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
|
||||
_on_click(e.position)
|
||||
elif e is InputEventScreenTouch:
|
||||
_on_touch(e)
|
||||
elif e is InputEventScreenDrag:
|
||||
# 触点一旦拖出去,就不再是「轻点」,交给相机环绕
|
||||
if e.index == _tap_index and e.position.distance_to(_tap_press_pos) > _TAP_TRAVEL_MAX:
|
||||
_tap_index = -1
|
||||
|
||||
func _on_touch(e: InputEventScreenTouch) -> void:
|
||||
if e.pressed:
|
||||
_touch_count += 1
|
||||
if _touch_count >= 2:
|
||||
_multi_gesture = true
|
||||
_tap_index = -1
|
||||
elif not _multi_gesture:
|
||||
_tap_index = e.index
|
||||
_tap_press_pos = e.position
|
||||
_tap_press_t = Time.get_ticks_msec() / 1000.0
|
||||
else:
|
||||
_touch_count = max(0, _touch_count - 1)
|
||||
if e.index == _tap_index and not _multi_gesture:
|
||||
var travel := e.position.distance_to(_tap_press_pos)
|
||||
var held := Time.get_ticks_msec() / 1000.0 - _tap_press_t
|
||||
if travel <= _TAP_TRAVEL_MAX and held <= _TAP_TIME_MAX:
|
||||
_on_click(e.position)
|
||||
_tap_index = -1
|
||||
if _touch_count == 0:
|
||||
_multi_gesture = false
|
||||
|
||||
func _on_click(screen_pos: Vector2) -> void:
|
||||
if camera == null:
|
||||
return
|
||||
var from := camera.project_ray_origin(screen_pos)
|
||||
var dir := camera.project_ray_normal(screen_pos)
|
||||
|
||||
# 1) 实体点选:射线 vs 每个 pickable 的网格世界包围盒(slab 相交,逐实体精确,
|
||||
# 大怪 / 小怪都不再被固定 1.4m 半径误判)。无网格的回退到胶囊近似。
|
||||
var best: Node3D = null
|
||||
var best_t := 1e20
|
||||
for n in pickables:
|
||||
if not is_instance_valid(n):
|
||||
continue
|
||||
var t := _ray_pick_t(from, dir, n)
|
||||
if t >= 0.0 and t < best_t:
|
||||
best_t = t
|
||||
best = n
|
||||
if best:
|
||||
target_selected.emit(best)
|
||||
return
|
||||
|
||||
# 2) 点地:射线与地表求交(沿射线二分找 y == 地表高度)
|
||||
var hit: Variant = _ray_ground(from, dir)
|
||||
if hit != null:
|
||||
_move_target = hit
|
||||
_has_target = true
|
||||
|
||||
# 射线 vs 实体(节点下所有 MeshInstance3D 的世界 AABB 合并)的进入距离 t;不命中返回 -1。
|
||||
func _ray_pick_t(from: Vector3, dir: Vector3, node: Node3D) -> float:
|
||||
var box := AABB()
|
||||
var have := false
|
||||
for m in node.find_children("*", "MeshInstance3D", true, false):
|
||||
var mi := m as MeshInstance3D
|
||||
if mi.mesh == null:
|
||||
continue
|
||||
var wb: AABB = mi.get_global_transform() * mi.get_aabb()
|
||||
box = wb if not have else box.merge(wb)
|
||||
have = true
|
||||
if not have:
|
||||
# 无网格:退回胶囊近似(头顶 1m,半径 PICK_RADIUS)
|
||||
var to := node.global_position + Vector3(0, 1.0, 0) - from
|
||||
var tp := to.dot(dir)
|
||||
if tp < 0.0:
|
||||
return -1.0
|
||||
var perp := (from + dir * tp) - (node.global_position + Vector3(0, 1.0, 0))
|
||||
return tp if perp.length() < PICK_RADIUS else -1.0
|
||||
box = box.grow(0.15) # 点选宽容度
|
||||
# slab 法求射线进入 box 的 t
|
||||
var tmin := -1e20
|
||||
var tmax := 1e20
|
||||
for axis in 3:
|
||||
var o: float = from[axis]
|
||||
var d: float = dir[axis]
|
||||
var lo: float = box.position[axis]
|
||||
var hi: float = box.position[axis] + box.size[axis]
|
||||
if absf(d) < 1e-9:
|
||||
if o < lo or o > hi:
|
||||
return -1.0
|
||||
else:
|
||||
var t1 := (lo - o) / d
|
||||
var t2 := (hi - o) / d
|
||||
if t1 > t2:
|
||||
var tmp := t1; t1 = t2; t2 = tmp
|
||||
tmin = maxf(tmin, t1)
|
||||
tmax = minf(tmax, t2)
|
||||
if tmin > tmax:
|
||||
return -1.0
|
||||
return tmin if tmin >= 0.0 else (tmax if tmax >= 0.0 else -1.0)
|
||||
|
||||
func _ray_ground(from: Vector3, dir: Vector3) -> Variant:
|
||||
if world == null or not world.has_method("sample_height"):
|
||||
return null
|
||||
var t := 0.0
|
||||
var last_above: bool = from.y - float(world.call("sample_height", from.x, from.z)) > 0.0
|
||||
for _i in 240:
|
||||
t += 1.0
|
||||
var p := from + dir * t
|
||||
if t > 800.0:
|
||||
break
|
||||
var above: bool = p.y - float(world.call("sample_height", p.x, p.z)) > 0.0
|
||||
if above != last_above:
|
||||
# 在 [t-1, t] 之间二分
|
||||
var a := t - 1.0
|
||||
var b := t
|
||||
for _j in 12:
|
||||
var m := (a + b) * 0.5
|
||||
var pm := from + dir * m
|
||||
if (pm.y - world.call("sample_height", pm.x, pm.z) > 0) == last_above:
|
||||
a = m
|
||||
else:
|
||||
b = m
|
||||
var pf := from + dir * b
|
||||
return Vector3(pf.x, float(world.call("sample_height", pf.x, pf.z)), pf.z)
|
||||
last_above = above
|
||||
return null
|
||||
|
||||
func _wasd() -> Vector2:
|
||||
var d := Vector2.ZERO
|
||||
if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP): d.y -= 1
|
||||
if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN): d.y += 1
|
||||
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT): d.x -= 1
|
||||
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT): d.x += 1
|
||||
return d.normalized()
|
||||
|
||||
func _blocked(x: float, z: float) -> bool:
|
||||
return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", x, z))
|
||||
|
||||
func _process(dt: float) -> void:
|
||||
if player == null:
|
||||
return
|
||||
if frozen:
|
||||
_has_target = false
|
||||
anim_state.emit("wait")
|
||||
return
|
||||
_run = force_run or Input.is_key_pressed(RUN_HOLD_KEY)
|
||||
var wish := Vector3.ZERO
|
||||
var wasd := _wasd()
|
||||
|
||||
if wasd != Vector2.ZERO:
|
||||
_has_target = false # 键盘覆盖点地
|
||||
var yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0)
|
||||
var fwd := Vector3(sin(yaw), 0, cos(yaw))
|
||||
var right := Vector3(fwd.z, 0, -fwd.x)
|
||||
wish = (fwd * -wasd.y + right * wasd.x).normalized()
|
||||
elif _has_target:
|
||||
var flat := Vector3(_move_target.x - player.position.x, 0, _move_target.z - player.position.z)
|
||||
if flat.length() <= ARRIVE_EPS:
|
||||
_has_target = false
|
||||
else:
|
||||
wish = flat.normalized()
|
||||
|
||||
var speed := 0.0
|
||||
if wish != Vector3.ZERO:
|
||||
var want_speed := (SPEED_RUN if _run else SPEED_WALK) * server_speed_scale
|
||||
var before := player.position
|
||||
var np := player.position + wish * want_speed * dt
|
||||
if not _blocked(np.x, player.position.z):
|
||||
player.position.x = np.x
|
||||
if not _blocked(player.position.x, np.z):
|
||||
player.position.z = np.z
|
||||
# 动画看「实际位移」而非意图:贴墙磨蹭时该切回 wait
|
||||
var disp := Vector2(player.position.x - before.x, player.position.z - before.z).length()
|
||||
if disp > want_speed * dt * 0.25:
|
||||
speed = want_speed
|
||||
player.rotation.y = atan2(wish.x, wish.z)
|
||||
else:
|
||||
_has_target = false
|
||||
|
||||
if world and world.has_method("sample_height"):
|
||||
player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
|
||||
moved.emit(player.position)
|
||||
|
||||
if speed <= 0.01:
|
||||
anim_state.emit("wait")
|
||||
elif not _run:
|
||||
anim_state.emit("walk")
|
||||
else:
|
||||
anim_state.emit("run")
|
||||
@@ -0,0 +1 @@
|
||||
uid://rpfnf58o5f70
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user