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:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+356
View File
@@ -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_sceneReconnectUI 负责重连遮罩
# 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()
+1
View File
@@ -0,0 +1 @@
uid://prh3okf1n2lk
+85
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://cwgs1qcu80qfm
+60
View File
@@ -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] 找不到 %sAndroid: 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 ""
+1
View File
@@ -0,0 +1 @@
uid://f260rrsoxa0p
+47
View File
@@ -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.zipAssetPack)把资源放到 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())
+1
View File
@@ -0,0 +1 @@
uid://debiskaw74tdw
+76
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://k51m5hekg71a
+163
View File
@@ -0,0 +1,163 @@
# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。
# 对标客户端 AudioLibBGM`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)
+1
View File
@@ -0,0 +1 @@
uid://ke6yfuy7eum2
+22
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dyvg0v8ntldha
+26
View File
@@ -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" : ""
}
+89
View File
@@ -0,0 +1,89 @@
# channel_status_test —— 频道负载查询(ChannelStatusheadless 自检。
# 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()
+1
View File
@@ -0,0 +1 @@
uid://bo1041j7pwnra
+139
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://b43adptpq1ua4
+152
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://berxs4vw0s7vd
+240
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://qe5xev5hp4l4
+116
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://dwoq34nldm7x3
+19
View File
@@ -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.zipadb push / ios-deploy 上传)。
AssetPack.ensure()
var root := AssetRoot.path()
if not AssetRoot.available():
push_warning("[client] 资源目录不存在:%s —— MT_ASSETS 环境变量 / assets.zipadb push 到 files/" % root)
var af := AppFlow.new()
af.name = "AppFlow"
add_child(af)
af.start(root)
+1
View File
@@ -0,0 +1 @@
uid://d384dorgv24yv
+6
View File
@@ -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")
+156
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bisseuypqt88s
+105
View File
@@ -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(), "关闭按钮")
+1
View File
@@ -0,0 +1 @@
uid://c5xpqyrnavvjx
+165
View File
@@ -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 -> 装备槽")
+1
View File
@@ -0,0 +1 @@
uid://chnd4ymmuptf0
+141
View File
@@ -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. CharShadowDecal + 强制 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
+1
View File
@@ -0,0 +1 @@
uid://4h1l6dtrxggf
+67
View File
@@ -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
+242
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dusxpcvp74to5
+99
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dyu4mphxwm5ti
+235
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://c728y7nra8uhh
+74
View File
@@ -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_namecol13)就是特效基名;等级后缀 = 2 + master_type(夹 2..4)。
# effect/pc/<class>/effect/<motion>_<N>.mseEffectRegistry 的 basename 扫描能找到散包 / PC / season1
# 少数技能有 _blow / _head / _foot / _yong 子特效;这里只放主特效,缺就跳过。
extends RefCounted
# GC_SPECIAL_EFFECT 的 typeItemData.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
+1
View File
@@ -0,0 +1 @@
uid://dit70gssglg2b
+83
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cj5x183tcq55n
+138
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bporisusalpvj
+191
View File
@@ -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 指向它的 MeshInstance3DMetin2World 设的)
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
+1
View File
@@ -0,0 +1 @@
uid://s4yeb23vdw3i
+686
View File
@@ -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_type0 普通 / 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:
# ESCui_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 -> MobViewrace = 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
+1
View File
@@ -0,0 +1 @@
uid://blsfh7xqpjfp5
+92
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://dgk3ohpwqq1h3
+133
View File
@@ -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 冷却内 -> 不重复下载")
+1
View File
@@ -0,0 +1 @@
uid://ympbq4jbv85r
+91
View File
@@ -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, "取消 -> 关闭且不发包")
+1
View File
@@ -0,0 +1 @@
uid://c7y3tetalldlq
+110
View File
@@ -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 行 + 我方标记")
+1
View File
@@ -0,0 +1 @@
uid://xbhde2rhxvgy
+331
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cs3i75781lp3u
+66
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://76o26fdux541
+94
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://ce8ey8voa2h7l
+66
View File
@@ -0,0 +1,66 @@
# Locale —— EterLocale 字符串表(`locale/locale/<lang>/*.txt`KEY\tVALUEUTF-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)
+1
View File
@@ -0,0 +1 @@
uid://cmkg44eg1hfh0
+156
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://6u27nmch6k60
+44
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://7w5o7rxr2wmk
+79 -13
View File
@@ -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()
+64
View File
@@ -0,0 +1,64 @@
# mob_view_test —— 怪 / NPC 真模型(MobViewheadless 自检。
# 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 ManNPC)——若有目录就该建成
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])
+1
View File
@@ -0,0 +1 @@
uid://b5dp6gbugrc1t
+103
View File
@@ -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}。
# status0 关 / 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)
+1
View File
@@ -0,0 +1 @@
uid://015nea7sbodm
+39
View File
@@ -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()) # Vector2cm
# 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 帧里南=+ZZ 翻向 -> 相对 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)
+1
View File
@@ -0,0 +1 @@
uid://c36ncgt70wf0a
+91
View File
@@ -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/11013step=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)),
}
+1
View File
@@ -0,0 +1 @@
uid://dgvdccv3267fh
+319
View File
@@ -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 indexP4 再按 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)
+1
View File
@@ -0,0 +1 @@
uid://k8vtf51x130l
+517
View File
@@ -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,动画状态取自 funcFUNC_*)。
#
# 默认生成「占位胶囊 + 名字牌」(快、无依赖,先跑通同步/插值/状态循环)。
# 要换成真模型: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_TARGETINGtarget 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
+1
View File
@@ -0,0 +1 @@
uid://mxr8pfgb5ysp
+220
View File
@@ -0,0 +1,220 @@
# netbridge_test —— M2Client 游戏内 API + NetWorld 场景桥的 headless 自检。
# godot --headless --path project --script netbridge_test.gd
# 无需服务器:用假 client 驱动 NetWorldM2Client 部分只查绑定面 + 未入局时拒绝。
# 退出码 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")
+1
View File
@@ -0,0 +1 @@
uid://d4kjxl5d6lo6m
+152
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bsjyki4suspqp
+126
View File
@@ -0,0 +1,126 @@
# p10_test —— P10serverinfo + 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")
+1
View File
@@ -0,0 +1 @@
uid://bpoupm0awu3x1
+97
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bgt65nkxb5gfr
+289
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://dseicj4ybu2qd
+131
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://4crf5im7shwi
+242
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://rpfnf58o5f70

Some files were not shown because too many files have changed in this diff Show More