# 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 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_dwBattleHitEffectID:net_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") # 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") 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")