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:
shen
2026-09-23 22:48:41 -07:00
parent 837c830182
commit 3981cbe5e6
709 changed files with 17 additions and 79025 deletions
-46
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://p7skimuw8kud
-84
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://dhtltbx5sioow
-62
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://cmwgj04n10rly
-567
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://prh3okf1n2lk
-235
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://dn635j1v44kp5
-98
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://cwgs1qcu80qfm
-129
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://k51m5hekg71a
-586
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://ke6yfuy7eum2
-88
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://ctbdjmg33pe67
-66
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://bdcaheq2nmoqg
-105
View File
@@ -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
-22
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://dyvg0v8ntldha
-81
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://yglonaagb03t
-179
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://cxhy4wk3451c1
-163
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://bo1041j7pwnra
-243
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://yu8c7genhjae
-195
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://b43adptpq1ua4
-72
View File
@@ -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
-146
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://bohd5850be0ex
-282
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://berxs4vw0s7vd
-90
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://dg8ihlbqw6jss
-250
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://qe5xev5hp4l4
-51
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://bhyev28dml0wh
-109
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://bilb10438dl5i
-106
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://dh4ucte7pxc3u
-220
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://dwoq34nldm7x3
-145
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://8amkno8ks12i
-16
View File
@@ -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),
-706
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://bisseuypqt88s
-271
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://b7cxwyvtq02xd
-152
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://x2vuateiv3w1
-218
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://ucwyc04610e3
-117
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://dlpn6gv5jrh14
-112
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://c77cvj7gtqck4
-55
View File
@@ -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
-22
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://buf6xm7130vgl
-35
View File
@@ -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
-13
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://b40nfrlx035b4
-105
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://c5xpqyrnavvjx
-16
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://6bv05bdsw8wh
-44
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://icsacgdld3gb
-86
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://da5ixd8n62gyh
-71
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://exadl2wqkpmh
-116
View File
@@ -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
-83
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://cl2sjnmvtvr3q
-28
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://b6olq0ioo7c0f
-64
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://1mm2fl01mhtn
-57
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://2do65c7c5cj3
-129
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
uid://48lu45ir8yo5
-77
View File
@@ -1,77 +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": [{"emitter": {"TimeEventLifeTime": [[0, 1.0]]}, "particle": {
"BillboardType": 1, "RotationType": 4, "RotationSpeed": 90, "ColorOperationType": 4}}]})
var emitter: GPUParticles3D = fx._emitters[0]
var pm: ParticleProcessMaterial = emitter.process_material
var material: ShaderMaterial = emitter.material_override
check(pm.angular_velocity_min == 0 and pm.angular_velocity_max == 0, "no second angular integration")
check(pm.anim_offset_min == 0 and pm.anim_offset_max == 1 and pm.anim_speed_max == 0, "stable per-birth sign seed")
check(material.get_shader_parameter("random_rotation") == true, "random rotation enabled")
check(is_equal_approx(material.get_shader_parameter("rotation_speed"), PI / 2), "full magnitude in radians")
if DisplayServer.get_name() != "headless": await pixels(material)
fx.free()
print("effect_random_rotation_test: failures=%d" % failures)
quit(1 if failures else 0)
func pixels(material: ShaderMaterial) -> 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 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))
material.set_shader_parameter("rotation_lifetime", 1.0)
var node := MultiMeshInstance3D.new()
node.multimesh = MultiMesh.new()
node.multimesh.transform_format = MultiMesh.TRANSFORM_3D
node.multimesh.use_custom_data = true
node.multimesh.mesh = QuadMesh.new()
node.multimesh.mesh.size = Vector2(1.2, 0.16)
node.multimesh.instance_count = 1
node.multimesh.set_instance_transform(0, Transform3D.IDENTITY)
node.material_override = material
viewport.add_child(node)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 2.0
camera.position.z = 3
viewport.add_child(camera)
var results: Array[Image] = []
for seed in [0.25, 0.75]:
node.multimesh.set_instance_custom_data(0, Color(0, 0.5, seed, 1))
await process_frame
await RenderingServer.frame_post_draw
results.append(viewport.get_texture().get_image())
var positive := 0
var negative := 0
var mismatch := 0
var lit := 0
for y in 128:
for x in 128:
var a := results[0].get_pixel(x, y).r > 0.5
var b := results[1].get_pixel(x, y).r > 0.5
if a:
lit += 1
positive += (2 * x - 127) * (2 * y - 127)
if b: negative += (2 * x - 127) * (2 * y - 127)
if a != (results[1].get_pixel(127 - x, y).r > 0.5): mismatch += 1
check(lit > 300, "rendered visible particles")
check(positive * float(negative) < 0, "opposite rotation directions")
check(mismatch < 60, "equal magnitude produces mirrored GPU images")
viewport.free()
await process_frame
@@ -1 +0,0 @@
uid://7if0yf3v2fuw
-63
View File
@@ -1,63 +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:
var fx := EffectPlayer.new()
# D3D8: SRCALPHA=5, INVSRCALPHA=6; both mesh and particle use this helper.
check(fx._blend(5, 6) == BaseMaterial3D.BLEND_MODE_MIX, "source-alpha transparency")
check(fx._blend(5, 2) == BaseMaterial3D.BLEND_MODE_ADD, "additive glow preserved")
fx.build({"particles": [{"start_time": 0.5,
"emitter": {"CycleLength": 1.0, "CycleLoopEnable": 1, "LoopCount": 2},
"particle": {"SrcBlendType": 5, "DestBlendType": 6}}]})
var state: Dictionary = fx._particle_states[0]
var emitter: GPUParticles3D = state.node
check(emitter.material_override.blend_mode == BaseMaterial3D.BLEND_MODE_MIX,
"built particle uses alpha material")
fx._advance_particle(state, 0.25)
check(is_equal_approx(state.clock, 0.25) and not emitter.emitting,
"stationary emitter advances clock and respects start delay")
# Simulate the play timer starting emission; isolate cycle gating from timers.
emitter.emitting = true
fx._advance_particle(state, 2.0)
check(emitter.emitting and not state.emission_stopped, "finite loop before end")
fx._advance_particle(state, 0.25)
check(not emitter.emitting and state.emission_stopped, "stationary finite loop ends")
state.clock = 0.0
state.start_time = 0.0
state.cycle_loop = false
state.emission_stopped = false
emitter.emitting = true
fx._advance_particle(state, 1.0)
check(not emitter.emitting and state.emission_stopped, "stationary non-loop ends")
state.clock = 0.0
state.cycle_loop = true
state.loop_count = 0
state.emission_stopped = false
emitter.emitting = true
fx._advance_particle(state, 10.0)
check(emitter.emitting and not state.emission_stopped, "unlimited loop preserved")
check(emitter.position == Vector3.ZERO, "missing position stays at origin")
fx.free()
var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/7budongbakbu_throw.mse")
var parsed: Dictionary = preload("res://fx/mse.gd").new().parse_file(path)
check(not parsed.get("particles", []).is_empty(), "real shaman effect loaded")
var real_fx := EffectPlayer.new()
real_fx.build(parsed, AssetRoot.path())
var alpha_count := 0
for i in parsed.get("particles", []).size():
var prop: Dictionary = parsed.particles[i].particle
if int(prop.get("SrcBlendType", 0)) == 5 and int(prop.get("DestBlendType", 0)) == 6:
alpha_count += 1
check(real_fx._emitters[i].material_override.blend_mode == BaseMaterial3D.BLEND_MODE_MIX,
"real shaman alpha particle %d" % i)
check(alpha_count > 0, "real fixture exercises alpha blending")
real_fx.free()
print("effect_render_regression_test: failures=%d" % failures)
quit(1 if failures else 0)

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