Files
mtgodot-poc/project/gamescene_test.gd
T
shenandshen c93894313a fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
2026-09-21 16:38:59 -07:00

179 lines
7.8 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# gamescene_test —— game_scene.gd 装配的 headless 自检(无服务器 / 无地图)。
# godot --headless --path project --script gamescene_test.gd
# 假 M2Client 驱动:断言场景装出来、子系统接线、实体镜像、主角定位。
extends SceneTree
const GameScene = preload("res://game_scene.gd")
const MobileUiRoot = preload("res://ui/mobile/mobile_ui_root.gd")
class FakeClient extends Node:
signal entity_spawned(entity: Dictionary)
signal entity_despawned(vid: int)
signal entity_moved(vid: int)
signal entity_main_set(vid: int)
signal entity_dead(vid: int)
signal vitals_changed(vid: int)
signal points_changed(points: Dictionary)
signal target_info(vid: int, hp_percent: int)
signal chat(type: int, vid: int, text: String)
signal damage(vid: int, amount: int, flag: int)
signal motion(vid: int, victim_vid: int, motion: int)
signal gift_available()
signal weather_changed(enabled: bool)
signal guild_make_requested()
signal script_dialog(skin: int, text: String)
var ents := {}
var main := 0
func is_in_game() -> bool: return true
func get_main_vid() -> int: return main
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
func move(_f, _a, _r, _x, _y) -> bool: return true
func attack(_m, _v) -> bool: return true
func set_target(_v) -> bool: return true
func guild_answer_make(_name: String) -> bool: return true
func spawn(vid, nm, pos, is_main := false):
ents[vid] = {"vid": vid, "name": nm, "pos": pos, "is_main": is_main,
"func": 0, "moving": false, "angle_deg": 0.0, "hp": 100, "max_hp": 100, "dead": false}
if is_main:
main = vid
entity_main_set.emit(vid)
entity_spawned.emit(ents[vid])
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: gamescene_test (assembly + wiring + entity mirror + spawn locate)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var fc := FakeClient.new()
get_root().add_child(fc)
# 主角先于场景就位(模拟重连 / 已在局内)
fc.spawn(1000, "Me", Vector3(20, 0, -30), true)
var gs: Node3D = GameScene.new()
get_root().add_child(gs)
await gs.setup(fc, "", "no_such_map") # setup 现在是协程(分帧避免阻塞漏 PONG)
await process_frame
# 1) 子系统都装出来了
_ck(gs.player != null, "player built")
_ck(gs.cam != null and gs.cam.current, "camera current")
_ck(gs.pc != null, "player_controller built")
_ck(gs.net_world != null, "net_world built")
_ck(gs.net_play != null, "net_play built")
# §3.7 命中特效 m_dwBattleHitEffectIDnet_play 用场景的特效注册表,挂在实体挂载点下
_ck(gs.net_play != null and gs.fx != null and gs.net_play.fx == gs.fx \
and gs.net_play.fx_parent == gs.get_node_or_null("Entities"),
"net_play.fx / fx_parent wired to the scene effect registry + entity mount")
_ck(gs.hud != null, "hud built")
_ck(gs.get_node_or_null("AppLifecycle") == null,
"GameScene does not create a second lifecycle coordinator")
_ck(gs.lifecycle_audio() != null and gs.lifecycle_audio().get_parent() == gs,
"GameScene exposes its audio to AppFlow lifecycle owner")
_ck(gs.dragon_soul_ui == null, "Dragon Soul runtime entry disabled for target 40250")
_ck(gs.atlas_ui != null, "atlas_ui built")
_ck(gs.get_node_or_null("Entities") != null, "entity mount present")
fc.weather_changed.emit(true)
await process_frame
_ck(gs.weather != null and gs.weather.kind() == "snow",
"40250 xmas_snow=1 enables snow on an eligible map")
gs.map_path = "metin2_map_n_flame_01"
fc.weather_changed.emit(true)
await process_frame
_ck(gs.weather.kind() == "none",
"40250 xmas_snow ignores excluded maps")
fc.weather_changed.emit(false)
await process_frame
_ck(gs.weather.kind() == "none", "40250 xmas_snow=0 disables snow")
# 2) 主角定位到出生点(load_map 失败 -> sample_height 返回 0 -> y=0
_ck(gs.player.position.distance_to(Vector3(20, 0, 30)) < 0.01, # MapCoord.to_world 翻 Z
"local player placed at spawn (world 帧), got %s" % gs.player.position)
# 3) net_world 不给主角画节点(本地 player 代表)
await process_frame
_ck(gs.net_world.node_for(1000) == null, "no net_world node for local vid")
# The isolated test does not start the audio director; keep world-reset focused
# on the attack queue rather than invoking an uninitialized BGM fade target.
gs._audio = null
gs._pending_shots.append(0)
gs._pending_shots.append(7)
gs.net_play._target_vid = 1000
gs.net_play._auto_attack_vid = 1000
gs.net_play._reserved_mode = gs.net_play.ReservedMode.CLICK_ACTOR
gs.net_play._is_attacking = true
gs.net_play._hit_windows = [{"start_time": 0.0, "end_time": 1.0}]
gs.net_play._victim_flush.append({"vid": 2000, "x": 1, "y": 2})
gs.pc._is_going = true
gs._on_world_reset()
_ck(gs._pending_shots.is_empty(), "world reset clears stale bow FLY/CG_SHOOT queue")
_ck(gs.net_play._main_vid == 0 and gs.net_play._target_vid == 0
and gs.net_play._auto_attack_vid == 0
and gs.net_play._reserved_mode == gs.net_play.ReservedMode.NONE,
"world reset clears NetPlay actor/target/reservation state")
_ck(not gs.net_play._is_attacking and gs.net_play._hit_windows.is_empty()
and gs.net_play._victim_flush.is_empty(),
"world reset clears attack windows and victim sync queue")
_ck(not gs.pc.is_going(), "world reset stops the local controller's stale destination")
fc.motion.emit(1000, 0, 305) # CRaceMotionData::NAME_CLAP
await process_frame
_ck(int(gs.player.get_meta("last_motion", 0)) == 305,
"local GC_MOTION reaches the player representative")
fc.gift_available.emit()
await process_frame
_ck(gs._gift_dialog != null and gs._gift_dialog.visible,
"gift server command opens a visible notification")
gs._gift_dialog.hide() # 关闭已有 modal,模拟玩家已处理上一条通知
fc.guild_make_requested.emit()
await process_frame
_ck(gs.guild_ui._create_dialog != null and gs.guild_ui._create_dialog.visible,
"guild make request reaches the in-game guild creation dialog")
gs.guild_ui._close_guild_create_dialog()
# Quest EventManager side effects are consumed by the scene owner, not left
# as parser-only signals.
fc.script_dialog.emit(0, "[SET_CAMERA x(1000) y(2000) z(300) distance(500) pitch(40) rot(90)][DUNGEON_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)]")
await process_frame
_ck(gs.cam.has_method("is_event_locked") and bool(gs.cam.call("is_event_locked")),
"quest SET_CAMERA reaches the scene camera")
_ck(gs.dungeon_result_ui != null and bool(gs.dungeon_result_ui.call("is_open")),
"quest DUNGEON_RESULT opens the scene result window")
# 4) 别的实体会被镜像成挂载节点
fc.spawn(2000, "Wolf", Vector3(25, 0, -30))
await process_frame
var mob: Node3D = gs.net_world.node_for(2000)
_ck(mob != null and mob.get_parent() == gs.get_node("Entities"), "remote entity mirrored under mount")
# 5) HUD 活着,points 驱动不报错
fc.points_changed.emit({"hp": 60, "max_hp": 100, "sp": 20, "max_sp": 50,
"exp": 10, "next_exp": 100, "level": 3})
await process_frame
_ck(true, "points_changed handled without error")
gs.queue_free()
await process_frame
# Mobile profile uses the same scene assembly and NetPlay HUD contract, but
# swaps only the presentation root. This also guards against accidentally
# constructing the desktop HUD alongside the touch layer.
ProjectSettings.set_setting("mt/ui/profile", "mobile")
var mobile_gs: Node3D = GameScene.new()
get_root().add_child(mobile_gs)
await mobile_gs.setup(fc, "", "no_such_map")
await process_frame
_ck(mobile_gs.hud is MobileUiRoot, "mobile profile selects MobileUiRoot")
_ck(mobile_gs.hud.input_overlay != null, "mobile profile creates touch overlay")
mobile_gs.queue_free()
ProjectSettings.set_setting("mt/ui/profile", "auto")