port: purge obsolete GDScript UI, legacy flows, and deprecated controllers
- Purged 66 obsolete GDScript UI components and mobile prototype UI from project/ui/ (retaining ui_assets.gd) - Purged deprecated GDScript game scene, flow state machines, and local gameplay controllers (app_flow, game_scene, player_controller, net_world, etc.) - Purged deprecated local system simulation scripts (consumable_system, chest_system, equip_rules, etc.) and project/fx/ - Purged legacy GDScript unit tests targeting obsolete classes - Unified viewport back buffer sizing in CGraphicBase/CGraphicDevice/PythonBoot - Cleaned up client_main.gd and python_host_check.gd for pure native port architecture - Verified all port tests passing: port_map.py check (0 errors), port_gate.sh macos PASS (19/19 suites), python_game_render_test PASS
This commit is contained in:
@@ -167,10 +167,13 @@ auto CGraphicDevice::MoveWebBrowserRect(const RECT &) -> void
|
||||
MT_PLATFORM_STUB();
|
||||
}
|
||||
|
||||
auto CGraphicDevice::ResizeBackBuffer(UINT, UINT) -> bool
|
||||
auto CGraphicDevice::ResizeBackBuffer(UINT uWidth, UINT uHeight) -> bool
|
||||
{
|
||||
MT_PLATFORM_STUB();
|
||||
return mt_platform_stub_return<bool>();
|
||||
ms_iWidth = uWidth;
|
||||
ms_iHeight = uHeight;
|
||||
ms_d3dPresentParameter.BackBufferWidth = uWidth;
|
||||
ms_d3dPresentParameter.BackBufferHeight = uHeight;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto CGraphicDevice::RegisterWarningString(UINT, const char *) -> void
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "EterPythonLib/StdAfx.h"
|
||||
#include "EterBase/Timer.h"
|
||||
#include "EterLib/Util.h"
|
||||
#include "EterLib/GrpBase.h"
|
||||
#include "../EterLib/HostCursor.h"
|
||||
#include "UserInterface/PythonNetworkStream.h"
|
||||
#include "UserInterface/AccountConnector.h"
|
||||
@@ -239,6 +240,7 @@ void SetUISize(int width, int height)
|
||||
g_window_manager->SetScreenSize(width, height);
|
||||
g_window_manager->SetResolution(width, height);
|
||||
UIRenderSetSize(width, height);
|
||||
CGraphicBase::SetBackBufferSize(width, height);
|
||||
if (g_game_singletons)
|
||||
{
|
||||
g_game_singletons->pySystem.GetConfig()->width = width;
|
||||
|
||||
@@ -135,6 +135,12 @@ void CGraphicBase::GetBackBufferSize(UINT* puWidth, UINT* puHeight)
|
||||
*puHeight=ms_d3dPresentParameter.BackBufferHeight;
|
||||
}
|
||||
|
||||
void CGraphicBase::SetBackBufferSize(UINT uWidth, UINT uHeight)
|
||||
{
|
||||
ms_d3dPresentParameter.BackBufferWidth = uWidth;
|
||||
ms_d3dPresentParameter.BackBufferHeight = uHeight;
|
||||
}
|
||||
|
||||
void CGraphicBase::SetDefaultIndexBuffer(UINT eDefIB)
|
||||
{
|
||||
if (eDefIB>=DEFAULT_IB_NUM)
|
||||
|
||||
@@ -205,6 +205,7 @@ class CGraphicBase
|
||||
|
||||
void SetViewport(DWORD dwX, DWORD dwY, DWORD dwWidth, DWORD dwHeight, float fMinZ, float fMaxZ);
|
||||
static void GetBackBufferSize(UINT* puWidth, UINT* puHeight);
|
||||
static void SetBackBufferSize(UINT uWidth, UINT uHeight);
|
||||
static bool IsTLVertexClipping();
|
||||
static bool IsFastTNL();
|
||||
static bool IsLowTextureMemory();
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# ambience_test —— AreaAmbienceData + .pra -> Audio source contract.
|
||||
# godot --headless --path project --script ambience_test.gd
|
||||
extends SceneTree
|
||||
|
||||
var _fail := 0
|
||||
|
||||
func _ck(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
if not ClassDB.class_exists("Metin2World"):
|
||||
printerr("FAIL: Metin2World extension is not registered")
|
||||
quit(1)
|
||||
return
|
||||
var assets := AssetRoot.path()
|
||||
var world: Node = ClassDB.instantiate("Metin2World")
|
||||
world.set("auto_load", false)
|
||||
world.set("assets_root", assets)
|
||||
world.set("map_path", "OutdoorA3/metin2_map_a3")
|
||||
world.set("focus_tile", Vector2i(3, 3))
|
||||
world.set("load_radius_tiles", 0)
|
||||
world.set("splat_enabled", false)
|
||||
world.set("water_enabled", false)
|
||||
world.set("env_enabled", false)
|
||||
get_root().add_child(world)
|
||||
var ok: bool = world.call("load_map")
|
||||
_ck(ok, "A3 map chunk loads")
|
||||
var sources: Array = world.call("get_ambience_sources")
|
||||
_ck(sources.size() == 1, "A3 chunk exposes one ambience source")
|
||||
if sources.size() == 1:
|
||||
var source: Dictionary = sources[0]
|
||||
_ck(int(source.get("range_cm", 0)) == 3300, "ambience range is preserved in cm")
|
||||
_ck(String(source.get("play_type", "")).to_upper() == "LOOP", "ambience play type is LOOP")
|
||||
var sounds: Array = source.get("sounds", [])
|
||||
_ck(sounds.size() == 1 and String(sounds[0]) == "sound/ambience/warp_test.mp3",
|
||||
".pra sound vector is exposed to Audio")
|
||||
world.queue_free()
|
||||
await process_frame
|
||||
if _fail == 0:
|
||||
print("PASS: ambience_test (AreaAmbienceData + .pra + world/audio contract)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
@@ -1 +0,0 @@
|
||||
uid://p7skimuw8kud
|
||||
@@ -1,84 +0,0 @@
|
||||
extends SceneTree
|
||||
|
||||
# Regression for locomotion crossfades: blending world-space joint endpoints
|
||||
# briefly shortened or stretched limbs. Parent/child segment lengths must stay
|
||||
# close to the two endpoint poses while wait/run transitions are in progress.
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
var failures: Array[String] = []
|
||||
var checked_segments := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func _check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failures.append(message)
|
||||
printerr("FAIL: ", message)
|
||||
|
||||
func _pose(anim: Node, skeleton: Skeleton3D) -> Dictionary:
|
||||
var result := {}
|
||||
for bone in skeleton.get_bone_count():
|
||||
var data: Dictionary = anim.call("get_effect_bone_pose", skeleton.get_bone_name(bone))
|
||||
if data.has("transform"):
|
||||
result[bone] = (data["transform"] as Transform3D).origin
|
||||
return result
|
||||
|
||||
func _segments(pose: Dictionary, skeleton: Skeleton3D) -> Dictionary:
|
||||
var result := {}
|
||||
for bone in skeleton.get_bone_count():
|
||||
var parent := skeleton.get_bone_parent(bone)
|
||||
if parent >= 0 and pose.has(bone) and pose.has(parent):
|
||||
var length := (pose[bone] as Vector3).distance_to(pose[parent] as Vector3)
|
||||
if length > 0.01:
|
||||
result[bone] = length
|
||||
return result
|
||||
|
||||
func run() -> void:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", "0")
|
||||
for race in 8:
|
||||
var view := PlayerView.new()
|
||||
root.add_child(view)
|
||||
if not view.build(AssetRoot.path(), race):
|
||||
_check(false, "race %d failed to build" % race)
|
||||
view.queue_free()
|
||||
continue
|
||||
view.model.set("lod_enabled", false)
|
||||
view.anim.set("blend_time", 0.15)
|
||||
await process_frame
|
||||
view.anim.set("time", minf(0.25, float(view.anim.call("get_duration")) * 0.5))
|
||||
var skeleton := view.model.get_node("Skeleton3D") as Skeleton3D
|
||||
var start := _segments(_pose(view.anim, skeleton), skeleton)
|
||||
view.set_anim_state("run")
|
||||
var transition: Array[Dictionary] = []
|
||||
for frame in 12:
|
||||
await process_frame
|
||||
transition.append(_segments(_pose(view.anim, skeleton), skeleton))
|
||||
var finish := _segments(_pose(view.anim, skeleton), skeleton)
|
||||
for bone in start:
|
||||
if not finish.has(bone):
|
||||
continue
|
||||
# Terminal *Nub helpers are zero-area animation handles and carry no
|
||||
# skinned child segment. Some clips intentionally collapse them.
|
||||
if String(skeleton.get_bone_name(bone)).ends_with("Nub"):
|
||||
continue
|
||||
var endpoint_min: float = minf(float(start[bone]), float(finish[bone]))
|
||||
var endpoint_max: float = maxf(float(start[bone]), float(finish[bone]))
|
||||
# Sub-5 cm helpers/finger tips may carry authored local translation
|
||||
# animation and do not explain whole-character deformation. Keep this
|
||||
# gate focused on visible limb and torso segments.
|
||||
if endpoint_min < 5.0:
|
||||
continue
|
||||
for frame in transition.size():
|
||||
if not transition[frame].has(bone):
|
||||
continue
|
||||
var value: float = transition[frame][bone]
|
||||
checked_segments += 1
|
||||
_check(value >= endpoint_min * 0.75 and value <= endpoint_max * 1.25,
|
||||
"race %d bone %s deforms in blend frame %d: %.3f not near %.3f..%.3f" %
|
||||
[race, skeleton.get_bone_name(bone), frame, value, endpoint_min, endpoint_max])
|
||||
view.queue_free()
|
||||
await process_frame
|
||||
_check(checked_segments > 1000, "blend regression did not inspect enough skinned segments")
|
||||
print("animation_blend_test: failures=", failures.size(), " segments=", checked_segments,
|
||||
" (8 races, wait -> run)")
|
||||
quit(0 if failures.is_empty() else 1)
|
||||
@@ -1 +0,0 @@
|
||||
uid://dhtltbx5sioow
|
||||
@@ -1,62 +0,0 @@
|
||||
extends SceneTree
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + message)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
Metin2AnimPlayer.clear_clip_cache()
|
||||
var a := PlayerView.new()
|
||||
var b := PlayerView.new()
|
||||
for view in [a, b]:
|
||||
check(view.build(AssetRoot.path(), 0), "warrior builds")
|
||||
root.add_child(view)
|
||||
view.anim.set_process(false)
|
||||
var initial_paths := {}
|
||||
for view in [a, b]:
|
||||
initial_paths[String(view.anim.get("anim_path"))] = true
|
||||
check(Metin2AnimPlayer.get_clip_cache_stats().misses == initial_paths.size(),
|
||||
"two actors decode only the distinct weighted idle resources")
|
||||
a.anim.set_time(0.3)
|
||||
b.anim.set_time(0.9)
|
||||
check(not is_equal_approx(a.anim.get_time(), b.anim.get_time()), "playback clocks remain independent")
|
||||
# 40250 refuses to interrupt an active one-shot with a locomotion motion, so
|
||||
# repeatedly calling set_anim_state() is not a cache stress test. Switch
|
||||
# the same three registered resources explicitly to exercise the hot reload
|
||||
# path while preserving the actor-level interruption gate.
|
||||
# WAIT is a weighted RaceData vector; asking _motion_file("wait") again
|
||||
# would roll a new resource and make the cache expectation nondeterministic.
|
||||
# Reuse the exact wait path already bound by actor A instead.
|
||||
var hot_paths: Array[String] = [a._motion_file("run"), a._motion_file("attack"),
|
||||
String(a.anim.get("anim_path"))]
|
||||
var expected_paths := {}
|
||||
for view in [a, b]:
|
||||
expected_paths[String(view.anim.get("anim_path"))] = true
|
||||
for path in hot_paths:
|
||||
expected_paths[path] = true
|
||||
for i in 3:
|
||||
for path in hot_paths:
|
||||
for view in [a, b]:
|
||||
view.anim.set("anim_path", path)
|
||||
view.anim.set("playing", true)
|
||||
view.anim.set_time(0.2)
|
||||
var stats: Dictionary = Metin2AnimPlayer.get_clip_cache_stats()
|
||||
var expected_misses := expected_paths.size()
|
||||
check(stats.misses == expected_misses and stats.hits >= 20 - expected_misses,
|
||||
"hot motion switches reuse decoded clips across weighted wait variants")
|
||||
check(stats.entries <= 32 and stats.section_bytes <= 64 * 1024 * 1024, "cache has entry and decoded-section budget")
|
||||
check(stats.msa_misses == expected_misses and stats.msa_hits >= 20 - expected_misses,
|
||||
"hot switches reuse parsed MSA metadata across weighted wait variants")
|
||||
check(stats.msa_entries <= 128, "MSA cache has an entry limit")
|
||||
Metin2AnimPlayer.clear_clip_cache()
|
||||
check(Metin2AnimPlayer.get_clip_cache_stats().msa_entries == 0, "MSA cache clears with clips")
|
||||
a.anim.set_time(0.5)
|
||||
check(a.model.get_visual_aabb().size.length() > 0, "clearing cache does not invalidate active clip")
|
||||
a.free()
|
||||
b.free()
|
||||
await process_frame
|
||||
print("animation_cache_test: failures=%d stats=%s" % [failures, stats])
|
||||
quit(1 if failures else 0)
|
||||
@@ -1 +0,0 @@
|
||||
uid://cmwgj04n10rly
|
||||
@@ -1,567 +0,0 @@
|
||||
# AppFlow (P10) —— 登录 → 选人 → 进游戏 的串场控制器。
|
||||
#
|
||||
# 把它做场景根:一个 M2Client 贯穿全程,LoadingScreen 常驻。
|
||||
# var af := preload("res://app_flow.gd").new()
|
||||
# add_child(af)
|
||||
# af.start(assets_root) # assets_root 传给 game_scene
|
||||
#
|
||||
# 状态机:LOGIN → SELECT → GAME。M2Client 信号驱动切屏。
|
||||
# 断线:所有阶段都回到 LoginPhase,并释放旧 GameScene;LoginPhase 是唯一
|
||||
# 的网络/界面属主。40250 的 OnRemoteDisconnect 不保留游戏窗口重连遮罩。
|
||||
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 GameScene = preload("res://game_scene.gd")
|
||||
const AppLifecycle = preload("res://app_lifecycle.gd")
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
|
||||
enum { LOGIN, SELECT, GAME }
|
||||
|
||||
var client: Node
|
||||
var serverinfo: RefCounted
|
||||
var _state := LOGIN
|
||||
var _assets := ""
|
||||
var _ui: CanvasLayer # 登录 / 选人的临时界面层
|
||||
var _loading: CanvasLayer
|
||||
var _game: Node
|
||||
var _lifecycle: Node # 全流程唯一生命周期协调者
|
||||
var _reconnect: Node
|
||||
var build_game_scene := true # 测试里置 false,只跑状态机不建重场景
|
||||
var _sel_server := 0
|
||||
var _sel_channel := 1
|
||||
var _id := OS.get_environment("MT_ACCOUNT")
|
||||
var _pw := OS.get_environment("MT_PASSWORD")
|
||||
var _chars: Array = []
|
||||
var _selected_char_slot := -1
|
||||
var _reconnecting_game := false
|
||||
var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec)
|
||||
var _probe_in_flight := false
|
||||
var _closing := false
|
||||
# MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。
|
||||
var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1" and not _id.is_empty() and not _pw.is_empty()
|
||||
var _auto_char_slot := -1 # MT_CHAR_SLOT:自动登录时优先选择的角色槽位。
|
||||
|
||||
func _init() -> void:
|
||||
var raw_slot := OS.get_environment("MT_CHAR_SLOT").strip_edges()
|
||||
if raw_slot.is_valid_int():
|
||||
_auto_char_slot = int(raw_slot)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
# CPythonApplication::Destroy releases process-wide model, motion, text/UI
|
||||
# and NPC resource caches after the live scene has been torn down. Keeping
|
||||
# these Ref-backed caches through ObjectDB shutdown causes the same native
|
||||
# leak/SIGSEGV seen in a real map + animated crowd scenario.
|
||||
if ClassDB.class_exists("Metin2Model"):
|
||||
Metin2Model.clear_texture_cache()
|
||||
if ClassDB.class_exists("Metin2AnimPlayer"):
|
||||
Metin2AnimPlayer.clear_clip_cache()
|
||||
MobView.clear_cache()
|
||||
UiAssets.clear_cache()
|
||||
|
||||
func start(assets_root: String = "", injected_client: Node = null) -> void:
|
||||
_assets = assets_root
|
||||
serverinfo = ServerInfoRes.new()
|
||||
# 联网内测的地址覆盖只在 MT_TEST_MODE=playable 生效;父运行器把同一路径交给
|
||||
# 预检,保证预检目标与客户端真实连接目标来自同一个 ServerInfo 解析。
|
||||
var test_serverlist := OS.get_environment("MT_PLAYABLE_SERVERLIST").strip_edges()
|
||||
if OS.get_environment("MT_TEST_MODE") == "playable" and not test_serverlist.is_empty():
|
||||
serverinfo.load_file(test_serverlist)
|
||||
else:
|
||||
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)
|
||||
# §9.3:生命周期属于 AppFlow,而不是会被切屏销毁的 GameScene。
|
||||
# 这样登录、选人、进游戏始终只有一个通知入口;Audio 在 GameScene
|
||||
# 完成装配后再绑定,客户端从 start() 起就已经绑定。
|
||||
_lifecycle = AppLifecycle.new()
|
||||
_lifecycle.name = "AppLifecycle"
|
||||
add_child(_lifecycle)
|
||||
_lifecycle.bind(client)
|
||||
# 40250 WM_CLOSE enters the application exit owner. Keep the Godot
|
||||
# notification on the same AppFlow owner that created the client/session so
|
||||
# disconnect, scene teardown and process exit happen once and in order.
|
||||
_lifecycle.close_requested.connect(_on_close_requested)
|
||||
# 40250 SetLoginPhase replaces the current phase window. Do not attach the
|
||||
# optional ReconnectUI to `disconnected`: doing so would reopen a reconnect
|
||||
# overlay after AppFlow has already become the LoginPhase owner.
|
||||
_goto_login()
|
||||
if auto_login:
|
||||
call_deferred("_do_connect")
|
||||
|
||||
func state() -> int:
|
||||
return _state
|
||||
|
||||
# Playable-test only: choose the server/channel before the deferred autologin
|
||||
# connect runs, and return the exact address _do_connect() will use.
|
||||
func select_test_server(server_index: int, channel: int) -> Dictionary:
|
||||
if OS.get_environment("MT_TEST_MODE") != "playable" or serverinfo == null:
|
||||
return {}
|
||||
if serverinfo.server(server_index).is_empty():
|
||||
return {}
|
||||
_sel_server = server_index
|
||||
_sel_channel = channel
|
||||
return serverinfo.address(_sel_server, _sel_channel)
|
||||
|
||||
# Read-only adapter for the packaged playable test. It deliberately returns
|
||||
# summaries rather than the private UI/game nodes so a report cannot retain a
|
||||
# stale scene reference across a phase or map transition.
|
||||
func get_playable_snapshot() -> Dictionary:
|
||||
var stage := "LOGIN"
|
||||
match _state:
|
||||
SELECT: stage = "SELECT"
|
||||
GAME: stage = "GAME"
|
||||
var selected: Dictionary = {}
|
||||
for character in _chars:
|
||||
if int(character.get("index", -1)) == _selected_char_slot:
|
||||
selected = {"index": _selected_char_slot, "name_present": not String(character.get("name", "")).is_empty()}
|
||||
break
|
||||
var scene_ready := false
|
||||
var map_key := ""
|
||||
if _game != null and is_instance_valid(_game) and _game.has_method("get_playable_context"):
|
||||
var context: Dictionary = _game.get_playable_context()
|
||||
scene_ready = bool(context.get("scene_ready", false))
|
||||
map_key = String(context.get("map_path", ""))
|
||||
return {"stage": stage, "selected_character": selected,
|
||||
"character_count": _chars.size(), "scene_ready": scene_ready,
|
||||
"map_key": map_key, "reconnecting": _reconnecting_game}
|
||||
|
||||
# Internal test adapter: callers must treat returned Node references as
|
||||
# borrowed and reacquire them after every scene/reconnect transition.
|
||||
func get_playable_context() -> Dictionary:
|
||||
if _game == null or not is_instance_valid(_game) or not _game.has_method("get_playable_context"):
|
||||
return {}
|
||||
return _game.get_playable_context()
|
||||
|
||||
# --- client 信号 -------------------------------------------------------------
|
||||
|
||||
func _wire_client() -> void:
|
||||
for sig in ["char_list", "char_name_changed", "entered_game", "disconnected", "login_failed", "connect_failed", "phase_changed", "server_quit_requested"]:
|
||||
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():
|
||||
var idx := int(_chars[0].get("index", 0))
|
||||
if _auto_char_slot >= 0:
|
||||
# An explicit MT_CHAR_SLOT that the account lacks must not silently enter
|
||||
# another character; stay on the select screen instead.
|
||||
idx = -1
|
||||
for character in _chars:
|
||||
if int(character.get("index", -1)) == _auto_char_slot:
|
||||
idx = _auto_char_slot
|
||||
break
|
||||
if idx >= 0:
|
||||
# Keep the selected slot for the ordinary character-select flow.
|
||||
_enter_character(idx)
|
||||
|
||||
func _on_char_name_changed(pid: int, name: String) -> void:
|
||||
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
|
||||
if sc and sc.has_method("on_char_name_changed"):
|
||||
sc.on_char_name_changed(pid, name)
|
||||
|
||||
func _on_entered_game() -> void:
|
||||
_reconnecting_game = false
|
||||
_goto_game()
|
||||
_start_guild_mark_download()
|
||||
|
||||
func _on_phase_changed(phase: String) -> void:
|
||||
# The native classic bridge reports 40250 PHASE_CLOSE as a transition back
|
||||
# to LoginPhase. Explicit PHASE_CLOSE is not a transport disconnect; keep
|
||||
# this phase-only owner path separate from the peer-close `disconnected`
|
||||
# signal so both sources have the same single LoginPhase owner.
|
||||
var p := phase.to_lower()
|
||||
if p not in ["login", "close"]:
|
||||
return
|
||||
if _state != GAME:
|
||||
if _state == SELECT:
|
||||
_goto_login()
|
||||
return
|
||||
if _game == null or not is_instance_valid(_game):
|
||||
_goto_login()
|
||||
return
|
||||
var reason := "服务器返回登录阶段"
|
||||
_on_disconnected(reason)
|
||||
|
||||
# 会徽下载: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:
|
||||
# 40250 intrologin.OnLoginFailure closes only the connecting dialog and
|
||||
# leaves the LoginPhase window alive. Preserve the existing login owner so
|
||||
# its callbacks, entered text and retry controls remain valid. Rebuild only
|
||||
# when the failure arrived from SELECT/another phase that has no login UI.
|
||||
if _state != LOGIN or _ui == null:
|
||||
_goto_login()
|
||||
_focus_login_password()
|
||||
_set_status("登录失败:%s" % reason)
|
||||
|
||||
func _on_connect_failed(reason: String) -> void:
|
||||
# 40250 LoginWindow.OnConnectFailure is separate from OnLoginFailure: keep
|
||||
# the LoginPhase owner and only report transport failure to its status/focus
|
||||
# surface. A game-socket refusal must not be presented as bad credentials.
|
||||
if _state != LOGIN or _ui == null:
|
||||
_goto_login()
|
||||
_focus_login_password()
|
||||
_set_status("连接失败:%s" % reason)
|
||||
|
||||
func _focus_login_password() -> void:
|
||||
if _ui == null:
|
||||
return
|
||||
var password := _ui.find_child("Password", true, false)
|
||||
if password is LineEdit:
|
||||
password.grab_focus()
|
||||
|
||||
func _on_disconnected(reason: String) -> void:
|
||||
# 40250 OnRemoteDisconnect -> SetLoginPhase always replaces the current
|
||||
# phase window. Retaining GameScene or a reconnect overlay here creates a
|
||||
# second owner and diverges from peer-close and explicit PHASE_CLOSE behavior.
|
||||
_reconnecting_game = false
|
||||
_goto_login()
|
||||
_set_status("连接断开:%s" % reason)
|
||||
|
||||
func _on_server_quit_requested() -> void:
|
||||
# 40250 ServerCommand("quit") posts the application quit message. Route it
|
||||
# through the same lifecycle owner as WM_CLOSE so the network is disconnected,
|
||||
# the retained world is reset, and the process exits exactly once.
|
||||
_on_close_requested()
|
||||
|
||||
func _on_close_requested() -> void:
|
||||
if _closing:
|
||||
return
|
||||
_closing = true
|
||||
# Mirror CPythonApplication::Destroy's network-before-window teardown. The
|
||||
# world_reset signal is emitted before the retained GameScene is released.
|
||||
if client and client.has_method("disconnect_from_server"):
|
||||
client.disconnect_from_server()
|
||||
if _lifecycle and is_instance_valid(_lifecycle):
|
||||
_lifecycle.bind(null, null)
|
||||
if _game and is_instance_valid(_game):
|
||||
_game.queue_free()
|
||||
_game = null
|
||||
get_tree().quit()
|
||||
|
||||
# --- 切屏 ------------------------------------------------------------------
|
||||
|
||||
func _clear_ui() -> void:
|
||||
if _ui:
|
||||
_ui.queue_free()
|
||||
_ui = null
|
||||
|
||||
func _goto_login() -> void:
|
||||
_state = LOGIN
|
||||
_reconnecting_game = false
|
||||
if _lifecycle:
|
||||
# GameScene 即将释放,不能让生命周期协调者保留悬空 Audio 引用。
|
||||
_lifecycle.bind(client)
|
||||
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
|
||||
# 40250 IntroLoading 对齐:进入游戏世界前必须确保主角真实模型已构建完成,杜绝任何占位模型闪现
|
||||
if is_instance_valid(_game) and "_model_built" in _game and client != null:
|
||||
var wait_msec := Time.get_ticks_msec()
|
||||
while not _game._model_built and (Time.get_ticks_msec() - wait_msec < 3000):
|
||||
# M2Client._process is the single frame-pump owner, matching
|
||||
# CPythonApplication::Process. Yielding lets that owner deliver
|
||||
# spawn/phase packets without a second same-frame pump here.
|
||||
if _game.has_method("_sync_main_character"):
|
||||
_game._sync_main_character()
|
||||
if _game._model_built:
|
||||
break
|
||||
if is_inside_tree() and get_tree():
|
||||
await get_tree().process_frame
|
||||
if _lifecycle and is_instance_valid(_game) and _game.has_method("lifecycle_audio"):
|
||||
_lifecycle.bind(client, _game.lifecycle_audio())
|
||||
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.name = "Password"
|
||||
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:
|
||||
if _probe_in_flight:
|
||||
return
|
||||
_probe_in_flight = true
|
||||
await _probe_channels_impl()
|
||||
_probe_in_flight = false
|
||||
|
||||
func _probe_channels_impl() -> 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.poll()
|
||||
if peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||
peer.disconnect_from_host()
|
||||
return true
|
||||
StreamPeerTCP.STATUS_ERROR, StreamPeerTCP.STATUS_NONE:
|
||||
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("retry_login"):
|
||||
# M2Client selects the 40250 LoginWindow.Connect retry owner when the
|
||||
# current LoginPhase came from a credential/connection failure, and falls
|
||||
# back to the initial full connect when no retry is pending.
|
||||
client.retry_login(addr["auth_host"], addr["auth_port"],
|
||||
addr["game_host"], addr["game_port"], _id, _pw)
|
||||
elif 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):
|
||||
_enter_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))
|
||||
screen.change_name_requested.connect(func(idx: int, name: String):
|
||||
if not client.has_method("change_name") or not bool(client.call("change_name", idx, name)):
|
||||
if screen.has_method("on_char_name_send_failed"):
|
||||
screen.on_char_name_send_failed())
|
||||
# 重连会重建选人界面再走一遍本函数;ONE_SHOT 只有在信号触发过才自动断开,
|
||||
# 首次连接若还挂着会报 already connected——先查再连。
|
||||
if client.has_signal("guild_marks_ready") \
|
||||
and not client.guild_marks_ready.is_connected(_on_marks_ready_for_select):
|
||||
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()
|
||||
|
||||
func _enter_character(idx: int) -> void:
|
||||
_selected_char_slot = idx
|
||||
if client.has_method("enter_game"):
|
||||
client.enter_game(idx)
|
||||
elif client.has_method("select_character"):
|
||||
client.select_character(idx)
|
||||
@@ -1 +0,0 @@
|
||||
uid://prh3okf1n2lk
|
||||
@@ -1,235 +0,0 @@
|
||||
# app_flow_lifecycle_test —— §9.3 生命周期单一属主回归。
|
||||
# AppFlow 跨登录 / 选人 / 游戏持有唯一 AppLifecycle;GameScene 不重复创建。
|
||||
extends SceneTree
|
||||
|
||||
const AppFlow = preload("res://app_flow.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal phase_changed(phase: String)
|
||||
signal disconnected(reason: String)
|
||||
signal char_list(characters: Array)
|
||||
signal char_name_changed(pid: int, name: String)
|
||||
signal entered_game()
|
||||
signal login_failed(reason: String)
|
||||
signal connect_failed(reason: String)
|
||||
signal server_quit_requested()
|
||||
signal char_created(slot: int)
|
||||
signal char_create_failed(reason: int)
|
||||
signal char_deleted(slot: int)
|
||||
signal char_delete_failed()
|
||||
var selects := []
|
||||
var reconnects := 0
|
||||
var connect_calls := 0
|
||||
var retry_login_calls := 0
|
||||
func select_character(index: int) -> bool:
|
||||
selects.append(index)
|
||||
return true
|
||||
func reconnect() -> bool:
|
||||
reconnects += 1
|
||||
return true
|
||||
func connect_to_server(_auth_host: String, _auth_port: int, _game_host: String,
|
||||
_game_port: int, _id: String, _pw: String) -> void:
|
||||
connect_calls += 1
|
||||
func retry_login(_auth_host: String, _auth_port: int, _game_host: String,
|
||||
_game_port: int, _id: String, _pw: String) -> void:
|
||||
retry_login_calls += 1
|
||||
|
||||
var _fail := 0
|
||||
|
||||
func _ck(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
var client := FakeClient.new()
|
||||
var flow := AppFlow.new()
|
||||
flow.build_game_scene = false
|
||||
get_root().add_child(flow)
|
||||
flow.start("", client)
|
||||
await process_frame
|
||||
|
||||
var lifecycle_nodes: Array[Node] = []
|
||||
for child in flow.get_children():
|
||||
if child.name == "AppLifecycle":
|
||||
lifecycle_nodes.append(child)
|
||||
_ck(lifecycle_nodes.size() == 1, "AppFlow owns exactly one AppLifecycle")
|
||||
_ck(flow.get_node_or_null("AppLifecycle") != null,
|
||||
"AppLifecycle survives login screen construction")
|
||||
_ck(flow._lifecycle.close_requested.is_connected(Callable(flow, "_on_close_requested")),
|
||||
"AppFlow consumes the desktop close request")
|
||||
var flow_file := FileAccess.open("res://app_flow.gd", FileAccess.READ)
|
||||
var flow_source: String = flow_file.get_as_text() if flow_file else ""
|
||||
var game_start: int = flow_source.find("func _goto_game()")
|
||||
var game_end: int = flow_source.find("# --- 登录表单", game_start)
|
||||
var game_body: String = flow_source.substr(game_start, game_end - game_start) if game_start >= 0 and game_end > game_start else ""
|
||||
_ck(not game_body.contains("client.net_poll()"),
|
||||
"AppFlow does not re-enter the M2Client frame pump while waiting for the model")
|
||||
# A 40250 GC_WARP is a replacement transport, not a terminal login error.
|
||||
# When the replacement connect fails synchronously, the native bridge must
|
||||
# retain the reconnect owner before returning to LoginPhase; otherwise the
|
||||
# next reconnect() call sees GameLogin and is incorrectly treated as a
|
||||
# duplicate in-flight request. Keep this source-level guard beside the
|
||||
# phase-only AppFlow behavior test so the native invariant cannot regress
|
||||
# while the Godot fake client still exercises the UI owner.
|
||||
var m2_path := ProjectSettings.globalize_path("res://../extension/src/net/m2_client.cpp")
|
||||
var m2_file := FileAccess.open(m2_path, FileAccess.READ)
|
||||
var m2_source: String = m2_file.get_as_text() if m2_file else ""
|
||||
var first_warp_start: int = m2_source.find("if (!classic_sess->connect_warp(")
|
||||
var first_warp_body: String = m2_source.substr(first_warp_start, 700) \
|
||||
if first_warp_start >= 0 else ""
|
||||
var second_warp_start: int = m2_source.find("if (!classic_sess->connect_warp(", first_warp_start + 1)
|
||||
var second_warp_body: String = m2_source.substr(second_warp_start, 700) \
|
||||
if second_warp_start >= 0 else ""
|
||||
_ck(not first_warp_body.contains("classic_remote_disconnect_pending = true")
|
||||
and not first_warp_body.contains("emit_signal(\"disconnected\""),
|
||||
"fast DirectEnter connect failure stays on the ClosePhase owner")
|
||||
_ck(not second_warp_body.contains("classic_remote_disconnect_pending = true")
|
||||
and not second_warp_body.contains("emit_signal(\"disconnected\""),
|
||||
"GC_WARP connect failure stays on the ClosePhase owner")
|
||||
# 40250 keeps RecvPhasePacket(PHASE_CLOSE) separate from
|
||||
# OnRemoteDisconnect: ClosePhase -> SetLoginPhase is a phase-owner change,
|
||||
# not a transport failure. The native bridge must therefore not synthesize a
|
||||
# disconnected signal or arm the remote-disconnect retry owner here.
|
||||
var phase_close_start: int = m2_source.find("classic_sess->on_phase_close = [this]()")
|
||||
var phase_close_end: int = m2_source.find("classic_sess->on_phase_leave", phase_close_start)
|
||||
var phase_close_body: String = m2_source.substr(phase_close_start,
|
||||
phase_close_end - phase_close_start) if phase_close_start >= 0 and phase_close_end > phase_close_start else ""
|
||||
_ck(not phase_close_body.contains("classic_remote_disconnect_pending = true")
|
||||
and not phase_close_body.contains("emit_signal(\"disconnected\""),
|
||||
"explicit PHASE_CLOSE does not synthesize transport disconnected")
|
||||
# An empty 40250 account is a valid SelectPhase result. The live probe must
|
||||
# be able to verify that packet/UI owner without treating the absence of a
|
||||
# selectable slot as a login failure or trying to enter a stale slot.
|
||||
var e2e_path := ProjectSettings.globalize_path("res://../extension/tools/net_classic_e2e.cpp")
|
||||
var e2e_file := FileAccess.open(e2e_path, FileAccess.READ)
|
||||
var e2e_source: String = e2e_file.get_as_text() if e2e_file else ""
|
||||
_ck(e2e_source.contains("MT_CLASSIC_EXPECT_EMPTY")
|
||||
and e2e_source.contains("PASS EMPTY CHARACTER LIST"),
|
||||
"live probe treats an all-empty character list as a valid SelectPhase result")
|
||||
_ck(client.get_parent() == flow,
|
||||
"AppFlow keeps the same client beside lifecycle coordinator")
|
||||
var login_snapshot: Dictionary = flow.get_playable_snapshot()
|
||||
_ck(login_snapshot.get("stage", "") == "LOGIN" and not login_snapshot.get("scene_ready", true),
|
||||
"playable snapshot is a read-only LOGIN summary")
|
||||
|
||||
# 40250 intrologin.OnLoginFailure closes only the connecting dialog. The
|
||||
# LoginPhase window remains the owner of the retry, including its password
|
||||
# field and status surface; rebuilding the whole layer changes the phase
|
||||
# owner and can strand a pending retry callback on the old UI.
|
||||
var login_ui_before: Node = flow._ui
|
||||
var login_fields: Array[Node] = flow._ui.find_children("*", "LineEdit", true, false)
|
||||
var password_before: Node = login_fields[1] if login_fields.size() > 1 else null
|
||||
client.login_failed.emit("WRONGPWD")
|
||||
await process_frame
|
||||
_ck(flow.state() == AppFlow.LOGIN and flow._ui == login_ui_before,
|
||||
"login failure keeps the existing LoginPhase UI for retry")
|
||||
_ck(password_before != null, "login UI has a password field for retry focus")
|
||||
var login_status := flow._ui.find_child("Status", true, false)
|
||||
_ck(login_status != null and String(login_status.text).contains("WRONGPWD"),
|
||||
"login failure is rendered in the existing status surface")
|
||||
client.connect_failed.emit("ECONNREFUSED")
|
||||
await process_frame
|
||||
_ck(flow.state() == AppFlow.LOGIN and flow._ui == login_ui_before,
|
||||
"game connect failure keeps the existing LoginPhase UI for retry")
|
||||
_ck(login_status != null and String(login_status.text).contains("ECONNREFUSED"),
|
||||
"game connect failure uses the transport status callback, not credential failure")
|
||||
# 40250 LoginWindow.Connect reuses the existing MainStream owner and only
|
||||
# replaces the AccountConnector transport after a credential failure. The
|
||||
# AppFlow retry must therefore use the bridge's retry_login seam instead of
|
||||
# treating a still-owned LoginPhase as a full disconnect/rebuild.
|
||||
flow._do_connect()
|
||||
_ck(client.retry_login_calls == 1 and client.connect_calls == 0,
|
||||
"login form retry replaces only the authentication transport")
|
||||
var retry_start: int = m2_source.find("void M2Client::retry_login")
|
||||
var retry_end: int = m2_source.find("void M2Client::disconnect_from_server", retry_start)
|
||||
var retry_body: String = m2_source.substr(retry_start, retry_end - retry_start) \
|
||||
if retry_start >= 0 and retry_end > retry_start else ""
|
||||
_ck(retry_body.contains("classic_sess->retry_login")
|
||||
and not retry_body.contains("disconnect_from_server()"),
|
||||
"native LoginPhase retry does not rebuild the whole connection owner")
|
||||
|
||||
# 40250 CPythonNetworkStream::OnRemoteDisconnect calls SetLoginPhase.
|
||||
# SetLoginPhase replaces GameWindow with LoginWindow; it does not retain the
|
||||
# GameScene behind a reconnect mask or auto-select a remembered slot.
|
||||
flow._game = Node.new()
|
||||
flow.add_child(flow._game)
|
||||
flow._state = AppFlow.GAME
|
||||
flow._selected_char_slot = 2
|
||||
client.disconnected.emit("wifi lost")
|
||||
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
|
||||
and flow._game == null,
|
||||
"game peer close returns to LoginPhase and releases GameScene")
|
||||
_ck(flow._reconnect == null or not flow._reconnect.is_showing(),
|
||||
"game peer close does not retain a reconnect overlay")
|
||||
client.char_list.emit([{"index": 2, "name": "Hero"}])
|
||||
_ck(flow.state() == AppFlow.SELECT and flow._game == null and client.selects.is_empty(),
|
||||
"post-disconnect character list enters SELECT without phantom re-entry")
|
||||
|
||||
# 40250 PHASE_CLOSE -> ClosePhase -> SetLoginPhase has the same LoginPhase
|
||||
# owner even when the bridge reports only the phase observer event.
|
||||
flow._game = Node.new()
|
||||
flow.add_child(flow._game)
|
||||
flow._state = AppFlow.GAME
|
||||
flow._reconnecting_game = false
|
||||
client.phase_changed.emit("login")
|
||||
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
|
||||
and flow._game == null
|
||||
and (flow._reconnect == null or not flow._reconnect.is_showing()),
|
||||
"game-side PHASE_CLOSE transition returns to LoginPhase owner")
|
||||
# The explicit PHASE_CLOSE spelling must use the same owner path as the
|
||||
# native phase-only login notification.
|
||||
flow._game = Node.new()
|
||||
flow.add_child(flow._game)
|
||||
flow._state = AppFlow.GAME
|
||||
client.phase_changed.emit("close")
|
||||
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
|
||||
and flow._game == null,
|
||||
"explicit PHASE_CLOSE observer releases GameScene for LoginPhase")
|
||||
|
||||
# 40250 SetSelectCharacterPhase still owns the UI when LoginPhase receives an
|
||||
# empty character list. The empty list must remain SELECT and never select a
|
||||
# stale slot.
|
||||
flow._state = AppFlow.LOGIN
|
||||
flow._reconnecting_game = false
|
||||
client.char_list.emit([])
|
||||
_ck(flow.state() == AppFlow.SELECT and not flow._reconnecting_game
|
||||
and flow._game == null and client.selects.is_empty(),
|
||||
"empty character list returns to the SELECT owner")
|
||||
|
||||
# 40250 ClosePhase/SetLoginPhase also owns the UI when a close arrives
|
||||
# before GAME. The phase-only bridge event must not leave the old SELECT
|
||||
# owner visible after the loading mask is dismissed.
|
||||
var select_ui_before_close: Node = flow._ui
|
||||
client.phase_changed.emit("login")
|
||||
_ck(flow.state() == AppFlow.LOGIN and flow._ui != select_ui_before_close,
|
||||
"pre-game LoginPhase transition replaces the SELECT owner")
|
||||
_ck(client.server_quit_requested.is_connected(Callable(flow, "_on_server_quit_requested")),
|
||||
"server quit command is wired to the application lifecycle owner")
|
||||
# Keep the following auto-login checks independent when the assertion above
|
||||
# is intentionally run against a pre-fix implementation.
|
||||
flow._reconnecting_game = false
|
||||
|
||||
|
||||
# Auto-login with an explicit MT_CHAR_SLOT must never fall back to another
|
||||
# character, and the chosen slot must be remembered for a later reconnect.
|
||||
flow._state = AppFlow.LOGIN
|
||||
flow.auto_login = true
|
||||
flow._auto_char_slot = 5
|
||||
flow._selected_char_slot = -1
|
||||
client.selects.clear()
|
||||
client.char_list.emit([{"index": 0, "name": "Other"}])
|
||||
_ck(client.selects.is_empty(), "absent explicit slot does not enter chars[0]")
|
||||
flow._state = AppFlow.LOGIN
|
||||
client.char_list.emit([{"index": 0, "name": "Other"}, {"index": 5, "name": "Fixture"}])
|
||||
_ck(client.selects == [5] and flow._selected_char_slot == 5,
|
||||
"explicit slot is entered and remembered for reconnect")
|
||||
|
||||
flow.queue_free()
|
||||
await process_frame
|
||||
if _fail == 0:
|
||||
print("PASS: app_flow_lifecycle_test (single lifecycle owner)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
@@ -1 +0,0 @@
|
||||
uid://dn635j1v44kp5
|
||||
@@ -1,98 +0,0 @@
|
||||
# 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:
|
||||
_exit_background()
|
||||
focus_changed.emit(true)
|
||||
NOTIFICATION_APPLICATION_FOCUS_OUT, NOTIFICATION_WM_WINDOW_FOCUS_OUT:
|
||||
_enter_background()
|
||||
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
|
||||
# §9.2:不停 BGM,改为「保存音量 + 静音」(对标 CSoundManager::SaveVolume 后
|
||||
# SetMusicVolume(0))。_bgm_name 不清空,回前台恢复音量即可同一首继续播。
|
||||
if _bound_audio and _bound_audio.has_method("save_volume"):
|
||||
_bound_audio.save_volume()
|
||||
if _bound_audio.has_method("set_music_volume"):
|
||||
_bound_audio.set_music_volume(0.0)
|
||||
if _bound_audio.has_method("set_sound_volume"):
|
||||
_bound_audio.set_sound_volume(0.0)
|
||||
elif _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
|
||||
# §9.2:还原后台前保存的音量。
|
||||
if _bound_audio and _bound_audio.has_method("restore_volume"):
|
||||
_bound_audio.restore_volume()
|
||||
if _bound_client and _bound_client.has_method("resume"):
|
||||
_bound_client.resume()
|
||||
resumed.emit()
|
||||
@@ -1 +0,0 @@
|
||||
uid://cwgs1qcu80qfm
|
||||
@@ -1,129 +0,0 @@
|
||||
# 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.add_signal_point(6400, 12800)
|
||||
_ck(atlas.signal_point_count() == 1 and atlas._signal_points[0] == Vector2(6400, 12800),
|
||||
"quest ADDMAPSIGNAL is retained as an Atlas waypoint")
|
||||
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")
|
||||
# 移动端地图用单指平移;第二根手指出现后,当前拖动必须被冻结,不能
|
||||
# 把双指手势继续解释成地图移动或世界点地。
|
||||
atlas._recenter_map()
|
||||
var single_down := InputEventScreenTouch.new()
|
||||
single_down.index = 4
|
||||
single_down.pressed = true
|
||||
single_down.position = Vector2(120, 120)
|
||||
atlas._on_map_input(single_down)
|
||||
var single_drag := InputEventScreenDrag.new()
|
||||
single_drag.index = 4
|
||||
single_drag.position = Vector2(160, 145)
|
||||
single_drag.relative = Vector2(40, 25)
|
||||
atlas._on_map_input(single_drag)
|
||||
_ck(atlas._map.position != atlas._map_origin, "atlas single-finger touch drag moves map")
|
||||
var single_up := InputEventScreenTouch.new()
|
||||
single_up.index = 4
|
||||
single_up.pressed = false
|
||||
single_up.position = single_drag.position
|
||||
atlas._on_map_input(single_up)
|
||||
atlas._recenter_map()
|
||||
var first_down := InputEventScreenTouch.new()
|
||||
first_down.index = 5
|
||||
first_down.pressed = true
|
||||
first_down.position = Vector2(120, 120)
|
||||
atlas._on_map_input(first_down)
|
||||
var second_down := InputEventScreenTouch.new()
|
||||
second_down.index = 6
|
||||
second_down.pressed = true
|
||||
second_down.position = Vector2(300, 120)
|
||||
atlas._on_map_input(second_down)
|
||||
var before_two_finger_drag: Vector2 = atlas._map.position
|
||||
var two_finger_drag := InputEventScreenDrag.new()
|
||||
two_finger_drag.index = 5
|
||||
two_finger_drag.position = Vector2(220, 220)
|
||||
two_finger_drag.relative = Vector2(100, 100)
|
||||
atlas._on_map_input(two_finger_drag)
|
||||
_ck(atlas._map.position == before_two_finger_drag,
|
||||
"two-finger map gesture does not pan from one finger")
|
||||
var first_up := InputEventScreenTouch.new()
|
||||
first_up.index = 5
|
||||
first_up.pressed = false
|
||||
first_up.position = two_finger_drag.position
|
||||
atlas._on_map_input(first_up)
|
||||
var second_up := InputEventScreenTouch.new()
|
||||
second_up.index = 6
|
||||
second_up.pressed = false
|
||||
second_up.position = Vector2(300, 120)
|
||||
atlas._on_map_input(second_up)
|
||||
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.clear_signal_points()
|
||||
_ck(atlas.signal_point_count() == 0, "quest signal clear removes Atlas waypoints")
|
||||
atlas.close()
|
||||
_ck(not atlas.is_open(), "atlas closes")
|
||||
@@ -1 +0,0 @@
|
||||
uid://k51m5hekg71a
|
||||
@@ -1,586 +0,0 @@
|
||||
# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。
|
||||
# 对标客户端 AudioLib:BGM(`bgm/*.mp3`)、UI wav(`**/sound/ui/`)、
|
||||
# 3D wav(`**/sound/<rel>`)。`.mss` 只保存按帧触发的声音文件名,
|
||||
# 在 Godot 侧解析为 60 FPS 的声音实例,再由同一个 Miles 音频入口播放。
|
||||
# 用 preload("res://audio.gd").new() 造一个,add_child,再 setup()。
|
||||
extends Node
|
||||
|
||||
## MilesLib constants (40250/ClientVS22/source/MilesLib/SoundManager.cpp).
|
||||
## The reference client keeps the listener at (0, 0, 0) and submits world
|
||||
## coordinates relative to it, divided by the sound scale.
|
||||
const SOUND_SCALE := 200.0
|
||||
const AMBIENCE_SOUND_SCALE := 1000.0
|
||||
const FADE_SPEED := 0.016
|
||||
const CHARACTER_SOUND_MIN_INTERVAL := 0.3
|
||||
const CHARACTER_SOUND_LIMIT_DISTANCE_SQ := 5000.0 * 5000.0
|
||||
|
||||
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 := ""
|
||||
var sound_scale := SOUND_SCALE
|
||||
var ambience_sound_scale := AMBIENCE_SOUND_SCALE
|
||||
var _listener_world := Vector3.ZERO
|
||||
var _listener_forward := Vector3(0, 0, -1)
|
||||
var _listener_up := Vector3.UP
|
||||
var _spatial_root: Node3D
|
||||
var _listener: AudioListener3D
|
||||
var _next_3d_id := 1
|
||||
var _active_3d := {} # int -> AudioStreamPlayer3D
|
||||
var _character_history := {} # sound path -> last time (seconds)
|
||||
var _mss_cache := {} # mss path + header -> sound instances
|
||||
var _ambience_states := {} # source key -> {id, inside, next_time}
|
||||
|
||||
const SOUND_FPS := 60.0
|
||||
|
||||
# §9.2 前后台音量存/恢复,字段名对齐参考 CSoundManager 的
|
||||
# m_fBackupMusicVolume / m_fBackupSoundVolume(REF/MilesLib/SoundManager.h:31)。
|
||||
var _backup_music_volume := -1.0
|
||||
var _backup_sound_volume := -1.0
|
||||
var _volume_saved := false
|
||||
|
||||
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
|
||||
_sound_roots.clear()
|
||||
# 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)
|
||||
_ensure_spatial_audio()
|
||||
|
||||
func _ensure_spatial_audio() -> void:
|
||||
if _spatial_root and is_instance_valid(_spatial_root):
|
||||
return
|
||||
_spatial_root = Node3D.new()
|
||||
_spatial_root.name = "MilesSpatialAudio"
|
||||
add_child(_spatial_root)
|
||||
_listener = AudioListener3D.new()
|
||||
_listener.name = "MilesListener"
|
||||
_spatial_root.add_child(_listener)
|
||||
_listener.current = true
|
||||
|
||||
func set_listener(world_pos: Vector3, forward := Vector3(0, 0, -1), up := Vector3.UP) -> void:
|
||||
# SetPosition / SetDirection + Update in SoundManager. The Godot listener
|
||||
# stays at the origin; active instances are re-based just like Miles3D.
|
||||
_listener_world = world_pos
|
||||
_listener_forward = forward.normalized() if forward.length_squared() > 0.000001 else Vector3(0, 0, -1)
|
||||
_listener_up = up.normalized() if up.length_squared() > 0.000001 else Vector3.UP
|
||||
if _listener:
|
||||
_listener.position = Vector3.ZERO
|
||||
_listener.look_at(_listener.position + _listener_forward, _listener_up)
|
||||
for id in _active_3d:
|
||||
var pl: AudioStreamPlayer3D = _active_3d[id]
|
||||
if is_instance_valid(pl):
|
||||
var scale: float = float(pl.get_meta("miles_scale", sound_scale))
|
||||
pl.position = (pl.get_meta("miles_world", _listener_world) - _listener_world) * 100.0 / maxf(scale, 0.001)
|
||||
|
||||
func update_listener(world_pos: Vector3, forward := Vector3(0, 0, -1), up := Vector3.UP) -> void:
|
||||
set_listener(world_pos, forward, up)
|
||||
|
||||
func set_sound_scale(value: float) -> void:
|
||||
sound_scale = maxf(value, 0.001)
|
||||
|
||||
func set_ambience_sound_scale(value: float) -> void:
|
||||
ambience_sound_scale = maxf(value, 0.001)
|
||||
|
||||
func get_sound_scale() -> float:
|
||||
return sound_scale
|
||||
|
||||
func get_ambience_sound_scale() -> float:
|
||||
return ambience_sound_scale
|
||||
|
||||
# --- 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 rels := PackedStringArray([rel])
|
||||
# ClientVS22's .mss files store paths with the virtual `sound/` prefix,
|
||||
# while each mounted sound root already points at `<patch>/sound`.
|
||||
if rel.to_lower().begins_with("sound/"):
|
||||
rels.append(rel.substr(6))
|
||||
var cands := PackedStringArray()
|
||||
for candidate: String in rels:
|
||||
cands.append(candidate)
|
||||
if candidate.get_extension().is_empty():
|
||||
cands.append(candidate + ".wav")
|
||||
cands.append(candidate + ".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 ""
|
||||
|
||||
# --- MilesLib .mss sound scripts -----------------------------------------
|
||||
|
||||
# ClientVS22/ MilesLib/Type.cpp::LoadSoundInformationPiece. A sound script
|
||||
# contains `SoundDataCount N` followed by exactly N `SoundData%02d time
|
||||
# "path"` records. The native loader replaces the output vector on every
|
||||
# call and rejects a missing or malformed record; preserve that contract here.
|
||||
func load_sound_information_piece(file_path: String, path_header: String = "") -> Array:
|
||||
var cache_key := file_path + "\n" + path_header
|
||||
if _mss_cache.has(cache_key):
|
||||
return _mss_cache[cache_key].duplicate(true)
|
||||
var empty: Array = []
|
||||
var f := FileAccess.open(file_path, FileAccess.READ)
|
||||
if f == null:
|
||||
_mss_cache[cache_key] = empty
|
||||
return empty
|
||||
var count := -1
|
||||
var records := {}
|
||||
while not f.eof_reached():
|
||||
var tokens := _mss_tokens(f.get_line())
|
||||
if tokens.is_empty():
|
||||
continue
|
||||
var key := String(tokens[0]).to_lower()
|
||||
if key == "sounddatacount":
|
||||
if tokens.size() != 2:
|
||||
_mss_cache[cache_key] = empty
|
||||
return empty
|
||||
count = int(tokens[1])
|
||||
continue
|
||||
if not key.begins_with("sounddata") or key.length() <= 9:
|
||||
continue
|
||||
var index_text := key.substr(9)
|
||||
if not index_text.is_valid_int() or tokens.size() != 3:
|
||||
continue
|
||||
var index := int(index_text)
|
||||
if index < 0:
|
||||
continue
|
||||
records[index] = {"time": float(tokens[1]),
|
||||
"sound": path_header + String(tokens[2])}
|
||||
if count < 0 or count > 4096:
|
||||
_mss_cache[cache_key] = empty
|
||||
return empty
|
||||
var out: Array = []
|
||||
for i in count:
|
||||
if not records.has(i):
|
||||
_mss_cache[cache_key] = empty
|
||||
return empty
|
||||
out.append(records[i])
|
||||
_mss_cache[cache_key] = out.duplicate(true)
|
||||
return out
|
||||
|
||||
# Exact equivalent of NSound::DataToInstance: `DWORD(time / (1/60))`.
|
||||
func data_to_sound_instances(sound_data: Array) -> Array:
|
||||
var out: Array = []
|
||||
for row in sound_data:
|
||||
if not row is Dictionary:
|
||||
continue
|
||||
var time := maxf(0.0, float(row.get("time", 0.0)))
|
||||
out.append({"frame": int(time * SOUND_FPS), "sound": String(row.get("sound", "")),
|
||||
"time": time})
|
||||
return out
|
||||
|
||||
func load_mss_instances(file_path: String, path_header: String = "") -> Array:
|
||||
return data_to_sound_instances(load_sound_information_piece(file_path, path_header))
|
||||
|
||||
# Resolve the virtual `sound/<tail>.mss` generated by RaceMotionData from a
|
||||
# motion .msa path. Sound roots are patch-aware and searched in their setup
|
||||
# order, just like `_find_sound`.
|
||||
func load_mss_for_motion(motion_path: String) -> Array:
|
||||
var p := motion_path.replace("\\", "/")
|
||||
var lower := p.to_lower()
|
||||
var marker := "/ymir work/"
|
||||
var at := lower.find(marker)
|
||||
if at < 0:
|
||||
return []
|
||||
var tail := p.substr(at + marker.length()).get_basename() + ".mss"
|
||||
for root in _sound_roots:
|
||||
var candidate := root.path_join(tail)
|
||||
if FileAccess.file_exists(candidate):
|
||||
return load_mss_instances(candidate)
|
||||
return []
|
||||
|
||||
# Equivalent of CSoundManager::UpdateSoundInstance(fx, fy, fz, frame, ...).
|
||||
# The caller owns the animation frame cursor and calls this once per newly
|
||||
# reached frame, so looping animations naturally replay frame-zero records.
|
||||
func update_sound_instances_3d(fx: float, fy: float, fz: float, frame: int,
|
||||
instances: Array, check_frequency := false) -> int:
|
||||
var played := 0
|
||||
for row in instances:
|
||||
if not row is Dictionary or int(row.get("frame", -1)) != frame:
|
||||
continue
|
||||
var id := play_character_sound_3d(fx, fy, fz, String(row.get("sound", "")), check_frequency)
|
||||
if id >= 0:
|
||||
played += 1
|
||||
return played
|
||||
|
||||
func _mss_tokens(line: String) -> Array:
|
||||
var out: Array = []
|
||||
var word := ""
|
||||
var quoted := false
|
||||
for i in line.length():
|
||||
var c := line.substr(i, 1)
|
||||
if c == "\"":
|
||||
quoted = not quoted
|
||||
continue
|
||||
if not quoted and (c == " " or c == "\t" or c == "\r"):
|
||||
if word != "":
|
||||
out.append(word)
|
||||
word = ""
|
||||
continue
|
||||
word += c
|
||||
if word != "":
|
||||
out.append(word)
|
||||
return out
|
||||
|
||||
# --- 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)
|
||||
|
||||
# 立即把 master_bgm 应用到当前正在播放的 BGM 声道(无淡入)。
|
||||
# 对标 CSoundManager::SetMusicVolume:改的是「当前音乐实例」的音量。
|
||||
func set_music_volume(v: float) -> void:
|
||||
master_bgm = clampf(v, 0.0, 1.0)
|
||||
var pl: AudioStreamPlayer = _bgm[_bgm_cur]
|
||||
if pl and pl.playing:
|
||||
pl.volume_db = linear_to_db(master_bgm) if master_bgm > 0.0 else -80.0
|
||||
|
||||
static func ratio_to_apply_volume(ratio: float) -> float:
|
||||
# CSoundManager::__ConvertRatioVolumeToApplyVolume.
|
||||
ratio = clampf(ratio, 0.0, 1.0)
|
||||
return ratio if ratio < 0.1 else pow(10.0, -1.0 + ratio)
|
||||
|
||||
static func grade_to_apply_volume(grade: int) -> float:
|
||||
return ratio_to_apply_volume(float(grade) / 5.0)
|
||||
|
||||
func set_sound_volume(v: float) -> void:
|
||||
master_sfx = clampf(v, 0.0, 1.0)
|
||||
_apply_3d_volume()
|
||||
|
||||
func set_sound_volume_ratio(ratio: float) -> void:
|
||||
set_sound_volume(ratio_to_apply_volume(ratio))
|
||||
|
||||
func set_sound_volume_grade(grade: int) -> void:
|
||||
set_sound_volume(grade_to_apply_volume(grade))
|
||||
|
||||
func set_music_volume_ratio(ratio: float) -> void:
|
||||
set_music_volume(ratio_to_apply_volume(ratio))
|
||||
|
||||
func set_music_volume_grade(grade: int) -> void:
|
||||
set_music_volume(ratio_to_apply_volume(float(grade) / 5.0))
|
||||
|
||||
# §9.2:进入后台前保存音量(CSoundManager::SaveVolume)。
|
||||
func save_volume() -> void:
|
||||
_backup_music_volume = master_bgm
|
||||
_backup_sound_volume = master_sfx
|
||||
_volume_saved = true
|
||||
|
||||
# §9.2:回到前台后恢复音量(CSoundManager::RestoreVolume)。
|
||||
# 不碰 _bgm_name / _bgm[],同一首继续播,只把音量拉回来。
|
||||
func restore_volume() -> void:
|
||||
if not _volume_saved:
|
||||
return
|
||||
master_bgm = _backup_music_volume
|
||||
master_sfx = _backup_sound_volume
|
||||
_volume_saved = false
|
||||
var pl: AudioStreamPlayer = _bgm[_bgm_cur]
|
||||
if pl and pl.playing:
|
||||
pl.volume_db = linear_to_db(master_bgm) if master_bgm > 0.0 else -80.0
|
||||
|
||||
# --- 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:
|
||||
play_sound_3d(world_pos.x, world_pos.y, world_pos.z, rel, 1)
|
||||
|
||||
func play_sound_3d(fx: float, fy: float, fz: float, rel: String, play_count := 1) -> int:
|
||||
return _play_3d(rel, Vector3(fx, fy, fz), sound_scale, play_count)
|
||||
|
||||
func play_ambience_sound_3d(fx: float, fy: float, fz: float, rel: String, play_count := 1) -> int:
|
||||
return _play_3d(rel, Vector3(fx, fy, fz), ambience_sound_scale, play_count)
|
||||
|
||||
func update_ambience_sources(sources: Array) -> void:
|
||||
# CArea::UpdateAroundAmbience: source positions/ranges are in centimetres
|
||||
# on the reference side. The world adapter exposes metres, so convert the
|
||||
# listener distance back to centimetres before applying the strict range.
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
var seen := {}
|
||||
for i in sources.size():
|
||||
var source: Dictionary = sources[i] if sources[i] is Dictionary else {}
|
||||
var key := String(source.get("key", i))
|
||||
seen[key] = true
|
||||
var sounds: Array = source.get("sounds", [])
|
||||
if sounds.is_empty():
|
||||
continue
|
||||
var pos: Vector3 = source.get("position", Vector3.ZERO)
|
||||
var range_cm := maxf(0.0, float(source.get("range_cm", 0.0)))
|
||||
var inside := (pos - _listener_world).length() * 100.0 < range_cm
|
||||
var state: Dictionary = _ambience_states.get(key, {"id": -1, "inside": false, "next_time": 0.0})
|
||||
var sound := String(sounds[0])
|
||||
var play_type := String(source.get("play_type", "LOOP")).to_upper()
|
||||
var id := int(state.get("id", -1))
|
||||
if play_type == "LOOP":
|
||||
if inside:
|
||||
if id < 0 or not _active_3d.has(id):
|
||||
id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 0)
|
||||
if id >= 0:
|
||||
var percentage := clampf(float(source.get("max_volume_area_percentage", 0.0)), 0.0, 1.0)
|
||||
var full_volume_radius := range_cm * percentage
|
||||
var distance_cm := (pos - _listener_world).length() * 100.0
|
||||
var volume := 1.0 if full_volume_radius <= 0.0 or distance_cm <= full_volume_radius else \
|
||||
1.0 - ((distance_cm - full_volume_radius) / maxf(range_cm - full_volume_radius, 0.001))
|
||||
set_sound_volume_3d(id, clampf(volume, 0.0, 1.0))
|
||||
else:
|
||||
if id >= 0:
|
||||
stop_sound_3d(id)
|
||||
id = -1
|
||||
elif play_type == "ONCE":
|
||||
if inside and not bool(state.get("inside", false)):
|
||||
id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 1)
|
||||
elif not inside:
|
||||
# ClientVS22 drops the bookkeeping id on exit; the one-shot itself
|
||||
# is allowed to finish playing.
|
||||
id = -1
|
||||
elif play_type == "STEP":
|
||||
if inside and now >= float(state.get("next_time", 0.0)):
|
||||
id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 1)
|
||||
var interval := maxf(0.0, float(source.get("play_interval", 0.0)))
|
||||
var variation := maxf(0.0, float(source.get("play_interval_variation", 0.0)))
|
||||
state["next_time"] = now + interval + randf_range(0.0, variation)
|
||||
elif not inside:
|
||||
id = -1
|
||||
state["id"] = id
|
||||
state["inside"] = inside
|
||||
_ambience_states[key] = state
|
||||
for key in _ambience_states.keys().duplicate():
|
||||
if seen.has(key):
|
||||
continue
|
||||
var old: Dictionary = _ambience_states[key]
|
||||
var old_id := int(old.get("id", -1))
|
||||
if old_id >= 0:
|
||||
stop_sound_3d(old_id)
|
||||
_ambience_states.erase(key)
|
||||
|
||||
func can_play_character_sound(rel: String, world_pos: Vector3, now := -1.0) -> bool:
|
||||
# PlayCharacterSound3D's optional bCheckFrequency path. Coordinates in the
|
||||
# game scene are metres; convert to centimetres before applying the exact
|
||||
# 5000*5000 ClientVS22 threshold.
|
||||
var delta_cm := (world_pos - _listener_world) * 100.0
|
||||
if delta_cm.x * delta_cm.x + delta_cm.y * delta_cm.y > CHARACTER_SOUND_LIMIT_DISTANCE_SQ:
|
||||
return false
|
||||
if now < 0.0:
|
||||
now = Time.get_ticks_msec() / 1000.0
|
||||
var key := rel.replace("\\", "/")
|
||||
if _character_history.has(key) and now - float(_character_history[key]) < CHARACTER_SOUND_MIN_INTERVAL:
|
||||
return false
|
||||
_character_history[key] = now
|
||||
return true
|
||||
|
||||
func play_character_sound_3d(fx: float, fy: float, fz: float, rel: String, check_frequency := false) -> int:
|
||||
var p := Vector3(fx, fy, fz)
|
||||
if check_frequency and not can_play_character_sound(rel, p):
|
||||
return -1
|
||||
return _play_3d(rel, p, sound_scale, 1)
|
||||
|
||||
func _play_3d(rel: String, world_pos: Vector3, scale: float, play_count: int) -> int:
|
||||
if master_sfx <= 0.0:
|
||||
return -1
|
||||
var path := _find_sound(rel)
|
||||
if path.is_empty():
|
||||
return -1
|
||||
var s := _load_stream(path)
|
||||
if s == null:
|
||||
return -1
|
||||
_ensure_spatial_audio()
|
||||
var pl := AudioStreamPlayer3D.new()
|
||||
pl.stream = s
|
||||
pl.unit_size = 6.0
|
||||
pl.max_distance = 60.0
|
||||
pl.volume_db = _volume_db(master_sfx)
|
||||
_spatial_root.add_child(pl)
|
||||
pl.set_meta("miles_world", world_pos)
|
||||
pl.set_meta("miles_scale", scale)
|
||||
pl.position = (world_pos - _listener_world) * 100.0 / maxf(scale, 0.001)
|
||||
var id := _next_3d_id
|
||||
_next_3d_id += 1
|
||||
_active_3d[id] = pl
|
||||
pl.set_meta("miles_remaining", maxi(play_count, 1))
|
||||
pl.set_meta("miles_loop", play_count == 0)
|
||||
pl.finished.connect(func():
|
||||
if not is_instance_valid(pl):
|
||||
return
|
||||
if bool(pl.get_meta("miles_loop", false)):
|
||||
pl.play()
|
||||
return
|
||||
var remaining := int(pl.get_meta("miles_remaining", 1)) - 1
|
||||
if remaining > 0:
|
||||
pl.set_meta("miles_remaining", remaining)
|
||||
pl.play()
|
||||
else:
|
||||
_active_3d.erase(id)
|
||||
pl.queue_free())
|
||||
if pl.is_inside_tree():
|
||||
pl.play()
|
||||
else:
|
||||
pl.call_deferred("play")
|
||||
return id
|
||||
|
||||
func _volume_db(value: float) -> float:
|
||||
return linear_to_db(maxf(value, 0.0001)) if value > 0.0 else -80.0
|
||||
|
||||
func _apply_3d_volume() -> void:
|
||||
for id in _active_3d:
|
||||
var pl: AudioStreamPlayer3D = _active_3d[id]
|
||||
if is_instance_valid(pl):
|
||||
pl.volume_db = _volume_db(master_sfx)
|
||||
|
||||
func set_sound_volume_3d(id: int, value: float) -> void:
|
||||
var pl: AudioStreamPlayer3D = _active_3d.get(id, null)
|
||||
if pl and is_instance_valid(pl):
|
||||
pl.volume_db = _volume_db(clampf(value, 0.0, 1.0))
|
||||
|
||||
func stop_sound_3d(id: int) -> void:
|
||||
var pl: AudioStreamPlayer3D = _active_3d.get(id, null)
|
||||
_active_3d.erase(id)
|
||||
if pl and is_instance_valid(pl):
|
||||
pl.stop()
|
||||
pl.queue_free()
|
||||
|
||||
func stop_all_sound_3d() -> void:
|
||||
for id in _active_3d.keys().duplicate():
|
||||
stop_sound_3d(int(id))
|
||||
_character_history.clear()
|
||||
_ambience_states.clear()
|
||||
|
||||
# ClientVS22 CSoundManager::Destroy equivalent. AudioStream resources are
|
||||
# cached independently of the player nodes, so freeing the scene alone can
|
||||
# leave MP3 streams/playbacks alive until ObjectDB shutdown.
|
||||
func shutdown() -> void:
|
||||
stop_all_sound_3d()
|
||||
for pl in _bgm:
|
||||
if pl and is_instance_valid(pl):
|
||||
pl.stop()
|
||||
pl.stream = null
|
||||
for pl in _ui_pool:
|
||||
if pl and is_instance_valid(pl):
|
||||
pl.stop()
|
||||
pl.stream = null
|
||||
_cache.clear()
|
||||
_mss_cache.clear()
|
||||
_queued_bgm = ""
|
||||
_bgm_name = ""
|
||||
|
||||
func fade_limit_out_music(name: String, limit_volume: float, speed := FADE_SPEED) -> void:
|
||||
if name != _bgm_name:
|
||||
return
|
||||
var pl: AudioStreamPlayer = _bgm[_bgm_cur]
|
||||
if pl == null or not pl.playing:
|
||||
return
|
||||
var limit_db := _volume_db(ratio_to_apply_volume(limit_volume))
|
||||
var duration := maxf(absf(pl.volume_db - limit_db) / maxf(speed * 60.0, 0.001), 0.01)
|
||||
_fade(pl, limit_db, duration)
|
||||
|
||||
func fade_out_all_music(speed := 0.01) -> void:
|
||||
var duration := maxf(1.0 / maxf(speed * 60.0, 0.001), 0.01)
|
||||
_fade(_bgm[0], -80.0, duration, true)
|
||||
_fade(_bgm[1], -80.0, duration, true)
|
||||
@@ -1 +0,0 @@
|
||||
uid://ke6yfuy7eum2
|
||||
@@ -1,88 +0,0 @@
|
||||
# audio_miles_test —— MilesLib SoundManager 对齐回归。
|
||||
# 验证 40250 的缩放、音量曲线和角色音效限频/距离规则。
|
||||
extends SceneTree
|
||||
|
||||
const Audio = preload("res://audio.gd")
|
||||
|
||||
var _fail := 0
|
||||
|
||||
func _ck(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
var audio := Audio.new()
|
||||
get_root().add_child(audio)
|
||||
|
||||
_ck(is_equal_approx(Audio.SOUND_SCALE, 200.0), "sound scale is 200")
|
||||
_ck(is_equal_approx(Audio.AMBIENCE_SOUND_SCALE, 1000.0), "ambience scale is 1000")
|
||||
_ck(is_equal_approx(Audio.ratio_to_apply_volume(0.05), 0.05), "low ratio stays linear")
|
||||
_ck(is_equal_approx(Audio.ratio_to_apply_volume(0.5), pow(10.0, -0.5)),
|
||||
"ratio volume uses ClientVS22 logarithmic curve")
|
||||
_ck(is_equal_approx(Audio.grade_to_apply_volume(5), 1.0), "grade 5 maps to full volume")
|
||||
_ck(is_equal_approx(Audio.grade_to_apply_volume(0), 0.0), "grade 0 maps to silence")
|
||||
|
||||
audio.set_sound_scale(320.0)
|
||||
audio.set_ambience_sound_scale(1200.0)
|
||||
_ck(is_equal_approx(audio.get_sound_scale(), 320.0), "sound scale setter")
|
||||
_ck(is_equal_approx(audio.get_ambience_sound_scale(), 1200.0), "ambience scale setter")
|
||||
audio.set_listener(Vector3.ZERO, Vector3(0, 0, -1), Vector3.UP)
|
||||
var assets := AssetRoot.path()
|
||||
if DirAccess.dir_exists_absolute(assets):
|
||||
audio.setup(assets)
|
||||
var mss := assets.path_join("sound_m/sound/pc/warrior/general/run.mss")
|
||||
if FileAccess.file_exists(mss):
|
||||
var data: Array = audio.load_sound_information_piece(mss)
|
||||
_ck(data.size() == 3, ".mss SoundDataCount and records parse")
|
||||
_ck(is_equal_approx(float(data[0]["time"]), 0.759), ".mss preserves event time")
|
||||
_ck(String(data[0]["sound"]) == "sound/common/walk_dirt_n.wav",
|
||||
".mss preserves virtual sound path")
|
||||
var instances: Array = audio.data_to_sound_instances(data)
|
||||
_ck(int(instances[0]["frame"]) == 45 and int(instances[2]["frame"]) == 11,
|
||||
".mss time converts to DWORD 60 FPS frames")
|
||||
_ck(audio._find_sound(String(data[0]["sound"])) != "",
|
||||
"virtual sound/ prefix resolves inside mounted sound root")
|
||||
var motion := assets.path_join("PC/ymir work/pc/warrior/general/run.msa")
|
||||
var motion_instances: Array = audio.load_mss_for_motion(motion)
|
||||
_ck(motion_instances.size() == 3, "motion .msa resolves paired .mss")
|
||||
var ambience := [{
|
||||
"key": "waterfall-test",
|
||||
"position": Vector3(20, 0, 0),
|
||||
"range_cm": 3300,
|
||||
"max_volume_area_percentage": 0.5,
|
||||
"play_type": "LOOP",
|
||||
"sounds": ["sound/ambience/warp_test.mp3"],
|
||||
}]
|
||||
audio.update_ambience_sources(ambience)
|
||||
var ambience_state: Dictionary = audio._ambience_states.get("waterfall-test", {})
|
||||
var ambience_id := int(ambience_state.get("id", -1))
|
||||
_ck(ambience_id >= 0 and audio._active_3d.has(ambience_id),
|
||||
"area ambience LOOP creates a Miles 3D instance")
|
||||
if ambience_id >= 0 and audio._active_3d.has(ambience_id):
|
||||
var ambience_player: AudioStreamPlayer3D = audio._active_3d[ambience_id]
|
||||
_ck(abs(ambience_player.position.x - (20.0 * 100.0 / 1200.0)) < 0.001,
|
||||
"ambience source uses cm -> ambience scale conversion")
|
||||
audio.update_ambience_sources([])
|
||||
_ck(not audio._ambience_states.has("waterfall-test") and not audio._active_3d.has(ambience_id),
|
||||
"leaving / unloading area ambience stops its loop")
|
||||
_ck(audio.can_play_character_sound("step.wav", Vector3(1, 0, 0), 10.0),
|
||||
"near character sound is allowed")
|
||||
_ck(not audio.can_play_character_sound("step.wav", Vector3(1, 0, 0), 10.2),
|
||||
"same character sound is suppressed within 0.3 seconds")
|
||||
_ck(audio.can_play_character_sound("step.wav", Vector3(1, 0, 0), 10.31),
|
||||
"same character sound resumes after 0.3 seconds")
|
||||
_ck(not audio.can_play_character_sound("far.wav", Vector3(51, 0, 0), 20.0),
|
||||
"character sound beyond 5000cm is suppressed")
|
||||
|
||||
audio.set_sound_volume(0.0)
|
||||
_ck(is_equal_approx(audio.master_sfx, 0.0), "sound volume setter accepts silence")
|
||||
audio.stop_all_sound_3d()
|
||||
audio.free()
|
||||
|
||||
if _fail == 0:
|
||||
print("PASS: audio_miles_test (scale + volume curve + character sound guard)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
@@ -1 +0,0 @@
|
||||
uid://ctbdjmg33pe67
|
||||
@@ -1,66 +0,0 @@
|
||||
# auth_connect_bridge_test —— 40250 AccountConnector OnConnectFailure 的
|
||||
# M2Client bridge 回归。运行时需要 MT_PROTOCOL=classic。
|
||||
extends SceneTree
|
||||
|
||||
var _fail := 0
|
||||
var _login_failures := 0
|
||||
var _connect_failures := 0
|
||||
|
||||
func _ck(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
if not ClassDB.class_exists("M2Client"):
|
||||
_ck(false, "M2Client extension is registered")
|
||||
_finish()
|
||||
return
|
||||
var client: Node = ClassDB.instantiate("M2Client")
|
||||
get_root().add_child(client)
|
||||
client.connect("login_failed", Callable(self, "_on_login_failure"))
|
||||
if client.has_signal("connect_failed"):
|
||||
client.connect("connect_failed", Callable(self, "_on_connect_failure"))
|
||||
else:
|
||||
_ck(false, "M2Client exposes connect_failed")
|
||||
|
||||
client.connect_to_server("this-auth-host-must-not-resolve.invalid", 1,
|
||||
"127.0.0.1", 1, "admin", "secret")
|
||||
_ck(_connect_failures == 1,
|
||||
"initial auth connect failure reaches the distinct OnConnectFailure bridge")
|
||||
_ck(_login_failures == 0,
|
||||
"initial auth connect failure is not reported as credential login failure")
|
||||
_ck(String(client.get_stage()) != "failed",
|
||||
"initial auth connect failure does not enter terminal Failed stage")
|
||||
|
||||
# LoginWindow.Connect must be able to reuse the retained ClassicSession owner
|
||||
# after the first failure, instead of falling back to a newly destroyed
|
||||
# connection owner.
|
||||
client.retry_login("another-auth-host-must-not-resolve.invalid", 1,
|
||||
"127.0.0.1", 1, "admin", "secret")
|
||||
_ck(_connect_failures == 2,
|
||||
"retry_login replaces only the auth transport after auth connect failure")
|
||||
_ck(_login_failures == 0,
|
||||
"auth connect retry remains separate from credential login failure")
|
||||
_ck(String(client.get_stage()) != "failed",
|
||||
"auth connect retry does not enter terminal Failed stage")
|
||||
|
||||
client.disconnect_from_server()
|
||||
client.queue_free()
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
if _fail == 0:
|
||||
print("PASS: auth_connect_bridge_test")
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(0 if _fail == 0 else 1)
|
||||
|
||||
func _on_login_failure(_reason: String) -> void:
|
||||
_login_failures += 1
|
||||
|
||||
func _on_connect_failure(_reason: String) -> void:
|
||||
_connect_failures += 1
|
||||
@@ -1 +0,0 @@
|
||||
uid://bdcaheq2nmoqg
|
||||
@@ -1,105 +0,0 @@
|
||||
# auth_peer_close_bridge_test —— 40250 CAccountConnector::OnRemoteDisconnect
|
||||
# 到 M2Client LoginPhase owner 的回归。运行时需要 MT_PROTOCOL=classic。
|
||||
extends SceneTree
|
||||
|
||||
var _fail := 0
|
||||
var _login_failures := 0
|
||||
var _connect_failures := 0
|
||||
|
||||
func _ck(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
if not ClassDB.class_exists("M2Client"):
|
||||
_ck(false, "M2Client extension is registered")
|
||||
_finish()
|
||||
return
|
||||
|
||||
var server := TCPServer.new()
|
||||
var listen_error := server.listen(0, "127.0.0.1")
|
||||
_ck(listen_error == OK, "local auth peer-close fixture listens")
|
||||
if listen_error != OK:
|
||||
_finish()
|
||||
return
|
||||
|
||||
var client: Node = ClassDB.instantiate("M2Client")
|
||||
get_root().add_child(client)
|
||||
client.connect("login_failed", Callable(self, "_on_login_failure"))
|
||||
if client.has_signal("connect_failed"):
|
||||
client.connect("connect_failed", Callable(self, "_on_connect_failure"))
|
||||
else:
|
||||
_ck(false, "M2Client exposes connect_failed")
|
||||
|
||||
client.connect_to_server("127.0.0.1", server.get_local_port(),
|
||||
"127.0.0.1", 1, "admin", "secret")
|
||||
for _i in range(30):
|
||||
if server.is_connection_available():
|
||||
break
|
||||
await process_frame
|
||||
_ck(server.is_connection_available(), "auth peer-close fixture accepts the connection")
|
||||
if server.is_connection_available():
|
||||
var peer := server.take_connection()
|
||||
_ck(peer != null, "auth peer-close fixture gets the accepted peer")
|
||||
if peer != null:
|
||||
peer.disconnect_from_host()
|
||||
|
||||
# Let M2Client observe EOF and run the same pump that the real LoginWindow
|
||||
# receives after CAccountConnector::OnRemoteDisconnect.
|
||||
for _i in range(30):
|
||||
await process_frame
|
||||
|
||||
_ck(_login_failures == 0,
|
||||
"auth peer close is not reported as credential login failure")
|
||||
_ck(_connect_failures == 0,
|
||||
"auth peer close is not reported as connect failure")
|
||||
_ck(String(client.get_stage()) != "failed",
|
||||
"auth peer close retains a non-terminal LoginPhase owner")
|
||||
|
||||
# The Offline owner must remain usable: a second LoginWindow.Connect replaces
|
||||
# only the auth transport, then the same peer-close rule applies again.
|
||||
var retry_server := TCPServer.new()
|
||||
var retry_error := retry_server.listen(0, "127.0.0.1")
|
||||
_ck(retry_error == OK, "auth peer-close retry fixture listens")
|
||||
if retry_error == OK:
|
||||
client.retry_login("127.0.0.1", retry_server.get_local_port(),
|
||||
"127.0.0.1", 1, "admin", "secret")
|
||||
for _i in range(30):
|
||||
if retry_server.is_connection_available():
|
||||
break
|
||||
await process_frame
|
||||
_ck(retry_server.is_connection_available(),
|
||||
"auth peer-close retry replaces the old auth transport")
|
||||
if retry_server.is_connection_available():
|
||||
var retry_peer := retry_server.take_connection()
|
||||
if retry_peer != null:
|
||||
retry_peer.disconnect_from_host()
|
||||
for _i in range(30):
|
||||
await process_frame
|
||||
_ck(_login_failures == 0 and _connect_failures == 0,
|
||||
"auth peer-close retry remains separate from both failure callbacks")
|
||||
_ck(String(client.get_stage()) != "failed",
|
||||
"auth peer-close retry keeps the LoginPhase owner non-terminal")
|
||||
|
||||
client.disconnect_from_server()
|
||||
client.queue_free()
|
||||
server.stop()
|
||||
retry_server.stop()
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
if _fail == 0:
|
||||
print("PASS: auth_peer_close_bridge_test")
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(0 if _fail == 0 else 1)
|
||||
|
||||
func _on_login_failure(_reason: String) -> void:
|
||||
_login_failures += 1
|
||||
|
||||
func _on_connect_failure(_reason: String) -> void:
|
||||
_connect_failures += 1
|
||||
@@ -1 +0,0 @@
|
||||
uid://8alojuolcg5f
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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 +0,0 @@
|
||||
uid://dyvg0v8ntldha
|
||||
@@ -1,81 +0,0 @@
|
||||
# BgmDirector —— 把 M2Client.bgm_changed 信号翻译成 Audio 的 BGM 播放。
|
||||
#
|
||||
# 存在的理由:§9.1 的地图 BGM 消费点,从 net_world.gd 里拆出来,避免 W2(可见性)
|
||||
# 和 W4(音频)同时改 net_world.gd。地图 → 曲目解析和音量应用在这里(§9.1)。
|
||||
#
|
||||
# 选曲逻辑对标 game.py:190(GameWindow.Open 内):
|
||||
# snd.SetMusicVolume(...); snd.SetSoundVolume(...) # 先应用音量
|
||||
# name = net.GetFieldMusicFileName()
|
||||
# if name: snd.FadeInMusic("BGM/" + name)
|
||||
# elif musicInfo.fieldMusic: snd.FadeInMusic("BGM/" + musicInfo.fieldMusic)
|
||||
# 默认音量 gs_fieldMusic_volume = 1.0f/5.0f*0.1f = 0.02f(服务器未下发时)。
|
||||
#
|
||||
# 用法:
|
||||
# var bd := preload("res://bgm_director.gd").new()
|
||||
# add_child(bd)
|
||||
# bd.bind(client, audio)
|
||||
extends Node
|
||||
|
||||
const MusicInfo = preload("res://musicinfo.gd")
|
||||
|
||||
# gs_fieldMusic_volume 默认值(PythonNetworkStreamPhaseLoading.cpp:299)。
|
||||
const DEFAULT_FIELD_MUSIC_VOLUME := 1.0 / 5.0 * 0.1 # = 0.02
|
||||
|
||||
var _client: Node
|
||||
var _audio: Node
|
||||
|
||||
# 服务器最近一次下发的 BGM(曲目 id 或路径)和音量(<0 表示未指定)。
|
||||
var current_track := ""
|
||||
var current_volume := -1.0
|
||||
|
||||
func bind(client: Node, audio: Node) -> void:
|
||||
_client = client
|
||||
_audio = audio
|
||||
if _client and _client.has_signal("bgm_changed"):
|
||||
_client.bgm_changed.connect(_on_bgm_changed)
|
||||
# 换图(§1.3):world_reset 后重跑选曲逻辑。新图的 BGM 包到达前沿用当前曲目。
|
||||
if _client and _client.has_signal("world_reset"):
|
||||
_client.world_reset.connect(_on_world_reset)
|
||||
|
||||
func _on_bgm_changed(name: String, volume: float) -> void:
|
||||
# M2Client 已在 EntityStore 侧做音量保留(BGM3 只带名、BGM4 带名+音量),
|
||||
# 这里拿到的 volume 就是「有效音量」,<0 仅表示从未收到过 BGM4。
|
||||
current_track = name
|
||||
current_volume = volume
|
||||
_select()
|
||||
|
||||
func _on_world_reset() -> void:
|
||||
_select()
|
||||
|
||||
# game.py:190 的三行选曲 + 先置音量。
|
||||
func _select() -> void:
|
||||
if _audio == null:
|
||||
return
|
||||
|
||||
# 1) 选曲:服务端名优先,否则回落 musicInfo.fieldMusic(METIN2THEMA = "M2BG.mp3")。
|
||||
var track := current_track
|
||||
if track == "":
|
||||
track = String(MusicInfo.fieldMusic)
|
||||
|
||||
# 2) 音量:服务端未指定(<0)用默认 0.02,否则用服务端值。
|
||||
var vol := current_volume if current_volume >= 0.0 else DEFAULT_FIELD_MUSIC_VOLUME
|
||||
vol = clampf(vol, 0.0, 1.0)
|
||||
|
||||
# 3) 先应用音量再淡入:Audio.play_bgm() 的淡入目标是 linear_to_db(master_bgm),
|
||||
# 对标 game.py 先 SetMusicVolume/SetSoundVolume 再 FadeInMusic。
|
||||
# 声音(SFX)音量在参考里取自系统设置(systemSetting.GetSoundVolume),
|
||||
# 不随 BGM 包下发,这里不改 master_sfx。
|
||||
if "master_bgm" in _audio:
|
||||
_audio.master_bgm = vol
|
||||
if _audio.has_method("set_music_volume"):
|
||||
_audio.set_music_volume(vol)
|
||||
|
||||
if track == "":
|
||||
if _audio.has_method("stop_bgm"):
|
||||
_audio.stop_bgm()
|
||||
return
|
||||
|
||||
if _audio.has_method("play_bgm"):
|
||||
_audio.play_bgm(track)
|
||||
# musicInfo.SaveLastPlayFieldMusic 语义:记录本次战场曲目。
|
||||
MusicInfo.save_last_play_field_music(track)
|
||||
@@ -1 +0,0 @@
|
||||
uid://yglonaagb03t
|
||||
@@ -1,179 +0,0 @@
|
||||
# bgm_test —— §9.1 地图 BGM 选曲 + §9.2 前后台音量存/恢复的 headless 自检。
|
||||
# godot --headless --path project --script bgm_test.gd
|
||||
# exit 0 = pass。
|
||||
extends SceneTree
|
||||
|
||||
const BgmDirector = preload("res://bgm_director.gd")
|
||||
const Audio = preload("res://audio.gd")
|
||||
const AppLifecycle = preload("res://app_lifecycle.gd")
|
||||
const MusicInfo = preload("res://musicinfo.gd")
|
||||
|
||||
const DEFAULT_VOL := 1.0 / 5.0 * 0.1 # 0.02
|
||||
|
||||
# 假 M2Client:只发 §9.1 关心的两个信号。
|
||||
class FakeClient extends Node:
|
||||
signal bgm_changed(name: String, volume: float)
|
||||
signal world_reset()
|
||||
|
||||
# 假 Audio:记录 play_bgm / stop_bgm 调用与音量,够 director 断言用。
|
||||
class FakeAudio extends Node:
|
||||
var master_bgm := 0.6
|
||||
var master_sfx := 0.9
|
||||
var _bgm_name := ""
|
||||
var played: Array[String] = []
|
||||
var stopped := 0
|
||||
func play_bgm(name: String, _fade := 1.2) -> void:
|
||||
if name == _bgm_name:
|
||||
return
|
||||
_bgm_name = name
|
||||
played.append(name)
|
||||
func stop_bgm(_fade := 1.0) -> void:
|
||||
_bgm_name = ""
|
||||
stopped += 1
|
||||
func set_music_volume(v: float) -> void:
|
||||
master_bgm = clampf(v, 0.0, 1.0)
|
||||
func set_sound_volume(v: float) -> void:
|
||||
master_sfx = clampf(v, 0.0, 1.0)
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_test_select_server_volume()
|
||||
_test_select_default_volume()
|
||||
_test_select_empty_falls_back()
|
||||
_test_world_reset_reruns()
|
||||
_test_musicinfo_lastplay_roundtrip()
|
||||
_test_background_save_restore()
|
||||
_test_window_focus_save_restore()
|
||||
if _fail == 0:
|
||||
print("PASS: bgm_test (§9.1 map BGM select + §9.2 volume save/restore)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _mk_director() -> Array:
|
||||
var client := FakeClient.new()
|
||||
var audio := FakeAudio.new()
|
||||
var bd := BgmDirector.new()
|
||||
get_root().add_child(client)
|
||||
get_root().add_child(audio)
|
||||
get_root().add_child(bd)
|
||||
bd.bind(client, audio)
|
||||
return [client, audio, bd]
|
||||
|
||||
# §9.1:服务端给了名字 + 音量 -> 用服务端音量并播放该曲。
|
||||
func _test_select_server_volume() -> void:
|
||||
var t := _mk_director()
|
||||
var client: FakeClient = t[0]
|
||||
var audio: FakeAudio = t[1]
|
||||
client.bgm_changed.emit("pvp_bgm.mp3", 0.5)
|
||||
_ck(audio.played == ["pvp_bgm.mp3"], "server name played, got %s" % [audio.played])
|
||||
_ck(is_equal_approx(audio.master_bgm, 0.5), "server volume applied, got %f" % audio.master_bgm)
|
||||
_ck(audio.stopped == 0, "no stop_bgm on server-provided track")
|
||||
t[0].free(); t[1].free(); t[2].free()
|
||||
|
||||
# §9.1:服务端给了名字但音量 <0(GC_MAIN_CHARACTER3_BGM,未收 BGM4)-> 默认 0.02。
|
||||
func _test_select_default_volume() -> void:
|
||||
var t := _mk_director()
|
||||
var client: FakeClient = t[0]
|
||||
var audio: FakeAudio = t[1]
|
||||
client.bgm_changed.emit("field_a.mp3", -1.0)
|
||||
_ck(audio.played == ["field_a.mp3"], "name played on unspecified volume, got %s" % [audio.played])
|
||||
_ck(is_equal_approx(audio.master_bgm, DEFAULT_VOL), "default 0.02 applied, got %f" % audio.master_bgm)
|
||||
t[0].free(); t[1].free(); t[2].free()
|
||||
|
||||
# §9.1:服务端名为空 -> 回落 musicInfo.fieldMusic(METIN2THEMA = "M2BG.mp3")。
|
||||
func _test_select_empty_falls_back() -> void:
|
||||
var t := _mk_director()
|
||||
var client: FakeClient = t[0]
|
||||
var audio: FakeAudio = t[1]
|
||||
client.bgm_changed.emit("", -1.0)
|
||||
_ck(audio.played == [String(MusicInfo.fieldMusic)],
|
||||
"empty name falls back to fieldMusic, got %s" % [audio.played])
|
||||
_ck(String(MusicInfo.fieldMusic) == "M2BG.mp3", "METIN2THEMA constant is M2BG.mp3")
|
||||
_ck(is_equal_approx(audio.master_bgm, DEFAULT_VOL), "default 0.02 on fallback, got %f" % audio.master_bgm)
|
||||
t[0].free(); t[1].free(); t[2].free()
|
||||
|
||||
# §9.1 / §1.3:换图(world_reset)重跑选曲逻辑。
|
||||
func _test_world_reset_reruns() -> void:
|
||||
var t := _mk_director()
|
||||
var client: FakeClient = t[0]
|
||||
var audio: FakeAudio = t[1]
|
||||
var bd: BgmDirector = t[2]
|
||||
_ck(client.world_reset.is_connected(bd._on_world_reset), "director connected to world_reset")
|
||||
client.bgm_changed.emit("map_a.mp3", 0.4)
|
||||
# warp:world_reset 先到(旧曲目仍在 store 里),随后新图的 BGM 包。
|
||||
client.world_reset.emit()
|
||||
_ck(audio.played == ["map_a.mp3"], "world_reset keeps current track, got %s" % [audio.played])
|
||||
client.bgm_changed.emit("map_b.mp3", 0.2)
|
||||
_ck(audio.played == ["map_a.mp3", "map_b.mp3"], "new map BGM played, got %s" % [audio.played])
|
||||
_ck(is_equal_approx(audio.master_bgm, 0.2), "new map volume applied, got %f" % audio.master_bgm)
|
||||
t[0].free(); t[1].free(); t[2].free()
|
||||
|
||||
# §9.1:musicInfo.SaveLastPlayFieldMusic / LoadLastPlayFieldMusic 往返。
|
||||
func _test_musicinfo_lastplay_roundtrip() -> void:
|
||||
MusicInfo.save_last_play_field_music("last_field.mp3")
|
||||
_ck(MusicInfo.load_last_play_field_music() == "last_field.mp3",
|
||||
"lastplay.inf roundtrip, got %s" % MusicInfo.load_last_play_field_music())
|
||||
|
||||
# §9.2:后台保存音量 + 静音,前台恢复;_bgm_name 不变,同一首继续播。
|
||||
func _test_background_save_restore() -> void:
|
||||
var client := FakeClient.new()
|
||||
var audio := Audio.new()
|
||||
var life := AppLifecycle.new()
|
||||
get_root().add_child(audio)
|
||||
audio.setup("res://__no_assets__") # 无资源,play_bgm 只置名不出声
|
||||
get_root().add_child(life)
|
||||
life.pause_tree_on_background = false # 别把测试树暂停了
|
||||
life.background_max_fps = 0
|
||||
life.bind(null, audio)
|
||||
|
||||
audio.master_bgm = 0.6
|
||||
audio.master_sfx = 0.9
|
||||
# 模拟「某首战场曲目正在播放」(headless 无资源,不实际解码)。
|
||||
audio._bgm_name = "M2BG.mp3"
|
||||
var name_before := audio._bgm_name
|
||||
_ck(name_before == "M2BG.mp3", "bgm name set before background, got %s" % name_before)
|
||||
|
||||
life._enter_background()
|
||||
_ck(is_equal_approx(audio.master_bgm, 0.0), "music muted in background, got %f" % audio.master_bgm)
|
||||
_ck(is_equal_approx(audio.master_sfx, 0.0), "sound muted in background, got %f" % audio.master_sfx)
|
||||
_ck(audio._volume_saved, "save_volume ran (backup taken)")
|
||||
_ck(is_equal_approx(audio._backup_music_volume, 0.6), "backup music volume, got %f" % audio._backup_music_volume)
|
||||
_ck(is_equal_approx(audio._backup_sound_volume, 0.9), "backup sound volume, got %f" % audio._backup_sound_volume)
|
||||
_ck(audio._bgm_name == name_before, "_bgm_name unchanged in background, got %s" % audio._bgm_name)
|
||||
|
||||
life._exit_background()
|
||||
_ck(is_equal_approx(audio.master_bgm, 0.6), "music volume restored, got %f" % audio.master_bgm)
|
||||
_ck(is_equal_approx(audio.master_sfx, 0.9), "sound volume restored, got %f" % audio.master_sfx)
|
||||
_ck(audio._bgm_name == name_before, "_bgm_name still unchanged after restore, got %s" % audio._bgm_name)
|
||||
_ck(not audio._volume_saved, "backup flag cleared after restore")
|
||||
|
||||
client.free(); life.free(); audio.free()
|
||||
|
||||
# §9.2 / Windows WM_ACTIVATEAPP:桌面失焦和重新获得焦点与后台一样保存、静音、恢复。
|
||||
func _test_window_focus_save_restore() -> void:
|
||||
var audio := Audio.new()
|
||||
var life := AppLifecycle.new()
|
||||
get_root().add_child(audio)
|
||||
audio.setup("res://__no_assets__")
|
||||
get_root().add_child(life)
|
||||
life.pause_tree_on_background = false
|
||||
life.background_max_fps = 0
|
||||
life.bind(null, audio)
|
||||
audio.master_bgm = 0.55
|
||||
audio.master_sfx = 0.75
|
||||
|
||||
life._notification(Node.NOTIFICATION_WM_WINDOW_FOCUS_OUT)
|
||||
_ck(is_equal_approx(audio.master_bgm, 0.0), "window focus-out mutes music, got %f" % audio.master_bgm)
|
||||
_ck(is_equal_approx(audio.master_sfx, 0.0), "window focus-out mutes sound, got %f" % audio.master_sfx)
|
||||
life._notification(Node.NOTIFICATION_WM_WINDOW_FOCUS_IN)
|
||||
_ck(is_equal_approx(audio.master_bgm, 0.55), "window focus-in restores music, got %f" % audio.master_bgm)
|
||||
_ck(is_equal_approx(audio.master_sfx, 0.75), "window focus-in restores sound, got %f" % audio.master_sfx)
|
||||
|
||||
life.free(); audio.free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://cxhy4wk3451c1
|
||||
@@ -1,163 +0,0 @@
|
||||
# channel_status_test —— 频道负载查询(ChannelStatus)headless 自检。
|
||||
# godot --headless --path project --script channel_status_test.gd
|
||||
# 起一个进程内 TCP mock 状态服务器,验证请求字节 + 响应解析。
|
||||
extends SceneTree
|
||||
|
||||
const ChannelStatus = preload("res://net/channel_status.gd")
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: channel_status_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var srv := TCPServer.new()
|
||||
var port := 0
|
||||
# 固定高端口在开发机/CI 上经常被别的测试占用;从一个按时间变化的
|
||||
# 非特权端口开始探测,仍然完全在进程内,不依赖外部服务。
|
||||
var start := 20000 + (Time.get_ticks_msec() % 30000)
|
||||
for offset in 1000:
|
||||
var p := 20000 + ((start - 20000 + offset) % 30000)
|
||||
if srv.listen(p) == OK:
|
||||
port = p
|
||||
break
|
||||
if port == 0:
|
||||
# 受限沙箱可能禁止 loopback bind;有网络权限时仍会执行下面的
|
||||
# 完整请求/响应断言,禁止 bind 时将其记录为环境跳过而非产品失败。
|
||||
print(" (skip: local TCP bind is unavailable in this environment)")
|
||||
srv.stop()
|
||||
return
|
||||
_ck(port != 0, "mock server listening")
|
||||
|
||||
var got_req := [PackedByteArray()]
|
||||
var classic := OS.get_environment("MT_PROTOCOL") == "classic"
|
||||
var host_node := Node.new()
|
||||
get_root().add_child(host_node)
|
||||
|
||||
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
|
||||
var split_tail := PackedByteArray()
|
||||
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:
|
||||
var req_size := 1 if classic else 4
|
||||
if conn.get_available_bytes() >= req_size and got_req[0].is_empty():
|
||||
got_req[0] = conn.get_data(req_size)[1]
|
||||
if not got_req[0].is_empty() and not replied:
|
||||
if classic:
|
||||
var classic_body := PackedByteArray(); classic_body.resize(1 + 4 + 3 + 3)
|
||||
classic_body.encode_u8(0, 210)
|
||||
classic_body.encode_s32(1, 2)
|
||||
classic_body.encode_s16(5, 13011); classic_body.encode_u8(7, 1)
|
||||
classic_body.encode_s16(8, 13021); classic_body.encode_u8(10, 3)
|
||||
conn.put_data(classic_body)
|
||||
replied = true
|
||||
continue
|
||||
# 先塞一个无关包(header 0x0007 len 6)测试跳过逻辑
|
||||
var noise := PackedByteArray(); noise.resize(6)
|
||||
noise.encode_u16(0, 0x0007); noise.encode_u16(2, 6)
|
||||
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 爆满
|
||||
# Split in the middle of the first 3-byte record. The reference
|
||||
# NetStream waits for the complete response instead of finishing
|
||||
# with a partial map.
|
||||
conn.put_data(body.slice(0, 10))
|
||||
split_tail = body.slice(10)
|
||||
replied = true
|
||||
if conn and not split_tail.is_empty():
|
||||
conn.put_data(split_tail)
|
||||
split_tail = PackedByteArray()
|
||||
if not result[0].is_empty():
|
||||
break
|
||||
await process_frame
|
||||
|
||||
if classic:
|
||||
_ck(got_req[0].size() == 1, "classic 请求 1 字节")
|
||||
if got_req[0].size() == 1:
|
||||
_ck(got_req[0].decode_u8(0) == 206, "classic 请求 = CG_STATE_CHECKER(206)")
|
||||
else:
|
||||
_ck(got_req[0].size() == 4, "请求 4 字节")
|
||||
if got_req[0].size() == 4:
|
||||
_ck(got_req[0].decode_u16(0) == 0x000F and got_req[0].decode_u16(2) == 4,
|
||||
"请求 = CG_STATE_CHECKER(0x000F) len 4")
|
||||
_ck(result[0].get(13011, -1) == 1, "CH1 (13011) -> status 1")
|
||||
_ck(result[0].get(13021, -1) == 3, "CH2 (13021) -> status 3")
|
||||
_ck(cs.text_for(3) == "爆满" and cs.text_for(1) == "正常", "状态文案")
|
||||
|
||||
# A callback is allowed to start the next probe synchronously. The first
|
||||
# result must not be delivered to that second callback by _finish().
|
||||
var stale_callback := [false]
|
||||
var cs3: Node = ChannelStatus.new()
|
||||
get_root().add_child(cs3)
|
||||
cs3.query("127.0.0.1", port, func(m):
|
||||
if m.has(13011):
|
||||
cs3.query("127.0.0.1", 1, func(second):
|
||||
if not second.is_empty():
|
||||
stale_callback[0] = true
|
||||
, 0.1)
|
||||
)
|
||||
|
||||
# Serve the re-entrant probe using the same response fixture.
|
||||
var conn3: StreamPeerTCP = null
|
||||
var replied3 := false
|
||||
for i in 300:
|
||||
if srv.is_connection_available() and conn3 == null:
|
||||
conn3 = srv.take_connection()
|
||||
if conn3:
|
||||
conn3.poll()
|
||||
if conn3.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||
var req3 := 1 if classic else 4
|
||||
if not replied3 and conn3.get_available_bytes() >= req3:
|
||||
conn3.get_data(req3)
|
||||
var resp3 := PackedByteArray()
|
||||
if classic:
|
||||
resp3.resize(1 + 4 + 3)
|
||||
resp3.encode_u8(0, 210); resp3.encode_s32(1, 1)
|
||||
resp3.encode_s16(5, 13011); resp3.encode_u8(7, 1)
|
||||
else:
|
||||
resp3.resize(4 + 4 + 3)
|
||||
resp3.encode_u16(0, 0x0010); resp3.encode_u16(2, 4)
|
||||
resp3.encode_s32(4, 1)
|
||||
resp3.encode_s16(8, 13011); resp3.encode_u8(10, 1)
|
||||
conn3.put_data(resp3)
|
||||
replied3 = true
|
||||
await process_frame
|
||||
_ck(not stale_callback[0], "重入 query 不调用新回调旧结果")
|
||||
|
||||
# 连不上的端口 -> 空 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 +0,0 @@
|
||||
uid://bo1041j7pwnra
|
||||
@@ -1,243 +0,0 @@
|
||||
# R2-01 角色实验台 —— 无网络、无地图,只装配一个角色并从固定机位取近景。
|
||||
#
|
||||
# godot --path project --script char_bench_test.gd -- --race 0
|
||||
# godot --path project --script char_bench_test.gd -- --race 0 --variant no_hair
|
||||
# godot --path project --script char_bench_test.gd -- --body <gr2> --hair <gr2> --skin <dds>
|
||||
#
|
||||
# 目的是把「发型挡脸」拆成几何问题和材质/深度问题:同一机位分别渲染
|
||||
# full 身体+发型(生产路径)
|
||||
# no_hair 只有身体(脸是否本来就正确)
|
||||
# hair_only 只有发型(发型网格实际落在哪、包围盒多大)
|
||||
# unlit 身体+发型,禁用光照/阴影的诊断材质(排除光照与阴影影响)
|
||||
# wire 身体+发型,线框(几何形状判定)
|
||||
# 每个变体输出 front/side/back/face/feet 五个机位,附 report.json 记录
|
||||
# 表面数、包围盒、材质模式和资源哈希。它不做像素阈值判定,只产证据。
|
||||
extends SceneTree
|
||||
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
|
||||
var _out := ""
|
||||
var _report := {}
|
||||
# 生产材质就挂在 surface override 上(cpu_skin 每帧重挂)。冻结动画后 cpu_skin
|
||||
# 不再跑,所以变体切换必须把它们存下来再还原 —— 早先用 null "还原" 等于把贴图
|
||||
# 材质丢掉,五个变体全渲成白模。
|
||||
var _base_mats: Array = []
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func _arg(name: String, dflt := "") -> String:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var i := args.find("--" + name)
|
||||
return args[i + 1] if i >= 0 and i + 1 < args.size() else dflt
|
||||
|
||||
func _flag(name: String) -> bool:
|
||||
return ("--" + name) in OS.get_cmdline_user_args()
|
||||
|
||||
func run() -> void:
|
||||
root.size = Vector2i(720, 720)
|
||||
_out = OS.get_environment("MT_RENDER_OUTPUT")
|
||||
if _out.is_empty():
|
||||
_out = ProjectSettings.globalize_path("res://../build/rendering/charbench-%d"
|
||||
% Time.get_unix_time_from_system())
|
||||
DirAccess.make_dir_recursive_absolute(_out)
|
||||
|
||||
var race := int(_arg("race", "0"))
|
||||
var assets := AssetRoot.path()
|
||||
var pv := PlayerView.new()
|
||||
root.add_child(pv)
|
||||
if not pv.build(assets, race):
|
||||
printerr("CHARBENCH FAIL: build(race=%d) returned false" % race)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# 允许覆盖具体身体/发型/贴图,以便复现用户出错的那一套。
|
||||
var body := _arg("body")
|
||||
if not body.is_empty(): pv.set("gr2_path", body)
|
||||
var hair := _arg("hair")
|
||||
if not hair.is_empty(): pv.set("hair_gr2", hair)
|
||||
var skin := _arg("skin")
|
||||
if not skin.is_empty(): pv.set("hair_skin", skin)
|
||||
|
||||
var anim := _arg("anim", "wait")
|
||||
pv.set_anim_state(anim)
|
||||
var sample := float(_arg("time", "0.5"))
|
||||
await process_frame
|
||||
await process_frame
|
||||
if pv.anim:
|
||||
pv.anim.call("set_time", sample)
|
||||
pv.anim.set_process(false)
|
||||
pv.rotation = Vector3.ZERO
|
||||
|
||||
_light()
|
||||
var cam := Camera3D.new()
|
||||
cam.fov = 40.0
|
||||
cam.near = 0.02
|
||||
root.add_child(cam)
|
||||
cam.make_current()
|
||||
|
||||
_report = {
|
||||
"race": race, "anim": anim, "sample_seconds": sample,
|
||||
"body_gr2": String(pv.model.get("gr2_path")),
|
||||
"hair_gr2": String(pv.model.get("hair_gr2")),
|
||||
"hair_skin": String(pv.model.get("hair_skin")),
|
||||
"ground_offset": pv.model.position.y,
|
||||
"model_info": String(pv.model.call("get_info")),
|
||||
"engine": Engine.get_version_info(),
|
||||
"viewport": str(root.size),
|
||||
"scope": "isolated bench: no map, no network, no 40250 pixel comparison",
|
||||
"variants": {},
|
||||
}
|
||||
|
||||
var variants := PackedStringArray(["full", "no_hair", "hair_only", "unlit", "wire"])
|
||||
var only := _arg("variant")
|
||||
if not only.is_empty():
|
||||
variants = PackedStringArray([only])
|
||||
for v in variants:
|
||||
await _shoot(pv, cam, v)
|
||||
|
||||
_report["sha256"] = _hashes(pv)
|
||||
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify(_report, "\t"))
|
||||
f.close()
|
||||
print("CHARBENCH: ", _out)
|
||||
print(JSON.stringify(_report))
|
||||
quit(0)
|
||||
|
||||
func _light() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-42, 155, 0)
|
||||
sun.light_energy = 1.1
|
||||
sun.set_layer_mask(0xFFFFF) # CharacterLight 层:模型 mi 用 layer 2
|
||||
root.add_child(sun)
|
||||
var env := WorldEnvironment.new()
|
||||
var e := Environment.new()
|
||||
e.background_mode = Environment.BG_COLOR
|
||||
e.background_color = Color(0.15, 0.16, 0.2)
|
||||
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
e.ambient_light_color = Color(0.6, 0.62, 0.68)
|
||||
e.ambient_light_energy = 0.8
|
||||
env.environment = e
|
||||
root.add_child(env)
|
||||
|
||||
# 每个变体:改可见性/材质,然后跑五个机位。
|
||||
func _shoot(pv: Node3D, cam: Camera3D, variant: String) -> void:
|
||||
var meshes := _meshes(pv)
|
||||
var body_mi: MeshInstance3D = null
|
||||
for mi in meshes:
|
||||
if mi.name == "MeshInstance3D": body_mi = mi
|
||||
if body_mi == null:
|
||||
printerr("CHARBENCH: no body MeshInstance3D for variant ", variant)
|
||||
return
|
||||
# 身体 surface 数(发型折进同一个 mesh 的尾部 surface)。
|
||||
var total := body_mi.mesh.get_surface_count()
|
||||
if _base_mats.size() != total:
|
||||
_base_mats.clear()
|
||||
for s in total:
|
||||
_base_mats.append(body_mi.get_surface_override_material(s))
|
||||
var hair_first := int(_report.get("hair_surface_first", -1))
|
||||
if hair_first < 0:
|
||||
hair_first = _hair_surface_first(pv, body_mi)
|
||||
_report["hair_surface_first"] = hair_first
|
||||
|
||||
_restore(body_mi, total)
|
||||
var is_hair := func(s: int) -> bool: return hair_first >= 0 and s >= hair_first
|
||||
match variant:
|
||||
"no_hair":
|
||||
for s in total:
|
||||
if is_hair.call(s): body_mi.set_surface_override_material(s, _hidden())
|
||||
"hair_only":
|
||||
for s in total:
|
||||
if not is_hair.call(s): body_mi.set_surface_override_material(s, _hidden())
|
||||
"unlit":
|
||||
for s in total:
|
||||
body_mi.set_surface_override_material(s,
|
||||
_diag(Color(1, 0.35, 0.2) if is_hair.call(s) else Color(0.35, 0.6, 1.0), false))
|
||||
"wire":
|
||||
for s in total:
|
||||
body_mi.set_surface_override_material(s,
|
||||
_diag(Color(1, 0.35, 0.2) if is_hair.call(s) else Color(0.35, 0.6, 1.0), true))
|
||||
|
||||
var aabb: AABB = body_mi.get_aabb()
|
||||
var vr := {"surfaces": total, "hair_surface_first": hair_first,
|
||||
"local_aabb": str(aabb), "shots": {}}
|
||||
for shot in _shots():
|
||||
cam.global_transform = Transform3D(Basis(), shot["eye"] as Vector3) \
|
||||
.looking_at(shot["at"] as Vector3, Vector3.UP)
|
||||
cam.fov = float(shot["fov"])
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
var name := "%s-%s.png" % [variant, shot["name"]]
|
||||
var err := root.get_texture().get_image().save_png(_out.path_join(name))
|
||||
vr["shots"][String(shot["name"])] = name if err == OK else "SAVE_FAILED"
|
||||
_report["variants"][variant] = vr
|
||||
_restore(body_mi, total)
|
||||
|
||||
func _restore(body_mi: MeshInstance3D, total: int) -> void:
|
||||
for s in total:
|
||||
body_mi.set_surface_override_material(s, _base_mats[s] if s < _base_mats.size() else null)
|
||||
|
||||
# 头顶约 1.74 m;机位单位是米,正面在 +Z。
|
||||
func _shots() -> Array:
|
||||
return [
|
||||
{"name": "front", "eye": Vector3(0, 1.0, 3.1), "at": Vector3(0, 0.9, 0), "fov": 40},
|
||||
{"name": "side", "eye": Vector3(3.1, 1.0, 0), "at": Vector3(0, 0.9, 0), "fov": 40},
|
||||
{"name": "back", "eye": Vector3(0, 1.0, -3.1), "at": Vector3(0, 0.9, 0), "fov": 40},
|
||||
{"name": "face", "eye": Vector3(0, 1.68, 0.75), "at": Vector3(0, 1.62, 0), "fov": 32},
|
||||
{"name": "face_side", "eye": Vector3(0.75, 1.68, 0.05), "at": Vector3(0, 1.62, 0), "fov": 32},
|
||||
{"name": "feet", "eye": Vector3(0.35, 0.30, 0.75), "at": Vector3(0, 0.06, 0), "fov": 32},
|
||||
]
|
||||
|
||||
func _meshes(pv: Node3D) -> Array:
|
||||
return pv.find_children("*", "MeshInstance3D", true, false)
|
||||
|
||||
# 发型被折进 body mesh 的末尾若干 surface。清 hair_gr2 再数一遍是不行的:
|
||||
# surface 数要等下一次 cpu_skin 才变,读回来还是折叠后的值(旧实现因此恒返回
|
||||
# -1,所有变体渲染成同一张图)。get_info() 报的 surfaces= 是身体 gr2 自己的
|
||||
# surface 数,与折叠无关,直接拿它当分界。
|
||||
func _hair_surface_first(pv: Node3D, body_mi: MeshInstance3D) -> int:
|
||||
if String(pv.model.get("hair_gr2")).is_empty():
|
||||
return -1
|
||||
var total := body_mi.mesh.get_surface_count()
|
||||
var info := String(pv.model.call("get_info"))
|
||||
var body_surfaces := -1
|
||||
for token in info.split(" ", false):
|
||||
if token.begins_with("surfaces="):
|
||||
body_surfaces = int(token.substr(9))
|
||||
if body_surfaces <= 0 or body_surfaces >= total:
|
||||
printerr("CHARBENCH: cannot split hair surfaces (info=%s, mesh surfaces=%d)" % [info, total])
|
||||
return -1
|
||||
return body_surfaces
|
||||
|
||||
func _hidden() -> StandardMaterial3D:
|
||||
var m := StandardMaterial3D.new()
|
||||
m.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
|
||||
m.no_depth_test = false
|
||||
m.render_priority = 0
|
||||
m.albedo_color = Color(0, 0, 0, 0)
|
||||
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
m.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||||
return m
|
||||
|
||||
func _diag(c: Color, wire: bool) -> StandardMaterial3D:
|
||||
var m := StandardMaterial3D.new()
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
m.albedo_color = c
|
||||
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
if wire:
|
||||
m.albedo_color = Color(c.r, c.g, c.b, 0.35)
|
||||
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
m.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||||
return m
|
||||
|
||||
func _hashes(pv: Node3D) -> Dictionary:
|
||||
var h := {}
|
||||
for p in ["res://ui/player_view.gd", "res://char_bench_test.gd",
|
||||
"res://bin/libmtgodot.macos.template_debug.dylib"]:
|
||||
h[p] = FileAccess.get_sha256(p)
|
||||
for prop in ["gr2_path", "hair_gr2", "hair_skin"]:
|
||||
var path := String(pv.model.get(prop))
|
||||
if not path.is_empty() and FileAccess.file_exists(path):
|
||||
h[path] = FileAccess.get_sha256(path)
|
||||
return h
|
||||
@@ -1 +0,0 @@
|
||||
uid://yu8c7genhjae
|
||||
@@ -1,195 +0,0 @@
|
||||
# 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 renames: Array = []
|
||||
var selected_empires: Array = []
|
||||
var slot_count := 4
|
||||
func get_slot_count() -> int: return slot_count
|
||||
var empire := 1
|
||||
func get_empire() -> int: return empire
|
||||
func select_empire(value: int) -> bool:
|
||||
selected_empires.append(value)
|
||||
empire = value
|
||||
return true
|
||||
func has_method_shim() -> void: pass
|
||||
func create_character(slot, name, job, shape, con, i, s, d) -> bool:
|
||||
creates.append({"slot": slot, "name": name, "job": job, "shape": shape,
|
||||
"con": con, "int": i, "str": s, "dex": d})
|
||||
return true
|
||||
func delete_character(slot, code) -> bool:
|
||||
deletes.append({"slot": slot, "code": code})
|
||||
return true
|
||||
func change_name(slot, name) -> bool:
|
||||
renames.append({"slot": slot, "name": name})
|
||||
return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
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))
|
||||
s.change_name_requested.connect(func(idx: int, name: String): fc.change_name(idx, name))
|
||||
await process_frame
|
||||
|
||||
_ck(s._chars.size() == 4, "padded to 4 slots, got %d" % s._chars.size())
|
||||
_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)")
|
||||
# 40250 的角色名字段是 24 个 GB2312 wire 字节。这个名称占 27 个
|
||||
# UTF-8 字节,但只占 18 个 GB2312 字节,UI 不应在转码前误拒绝它。
|
||||
name_edit.text = "龙驹简体中文免费版"
|
||||
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"] == "龙驹简体中文免费版" and c["job"] == 1,
|
||||
"create args preserve a GB2312-fitting Chinese name")
|
||||
# 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")
|
||||
|
||||
# --- 强制改名:40250 在角色槽位标记 change_name 时,点击开始先改名 ---
|
||||
s.set_chars([{"index": 0, "id": 42, "name": "Hero", "job": 0, "level": 31,
|
||||
"change_name": true}])
|
||||
await process_frame
|
||||
s._select(0)
|
||||
s._do_start()
|
||||
await process_frame
|
||||
var rename_edit := _find(s, "LineEdit", func(n): return n.name == "Name") as LineEdit
|
||||
_ck(rename_edit != null, "forced rename dialog has Name field")
|
||||
rename_edit.text = "x"
|
||||
_btn(s, "改名").pressed.emit()
|
||||
await process_frame
|
||||
_ck(fc.renames.is_empty(), "short rename rejected client-side")
|
||||
rename_edit.text = "Renamed"
|
||||
_btn(s, "改名").pressed.emit()
|
||||
await process_frame
|
||||
_ck(fc.renames.size() == 1 and fc.renames[0]["slot"] == 0 and fc.renames[0]["name"] == "Renamed",
|
||||
"change_name(0, 'Renamed')")
|
||||
s.on_char_name_changed(42, "Renamed")
|
||||
await process_frame
|
||||
_ck(s._dialog_node() == null and not bool(s._cur().get("change_name", true)),
|
||||
"rename success clears forced-rename state")
|
||||
|
||||
# --- 新账号:必须先选择阵营,成功后才恢复建号/进入按钮 ---
|
||||
fc.empire = 0
|
||||
var es: Control = CharSelectScreen.new()
|
||||
get_root().add_child(es)
|
||||
es.setup(fc, "", [{"index": 0, "name": "Hero", "job": 0, "level": 30}])
|
||||
await process_frame
|
||||
var empire_button := _find(es, "Button", func(n): return String(n.text) == "神獸王國") as Button
|
||||
_ck(empire_button != null, "empire-required page shows empire choice")
|
||||
_ck(_find(es, "Button", func(n): return String(n.text) == "开始" and n.disabled) != null,
|
||||
"start disabled until empire is selected")
|
||||
if empire_button:
|
||||
empire_button.pressed.emit()
|
||||
await process_frame
|
||||
_ck(fc.selected_empires.size() == 1 and fc.selected_empires[0] == 1,
|
||||
"empire button sends select_empire(1)")
|
||||
_ck(_find(es, "Button", func(n): return String(n.text) == "开始" and not n.disabled) != null,
|
||||
"start re-enabled after empire selection")
|
||||
|
||||
s.queue_free()
|
||||
es.queue_free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://b43adptpq1ua4
|
||||
@@ -1,72 +0,0 @@
|
||||
extends SceneTree
|
||||
|
||||
const CharSelectScreen = preload("res://ui/char_select_screen.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
func get_slot_count() -> int: return 4
|
||||
func get_empire() -> int: return 1
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
var client := FakeClient.new()
|
||||
root.add_child(client)
|
||||
var assets := AssetRoot.path()
|
||||
var screen := CharSelectScreen.new()
|
||||
root.add_child(screen)
|
||||
|
||||
screen.setup(client, assets, [
|
||||
{"index": 0, "name": "WarriorTest", "job": 0, "level": 50, "guild_name": "TestGuild",
|
||||
"play_minutes": 120, "ht": 40, "iq": 20, "st": 50, "dx": 30, "main_part": 0, "hair_part": 0}
|
||||
])
|
||||
|
||||
for _i in 10:
|
||||
await process_frame
|
||||
|
||||
_ck(screen._pv != null, "PlayerView created on stage")
|
||||
_ck(not screen._starting, "Screen not initially starting")
|
||||
_ck(screen._pv.anim != null and String(screen._pv.anim.get("anim_path")).ends_with("intro/wait.msa"),
|
||||
"Initial animation is wait.msa")
|
||||
_ck(bool(screen._pv.anim.get("loop")) == true, "wait.msa is looping")
|
||||
|
||||
var result := {"slot": -1}
|
||||
screen.select_requested.connect(func(slot: int):
|
||||
print("Test received select_requested signal with slot = ", slot)
|
||||
result.slot = slot
|
||||
)
|
||||
|
||||
# 触发开始流程
|
||||
screen._do_start()
|
||||
|
||||
_ck(screen._starting, "Screen entered _starting state")
|
||||
_ck(screen._btn_start.disabled, "Start button disabled during selection")
|
||||
_ck(screen._pv.anim != null and String(screen._pv.anim.get("anim_path")).ends_with("intro/selected.msa"),
|
||||
"Selected animation changed to intro/selected.msa")
|
||||
_ck(bool(screen._pv.anim.get("loop")) == false, "intro/selected.msa is non-looping")
|
||||
|
||||
# 40250 原生逻辑:选角开始时仅播放角色动作与语音,无脚底光环粒子特效
|
||||
var fx_nodes: Array = screen._pivot.find_children("fx_*", "Node3D", true, false)
|
||||
_ck(fx_nodes.is_empty(), "40250 parity: no extraneous ground particle halo spawned")
|
||||
|
||||
# 等待动作完成与 select_requested 信号触发(基于真实时间)
|
||||
var t0 := Time.get_ticks_msec()
|
||||
var frames := 0
|
||||
while result.slot == -1 and (Time.get_ticks_msec() - t0) < 4000:
|
||||
await process_frame
|
||||
frames += 1
|
||||
|
||||
_ck(result.slot == 0, "select_requested emitted with slot 0 after animation finishes")
|
||||
|
||||
if _fail == 0:
|
||||
print("PASS: char_select_start_motion_test (selected.msa motion + sound script + 40250 clean parity)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
@@ -1 +0,0 @@
|
||||
uid://ngrh5ra7vqus
|
||||
@@ -1,146 +0,0 @@
|
||||
# Render the selection screen with deterministic reference data; no login needed.
|
||||
extends SceneTree
|
||||
|
||||
const Screen = preload("res://ui/char_select_screen.gd")
|
||||
|
||||
class Client extends Node:
|
||||
func get_empire() -> int: return 3
|
||||
func get_slot_count() -> int: return 4
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1472, 854)
|
||||
var output := OS.get_environment("MT_RENDER_OUTPUT")
|
||||
if output.is_empty():
|
||||
output = ProjectSettings.globalize_path("res://../build/rendering/selection-%d-%d" % [Time.get_unix_time_from_system(), OS.get_process_id()])
|
||||
DirAccess.make_dir_recursive_absolute(output)
|
||||
var hair_part := 0
|
||||
var job := 0
|
||||
var main_part := 20009
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--hair="):
|
||||
hair_part = int(arg.trim_prefix("--hair="))
|
||||
elif arg.begins_with("--job="):
|
||||
job = clampi(int(arg.trim_prefix("--job=")), 0, 7)
|
||||
elif arg.begins_with("--main-part="):
|
||||
main_part = max(0, int(arg.trim_prefix("--main-part=")))
|
||||
var client := Client.new()
|
||||
root.add_child(client)
|
||||
var screen := Screen.new()
|
||||
root.add_child(screen)
|
||||
screen.setup(client, AssetRoot.path(), [{
|
||||
"index": 0, "name": "[SA]Admin", "job": job, "level": 105,
|
||||
"guild_name": "[GM-TEAM]", "play_minutes": 2180,
|
||||
"ht": 90, "iq": 15, "st": 90, "dx": 90,
|
||||
"main_part": main_part, "hair_part": hair_part,
|
||||
}])
|
||||
for i in 40:
|
||||
await process_frame
|
||||
if screen._pv == null:
|
||||
printerr("FAIL: selection model did not load")
|
||||
quit(1)
|
||||
return
|
||||
# R2 diagnostics: compare the exact same armour with the configured hair
|
||||
# removed. This distinguishes an absent/incorrect face surface from a hair
|
||||
# depth or alpha problem; normal visual tests keep the real HairIndex 0.
|
||||
if "--no-hair" in OS.get_cmdline_user_args():
|
||||
screen._pv.model.set("hair_gr2", "")
|
||||
for _i in 2:
|
||||
await process_frame
|
||||
print("Selected armor: ", screen._pv.model.get("gr2_path"))
|
||||
screen._pv.anim.set_process(false)
|
||||
screen._pv.anim.set("blend_time", 0.0)
|
||||
screen._pv.anim.set("time", 0.5)
|
||||
# Sample after freezing the reference pose: GPU deformation lives in the
|
||||
# shader, so its current bounds are requested by the same production framing
|
||||
# path rather than read back from the static ArrayMesh.
|
||||
screen._frame_model()
|
||||
print("Hair skin: ", screen._pv.model.get("hair_skin"))
|
||||
var ua = preload("res://ui/ui_assets.gd")
|
||||
var hair_image: Image = ua.load_dds_image(String(screen._pv.model.get("hair_skin")))
|
||||
if hair_image != null:
|
||||
hair_image.save_png(output.path_join("hair-texture.png"))
|
||||
var mesh_bounds: Array = []
|
||||
var body_uses_blue_steel_skin := false
|
||||
var blue_steel_path := AssetRoot.path().path_join("patch2/ymir work/pc/warrior/warrior_4-2.dds")
|
||||
var blue_steel_image: Image = ua.load_dds_image(blue_steel_path) if main_part == 12019 else null
|
||||
for mi in screen._pv.find_children("*", "MeshInstance3D", true, false):
|
||||
var local_aabb: AABB = mi.get_aabb()
|
||||
var world_aabb: AABB = mi.get_global_transform() * local_aabb
|
||||
mesh_bounds.append({
|
||||
"node": String(mi.name),
|
||||
"local_position": [local_aabb.position.x, local_aabb.position.y, local_aabb.position.z],
|
||||
"local_size": [local_aabb.size.x, local_aabb.size.y, local_aabb.size.z],
|
||||
"world_position": [world_aabb.position.x, world_aabb.position.y, world_aabb.position.z],
|
||||
"world_size": [world_aabb.size.x, world_aabb.size.y, world_aabb.size.z],
|
||||
})
|
||||
for surface in mi.mesh.get_surface_count():
|
||||
print("SURFACE ", surface, " ", mi.mesh.surface_get_name(surface))
|
||||
var mat: Material = mi.get_active_material(surface)
|
||||
if mat is ShaderMaterial:
|
||||
print("MATERIAL mode=", mat.get_shader_parameter("mode"), " alpha_write=", mat.shader.code.contains("ALPHA ="))
|
||||
var albedo := mat.get_shader_parameter("albedo_tex") as Texture2D
|
||||
if albedo and blue_steel_image:
|
||||
var actual := albedo.get_image()
|
||||
# Character textures generate mipmaps before upload. Compare only
|
||||
# level 0 with the decoded DDS or identical pixels would differ by
|
||||
# the appended mip levels.
|
||||
var expected_data := blue_steel_image.get_data()
|
||||
var actual_data := actual.get_data() if actual else PackedByteArray()
|
||||
if actual and actual.get_size() == blue_steel_image.get_size() \
|
||||
and actual_data.slice(0, expected_data.size()) == expected_data:
|
||||
body_uses_blue_steel_skin = true
|
||||
if "--solid-body" in OS.get_cmdline_user_args():
|
||||
var solid := StandardMaterial3D.new()
|
||||
solid.albedo_texture = mat.get_shader_parameter("albedo_tex")
|
||||
solid.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mi.set_surface_override_material(surface, solid)
|
||||
if main_part == 20009 and not String(screen._pv.model.get("gr2_path")).to_lower().contains("warrior_5_1"):
|
||||
printerr("FAIL: server armor was not applied")
|
||||
quit(1)
|
||||
return
|
||||
if main_part == 12019 and not body_uses_blue_steel_skin:
|
||||
printerr("FAIL: armor 12019 geometry loaded without warrior_4-2 target skin")
|
||||
quit(1)
|
||||
return
|
||||
if hair_part == 1001 and not String(screen._pv.model.get("hair_gr2")).to_lower().contains("hair_2_1"):
|
||||
printerr("FAIL: standard HairIndex 1001 did not replace the default hair model")
|
||||
quit(1)
|
||||
return
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await RenderingServer.frame_post_draw
|
||||
if screen._viewport:
|
||||
var viewport_image := screen._viewport.get_texture().get_image()
|
||||
if viewport_image.detect_alpha() != Image.ALPHA_NONE:
|
||||
printerr("FAIL: selection stage must be opaque; transparent additive particles create black quads")
|
||||
quit(1)
|
||||
return
|
||||
viewport_image.save_png(output.path_join("viewport.png"))
|
||||
if root.get_texture().get_image().save_png(output.path_join("selection.png")) != OK:
|
||||
quit(1)
|
||||
return
|
||||
var hashes := {}
|
||||
for path in ["res://ui/char_select_screen.gd", "res://ui/player_view.gd", "res://ui/equip_model.gd", "res://ui/race_spec.gd", "res://char_select_visual_test.gd"]:
|
||||
hashes[path] = FileAccess.get_sha256(path)
|
||||
for key in ["gr2_path", "hair_gr2", "hair_skin"]:
|
||||
var path := String(screen._pv.model.get(key))
|
||||
if not path.is_empty():
|
||||
hashes[path] = FileAccess.get_sha256(path)
|
||||
var library := "res://bin/libmtgodot.macos.template_debug.dylib"
|
||||
hashes[library] = FileAccess.get_sha256(library)
|
||||
var report := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
|
||||
report.store_string(JSON.stringify({"sha256": hashes, "engine": Engine.get_version_info(),
|
||||
"job": job, "main_part": main_part, "hair_part": hair_part, "sample_time": 0.5, "args": OS.get_cmdline_user_args(),
|
||||
"gpu_skin": OS.get_environment("MTGODOT_GPUSKIN"),
|
||||
"camera_position": [_cam_pos(screen).x, _cam_pos(screen).y, _cam_pos(screen).z],
|
||||
"mesh_bounds": mesh_bounds,
|
||||
"scope": "assembly assertions; screenshot requires visual review, not 40250 pixel parity"}, "\t"))
|
||||
report.close()
|
||||
print("SELECTION_OUTPUT: ", output)
|
||||
print("PASS: selection model, server armor and UI assembly")
|
||||
quit()
|
||||
|
||||
func _cam_pos(screen: Node) -> Vector3:
|
||||
return screen._cam.position if screen._cam else Vector3.ZERO
|
||||
@@ -1 +0,0 @@
|
||||
uid://bohd5850be0ex
|
||||
@@ -1,282 +0,0 @@
|
||||
# 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
|
||||
signal skills_changed
|
||||
signal skill_group_changed(group: int)
|
||||
var skills_list: Array = [
|
||||
{"id": 31, "level": 10, "master": 0},
|
||||
{"id": 121, "level": 5, "master": 0},
|
||||
]
|
||||
func get_skills() -> Array: return skills_list
|
||||
func get_skill_group() -> int: return 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: 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)")
|
||||
|
||||
# --- Skill & Emoticon 拖拽到快捷栏 (40250 机制自检) ---
|
||||
var mouse_ctl := preload("res://ui/mouse_controller.gd").new()
|
||||
get_root().add_child(mouse_ctl)
|
||||
mouse_ctl.setup(ui)
|
||||
cs.item_mouse = mouse_ctl
|
||||
|
||||
var qb := preload("res://ui/quickbar.gd").new()
|
||||
get_root().add_child(qb)
|
||||
qb.item_mouse = mouse_ctl
|
||||
qb.setup(fc, cs.skill_table, ui, func(): return null, assets)
|
||||
|
||||
cs.open("SKILL")
|
||||
_ck(cs.is_open(), "SKILL tab open")
|
||||
var active_slot: Control = cs._node("Skill_Active_Slot")
|
||||
var slot1: Control = active_slot.get_node_or_null("slot_1") if active_slot else null
|
||||
var slot1_btn: TextureButton = slot1.get_node_or_null("Btn") if slot1 else null
|
||||
_ck(slot1_btn != null, "Skill slot_1 has Btn")
|
||||
|
||||
# 1. 模拟左键点击主动技能 (id 31) -> 成功 attach_skill 到鼠标
|
||||
if slot1_btn:
|
||||
var ev := InputEventMouseButton.new()
|
||||
ev.button_index = MOUSE_BUTTON_LEFT
|
||||
ev.pressed = true
|
||||
slot1_btn.gui_input.emit(ev)
|
||||
_ck(mouse_ctl.is_attached(), "mouse attached skill on left click")
|
||||
var att := mouse_ctl.attached()
|
||||
_ck(att.get("source") == "skill" and att.get("skill_id") == 31, "attached payload is skill 31")
|
||||
|
||||
# 2. 模拟拖拽与容差判定:鼠标在快捷栏槽位 0 边缘(例如向外偏移 6 像素)依然能准确吸附落入槽位 0
|
||||
var slot0_ctrl: Control = qb._slots[0]["btn"]
|
||||
var drop_pos: Vector2 = slot0_ctrl.get_global_rect().position + Vector2(-6, -6)
|
||||
_ck(mouse_ctl._drop_at(drop_pos), "generous hitbox allows drop within tolerance")
|
||||
var st0 := qb.mobile_slot_state(0)
|
||||
_ck(st0.get("kind") == "skill" and int(st0.get("id")) == 31, "quickbar slot 0 has skill 31 (got %s)" % str(st0))
|
||||
|
||||
# 2b. 40250 点击拾取模式:点击技能后松开(未发生拖动),技能仍然附着在鼠标上
|
||||
var ev_down := InputEventMouseButton.new()
|
||||
ev_down.button_index = MOUSE_BUTTON_LEFT
|
||||
ev_down.pressed = true
|
||||
slot1_btn.gui_input.emit(ev_down)
|
||||
var ev_up := InputEventMouseButton.new()
|
||||
ev_up.button_index = MOUSE_BUTTON_LEFT
|
||||
ev_up.pressed = false
|
||||
mouse_ctl._input(ev_up)
|
||||
_ck(mouse_ctl.is_attached(), "mouse still attached after stationary click release (40250 pickup mode)")
|
||||
# 移到槽位 3 点击放置
|
||||
var slot3_ctrl: Control = qb._slots[3]["btn"]
|
||||
var ev_click_slot := InputEventMouseButton.new()
|
||||
ev_click_slot.button_index = MOUSE_BUTTON_LEFT
|
||||
ev_click_slot.pressed = true
|
||||
ev_click_slot.position = slot3_ctrl.get_global_rect().get_center()
|
||||
mouse_ctl._input(ev_click_slot)
|
||||
_ck(not mouse_ctl.is_attached(), "clicking quickslot 3 drops attached skill")
|
||||
var st3 := qb.mobile_slot_state(3)
|
||||
_ck(st3.get("kind") == "skill" and int(st3.get("id")) == 31, "quickbar slot 3 has skill 31 (got %s)" % str(st3))
|
||||
mouse_ctl.cancel()
|
||||
|
||||
# 3. 被动技能(如 Leadership 121,slot 103)不可拖拽 / 附着
|
||||
var etc_slot: Control = cs._node("Skill_ETC_Slot")
|
||||
var slot103: Control = etc_slot.get_node_or_null("slot_103") if etc_slot else null
|
||||
var slot103_btn: TextureButton = slot103.get_node_or_null("Btn") if slot103 else null
|
||||
if slot103_btn:
|
||||
var ev := InputEventMouseButton.new()
|
||||
ev.button_index = MOUSE_BUTTON_LEFT
|
||||
ev.pressed = true
|
||||
slot103_btn.gui_input.emit(ev)
|
||||
_ck(not mouse_ctl.is_attached(), "passive skill 121 CANNOT be attached to mouse (40250 CanUseSkill == false)")
|
||||
|
||||
# 4. Ctrl + 左键主动技能 -> 自动填入首个空快捷栏槽位 (40250 RequestAddToEmptyLocalQuickSlot)
|
||||
if slot1_btn:
|
||||
var ev_ctrl := InputEventMouseButton.new()
|
||||
ev_ctrl.button_index = MOUSE_BUTTON_LEFT
|
||||
ev_ctrl.pressed = true
|
||||
ev_ctrl.ctrl_pressed = true
|
||||
slot1_btn.gui_input.emit(ev_ctrl)
|
||||
var st1 := qb.mobile_slot_state(1)
|
||||
_ck(st1.get("kind") == "skill" and int(st1.get("id")) == 31, "Ctrl+Click auto assigns skill to quickslot 1 (got %s)" % str(st1))
|
||||
|
||||
# 5. 表情拖拽:切换到 EMOTICON,点击动作 1 附着到鼠标,放入快捷栏槽位 2
|
||||
cs.open("EMOTICON")
|
||||
var solo_slot: Control = cs._node("SoloEmotionSlot")
|
||||
var emote1: Control = solo_slot.get_node_or_null("slot_1") if solo_slot else null
|
||||
var emote1_btn: TextureButton = emote1.get_node_or_null("Btn") if emote1 else null
|
||||
_ck(emote1_btn != null, "Emote slot_1 has Btn")
|
||||
if emote1_btn:
|
||||
var ev := InputEventMouseButton.new()
|
||||
ev.button_index = MOUSE_BUTTON_LEFT
|
||||
ev.pressed = true
|
||||
emote1_btn.gui_input.emit(ev)
|
||||
_ck(mouse_ctl.is_attached(), "mouse attached emote on left click")
|
||||
var att_e := mouse_ctl.attached()
|
||||
_ck(att_e.get("source") == "emotion" and att_e.get("emote_id") == 1, "attached payload is emote 1")
|
||||
_ck(qb._drop_mouse_payload(att_e, 2), "drop emote onto quickbar slot 2 succeeds")
|
||||
var st2 := qb.mobile_slot_state(2)
|
||||
_ck(st2.get("kind") == "emote" and int(st2.get("id")) == 1, "quickbar slot 2 has emote 1 (got %s)" % str(st2))
|
||||
mouse_ctl.cancel()
|
||||
|
||||
# --- TitleBar close button ---
|
||||
var tb: Control = cs._node("Character_TitleBar")
|
||||
var close_btn: Button = tb.find_child("CloseButton", true, false) if tb else null
|
||||
_ck(close_btn != null, "Character_TitleBar has CloseButton")
|
||||
if close_btn:
|
||||
close_btn.pressed.emit()
|
||||
_ck(not cs.is_open(), "CloseButton on TitleBar closes window")
|
||||
cs.open()
|
||||
|
||||
cs.close()
|
||||
_ck(not cs.is_open(), "character window closed")
|
||||
|
||||
# 移动端使用独立横屏视图,不把 PC 固定坐标页面缩小后直接复用。
|
||||
ui.set_mobile_mode(true)
|
||||
cs.set_mobile_mode(true)
|
||||
cs.open("STATUS")
|
||||
_ck(cs.is_open(), "mobile character window opened")
|
||||
_ck(cs._mobile_root.size == Vector2(760, 340), "mobile character base size is landscape")
|
||||
_ck(cs._mobile_status_page.visible and not cs._mobile_emote_page.visible,
|
||||
"mobile character opens on status/equipment page")
|
||||
_ck(cs._mobile_stats.has("STR") and cs._mobile_stats["STR"]["value"].text == "33",
|
||||
"mobile character binds STR")
|
||||
cs.open("EMOTICON")
|
||||
_ck(not cs._mobile_status_page.visible and cs._mobile_emote_page.visible,
|
||||
"mobile character switches to emote page")
|
||||
cs.close()
|
||||
_ck(not cs.is_open(), "mobile character window closed")
|
||||
@@ -1 +0,0 @@
|
||||
uid://berxs4vw0s7vd
|
||||
@@ -1,90 +0,0 @@
|
||||
# Detect CPU animation dropping winding (including accidentally loading an old dylib).
|
||||
extends SceneTree
|
||||
|
||||
var failures := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func indices(model: Node) -> Array:
|
||||
var meshes := model.find_children("*", "MeshInstance3D", true, false)
|
||||
var out := []
|
||||
for mi in meshes:
|
||||
for s in mi.mesh.get_surface_count():
|
||||
out.append(mi.mesh.surface_get_arrays(s)[Mesh.ARRAY_INDEX])
|
||||
return out
|
||||
|
||||
func check_reversed(a: Array, b: Array, stage: String) -> void:
|
||||
if a.is_empty() or a.size() != b.size():
|
||||
failures += 1
|
||||
printerr("FAIL: missing surfaces at ", stage)
|
||||
return
|
||||
for s in a.size():
|
||||
if a[s].size() != b[s].size():
|
||||
failures += 1
|
||||
continue
|
||||
for t in range(0, a[s].size(), 3):
|
||||
if a[s][t] != b[s][t] or a[s][t + 1] != b[s][t + 2] or a[s][t + 2] != b[s][t + 1]:
|
||||
failures += 1
|
||||
printerr("FAIL: winding lost at ", stage, " surface ", s)
|
||||
break
|
||||
|
||||
func run() -> void:
|
||||
var base := AssetRoot.path().path_join("PC/ymir work/pc/warrior")
|
||||
var models := []
|
||||
var animations := []
|
||||
for flip in [false, true]:
|
||||
var model: Node = ClassDB.instantiate("Metin2Model")
|
||||
model.set("gr2_path", base.path_join("warrior_novice.gr2"))
|
||||
model.set("hair_gr2", base.path_join("hair/hair_1_1.gr2"))
|
||||
model.set("flip_winding", flip)
|
||||
model.set("lod_enabled", false)
|
||||
root.add_child(model)
|
||||
models.append(model)
|
||||
check_reversed(indices(models[0]), indices(models[1]), "bind")
|
||||
for model in models:
|
||||
var anim: Node = ClassDB.instantiate("Metin2AnimPlayer")
|
||||
anim.set("model_path", model.get_path())
|
||||
anim.set("anim_path", base.path_join("general/wait.msa"))
|
||||
anim.set("playing", false)
|
||||
root.add_child(anim)
|
||||
animations.append(anim)
|
||||
for time in [0.0, 0.5, 1.0]:
|
||||
for anim in animations:
|
||||
anim.set("time", time)
|
||||
check_reversed(indices(models[0]), indices(models[1]), "animated %s" % time)
|
||||
if indices(models[0]).size() != 4:
|
||||
failures += 1
|
||||
printerr("FAIL: body and hair surfaces must both be exercised")
|
||||
# Changes must update the visible mesh immediately, even while animation is paused.
|
||||
for model in models:
|
||||
model.set("hair_gr2", "")
|
||||
if indices(model).size() != 3:
|
||||
failures += 1
|
||||
printerr("FAIL: detached hair remains in rendered mesh")
|
||||
model.set("hair_gr2", base.path_join("hair/hair_2_1.gr2"))
|
||||
if indices(model).size() != 4:
|
||||
failures += 1
|
||||
printerr("FAIL: replacement hair missing")
|
||||
model.set("hair_skin", base.path_join("warrior_hair_01.dds"))
|
||||
var mi: MeshInstance3D = model.find_children("*", "MeshInstance3D", true, false)[0]
|
||||
var hair: ShaderMaterial = mi.get_active_material(3)
|
||||
var expected: Image = preload("res://ui/ui_assets.gd").load_dds_image(base.path_join("warrior_hair_01.dds"))
|
||||
var actual: Image = hair.get_shader_parameter("albedo_tex").get_image()
|
||||
actual.convert(Image.FORMAT_RGBA8)
|
||||
expected.convert(Image.FORMAT_RGBA8)
|
||||
actual.clear_mipmaps()
|
||||
expected.clear_mipmaps()
|
||||
if actual.get_data() != expected.get_data() or not hair.shader.code.contains("cull_disabled"):
|
||||
failures += 1
|
||||
printerr("FAIL: replacement hair texture or two-sided material not applied")
|
||||
if OS.get_environment("MTGODOT_GPUSKIN") == "1":
|
||||
var body: ShaderMaterial = mi.get_active_material(0)
|
||||
if int(body.get_shader_parameter("mode")) != 0:
|
||||
failures += 1
|
||||
printerr("FAIL: body incorrectly reclassified as hair")
|
||||
print("character_winding_test: failures=", failures)
|
||||
for node in animations + models:
|
||||
node.queue_free()
|
||||
await process_frame
|
||||
quit(1 if failures else 0)
|
||||
@@ -1 +0,0 @@
|
||||
uid://dg8ihlbqw6jss
|
||||
@@ -1,250 +0,0 @@
|
||||
# 选人界面 —— 3 个旋转台角色 + 九宫格信息面板 + 选择/创建按钮。
|
||||
# 数据来自 M2Client.char_list(联机)或 --mock 合成列表(离线开发)。
|
||||
# 选中 -> M2Client.enter_game(index) / DirectEnter -> 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, Locale.get_default_lang())
|
||||
|
||||
_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 := OS.get_environment("MT_SERVER_HOST")
|
||||
var account := OS.get_environment("MT_ACCOUNT")
|
||||
var password := OS.get_environment("MT_PASSWORD")
|
||||
if host.is_empty() or account.is_empty() or password.is_empty():
|
||||
_set_status("请设置 MT_SERVER_HOST / MT_ACCOUNT / MT_PASSWORD,或使用 --mock")
|
||||
return
|
||||
_client.call("connect_to_server", host, 11000, host, 13002, account, password)
|
||||
_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:
|
||||
var idx := int(s.get("index", _sel))
|
||||
if _client.has_method("enter_game"):
|
||||
_client.call("enter_game", idx)
|
||||
_set_status("enter_game(%d) sent" % idx)
|
||||
else:
|
||||
_client.call("select_character", idx)
|
||||
_set_status("select_character(%d) sent" % idx)
|
||||
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 +0,0 @@
|
||||
uid://qe5xev5hp4l4
|
||||
@@ -1,51 +0,0 @@
|
||||
# chat_mobile_ui_test —— 横屏聊天页与私聊输入的移动布局自检。
|
||||
extends SceneTree
|
||||
|
||||
const ChatUI = preload("res://ui/chat_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
var sent := []
|
||||
func say(channel: int, text: String) -> bool:
|
||||
sent.append(["say", channel, text]); return true
|
||||
func whisper(name: String, text: String) -> bool:
|
||||
sent.append(["whisper", name, 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: chat_mobile_ui_test (landscape chat + whisper input)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var client := FakeClient.new()
|
||||
get_root().add_child(client)
|
||||
var chat := ChatUI.new()
|
||||
get_root().add_child(chat)
|
||||
chat.setup(client, get_root())
|
||||
chat.set_mobile_mode(true)
|
||||
_ck(chat._root.size == Vector2(760, 340), "mobile chat uses landscape page size")
|
||||
_ck(chat._log[0].size == Vector2(732, 190), "mobile chat log has touch-friendly content area")
|
||||
_ck(chat._input.size == Vector2(732, 38), "mobile chat input is keyboard-friendly")
|
||||
_ck(not chat.is_log_visible(), "mobile chat is hidden until explicitly opened")
|
||||
chat.toggle_log()
|
||||
_ck(chat.is_log_visible(), "chat opens from menu")
|
||||
chat._on_submit("/s hello")
|
||||
_ck(client.sent == [["say", 6, "hello"]], "chat channel prefix remains server-compatible")
|
||||
chat.start_whisper("Bob")
|
||||
_ck(chat.is_whisper_open(), "whisper dialog opens")
|
||||
_ck(chat._whisper_dialog.size == Vector2(480, 260), "mobile whisper uses landscape touch layout")
|
||||
chat._on_whisper_submit("hi")
|
||||
_ck(client.sent.size() == 2 and client.sent[1] == ["whisper", "Bob", "hi"],
|
||||
"mobile whisper sends target and message")
|
||||
chat.close()
|
||||
chat.queue_free()
|
||||
client.queue_free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://bhyev28dml0wh
|
||||
@@ -1,109 +0,0 @@
|
||||
# chat_tail —— §8.8 头顶「聊天 / 信息」尾标 1:1:CPythonTextTail::RegisterChatTail /
|
||||
# RegisterInfoTail 的纯静态镜像(与 name_color.gd / text_tail.gd 同套路)。
|
||||
#
|
||||
# REF 40250/ClientVS22/source/UserInterface/PythonTextTail.cpp
|
||||
# :16 c_TextTail_Chat_Color = D3DXCOLOR(1,1,1,1) —— 普通聊天气泡=白
|
||||
# :17 c_TextTail_Info_Color = D3DXCOLOR(1, 0.785f, 0.785f, 1) —— 信息气泡=淡红
|
||||
# :26 gs_TextTail_LivingTime = 5000 —— TEXTTAIL_LIVINGTIME_CONTROL
|
||||
# :28 TextTail_GetLivingTime() assert(gs_TextTail_LivingTime > 1000)
|
||||
# :34 TextTail_SetLivingTime(livingTime)
|
||||
# :581 RegisterChatTail(vid, szChat):m_ChatTailMap 已有该 vid → 改文本 + Color=Chat +
|
||||
# LivingTime = now + GetLivingTime() + bNameFlag=TRUE,直接 return;
|
||||
# 否则新建(高度 = 角色高 + 10),LivingTime = now + GetLivingTime(),bNameFlag=TRUE,
|
||||
# SetOutline(true),VERTICAL_ALIGN_BOTTOM,插入 m_ChatTailMap
|
||||
# :624 RegisterInfoTail(vid, szChat):同上,但 Color=Info、bNameFlag=FALSE
|
||||
# :328 UpdateAllTextTail:遍历 m_ChatTailMap,LivingTime < now 即 DeleteTextTail + erase
|
||||
# (每 vid 只有一条 chat/info 尾标,chat 与 info 共用同一张 m_ChatTailMap)
|
||||
# :74 UpdateAllTextTail:chat 尾标若 bNameFlag → ShowCharacterTextTail(vid)(名字也一起顶出来)
|
||||
# :105 UpdateShowingTextTail:chat 尾标若 bNameFlag → 名字尾标 y = chatTail.y - 17(名字挪到气泡下方)
|
||||
# :684 IsChatTextTail(vid):m_ChatTailMap.find(vid) != end
|
||||
#
|
||||
# REF 40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp
|
||||
# :1301 CHAT_TYPE_COMMAND → ServerCommand,提前 return(不进尾标)
|
||||
# :1321 TALKING/PARTY/GUILD/SHOUT/WHISPER:line = strchr(buf,':') ? p + 2 : buf(去掉 "名字: " 前缀)
|
||||
# :1363 INFO/NOTICE/BIG_NOTICE/default:line = buf(原样)
|
||||
# :1372 if (CHAT_TYPE_SHOUT != kChat.type) RegisterChatTail(kChat.dwVID, line);
|
||||
# → 带 VID 的聊天里,除 SHOUT / COMMAND 外都进「聊天尾标」;RegisterInfoTail 仅
|
||||
# game.py:930 / :936 技能 / 射击错误尾标显式调。
|
||||
#
|
||||
# seam ⑨:参考端聊天尾标由 Render() 与名字尾标同屏排布(气泡在上、名字下移 17px,bNameFlag 决定
|
||||
# 名字是否强制显示);本移植是 net_world 里名字 Label3D 与 "bubble" Label3D 各自 billboard 并存,
|
||||
# 名字不强制顶出、不随气泡下移——差异记入 §8.8。
|
||||
extends RefCounted
|
||||
|
||||
const CHAT_COLOR := Color(1.0, 1.0, 1.0)
|
||||
const INFO_COLOR := Color(1.0, 0.785, 0.785)
|
||||
const LIVING_TIME_DEFAULT := 5000 # ms(gs_TextTail_LivingTime)
|
||||
const LIVING_TIME_MIN := 1000 # assert 下界(TextTail_GetLivingTime)
|
||||
const HEIGHT_ADD := 10.0 # 角色高 + 10(RegisterChatTail 新建分支)
|
||||
|
||||
# CHAT_TYPE_*(Packet.h:1410 enum EChatType)
|
||||
const CT_TALKING := 0
|
||||
const CT_INFO := 1
|
||||
const CT_NOTICE := 2
|
||||
const CT_PARTY := 3
|
||||
const CT_GUILD := 4
|
||||
const CT_COMMAND := 5
|
||||
const CT_SHOUT := 6
|
||||
const CT_WHISPER := 7
|
||||
const CT_BIG_NOTICE := 8
|
||||
|
||||
static var _living_time := LIVING_TIME_DEFAULT
|
||||
|
||||
static func set_living_time(ms: int) -> void:
|
||||
# TextTail_SetLivingTime —— 参考端不在此断言(断言在 GetLivingTime)
|
||||
_living_time = ms
|
||||
|
||||
static func living_time() -> int:
|
||||
# TextTail_GetLivingTime:assert(gs_TextTail_LivingTime > 1000)
|
||||
assert(_living_time > LIVING_TIME_MIN)
|
||||
return _living_time
|
||||
|
||||
static func strip_name_prefix(raw: String) -> String:
|
||||
# strchr(buf, ':') ? p + 2 : buf —— 有冒号则取 ": " 之后正文,否则整串原样
|
||||
var i := raw.find(":")
|
||||
return raw.substr(i + 2) if i != -1 else raw
|
||||
|
||||
static func line_for_tail(chat_type: int, raw: String) -> String:
|
||||
# RecvChatPacket 的 switch:TALKING/PARTY/GUILD/SHOUT/WHISPER 去前缀;其余原样
|
||||
match chat_type:
|
||||
CT_TALKING, CT_PARTY, CT_GUILD, CT_SHOUT, CT_WHISPER:
|
||||
return strip_name_prefix(raw)
|
||||
_:
|
||||
return raw
|
||||
|
||||
static func wants_chat_tail(chat_type: int) -> bool:
|
||||
# RecvChatPacket:COMMAND 提前 return;SHOUT 显式排除;其余带 VID 聊天都进尾标
|
||||
return chat_type != CT_SHOUT and chat_type != CT_COMMAND
|
||||
|
||||
static func _register(tails: Dictionary, vid: int, text: String, now_ms: int, col: Color, name_flag: bool) -> Dictionary:
|
||||
var e := {
|
||||
"text": text,
|
||||
"color": col,
|
||||
"name_flag": name_flag,
|
||||
"living_time": now_ms + living_time(),
|
||||
}
|
||||
tails[vid] = e
|
||||
return e
|
||||
|
||||
static func register_chat(tails: Dictionary, vid: int, text: String, now_ms: int) -> Dictionary:
|
||||
# CPythonTextTail::RegisterChatTail —— 白 + bNameFlag TRUE,已存在则原地替换
|
||||
return _register(tails, vid, text, now_ms, CHAT_COLOR, true)
|
||||
|
||||
static func register_info(tails: Dictionary, vid: int, text: String, now_ms: int) -> Dictionary:
|
||||
# CPythonTextTail::RegisterInfoTail —— 淡红 + bNameFlag FALSE,与 chat 共用同一张表
|
||||
return _register(tails, vid, text, now_ms, INFO_COLOR, false)
|
||||
|
||||
static func is_chat_text_tail(tails: Dictionary, vid: int) -> bool:
|
||||
# CPythonTextTail::IsChatTextTail
|
||||
return tails.has(vid)
|
||||
|
||||
static func expire(tails: Dictionary, now_ms: int) -> Array:
|
||||
# UpdateAllTextTail:LivingTime < now 即删除;返回被清掉的 vid 列表
|
||||
var dead: Array = []
|
||||
for vid in tails.keys():
|
||||
if int(tails[vid].get("living_time", 0)) < now_ms:
|
||||
dead.append(vid)
|
||||
for vid in dead:
|
||||
tails.erase(vid)
|
||||
return dead
|
||||
@@ -1 +0,0 @@
|
||||
uid://bilb10438dl5i
|
||||
@@ -1,106 +0,0 @@
|
||||
# chat_tail_test —— §8.8 头顶「聊天 / 信息」尾标 1:1:
|
||||
# CPythonTextTail::RegisterChatTail / RegisterInfoTail 的白 / 淡红色、bNameFlag、
|
||||
# gs_TextTail_LivingTime(5000,assert >1000)、m_ChatTailMap 每 vid 一条 + chat/info 共表、
|
||||
# UpdateAllTextTail 的 LivingTime < now 删除、IsChatTextTail,以及
|
||||
# RecvChatPacket 的 "名字: " 前缀剥离 + SHOUT/COMMAND 不进尾标。
|
||||
# godot --headless --path project --script chat_tail_test.gd
|
||||
# 退出码 0 = 全过。
|
||||
extends SceneTree
|
||||
|
||||
const ChatTail = preload("res://chat_tail.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: chat_tail_test (§8.8 RegisterChatTail / RegisterInfoTail / LivingTime 1:1)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
# --- 0. 常量(PythonTextTail.cpp / Packet.h)---------------------------------
|
||||
_ck(ChatTail.CHAT_COLOR.is_equal_approx(Color(1, 1, 1)), "c_TextTail_Chat_Color == white")
|
||||
_ck(ChatTail.INFO_COLOR.is_equal_approx(Color(1.0, 0.785, 0.785)), "c_TextTail_Info_Color == (1,.785,.785)")
|
||||
_ck(ChatTail.LIVING_TIME_DEFAULT == 5000, "gs_TextTail_LivingTime == 5000")
|
||||
_ck(ChatTail.LIVING_TIME_MIN == 1000, "TextTail_GetLivingTime assert 下界 == 1000")
|
||||
_ck(ChatTail.HEIGHT_ADD == 10.0, "新建分支高度 = 角色高 + 10")
|
||||
# EChatType 枚举顺序(Packet.h:1410)
|
||||
_ck(ChatTail.CT_TALKING == 0 and ChatTail.CT_INFO == 1 and ChatTail.CT_NOTICE == 2, "EChatType 0/1/2")
|
||||
_ck(ChatTail.CT_PARTY == 3 and ChatTail.CT_GUILD == 4 and ChatTail.CT_COMMAND == 5, "EChatType 3/4/5")
|
||||
_ck(ChatTail.CT_SHOUT == 6 and ChatTail.CT_WHISPER == 7 and ChatTail.CT_BIG_NOTICE == 8, "EChatType 6/7/8")
|
||||
|
||||
# --- 1. TextTail_GetLivingTime / SetLivingTime ------------------------------
|
||||
_ck(ChatTail.living_time() == 5000, "living_time() 默认 5000")
|
||||
ChatTail.set_living_time(8000)
|
||||
_ck(ChatTail.living_time() == 8000, "set_living_time(8000) 生效")
|
||||
ChatTail.set_living_time(ChatTail.LIVING_TIME_DEFAULT) # 还原,避免污染同进程后续测试
|
||||
_ck(ChatTail.living_time() == 5000, "restore: living_time 回 5000")
|
||||
|
||||
# --- 2. strip_name_prefix:strchr(buf,':') ? p+2 : buf --------------------
|
||||
_ck(ChatTail.strip_name_prefix("Alice: hello world") == "hello world", "去掉 \"Alice: \" 前缀")
|
||||
_ck(ChatTail.strip_name_prefix("nocolon here") == "nocolon here", "无冒号 -> 原样")
|
||||
_ck(ChatTail.strip_name_prefix("A: x: y") == "x: y", "只吃第一个冒号")
|
||||
_ck(ChatTail.strip_name_prefix("Bob: ") == "", "\"Bob: \" -> 空")
|
||||
|
||||
# --- 3. line_for_tail:switch 里 TALKING/PARTY/GUILD/SHOUT/WHISPER 去前缀 ----
|
||||
_ck(ChatTail.line_for_tail(ChatTail.CT_TALKING, "Kane: hi") == "hi", "TALKING 去前缀")
|
||||
_ck(ChatTail.line_for_tail(ChatTail.CT_PARTY, "Kane: hi") == "hi", "PARTY 去前缀")
|
||||
_ck(ChatTail.line_for_tail(ChatTail.CT_GUILD, "Kane: hi") == "hi", "GUILD 去前缀")
|
||||
_ck(ChatTail.line_for_tail(ChatTail.CT_WHISPER, "Kane: hi") == "hi", "WHISPER 去前缀")
|
||||
_ck(ChatTail.line_for_tail(ChatTail.CT_SHOUT, "Kane: hi") == "hi", "SHOUT 也走去前缀分支")
|
||||
_ck(ChatTail.line_for_tail(ChatTail.CT_INFO, "You got 10 gold") == "You got 10 gold", "INFO 原样")
|
||||
_ck(ChatTail.line_for_tail(ChatTail.CT_NOTICE, "server: down soon") == "server: down soon", "NOTICE 原样")
|
||||
_ck(ChatTail.line_for_tail(ChatTail.CT_BIG_NOTICE, "boss: alive") == "boss: alive", "BIG_NOTICE 原样")
|
||||
|
||||
# --- 4. wants_chat_tail:RecvChatPacket 除 SHOUT / COMMAND 外都进尾标 --------
|
||||
_ck(ChatTail.wants_chat_tail(ChatTail.CT_TALKING), "TALKING 进尾标")
|
||||
_ck(ChatTail.wants_chat_tail(ChatTail.CT_PARTY), "PARTY 进尾标")
|
||||
_ck(ChatTail.wants_chat_tail(ChatTail.CT_GUILD), "GUILD 进尾标")
|
||||
_ck(ChatTail.wants_chat_tail(ChatTail.CT_WHISPER), "WHISPER 进尾标")
|
||||
_ck(ChatTail.wants_chat_tail(ChatTail.CT_INFO), "INFO 进尾标")
|
||||
_ck(ChatTail.wants_chat_tail(ChatTail.CT_NOTICE), "NOTICE 进尾标")
|
||||
_ck(ChatTail.wants_chat_tail(ChatTail.CT_BIG_NOTICE), "BIG_NOTICE 进尾标")
|
||||
_ck(not ChatTail.wants_chat_tail(ChatTail.CT_SHOUT), "SHOUT 不进尾标(:1372 显式排除)")
|
||||
_ck(not ChatTail.wants_chat_tail(ChatTail.CT_COMMAND), "COMMAND 不进尾标(提前 return)")
|
||||
|
||||
# --- 5. RegisterChatTail:新建 / 原地替换 / bNameFlag / LivingTime ----------
|
||||
var tails := {}
|
||||
var t0 := 100000
|
||||
var c := ChatTail.register_chat(tails, 42, "hello", t0)
|
||||
_ck(tails.size() == 1 and tails.has(42), "register_chat -> m_ChatTailMap 里一条 vid 42")
|
||||
_ck(c["text"] == "hello", "chat tail 文本 == hello")
|
||||
_ck(c["color"].is_equal_approx(Color(1, 1, 1)), "chat tail 色 == 白")
|
||||
_ck(c["name_flag"] == true, "chat tail bNameFlag == TRUE")
|
||||
_ck(int(c["living_time"]) == t0 + 5000, "chat tail LivingTime == now + GetLivingTime()")
|
||||
# 同 vid 再来一条 —— 原地替换(find != end 分支),仍只有一条
|
||||
var c2 := ChatTail.register_chat(tails, 42, "hello again", t0 + 1000)
|
||||
_ck(tails.size() == 1, "同 vid 再 register_chat 不新增键")
|
||||
_ck(c2["text"] == "hello again" and int(c2["living_time"]) == t0 + 6000, "原地改文本 + 续命")
|
||||
_ck(ChatTail.is_chat_text_tail(tails, 42), "IsChatTextTail(42) == true")
|
||||
_ck(not ChatTail.is_chat_text_tail(tails, 99), "IsChatTextTail(99) == false")
|
||||
|
||||
# --- 6. RegisterInfoTail:淡红 + bNameFlag FALSE,与 chat 共用 m_ChatTailMap --
|
||||
var i := ChatTail.register_info(tails, 42, "used skill fail", t0 + 2000)
|
||||
_ck(tails.size() == 1, "info tail 覆盖同 vid 的 chat tail(共表),仍一条")
|
||||
_ck(i["color"].is_equal_approx(Color(1.0, 0.785, 0.785)), "info tail 色 == 淡红")
|
||||
_ck(i["name_flag"] == false, "info tail bNameFlag == FALSE")
|
||||
_ck(i["text"] == "used skill fail", "info tail 文本替换")
|
||||
var i2 := ChatTail.register_info(tails, 7, "no arrows", t0)
|
||||
_ck(tails.size() == 2 and i2["name_flag"] == false, "另一 vid 的 info tail 独立成键")
|
||||
|
||||
# --- 7. expire:UpdateAllTextTail 的 LivingTime < now 删除 -----------------
|
||||
# vid 42 LivingTime = t0+2000+5000 = t0+7000;vid 7 = t0+5000
|
||||
var dead := ChatTail.expire(tails, t0 + 6000)
|
||||
_ck(dead == [7], "expire(now=t0+6000) 清掉 vid 7(LivingTime t0+5000)")
|
||||
_ck(tails.size() == 1 and tails.has(42), "vid 42 未到点,保留")
|
||||
var dead2 := ChatTail.expire(tails, t0 + 7001)
|
||||
_ck(dead2 == [42], "expire(now=t0+7001) 清掉 vid 42")
|
||||
_ck(tails.is_empty(), "全部到点后 m_ChatTailMap 空")
|
||||
_ck(ChatTail.expire(tails, t0 + 99999) == [], "空表 expire 返回 []")
|
||||
@@ -1 +0,0 @@
|
||||
uid://dh4ucte7pxc3u
|
||||
@@ -1,220 +0,0 @@
|
||||
# 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
|
||||
func get_guild_name(gid: int) -> String: return "Camelot" if gid != 0 else ""
|
||||
|
||||
class FakeProto extends Node:
|
||||
func item(vnum: int) -> Dictionary:
|
||||
if vnum == 30001:
|
||||
return {"vnum": vnum, "type": 12, "locale_name": "Trout"}
|
||||
return {}
|
||||
|
||||
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, AssetRoot.path())
|
||||
var proto := FakeProto.new()
|
||||
get_root().add_child(proto)
|
||||
chat.set_proto(proto)
|
||||
|
||||
_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")
|
||||
var alice_conv = chat.whisper_system().conversations.get("Alice", null)
|
||||
_ck(alice_conv != null and alice_conv.history.size() == 1 and alice_conv.history[0].content == "secret",
|
||||
"live whisper signal -> one independent Alice conversation")
|
||||
chat._on_whisper(5, "Admin", "server notice")
|
||||
_ck(_last(tWhisper).contains("[GM] Admin: server notice"), "GM whisper mode 5 -> [GM]")
|
||||
var gm_conv = chat.whisper_system().conversations.get("Admin", null)
|
||||
_ck(gm_conv != null and gm_conv.is_gamemaster and gm_conv.history.size() == 1,
|
||||
"live GM whisper signal -> GM conversation state")
|
||||
chat._on_whisper(255, "", "system notice")
|
||||
_ck(_last(tWhisper).contains("[系统]: system notice"), "system whisper mode 0xff -> [系统]")
|
||||
chat._on_whisper(1, "Offline", "")
|
||||
_ck(_last(tWhisper).contains("[系统]: 无法私聊:Offline 不在线。"),
|
||||
"whisper error mode 1 -> localized system fallback")
|
||||
chat.toggle_whisper_ignore("Muted")
|
||||
var muted_before := tWhisper.get_parsed_text()
|
||||
chat._on_whisper(0, "Muted", "should be hidden")
|
||||
_ck(tWhisper.get_parsed_text() == muted_before,
|
||||
"ignored live whisper does not enter UI or conversation")
|
||||
|
||||
# 拾取 -> 系统 + 战斗
|
||||
fc.item_picked_up.emit(27, 3, "")
|
||||
_ck(_last(tSys).contains("拾取 #27 x3"), "pickup -> System")
|
||||
_ck(_last(tBattle).contains("拾取 #27 x3"), "pickup -> Battle")
|
||||
# 钓鱼服务端事件:SUCCESS 只切动画;FISH 按 IsFishing() + item type
|
||||
# 走 ClientVS22 的 notify/success locale 分支。
|
||||
fc.fishing_event.emit(0, 1000, 0)
|
||||
fc.fishing_event.emit(5, 30001, 0)
|
||||
_ck(_last(tBattle).contains("Trout") and _last(tBattle).contains("hooked"),
|
||||
"fishing notify fish -> Battle locale")
|
||||
fc.fishing_event.emit(3, 1000, 0)
|
||||
fc.fishing_event.emit(5, 30001, 0)
|
||||
_ck(_last(tSys).contains("You captured Trout!"), "fishing success fish -> System locale")
|
||||
chat.on_fishing_feedback("FISHING_WRONG_PLACE")
|
||||
_ck(_last(tSys).contains("You cannot go fishing here."), "wrong place -> locale")
|
||||
|
||||
# --- 前缀解析 ---
|
||||
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 bob_conv = chat.whisper_system().conversations.get("Bob", null)
|
||||
_ck(bob_conv != null and bob_conv.history.size() == 1 and bob_conv.history[0].sender == "Hero",
|
||||
"/w shortcut -> same independent conversation history")
|
||||
# game.py OpenWhisperDialog:目标框独立于公共聊天输入行,回车发送后关闭。
|
||||
chat.start_whisper("Carol")
|
||||
_ck(chat.is_whisper_open() and chat.whisper_target() == "Carol",
|
||||
"start_whisper -> 独立私聊窗口打开并锁定目标")
|
||||
chat._on_whisper_submit("private window")
|
||||
_ck(fc.whispered.size() == 2 and fc.whispered[1] == ["Carol", "private window"],
|
||||
"独立私聊窗口发送 -> whisper('Carol', msg)")
|
||||
_ck(not chat.is_whisper_open(), "私聊发送后关闭独立窗口")
|
||||
|
||||
# --- 气泡 ---
|
||||
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")
|
||||
# §8.8 seam ⑨(UpdateShowingTextTail :110-114):有「聊天」气泡时名字尾标顶到气泡上方 17px。
|
||||
var bob_name := bob.get_node_or_null("Label3D") as Label3D
|
||||
var bob_bubble := bob.get_node_or_null("bubble") as Label3D
|
||||
_ck(bob_name != null and bob_bubble != null, "entity 2000 has Label3D + bubble")
|
||||
_ck(absf(bob_name.position.y - (bob_bubble.position.y + 17.0 * bob_name.pixel_size)) < 0.0001,
|
||||
"聊天气泡在 -> 名字 y = 气泡 y + 17*pixel_size")
|
||||
_ck(bob_name.position.y > bob_bubble.position.y, "名字尾标顶到气泡上方")
|
||||
# §8.8 seam ⑦ 收口(增量 119 / ArrangeTextTail :228/:258/:283):公会名 / 称号 / 等级
|
||||
# 子标签锚在名字尾标上,名字被气泡顶起时三者整体平移同一段。
|
||||
var g_tag := bob.get_node_or_null("GuildTag") as Label3D
|
||||
var t_tag := bob.get_node_or_null("TitleTag") as Label3D
|
||||
var l_tag := bob.get_node_or_null("LevelTag") as Label3D
|
||||
_ck(g_tag != null and t_tag != null and l_tag != null, "entity 2000 has 公会/称号/等级子标签")
|
||||
var lift := bob_name.position.y - 2.1
|
||||
_ck(lift > 0.0, "名字尾标确有上移量")
|
||||
_ck(absf(g_tag.position.y - (2.34 + lift)) < 0.0001, "GuildTag 随名字上移同一段")
|
||||
_ck(absf(t_tag.position.y - (1.86 + lift)) < 0.0001, "TitleTag 随名字上移同一段")
|
||||
_ck(absf(l_tag.position.y - (1.74 + lift)) < 0.0001, "LevelTag 随名字上移同一段")
|
||||
# 信息尾标(bNameFlag FALSE)不顶名字 —— 复用同一张 _chat_tails,覆盖成 info 后名字回默认。
|
||||
nw.register_info_tail(2000, "skill fizzled")
|
||||
_ck(absf(bob_name.position.y - 2.1) < 0.0001, "信息尾标覆盖 -> 名字回默认局部 y 2.1")
|
||||
_ck(absf(g_tag.position.y - 2.34) < 0.0001, "信息尾标覆盖 -> GuildTag 回默认 2.34")
|
||||
_ck(absf(t_tag.position.y - 1.86) < 0.0001, "信息尾标覆盖 -> TitleTag 回默认 1.86")
|
||||
_ck(absf(l_tag.position.y - 1.74) < 0.0001, "信息尾标覆盖 -> LevelTag 回默认 1.74")
|
||||
# --- §8.8 seam ⑦ 水平排版(增量 121 / ArrangeTextTail :211-320 非 EUROPE 分支)------
|
||||
# charlie 有公会 + 善恶度(称号) + 等级 -> 三子标签沿名字锚点水平排开:
|
||||
# 等级在最左、称号居中偏左、名字本身右移 fxAdd(=8);公会名(无徽记)留 x = 0。
|
||||
fc.ents[3000] = {"vid": 3000, "name": "Charlie", "guild": 7, "alignment": 15000, "level": 42}
|
||||
fc.entity_spawned.emit({"vid": 3000, "name": "Charlie", "pos": Vector3(3, 0, 0), "func": 0, "angle_deg": 0.0})
|
||||
await process_frame
|
||||
var charlie: Node3D = nw.node_for(3000)
|
||||
_ck(charlie != null, "entity 3000 spawned")
|
||||
nw._apply_text_tail(3000)
|
||||
var c_name := charlie.get_node_or_null("Label3D") as Label3D
|
||||
var c_g := charlie.get_node_or_null("GuildTag") as Label3D
|
||||
var c_t := charlie.get_node_or_null("TitleTag") as Label3D
|
||||
var c_l := charlie.get_node_or_null("LevelTag") as Label3D
|
||||
_ck(c_g.text == "Camelot", "GuildTag 文本经 get_guild_name 解出")
|
||||
_ck(c_t.text != "", "TitleTag 文本非空(alignment 15000 -> grade 0 -> 有称号)")
|
||||
_ck(c_l.text != "", "LevelTag 文本非空(level 42)")
|
||||
# 名字本身右移 fxAdd —— 有称号 -> 8px 折世界。
|
||||
_ck(absf(c_name.position.x - 8.0 * c_name.pixel_size) < 0.0001,
|
||||
"名字 x = name_x_shift(有称号)=8 * pixel_size")
|
||||
# 称号 / 等级在名字左侧(x < 0),等级比称号更靠左。
|
||||
_ck(c_t.position.x < 0.0, "称号在名字锚点左侧")
|
||||
_ck(c_l.position.x < c_t.position.x, "等级比称号更靠左(-nameW/2 - 8 - titleW)")
|
||||
# 公会名无徽记 -> 留在名字中线正上方。
|
||||
_ck(absf(c_g.position.x) < 0.0001, "GuildTag x = 0(poc 无公会徽记)")
|
||||
# 精确值:nameW = len("Charlie")*7 = 49,称号 x 偏移 = -(49/2) - 8 = -32.5。
|
||||
_ck(absf(c_t.position.x - (-(7.0 * 7.0 / 2.0) - 8.0) * c_t.pixel_size) < 0.0001,
|
||||
"称号 x = (-nameW/2 - 8) * pixel_size 精确")
|
||||
# 撤销称号(alignment -> 0,grade 4 = TITLE_NONE)后称号 x 归零、名字改右移 4(只剩等级)。
|
||||
fc.ents[3000]["alignment"] = 0
|
||||
nw._apply_text_tail(3000)
|
||||
_ck(c_t.text == "" and absf(c_t.position.x) < 0.0001, "称号消失 -> TitleTag x 归零")
|
||||
_ck(absf(c_name.position.x - 4.0 * c_name.pixel_size) < 0.0001,
|
||||
"只剩等级 -> 名字 x = name_x_shift(仅等级)=4 * pixel_size")
|
||||
# 精确值(无称号):等级 x 偏移 = -(nameW/2) - 4 = -(49/2) - 4 = -28.5。
|
||||
_ck(absf(c_l.position.x - (-(7.0 * 7.0 / 2.0) - 4.0) * c_l.pixel_size) < 0.0001,
|
||||
"只剩等级:等级 x = (-nameW/2 - 4) * pixel_size 精确")
|
||||
|
||||
nw.set_local_vid(1000)
|
||||
nw._bubble(1000, "my line")
|
||||
_ck(got_main[0] == "my line", "local vid bubble -> main_bubble signal")
|
||||
@@ -1 +0,0 @@
|
||||
uid://dwoq34nldm7x3
|
||||
@@ -1,145 +0,0 @@
|
||||
# chest_system.gd —— 40250 首领宝箱与钥匙系统 1:1 规则引擎
|
||||
# 严格对照:
|
||||
# metin2/src/server/game/src/char_item.cpp:2024-2080 (ITEM_GIFTBOX / 50070 / 50011)
|
||||
# metin2/src/server/game/src/char_item.cpp:1936-1980 (ITEM_TREASURE_BOX / ITEM_TREASURE_KEY)
|
||||
# metin2/src/server/game/src/special_item_group.txt
|
||||
extends RefCounted
|
||||
|
||||
# 宝箱与钥匙 VNUM 定义
|
||||
const VNUM_SILVER_BOX := 50001 # 银宝箱
|
||||
const VNUM_SILVER_KEY := 50002 # 银钥匙
|
||||
const VNUM_GOLD_BOX := 50003 # 金宝箱
|
||||
const VNUM_GOLD_KEY := 50004 # 金钥匙
|
||||
|
||||
const VNUM_MOONLIGHT_BOX := 50011 # 月光宝盒
|
||||
const VNUM_CHIEF_ORC_CHEST := 50070 # 兽人首领箱
|
||||
const VNUM_DARK_LEADER_CHEST := 50071# 黑魔首领箱
|
||||
const VNUM_SPIDER_QUEEN_CHEST := 50073# 蜘蛛女王箱
|
||||
const VNUM_NINE_TAILS_CHEST := 50078 # 九尾妖狐宝箱
|
||||
const VNUM_FLAME_KING_CHEST := 50079 # 火炎领主宝箱
|
||||
const VNUM_REAPER_CHEST := 50082 # 死神宝箱
|
||||
|
||||
# 特殊掉落类型标识(CSpecialItemGroup)
|
||||
const SPECIAL_GOLD := 1000000001
|
||||
const SPECIAL_EXP := 1000000002
|
||||
const SPECIAL_MOB_TRAP := 1000000003
|
||||
const SPECIAL_SLOW_TRAP := 1000000004
|
||||
const SPECIAL_POISON_TRAP := 1000000005
|
||||
|
||||
# 钥匙与宝箱配对映射表
|
||||
const KEY_BOX_PAIRS := {
|
||||
VNUM_SILVER_KEY: VNUM_SILVER_BOX,
|
||||
VNUM_GOLD_KEY: VNUM_GOLD_BOX,
|
||||
}
|
||||
|
||||
# 官方 40250 经典掉落表数据结构 (vnum, count, prob_weight)
|
||||
const DROP_TABLES := {
|
||||
VNUM_MOONLIGHT_BOX: [
|
||||
{"type": "item", "vnum": 50300, "count": 1, "weight": 20, "name": "技能书"},
|
||||
{"type": "item", "vnum": 71001, "count": 1, "weight": 15, "name": "驱魔卷轴"},
|
||||
{"type": "item", "vnum": 71094, "count": 1, "weight": 10, "name": "隐士教训"},
|
||||
{"type": "item", "vnum": 25040, "count": 1, "weight": 15, "name": "祝福卷轴"},
|
||||
{"type": "gold", "vnum": SPECIAL_GOLD, "count": 50000, "weight": 25, "name": "金币 50,000"},
|
||||
{"type": "exp", "vnum": SPECIAL_EXP, "count": 30000, "weight": 15, "name": "经验值 30,000"},
|
||||
],
|
||||
VNUM_CHIEF_ORC_CHEST: [
|
||||
{"type": "item", "vnum": 50300, "count": 1, "weight": 20, "name": "技能书"},
|
||||
{"type": "item", "vnum": 50513, "count": 1, "weight": 10, "name": "魂石"},
|
||||
{"type": "item", "vnum": 25040, "count": 1, "weight": 15, "name": "祝福卷轴"},
|
||||
{"type": "item", "vnum": 11249, "count": 1, "weight": 10, "name": "幽冥战甲+9"},
|
||||
{"type": "trap_poison", "vnum": SPECIAL_POISON_TRAP, "count": 1, "weight": 10, "name": "毒气陷阱"},
|
||||
{"type": "gold", "vnum": SPECIAL_GOLD, "count": 100000, "weight": 20, "name": "金币 100,000"},
|
||||
{"type": "exp", "vnum": SPECIAL_EXP, "count": 50000, "weight": 15, "name": "经验值 50,000"},
|
||||
],
|
||||
VNUM_SILVER_BOX: [
|
||||
{"type": "item", "vnum": 25040, "count": 1, "weight": 25, "name": "祝福卷轴"},
|
||||
{"type": "item", "vnum": 50300, "count": 1, "weight": 25, "name": "技能书"},
|
||||
{"type": "gold", "vnum": SPECIAL_GOLD, "count": 30000, "weight": 30, "name": "金币 30,000"},
|
||||
{"type": "trap_slow", "vnum": SPECIAL_SLOW_TRAP, "count": 1, "weight": 20, "name": "减速毒雾"},
|
||||
],
|
||||
VNUM_GOLD_BOX: [
|
||||
{"type": "item", "vnum": 25040, "count": 2, "weight": 25, "name": "祝福卷轴 x2"},
|
||||
{"type": "item", "vnum": 50513, "count": 1, "weight": 20, "name": "魂石"},
|
||||
{"type": "item", "vnum": 71001, "count": 2, "weight": 20, "name": "驱魔卷轴 x2"},
|
||||
{"type": "gold", "vnum": SPECIAL_GOLD, "count": 80000, "weight": 35, "name": "金币 80,000"},
|
||||
]
|
||||
}
|
||||
|
||||
static func is_gift_box(vnum: int) -> bool:
|
||||
return vnum in [
|
||||
VNUM_MOONLIGHT_BOX, VNUM_CHIEF_ORC_CHEST, VNUM_DARK_LEADER_CHEST,
|
||||
VNUM_SPIDER_QUEEN_CHEST, VNUM_NINE_TAILS_CHEST, VNUM_FLAME_KING_CHEST,
|
||||
VNUM_REAPER_CHEST
|
||||
]
|
||||
|
||||
static func is_treasure_box(vnum: int) -> bool:
|
||||
return vnum in [VNUM_SILVER_BOX, VNUM_GOLD_BOX]
|
||||
|
||||
static func is_treasure_key(vnum: int) -> bool:
|
||||
return vnum in [VNUM_SILVER_KEY, VNUM_GOLD_KEY]
|
||||
|
||||
static func is_matching_key(key_vnum: int, box_vnum: int) -> bool:
|
||||
return KEY_BOX_PAIRS.get(key_vnum, 0) == box_vnum
|
||||
|
||||
# 开启普通礼盒(无需钥匙,直接右键)
|
||||
static func open_gift_box(
|
||||
box_vnum: int,
|
||||
empty_inventory_slots: int,
|
||||
roll_val := -1
|
||||
) -> Dictionary:
|
||||
if not is_gift_box(box_vnum):
|
||||
return {"success": false, "err": "not_gift_box", "msg": "该物品无法直接开启。"}
|
||||
|
||||
if empty_inventory_slots < 1:
|
||||
return {"success": false, "err": "inventory_full", "msg": "背包已满,无法开启宝箱。"}
|
||||
|
||||
return _roll_chest_reward(box_vnum, roll_val)
|
||||
|
||||
# 使用钥匙开启上锁宝箱(拖拽钥匙至宝箱)
|
||||
static func open_treasure_box_with_key(
|
||||
key_vnum: int,
|
||||
box_vnum: int,
|
||||
empty_inventory_slots: int,
|
||||
roll_val := -1
|
||||
) -> Dictionary:
|
||||
if not is_treasure_box(box_vnum):
|
||||
return {"success": false, "err": "not_treasure_box", "msg": "这不是一个需要钥匙的宝箱。"}
|
||||
|
||||
if not is_matching_key(key_vnum, box_vnum):
|
||||
return {"success": false, "err": "key_mismatch", "msg": "这把钥匙无法打开该宝箱。"}
|
||||
|
||||
if empty_inventory_slots < 1:
|
||||
return {"success": false, "err": "inventory_full", "msg": "背包已满,无法开启宝箱。"}
|
||||
|
||||
var res := _roll_chest_reward(box_vnum, roll_val)
|
||||
res["consumed_key_vnum"] = key_vnum
|
||||
res["consumed_box_vnum"] = box_vnum
|
||||
return res
|
||||
|
||||
# 抽选掉落物
|
||||
static func _roll_chest_reward(box_vnum: int, roll_val: int) -> Dictionary:
|
||||
var drops: Array = DROP_TABLES.get(box_vnum, DROP_TABLES[VNUM_MOONLIGHT_BOX])
|
||||
var total_weight := 0
|
||||
for d in drops:
|
||||
total_weight += int(d.get("weight", 10))
|
||||
|
||||
var dice: int = roll_val if roll_val > 0 else (randi() % total_weight)
|
||||
dice = dice % total_weight # 约束范围
|
||||
|
||||
var accum := 0
|
||||
var chosen: Dictionary = drops[0]
|
||||
for d in drops:
|
||||
accum += int(d.get("weight", 10))
|
||||
if dice < accum:
|
||||
chosen = d
|
||||
break
|
||||
|
||||
return {
|
||||
"success": true,
|
||||
"box_vnum": box_vnum,
|
||||
"reward_type": chosen.get("type", "item"),
|
||||
"reward_vnum": chosen.get("vnum", 0),
|
||||
"reward_count": chosen.get("count", 1),
|
||||
"reward_name": chosen.get("name", "神秘道具"),
|
||||
"is_trap": String(chosen.get("type", "")).begins_with("trap_"),
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://8amkno8ks12i
|
||||
@@ -6,21 +6,10 @@
|
||||
# 资源目录由 AssetRoot 决定:40250 Client/pack 资源包。
|
||||
extends Node
|
||||
|
||||
const PackageRenderTest = preload("res://package_render_test.gd")
|
||||
const ForestMapRenderTest = preload("res://forest_map_render_test.gd")
|
||||
|
||||
func _ready() -> void:
|
||||
if OS.get_environment("MT_TEST_MODE") == "particle_exit":
|
||||
add_child(preload("res://particle_exit_test.gd").new())
|
||||
return
|
||||
# MT_TEST_MODE=render:包内离线渲染自检,不起 CPythonApplication、不连服务器。
|
||||
# 导出模板不接受 --script,这是导出包里唯一能跑离线渲染回归的入口。
|
||||
if OS.get_environment("MT_TEST_MODE") == "render":
|
||||
AssetPack.ensure()
|
||||
var t := PackageRenderTest.new()
|
||||
t.name = "PackageRenderTest"
|
||||
add_child(t)
|
||||
return
|
||||
# MT_TEST_MODE=python:在 app 进程里起内嵌解释器跑 system.py(PORT-PLAN 批次 2P step 4)。
|
||||
# 导出模板不接受 --script,这是在真机 APK/IPA 里跑这条检查的唯一入口。
|
||||
if OS.get_environment("MT_TEST_MODE") == "python":
|
||||
@@ -32,11 +21,6 @@ func _ready() -> void:
|
||||
printerr("%d check(s) failed" % fail)
|
||||
get_tree().quit(1 if fail else 0)
|
||||
return
|
||||
if OS.get_environment("MT_TEST_MODE") == "forest_render":
|
||||
var forest := ForestMapRenderTest.new()
|
||||
forest.name = "ForestMapRenderTest"
|
||||
add_child(forest)
|
||||
return
|
||||
|
||||
# 原生 port 路径:40250 system.py 的界面直接画在 Godot Canvas 上(PORT-PLAN 2V0-f),
|
||||
# Logo → 登录 → 选人 → 进游戏,RenderGame 的 3D 绘制由 python_3d_surface.gd 画出(2V2-e),
|
||||
|
||||
@@ -1,706 +0,0 @@
|
||||
# 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)
|
||||
signal combo_changed(enabled: bool)
|
||||
var main := 1000
|
||||
var skills := []
|
||||
var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}}
|
||||
var attacks := []
|
||||
var moves := []
|
||||
var order := []
|
||||
var synced := []
|
||||
var said := []
|
||||
var affects := []
|
||||
var fly_targets := [] # §3.6:CG_FLY_TARGETING
|
||||
var primary_fly_targets := []
|
||||
var shots := [] # §3.6:CG_SHOOT
|
||||
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_entities() -> Array: return ents.values()
|
||||
func get_affects() -> Array: return affects
|
||||
func attack(motion, vid) -> bool: attacks.append([motion, vid]); return true
|
||||
func add_fly_targeting(vid, x, y) -> bool: fly_targets.append([vid, x, y]); return true
|
||||
func fly_targeting(vid, x, y) -> bool:
|
||||
primary_fly_targets.append([vid, x, y])
|
||||
fly_targets.append([vid, x, y])
|
||||
return true
|
||||
func shoot(skill) -> bool: shots.append(skill); return true
|
||||
func set_target(v) -> bool: return true
|
||||
func move(a, b, c, d, e) -> bool: moves.append([a, b, c, d, e]); order.append("state"); return true
|
||||
func sync_positions(arr) -> bool: synced.append(arr); return true
|
||||
func say(t, s) -> bool: said.append([t, s]); return true
|
||||
func get_skills() -> Array: return skills
|
||||
|
||||
class FakeNetWorld extends Node:
|
||||
var nodes := {}
|
||||
var pushes := [] # [vid, dir(actor-world xy), force]
|
||||
var pushing := {}
|
||||
func node_for(vid: int) -> Node3D: return nodes.get(vid, null)
|
||||
func push_victim(vid: int, dir: Vector2, force: float) -> void:
|
||||
pushes.append([vid, dir, force])
|
||||
pushing[vid] = true
|
||||
func is_pushing(vid: int) -> bool: return bool(pushing.get(vid, false))
|
||||
func blending_position(vid: int) -> Vector3: return nodes[vid].global_position + Vector3(0.1, 0, 0)
|
||||
|
||||
# 受击方视图桩:.msm 防御球 + InsertDelay + __Hit* 反应
|
||||
class FakeVictim extends Node3D:
|
||||
var spheres := [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}]
|
||||
var delays := []
|
||||
var reactions := []
|
||||
func get_defending_spheres() -> Array: return spheres
|
||||
func insert_delay(t: float) -> void: delays.append(t)
|
||||
func hit_good(scalar: float, _stunned: bool) -> void: reactions.append(["good", scalar])
|
||||
func hit_greate(scalar: float, _stunned: bool) -> void: reactions.append(["greate", scalar])
|
||||
func hit_stone(_stunned: bool) -> void: reactions.append(["stone", 0.0])
|
||||
|
||||
class FakeFx extends Node:
|
||||
var spawned := [] # [name, global_pos, node]
|
||||
func spawn_at(n: String, parent: Node3D, pos: Vector3, _one_shot := true) -> Node3D:
|
||||
var e := Node3D.new()
|
||||
parent.add_child(e)
|
||||
e.global_position = pos
|
||||
spawned.append([n, pos, e])
|
||||
return e
|
||||
|
||||
class FakeProto extends Node:
|
||||
var items := {}
|
||||
func item(vnum: int) -> Dictionary: return items.get(vnum, {})
|
||||
|
||||
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 FakeAnim extends Node:
|
||||
var md := {}
|
||||
func get_motion_data() -> Dictionary: return md
|
||||
|
||||
class FakeView extends Node3D:
|
||||
signal motion_bound(state: String)
|
||||
var states := []
|
||||
var attack_motions := [] # [motion_mode, motion_index, speed_ratio]
|
||||
var order: Array = []
|
||||
var delays := []
|
||||
var anim := FakeAnim.new()
|
||||
func _init() -> void:
|
||||
add_child(anim)
|
||||
func set_anim_state(s): states.append(s)
|
||||
func play_attack_motion(mode: int, index: int, speed: float) -> void:
|
||||
attack_motions.append([mode, index, speed])
|
||||
order.append("motion")
|
||||
motion_bound.emit("attack")
|
||||
func insert_delay(t: float) -> void: delays.append(t)
|
||||
|
||||
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 _reset_hit(np: Node, fc: Node, nw: Node, fx: Node) -> void:
|
||||
np._hit_dedup.clear()
|
||||
np._victim_invisible_until.clear()
|
||||
np._def_last.clear()
|
||||
np._delay = 0.0
|
||||
np._victim_flush.clear()
|
||||
fc.attacks.clear()
|
||||
fc.synced.clear()
|
||||
nw.pushes.clear()
|
||||
nw.pushing.clear()
|
||||
fx.spawned.clear()
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: combat_fx_test (hit-gate / 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
|
||||
|
||||
# --- 受击:播击退动作 + 抖屏;「命中」不靠固定硬直秒数锁输入(§3.7)---
|
||||
fc.damage.emit(1000, 20, 0) # 命中(非闪避)
|
||||
_ck(np._knock_down, "hit -> knock_down latch set")
|
||||
_ck(np.is_stunned(), "hit (knock_down) -> cannot act")
|
||||
_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, "knock_down -> pc.frozen = true (got %s)" % pc.frozen)
|
||||
|
||||
# 击退动作结束 -> 解除(动作时长驱动)
|
||||
np._knock_down = false
|
||||
_ck(not np.is_stunned(), "knockback motion ended -> can act")
|
||||
|
||||
# 服务端 stun 标志独立驱动闸门
|
||||
fc.ents[1000]["stunned"] = true
|
||||
_ck(np.is_stunned(), "server stunned flag -> cannot act")
|
||||
_ck(not np._can_attack(), "server stunned -> _can_attack() false")
|
||||
fc.ents[1000]["stunned"] = false
|
||||
_ck(not np.is_stunned(), "server stun cleared -> can act")
|
||||
|
||||
# 闪避不播击退
|
||||
np._knock_down = false
|
||||
fc.damage.emit(1000, 0, 1 << 2) # DODGE
|
||||
_ck(not np._knock_down, "dodge -> no knock_down")
|
||||
_ck(not np.is_stunned(), "dodge -> can act")
|
||||
|
||||
# 别人受击不影响自己
|
||||
np._knock_down = false
|
||||
fc.damage.emit(2000, 30, 0)
|
||||
_ck(not np._knock_down, "other's damage -> no knock_down")
|
||||
|
||||
# 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._knock_down = false
|
||||
np._target_vid = 2000
|
||||
np.attack_period = 0.0
|
||||
# §3.3:普攻节奏来自动作数据 / 攻速系数,不再是硬编码常量
|
||||
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD),
|
||||
"no motion data -> attack period falls back to DEFAULT_ATTACK_PERIOD")
|
||||
np._apply_attack_speed(150)
|
||||
_ck(is_equal_approx(np._atk_speed_factor, 1.5), "attack_speed 150 -> factor 1.5")
|
||||
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5),
|
||||
"attack speed scales the resolved period")
|
||||
# CInstanceBase::SetAttackSpeed:uAtkSpd > 1100 -> 0;否则 /100,无上下限
|
||||
np._apply_attack_speed(400)
|
||||
_ck(is_equal_approx(np._atk_speed_factor, 4.0), "attack_speed 400 -> factor 4.0 (no clamp)")
|
||||
np._apply_attack_speed(1200)
|
||||
_ck(np._atk_speed_factor == 0.0, "attack_speed > 1100 -> factor 0")
|
||||
_ck(is_finite(np._current_attack_period()), "factor 0 -> attack period stays finite")
|
||||
np._apply_attack_speed(100)
|
||||
|
||||
# --- CGraphicThingInstance::InsertDelay / UpdateTime:本地时间 ---
|
||||
np._local_time = 5.0
|
||||
np._delay = 0.05
|
||||
np._advance_local_time(0.02)
|
||||
_ck(is_equal_approx(np._local_time, 5.0) and is_equal_approx(np._delay, 0.03),
|
||||
"delay > elapsed -> local time frozen, delay shrinks")
|
||||
np._advance_local_time(0.05)
|
||||
_ck(is_equal_approx(np._local_time, 5.02) and np._delay == 0.0,
|
||||
"delay < elapsed -> local time advances by the remainder")
|
||||
np._apply_attack_speed(200)
|
||||
np._motion_start_t = np._local_time - 0.1
|
||||
_ck(is_equal_approx(np._attacking_elapsed(), 0.2),
|
||||
"GetAttackingElapsedTime = (local - start) * fSpeedRatio")
|
||||
np._apply_attack_speed(100)
|
||||
var lt0: float = np._local_time
|
||||
np._process(0.016)
|
||||
_ck(np._local_time > lt0, "_process advances the local time")
|
||||
|
||||
# --- SetComboSkillFlag(PythonPlayerSkill.cpp):GC 连击开关 -> SetComboType(MIN(lv,2)) ---
|
||||
fc.skills = [{"id": 122, "level": 3, "master": 0}]
|
||||
fc.combo_changed.emit(true)
|
||||
_ck(np._combo_type == 2, "combo on + skill 122 lv3 -> combo type MIN(3,2)=2, got %d" % np._combo_type)
|
||||
fc.combo_changed.emit(false)
|
||||
_ck(np._combo_type == 0, "combo off -> combo type 0")
|
||||
fc.skills = [{"id": 122, "level": 1, "master": 0}]
|
||||
fc.combo_changed.emit(true)
|
||||
_ck(np._combo_type == 1, "combo on + skill 122 lv1 -> combo type 1")
|
||||
fc.skills = [{"id": 122, "level": 0, "master": 0}]
|
||||
fc.combo_changed.emit(false)
|
||||
_ck(np._combo_type == 1, "skill 122 level 0 -> return before SetComboType")
|
||||
fc.skills = []
|
||||
fc.combo_changed.emit(false)
|
||||
_ck(np._combo_type == 1, "no skill 122 slot -> return before SetComboType")
|
||||
np._combo_type = 0
|
||||
|
||||
# --- §3.5 连击状态机:InputComboAttackCommand / __RunNextCombo / ComboProcess ---
|
||||
# 合成一张 4 段的 1H type1 段表(段号 14..17),并给 player_view 一个可控 .msa。
|
||||
var K: int = (np.MOTION_MODE_GENERAL << 16) | 0
|
||||
np._combo_tables = {0: {K: PackedInt32Array([14, 15, 16, 17])}}
|
||||
np.combo_class = 0
|
||||
np.combo_motion_mode = np.MOTION_MODE_GENERAL
|
||||
view.anim.md = {
|
||||
"has_combo_input": true, "duration": 1.0,
|
||||
"pre_input_time": 0.10, "direct_input_time": 0.20, "input_limit_time": 0.40,
|
||||
"next_combo": 0.20,
|
||||
}
|
||||
view.order = fc.order
|
||||
var pcn := Node3D.new(); get_root().add_child(pcn)
|
||||
np.pc.player = pcn
|
||||
np._vid_reserved = 2000
|
||||
fc.attacks.clear()
|
||||
await process_frame # SceneTree 脚本:等一帧让 add_child 的节点真正入树(global_position 才有效)
|
||||
|
||||
# 第一次输入:m_dwcurComboIndex 0 -> __RunNextCombo -> 段 1(段号 14)
|
||||
np._do_attack_swing(pcn, {})
|
||||
_ck(fc.order.size() >= 2 and fc.order[-2] == "motion" and fc.order[-1] == "state",
|
||||
"attack motion is bound before FUNC_COMBO state is sent")
|
||||
_ck(np._combo_index == 1, "first input -> combo index 1, got %d" % np._combo_index)
|
||||
_ck(fc.attacks.is_empty(), "motion without AttackingData -> no synthetic CG_ATTACK")
|
||||
_ck(not np._has_attack_motion_data, "missing AttackingData is recorded as invalid local hit source")
|
||||
_ck(fc.moves.size() >= 1 and fc.moves.back()[0] == np.FUNC_COMBO and fc.moves.back()[1] == 14,
|
||||
"FUNC_COMBO carries seg no. 14 (NAME_COMBO_ATTACK_1)")
|
||||
_ck(view.attack_motions.size() == 1 and view.attack_motions[0] == [np.MOTION_MODE_GENERAL, 14, 1.0],
|
||||
"swing binds the seg motion (mode, index 14, speed ratio 1.0), got %s" % [view.attack_motions])
|
||||
|
||||
# 立刻再输入(elapsed ~0 < pre_input_time):既不推进也不置 pre-input
|
||||
np._do_attack_swing(pcn, {})
|
||||
_ck(np._combo_index == 1 and not np._is_pre_input, "input before InputStartTime -> ignored")
|
||||
|
||||
# elapsed 落在 [start, next):置 m_isPreInput,不推进(GetAttackingElapsedTime 基于本地时间)
|
||||
np._motion_start_t = np._local_time - 0.15
|
||||
np._do_attack_swing(pcn, {})
|
||||
_ck(np._combo_index == 1 and np._is_pre_input, "input in [start,next) -> pre-input latched")
|
||||
|
||||
# ComboProcess:elapsed 过了 NextComboTime -> 触发挂起的 pre-input -> 段 2
|
||||
np._motion_start_t = np._local_time - 0.25
|
||||
np._combo_process()
|
||||
_ck(np._combo_index == 2 and not np._is_pre_input, "ComboProcess fires pre-input -> combo index 2")
|
||||
|
||||
# elapsed 过 NextComboTime 直接输入 -> 立即推进(段 3、段 4)
|
||||
np._motion_start_t = np._local_time - 0.30
|
||||
np._do_attack_swing(pcn, {})
|
||||
_ck(np._combo_index == 3, "input past NextComboTime -> advance to 3")
|
||||
np._motion_start_t = np._local_time - 0.30
|
||||
np._do_attack_swing(pcn, {})
|
||||
_ck(np._combo_index == 4, "advance to last seg 4")
|
||||
|
||||
# 段 4 是最后一段:__OnEndCombo(非骑乘不复位),再输入越界不推进
|
||||
np._motion_start_t = np._local_time - 0.30
|
||||
np._do_attack_swing(pcn, {})
|
||||
_ck(np._combo_index == 4, "past last seg -> no further advance (non-horse)")
|
||||
|
||||
# 参考端 ComboProcess 没有「超过整段时长就清连击」:elapsed > duration 段号保持
|
||||
np._motion_start_t = np._local_time - 1.5
|
||||
np._combo_process()
|
||||
_ck(np._combo_index == 4, "ComboProcess has no duration timeout -> combo index kept, got %d" % np._combo_index)
|
||||
# __SetMotion 尾:新绑定的动作没有 ComboInputData(回 Wait)-> m_dwcurComboIndex = 0
|
||||
view.anim.md = {"duration": 2.0}
|
||||
view.motion_bound.emit("wait")
|
||||
_ck(np._combo_index == 0, "bound motion without ComboInputData -> combo index 0")
|
||||
# __SetMotion 尾:绑定带 MotionAttackData 的动作 -> m_HitDataMap.clear()
|
||||
np._hit_dedup = {0: {2000: 1.0}}
|
||||
view.anim.md = {"has_attacking_data": true, "has_combo_input": true, "duration": 1.0}
|
||||
view.motion_bound.emit("attack")
|
||||
_ck(np._hit_dedup.is_empty(), "bound attacking motion -> hit data map cleared")
|
||||
|
||||
# 没有段表(缺资源)-> 仍按 40250 的单段普攻状态包(段号 13);没有
|
||||
# AttackingData 时不能伪造 CG_ATTACK,攻速系数仍原样传给动作播放。
|
||||
np._combo_tables = {}
|
||||
fc.moves.clear()
|
||||
np._apply_attack_speed(150)
|
||||
np._do_attack_swing(pcn, {})
|
||||
_ck(np._combo_index == 0 and fc.moves.back()[0] == np.FUNC_COMBO \
|
||||
and fc.moves.back()[1] == np.NAME_NORMAL_ATTACK,
|
||||
"no combo table -> single NORMAL_ATTACK swing")
|
||||
_ck(view.attack_motions.back() == [np.MOTION_MODE_GENERAL, np.NAME_NORMAL_ATTACK, 1.5],
|
||||
"attack motion plays at fSpeedRatio 1.5, got %s" % [view.attack_motions.back()])
|
||||
_ck(fc.attacks.is_empty(), "missing .msa HitDataContainer -> no local CG_ATTACK fallback")
|
||||
np._apply_attack_speed(100)
|
||||
|
||||
# --- __NormalAttackProcess 的 m_HitDataMap:新窗 insert;旧窗 map::insert 不覆盖 + 每窗上限 ---
|
||||
np._hit_dedup.clear()
|
||||
np._hit_motion_type = np.MOTION_TYPE_SKILL
|
||||
np._hit_limit_count = 2
|
||||
np._hit_invisible_time = 0.5
|
||||
np._local_time = 10.0
|
||||
_ck(np._register_hit(0, 1) and is_equal_approx(float(np._hit_dedup[0][1]), 10.5),
|
||||
"first hit of a window -> insert local + fInvisibleTime, process")
|
||||
np._local_time = 11.0
|
||||
_ck(np._register_hit(0, 1) and is_equal_approx(float(np._hit_dedup[0][1]), 10.5),
|
||||
"victim already in the window -> map::insert keeps the old time, count 1 <= limit")
|
||||
_ck(np._register_hit(0, 2), "second victim -> count 2 <= iHitLimitCount 2")
|
||||
_ck(not np._register_hit(0, 3), "third victim -> count 3 > iHitLimitCount -> FALSE")
|
||||
np._hit_limit_count = 0
|
||||
np._hit_dedup.clear()
|
||||
_ck(np._register_hit(0, 1), "SKILL: first hit of a window skips the limit check")
|
||||
_ck(not np._register_hit(0, 1), "SKILL iHitLimitCount 0 -> next hit in that window 1 > 0 -> FALSE")
|
||||
np._hit_motion_type = np.MOTION_TYPE_COMBO
|
||||
for i in 16:
|
||||
np._register_hit(1, 100 + i)
|
||||
_ck(not np._register_hit(1, 200), "COMBO/NORMAL: 17th victim in one window -> > 16 -> FALSE")
|
||||
np._hit_dedup.clear()
|
||||
|
||||
# --- §3.5 修改 4 / §3.7:__NormalAttackProcess 扫掠球 vs .msm 防御球 -> __ProcessDataAttackSuccess ---
|
||||
# SceneTree 脚本里 add_child 到 root 的节点要等一帧才真正 is_inside_tree(),
|
||||
# 几何判定读 global_position,所以这一小节先 await 一帧。
|
||||
var nw := FakeNetWorld.new(); get_root().add_child(nw)
|
||||
np.net_world = nw
|
||||
var fx := FakeFx.new(); get_root().add_child(fx)
|
||||
var fx_parent := Node3D.new(); get_root().add_child(fx_parent)
|
||||
np.fx = fx
|
||||
np.fx_parent = fx_parent
|
||||
var vnode := FakeVictim.new(); get_root().add_child(vnode)
|
||||
nw.nodes[2000] = vnode
|
||||
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "race": 101, "hp": 50, "dead": false, "owner_vid": 1000}
|
||||
fc.ents[1000]["dead"] = false
|
||||
await process_frame
|
||||
vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 m(yaw 0:模型 -Y = Godot +Z)
|
||||
vnode.rotation.y = 0.0
|
||||
pcn.global_position = Vector3.ZERO
|
||||
pcn.rotation.y = 0.0
|
||||
# 一个采样:t=0.1 时刀尖球从模型本地 (-60,-80) 横扫到 (60,-80),高 1 m
|
||||
view.anim.md = {
|
||||
"has_attacking_data": true,
|
||||
"hit_windows": [{
|
||||
"start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand", "weapon_length": 120.0,
|
||||
"samples": [{"time": 0.1, "last_pos": Vector3(-60, -80, 100), "pos": Vector3(60, -80, 100)}],
|
||||
}],
|
||||
"motion_type": np.MOTION_TYPE_COMBO, "hitting_type": np.HIT_TYPE_GOOD, "hit_limit_count": 0,
|
||||
"invisible_time": 0.1, "stiffen_time": 0.05, "external_force": 3.0, "next_combo": 0.2,
|
||||
}
|
||||
np._combo_tables = {}
|
||||
np._target_vid = 0
|
||||
view.delays.clear()
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
np._do_attack_swing(pcn, {}) # _emit_swing:有命中窗 -> CG_ATTACK 不在挥击时发
|
||||
_ck(fc.attacks.size() == 0, "hit windows present -> CG_ATTACK deferred off the swing")
|
||||
_ck(np._hit_windows.size() == 1, "swing cached the .msa hit window")
|
||||
# 采样时刻 0.1 不在 [motiontime - elapsed, motiontime] 内 -> 不判定
|
||||
np._motion_start_t = np._local_time - 0.2
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.is_empty(), "no .msa sample inside [t-dt, t] -> no hit")
|
||||
np._motion_start_t = np._local_time - 0.105
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks == [[0, 2000]], "sweep sphere crosses the defending sphere -> OnHit CG_ATTACK(0, 2000), got %s" % [fc.attacks])
|
||||
_ck(np._target_vid == 2000, "OnHit -> SetTarget(victim)")
|
||||
_ck(is_equal_approx(np._delay, 0.05) and view.delays == [0.05], "attacker InsertDelay(fStiffenTime), got %s" % [view.delays])
|
||||
_ck(vnode.delays == [0.05], "victim InsertDelay(fStiffenTime), got %s" % [vnode.delays])
|
||||
_ck(float(np._victim_invisible_until.get(2000, 0.0)) > np._now(), "victim m_fInvisibleTime = now + fInvisibleTime")
|
||||
_ck(vnode.reactions == [["good", 1.0]], "HIT_TYPE_GOOD -> __HitGood(scalar cos(0) = 1), got %s" % [vnode.reactions])
|
||||
_ck(fx.spawned.size() == 1 and fx.spawned[0][0] == np.EFFECT_HIT \
|
||||
and Vector3(fx.spawned[0][1]).is_equal_approx(vnode.global_position),
|
||||
"blow_1_low.mse spawned at the victim position, got %s" % [fx.spawned])
|
||||
if fx.spawned.size() == 1:
|
||||
_ck(absf(absf((fx.spawned[0][2] as Node3D).rotation.y) - PI) < 0.01, "hit effect yaw = atan2(-dx, -dz) = ±π")
|
||||
_ck(nw.pushes.size() == 1 and nw.pushes[0][0] == 2000 and Vector2(nw.pushes[0][1]).is_equal_approx(Vector2(0, -1)) \
|
||||
and is_equal_approx(float(nw.pushes[0][2]), 3.0),
|
||||
"__PushCircle + IncreaseExternalForce(3) along victim - attacker (actor-world (0,-1)), got %s" % [nw.pushes])
|
||||
np._flush_victim_list()
|
||||
var bxy := MapCoord.to_server_cm(vnode.global_position + Vector3(0.1, 0, 0))
|
||||
_ck(fc.synced.size() == 1 and fc.synced[0][0]["vid"] == 2000 and fc.synced[0][0]["x"] == int(bxy.x) \
|
||||
and fc.synced[0][0]["y"] == int(bxy.y),
|
||||
"pushing victim -> frame-end CG_SYNC_POSITION at its blending position, got %s" % [fc.synced])
|
||||
# COMBO:同窗同目标只一次(先把受击方无敌时间去掉,单独验 m_HitDataMap)
|
||||
np._victim_invisible_until.clear()
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.size() == 1, "COMBO motion_type -> same window/victim hits once only")
|
||||
# AttackingProcess:rVictim.__isInvisible() -> FALSE
|
||||
np._hit_dedup.clear()
|
||||
np._def_last.clear()
|
||||
np._victim_invisible_until[2000] = np._now() + 5.0
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.size() == 1, "victim still inside m_fInvisibleTime -> no hit")
|
||||
|
||||
# __CanPushDestActor:owner 不是攻击者 / owner 超 3 秒 -> 不推;晕眩 -> 推
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
fc.ents[2000]["owner_vid"] = 0
|
||||
np._attack_process(0.016)
|
||||
np._flush_victim_list()
|
||||
_ck(fc.attacks.size() == 1 and nw.pushes.is_empty() and fc.synced.is_empty(),
|
||||
"victim not owned by the attacker -> hit, no push, no CG_SYNC_POSITION")
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
fc.ents[2000]["owner_vid"] = 1000
|
||||
np._owner_seen[2000] = [1000, np._now() - 3.5]
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.size() == 1 and nw.pushes.is_empty(), "owner time > 3 s -> no push")
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
fc.ents[2000]["stunned"] = true
|
||||
np._attack_process(0.016)
|
||||
_ck(nw.pushes.size() == 1, "stunned victim -> always pushable")
|
||||
fc.ents[2000]["stunned"] = false
|
||||
np._owner_seen.erase(2000)
|
||||
|
||||
# HIT_TYPE_NONE:__ProcessDataAttackSuccess 直接 return(不硬直、不发包);GREAT -> __HitGreate
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
view.delays.clear()
|
||||
vnode.delays.clear()
|
||||
vnode.reactions.clear()
|
||||
np._hit_type = np.HIT_TYPE_NONE
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.is_empty() and view.delays.is_empty() and vnode.delays.is_empty() and fx.spawned.is_empty(),
|
||||
"HIT_TYPE_NONE -> no delay / effect / OnHit")
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
np._hit_type = np.HIT_TYPE_GREAT
|
||||
np._attack_process(0.016)
|
||||
_ck(vnode.reactions.size() == 1 and vnode.reactions[0][0] == "greate", "HIT_TYPE_GREAT -> __HitGreate, got %s" % [vnode.reactions])
|
||||
np._hit_type = np.HIT_TYPE_GOOD
|
||||
|
||||
# 距离怪癖:v3Distance = (dX, dZ, dZ) —— 平面 Y 不参与,高度差算两次
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
vnode.global_position = Vector3(3.5, 0, 1.0)
|
||||
vnode.spheres = [{"radius": 45.0, "pos": Vector3(-350, 0, 100), "bone": ""}]
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.is_empty(), "|dX| 350 >= 300 -> distance reject although the spheres touch")
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
vnode.global_position = Vector3(0, 0, 3.5)
|
||||
vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 270, 100), "bone": ""}]
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.size() == 1, "|dY| 350 is not part of the distance check -> hit")
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
vnode.global_position = Vector3(0, 2.2, 1.0)
|
||||
vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}]
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.is_empty(), "height 2.2 m -> 2 * 220² >= 300² -> reject")
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
vnode.global_position = Vector3(0, 2.0, 1.0)
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.size() == 1, "height 2.0 m -> 2 * 200² < 300², Z cylinder ignores height -> hit")
|
||||
|
||||
# IS_HUGE_RACE:500 cm 距离、不推、特效在 (攻击者 + 防御球) * 0.5
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
fc.ents[2000]["race"] = 2493
|
||||
vnode.global_position = Vector3(4.0, 0, 1.0)
|
||||
vnode.spheres = [{"radius": 45.0, "pos": Vector3(-400, 0, 100), "bone": ""}]
|
||||
np._attack_process(0.016)
|
||||
np._flush_victim_list()
|
||||
_ck(fc.attacks.size() == 1 and nw.pushes.is_empty() and fc.synced.is_empty(),
|
||||
"IS_HUGE_RACE: 400 cm < 500 -> hit, no push, no sync")
|
||||
_ck(fx.spawned.size() == 1 and Vector3(fx.spawned[0][1]).is_equal_approx(Vector3(0, 0.5, 0.5)),
|
||||
"IS_HUGE_RACE: effect at (attacker + defending sphere) * 0.5, got %s" % [fx.spawned])
|
||||
fc.ents[2000]["race"] = 101
|
||||
|
||||
# 防御球随受击方朝向旋转;__HitGood 的 scalar = cos(攻击者朝向 - 受击方朝向)
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
vnode.reactions.clear()
|
||||
vnode.global_position = Vector3(0, 0, 1.6)
|
||||
vnode.spheres = [{"radius": 25.0, "pos": Vector3(0, -60, 100), "bone": ""}]
|
||||
vnode.rotation.y = 0.0
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.is_empty(), "victim yaw 0 -> defending sphere 220 cm ahead -> miss")
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
vnode.rotation.y = PI
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.size() == 1, "victim yaw π -> sphere swings to 100 cm -> hit")
|
||||
_ck(vnode.reactions.size() == 1 and is_equal_approx(float(vnode.reactions[0][1]), -1.0),
|
||||
"__HitGood scalar = cos(0 - π) = -1, got %s" % [vnode.reactions])
|
||||
vnode.rotation.y = 0.0
|
||||
|
||||
# 身后:扫掠球够不到
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
vnode.global_position = Vector3(0, 0, -1.0)
|
||||
vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}]
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.is_empty(), "victim behind the attacker -> sweep misses")
|
||||
|
||||
# 占位节点(无 .msm 防御球):40250 的空 defending list 不命中,不退化为胶囊。
|
||||
var ph := Node3D.new(); get_root().add_child(ph)
|
||||
await process_frame
|
||||
ph.global_position = Vector3(0, 0, 1.0)
|
||||
nw.nodes[2000] = ph
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
np._motion_start_t = np._local_time - 0.105
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.is_empty(), "node without .msm defending spheres -> no synthetic hit")
|
||||
nw.nodes[2000] = vnode
|
||||
vnode.global_position = Vector3(0, 0, 1.0)
|
||||
|
||||
np._motion_start_t = np._local_time - 2.0 # 动作越过所有命中窗
|
||||
np._attack_process(0.016)
|
||||
_ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared")
|
||||
# 目标已死:挥击命中窗内也不发 CG_ATTACK(死亡目标不是有效命中)
|
||||
fc.ents[2000]["dead"] = true
|
||||
np._do_attack_swing(pcn, {})
|
||||
_reset_hit(np, fc, nw, fx)
|
||||
np._motion_start_t = np._local_time - 0.105
|
||||
np._attack_process(0.016)
|
||||
_ck(fc.attacks.is_empty(), "dead victim inside the hit window -> no CG_ATTACK")
|
||||
fc.ents[2000]["dead"] = false
|
||||
np.net_world = null
|
||||
np.fx = null
|
||||
|
||||
# --- §3.5 修改 6:武器种类 -> combo_motion_mode(RefreshState 的 SetMotionMode 分支)---
|
||||
# 纯映射表:CItemData 类型/子类型 + 骑乘/变身 -> CRaceMotionData::EMode
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, false, false, false) \
|
||||
== np.MOTION_MODE_ONEHAND_SWORD, "sword -> ONEHAND_SWORD")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_TWO_HANDED, false, false, false) \
|
||||
== np.MOTION_MODE_TWOHAND_SWORD, "two-handed -> TWOHAND_SWORD")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_DAGGER, false, false, false) \
|
||||
== np.MOTION_MODE_DUALHAND_SWORD, "dagger -> DUALHAND_SWORD")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BOW, false, false, false) \
|
||||
== np.MOTION_MODE_BOW, "bow -> BOW")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_FAN, false, false, false) \
|
||||
== np.MOTION_MODE_FAN, "fan -> FAN")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BELL, false, false, false) \
|
||||
== np.MOTION_MODE_BELL, "bell -> BELL")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_ARROW, false, false, false) \
|
||||
== np.MOTION_MODE_GENERAL, "arrow / bare hand -> GENERAL")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_ROD, 0, false, false, false) \
|
||||
== np.MOTION_MODE_FISHING, "fishing rod -> FISHING")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_ROD, 0, true, false, false) \
|
||||
== np.MOTION_MODE_HORSE, "rod while mounted -> HORSE")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, true, false, false) \
|
||||
== np.MOTION_MODE_HORSE_ONEHAND_SWORD, "mounted sword -> HORSE_ONEHAND_SWORD")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BOW, true, false, false) \
|
||||
== np.MOTION_MODE_HORSE_BOW, "mounted bow -> HORSE_BOW")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_ARROW, true, false, false) \
|
||||
== np.MOTION_MODE_HORSE, "mounted arrow -> HORSE")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, false, true, false) \
|
||||
== np.MOTION_MODE_GENERAL, "poly overrides weapon -> GENERAL")
|
||||
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, true, false, true) \
|
||||
== np.MOTION_MODE_WEDDING_DRESS, "wedding dress overrides all -> WEDDING_DRESS")
|
||||
|
||||
# 端到端:proto 桩 + parts[WEAPON] -> _refresh_motion_mode() -> combo_motion_mode 变,且清连击
|
||||
var fp := FakeProto.new()
|
||||
get_root().add_child(fp)
|
||||
fp.items[19] = {"type": np.ITEM_TYPE_WEAPON, "sub_type": np.WEAPON_SUB_DAGGER}
|
||||
np.proto = fp
|
||||
np._main_vid = 1000
|
||||
np.combo_motion_mode = np.MOTION_MODE_GENERAL
|
||||
np._combo_index = 3
|
||||
fc.ents[1000]["parts"] = [0, 19, 0, 0]
|
||||
np._refresh_motion_mode()
|
||||
_ck(np.combo_motion_mode == np.MOTION_MODE_DUALHAND_SWORD, "parts[WEAPON]=dagger -> DUALHAND_SWORD")
|
||||
_ck(np._combo_index == 0, "motion mode change clears the running combo")
|
||||
fc.ents[1000]["mount_vnum"] = 20101
|
||||
np._refresh_motion_mode()
|
||||
_ck(np.combo_motion_mode == np.MOTION_MODE_HORSE_DUALHAND_SWORD, "mount up -> HORSE_DUALHAND_SWORD")
|
||||
fc.ents[1000].erase("parts")
|
||||
fc.ents[1000].erase("mount_vnum")
|
||||
np.proto = null
|
||||
|
||||
# --- §3.5 增量 85:弓箭 __GetBowRange() → _clickable_distance ---
|
||||
np._bow_distance_bonus = 0.0
|
||||
np.combo_motion_mode = np.MOTION_MODE_GENERAL
|
||||
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), np.CLICK_DIST_DEFAULT_CM / np.CM),
|
||||
"non-bow: 玩家 / 怪目标 reach = 150cm")
|
||||
np.combo_motion_mode = np.MOTION_MODE_BOW
|
||||
_ck(is_equal_approx(np._bow_range_cm(), 2500.0 - 100.0), "__GetBowRange 基础 = 2500-100")
|
||||
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), (2500.0 - 100.0) / np.CM),
|
||||
"bow mode: 非 NPC / 非采集物目标 reach = __GetBowRange()")
|
||||
_ck(is_equal_approx(np._clickable_distance({"ch_type": 1}), np.CLICK_DIST_NPC_CM / np.CM),
|
||||
"NPC 覆盖弓箭距离 → 500cm")
|
||||
_ck(is_equal_approx(np._clickable_distance({"ch_type": 3}), np.CLICK_DIST_RESOURCE_CM / np.CM),
|
||||
"采集物覆盖弓箭距离 → 100cm")
|
||||
np.combo_motion_mode = np.MOTION_MODE_HORSE_BOW
|
||||
_ck(np._is_bow_mode(), "HORSE_BOW 也算 bow mode")
|
||||
np._bow_distance_bonus = 300.0
|
||||
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), (2500.0 - 100.0 + 300.0) / np.CM),
|
||||
"POINT_BOW_DISTANCE 加成叠加进 __GetBowRange()")
|
||||
np._on_points({"attack_speed": 0, "bow_distance": 0})
|
||||
_ck(is_equal_approx(np._bow_distance_bonus, 0.0), "_on_points 读取 bow_distance → _bow_distance_bonus")
|
||||
np.combo_motion_mode = np.MOTION_MODE_GENERAL
|
||||
|
||||
# --- §3.6 增量 86:弓 FLY 事件(OnSetFlyTarget / OnShoot)---
|
||||
var nw2 := FakeNetWorld.new(); get_root().add_child(nw2)
|
||||
var tnode := Node3D.new(); get_root().add_child(tnode)
|
||||
await process_frame
|
||||
tnode.global_position = Vector3(3.0, 0.0, 4.0)
|
||||
nw2.nodes[2000] = tnode
|
||||
np.net_world = nw2
|
||||
np._target_vid = 2000
|
||||
np._swing_skill = 0
|
||||
np._combo_tables = {}
|
||||
view.anim.md = {}
|
||||
fc.fly_targets.clear()
|
||||
fc.primary_fly_targets.clear()
|
||||
fc.attacks.clear()
|
||||
var got_shot := []
|
||||
np.bow_shot_fired.connect(func(sk: int): got_shot.append(sk))
|
||||
# 非弓模式:起手不发 CG_FLY_TARGETING,也不冒 bow_shot_fired
|
||||
np.combo_motion_mode = np.MOTION_MODE_GENERAL
|
||||
np._emit_swing(np.NAME_NORMAL_ATTACK, false)
|
||||
_ck(fc.fly_targets.is_empty() and got_shot.is_empty(), "非弓模式挥击 → 无 FLY 事件")
|
||||
# 弓模式:起手即发 CG_FLY_TARGETING(目标VID, 目标平面坐标) + 冒 bow_shot_fired(uSkill)
|
||||
np.combo_motion_mode = np.MOTION_MODE_BOW
|
||||
np._emit_swing(np.NAME_NORMAL_ATTACK, false)
|
||||
_ck(fc.fly_targets.size() == 1 and fc.fly_targets[0][0] == 2000,
|
||||
"弓挥击起手 → OnSetFlyTarget 发 CG_FLY_TARGETING(vid 2000)")
|
||||
_ck(fc.primary_fly_targets.size() == 1 and fc.primary_fly_targets[0][0] == 2000,
|
||||
"弓挥击优先调用独立 fly_targeting API(不误用 add_fly_targeting)")
|
||||
var exp_xy := MapCoord.to_server_cm(Vector3(3.0, 0.0, 4.0))
|
||||
_ck(fc.fly_targets[0][1] == int(exp_xy.x) and fc.fly_targets[0][2] == int(exp_xy.y),
|
||||
"CG_FLY_TARGETING 坐标 = 目标节点 server-XY(OnGetFlyTargetPosition 平面分量)")
|
||||
_ck(got_shot == [0], "弓挥击 → bow_shot_fired(uSkill=0)(OnShoot 延后到 .msa FLY 帧)")
|
||||
# 无目标:不发
|
||||
np._target_vid = 0
|
||||
np._vid_reserved = 0
|
||||
fc.fly_targets.clear()
|
||||
fc.primary_fly_targets.clear()
|
||||
np._send_fly_target()
|
||||
_ck(fc.fly_targets.is_empty(), "无目标 → 不发 CG_FLY_TARGETING")
|
||||
np.net_world = null
|
||||
np.combo_motion_mode = np.MOTION_MODE_GENERAL
|
||||
np._swing_skill = 0
|
||||
|
||||
# --- 状态图标条 ---
|
||||
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):
|
||||
# 40250 identifies the control as restart_here_button; localized text is
|
||||
# data and must not be used as the behavioral selector.
|
||||
if n.name == "RestartHereButton":
|
||||
hb = n
|
||||
_ck(hb != null, "restart-here button present")
|
||||
if hb:
|
||||
# The reference dialog blocks restart_here for 10 seconds. This check
|
||||
# targets the command emission after that cooldown, as in the dedicated
|
||||
# restart parity test below.
|
||||
death._here_cooldown_remaining = 0.0
|
||||
hb.disabled = false
|
||||
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 +0,0 @@
|
||||
uid://bisseuypqt88s
|
||||
@@ -1,271 +0,0 @@
|
||||
extends SceneTree
|
||||
|
||||
# combat_parity_test —— 验证 40250 战斗与视觉对齐:
|
||||
# 1. 防御碰撞球骨骼世界矩阵绑定(ActorInstanceCollisionDetection.cpp:33-70)
|
||||
# 2. 击退撞墙地形阻挡截断(InstanceBase.cpp BlockMovement / is_blocked)
|
||||
# 3. 攻击挥刀动作期间移动锁定(ActorInstanceMotion.cpp:472 isLock / PythonPlayerInput.cpp:457)
|
||||
# 4. 头顶伤害与 MISS 飘字官方 damagevalue/ DDS 贴图与排版(InstanceBaseEffect.cpp:150-205)
|
||||
|
||||
const NetPlay = preload("res://net_play.gd")
|
||||
const NetWorld = preload("res://net_world.gd")
|
||||
const DamageEffect = preload("res://damage_effect.gd")
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
const PlayerCtl = preload("res://player_controller.gd")
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
|
||||
var failures := 0
|
||||
|
||||
func check(ok: bool, msg: String) -> void:
|
||||
if ok:
|
||||
print("OK: %s" % msg)
|
||||
else:
|
||||
failures += 1
|
||||
printerr("FAIL: %s" % msg)
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("run_all")
|
||||
|
||||
func run_all() -> void:
|
||||
print("--- Running combat_parity_test ---")
|
||||
test_bone_defending_spheres()
|
||||
test_knockback_collision_blocking()
|
||||
test_attack_lock()
|
||||
test_damage_billboard_textures()
|
||||
test_actor_shake()
|
||||
test_resist_fallen()
|
||||
test_skill_cancel_window()
|
||||
print("combat_parity_test finished with %d failures" % failures)
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
# 1. 骨骼防御球测试
|
||||
func test_bone_defending_spheres() -> void:
|
||||
var np := NetPlay.new()
|
||||
var vnode := Node3D.new()
|
||||
root.add_child(vnode)
|
||||
vnode.position = Vector3(10, 0, 5)
|
||||
|
||||
# Mock model
|
||||
var model := Node3D.new()
|
||||
model.name = "Metin2Model"
|
||||
vnode.add_child(model)
|
||||
|
||||
# Mock anim
|
||||
var anim := Node.new()
|
||||
anim.name = "Metin2AnimPlayer"
|
||||
var script := GDScript.new()
|
||||
script.source_code = """
|
||||
extends Node
|
||||
func get_effect_bone_pose(bone: String) -> Dictionary:
|
||||
if bone == "Bip01 Spine":
|
||||
return {"transform": Transform3D(Basis.IDENTITY, Vector3(0, 1.0, 0))}
|
||||
return {}
|
||||
"""
|
||||
script.reload()
|
||||
anim.set_script(script)
|
||||
vnode.add_child(anim)
|
||||
|
||||
# Mock get_defending_spheres
|
||||
var vnode_script := GDScript.new()
|
||||
vnode_script.source_code = """
|
||||
extends Node3D
|
||||
var model: Node3D
|
||||
var anim: Node
|
||||
func get_defending_spheres() -> Array:
|
||||
return [
|
||||
{"radius": 50.0, "pos": Vector3(0, 50.0, 0), "bone": "Bip01 Spine"},
|
||||
{"radius": 40.0, "pos": Vector3(0, 0, 80.0), "bone": ""}
|
||||
]
|
||||
"""
|
||||
vnode_script.reload()
|
||||
vnode.set_script(vnode_script)
|
||||
vnode.set("model", model)
|
||||
vnode.set("anim", anim)
|
||||
|
||||
var v_pos := NetPlay._actor_cm(NetPlay._world_pos(vnode))
|
||||
var spheres: Array = np._defending_spheres(1234, vnode, v_pos)
|
||||
check(spheres.size() == 2, "2 defending spheres produced")
|
||||
|
||||
var sp0: Dictionary = spheres[0]
|
||||
check(float(sp0.radius) == 50.0, "sphere 0 radius is 50")
|
||||
check(sp0.pos.is_equal_approx(Vector3(1000, -500, 150)), "bone defending sphere accurately transformed with bone matrix")
|
||||
var sp1: Dictionary = spheres[1]
|
||||
check(float(sp1.radius) == 40.0, "sphere 1 radius is 40")
|
||||
|
||||
vnode.queue_free()
|
||||
np.queue_free()
|
||||
|
||||
# 2. 击退地形阻挡测试
|
||||
func test_knockback_collision_blocking() -> void:
|
||||
var nw := NetWorld.new()
|
||||
root.add_child(nw)
|
||||
|
||||
var client_script := GDScript.new()
|
||||
client_script.source_code = """
|
||||
extends Node
|
||||
signal entity_spawned(d)
|
||||
signal entity_despawned(vid)
|
||||
signal entity_main_set(vid)
|
||||
signal vitals_changed(vid, hp, sp)
|
||||
signal entity_dead(vid)
|
||||
signal damage(vid, amount, flag)
|
||||
signal chat(t, v, s)
|
||||
signal entity_moved(vid, pos)
|
||||
func is_in_game() -> bool: return true
|
||||
func get_entity(vid: int) -> Dictionary:
|
||||
return {"vid": vid, "pos": Vector3(10.0, 0.0, -20.0)}
|
||||
"""
|
||||
client_script.reload()
|
||||
var fake_client := Node.new()
|
||||
fake_client.set_script(client_script)
|
||||
root.add_child(fake_client)
|
||||
|
||||
var mount := Node3D.new()
|
||||
root.add_child(mount)
|
||||
nw.setup(fake_client, mount)
|
||||
|
||||
# Spawn dummy entity
|
||||
var ent_node := Node3D.new()
|
||||
mount.add_child(ent_node)
|
||||
nw._by_vid[555] = ent_node
|
||||
|
||||
# Mock world with blocked region
|
||||
var world_script := GDScript.new()
|
||||
world_script.source_code = """
|
||||
extends Node
|
||||
func is_blocked(x: float, z: float) -> bool:
|
||||
return x >= 11.0
|
||||
func to_world(pos: Vector3) -> Vector3:
|
||||
return Vector3(pos.x / 100.0, 0, -pos.y / 100.0)
|
||||
"""
|
||||
world_script.reload()
|
||||
var fake_world := Node.new()
|
||||
fake_world.set_script(world_script)
|
||||
nw.world = fake_world
|
||||
|
||||
# Push victim towards +x by force 5.0
|
||||
nw.push_victim(555, Vector2(1, 0), 5.0)
|
||||
check(nw.is_pushing(555), "victim is in pushing state initially")
|
||||
|
||||
# Step physics push until collision
|
||||
var e: Dictionary = fake_client.call("get_entity", 555)
|
||||
for i in range(10):
|
||||
var pos: Vector3 = nw._pushed_position(555, e, 0.05, Vector3(10, 0, -20))
|
||||
if not nw.is_pushing(555):
|
||||
break
|
||||
|
||||
var cur_pos: Vector3 = nw._pushed_position(555, e, 0.05, Vector3(10, 0, -20))
|
||||
check(cur_pos.x <= 11.05, "knockback stopped by is_blocked at boundary (x=%f <= 11.05)" % cur_pos.x)
|
||||
|
||||
mount.queue_free()
|
||||
fake_client.queue_free()
|
||||
fake_world.queue_free()
|
||||
nw.queue_free()
|
||||
|
||||
# 3. 攻击动作锁定移动测试
|
||||
func test_attack_lock() -> void:
|
||||
var np := NetPlay.new()
|
||||
root.add_child(np)
|
||||
|
||||
var pc := PlayerCtl.new()
|
||||
var player_node := Node3D.new()
|
||||
root.add_child(player_node)
|
||||
pc.player = player_node
|
||||
root.add_child(pc)
|
||||
np.pc = pc
|
||||
|
||||
check(not np.is_lock(), "initially not locked")
|
||||
check(not pc.locked, "pc.locked initially false")
|
||||
|
||||
# Start swing
|
||||
np._emit_swing(1, false)
|
||||
np._process(0.016)
|
||||
check(np.is_lock(), "is_lock() is true during attack swing")
|
||||
check(pc.locked, "pc.locked is true during attack swing")
|
||||
|
||||
# Advance past attack duration
|
||||
np._local_time += 2.0
|
||||
np._process(0.016)
|
||||
check(not np.is_lock(), "is_lock() is false after attack ends")
|
||||
check(not pc.locked, "pc.locked is false after attack ends")
|
||||
|
||||
player_node.queue_free()
|
||||
pc.queue_free()
|
||||
np.queue_free()
|
||||
|
||||
# 4. 伤害数字 DDS 资源与 Billboard 测试
|
||||
func test_damage_billboard_textures() -> void:
|
||||
var assets_path := AssetRoot.path()
|
||||
if not AssetRoot.available():
|
||||
print("SKIP: damage DDS textures test (assets directory not present)")
|
||||
return
|
||||
|
||||
var target_0: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/target_0.dds")
|
||||
check(target_0 != null, "target_0.dds loaded successfully via UiAssets")
|
||||
if target_0:
|
||||
check(target_0.get_width() > 0 and target_0.get_height() > 0, "target_0.dds has valid dimensions (%dx%d)" % [target_0.get_width(), target_0.get_height()])
|
||||
|
||||
var target_miss: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/target_miss.dds")
|
||||
check(target_miss != null, "target_miss.dds loaded successfully via UiAssets")
|
||||
|
||||
var damage_0: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/damage_0.dds")
|
||||
check(damage_0 != null, "damage_0.dds loaded successfully via UiAssets")
|
||||
|
||||
var damage_miss: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/damage_miss.dds")
|
||||
check(damage_miss != null, "damage_miss.dds loaded successfully via UiAssets")
|
||||
|
||||
# 5. 受击瞬时微震颤测试(ActorInstanceBattle.cpp __Shake)
|
||||
func test_actor_shake() -> void:
|
||||
var pv := PlayerView.new()
|
||||
var mv := MobView.new()
|
||||
root.add_child(pv)
|
||||
root.add_child(mv)
|
||||
|
||||
check(pv.has_method("shake"), "PlayerView has shake() method")
|
||||
check(mv.has_method("shake"), "MobView has shake() method")
|
||||
|
||||
pv.shake()
|
||||
check(pv._hit.shake_left > 0.0, "PlayerView shake timer active after shake()")
|
||||
|
||||
mv.shake()
|
||||
check(mv._hit.shake_left > 0.0, "MobView shake timer active after shake()")
|
||||
|
||||
pv.queue_free()
|
||||
mv.queue_free()
|
||||
|
||||
# 6. 霸体抗击退测试(ActorInstanceBattle.cpp IsResistFallen)
|
||||
func test_resist_fallen() -> void:
|
||||
var np := NetPlay.new()
|
||||
|
||||
# 普通怪 / 普通角色无霸体
|
||||
var normal_ent := {"race": 101, "affect_flags": 0}
|
||||
check(not np._is_resist_fallen(normal_ent), "normal entity is not resist fallen")
|
||||
|
||||
# 铁布衫 / 天罡气 (AFFECT_CHEONGEUN 1 << 16)
|
||||
var iron_body_ent := {"race": 101, "affect_flags": 1 << 16}
|
||||
check(np._is_resist_fallen(iron_body_ent), "AFFECT_CHEONGEUN entity is resist fallen")
|
||||
check(not np._can_push(iron_body_ent, 999, false), "resist fallen entity cannot be pushed")
|
||||
|
||||
# 巨型怪物 (IS_HUGE_RACE 2493)
|
||||
var huge_ent := {"race": 2493, "affect_flags": 0}
|
||||
check(np._is_resist_fallen(huge_ent), "huge race entity is resist fallen")
|
||||
check(not np._can_push(huge_ent, 888, true), "huge entity cannot be pushed")
|
||||
|
||||
np.queue_free()
|
||||
|
||||
# 7. 技能后摇打断测试(ActorInstanceMotion.cpp IsCancelEnableSkill)
|
||||
func test_skill_cancel_window() -> void:
|
||||
var np := NetPlay.new()
|
||||
|
||||
# 常规技能在施法期间锁定移动
|
||||
np.start_skill_cast(2.0, false, false)
|
||||
check(np.is_lock() == true, "normal skill is locked during cast")
|
||||
|
||||
# 带 cancel_enable 的技能在打断窗口内允许移动
|
||||
np.start_skill_cast(2.0, false, true)
|
||||
check(np.is_lock() == false, "cancel_enable skill does not lock movement")
|
||||
|
||||
np._on_motion_bound("wait")
|
||||
check(np.is_lock() == false, "is_lock is false after wait")
|
||||
|
||||
np.queue_free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://b7cxwyvtq02xd
|
||||
@@ -1,152 +0,0 @@
|
||||
# complex_item_drop_test —— 仓库 / 商城 / 交易 / NPC 商店的物品态回归。
|
||||
# 验证增量 54 的全局 MouseController 可以把物品送到各窗口的明确目标槽,
|
||||
# 而不是只验证背包和快捷栏。
|
||||
extends SceneTree
|
||||
|
||||
const MouseController = preload("res://ui/mouse_controller.gd")
|
||||
const CursorManager = preload("res://ui/cursor_manager.gd")
|
||||
const SafeboxUI = preload("res://ui/safebox_ui.gd")
|
||||
const MallUI = preload("res://ui/mall_ui.gd")
|
||||
const ShopUI = preload("res://ui/shop_ui.gd")
|
||||
const ExchangeUI = preload("res://ui/exchange_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal safebox_changed()
|
||||
signal inventory_changed()
|
||||
signal mall_opened(size: int)
|
||||
signal mall_changed()
|
||||
signal shop_opened(vid: int)
|
||||
signal shop_closed()
|
||||
signal shop_error(kind: String)
|
||||
signal exchange_changed()
|
||||
|
||||
var calls: Array = []
|
||||
var safe_open := true
|
||||
var safe_size := 1
|
||||
var safe_items: Array = []
|
||||
var mall_open := true
|
||||
var mall_size := 1
|
||||
var mall_items: Array = []
|
||||
var shop_open := true
|
||||
var exchange := {"active": true, "self_items": [], "peer_items": []}
|
||||
|
||||
func is_safebox_open() -> bool: return safe_open
|
||||
func get_safebox_size() -> int: return safe_size
|
||||
func get_safebox_gold() -> int: return 0
|
||||
func get_safebox_items() -> Array: return safe_items
|
||||
func safebox_checkin(dst, window, cell) -> bool:
|
||||
calls.append(["safe_checkin", dst, window, cell]); return true
|
||||
func safebox_move(src, dst, count) -> bool:
|
||||
calls.append(["safe_move", src, dst, count]); return true
|
||||
func safebox_checkout(src, window, cell) -> bool:
|
||||
calls.append(["safe_checkout", src, window, cell]); return true
|
||||
func get_inventory() -> Array: return []
|
||||
|
||||
func is_mall_open() -> bool: return mall_open
|
||||
func get_mall_size() -> int: return mall_size
|
||||
func get_mall_items() -> Array: return mall_items
|
||||
func mall_checkout(src, window, cell) -> bool:
|
||||
calls.append(["mall_checkout", src, window, cell]); return true
|
||||
|
||||
func is_shop_open() -> bool: return shop_open
|
||||
func get_shop_items() -> Array: return []
|
||||
func get_shop() -> Dictionary: return {"tabs": [{"name": "", "items": []}]}
|
||||
func shop_sell(cell, count) -> bool:
|
||||
calls.append(["shop_sell", cell, count]); return true
|
||||
|
||||
func get_exchange() -> Dictionary: return exchange
|
||||
func exchange_add_item(window, cell, slot) -> bool:
|
||||
calls.append(["exchange_add_item", window, cell, slot]); return true
|
||||
|
||||
var _fail := 0
|
||||
|
||||
func _ck(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _has_call(client: FakeClient, name: String, predicate: Callable) -> bool:
|
||||
for call in client.calls:
|
||||
if call[0] == name and predicate.call(call):
|
||||
return true
|
||||
return false
|
||||
|
||||
func _init() -> void:
|
||||
var host := Control.new()
|
||||
get_root().add_child(host)
|
||||
var cursor := CursorManager.new()
|
||||
get_root().add_child(cursor)
|
||||
var mouse := MouseController.new()
|
||||
get_root().add_child(mouse)
|
||||
mouse.setup(host, cursor)
|
||||
var client := FakeClient.new()
|
||||
get_root().add_child(client)
|
||||
|
||||
var safe := SafeboxUI.new()
|
||||
safe.item_mouse = mouse
|
||||
get_root().add_child(safe)
|
||||
safe.setup(client, host)
|
||||
client.safe_items = [{"cell": 2, "vnum": 19, "count": 3}]
|
||||
safe.refresh()
|
||||
_ck(mouse._targets.size() >= 45, "safebox registers all 45 visible drop slots")
|
||||
_ck(safe._drop_to_slot({"window": 1, "cell": 7, "vnum": 19, "count": 1}, 4),
|
||||
"inventory item drops into empty safebox slot")
|
||||
_ck(_has_call(client, "safe_checkin", func(c): return c[1] == 4 and c[2] == 1 and c[3] == 7),
|
||||
"safebox drop uses explicit destination slot")
|
||||
var safe_item := safe._cells[2] as Control
|
||||
var safe_event := InputEventMouseButton.new()
|
||||
safe_event.button_index = MOUSE_BUTTON_LEFT
|
||||
safe_event.pressed = true
|
||||
safe._on_cell_input(2, safe_event)
|
||||
_ck(mouse.is_attached() and int(mouse.attached().get("window", -1)) == 3,
|
||||
"safebox item enters global item state")
|
||||
mouse.cancel()
|
||||
|
||||
var mall := MallUI.new()
|
||||
mall.item_mouse = mouse
|
||||
get_root().add_child(mall)
|
||||
mall.setup(client, host)
|
||||
client.mall_items = [{"cell": 5, "vnum": 27, "count": 2}]
|
||||
mall.refresh()
|
||||
var mall_name := mall._list.get_child(0).get_child(0) as Label
|
||||
var mall_event := InputEventMouseButton.new()
|
||||
mall_event.button_index = MOUSE_BUTTON_LEFT
|
||||
mall_event.pressed = true
|
||||
mall_name.gui_input.emit(mall_event)
|
||||
_ck(mouse.is_attached() and int(mouse.attached().get("window", -1)) == 4
|
||||
and int(mouse.attached().get("cell", -1)) == 5,
|
||||
"mall item enters global item state")
|
||||
mouse.cancel()
|
||||
|
||||
var exchange := ExchangeUI.new()
|
||||
exchange.item_mouse = mouse
|
||||
get_root().add_child(exchange)
|
||||
exchange.setup(client, host)
|
||||
_ck(exchange._drop_to_slot({"window": 1, "cell": 8, "vnum": 29, "count": 1}, 6),
|
||||
"inventory item drops into empty exchange slot")
|
||||
_ck(_has_call(client, "exchange_add_item", func(c): return c[1] == 1 and c[2] == 8 and c[3] == 6),
|
||||
"exchange drop uses explicit display slot")
|
||||
|
||||
var shop := ShopUI.new()
|
||||
shop.item_mouse = mouse
|
||||
get_root().add_child(shop)
|
||||
shop.setup(client, host)
|
||||
shop.open()
|
||||
_ck(shop._mode == 1 and not shop._sell_drop_target.visible,
|
||||
"shop opens in buy mode with sell target hidden")
|
||||
_ck(not shop._drop_to_sell({"window": 1, "cell": 9, "vnum": 31, "count": 4}),
|
||||
"buy mode rejects item drop to sell target")
|
||||
shop._set_mode(2)
|
||||
_ck(shop._mode == 2 and shop._sell_drop_target.visible,
|
||||
"sell mode shows sell target")
|
||||
_ck(shop._drop_to_sell({"window": 1, "cell": 9, "vnum": 31, "count": 4}),
|
||||
"inventory item drops into shop sell target")
|
||||
_ck(_has_call(client, "shop_sell", func(c): return c[1] == 9 and c[2] == 1),
|
||||
"shop drop uses selected sell quantity")
|
||||
|
||||
if _fail == 0:
|
||||
print("PASS: complex_item_drop_test (safebox + mall + exchange + shop)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
@@ -1 +0,0 @@
|
||||
uid://x2vuateiv3w1
|
||||
@@ -1,218 +0,0 @@
|
||||
# consumable_system.gd —— 高级消耗品与瞬移定位系统(40250 官方 1:1 对齐)
|
||||
# 对齐源码:
|
||||
# - 40250 char_item.cpp:3950-4050 (AFFECT_AUTO_HP/SP_RECOVERY, 日月神水开关)
|
||||
# - 40250 char_item.cpp:7326-7390 (AutoRecoveryItemProcess, 自动补充与容量耗尽)
|
||||
# - 40250 affect.h:95-96 (AFFECT_AUTO_HP_RECOVERY = 534, AFFECT_AUTO_SP_RECOVERY = 535)
|
||||
# - 40250 char_item.cpp 龙神药水 (71027~71030: 攻/防/血/魔法比率加成, 30 分钟)
|
||||
# - 40250 char_item.cpp 卷轴 (22001 回城卷轴, 22010 地标传送卷轴)
|
||||
class_name ConsumableSystem
|
||||
extends RefCounted
|
||||
|
||||
# 日月神水 Vnum 范围(40250 官方标准)
|
||||
const VNUM_SUN_ELIXIR_S := 72723 # 太阳神水 (小) - 1,000,000 HP
|
||||
const VNUM_SUN_ELIXIR_M := 72724 # 太阳神水 (中) - 3,000,000 HP
|
||||
const VNUM_SUN_ELIXIR_L := 72725 # 太阳神水 (大) - 5,000,000 HP
|
||||
const VNUM_SUN_ELIXIR_EX := 72726 # 太阳神水 (特别) - 10,000,000 HP
|
||||
|
||||
const VNUM_MOON_ELIXIR_S := 72727 # 月亮神水 (小) - 100,000 SP
|
||||
const VNUM_MOON_ELIXIR_M := 72728 # 月亮神水 (中) - 300,000 SP
|
||||
const VNUM_MOON_ELIXIR_L := 72729 # 月亮神水 (大) - 500,000 SP
|
||||
const VNUM_MOON_ELIXIR_EX := 72730 # 月亮神水 (特别) - 1,000,000 SP
|
||||
|
||||
# 龙神药水 Vnum
|
||||
const VNUM_DRAGON_GOD_ATTACK := 71027 # 龙神攻击 (+15% 伤害)
|
||||
const VNUM_DRAGON_GOD_DEFENSE := 71028 # 龙神防御 (+15% 防御)
|
||||
const VNUM_DRAGON_GOD_LIFE := 71029 # 龙神生命 (+20% HP)
|
||||
const VNUM_DRAGON_GOD_MANA := 71030 # 龙神法力 (+20% SP)
|
||||
|
||||
# 卷轴 Vnum
|
||||
const VNUM_TOWN_SCROLL := 22001 # 瞬间回城卷轴
|
||||
const VNUM_RETURN_SCROLL := 22010 # 地标记忆/传送卷轴
|
||||
|
||||
# 40250 Affect 编号
|
||||
const AFFECT_AUTO_HP_RECOVERY := 534
|
||||
const AFFECT_AUTO_SP_RECOVERY := 535
|
||||
const DRAGON_GOD_DURATION := 1800.0 # 30 分钟持续时间
|
||||
|
||||
# 神水默认容量表 (Vnum -> 容量)
|
||||
const ELIXIR_CAPACITIES := {
|
||||
72723: 1000000,
|
||||
72724: 3000000,
|
||||
72725: 5000000,
|
||||
72726: 10000000,
|
||||
72727: 100000,
|
||||
72728: 300000,
|
||||
72729: 500000,
|
||||
72730: 1000000,
|
||||
}
|
||||
|
||||
## 检查是否为日月神水
|
||||
static func is_elixir(vnum: int) -> bool:
|
||||
return (vnum >= 72723 and vnum <= 72730)
|
||||
|
||||
## 检查是否为生命神水(太阳神水)
|
||||
static func is_hp_elixir(vnum: int) -> bool:
|
||||
return (vnum >= 72723 and vnum <= 72726)
|
||||
|
||||
## 检查是否为法力神水(月亮神水)
|
||||
static func is_sp_elixir(vnum: int) -> bool:
|
||||
return (vnum >= 72727 and vnum <= 72730)
|
||||
|
||||
## 检查是否为龙神特药
|
||||
static func is_dragon_god_potion(vnum: int) -> bool:
|
||||
return vnum in [VNUM_DRAGON_GOD_ATTACK, VNUM_DRAGON_GOD_DEFENSE, VNUM_DRAGON_GOD_LIFE, VNUM_DRAGON_GOD_MANA]
|
||||
|
||||
## 初始化神水数据(若插槽容量未设定)
|
||||
static func init_elixir_if_needed(item_data: Dictionary) -> void:
|
||||
var vnum := int(item_data.get("vnum", 0))
|
||||
var sockets: Array = item_data.get("sockets", [0, 0, 0]).duplicate()
|
||||
while sockets.size() < 3:
|
||||
sockets.append(0)
|
||||
# socket[0]: 0=未激活, 1=激活
|
||||
# socket[1]: 已使用量
|
||||
# socket[2]: 总容量
|
||||
if int(sockets[2]) <= 0:
|
||||
sockets[2] = ELIXIR_CAPACITIES.get(vnum, 1000000)
|
||||
sockets[0] = 0
|
||||
sockets[1] = 0
|
||||
item_data["sockets"] = sockets
|
||||
|
||||
## 切换神水激活状态 (40250 char_item.cpp:3950)
|
||||
static func toggle_elixir(item_data: Dictionary) -> Dictionary:
|
||||
init_elixir_if_needed(item_data)
|
||||
var sockets: Array = item_data["sockets"]
|
||||
var cur_active := (int(sockets[0]) != 0)
|
||||
var new_active := not cur_active
|
||||
sockets[0] = 1 if new_active else 0
|
||||
item_data["sockets"] = sockets
|
||||
|
||||
var is_hp := is_hp_elixir(int(item_data.get("vnum", 0)))
|
||||
var kind := "生命" if is_hp else "法力"
|
||||
var msg := ("已激活%s神水自充能!" % kind) if new_active else ("已关闭%s神水自充能。" % kind)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"active": new_active,
|
||||
"affect_type": AFFECT_AUTO_HP_RECOVERY if is_hp else AFFECT_AUTO_SP_RECOVERY,
|
||||
"msg": msg,
|
||||
}
|
||||
|
||||
## 自充能 Tick 处理 (40250 char_item.cpp:7326 AutoRecoveryItemProcess)
|
||||
## 返回消耗与恢复量,若耗尽返回 exhausted=true
|
||||
static func process_elixir_recovery(item_data: Dictionary, cur_val: int, max_val: int) -> Dictionary:
|
||||
init_elixir_if_needed(item_data)
|
||||
var sockets: Array = item_data["sockets"]
|
||||
if int(sockets[0]) == 0:
|
||||
return {"recovered": 0, "exhausted": false} # 未激活
|
||||
|
||||
var used := int(sockets[1])
|
||||
var cap := int(sockets[2])
|
||||
var avail := maxi(0, cap - used)
|
||||
if avail <= 0:
|
||||
return {"recovered": 0, "exhausted": true}
|
||||
|
||||
var deficit := maxi(0, max_val - cur_val)
|
||||
if deficit <= 0:
|
||||
return {"recovered": 0, "exhausted": false}
|
||||
|
||||
var heal: int = mini(deficit, avail)
|
||||
sockets[1] = used + heal
|
||||
item_data["sockets"] = sockets
|
||||
|
||||
var is_depleted := (int(sockets[1]) >= cap)
|
||||
return {
|
||||
"recovered": heal,
|
||||
"exhausted": is_depleted,
|
||||
"remaining": maxi(0, cap - int(sockets[1])),
|
||||
"capacity": cap,
|
||||
}
|
||||
|
||||
## 使用龙神特药 (40250 char_item.cpp)
|
||||
## 返回 Buff 增益配置
|
||||
static func use_dragon_god_potion(vnum: int) -> Dictionary:
|
||||
match vnum:
|
||||
VNUM_DRAGON_GOD_ATTACK:
|
||||
return {
|
||||
"ok": true,
|
||||
"vnum": vnum,
|
||||
"name": "龙神攻击",
|
||||
"duration": DRAGON_GOD_DURATION,
|
||||
"bonus_type": "POINT_ATT_BONUS",
|
||||
"bonus_value": 15,
|
||||
"msg": "使用了龙神攻击,攻击力提升 15%,持续 30 分钟!"
|
||||
}
|
||||
VNUM_DRAGON_GOD_DEFENSE:
|
||||
return {
|
||||
"ok": true,
|
||||
"vnum": vnum,
|
||||
"name": "龙神防御",
|
||||
"duration": DRAGON_GOD_DURATION,
|
||||
"bonus_type": "POINT_DEF_BONUS",
|
||||
"bonus_value": 15,
|
||||
"msg": "使用了龙神防御,防御力提升 15%,持续 30 分钟!"
|
||||
}
|
||||
VNUM_DRAGON_GOD_LIFE:
|
||||
return {
|
||||
"ok": true,
|
||||
"vnum": vnum,
|
||||
"name": "龙神生命",
|
||||
"duration": DRAGON_GOD_DURATION,
|
||||
"bonus_type": "POINT_MAX_HP_PCT",
|
||||
"bonus_value": 20,
|
||||
"msg": "使用了龙神生命,最大生命值提升 20%,持续 30 分钟!"
|
||||
}
|
||||
VNUM_DRAGON_GOD_MANA:
|
||||
return {
|
||||
"ok": true,
|
||||
"vnum": vnum,
|
||||
"name": "龙神法力",
|
||||
"duration": DRAGON_GOD_DURATION,
|
||||
"bonus_type": "POINT_MAX_SP_PCT",
|
||||
"bonus_value": 20,
|
||||
"msg": "使用了龙神法力,最大法力值提升 20%,持续 30 分钟!"
|
||||
}
|
||||
return {"ok": false, "msg": "未知的龙神药水"}
|
||||
|
||||
## 使用回城卷轴 / 地标卷轴 (40250 char_item.cpp)
|
||||
static func use_warp_scroll(vnum: int, item_data: Dictionary, cur_x: float, cur_y: float, cur_map_id := 1) -> Dictionary:
|
||||
if vnum == VNUM_TOWN_SCROLL:
|
||||
return {
|
||||
"ok": true,
|
||||
"action": "TOWN_WARP",
|
||||
"consumed": true,
|
||||
"msg": "使用了回城卷轴,即将传送回城!"
|
||||
}
|
||||
elif vnum == VNUM_RETURN_SCROLL:
|
||||
var sockets: Array = item_data.get("sockets", [0, 0, 0]).duplicate()
|
||||
while sockets.size() < 3:
|
||||
sockets.append(0)
|
||||
|
||||
# 若未记录坐标(socket[0] == 0),则记录当前位置,不消耗卷轴!
|
||||
if int(sockets[0]) == 0 and int(sockets[1]) == 0:
|
||||
sockets[0] = int(cur_x)
|
||||
sockets[1] = int(cur_y)
|
||||
sockets[2] = cur_map_id
|
||||
item_data["sockets"] = sockets
|
||||
return {
|
||||
"ok": true,
|
||||
"action": "RECORDED",
|
||||
"consumed": false,
|
||||
"coords": Vector2(cur_x, cur_y),
|
||||
"map_id": cur_map_id,
|
||||
"msg": "成功将当前坐标 (%d, %d) 记录到地标卷轴!" % [int(cur_x), int(cur_y)]
|
||||
}
|
||||
else:
|
||||
# 已记录坐标:传送至记录位置并消耗卷轴
|
||||
var target_x := int(sockets[0])
|
||||
var target_y := int(sockets[1])
|
||||
var target_map := int(sockets[2])
|
||||
return {
|
||||
"ok": true,
|
||||
"action": "WARP_TO_COORDS",
|
||||
"consumed": true,
|
||||
"target_x": target_x,
|
||||
"target_y": target_y,
|
||||
"target_map": target_map,
|
||||
"msg": "使用了地标卷轴,正在传送至 (%d, %d)!" % [target_x, target_y]
|
||||
}
|
||||
return {"ok": false, "msg": "未知的传送卷轴"}
|
||||
@@ -1 +0,0 @@
|
||||
uid://ucwyc04610e3
|
||||
@@ -1,117 +0,0 @@
|
||||
# damage_effect —— §8.8 / §5.4 头顶伤害飘字「是否 / 如何显示」1:1:
|
||||
# CInstanceBase::AddDamageEffect + ProcessDamage 的分类(DODGE|BLOCK -> MISS、
|
||||
# self -> "damage_"、nontarget -> 不渲染、target -> "target_")+ 数字逐位拆分 +
|
||||
# CPythonSystem::IsShowDamage / SetShowDamageFlag 门。
|
||||
# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd 同一套纯静态镜像套路。)
|
||||
#
|
||||
# REF(40250 ClientVS22 source/ 为唯一基线):
|
||||
# UserInterface/InstanceBase.h:300 enum DamageFlag { NORMAL 1<<0, POISON 1<<1, DODGE 1<<2, BLOCK 1<<3, PENETRATE 1<<4, CRITICAL 1<<5 }
|
||||
# UserInterface/Packet.h:1943 TPacketGCDamageInfo { BYTE header; DWORD dwVID; BYTE flag; int damage; }
|
||||
# UserInterface/PythonNetworkStreamPhaseGame.cpp:2401 RecvDamageInfoPacket:
|
||||
# pInstTarget = GetInstancePtr(dwVID);bSelf = (pInstTarget == MainInstance);
|
||||
# bTarget = (pInstTarget == m_pInstTarget);damage >= 0 才 AddDamageEffect(< 0 记 TraceError)
|
||||
# UserInterface/InstanceBaseEffect.cpp:87 AddDamageEffect:仅当 IsShowDamage() 才 push m_DamageQueue({bSelf,bTarget,damage,flag})
|
||||
# UserInterface/InstanceBaseEffect.cpp:100 ProcessDamage(InstanceBase.cpp:1910 每帧调一次):
|
||||
# 每帧 pop_front 一条;
|
||||
# (flag & DODGE) || (flag & BLOCK) -> bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS,return
|
||||
# (flag & CRITICAL) -> 该 build CreateEffect 被注释掉,落到普通数字(无独立暴击视觉)
|
||||
# POISON 分支整段注释掉
|
||||
# bSelf -> strDamageType="damage_",EFFECT_DAMAGE_SELFDAMAGE / SELFDAMAGE2 交替(m_bDamageEffectType 每次取反)
|
||||
# !bTarget -> strDamageType="nontarget_",EFFECT_DAMAGE_NOT_TARGET 后 **return(不渲染)**
|
||||
# else -> strDamageType="target_",EFFECT_DAMAGE_TARGET
|
||||
# while(damage>0):num=damage%10; damage/=10; index 0..7(>7 TraceError break);
|
||||
# 贴图 "d:/ymir work/effect/affect/damagevalue/<type><num>.dds",
|
||||
# 水平偏移 FONT_WIDTH(30)*index(相机右方向,matTrans._41 = -(FONT_WIDTH*index))
|
||||
# UserInterface/PythonSystem.cpp:308 m_Config.bShowDamage = true(默认)
|
||||
# UserInterface/PythonSystem.cpp:337 IsShowDamage() -> m_Config.bShowDamage
|
||||
# UserInterface/PythonSystem.cpp:342 SetShowDamageFlag(iFlag) -> bShowDamage = (iFlag == 1)
|
||||
# UserInterface/PythonSystem.cpp:451 config "SHOW_DAMAGE" -> atoi(value) == 1
|
||||
# UserInterface/PythonSystem.cpp:537 save "SHOW_DAMAGE %d"(仅当 == 0,即非默认时落盘)
|
||||
# UserInterface/PythonSystemModule.cpp:192/219 systemSetShowDamageFlag / systemIsShowDamage
|
||||
# root/uigameoption.py __OnClickShowDamageOn/OffButton -> systemSetting.SetShowDamageFlag(...)
|
||||
#
|
||||
# seam ⑪:参考端每位数字是一张 damagevalue/<type><digit>.dds 贴图做的 CEffectManager 特效,
|
||||
# 在相机右方向按 FONT_WIDTH*index 像素铺开;本移植 net_world 用一块 billboard Label3D 直接写
|
||||
# str(amount)(无 damagevalue dds 图集 / 无逐位特效 / 无相机右向像素铺排),MISS 写 "MISS"。
|
||||
# self 的 SELFDAMAGE/SELFDAMAGE2 交替本移植退化成落字水平抖动符号交替(仅存在感,无双特效资源)。
|
||||
# 暴击在该 40250 build 参考端本就无独立视觉,这里也不再放大 / 变色(与 <=112 的占位实现相比是回归对齐)。
|
||||
# 另:参考端 m_DamageQueue 是「每个实例一条」,ProcessDamage 每帧每实例 pop 一条;本移植是
|
||||
# net_world 一条全局队列每帧 pop 一条 —— 同一帧多个 VID 同时受击时会分帧显示。
|
||||
extends RefCounted
|
||||
|
||||
# CInstanceBase::DamageFlag
|
||||
const DAMAGE_NORMAL := 1 << 0
|
||||
const DAMAGE_POISON := 1 << 1
|
||||
const DAMAGE_DODGE := 1 << 2
|
||||
const DAMAGE_BLOCK := 1 << 3
|
||||
const DAMAGE_PENETRATE := 1 << 4
|
||||
const DAMAGE_CRITICAL := 1 << 5
|
||||
|
||||
const SHOW_DAMAGE_DEFAULT := true # m_Config.bShowDamage 默认
|
||||
const CFG_KEY := "SHOW_DAMAGE" # PythonSystem 配置行 / 存盘键
|
||||
const FONT_WIDTH := 30 # ProcessDamage 逐位水平像素步进
|
||||
const MAX_DIGITS := 8 # index > 7 -> break
|
||||
|
||||
enum Kind { MISS, DIGITS }
|
||||
|
||||
# AddDamageEffect:仅当 IsShowDamage() 才入队
|
||||
static func should_queue(show_damage: bool) -> bool:
|
||||
return show_damage
|
||||
|
||||
# SetShowDamageFlag(int iFlag): m_Config.bShowDamage = iFlag == 1
|
||||
static func show_damage_from_flag(flag: int) -> bool:
|
||||
return flag == 1
|
||||
|
||||
# config "SHOW_DAMAGE" 行:atoi(value) == 1
|
||||
static func show_damage_from_cfg(value: Variant) -> bool:
|
||||
return int(value) == 1
|
||||
|
||||
# RecvDamageInfoPacket:bSelf = 目标实例是主角
|
||||
static func is_self(vid: int, main_vid: int) -> bool:
|
||||
return vid != 0 and vid == main_vid
|
||||
|
||||
# RecvDamageInfoPacket:bTarget = 目标实例是当前目标框 VID
|
||||
static func is_target(vid: int, target_vid: int) -> bool:
|
||||
return vid != 0 and vid == target_vid
|
||||
|
||||
# RecvDamageInfoPacket:damage < 0 记 TraceError 不处理
|
||||
static func valid_amount(amount: int) -> bool:
|
||||
return amount >= 0
|
||||
|
||||
# ProcessDamage 分类。返回:
|
||||
# {"kind": Kind, "type": String("damage_"/"target_"/"nontarget_"), "show": bool, "self": bool}
|
||||
# show == false 只有一种来源:非 self 且非 target 的数字伤害(ProcessDamage 里 return,不渲染)。
|
||||
static func classify(flag: int, b_self: bool, b_target: bool) -> Dictionary:
|
||||
if (flag & DAMAGE_DODGE) != 0 or (flag & DAMAGE_BLOCK) != 0:
|
||||
# EFFECT_DAMAGE_MISS(self)/ EFFECT_DAMAGE_TARGETMISS(其它)
|
||||
return {"kind": Kind.MISS, "type": "damage_" if b_self else "target_", "show": true, "self": b_self}
|
||||
if b_self:
|
||||
return {"kind": Kind.DIGITS, "type": "damage_", "show": true, "self": true}
|
||||
if not b_target:
|
||||
return {"kind": Kind.DIGITS, "type": "nontarget_", "show": false, "self": false}
|
||||
return {"kind": Kind.DIGITS, "type": "target_", "show": true, "self": false}
|
||||
|
||||
# while(damage>0){ num=damage%10; damage/=10; } —— 低位在前,最多 8 位(index>7 break)。
|
||||
# damage <= 0 不产出任何位(与参考 while 一致)。
|
||||
static func digits(damage: int) -> Array:
|
||||
var out: Array = []
|
||||
var d := damage
|
||||
if d <= 0:
|
||||
return out
|
||||
var index := 0
|
||||
while d > 0:
|
||||
if index > MAX_DIGITS - 1:
|
||||
break
|
||||
out.append(d % 10)
|
||||
d = d / 10
|
||||
index += 1
|
||||
return out
|
||||
|
||||
# 第 index 位数字相对锚点的水平像素偏移(相机右方向)。
|
||||
static func digit_offset_px(index: int) -> float:
|
||||
return float(FONT_WIDTH * index)
|
||||
|
||||
# ProcessDamage 的 self 特效交替:m_bDamageEffectType==false -> SELFDAMAGE(0),否则 SELFDAMAGE2(1),
|
||||
# 每次自伤后取反。返回本次用的槽位(0/1)。
|
||||
static func self_damage_slot(toggle_state: bool) -> int:
|
||||
return 1 if toggle_state else 0
|
||||
@@ -1 +0,0 @@
|
||||
uid://dlpn6gv5jrh14
|
||||
@@ -1,112 +0,0 @@
|
||||
# damage_effect_test —— §8.8 / §5.4 头顶伤害飘字 1:1:
|
||||
# CInstanceBase::AddDamageEffect / ProcessDamage 的分类(DODGE|BLOCK -> MISS、
|
||||
# self -> "damage_"、nontarget -> 不渲染、target -> "target_")、数字逐位拆分、
|
||||
# CPythonSystem::IsShowDamage / SetShowDamageFlag 门。
|
||||
# godot --headless --path project --script damage_effect_test.gd
|
||||
# 退出码 0 = 全过。
|
||||
extends SceneTree
|
||||
|
||||
const DamageEffect = preload("res://damage_effect.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: damage_effect_test (§8.8/§5.4 AddDamageEffect / ProcessDamage 分类 + IsShowDamage 1:1)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
# --- 0. 常量(InstanceBase.h / PythonSystem.cpp / InstanceBaseEffect.cpp)----
|
||||
_ck(DamageEffect.DAMAGE_NORMAL == 1 << 0, "DAMAGE_NORMAL == 1<<0")
|
||||
_ck(DamageEffect.DAMAGE_POISON == 1 << 1, "DAMAGE_POISON == 1<<1")
|
||||
_ck(DamageEffect.DAMAGE_DODGE == 1 << 2, "DAMAGE_DODGE == 1<<2")
|
||||
_ck(DamageEffect.DAMAGE_BLOCK == 1 << 3, "DAMAGE_BLOCK == 1<<3")
|
||||
_ck(DamageEffect.DAMAGE_PENETRATE == 1 << 4, "DAMAGE_PENETRATE == 1<<4")
|
||||
_ck(DamageEffect.DAMAGE_CRITICAL == 1 << 5, "DAMAGE_CRITICAL == 1<<5")
|
||||
_ck(DamageEffect.SHOW_DAMAGE_DEFAULT == true, "m_Config.bShowDamage 默认 true")
|
||||
_ck(DamageEffect.CFG_KEY == "SHOW_DAMAGE", "配置键 == SHOW_DAMAGE")
|
||||
_ck(DamageEffect.FONT_WIDTH == 30, "FONT_WIDTH == 30")
|
||||
_ck(DamageEffect.MAX_DIGITS == 8, "index 上限 8(>7 break)")
|
||||
|
||||
# --- 1. IsShowDamage 门 -----------------------------------------------------
|
||||
_ck(DamageEffect.should_queue(true) == true, "IsShowDamage() -> 入队")
|
||||
_ck(DamageEffect.should_queue(false) == false, "!IsShowDamage() -> 不入队")
|
||||
_ck(DamageEffect.show_damage_from_flag(1) == true, "SetShowDamageFlag(1) -> true")
|
||||
_ck(DamageEffect.show_damage_from_flag(0) == false, "SetShowDamageFlag(0) -> false")
|
||||
_ck(DamageEffect.show_damage_from_flag(2) == false, "SetShowDamageFlag(2) -> false(严格 == 1)")
|
||||
_ck(DamageEffect.show_damage_from_cfg("1") == true, "cfg \"1\" -> true")
|
||||
_ck(DamageEffect.show_damage_from_cfg("0") == false, "cfg \"0\" -> false")
|
||||
_ck(DamageEffect.show_damage_from_cfg("2") == false, "cfg \"2\" -> false")
|
||||
_ck(DamageEffect.show_damage_from_cfg(1) == true, "cfg int 1 -> true")
|
||||
|
||||
# --- 2. bSelf / bTarget(RecvDamageInfoPacket)----------------------------
|
||||
_ck(DamageEffect.is_self(42, 42) == true, "vid == main -> bSelf")
|
||||
_ck(DamageEffect.is_self(42, 7) == false, "vid != main -> !bSelf")
|
||||
_ck(DamageEffect.is_self(0, 0) == false, "vid 0 -> !bSelf")
|
||||
_ck(DamageEffect.is_target(9, 9) == true, "vid == targetBoard VID -> bTarget")
|
||||
_ck(DamageEffect.is_target(9, 0) == false, "无目标 -> !bTarget")
|
||||
_ck(DamageEffect.is_target(0, 0) == false, "vid 0 -> !bTarget")
|
||||
|
||||
# --- 3. damage >= 0 门 --------------------------------------------------
|
||||
_ck(DamageEffect.valid_amount(0) == true, "damage 0 -> 处理(>= 0)")
|
||||
_ck(DamageEffect.valid_amount(5) == true, "damage 5 -> 处理")
|
||||
_ck(DamageEffect.valid_amount(-1) == false, "damage < 0 -> TraceError 不处理")
|
||||
|
||||
# --- 4. ProcessDamage 分类 --------------------------------------------------
|
||||
var m := DamageEffect.Kind.MISS
|
||||
var g := DamageEffect.Kind.DIGITS
|
||||
# DODGE / BLOCK -> MISS
|
||||
var c := DamageEffect.classify(DamageEffect.DAMAGE_DODGE, true, true)
|
||||
_ck(c["kind"] == m and c["type"] == "damage_" and c["show"] and c["self"], "DODGE + self -> MISS damage_ show self")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_DODGE, false, true)
|
||||
_ck(c["kind"] == m and c["type"] == "target_" and c["show"] and not c["self"], "DODGE + 非 self -> MISS target_")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_BLOCK, true, false)
|
||||
_ck(c["kind"] == m and c["type"] == "damage_", "BLOCK + self -> MISS damage_")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_BLOCK, false, false)
|
||||
_ck(c["kind"] == m and c["type"] == "target_", "BLOCK + 非 self -> MISS target_(即便非 target)")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_DODGE | DamageEffect.DAMAGE_BLOCK, false, true)
|
||||
_ck(c["kind"] == m, "DODGE|BLOCK 合并 -> MISS")
|
||||
# 数字:self / target / nontarget
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, true, false)
|
||||
_ck(c["kind"] == g and c["type"] == "damage_" and c["show"] and c["self"], "普通 + self -> DIGITS damage_ show self")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, false, true)
|
||||
_ck(c["kind"] == g and c["type"] == "target_" and c["show"] and not c["self"], "普通 + target -> DIGITS target_ show")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, false, false)
|
||||
_ck(c["kind"] == g and c["type"] == "nontarget_" and c["show"] == false, "普通 + 非 self 非 target -> nontarget_ 不渲染")
|
||||
# CRITICAL 在该 build 无独立视觉 -> 仍按数字
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, true, false)
|
||||
_ck(c["kind"] == g and c["type"] == "damage_" and c["show"], "CRITICAL + self -> 仍普通数字 damage_")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, false, true)
|
||||
_ck(c["kind"] == g and c["type"] == "target_" and c["show"], "CRITICAL + target -> 普通数字 target_")
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, false, false)
|
||||
_ck(c["show"] == false, "CRITICAL + nontarget -> 仍不渲染")
|
||||
# POISON 分支被注释掉 -> 走普通
|
||||
c = DamageEffect.classify(DamageEffect.DAMAGE_POISON, false, true)
|
||||
_ck(c["kind"] == g and c["type"] == "target_", "POISON(分支注释掉)+ target -> 普通数字 target_")
|
||||
|
||||
# --- 5. digits:低位在前,最多 8 位 --------------------------------------
|
||||
_ck(DamageEffect.digits(0) == [], "digits(0) == [](while 零次)")
|
||||
_ck(DamageEffect.digits(-5) == [], "digits(-5) == []")
|
||||
_ck(DamageEffect.digits(7) == [7], "digits(7) == [7]")
|
||||
_ck(DamageEffect.digits(123) == [3, 2, 1], "digits(123) == [3,2,1](低位在前)")
|
||||
_ck(DamageEffect.digits(90) == [0, 9], "digits(90) == [0,9]")
|
||||
_ck(DamageEffect.digits(2000000) == [0, 0, 0, 0, 0, 0, 2], "digits(2000000) 7 位")
|
||||
_ck(DamageEffect.digits(999999999).size() == 8, "digits(9 位) 截到 8 位(index>7 break)")
|
||||
_ck(DamageEffect.digits(999999999) == [9, 9, 9, 9, 9, 9, 9, 9], "digits(999999999) == 8×9")
|
||||
|
||||
# --- 6. digit_offset_px:FONT_WIDTH*index ------------------------------
|
||||
_ck(is_equal_approx(DamageEffect.digit_offset_px(0), 0.0), "第 0 位偏移 0")
|
||||
_ck(is_equal_approx(DamageEffect.digit_offset_px(1), 30.0), "第 1 位偏移 30")
|
||||
_ck(is_equal_approx(DamageEffect.digit_offset_px(3), 90.0), "第 3 位偏移 90")
|
||||
|
||||
# --- 7. self 特效交替(m_bDamageEffectType)---------------------------
|
||||
_ck(DamageEffect.self_damage_slot(false) == 0, "toggle false -> SELFDAMAGE(0)")
|
||||
_ck(DamageEffect.self_damage_slot(true) == 1, "toggle true -> SELFDAMAGE2(1)")
|
||||
@@ -1 +0,0 @@
|
||||
uid://c77cvj7gtqck4
|
||||
@@ -1,55 +0,0 @@
|
||||
# damage_local_main_parity_test —— 40250 RecvDamageInfoPacket / MainInstance。
|
||||
#
|
||||
# 参考端即使 pInstTarget == MainInstance,也会把伤害交给
|
||||
# CInstanceBase::AddDamageEffect;当前端的本地主角不在 _by_vid,而是由
|
||||
# NetWorld._local_node 单独持有,因此必须走同一条本地节点回退路径。
|
||||
#
|
||||
# 修复前:本地 VID 的伤害进入队列后,在 _process_damage_queue() 因 _by_vid
|
||||
# 查不到节点而被丢弃;修复后:应创建 dmg_<vid> 飘字节点。
|
||||
extends SceneTree
|
||||
|
||||
const NetWorld = preload("res://net_world.gd")
|
||||
|
||||
var _fail := 0
|
||||
|
||||
func _ck(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
var parent := Node3D.new()
|
||||
root.add_child(parent)
|
||||
|
||||
var local := Node3D.new()
|
||||
local.name = "LocalMain"
|
||||
parent.add_child(local)
|
||||
local.position = Vector3(2.0, 0.0, 3.0)
|
||||
|
||||
var nw := NetWorld.new()
|
||||
root.add_child(nw)
|
||||
# 直接建立 GameScene.setup() 为本地主角设置的关键状态,避免测试把
|
||||
# 普通 _by_vid actor 当成本地主角,掩盖 MainInstance 分支。
|
||||
nw.parent = parent
|
||||
nw._local_vid = 1000
|
||||
nw._main_vid = 1000
|
||||
nw._target_vid = 1000
|
||||
nw._local_node = local
|
||||
nw._show_damage = true
|
||||
|
||||
nw._on_damage(1000, 123, 0)
|
||||
_ck(nw._damage_queue.size() == 1, "MainInstance damage is queued")
|
||||
|
||||
nw._process_damage_queue()
|
||||
_ck(parent.get_node_or_null("dmg_1000") != null,
|
||||
"MainInstance damage creates the local damage billboard")
|
||||
|
||||
if _fail == 0:
|
||||
print("PASS: damage_local_main_parity_test (MainInstance damage billboard)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
@@ -1 +0,0 @@
|
||||
uid://cc4w5nbmfocpr
|
||||
@@ -1,22 +0,0 @@
|
||||
extends SceneTree
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
func _init() -> void:
|
||||
var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/shamanblackcore.dds")
|
||||
var before := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||||
var images: Array[Image] = []
|
||||
var failed := false
|
||||
for i in range(20):
|
||||
var decoded := UiAssets.load_dds_image(path)
|
||||
if decoded == null or decoded.is_empty():
|
||||
failed = true
|
||||
else:
|
||||
images.append(decoded)
|
||||
var after := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||||
failed = failed or before != after or images.size() != 20
|
||||
for decoded in images:
|
||||
failed = failed or decoded.get_data() != images[0].get_data()
|
||||
if failed:
|
||||
printerr("FAIL: DDS helper ownership/images before=%d after=%d images=%d" % [before, after, images.size()])
|
||||
else:
|
||||
print("PASS: dds_lifecycle_test (20 decodes, node count unchanged, images survive helper release)")
|
||||
quit(1 if failed else 0)
|
||||
@@ -1 +0,0 @@
|
||||
uid://buf6xm7130vgl
|
||||
@@ -1,35 +0,0 @@
|
||||
# disconnect_world_reset_test —— 40250 SetOffLinePhase/Destroy 清理边界。
|
||||
# 显式断开不能只销毁 socket;保留 GameScene 时必须通知 presentation owner 清空旧世界。
|
||||
extends SceneTree
|
||||
|
||||
var _failures := 0
|
||||
|
||||
func _check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
_failures += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
var client := M2Client.new()
|
||||
get_root().add_child(client)
|
||||
var resets := [0]
|
||||
client.world_reset.connect(func() -> void:
|
||||
resets[0] += 1)
|
||||
|
||||
# The client is offline here, so this checks the public lifecycle contract:
|
||||
# explicit disconnect always gives the retained scene one synchronous reset
|
||||
# notification, while remaining idempotent at the transport layer.
|
||||
client.disconnect_from_server()
|
||||
_check(resets[0] == 1,
|
||||
"explicit disconnect emits one world_reset for retained scene cleanup")
|
||||
client.disconnect_from_server()
|
||||
_check(resets[0] == 2,
|
||||
"repeated explicit disconnect emits one reset per lifecycle boundary")
|
||||
|
||||
client.free()
|
||||
if _failures == 0:
|
||||
print("PASS: disconnect_world_reset_test")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _failures)
|
||||
quit(1)
|
||||
@@ -1 +0,0 @@
|
||||
uid://c8ula8khqwrph
|
||||
@@ -1,13 +0,0 @@
|
||||
# dragon_soul_scope_test —— 40250 当前服务器范围自检。
|
||||
# 协议层仍保留 ds_refine 兼容 API,但游戏场景不得显示/消费龙魂入口。
|
||||
extends SceneTree
|
||||
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
|
||||
func _init() -> void:
|
||||
if GameScene.ENABLE_DRAGON_SOUL:
|
||||
printerr("FAIL: Dragon Soul runtime entry must be disabled for target 40250")
|
||||
quit(1)
|
||||
return
|
||||
print("PASS: dragon_soul_scope_test (runtime entry disabled; protocol compatibility retained)")
|
||||
quit(0)
|
||||
@@ -1 +0,0 @@
|
||||
uid://b40nfrlx035b4
|
||||
@@ -1,105 +0,0 @@
|
||||
# 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 +0,0 @@
|
||||
uid://c5xpqyrnavvjx
|
||||
@@ -1,16 +0,0 @@
|
||||
extends SceneTree
|
||||
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
|
||||
func _init() -> void:
|
||||
var root := "../assets"
|
||||
var img: Image = UiAssets.load_dds_image("../assets/ETC/ymir work/ui/public.dds")
|
||||
if img:
|
||||
print("Loaded public.dds: size=", img.get_size(), " format=", img.get_format())
|
||||
for y in range(232, 240):
|
||||
var row := ""
|
||||
for x in range(121, 130):
|
||||
var p := img.get_pixel(x, y)
|
||||
row += "(%.1f,%.1f,%.1f,%.1f) " % [p.r, p.g, p.b, p.a]
|
||||
print("y=", y, ": ", row)
|
||||
quit(0)
|
||||
@@ -1 +0,0 @@
|
||||
uid://6bv05bdsw8wh
|
||||
@@ -1,44 +0,0 @@
|
||||
# dungeon_result_test —— DUNGEON_RESULT 的九字段 UI 消费回归。
|
||||
extends SceneTree
|
||||
|
||||
const DungeonResultUI = preload("res://ui/dungeon_result_ui.gd")
|
||||
|
||||
var _fail := 0
|
||||
|
||||
func _ck(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: dungeon_result_test (ShowDungeonResult nine fields)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var layer := CanvasLayer.new()
|
||||
get_root().add_child(layer)
|
||||
var result_ui := DungeonResultUI.new()
|
||||
get_root().add_child(result_ui)
|
||||
result_ui.setup(layer)
|
||||
var result := {
|
||||
"killstone_count": 2, "killmob_count": 40, "find_hidden": 1, "hidden_total": 3,
|
||||
"use_potion": 5, "is_revived": 0, "killallmob": 1, "total_time": 600,
|
||||
"bonus_exp": 1234,
|
||||
}
|
||||
result_ui.show_result(result)
|
||||
await process_frame
|
||||
_ck(result_ui.is_open(), "result window is visible")
|
||||
_ck(result_ui.result() == result, "all nine server fields are retained")
|
||||
var rows: Array = result_ui._display_rows()
|
||||
_ck(rows.size() == 8, "display has the eight grouped rows")
|
||||
_ck(str(rows[6][1]) == "10:00", "total_time is formatted as mm:ss")
|
||||
_ck(str(rows[7][1]) == "1234", "bonus_exp is displayed")
|
||||
result_ui.close()
|
||||
_ck(not result_ui.is_open(), "close hides result window")
|
||||
layer.queue_free()
|
||||
result_ui.queue_free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://icsacgdld3gb
|
||||
@@ -1,86 +0,0 @@
|
||||
# DungeonState —— GC_DUNGEON 目的地状态(40250/ClientVS22)。
|
||||
#
|
||||
# 参考端 PythonNetworkStreamPhaseGame.cpp:3875-3901 收到目的地后调用
|
||||
# CPythonPlayer::SetDungeonDestinationPosition;PythonPlayer.cpp:147-167、
|
||||
# :1255-1285 保存全局像素/厘米坐标、立即提示,并在接近目标后清除,未到达时
|
||||
# 每 20 秒再次提示。本工程把目标状态与 UI 解耦,UI 只消费信号。
|
||||
extends Node
|
||||
|
||||
signal destination_changed(active: bool, world_pos: Vector3)
|
||||
signal destination_reached(world_pos: Vector3)
|
||||
signal alarm_requested(world_pos: Vector3)
|
||||
|
||||
const TIME_ATTACK_START := 0
|
||||
const DESTINATION_POSITION := 1
|
||||
const ARRIVAL_THRESHOLD_CM := 10000.0
|
||||
const ALARM_INTERVAL_SEC := 20.0
|
||||
|
||||
var client: Node
|
||||
var player_getter: Callable
|
||||
var active := false
|
||||
var destination_cm := Vector2.ZERO
|
||||
var destination_world := Vector3.ZERO
|
||||
var last_subheader := -1
|
||||
var alarm_count := 0
|
||||
var _last_alarm_ms := -1
|
||||
|
||||
func setup(m2client: Node, get_player: Callable) -> void:
|
||||
client = m2client
|
||||
player_getter = get_player
|
||||
if client and client.has_signal("dungeon_event"):
|
||||
client.dungeon_event.connect(_on_dungeon_event)
|
||||
if client and client.has_signal("world_reset"):
|
||||
client.world_reset.connect(clear)
|
||||
|
||||
func _on_dungeon_event(subheader: int, x: int, y: int, has_destination: bool) -> void:
|
||||
last_subheader = subheader
|
||||
# ClientVS22's TIME_ATTACK_START branch is intentionally empty. Consume the
|
||||
# packet but do not invent a local timer that the reference client does not run.
|
||||
if subheader != DESTINATION_POSITION or not has_destination:
|
||||
return
|
||||
destination_cm = Vector2(float(x), float(y))
|
||||
# GC_DUNGEON carries TPixelPosition x/y in global server centimetres. The
|
||||
# network frame uses -Y for Godot Z before MapCoord applies the map base.
|
||||
destination_world = MapCoord.to_world(Vector3(float(x) * 0.01, 0.0, -float(y) * 0.01))
|
||||
active = true
|
||||
_last_alarm_ms = -1
|
||||
destination_changed.emit(true, destination_world)
|
||||
_alarm()
|
||||
|
||||
func clear() -> void:
|
||||
var was_active := active
|
||||
active = false
|
||||
destination_cm = Vector2.ZERO
|
||||
destination_world = Vector3.ZERO
|
||||
_last_alarm_ms = -1
|
||||
if was_active:
|
||||
destination_changed.emit(false, Vector3.ZERO)
|
||||
|
||||
func _player() -> Node3D:
|
||||
if not player_getter.is_valid():
|
||||
return null
|
||||
var p: Variant = player_getter.call()
|
||||
return p as Node3D
|
||||
|
||||
func _alarm() -> void:
|
||||
_last_alarm_ms = Time.get_ticks_msec()
|
||||
alarm_count += 1
|
||||
alarm_requested.emit(destination_world)
|
||||
|
||||
func _process(_dt: float) -> void:
|
||||
if not active:
|
||||
return
|
||||
var p := _player()
|
||||
if p:
|
||||
# Match PythonPlayer.cpp:155: integer Manhattan distance, not Euclidean.
|
||||
var p_cm := MapCoord.to_server_cm(p.global_position)
|
||||
var distance_cm := absf(p_cm.x - destination_cm.x) + absf(p_cm.y - destination_cm.y)
|
||||
if distance_cm < ARRIVAL_THRESHOLD_CM:
|
||||
var reached := destination_world
|
||||
active = false
|
||||
destination_changed.emit(false, reached)
|
||||
destination_reached.emit(reached)
|
||||
return
|
||||
var now := Time.get_ticks_msec()
|
||||
if _last_alarm_ms < 0 or now - _last_alarm_ms > int(ALARM_INTERVAL_SEC * 1000.0):
|
||||
_alarm()
|
||||
@@ -1 +0,0 @@
|
||||
uid://da5ixd8n62gyh
|
||||
@@ -1,71 +0,0 @@
|
||||
# dungeon_state_test —— GC_DUNGEON 目的地状态回归。
|
||||
# godot --headless --path project --script dungeon_state_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const DungeonState = preload("res://dungeon_state.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal dungeon_event(subheader: int, x: int, y: int, has_destination: bool)
|
||||
signal world_reset
|
||||
|
||||
var _fail := 0
|
||||
|
||||
func _ck(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: dungeon_state_test (GC_DUNGEON destination / arrival / reset)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
MapCoord.set_base(Vector2.ZERO)
|
||||
var client := FakeClient.new()
|
||||
var player := Node3D.new()
|
||||
get_root().add_child(client)
|
||||
get_root().add_child(player)
|
||||
var state := DungeonState.new()
|
||||
get_root().add_child(state)
|
||||
state.setup(client, func() -> Node3D: return player)
|
||||
await process_frame
|
||||
var changes: Array = []
|
||||
var alarms: Array = []
|
||||
state.destination_changed.connect(func(active: bool, pos: Vector3): changes.append([active, pos]))
|
||||
state.alarm_requested.connect(func(pos: Vector3): alarms.append(pos))
|
||||
|
||||
client.dungeon_event.emit(state.TIME_ATTACK_START, 0, 0, false)
|
||||
_ck(not state.active and state.last_subheader == state.TIME_ATTACK_START,
|
||||
"TIME_ATTACK_START is consumed without inventing a timer")
|
||||
client.dungeon_event.emit(state.DESTINATION_POSITION, 3210, 6540, true)
|
||||
_ck(state.active, "destination packet activates route")
|
||||
_ck(state.destination_cm == Vector2(3210, 6540), "destination keeps server cm")
|
||||
_ck(state.destination_world.distance_to(Vector3(32.1, 0.0, 65.4)) < 0.01,
|
||||
"destination converts global cm through MapCoord")
|
||||
_ck(changes.size() == 1 and changes[0][0] == true, "destination_changed opens target")
|
||||
_ck(alarms.size() == 1, "destination alarms immediately")
|
||||
|
||||
# Reference uses Manhattan < 10000 cm, so exactly 100m is not complete and
|
||||
# 99m is complete. Keep the player in the same global coordinate frame.
|
||||
player.global_position = Vector3(32.1, 0.0, 65.4 - 100.0)
|
||||
state._process(0.0)
|
||||
_ck(state.active, "Manhattan distance exactly 100m remains active")
|
||||
player.global_position = Vector3(32.1, 0.0, 65.4 - 99.0)
|
||||
state._process(0.0)
|
||||
_ck(not state.active, "Manhattan distance below 100m completes route")
|
||||
_ck(changes.size() == 2 and changes[1][0] == false, "arrival closes target")
|
||||
|
||||
client.dungeon_event.emit(state.DESTINATION_POSITION, 10000, 20000, true)
|
||||
_ck(state.active, "second destination reopens route")
|
||||
client.world_reset.emit()
|
||||
_ck(not state.active and state.destination_cm == Vector2.ZERO and changes.size() == 4,
|
||||
"world reset clears dungeon destination")
|
||||
|
||||
client.queue_free()
|
||||
player.queue_free()
|
||||
state.queue_free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://exadl2wqkpmh
|
||||
@@ -1,116 +0,0 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
const OPERATIONS := [2, 3, 4, 5, 6, 8]
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
var materials: Array = []
|
||||
var white := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||
white.fill(Color.WHITE)
|
||||
var texture := ImageTexture.create_from_image(white)
|
||||
for destination in [6, 2]:
|
||||
for operation in OPERATIONS:
|
||||
fx.build({"particles": [{"particle": {"BillboardType": 1, "ColorOperationType": operation,
|
||||
"SrcBlendType": 5, "DestBlendType": destination}}]})
|
||||
var material: Material = fx._emitters[-1].material_override
|
||||
if operation == 4:
|
||||
check(material is StandardMaterial3D, "ordinary modulation unchanged")
|
||||
material.albedo_texture = texture
|
||||
else:
|
||||
check(material is ShaderMaterial, "operation %d has dedicated material" % operation)
|
||||
if material is ShaderMaterial:
|
||||
check(material.get_shader_parameter("color_operation") == operation, "texture-stage operation")
|
||||
check(material.get_shader_parameter("color_gain") == (2.0 if operation == 5 else (4.0 if operation == 6 else 1.0)), "RGB gain")
|
||||
check(material.get_shader_parameter("billboard_mode") == BaseMaterial3D.BILLBOARD_PARTICLES, "particle rotation preserved")
|
||||
material.set_shader_parameter("albedo_texture", texture)
|
||||
materials.append(material)
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await pixels(materials)
|
||||
else:
|
||||
print("SKIP: color-operation pixel checks require rendering")
|
||||
fx.free()
|
||||
var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/noejeon_4_blow.mse")
|
||||
var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path)
|
||||
var count := 0
|
||||
for particle in spec.get("particles", []):
|
||||
if int(particle.particle.get("ColorOperationType", 4)) != 5:
|
||||
continue
|
||||
var prop: Dictionary = particle.particle.duplicate(true)
|
||||
prop.erase("TextureFiles") # real properties, without texture-loader coupling
|
||||
var real_fx := EffectPlayer.new()
|
||||
real_fx.build({"particles": [{"particle": prop}]})
|
||||
var material: Material = real_fx._emitters[0].material_override
|
||||
check(material is ShaderMaterial, "real MODULATE2X material")
|
||||
if material is ShaderMaterial:
|
||||
check(material.get_shader_parameter("color_gain") == 2.0, "real MODULATE2X gain")
|
||||
check(material.get_shader_parameter("billboard_mode") == BaseMaterial3D.BILLBOARD_FIXED_Y, "real Y billboard preserved")
|
||||
count += 1
|
||||
real_fx.free()
|
||||
check(count > 0, "real MODULATE2X coverage")
|
||||
print("real color-operation particles=%d" % count)
|
||||
print("effect_color_operation_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
func pixels(materials: Array) -> void:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(96, 96)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var env := WorldEnvironment.new()
|
||||
env.environment = Environment.new()
|
||||
env.environment.background_mode = Environment.BG_COLOR
|
||||
env.environment.background_color = Color.BLACK
|
||||
viewport.add_child(env)
|
||||
var camera := Camera3D.new()
|
||||
viewport.add_child(camera)
|
||||
camera.position = Vector3(0, 0, 3)
|
||||
camera.look_at(Vector3.ZERO)
|
||||
camera.current = true
|
||||
var quad := MeshInstance3D.new()
|
||||
var source := QuadMesh.new()
|
||||
source.size = Vector2(2, 2)
|
||||
var arrays := source.get_mesh_arrays()
|
||||
var colors := PackedColorArray()
|
||||
colors.resize(arrays[Mesh.ARRAY_VERTEX].size())
|
||||
colors.fill(Color(0.6, 0.25, 0.125, 0.5))
|
||||
arrays[Mesh.ARRAY_COLOR] = colors
|
||||
quad.mesh = ArrayMesh.new()
|
||||
quad.mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||
viewport.add_child(quad)
|
||||
for tint in [Color.WHITE, Color(0.5, 0.75, 0.25, 0.8)]:
|
||||
var texture_image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||
texture_image.fill(tint)
|
||||
var decoded := texture_image.get_pixel(0, 0).srgb_to_linear()
|
||||
var texture := ImageTexture.create_from_image(texture_image)
|
||||
for i in materials.size():
|
||||
if materials[i] is ShaderMaterial:
|
||||
materials[i].set_shader_parameter("albedo_texture", texture)
|
||||
else:
|
||||
materials[i].albedo_texture = texture
|
||||
quad.material_override = materials[i]
|
||||
await process_frame
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var pixel := viewport.get_texture().get_image().get_pixel(48, 48).srgb_to_linear()
|
||||
var operation: int = OPERATIONS[i % OPERATIONS.size()]
|
||||
var vertex := Vector3(0.6, 0.25, 0.125)
|
||||
var texel := Vector3(decoded.r, decoded.g, decoded.b)
|
||||
var expected := vertex * texel
|
||||
match operation:
|
||||
2: expected = vertex
|
||||
3: expected = texel
|
||||
5: expected *= 2.0
|
||||
6: expected *= 4.0
|
||||
8: expected = vertex + texel - Vector3.ONE * 0.5
|
||||
expected = expected.clamp(Vector3.ZERO, Vector3.ONE) * (0.5 * decoded.a)
|
||||
var actual := Vector3(pixel.r, pixel.g, pixel.b)
|
||||
check(actual.distance_to(expected) < 0.025, "op=%d tint=%s RGB/alpha got=%s expected=%s" % [operation, tint, actual, expected])
|
||||
print("color-operation pixel cases=24")
|
||||
viewport.queue_free()
|
||||
await process_frame
|
||||
@@ -1 +0,0 @@
|
||||
uid://dds0frgva7yxj
|
||||
@@ -1,83 +0,0 @@
|
||||
extends SceneTree
|
||||
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
|
||||
func effective(pm: ParticleProcessMaterial, age: float) -> Color:
|
||||
return pm.color * pm.color_ramp.gradient.sample(age) if pm.color_ramp else pm.color
|
||||
|
||||
func _init() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": {
|
||||
"TimeEventColorRed": [[0.0, 0.2], [0.5, 0.8], [1.0, 0.4]],
|
||||
"TimeEventColorGreen": [[0.25, 0.4], [0.75, 0.8]],
|
||||
"TimeEventColorBlue": [[0.0, 0.5]],
|
||||
"TimeEventAlpha": [[0.0, 1.0], [1.0, 0.0]]}},
|
||||
{"particle": {"TimeEventColorRed": [[0.0, 0.5]], "TimeEventAlpha": [[0.0, 1.0]]}},
|
||||
{"particle": {"TimeEventColorBlue": [[0.0, 0.2], [1.0, 0.8]]}},
|
||||
{"particle": {}}]})
|
||||
var pm: ParticleProcessMaterial = fx._emitters[0].process_material
|
||||
check(pm.color == Color.WHITE, "neutral base prevents double tint")
|
||||
check(effective(pm, 0.0).is_equal_approx(Color(0.2, 0.4, 0.5, 1.0)), "birth RGBA")
|
||||
check(effective(pm, 0.25).is_equal_approx(Color(0.5, 0.4, 0.5, 0.75)), "independent green knot")
|
||||
check(effective(pm, 0.5).is_equal_approx(Color(0.8, 0.6, 0.5, 0.5)), "middle RGBA")
|
||||
check(effective(pm, 0.75).is_equal_approx(Color(0.6, 0.8, 0.5, 0.25)), "late RGBA")
|
||||
check(effective(pm, 1.0).is_equal_approx(Color(0.4, 0.8, 0.5, 0.0)), "end RGBA")
|
||||
check(effective(fx._emitters[1].process_material, 0.5).is_equal_approx(Color(0.5, 1.0, 1.0)),
|
||||
"constant 0.5 tint remains 0.5, not 0.25")
|
||||
check(effective(fx._emitters[2].process_material, 0.5).is_equal_approx(Color(1.0, 1.0, 0.5)),
|
||||
"RGB animates without alpha track")
|
||||
check(effective(fx._emitters[3].process_material, 0.5) == Color.WHITE, "missing tracks default white")
|
||||
fx.free()
|
||||
var malformed := EffectPlayer.new()
|
||||
var cleaned: GradientTexture1D = malformed._color_ramp({"TimeEventColorRed": [
|
||||
[1.0, 0.8], [], [0.0, 0.1], [0.0, 0.2], [NAN, 0.5], [0.5, INF], ["bad", 0.4]]})
|
||||
check(cleaned.gradient.sample(0.5).is_equal_approx(Color(0.5, 1, 1)),
|
||||
"unordered/duplicate keys normalized and invalid values ignored")
|
||||
malformed.free()
|
||||
real_resources()
|
||||
print("effect_color_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
|
||||
# Independent scalar interpolation oracle for the sorted, valid asset tracks.
|
||||
func reference(rows: Array, age: float) -> float:
|
||||
if rows.is_empty():
|
||||
return 1.0
|
||||
if age <= float(rows[0][0]):
|
||||
return float(rows[0][1])
|
||||
for i in range(1, rows.size()):
|
||||
if age <= float(rows[i][0]):
|
||||
var fraction := (age - float(rows[i - 1][0])) / (float(rows[i][0]) - float(rows[i - 1][0]))
|
||||
return float(rows[i - 1][1]) * (1.0 - fraction) + float(rows[i][1]) * fraction
|
||||
return float(rows[-1][1])
|
||||
|
||||
func real_resources() -> void:
|
||||
var count := 0
|
||||
for name in ["warrior/effect/palbang_4_sword.mse", "assassin/effect/beam_blow.mse",
|
||||
"sura/effect/pabeopsul_4_blow.mse", "shaman/effect/noejeon_making_1.mse"]:
|
||||
var path := AssetRoot.path().path_join("PC/ymir work/pc/" + name)
|
||||
var parsed: Dictionary = preload("res://fx/mse.gd").new().parse_file(path)
|
||||
check(not parsed.get("particles", []).is_empty(), "real resource loaded: " + name)
|
||||
for particle in parsed.get("particles", []):
|
||||
# Exercise real RGBA through material construction; texture loading and
|
||||
# emission geometry are deliberately outside this color-only regression.
|
||||
var prop: Dictionary = {}
|
||||
for key in ["TimeEventColorRed", "TimeEventColorGreen", "TimeEventColorBlue", "TimeEventAlpha"]:
|
||||
prop[key] = particle.particle.get(key, [])
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": prop}]})
|
||||
var pm: ParticleProcessMaterial = fx._emitters[0].process_material
|
||||
for step in range(41):
|
||||
var age := float(step) / 40.0
|
||||
var expected := Color(reference(prop.TimeEventColorRed, age),
|
||||
reference(prop.TimeEventColorGreen, age), reference(prop.TimeEventColorBlue, age),
|
||||
reference(prop.TimeEventAlpha, age))
|
||||
check(effective(pm, age).is_equal_approx(expected), "real RGBA %s particle %d t=%f" % [name, count, age])
|
||||
count += 1
|
||||
fx.free()
|
||||
print("real color tracks: particles=%d samples=%d" % [count, count * 41])
|
||||
@@ -1 +0,0 @@
|
||||
uid://cl2sjnmvtvr3q
|
||||
@@ -1,28 +0,0 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
for attached in [0, 1]:
|
||||
fx.build({"particles": [{"emitter": {"CycleLength": 0.8, "CycleLoopEnable": 0,
|
||||
"TimeEventLifeTime": [[0, 0.05]]}, "particle": {"AttachEnable": attached}}]})
|
||||
root.add_child(fx)
|
||||
fx.play(true)
|
||||
for i in range(2):
|
||||
check(fx._emitters[i].local_coords == bool(i), "AttachEnable determines local/world simulation")
|
||||
check(not fx._emitters[i].one_shot, "effect cleanup must not impose single GPU lifetime")
|
||||
await create_timer(0.3).timeout
|
||||
for emitter in fx._emitters:
|
||||
check(emitter.emitting, "short-lived particles still emit inside source cycle")
|
||||
await create_timer(0.6).timeout
|
||||
for emitter in fx._emitters:
|
||||
check(not emitter.emitting, "source cycle still stops emission")
|
||||
fx.free()
|
||||
print("effect_emission_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -1 +0,0 @@
|
||||
uid://b6olq0ioo7c0f
|
||||
@@ -1,64 +0,0 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
for kind in [4, 5]:
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": {"BillboardType": kind, "ColorOperationType": 4}}]})
|
||||
var emitter: GPUParticles3D = fx._emitters[0]
|
||||
var mesh := emitter.draw_pass_1
|
||||
var arrays := mesh.surface_get_arrays(0)
|
||||
var angles := [-30.0, 30.0] if kind == 4 else [0.0, -60.0, 60.0]
|
||||
check(arrays[Mesh.ARRAY_VERTEX].size() == angles.size() * 6, "one quad per reference face")
|
||||
for i in angles.size():
|
||||
for v in 6:
|
||||
check(is_equal_approx(arrays[Mesh.ARRAY_TEX_UV2][i * 6 + v].x, deg_to_rad(angles[i])), "reference face angle")
|
||||
check(emitter.material_override is ShaderMaterial, "custom face shader")
|
||||
check(emitter.material_override.get_shader_parameter("billboard_mode") == 5, "multiface mode")
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await pixels(mesh, emitter.material_override, kind)
|
||||
fx.free()
|
||||
print("effect_faces_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
func pixels(mesh: Mesh, material: ShaderMaterial, kind: int) -> void:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(128, 128)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var world := WorldEnvironment.new()
|
||||
world.environment = Environment.new()
|
||||
world.environment.background_mode = Environment.BG_COLOR
|
||||
world.environment.background_color = Color.BLACK
|
||||
viewport.add_child(world)
|
||||
var image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color.WHITE)
|
||||
material.set_shader_parameter("albedo_texture", ImageTexture.create_from_image(image))
|
||||
var instance := MeshInstance3D.new()
|
||||
instance.mesh = mesh
|
||||
instance.material_override = material
|
||||
viewport.add_child(instance)
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 1.28
|
||||
viewport.add_child(camera)
|
||||
for angle in [0.0, 0.7]:
|
||||
camera.position = Vector3(sin(angle) * 3, 0, cos(angle) * 3)
|
||||
camera.look_at(Vector3.ZERO)
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var pixels := viewport.get_texture().get_image()
|
||||
var width := 0
|
||||
for x in 128:
|
||||
if pixels.get_pixel(x, 64).r > 0.5: width += 1
|
||||
# Default width .64m: 2FACE projects cos(30deg), 3FACE includes a full front face.
|
||||
var expected := 64.0 * (cos(PI / 6) if kind == 4 else 1.0)
|
||||
check(absf(width - expected) <= 3.0, "Metal projection kind=%d camera=%f width=%d expected=%f" % [kind, angle, width, expected])
|
||||
viewport.free()
|
||||
await process_frame
|
||||
@@ -1 +0,0 @@
|
||||
uid://1mm2fl01mhtn
|
||||
@@ -1,57 +0,0 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": {"BillboardType": 3, "ColorOperationType": 4}}]})
|
||||
var material: Material = fx._emitters[0].material_override
|
||||
check(material is ShaderMaterial, "LIE no longer uses camera-facing StandardMaterial")
|
||||
if material is ShaderMaterial:
|
||||
check(material.get_shader_parameter("billboard_mode") == 4, "horizontal custom billboard selected")
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await pixels(material)
|
||||
fx.free()
|
||||
print("effect_lie_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
func pixels(material: ShaderMaterial) -> void:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(96, 96)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var environment := WorldEnvironment.new()
|
||||
environment.environment = Environment.new()
|
||||
environment.environment.background_mode = Environment.BG_COLOR
|
||||
environment.environment.background_color = Color.BLACK
|
||||
viewport.add_child(environment)
|
||||
var image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color.WHITE)
|
||||
material.set_shader_parameter("albedo_texture", ImageTexture.create_from_image(image))
|
||||
var mesh := MeshInstance3D.new()
|
||||
mesh.mesh = QuadMesh.new()
|
||||
mesh.mesh.size = Vector2(1.5, 1.5)
|
||||
mesh.material_override = material
|
||||
viewport.add_child(mesh)
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 3
|
||||
viewport.add_child(camera)
|
||||
for top in [true, false]:
|
||||
camera.position = Vector3(0, 3, 0) if top else Vector3(0, 0, 3)
|
||||
camera.look_at(Vector3.ZERO, Vector3.FORWARD if top else Vector3.UP)
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var pixels := viewport.get_texture().get_image()
|
||||
var lit := 0
|
||||
for y in 96:
|
||||
for x in 96:
|
||||
if pixels.get_pixel(x, y).r > 0.5: lit += 1
|
||||
check(lit > 1500 if top else lit < 100, "horizontal quad visibility top=%s pixels=%d" % [top, lit])
|
||||
viewport.free()
|
||||
await process_frame
|
||||
@@ -1 +0,0 @@
|
||||
uid://2do65c7c5cj3
|
||||
@@ -1,129 +0,0 @@
|
||||
extends SceneTree
|
||||
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
const EffectRegistry = preload("res://fx/effect_registry.gd")
|
||||
var failures := 0
|
||||
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func fixture():
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"start_time": 0.2,
|
||||
"emitter": {"CycleLength": 1.0, "CycleLoopEnable": 1}}],
|
||||
"lights": [{"duration": 0.8, "diffuse": [1.0, 0.5, 0.2, 1.0]}]})
|
||||
# A synthetic two-frame mesh isolates playback from MDE asset decoding.
|
||||
var host := Node3D.new()
|
||||
var mesh := MeshInstance3D.new()
|
||||
mesh.material_override = StandardMaterial3D.new()
|
||||
host.add_child(mesh)
|
||||
fx.add_child(host)
|
||||
fx._mesh_nodes.append(host)
|
||||
fx._mesh_states.append({"node": host, "clock": 0.0, "start_time": 0.0,
|
||||
"frame_delay": 0.5, "duration": 1.0, "children": [{"node": mesh,
|
||||
"frames": [{"visibility": 1.0}, {"visibility": 0.5}],
|
||||
"meshes": [ArrayMesh.new(), ArrayMesh.new()], "frame": -1}]})
|
||||
root.add_child(fx)
|
||||
fx.set_process(false) # Advance deterministically; SceneTree timers still run.
|
||||
return fx
|
||||
|
||||
func run() -> void:
|
||||
var fx = fixture()
|
||||
var emitter: GPUParticles3D = fx._emitters[0]
|
||||
check(not emitter.emitting, "build does not emit before play")
|
||||
fx.play(true)
|
||||
check(not emitter.emitting, "play respects delay immediately")
|
||||
fx._process(0.25)
|
||||
check(emitter.emitting, "particle starts on playback clock")
|
||||
fx._process(0.35)
|
||||
check(fx._mesh_states[0].children[0].frame == 1, "mesh reached second frame")
|
||||
fx.stop()
|
||||
fx._process(0.01)
|
||||
check(not fx._lights[0].visible, "stopped light stays hidden")
|
||||
check(not fx._mesh_nodes[0].visible, "stopped mesh stays hidden")
|
||||
check(is_equal_approx(fx._light_states[0].clock, 0.6), "stop freezes CPU clock")
|
||||
await create_timer(0.3).timeout
|
||||
check(not emitter.emitting, "old delayed start cannot revive stopped emitter")
|
||||
fx.play(false)
|
||||
check(not emitter.one_shot, "normal replay restores source loop mode")
|
||||
check(is_zero_approx(fx._mesh_states[0].clock), "mesh replay resets clock")
|
||||
check(is_zero_approx(fx._light_states[0].clock), "light replay resets clock")
|
||||
check(fx._mesh_states[0].children[0].frame == 0, "mesh replay restores first frame")
|
||||
check(fx._lights[0].visible, "light replay restores visibility")
|
||||
fx._process(0.1)
|
||||
check(not emitter.emitting, "replay uses new start delay")
|
||||
fx._process(0.1)
|
||||
check(emitter.emitting, "replay starts at delay boundary")
|
||||
# Old forced-one-shot cleanup must not delete a later persistent playback.
|
||||
await create_timer(2.5).timeout
|
||||
check(is_instance_valid(fx), "old auto-free cannot delete replay")
|
||||
if is_instance_valid(fx):
|
||||
fx.free()
|
||||
var once = fixture()
|
||||
once.play(true)
|
||||
once._process(once._longest_life() + 0.6)
|
||||
check(once.is_queued_for_deletion(), "current one-shot still cleans up")
|
||||
if not once.is_queued_for_deletion():
|
||||
once.free()
|
||||
await process_frame
|
||||
# Exercise the actual SceneTree process path, not just manual clock updates.
|
||||
var live = fixture()
|
||||
live.set_process(true)
|
||||
live.play()
|
||||
await create_timer(0.35).timeout
|
||||
check(live._emitters[0].emitting, "SceneTree starts delayed emission")
|
||||
live.stop()
|
||||
var stopped_clock: float = live._particle_states[0].clock
|
||||
await create_timer(0.1).timeout
|
||||
check(not live._emitters[0].emitting and not live._lights[0].visible,
|
||||
"SceneTree does not reactivate stopped effect")
|
||||
check(is_equal_approx(live._particle_states[0].clock, stopped_clock),
|
||||
"SceneTree leaves stopped clock unchanged")
|
||||
live.free()
|
||||
await registry_lifecycle()
|
||||
print("effect_playback_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
|
||||
# CBT-01 evidence: EffectRegistry reports the real spawn and tree-exit boundaries.
|
||||
func registry_lifecycle() -> void:
|
||||
var registry := EffectRegistry.new()
|
||||
var spec := {"lights": [{"duration": 0.8, "diffuse": [1.0, 0.5, 0.2, 1.0]}]}
|
||||
registry._path_cache["synthetic"] = "synthetic.mse"
|
||||
registry._spec_cache["synthetic.mse"] = spec
|
||||
var spawned: Array = []
|
||||
var finished: Array = []
|
||||
registry.fx_spawned.connect(func(effect: String, id: int, lifetime: int) -> void: spawned.append([effect, id, lifetime]))
|
||||
registry.fx_finished.connect(func(effect: String, id: int, lifetime: int, elapsed: int, reason: String) -> void:
|
||||
finished.append([effect, id, lifetime, elapsed, reason]))
|
||||
var parent := Node3D.new()
|
||||
root.add_child(parent)
|
||||
var once := registry.spawn("synthetic", parent, true)
|
||||
check(spawned.size() == 1 and spawned[0][0] == "synthetic" and spawned[0][2] == 1000,
|
||||
"one-shot spawn reports its MSE-defined lifetime: %s" % [spawned])
|
||||
check(finished.is_empty(), "spawn alone is not a finish")
|
||||
await create_timer(1.8).timeout
|
||||
check(not is_instance_valid(once), "one-shot effect left the tree on its own clock")
|
||||
check(finished.size() == 1 and finished[0][1] == spawned[0][1] and finished[0][4] == "cleanup",
|
||||
"tree exit reports the same id with reason cleanup: %s" % [finished])
|
||||
if finished.size() == 1:
|
||||
check(int(finished[0][3]) >= 1000 and int(finished[0][3]) <= 1000 + 1000,
|
||||
"cleanup happened inside lifetime + 1s (elapsed %dms)" % int(finished[0][3]))
|
||||
var looping := registry.spawn("synthetic", parent, false)
|
||||
check(spawned.size() == 2 and spawned[1][2] == -1 and spawned[1][1] != spawned[0][1],
|
||||
"looping spawn has a fresh id and no defined lifetime")
|
||||
var early := registry.spawn("synthetic", parent, true)
|
||||
parent.remove_child(early)
|
||||
early.free()
|
||||
check(finished.size() == 2 and finished[1][4] == "removed", "owner removal before cleanup reports reason removed")
|
||||
parent.free()
|
||||
check(finished.size() == 3 and finished[2][1] == spawned[1][1], "scene teardown closes the looping effect")
|
||||
var orphan := Node3D.new()
|
||||
check(registry.spawn("missing", orphan, true) == null and spawned.size() == 3,
|
||||
"unresolvable effect emits no spawn event")
|
||||
orphan.free()
|
||||
await process_frame
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user