From 3981cbe5e6ee2a0a26837ba0d185e241b2be5388 Mon Sep 17 00:00:00 2001 From: shen <> Date: Wed, 23 Sep 2026 22:48:41 -0700 Subject: [PATCH] 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 --- extension/src/platform/EterLib/GrpDevice.cpp | 9 +- .../src/platform/ScriptLib/PythonBoot.cpp | 2 + extension/src/port/EterLib/GrpBase.cpp | 6 + extension/src/port/EterLib/GrpBase.h | 1 + project/ambience_test.gd | 46 - project/ambience_test.gd.uid | 1 - project/animation_blend_test.gd | 84 - project/animation_blend_test.gd.uid | 1 - project/animation_cache_test.gd | 62 - project/animation_cache_test.gd.uid | 1 - project/app_flow.gd | 567 ---- project/app_flow.gd.uid | 1 - project/app_flow_lifecycle_test.gd | 235 -- project/app_flow_lifecycle_test.gd.uid | 1 - project/app_lifecycle.gd | 98 - project/app_lifecycle.gd.uid | 1 - project/atlas_test.gd | 129 - project/atlas_test.gd.uid | 1 - project/audio.gd | 586 ---- project/audio.gd.uid | 1 - project/audio_miles_test.gd | 88 - project/audio_miles_test.gd.uid | 1 - project/auth_connect_bridge_test.gd | 66 - project/auth_connect_bridge_test.gd.uid | 1 - project/auth_peer_close_bridge_test.gd | 105 - project/auth_peer_close_bridge_test.gd.uid | 1 - project/bake_asset_index.gd | 22 - project/bake_asset_index.gd.uid | 1 - project/bgm_director.gd | 81 - project/bgm_director.gd.uid | 1 - project/bgm_test.gd | 179 -- project/bgm_test.gd.uid | 1 - project/channel_status_test.gd | 163 - project/channel_status_test.gd.uid | 1 - project/char_bench_test.gd | 243 -- project/char_bench_test.gd.uid | 1 - project/char_create_delete_test.gd | 195 -- project/char_create_delete_test.gd.uid | 1 - project/char_select_start_motion_test.gd | 72 - project/char_select_start_motion_test.gd.uid | 1 - project/char_select_visual_test.gd | 146 - project/char_select_visual_test.gd.uid | 1 - project/char_status_ui_test.gd | 282 -- project/char_status_ui_test.gd.uid | 1 - project/character_winding_test.gd | 90 - project/character_winding_test.gd.uid | 1 - project/charselect.gd | 250 -- project/charselect.gd.uid | 1 - project/chat_mobile_ui_test.gd | 51 - project/chat_mobile_ui_test.gd.uid | 1 - project/chat_tail.gd | 109 - project/chat_tail.gd.uid | 1 - project/chat_tail_test.gd | 106 - project/chat_tail_test.gd.uid | 1 - project/chat_test.gd | 220 -- project/chat_test.gd.uid | 1 - project/chest_system.gd | 145 - project/chest_system.gd.uid | 1 - project/client_main.gd | 16 - project/combat_fx_test.gd | 706 ---- project/combat_fx_test.gd.uid | 1 - project/combat_parity_test.gd | 271 -- project/combat_parity_test.gd.uid | 1 - project/complex_item_drop_test.gd | 152 - project/complex_item_drop_test.gd.uid | 1 - project/consumable_system.gd | 218 -- project/consumable_system.gd.uid | 1 - project/damage_effect.gd | 117 - project/damage_effect.gd.uid | 1 - project/damage_effect_test.gd | 112 - project/damage_effect_test.gd.uid | 1 - project/damage_local_main_parity_test.gd | 55 - project/damage_local_main_parity_test.gd.uid | 1 - project/dds_lifecycle_test.gd | 22 - project/dds_lifecycle_test.gd.uid | 1 - project/disconnect_world_reset_test.gd | 35 - project/disconnect_world_reset_test.gd.uid | 1 - project/dragon_soul_scope_test.gd | 13 - project/dragon_soul_scope_test.gd.uid | 1 - project/dragon_soul_test.gd | 105 - project/dragon_soul_test.gd.uid | 1 - project/dump_pixels.gd | 16 - project/dump_pixels.gd.uid | 1 - project/dungeon_result_test.gd | 44 - project/dungeon_result_test.gd.uid | 1 - project/dungeon_state.gd | 86 - project/dungeon_state.gd.uid | 1 - project/dungeon_state_test.gd | 71 - project/dungeon_state_test.gd.uid | 1 - project/effect_color_operation_test.gd | 116 - project/effect_color_operation_test.gd.uid | 1 - project/effect_color_test.gd | 83 - project/effect_color_test.gd.uid | 1 - project/effect_emission_test.gd | 28 - project/effect_emission_test.gd.uid | 1 - project/effect_faces_test.gd | 64 - project/effect_faces_test.gd.uid | 1 - project/effect_lie_test.gd | 57 - project/effect_lie_test.gd.uid | 1 - project/effect_playback_test.gd | 129 - project/effect_playback_test.gd.uid | 1 - project/effect_random_rotation_test.gd | 77 - project/effect_random_rotation_test.gd.uid | 1 - project/effect_render_regression_test.gd | 63 - project/effect_render_regression_test.gd.uid | 1 - project/effect_rotation_test.gd | 103 - project/effect_rotation_test.gd.uid | 1 - project/effect_scale_test.gd | 81 - project/effect_scale_test.gd.uid | 1 - project/effect_space_test.gd | 22 - project/effect_space_test.gd.uid | 1 - project/effect_surface_test.gd | 77 - project/effect_surface_test.gd.uid | 1 - project/effect_texture_animation_test.gd | 83 - project/effect_texture_animation_test.gd.uid | 1 - project/entity_rules.gd | 312 -- project/entity_rules.gd.uid | 1 - project/entity_rules_test.gd | 172 - project/entity_rules_test.gd.uid | 1 - project/environment_test.gd | 61 - project/environment_test.gd.uid | 1 - project/equip_model_test.gd | 401 --- project/equip_model_test.gd.uid | 1 - project/equip_rules.gd | 331 -- project/equip_rules.gd.uid | 1 - project/equip_rules_test.gd | 187 -- project/equip_rules_test.gd.uid | 1 - project/eterngrn_polish_test.gd | 146 - project/eterngrn_polish_test.gd.uid | 1 - project/fishing_water_test.gd | 60 - project/fishing_water_test.gd.uid | 1 - project/fly_attachment_effect_test.gd | 90 - project/fly_attachment_effect_test.gd.uid | 1 - project/fly_data_msf_test.gd | 63 - project/fly_data_msf_test.gd.uid | 1 - project/fly_dynamic_collision_test.gd | 116 - project/fly_dynamic_collision_test.gd.uid | 1 - project/fly_object.gd | 847 ----- project/fly_object.gd.uid | 1 - project/fly_reset_test.gd | 50 - project/fly_reset_test.gd.uid | 1 - project/fly_target_bounds_test.gd | 42 - project/fly_target_bounds_test.gd.uid | 1 - project/fly_target_lifecycle_test.gd | 64 - project/fly_target_lifecycle_test.gd.uid | 1 - project/fly_test.gd | 241 -- project/fly_test.gd.uid | 1 - project/forest_map_render_test.gd | 596 ---- project/forest_map_render_test.gd.uid | 1 - project/forest_mob_render_test.gd | 294 -- project/forest_mob_render_test.gd.uid | 1 - project/forest_viewpoints_test.gd | 92 - project/forest_viewpoints_test.gd.uid | 1 - project/fx/effect_player.gd | 863 ----- project/fx/effect_player.gd.uid | 1 - project/fx/effect_registry.gd | 171 - project/fx/effect_registry.gd.uid | 1 - project/fx/effect_space.gd | 6 - project/fx/effect_space.gd.uid | 1 - project/fx/mde.gd | 197 -- project/fx/mde.gd.uid | 1 - project/fx/motion_effect_anchor.gd | 44 - project/fx/motion_effect_anchor.gd.uid | 1 - project/fx/mse.gd | 256 -- project/fx/mse.gd.uid | 1 - project/fx/particle_color_material.gd | 139 - project/fx/particle_color_material.gd.uid | 1 - project/fx/particle_faces.gd | 31 - project/fx/particle_faces.gd.uid | 1 - project/fx/skill_fx.gd | 74 - project/fx/skill_fx.gd.uid | 1 - project/fx/target_effect_anchor.gd | 43 - project/fx/target_effect_anchor.gd.uid | 1 - project/fx/weather.gd | 83 - project/fx/weather.gd.uid | 1 - project/fx_test.gd | 227 -- project/fx_test.gd.uid | 1 - project/game_camera.gd | 430 --- project/game_camera.gd.uid | 1 - project/game_camera_test.gd | 89 - project/game_camera_test.gd.uid | 1 - project/game_scene.gd | 2017 ------------ project/game_scene.gd.uid | 1 - project/gamescene_test.gd | 178 -- project/gamescene_test.gd.uid | 1 - project/gamescene_visual_test.gd | 87 - project/gamescene_visual_test.gd.uid | 1 - project/gpu_lod_attachment_test.gd | 63 - project/gpu_lod_attachment_test.gd.uid | 1 - project/gpu_pose_bounds_test.gd | 49 - project/gpu_pose_bounds_test.gd.uid | 1 - project/guild_creation_ui_test.gd | 67 - project/guild_creation_ui_test.gd.uid | 1 - project/guild_mark_tail_test.gd | 114 - project/guild_mark_tail_test.gd.uid | 1 - project/guild_mark_test.gd | 133 - project/guild_mark_test.gd.uid | 1 - project/guild_refine_test.gd | 107 - project/guild_refine_test.gd.uid | 1 - project/guild_war_skill_test.gd | 151 - project/guild_war_skill_test.gd.uid | 1 - project/help_ui_test.gd | 125 - project/help_ui_test.gd.uid | 1 - project/hit_collision.gd | 160 - project/hit_collision.gd.uid | 1 - project/hit_collision_test.gd | 78 - project/hit_collision_test.gd.uid | 1 - project/hit_view_test.gd | 259 -- project/hit_view_test.gd.uid | 1 - project/hud.gd | 379 --- project/hud.gd.uid | 1 - project/indexed_fly_type_test.gd | 109 - project/indexed_fly_type_test.gd.uid | 1 - project/input_key_test.gd | 164 - project/input_key_test.gd.uid | 1 - project/intro_create_phase.gd | 132 - project/intro_create_phase.gd.uid | 1 - project/intro_empire_phase.gd | 99 - project/intro_empire_phase.gd.uid | 1 - project/intro_loading_phase.gd | 97 - project/intro_loading_phase.gd.uid | 1 - project/intro_login_phase.gd | 124 - project/intro_login_phase.gd.uid | 1 - project/intro_select_phase.gd | 130 - project/intro_select_phase.gd.uid | 1 - project/inventory_test.gd | 74 - project/inventory_test.gd.uid | 1 - project/inventory_ui_test.gd | 131 - project/inventory_ui_test.gd.uid | 1 - project/item_action_sound_parity_test.gd | 57 - project/item_action_sound_parity_test.gd.uid | 1 - project/item_attr_system.gd | 231 -- project/item_attr_system.gd.uid | 1 - project/item_tooltip_test.gd | 69 - project/item_tooltip_test.gd.uid | 1 - project/keyboard_motion_timeline_test.gd | 224 -- project/keyboard_motion_timeline_test.gd.uid | 1 - project/live_smoke_test.gd | 357 --- project/live_smoke_test.gd.uid | 1 - project/locale.gd | 118 - project/locale.gd.uid | 1 - project/login.gd | 180 -- project/login.gd.uid | 1 - project/love_ui_test.gd | 59 - project/love_ui_test.gd.uid | 1 - project/main.gd | 356 --- project/main.gd.uid | 1 - project/main.tscn | 6 - project/main_character_sync_test.gd | 116 - project/main_character_sync_test.gd.uid | 1 - project/metin_socket_system.gd | 204 -- project/metin_socket_system.gd.uid | 1 - project/mob_death_drop_test.gd | 198 -- project/mob_death_drop_test.gd.uid | 1 - project/mob_drop_scatter.gd | 64 - project/mob_drop_scatter.gd.uid | 1 - project/mob_view_test.gd | 215 -- project/mob_view_test.gd.uid | 1 - project/mob_winding_test.gd | 43 - project/mob_winding_test.gd.uid | 1 - project/mobile_gesture_test.gd | 212 -- project/mobile_gesture_test.gd.uid | 1 - project/mobile_ui_test.gd | 116 - project/mobile_ui_test.gd.uid | 1 - project/mobile_window_host_test.gd | 74 - project/mobile_window_host_test.gd.uid | 1 - project/model_render_test.gd | 437 --- project/model_render_test.gd.uid | 1 - project/motion_effect_anchor_test.gd | 61 - project/motion_effect_anchor_test.gd.uid | 1 - project/motion_event_fly_test.gd | 97 - project/motion_event_fly_test.gd.uid | 1 - project/motion_event_payload_test.gd | 66 - project/motion_event_payload_test.gd.uid | 1 - project/motion_event_timing_test.gd | 64 - project/motion_event_timing_test.gd.uid | 1 - project/motion_event_visibility_test.gd | 38 - project/motion_event_visibility_test.gd.uid | 1 - project/motion_event_warp_test.gd | 104 - project/motion_event_warp_test.gd.uid | 1 - project/motion_registry_test.gd | 69 - project/motion_registry_test.gd.uid | 1 - project/motion_splash.gd | 107 - project/motion_splash.gd.uid | 1 - project/motion_splash_test.gd | 60 - project/motion_splash_test.gd.uid | 1 - project/mouse_controller_test.gd | 68 - project/mouse_controller_test.gd.uid | 1 - project/movement_parity_test.gd | 113 - project/movement_parity_test.gd.uid | 1 - project/musicinfo.gd | 47 - project/musicinfo.gd.uid | 1 - project/name_color.gd | 175 - project/name_color.gd.uid | 1 - project/name_color_test.gd | 180 -- project/name_color_test.gd.uid | 1 - project/name_show.gd | 77 - project/name_show.gd.uid | 1 - project/name_show_test.gd | 79 - project/name_show_test.gd.uid | 1 - project/name_tail_layout.gd | 150 - project/name_tail_layout.gd.uid | 1 - project/name_tail_layout_test.gd | 134 - project/name_tail_layout_test.gd.uid | 1 - project/native_tree_test.gd | 107 - project/native_tree_test.gd.uid | 1 - project/net/channel_status.gd | 181 -- project/net/channel_status.gd.uid | 1 - project/net/mapcoord.gd | 41 - project/net/mapcoord.gd.uid | 1 - project/net/serverinfo.gd | 92 - project/net/serverinfo.gd.uid | 1 - project/net_client_fake.gd | 221 -- project/net_client_fake.gd.uid | 1 - project/net_play.gd | 2826 ----------------- project/net_play.gd.uid | 1 - project/net_trace.gd | 101 - project/net_trace.gd.uid | 1 - project/net_world.gd | 2413 -------------- project/net_world.gd.uid | 1 - project/net_world_main_switch_test.gd | 63 - project/net_world_main_switch_test.gd.uid | 1 - project/net_world_motion_event_test.gd | 44 - project/net_world_motion_event_test.gd.uid | 1 - project/net_world_push_test.gd | 125 - project/net_world_push_test.gd.uid | 1 - project/net_world_remote_action_test.gd | 212 -- project/net_world_remote_action_test.gd.uid | 1 - project/net_world_reset_test.gd | 87 - project/net_world_reset_test.gd.uid | 1 - project/net_world_respawn_test.gd | 120 - project/net_world_respawn_test.gd.uid | 1 - project/net_world_vis_test.gd | 200 -- project/net_world_vis_test.gd.uid | 1 - project/netbridge_test.gd | 267 -- project/netbridge_test.gd.uid | 1 - project/netplay_test.gd | 633 ---- project/netplay_test.gd.uid | 1 - project/p10_test.gd | 143 - project/p10_test.gd.uid | 1 - project/p2b_test.gd | 186 -- project/p2b_test.gd.uid | 1 - project/p8_test.gd | 376 --- project/p8_test.gd.uid | 1 - project/p9_test.gd | 170 - project/p9_test.gd.uid | 1 - project/package_render_test.gd | 198 -- project/package_render_test.gd.uid | 1 - project/part_hiding.gd | 59 - project/part_hiding.gd.uid | 1 - project/part_hiding_test.gd | 76 - project/part_hiding_test.gd.uid | 1 - project/physics_push.gd | 129 - project/physics_push.gd.uid | 1 - project/physics_push_test.gd | 72 - project/physics_push_test.gd.uid | 1 - project/pick_show.gd | 69 - project/pick_show.gd.uid | 1 - project/pick_show_test.gd | 69 - project/pick_show_test.gd.uid | 1 - project/playable_adapter_test.gd | 56 - project/playable_adapter_test.gd.uid | 1 - project/playable_combat_test.gd | 359 --- project/playable_combat_test.gd.uid | 1 - project/playable_config_gate.gd | 75 - project/playable_config_gate.gd.uid | 1 - project/playable_harness_test.gd | 1250 -------- project/playable_harness_test.gd.uid | 1 - project/playable_live_test.gd | 181 -- project/playable_live_test.gd.uid | 1 - project/playable_metrics_test.gd | 136 - project/playable_metrics_test.gd.uid | 1 - project/playable_skill_matrix.gd | 64 - project/playable_skill_matrix.gd.uid | 1 - project/player_controller.gd | 828 ----- project/player_controller.gd.uid | 1 - project/player_motion_test.gd | 104 - project/player_motion_test.gd.uid | 1 - project/player_move_test.gd | 195 -- project/player_move_test.gd.uid | 1 - project/player_skill.gd | 510 --- project/player_skill.gd.uid | 1 - project/player_skill_test.gd | 315 -- project/player_skill_test.gd.uid | 1 - project/popup_windows_parity_test.gd | 177 -- project/popup_windows_parity_test.gd.uid | 1 - project/print_server_address.gd | 35 - project/print_server_address.gd.uid | 1 - project/private_shop_ui_test.gd | 146 - project/private_shop_ui_test.gd.uid | 1 - project/python_host_check.gd | 4 +- project/quest_curtain_test.gd | 37 - project/quest_curtain_test.gd.uid | 1 - project/quest_event_test.gd | 169 - project/quest_event_test.gd.uid | 1 - project/quest_test.gd | 255 -- project/quest_test.gd.uid | 1 - project/race_motion_assembly_test.gd | 58 - project/race_motion_assembly_test.gd.uid | 1 - project/race_spec_test.gd | 140 - project/race_spec_test.gd.uid | 1 - project/remote_motion_fly_test.gd | 72 - project/remote_motion_fly_test.gd.uid | 1 - project/remote_motion_splash_test.gd | 141 - project/remote_motion_splash_test.gd.uid | 1 - project/remote_player_test.gd | 103 - project/remote_player_test.gd.uid | 1 - project/rendering_scenario_test.gd | 230 -- project/rendering_scenario_test.gd.uid | 1 - project/screen_hp_bar.gd | 41 - project/screen_hp_bar.gd.uid | 1 - project/screen_hp_bar_test.gd | 99 - project/screen_hp_bar_test.gd.uid | 1 - project/select_item_ui_test.gd | 110 - project/select_item_ui_test.gd.uid | 1 - project/shop_cube_mall_test.gd | 150 - project/shop_cube_mall_test.gd.uid | 1 - project/shop_rules_test.gd | 73 - project/shop_rules_test.gd.uid | 1 - project/skill_fx_test.gd | 79 - project/skill_fx_test.gd.uid | 1 - project/skill_motion_loop_test.gd | 113 - project/skill_motion_loop_test.gd.uid | 1 - project/skill_slot_map.gd | 54 - project/skill_slot_map.gd.uid | 1 - project/skill_test.gd | 501 --- project/skill_test.gd.uid | 1 - project/skybox_test.gd | 95 - project/skybox_test.gd.uid | 1 - project/system_menu_ui_test.gd | 189 -- project/system_menu_ui_test.gd.uid | 1 - project/system_option_ui_test.gd | 131 - project/system_option_ui_test.gd.uid | 1 - project/target_board.gd | 91 - project/target_board.gd.uid | 1 - project/target_board_test.gd | 96 - project/target_board_test.gd.uid | 1 - project/target_effect_test.gd | 82 - project/target_effect_test.gd.uid | 1 - project/taskbar_dock_test.gd | 266 -- project/taskbar_dock_test.gd.uid | 1 - project/test_all_map_height_probe.gd | 122 - project/test_all_map_height_probe.gd.uid | 1 - project/test_bridge_height_parity.gd | 99 - project/test_bridge_height_parity.gd.uid | 1 - project/test_chest_keys_parity.gd | 97 - project/test_chest_keys_parity.gd.uid | 1 - project/test_chinese_locale.gd | 164 - project/test_chinese_locale.gd.uid | 1 - project/test_click_target_effect_parity.gd | 183 -- .../test_click_target_effect_parity.gd.uid | 1 - .../test_combat_death_pickup_entry_parity.gd | 209 -- ...st_combat_death_pickup_entry_parity.gd.uid | 1 - project/test_combo_continuous_flow.gd | 210 -- project/test_combo_continuous_flow.gd.uid | 1 - project/test_consumables_parity.gd | 126 - project/test_consumables_parity.gd.uid | 1 - project/test_death_restart_parity.gd | 161 - project/test_death_restart_parity.gd.uid | 1 - project/test_drop_and_cursor_parity.gd | 161 - project/test_drop_and_cursor_parity.gd.uid | 1 - project/test_exp_fly_parity.gd | 213 -- project/test_exp_fly_parity.gd.uid | 1 - project/test_full_skill_flow.gd | 66 - project/test_full_skill_flow.gd.uid | 1 - project/test_ground_pickup_parity.gd | 217 -- project/test_ground_pickup_parity.gd.uid | 1 - project/test_intro_create_parity.gd | 132 - project/test_intro_create_parity.gd.uid | 1 - project/test_intro_empire_parity.gd | 87 - project/test_intro_empire_parity.gd.uid | 1 - project/test_intro_loading_parity.gd | 77 - project/test_intro_loading_parity.gd.uid | 1 - project/test_intro_login_parity.gd | 96 - project/test_intro_login_parity.gd.uid | 1 - project/test_intro_select_parity.gd | 124 - project/test_intro_select_parity.gd.uid | 1 - project/test_item_attr_parity.gd | 138 - project/test_item_attr_parity.gd.uid | 1 - project/test_item_tooltip_parity.gd | 256 -- project/test_item_tooltip_parity.gd.uid | 1 - project/test_levelup_combat_parity.gd | 175 - project/test_levelup_combat_parity.gd.uid | 1 - project/test_metin_socket_parity.gd | 118 - project/test_metin_socket_parity.gd.uid | 1 - project/test_minimap_radar_parity.gd | 105 - project/test_minimap_radar_parity.gd.uid | 1 - project/test_mob_drop_scatter_parity.gd | 74 - project/test_mob_drop_scatter_parity.gd.uid | 1 - project/test_no_auto_move_regression.gd | 233 -- project/test_no_auto_move_regression.gd.uid | 1 - project/test_npc_dialog_shop_ui_parity.gd | 229 -- project/test_npc_dialog_shop_ui_parity.gd.uid | 1 - project/test_npc_shop_flow.gd | 477 --- project/test_npc_shop_flow.gd.uid | 1 - project/test_quest_dialog_parity.gd | 417 --- project/test_quest_dialog_parity.gd.uid | 1 - project/test_quest_hud_parity.gd | 176 - project/test_quest_hud_parity.gd.uid | 1 - project/test_refine_parity.gd | 178 -- project/test_refine_parity.gd.uid | 1 - project/test_safebox_mall_parity.gd | 380 --- project/test_safebox_mall_parity.gd.uid | 1 - project/test_skill_ground_fix.gd | 154 - project/test_skill_ground_fix.gd.uid | 1 - project/test_skill_inventory_parity.gd | 541 ---- project/test_skill_inventory_parity.gd.uid | 1 - project/test_skill_state_arg_parity.gd | 51 - project/test_skill_state_arg_parity.gd.uid | 1 - project/test_target_board_parity.gd | 122 - project/test_target_board_parity.gd.uid | 1 - project/test_taskbar_quickslot_parity.gd | 153 - project/test_taskbar_quickslot_parity.gd.uid | 1 - project/test_ui_char.gd | 24 - project/test_ui_char.gd.uid | 1 - .../test_warrior_mental_skills_regression.gd | 81 - ...st_warrior_mental_skills_regression.gd.uid | 1 - project/test_wasd_steering_parity.gd | 132 - project/test_wasd_steering_parity.gd.uid | 1 - project/test_whisper_chat_parity.gd | 112 - project/test_whisper_chat_parity.gd.uid | 1 - project/testing/forest_viewpoints.gd | 195 -- project/testing/forest_viewpoints.gd.uid | 1 - project/testing/playable_config.gd | 478 --- project/testing/playable_config.gd.uid | 1 - project/testing/playable_flow.gd | 1357 -------- project/testing/playable_flow.gd.uid | 1 - project/testing/playable_metrics.gd | 292 -- project/testing/playable_metrics.gd.uid | 1 - project/testing/playable_probe.gd | 247 -- project/testing/playable_probe.gd.uid | 1 - project/testing/playable_report.gd | 251 -- project/testing/playable_report.gd.uid | 1 - project/testing/playable_window.gd | 65 - project/testing/playable_window.gd.uid | 1 - project/text_metrics.gd | 115 - project/text_metrics.gd.uid | 1 - project/text_metrics_test.gd | 141 - project/text_metrics_test.gd.uid | 1 - project/text_tail.gd | 127 - project/text_tail.gd.uid | 1 - project/text_tail_arrange.gd | 105 - project/text_tail_arrange.gd.uid | 1 - project/text_tail_arrange_test.gd | 152 - project/text_tail_arrange_test.gd.uid | 1 - project/text_tail_test.gd | 110 - project/text_tail_test.gd.uid | 1 - project/ui/atlas_ui.gd | 327 -- project/ui/atlas_ui.gd.uid | 1 - project/ui/char_select_screen.gd | 1038 ------ project/ui/char_select_screen.gd.uid | 1 - project/ui/char_shadow.gd | 43 - project/ui/char_shadow.gd.uid | 1 - project/ui/char_status_ui.gd | 1277 -------- project/ui/char_status_ui.gd.uid | 1 - project/ui/chat_ui.gd | 549 ---- project/ui/chat_ui.gd.uid | 1 - project/ui/cube_ui.gd | 366 --- project/ui/cube_ui.gd.uid | 1 - project/ui/cursor_manager.gd | 153 - project/ui/cursor_manager.gd.uid | 1 - project/ui/death_ui.gd | 137 - project/ui/death_ui.gd.uid | 1 - project/ui/dialogs.gd | 84 - project/ui/dialogs.gd.uid | 1 - project/ui/dragon_soul_ui.gd | 170 - project/ui/dragon_soul_ui.gd.uid | 1 - project/ui/dungeon_result_ui.gd | 106 - project/ui/dungeon_result_ui.gd.uid | 1 - project/ui/equip_model.gd | 601 ---- project/ui/equip_model.gd.uid | 1 - project/ui/exchange_ui.gd | 529 --- project/ui/exchange_ui.gd.uid | 1 - project/ui/friend_ui.gd | 298 -- project/ui/friend_ui.gd.uid | 1 - project/ui/game_option_ui.gd | 392 --- project/ui/game_option_ui.gd.uid | 1 - project/ui/ground_items.gd | 570 ---- project/ui/ground_items.gd.uid | 1 - project/ui/guild_ui.gd | 691 ---- project/ui/guild_ui.gd.uid | 1 - project/ui/help_ui.gd | 278 -- project/ui/help_ui.gd.uid | 1 - project/ui/hit_reaction.gd | 133 - project/ui/hit_reaction.gd.uid | 1 - project/ui/inventory_ui.gd | 1014 ------ project/ui/inventory_ui.gd.uid | 1 - project/ui/item_list.gd | 55 - project/ui/item_list.gd.uid | 1 - project/ui/item_tooltip.gd | 773 ----- project/ui/item_tooltip.gd.uid | 1 - project/ui/item_tooltip_bridge.gd | 56 - project/ui/item_tooltip_bridge.gd.uid | 1 - project/ui/item_tooltip_view.gd | 303 -- project/ui/item_tooltip_view.gd.uid | 1 - project/ui/loading_screen.gd | 90 - project/ui/loading_screen.gd.uid | 1 - project/ui/love_ui.gd | 74 - project/ui/love_ui.gd.uid | 1 - project/ui/mall_ui.gd | 373 --- project/ui/mall_ui.gd.uid | 1 - project/ui/minimap.gd | 378 --- project/ui/minimap.gd.uid | 1 - project/ui/mob_view.gd | 720 ----- project/ui/mob_view.gd.uid | 1 - project/ui/mobile/mobile_hud.gd | 342 -- project/ui/mobile/mobile_hud.gd.uid | 1 - project/ui/mobile/mobile_input_overlay.gd | 222 -- project/ui/mobile/mobile_input_overlay.gd.uid | 1 - project/ui/mobile/mobile_menu_drawer.gd | 154 - project/ui/mobile/mobile_menu_drawer.gd.uid | 1 - project/ui/mobile/mobile_touch_button.gd | 226 -- project/ui/mobile/mobile_touch_button.gd.uid | 1 - project/ui/mobile/mobile_ui_root.gd | 453 --- project/ui/mobile/mobile_ui_root.gd.uid | 1 - project/ui/mobile/mobile_window_host.gd | 163 - project/ui/mobile/mobile_window_host.gd.uid | 1 - project/ui/mobile/virtual_joystick.gd | 86 - project/ui/mobile/virtual_joystick.gd.uid | 1 - project/ui/motion_registry.gd | 206 -- project/ui/motion_registry.gd.uid | 1 - project/ui/mouse_controller.gd | 333 -- project/ui/mouse_controller.gd.uid | 1 - project/ui/msa_motion.gd | 38 - project/ui/msa_motion.gd.uid | 1 - project/ui/party_ui.gd | 327 -- project/ui/party_ui.gd.uid | 1 - project/ui/player_view.gd | 870 ----- project/ui/player_view.gd.uid | 1 - project/ui/private_shop_ui.gd | 529 --- project/ui/private_shop_ui.gd.uid | 1 - project/ui/quest_curtain.gd | 94 - project/ui/quest_curtain.gd.uid | 1 - project/ui/quest_dialog.gd | 1314 -------- project/ui/quest_dialog.gd.uid | 1 - project/ui/quest_log.gd | 454 --- project/ui/quest_log.gd.uid | 1 - project/ui/quickbar.gd | 1651 ---------- project/ui/quickbar.gd.uid | 1 - project/ui/quiet_button.gd | 9 - project/ui/quiet_button.gd.uid | 1 - project/ui/quiet_panel.gd | 9 - project/ui/quiet_panel.gd.uid | 1 - project/ui/race_spec.gd | 161 - project/ui/race_spec.gd.uid | 1 - project/ui/reconnect_ui.gd | 158 - project/ui/reconnect_ui.gd.uid | 1 - project/ui/refine_ui.gd | 468 --- project/ui/refine_ui.gd.uid | 1 - project/ui/remote_player_view.gd | 76 - project/ui/remote_player_view.gd.uid | 1 - project/ui/safebox_ui.gd | 779 ----- project/ui/safebox_ui.gd.uid | 1 - project/ui/select_item_ui.gd | 217 -- project/ui/select_item_ui.gd.uid | 1 - project/ui/shop_ui.gd | 685 ---- project/ui/shop_ui.gd.uid | 1 - project/ui/skill_table.gd | 600 ---- project/ui/skill_table.gd.uid | 1 - project/ui/skill_ui.gd | 438 --- project/ui/skill_ui.gd.uid | 1 - project/ui/system_menu_ui.gd | 257 -- project/ui/system_menu_ui.gd.uid | 1 - project/ui/system_option_ui.gd | 396 --- project/ui/system_option_ui.gd.uid | 1 - project/ui/target_ui.gd | 215 -- project/ui/target_ui.gd.uid | 1 - project/ui/ui_build.gd | 507 --- project/ui/ui_build.gd.uid | 1 - project/ui/ui_manager.gd | 479 --- project/ui/ui_manager.gd.uid | 1 - project/ui/ui_profile.gd | 48 - project/ui/ui_profile.gd.uid | 1 - project/ui/uiscript.gd | 357 --- project/ui/uiscript.gd.uid | 1 - project/ui/view_equipment_ui.gd | 158 - project/ui/view_equipment_ui.gd.uid | 1 - project/ui/weapon_trace.gd | 311 -- project/ui/weapon_trace.gd.uid | 1 - project/ui_kit.gd | 78 - project/ui_kit.gd.uid | 1 - project/ui_test.gd | 174 - project/ui_test.gd.uid | 1 - project/view_equipment_ui_test.gd | 65 - project/view_equipment_ui_test.gd.uid | 1 - project/water_reference_test.gd | 51 - project/water_reference_test.gd.uid | 1 - project/weapon_attach_test.gd | 165 - project/weapon_attach_test.gd.uid | 1 - .../weapon_reach_scale_attack_parity_test.gd | 50 - ...apon_reach_scale_attack_parity_test.gd.uid | 1 - project/weapon_trace_endpoint_parity_test.gd | 52 - .../weapon_trace_endpoint_parity_test.gd.uid | 1 - .../weapon_trace_multi_part_parity_test.gd | 59 - ...weapon_trace_multi_part_parity_test.gd.uid | 1 - .../weapon_trace_reach_affect_parity_test.gd | 51 - ...apon_trace_reach_affect_parity_test.gd.uid | 1 - project/weapon_trace_test.gd | 103 - project/weapon_trace_test.gd.uid | 1 - project/whisper_chat_system.gd | 170 - project/whisper_chat_system.gd.uid | 1 - project/world/world_time.gd | 77 - project/world/world_time.gd.uid | 1 - project/world_demo.gd | 169 - project/world_demo.gd.uid | 1 - project/world_probe.gd | 76 - project/world_probe.gd.uid | 1 - project/world_shot.gd | 58 - project/world_shot.gd.uid | 1 - 709 files changed, 17 insertions(+), 79025 deletions(-) delete mode 100644 project/ambience_test.gd delete mode 100644 project/ambience_test.gd.uid delete mode 100644 project/animation_blend_test.gd delete mode 100644 project/animation_blend_test.gd.uid delete mode 100644 project/animation_cache_test.gd delete mode 100644 project/animation_cache_test.gd.uid delete mode 100644 project/app_flow.gd delete mode 100644 project/app_flow.gd.uid delete mode 100644 project/app_flow_lifecycle_test.gd delete mode 100644 project/app_flow_lifecycle_test.gd.uid delete mode 100644 project/app_lifecycle.gd delete mode 100644 project/app_lifecycle.gd.uid delete mode 100644 project/atlas_test.gd delete mode 100644 project/atlas_test.gd.uid delete mode 100644 project/audio.gd delete mode 100644 project/audio.gd.uid delete mode 100644 project/audio_miles_test.gd delete mode 100644 project/audio_miles_test.gd.uid delete mode 100644 project/auth_connect_bridge_test.gd delete mode 100644 project/auth_connect_bridge_test.gd.uid delete mode 100644 project/auth_peer_close_bridge_test.gd delete mode 100644 project/auth_peer_close_bridge_test.gd.uid delete mode 100644 project/bake_asset_index.gd delete mode 100644 project/bake_asset_index.gd.uid delete mode 100644 project/bgm_director.gd delete mode 100644 project/bgm_director.gd.uid delete mode 100644 project/bgm_test.gd delete mode 100644 project/bgm_test.gd.uid delete mode 100644 project/channel_status_test.gd delete mode 100644 project/channel_status_test.gd.uid delete mode 100644 project/char_bench_test.gd delete mode 100644 project/char_bench_test.gd.uid delete mode 100644 project/char_create_delete_test.gd delete mode 100644 project/char_create_delete_test.gd.uid delete mode 100644 project/char_select_start_motion_test.gd delete mode 100644 project/char_select_start_motion_test.gd.uid delete mode 100644 project/char_select_visual_test.gd delete mode 100644 project/char_select_visual_test.gd.uid delete mode 100644 project/char_status_ui_test.gd delete mode 100644 project/char_status_ui_test.gd.uid delete mode 100644 project/character_winding_test.gd delete mode 100644 project/character_winding_test.gd.uid delete mode 100644 project/charselect.gd delete mode 100644 project/charselect.gd.uid delete mode 100644 project/chat_mobile_ui_test.gd delete mode 100644 project/chat_mobile_ui_test.gd.uid delete mode 100644 project/chat_tail.gd delete mode 100644 project/chat_tail.gd.uid delete mode 100644 project/chat_tail_test.gd delete mode 100644 project/chat_tail_test.gd.uid delete mode 100644 project/chat_test.gd delete mode 100644 project/chat_test.gd.uid delete mode 100644 project/chest_system.gd delete mode 100644 project/chest_system.gd.uid delete mode 100644 project/combat_fx_test.gd delete mode 100644 project/combat_fx_test.gd.uid delete mode 100644 project/combat_parity_test.gd delete mode 100644 project/combat_parity_test.gd.uid delete mode 100644 project/complex_item_drop_test.gd delete mode 100644 project/complex_item_drop_test.gd.uid delete mode 100644 project/consumable_system.gd delete mode 100644 project/consumable_system.gd.uid delete mode 100644 project/damage_effect.gd delete mode 100644 project/damage_effect.gd.uid delete mode 100644 project/damage_effect_test.gd delete mode 100644 project/damage_effect_test.gd.uid delete mode 100644 project/damage_local_main_parity_test.gd delete mode 100644 project/damage_local_main_parity_test.gd.uid delete mode 100644 project/dds_lifecycle_test.gd delete mode 100644 project/dds_lifecycle_test.gd.uid delete mode 100644 project/disconnect_world_reset_test.gd delete mode 100644 project/disconnect_world_reset_test.gd.uid delete mode 100644 project/dragon_soul_scope_test.gd delete mode 100644 project/dragon_soul_scope_test.gd.uid delete mode 100644 project/dragon_soul_test.gd delete mode 100644 project/dragon_soul_test.gd.uid delete mode 100644 project/dump_pixels.gd delete mode 100644 project/dump_pixels.gd.uid delete mode 100644 project/dungeon_result_test.gd delete mode 100644 project/dungeon_result_test.gd.uid delete mode 100644 project/dungeon_state.gd delete mode 100644 project/dungeon_state.gd.uid delete mode 100644 project/dungeon_state_test.gd delete mode 100644 project/dungeon_state_test.gd.uid delete mode 100644 project/effect_color_operation_test.gd delete mode 100644 project/effect_color_operation_test.gd.uid delete mode 100644 project/effect_color_test.gd delete mode 100644 project/effect_color_test.gd.uid delete mode 100644 project/effect_emission_test.gd delete mode 100644 project/effect_emission_test.gd.uid delete mode 100644 project/effect_faces_test.gd delete mode 100644 project/effect_faces_test.gd.uid delete mode 100644 project/effect_lie_test.gd delete mode 100644 project/effect_lie_test.gd.uid delete mode 100644 project/effect_playback_test.gd delete mode 100644 project/effect_playback_test.gd.uid delete mode 100644 project/effect_random_rotation_test.gd delete mode 100644 project/effect_random_rotation_test.gd.uid delete mode 100644 project/effect_render_regression_test.gd delete mode 100644 project/effect_render_regression_test.gd.uid delete mode 100644 project/effect_rotation_test.gd delete mode 100644 project/effect_rotation_test.gd.uid delete mode 100644 project/effect_scale_test.gd delete mode 100644 project/effect_scale_test.gd.uid delete mode 100644 project/effect_space_test.gd delete mode 100644 project/effect_space_test.gd.uid delete mode 100644 project/effect_surface_test.gd delete mode 100644 project/effect_surface_test.gd.uid delete mode 100644 project/effect_texture_animation_test.gd delete mode 100644 project/effect_texture_animation_test.gd.uid delete mode 100644 project/entity_rules.gd delete mode 100644 project/entity_rules.gd.uid delete mode 100644 project/entity_rules_test.gd delete mode 100644 project/entity_rules_test.gd.uid delete mode 100644 project/environment_test.gd delete mode 100644 project/environment_test.gd.uid delete mode 100644 project/equip_model_test.gd delete mode 100644 project/equip_model_test.gd.uid delete mode 100644 project/equip_rules.gd delete mode 100644 project/equip_rules.gd.uid delete mode 100644 project/equip_rules_test.gd delete mode 100644 project/equip_rules_test.gd.uid delete mode 100644 project/eterngrn_polish_test.gd delete mode 100644 project/eterngrn_polish_test.gd.uid delete mode 100644 project/fishing_water_test.gd delete mode 100644 project/fishing_water_test.gd.uid delete mode 100644 project/fly_attachment_effect_test.gd delete mode 100644 project/fly_attachment_effect_test.gd.uid delete mode 100644 project/fly_data_msf_test.gd delete mode 100644 project/fly_data_msf_test.gd.uid delete mode 100644 project/fly_dynamic_collision_test.gd delete mode 100644 project/fly_dynamic_collision_test.gd.uid delete mode 100644 project/fly_object.gd delete mode 100644 project/fly_object.gd.uid delete mode 100644 project/fly_reset_test.gd delete mode 100644 project/fly_reset_test.gd.uid delete mode 100644 project/fly_target_bounds_test.gd delete mode 100644 project/fly_target_bounds_test.gd.uid delete mode 100644 project/fly_target_lifecycle_test.gd delete mode 100644 project/fly_target_lifecycle_test.gd.uid delete mode 100644 project/fly_test.gd delete mode 100644 project/fly_test.gd.uid delete mode 100644 project/forest_map_render_test.gd delete mode 100644 project/forest_map_render_test.gd.uid delete mode 100644 project/forest_mob_render_test.gd delete mode 100644 project/forest_mob_render_test.gd.uid delete mode 100644 project/forest_viewpoints_test.gd delete mode 100644 project/forest_viewpoints_test.gd.uid delete mode 100644 project/fx/effect_player.gd delete mode 100644 project/fx/effect_player.gd.uid delete mode 100644 project/fx/effect_registry.gd delete mode 100644 project/fx/effect_registry.gd.uid delete mode 100644 project/fx/effect_space.gd delete mode 100644 project/fx/effect_space.gd.uid delete mode 100644 project/fx/mde.gd delete mode 100644 project/fx/mde.gd.uid delete mode 100644 project/fx/motion_effect_anchor.gd delete mode 100644 project/fx/motion_effect_anchor.gd.uid delete mode 100644 project/fx/mse.gd delete mode 100644 project/fx/mse.gd.uid delete mode 100644 project/fx/particle_color_material.gd delete mode 100644 project/fx/particle_color_material.gd.uid delete mode 100644 project/fx/particle_faces.gd delete mode 100644 project/fx/particle_faces.gd.uid delete mode 100644 project/fx/skill_fx.gd delete mode 100644 project/fx/skill_fx.gd.uid delete mode 100644 project/fx/target_effect_anchor.gd delete mode 100644 project/fx/target_effect_anchor.gd.uid delete mode 100644 project/fx/weather.gd delete mode 100644 project/fx/weather.gd.uid delete mode 100644 project/fx_test.gd delete mode 100644 project/fx_test.gd.uid delete mode 100644 project/game_camera.gd delete mode 100644 project/game_camera.gd.uid delete mode 100644 project/game_camera_test.gd delete mode 100644 project/game_camera_test.gd.uid delete mode 100644 project/game_scene.gd delete mode 100644 project/game_scene.gd.uid delete mode 100644 project/gamescene_test.gd delete mode 100644 project/gamescene_test.gd.uid delete mode 100644 project/gamescene_visual_test.gd delete mode 100644 project/gamescene_visual_test.gd.uid delete mode 100644 project/gpu_lod_attachment_test.gd delete mode 100644 project/gpu_lod_attachment_test.gd.uid delete mode 100644 project/gpu_pose_bounds_test.gd delete mode 100644 project/gpu_pose_bounds_test.gd.uid delete mode 100644 project/guild_creation_ui_test.gd delete mode 100644 project/guild_creation_ui_test.gd.uid delete mode 100644 project/guild_mark_tail_test.gd delete mode 100644 project/guild_mark_tail_test.gd.uid delete mode 100644 project/guild_mark_test.gd delete mode 100644 project/guild_mark_test.gd.uid delete mode 100644 project/guild_refine_test.gd delete mode 100644 project/guild_refine_test.gd.uid delete mode 100644 project/guild_war_skill_test.gd delete mode 100644 project/guild_war_skill_test.gd.uid delete mode 100644 project/help_ui_test.gd delete mode 100644 project/help_ui_test.gd.uid delete mode 100644 project/hit_collision.gd delete mode 100644 project/hit_collision.gd.uid delete mode 100644 project/hit_collision_test.gd delete mode 100644 project/hit_collision_test.gd.uid delete mode 100644 project/hit_view_test.gd delete mode 100644 project/hit_view_test.gd.uid delete mode 100644 project/hud.gd delete mode 100644 project/hud.gd.uid delete mode 100644 project/indexed_fly_type_test.gd delete mode 100644 project/indexed_fly_type_test.gd.uid delete mode 100644 project/input_key_test.gd delete mode 100644 project/input_key_test.gd.uid delete mode 100644 project/intro_create_phase.gd delete mode 100644 project/intro_create_phase.gd.uid delete mode 100644 project/intro_empire_phase.gd delete mode 100644 project/intro_empire_phase.gd.uid delete mode 100644 project/intro_loading_phase.gd delete mode 100644 project/intro_loading_phase.gd.uid delete mode 100644 project/intro_login_phase.gd delete mode 100644 project/intro_login_phase.gd.uid delete mode 100644 project/intro_select_phase.gd delete mode 100644 project/intro_select_phase.gd.uid delete mode 100644 project/inventory_test.gd delete mode 100644 project/inventory_test.gd.uid delete mode 100644 project/inventory_ui_test.gd delete mode 100644 project/inventory_ui_test.gd.uid delete mode 100644 project/item_action_sound_parity_test.gd delete mode 100644 project/item_action_sound_parity_test.gd.uid delete mode 100644 project/item_attr_system.gd delete mode 100644 project/item_attr_system.gd.uid delete mode 100644 project/item_tooltip_test.gd delete mode 100644 project/item_tooltip_test.gd.uid delete mode 100644 project/keyboard_motion_timeline_test.gd delete mode 100644 project/keyboard_motion_timeline_test.gd.uid delete mode 100644 project/live_smoke_test.gd delete mode 100644 project/live_smoke_test.gd.uid delete mode 100644 project/locale.gd delete mode 100644 project/locale.gd.uid delete mode 100644 project/login.gd delete mode 100644 project/login.gd.uid delete mode 100644 project/love_ui_test.gd delete mode 100644 project/love_ui_test.gd.uid delete mode 100644 project/main.gd delete mode 100644 project/main.gd.uid delete mode 100644 project/main.tscn delete mode 100644 project/main_character_sync_test.gd delete mode 100644 project/main_character_sync_test.gd.uid delete mode 100644 project/metin_socket_system.gd delete mode 100644 project/metin_socket_system.gd.uid delete mode 100644 project/mob_death_drop_test.gd delete mode 100644 project/mob_death_drop_test.gd.uid delete mode 100644 project/mob_drop_scatter.gd delete mode 100644 project/mob_drop_scatter.gd.uid delete mode 100644 project/mob_view_test.gd delete mode 100644 project/mob_view_test.gd.uid delete mode 100644 project/mob_winding_test.gd delete mode 100644 project/mob_winding_test.gd.uid delete mode 100644 project/mobile_gesture_test.gd delete mode 100644 project/mobile_gesture_test.gd.uid delete mode 100644 project/mobile_ui_test.gd delete mode 100644 project/mobile_ui_test.gd.uid delete mode 100644 project/mobile_window_host_test.gd delete mode 100644 project/mobile_window_host_test.gd.uid delete mode 100644 project/model_render_test.gd delete mode 100644 project/model_render_test.gd.uid delete mode 100644 project/motion_effect_anchor_test.gd delete mode 100644 project/motion_effect_anchor_test.gd.uid delete mode 100644 project/motion_event_fly_test.gd delete mode 100644 project/motion_event_fly_test.gd.uid delete mode 100644 project/motion_event_payload_test.gd delete mode 100644 project/motion_event_payload_test.gd.uid delete mode 100644 project/motion_event_timing_test.gd delete mode 100644 project/motion_event_timing_test.gd.uid delete mode 100644 project/motion_event_visibility_test.gd delete mode 100644 project/motion_event_visibility_test.gd.uid delete mode 100644 project/motion_event_warp_test.gd delete mode 100644 project/motion_event_warp_test.gd.uid delete mode 100644 project/motion_registry_test.gd delete mode 100644 project/motion_registry_test.gd.uid delete mode 100644 project/motion_splash.gd delete mode 100644 project/motion_splash.gd.uid delete mode 100644 project/motion_splash_test.gd delete mode 100644 project/motion_splash_test.gd.uid delete mode 100644 project/mouse_controller_test.gd delete mode 100644 project/mouse_controller_test.gd.uid delete mode 100644 project/movement_parity_test.gd delete mode 100644 project/movement_parity_test.gd.uid delete mode 100644 project/musicinfo.gd delete mode 100644 project/musicinfo.gd.uid delete mode 100644 project/name_color.gd delete mode 100644 project/name_color.gd.uid delete mode 100644 project/name_color_test.gd delete mode 100644 project/name_color_test.gd.uid delete mode 100644 project/name_show.gd delete mode 100644 project/name_show.gd.uid delete mode 100644 project/name_show_test.gd delete mode 100644 project/name_show_test.gd.uid delete mode 100644 project/name_tail_layout.gd delete mode 100644 project/name_tail_layout.gd.uid delete mode 100644 project/name_tail_layout_test.gd delete mode 100644 project/name_tail_layout_test.gd.uid delete mode 100644 project/native_tree_test.gd delete mode 100644 project/native_tree_test.gd.uid delete mode 100644 project/net/channel_status.gd delete mode 100644 project/net/channel_status.gd.uid delete mode 100644 project/net/mapcoord.gd delete mode 100644 project/net/mapcoord.gd.uid delete mode 100644 project/net/serverinfo.gd delete mode 100644 project/net/serverinfo.gd.uid delete mode 100644 project/net_client_fake.gd delete mode 100644 project/net_client_fake.gd.uid delete mode 100644 project/net_play.gd delete mode 100644 project/net_play.gd.uid delete mode 100644 project/net_trace.gd delete mode 100644 project/net_trace.gd.uid delete mode 100644 project/net_world.gd delete mode 100644 project/net_world.gd.uid delete mode 100644 project/net_world_main_switch_test.gd delete mode 100644 project/net_world_main_switch_test.gd.uid delete mode 100644 project/net_world_motion_event_test.gd delete mode 100644 project/net_world_motion_event_test.gd.uid delete mode 100644 project/net_world_push_test.gd delete mode 100644 project/net_world_push_test.gd.uid delete mode 100644 project/net_world_remote_action_test.gd delete mode 100644 project/net_world_remote_action_test.gd.uid delete mode 100644 project/net_world_reset_test.gd delete mode 100644 project/net_world_reset_test.gd.uid delete mode 100644 project/net_world_respawn_test.gd delete mode 100644 project/net_world_respawn_test.gd.uid delete mode 100644 project/net_world_vis_test.gd delete mode 100644 project/net_world_vis_test.gd.uid delete mode 100644 project/netbridge_test.gd delete mode 100644 project/netbridge_test.gd.uid delete mode 100644 project/netplay_test.gd delete mode 100644 project/netplay_test.gd.uid delete mode 100644 project/p10_test.gd delete mode 100644 project/p10_test.gd.uid delete mode 100644 project/p2b_test.gd delete mode 100644 project/p2b_test.gd.uid delete mode 100644 project/p8_test.gd delete mode 100644 project/p8_test.gd.uid delete mode 100644 project/p9_test.gd delete mode 100644 project/p9_test.gd.uid delete mode 100644 project/package_render_test.gd delete mode 100644 project/package_render_test.gd.uid delete mode 100644 project/part_hiding.gd delete mode 100644 project/part_hiding.gd.uid delete mode 100644 project/part_hiding_test.gd delete mode 100644 project/part_hiding_test.gd.uid delete mode 100644 project/physics_push.gd delete mode 100644 project/physics_push.gd.uid delete mode 100644 project/physics_push_test.gd delete mode 100644 project/physics_push_test.gd.uid delete mode 100644 project/pick_show.gd delete mode 100644 project/pick_show.gd.uid delete mode 100644 project/pick_show_test.gd delete mode 100644 project/pick_show_test.gd.uid delete mode 100644 project/playable_adapter_test.gd delete mode 100644 project/playable_adapter_test.gd.uid delete mode 100644 project/playable_combat_test.gd delete mode 100644 project/playable_combat_test.gd.uid delete mode 100644 project/playable_config_gate.gd delete mode 100644 project/playable_config_gate.gd.uid delete mode 100644 project/playable_harness_test.gd delete mode 100644 project/playable_harness_test.gd.uid delete mode 100644 project/playable_live_test.gd delete mode 100644 project/playable_live_test.gd.uid delete mode 100644 project/playable_metrics_test.gd delete mode 100644 project/playable_metrics_test.gd.uid delete mode 100644 project/playable_skill_matrix.gd delete mode 100644 project/playable_skill_matrix.gd.uid delete mode 100644 project/player_controller.gd delete mode 100644 project/player_controller.gd.uid delete mode 100644 project/player_motion_test.gd delete mode 100644 project/player_motion_test.gd.uid delete mode 100644 project/player_move_test.gd delete mode 100644 project/player_move_test.gd.uid delete mode 100644 project/player_skill.gd delete mode 100644 project/player_skill.gd.uid delete mode 100644 project/player_skill_test.gd delete mode 100644 project/player_skill_test.gd.uid delete mode 100644 project/popup_windows_parity_test.gd delete mode 100644 project/popup_windows_parity_test.gd.uid delete mode 100644 project/print_server_address.gd delete mode 100644 project/print_server_address.gd.uid delete mode 100644 project/private_shop_ui_test.gd delete mode 100644 project/private_shop_ui_test.gd.uid delete mode 100644 project/quest_curtain_test.gd delete mode 100644 project/quest_curtain_test.gd.uid delete mode 100644 project/quest_event_test.gd delete mode 100644 project/quest_event_test.gd.uid delete mode 100644 project/quest_test.gd delete mode 100644 project/quest_test.gd.uid delete mode 100644 project/race_motion_assembly_test.gd delete mode 100644 project/race_motion_assembly_test.gd.uid delete mode 100644 project/race_spec_test.gd delete mode 100644 project/race_spec_test.gd.uid delete mode 100644 project/remote_motion_fly_test.gd delete mode 100644 project/remote_motion_fly_test.gd.uid delete mode 100644 project/remote_motion_splash_test.gd delete mode 100644 project/remote_motion_splash_test.gd.uid delete mode 100644 project/remote_player_test.gd delete mode 100644 project/remote_player_test.gd.uid delete mode 100644 project/rendering_scenario_test.gd delete mode 100644 project/rendering_scenario_test.gd.uid delete mode 100644 project/screen_hp_bar.gd delete mode 100644 project/screen_hp_bar.gd.uid delete mode 100644 project/screen_hp_bar_test.gd delete mode 100644 project/screen_hp_bar_test.gd.uid delete mode 100644 project/select_item_ui_test.gd delete mode 100644 project/select_item_ui_test.gd.uid delete mode 100644 project/shop_cube_mall_test.gd delete mode 100644 project/shop_cube_mall_test.gd.uid delete mode 100644 project/shop_rules_test.gd delete mode 100644 project/shop_rules_test.gd.uid delete mode 100644 project/skill_fx_test.gd delete mode 100644 project/skill_fx_test.gd.uid delete mode 100644 project/skill_motion_loop_test.gd delete mode 100644 project/skill_motion_loop_test.gd.uid delete mode 100644 project/skill_slot_map.gd delete mode 100644 project/skill_slot_map.gd.uid delete mode 100644 project/skill_test.gd delete mode 100644 project/skill_test.gd.uid delete mode 100644 project/skybox_test.gd delete mode 100644 project/skybox_test.gd.uid delete mode 100644 project/system_menu_ui_test.gd delete mode 100644 project/system_menu_ui_test.gd.uid delete mode 100644 project/system_option_ui_test.gd delete mode 100644 project/system_option_ui_test.gd.uid delete mode 100644 project/target_board.gd delete mode 100644 project/target_board.gd.uid delete mode 100644 project/target_board_test.gd delete mode 100644 project/target_board_test.gd.uid delete mode 100644 project/target_effect_test.gd delete mode 100644 project/target_effect_test.gd.uid delete mode 100644 project/taskbar_dock_test.gd delete mode 100644 project/taskbar_dock_test.gd.uid delete mode 100644 project/test_all_map_height_probe.gd delete mode 100644 project/test_all_map_height_probe.gd.uid delete mode 100644 project/test_bridge_height_parity.gd delete mode 100644 project/test_bridge_height_parity.gd.uid delete mode 100644 project/test_chest_keys_parity.gd delete mode 100644 project/test_chest_keys_parity.gd.uid delete mode 100644 project/test_chinese_locale.gd delete mode 100644 project/test_chinese_locale.gd.uid delete mode 100644 project/test_click_target_effect_parity.gd delete mode 100644 project/test_click_target_effect_parity.gd.uid delete mode 100644 project/test_combat_death_pickup_entry_parity.gd delete mode 100644 project/test_combat_death_pickup_entry_parity.gd.uid delete mode 100644 project/test_combo_continuous_flow.gd delete mode 100644 project/test_combo_continuous_flow.gd.uid delete mode 100644 project/test_consumables_parity.gd delete mode 100644 project/test_consumables_parity.gd.uid delete mode 100644 project/test_death_restart_parity.gd delete mode 100644 project/test_death_restart_parity.gd.uid delete mode 100644 project/test_drop_and_cursor_parity.gd delete mode 100644 project/test_drop_and_cursor_parity.gd.uid delete mode 100644 project/test_exp_fly_parity.gd delete mode 100644 project/test_exp_fly_parity.gd.uid delete mode 100644 project/test_full_skill_flow.gd delete mode 100644 project/test_full_skill_flow.gd.uid delete mode 100644 project/test_ground_pickup_parity.gd delete mode 100644 project/test_ground_pickup_parity.gd.uid delete mode 100644 project/test_intro_create_parity.gd delete mode 100644 project/test_intro_create_parity.gd.uid delete mode 100644 project/test_intro_empire_parity.gd delete mode 100644 project/test_intro_empire_parity.gd.uid delete mode 100644 project/test_intro_loading_parity.gd delete mode 100644 project/test_intro_loading_parity.gd.uid delete mode 100644 project/test_intro_login_parity.gd delete mode 100644 project/test_intro_login_parity.gd.uid delete mode 100644 project/test_intro_select_parity.gd delete mode 100644 project/test_intro_select_parity.gd.uid delete mode 100644 project/test_item_attr_parity.gd delete mode 100644 project/test_item_attr_parity.gd.uid delete mode 100644 project/test_item_tooltip_parity.gd delete mode 100644 project/test_item_tooltip_parity.gd.uid delete mode 100644 project/test_levelup_combat_parity.gd delete mode 100644 project/test_levelup_combat_parity.gd.uid delete mode 100644 project/test_metin_socket_parity.gd delete mode 100644 project/test_metin_socket_parity.gd.uid delete mode 100644 project/test_minimap_radar_parity.gd delete mode 100644 project/test_minimap_radar_parity.gd.uid delete mode 100644 project/test_mob_drop_scatter_parity.gd delete mode 100644 project/test_mob_drop_scatter_parity.gd.uid delete mode 100644 project/test_no_auto_move_regression.gd delete mode 100644 project/test_no_auto_move_regression.gd.uid delete mode 100644 project/test_npc_dialog_shop_ui_parity.gd delete mode 100644 project/test_npc_dialog_shop_ui_parity.gd.uid delete mode 100644 project/test_npc_shop_flow.gd delete mode 100644 project/test_npc_shop_flow.gd.uid delete mode 100644 project/test_quest_dialog_parity.gd delete mode 100644 project/test_quest_dialog_parity.gd.uid delete mode 100644 project/test_quest_hud_parity.gd delete mode 100644 project/test_quest_hud_parity.gd.uid delete mode 100644 project/test_refine_parity.gd delete mode 100644 project/test_refine_parity.gd.uid delete mode 100644 project/test_safebox_mall_parity.gd delete mode 100644 project/test_safebox_mall_parity.gd.uid delete mode 100644 project/test_skill_ground_fix.gd delete mode 100644 project/test_skill_ground_fix.gd.uid delete mode 100644 project/test_skill_inventory_parity.gd delete mode 100644 project/test_skill_inventory_parity.gd.uid delete mode 100644 project/test_skill_state_arg_parity.gd delete mode 100644 project/test_skill_state_arg_parity.gd.uid delete mode 100644 project/test_target_board_parity.gd delete mode 100644 project/test_target_board_parity.gd.uid delete mode 100644 project/test_taskbar_quickslot_parity.gd delete mode 100644 project/test_taskbar_quickslot_parity.gd.uid delete mode 100644 project/test_ui_char.gd delete mode 100644 project/test_ui_char.gd.uid delete mode 100644 project/test_warrior_mental_skills_regression.gd delete mode 100644 project/test_warrior_mental_skills_regression.gd.uid delete mode 100644 project/test_wasd_steering_parity.gd delete mode 100644 project/test_wasd_steering_parity.gd.uid delete mode 100644 project/test_whisper_chat_parity.gd delete mode 100644 project/test_whisper_chat_parity.gd.uid delete mode 100644 project/testing/forest_viewpoints.gd delete mode 100644 project/testing/forest_viewpoints.gd.uid delete mode 100644 project/testing/playable_config.gd delete mode 100644 project/testing/playable_config.gd.uid delete mode 100644 project/testing/playable_flow.gd delete mode 100644 project/testing/playable_flow.gd.uid delete mode 100644 project/testing/playable_metrics.gd delete mode 100644 project/testing/playable_metrics.gd.uid delete mode 100644 project/testing/playable_probe.gd delete mode 100644 project/testing/playable_probe.gd.uid delete mode 100644 project/testing/playable_report.gd delete mode 100644 project/testing/playable_report.gd.uid delete mode 100644 project/testing/playable_window.gd delete mode 100644 project/testing/playable_window.gd.uid delete mode 100644 project/text_metrics.gd delete mode 100644 project/text_metrics.gd.uid delete mode 100644 project/text_metrics_test.gd delete mode 100644 project/text_metrics_test.gd.uid delete mode 100644 project/text_tail.gd delete mode 100644 project/text_tail.gd.uid delete mode 100644 project/text_tail_arrange.gd delete mode 100644 project/text_tail_arrange.gd.uid delete mode 100644 project/text_tail_arrange_test.gd delete mode 100644 project/text_tail_arrange_test.gd.uid delete mode 100644 project/text_tail_test.gd delete mode 100644 project/text_tail_test.gd.uid delete mode 100644 project/ui/atlas_ui.gd delete mode 100644 project/ui/atlas_ui.gd.uid delete mode 100644 project/ui/char_select_screen.gd delete mode 100644 project/ui/char_select_screen.gd.uid delete mode 100644 project/ui/char_shadow.gd delete mode 100644 project/ui/char_shadow.gd.uid delete mode 100644 project/ui/char_status_ui.gd delete mode 100644 project/ui/char_status_ui.gd.uid delete mode 100644 project/ui/chat_ui.gd delete mode 100644 project/ui/chat_ui.gd.uid delete mode 100644 project/ui/cube_ui.gd delete mode 100644 project/ui/cube_ui.gd.uid delete mode 100644 project/ui/cursor_manager.gd delete mode 100644 project/ui/cursor_manager.gd.uid delete mode 100644 project/ui/death_ui.gd delete mode 100644 project/ui/death_ui.gd.uid delete mode 100644 project/ui/dialogs.gd delete mode 100644 project/ui/dialogs.gd.uid delete mode 100644 project/ui/dragon_soul_ui.gd delete mode 100644 project/ui/dragon_soul_ui.gd.uid delete mode 100644 project/ui/dungeon_result_ui.gd delete mode 100644 project/ui/dungeon_result_ui.gd.uid delete mode 100644 project/ui/equip_model.gd delete mode 100644 project/ui/equip_model.gd.uid delete mode 100644 project/ui/exchange_ui.gd delete mode 100644 project/ui/exchange_ui.gd.uid delete mode 100644 project/ui/friend_ui.gd delete mode 100644 project/ui/friend_ui.gd.uid delete mode 100644 project/ui/game_option_ui.gd delete mode 100644 project/ui/game_option_ui.gd.uid delete mode 100644 project/ui/ground_items.gd delete mode 100644 project/ui/ground_items.gd.uid delete mode 100644 project/ui/guild_ui.gd delete mode 100644 project/ui/guild_ui.gd.uid delete mode 100644 project/ui/help_ui.gd delete mode 100644 project/ui/help_ui.gd.uid delete mode 100644 project/ui/hit_reaction.gd delete mode 100644 project/ui/hit_reaction.gd.uid delete mode 100644 project/ui/inventory_ui.gd delete mode 100644 project/ui/inventory_ui.gd.uid delete mode 100644 project/ui/item_list.gd delete mode 100644 project/ui/item_list.gd.uid delete mode 100644 project/ui/item_tooltip.gd delete mode 100644 project/ui/item_tooltip.gd.uid delete mode 100644 project/ui/item_tooltip_bridge.gd delete mode 100644 project/ui/item_tooltip_bridge.gd.uid delete mode 100644 project/ui/item_tooltip_view.gd delete mode 100644 project/ui/item_tooltip_view.gd.uid delete mode 100644 project/ui/loading_screen.gd delete mode 100644 project/ui/loading_screen.gd.uid delete mode 100644 project/ui/love_ui.gd delete mode 100644 project/ui/love_ui.gd.uid delete mode 100644 project/ui/mall_ui.gd delete mode 100644 project/ui/mall_ui.gd.uid delete mode 100644 project/ui/minimap.gd delete mode 100644 project/ui/minimap.gd.uid delete mode 100644 project/ui/mob_view.gd delete mode 100644 project/ui/mob_view.gd.uid delete mode 100644 project/ui/mobile/mobile_hud.gd delete mode 100644 project/ui/mobile/mobile_hud.gd.uid delete mode 100644 project/ui/mobile/mobile_input_overlay.gd delete mode 100644 project/ui/mobile/mobile_input_overlay.gd.uid delete mode 100644 project/ui/mobile/mobile_menu_drawer.gd delete mode 100644 project/ui/mobile/mobile_menu_drawer.gd.uid delete mode 100644 project/ui/mobile/mobile_touch_button.gd delete mode 100644 project/ui/mobile/mobile_touch_button.gd.uid delete mode 100644 project/ui/mobile/mobile_ui_root.gd delete mode 100644 project/ui/mobile/mobile_ui_root.gd.uid delete mode 100644 project/ui/mobile/mobile_window_host.gd delete mode 100644 project/ui/mobile/mobile_window_host.gd.uid delete mode 100644 project/ui/mobile/virtual_joystick.gd delete mode 100644 project/ui/mobile/virtual_joystick.gd.uid delete mode 100644 project/ui/motion_registry.gd delete mode 100644 project/ui/motion_registry.gd.uid delete mode 100644 project/ui/mouse_controller.gd delete mode 100644 project/ui/mouse_controller.gd.uid delete mode 100644 project/ui/msa_motion.gd delete mode 100644 project/ui/msa_motion.gd.uid delete mode 100644 project/ui/party_ui.gd delete mode 100644 project/ui/party_ui.gd.uid delete mode 100644 project/ui/player_view.gd delete mode 100644 project/ui/player_view.gd.uid delete mode 100644 project/ui/private_shop_ui.gd delete mode 100644 project/ui/private_shop_ui.gd.uid delete mode 100644 project/ui/quest_curtain.gd delete mode 100644 project/ui/quest_curtain.gd.uid delete mode 100644 project/ui/quest_dialog.gd delete mode 100644 project/ui/quest_dialog.gd.uid delete mode 100644 project/ui/quest_log.gd delete mode 100644 project/ui/quest_log.gd.uid delete mode 100644 project/ui/quickbar.gd delete mode 100644 project/ui/quickbar.gd.uid delete mode 100644 project/ui/quiet_button.gd delete mode 100644 project/ui/quiet_button.gd.uid delete mode 100644 project/ui/quiet_panel.gd delete mode 100644 project/ui/quiet_panel.gd.uid delete mode 100644 project/ui/race_spec.gd delete mode 100644 project/ui/race_spec.gd.uid delete mode 100644 project/ui/reconnect_ui.gd delete mode 100644 project/ui/reconnect_ui.gd.uid delete mode 100644 project/ui/refine_ui.gd delete mode 100644 project/ui/refine_ui.gd.uid delete mode 100644 project/ui/remote_player_view.gd delete mode 100644 project/ui/remote_player_view.gd.uid delete mode 100644 project/ui/safebox_ui.gd delete mode 100644 project/ui/safebox_ui.gd.uid delete mode 100644 project/ui/select_item_ui.gd delete mode 100644 project/ui/select_item_ui.gd.uid delete mode 100644 project/ui/shop_ui.gd delete mode 100644 project/ui/shop_ui.gd.uid delete mode 100644 project/ui/skill_table.gd delete mode 100644 project/ui/skill_table.gd.uid delete mode 100644 project/ui/skill_ui.gd delete mode 100644 project/ui/skill_ui.gd.uid delete mode 100644 project/ui/system_menu_ui.gd delete mode 100644 project/ui/system_menu_ui.gd.uid delete mode 100644 project/ui/system_option_ui.gd delete mode 100644 project/ui/system_option_ui.gd.uid delete mode 100644 project/ui/target_ui.gd delete mode 100644 project/ui/target_ui.gd.uid delete mode 100644 project/ui/ui_build.gd delete mode 100644 project/ui/ui_build.gd.uid delete mode 100644 project/ui/ui_manager.gd delete mode 100644 project/ui/ui_manager.gd.uid delete mode 100644 project/ui/ui_profile.gd delete mode 100644 project/ui/ui_profile.gd.uid delete mode 100644 project/ui/uiscript.gd delete mode 100644 project/ui/uiscript.gd.uid delete mode 100644 project/ui/view_equipment_ui.gd delete mode 100644 project/ui/view_equipment_ui.gd.uid delete mode 100644 project/ui/weapon_trace.gd delete mode 100644 project/ui/weapon_trace.gd.uid delete mode 100644 project/ui_kit.gd delete mode 100644 project/ui_kit.gd.uid delete mode 100644 project/ui_test.gd delete mode 100644 project/ui_test.gd.uid delete mode 100644 project/view_equipment_ui_test.gd delete mode 100644 project/view_equipment_ui_test.gd.uid delete mode 100644 project/water_reference_test.gd delete mode 100644 project/water_reference_test.gd.uid delete mode 100644 project/weapon_attach_test.gd delete mode 100644 project/weapon_attach_test.gd.uid delete mode 100644 project/weapon_reach_scale_attack_parity_test.gd delete mode 100644 project/weapon_reach_scale_attack_parity_test.gd.uid delete mode 100644 project/weapon_trace_endpoint_parity_test.gd delete mode 100644 project/weapon_trace_endpoint_parity_test.gd.uid delete mode 100644 project/weapon_trace_multi_part_parity_test.gd delete mode 100644 project/weapon_trace_multi_part_parity_test.gd.uid delete mode 100644 project/weapon_trace_reach_affect_parity_test.gd delete mode 100644 project/weapon_trace_reach_affect_parity_test.gd.uid delete mode 100644 project/weapon_trace_test.gd delete mode 100644 project/weapon_trace_test.gd.uid delete mode 100644 project/whisper_chat_system.gd delete mode 100644 project/whisper_chat_system.gd.uid delete mode 100644 project/world/world_time.gd delete mode 100644 project/world/world_time.gd.uid delete mode 100644 project/world_demo.gd delete mode 100644 project/world_demo.gd.uid delete mode 100644 project/world_probe.gd delete mode 100644 project/world_probe.gd.uid delete mode 100644 project/world_shot.gd delete mode 100644 project/world_shot.gd.uid diff --git a/extension/src/platform/EterLib/GrpDevice.cpp b/extension/src/platform/EterLib/GrpDevice.cpp index b79c2d5b..15cb7eec 100644 --- a/extension/src/platform/EterLib/GrpDevice.cpp +++ b/extension/src/platform/EterLib/GrpDevice.cpp @@ -167,10 +167,13 @@ auto CGraphicDevice::MoveWebBrowserRect(const RECT &) -> void MT_PLATFORM_STUB(); } -auto CGraphicDevice::ResizeBackBuffer(UINT, UINT) -> bool +auto CGraphicDevice::ResizeBackBuffer(UINT uWidth, UINT uHeight) -> bool { - MT_PLATFORM_STUB(); - return mt_platform_stub_return(); + ms_iWidth = uWidth; + ms_iHeight = uHeight; + ms_d3dPresentParameter.BackBufferWidth = uWidth; + ms_d3dPresentParameter.BackBufferHeight = uHeight; + return true; } auto CGraphicDevice::RegisterWarningString(UINT, const char *) -> void diff --git a/extension/src/platform/ScriptLib/PythonBoot.cpp b/extension/src/platform/ScriptLib/PythonBoot.cpp index 2e92e143..0676a424 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.cpp +++ b/extension/src/platform/ScriptLib/PythonBoot.cpp @@ -7,6 +7,7 @@ #include "EterPythonLib/StdAfx.h" #include "EterBase/Timer.h" #include "EterLib/Util.h" +#include "EterLib/GrpBase.h" #include "../EterLib/HostCursor.h" #include "UserInterface/PythonNetworkStream.h" #include "UserInterface/AccountConnector.h" @@ -239,6 +240,7 @@ void SetUISize(int width, int height) g_window_manager->SetScreenSize(width, height); g_window_manager->SetResolution(width, height); UIRenderSetSize(width, height); + CGraphicBase::SetBackBufferSize(width, height); if (g_game_singletons) { g_game_singletons->pySystem.GetConfig()->width = width; diff --git a/extension/src/port/EterLib/GrpBase.cpp b/extension/src/port/EterLib/GrpBase.cpp index f8bba470..4a40c631 100644 --- a/extension/src/port/EterLib/GrpBase.cpp +++ b/extension/src/port/EterLib/GrpBase.cpp @@ -135,6 +135,12 @@ void CGraphicBase::GetBackBufferSize(UINT* puWidth, UINT* puHeight) *puHeight=ms_d3dPresentParameter.BackBufferHeight; } +void CGraphicBase::SetBackBufferSize(UINT uWidth, UINT uHeight) +{ + ms_d3dPresentParameter.BackBufferWidth = uWidth; + ms_d3dPresentParameter.BackBufferHeight = uHeight; +} + void CGraphicBase::SetDefaultIndexBuffer(UINT eDefIB) { if (eDefIB>=DEFAULT_IB_NUM) diff --git a/extension/src/port/EterLib/GrpBase.h b/extension/src/port/EterLib/GrpBase.h index 880d86de..b360d9f5 100644 --- a/extension/src/port/EterLib/GrpBase.h +++ b/extension/src/port/EterLib/GrpBase.h @@ -205,6 +205,7 @@ class CGraphicBase void SetViewport(DWORD dwX, DWORD dwY, DWORD dwWidth, DWORD dwHeight, float fMinZ, float fMaxZ); static void GetBackBufferSize(UINT* puWidth, UINT* puHeight); + static void SetBackBufferSize(UINT uWidth, UINT uHeight); static bool IsTLVertexClipping(); static bool IsFastTNL(); static bool IsLowTextureMemory(); diff --git a/project/ambience_test.gd b/project/ambience_test.gd deleted file mode 100644 index 561ba9c2..00000000 --- a/project/ambience_test.gd +++ /dev/null @@ -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) diff --git a/project/ambience_test.gd.uid b/project/ambience_test.gd.uid deleted file mode 100644 index 486f7efd..00000000 --- a/project/ambience_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://p7skimuw8kud diff --git a/project/animation_blend_test.gd b/project/animation_blend_test.gd deleted file mode 100644 index 4b0c59f9..00000000 --- a/project/animation_blend_test.gd +++ /dev/null @@ -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) diff --git a/project/animation_blend_test.gd.uid b/project/animation_blend_test.gd.uid deleted file mode 100644 index 0a643010..00000000 --- a/project/animation_blend_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dhtltbx5sioow diff --git a/project/animation_cache_test.gd b/project/animation_cache_test.gd deleted file mode 100644 index 8a029b6a..00000000 --- a/project/animation_cache_test.gd +++ /dev/null @@ -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) diff --git a/project/animation_cache_test.gd.uid b/project/animation_cache_test.gd.uid deleted file mode 100644 index 985312db..00000000 --- a/project/animation_cache_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cmwgj04n10rly diff --git a/project/app_flow.gd b/project/app_flow.gd deleted file mode 100644 index 15ce21fe..00000000 --- a/project/app_flow.gd +++ /dev/null @@ -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) diff --git a/project/app_flow.gd.uid b/project/app_flow.gd.uid deleted file mode 100644 index d04575e5..00000000 --- a/project/app_flow.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://prh3okf1n2lk diff --git a/project/app_flow_lifecycle_test.gd b/project/app_flow_lifecycle_test.gd deleted file mode 100644 index af010711..00000000 --- a/project/app_flow_lifecycle_test.gd +++ /dev/null @@ -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) diff --git a/project/app_flow_lifecycle_test.gd.uid b/project/app_flow_lifecycle_test.gd.uid deleted file mode 100644 index e870e329..00000000 --- a/project/app_flow_lifecycle_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dn635j1v44kp5 diff --git a/project/app_lifecycle.gd b/project/app_lifecycle.gd deleted file mode 100644 index 4d70e3a2..00000000 --- a/project/app_lifecycle.gd +++ /dev/null @@ -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() diff --git a/project/app_lifecycle.gd.uid b/project/app_lifecycle.gd.uid deleted file mode 100644 index 19213d97..00000000 --- a/project/app_lifecycle.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cwgs1qcu80qfm diff --git a/project/atlas_test.gd b/project/atlas_test.gd deleted file mode 100644 index 06321700..00000000 --- a/project/atlas_test.gd +++ /dev/null @@ -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") diff --git a/project/atlas_test.gd.uid b/project/atlas_test.gd.uid deleted file mode 100644 index a8eb2cfc..00000000 --- a/project/atlas_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://k51m5hekg71a diff --git a/project/audio.gd b/project/audio.gd deleted file mode 100644 index a175d054..00000000 --- a/project/audio.gd +++ /dev/null @@ -1,586 +0,0 @@ -# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。 -# 对标客户端 AudioLib:BGM(`bgm/*.mp3`)、UI wav(`**/sound/ui/`)、 -# 3D wav(`**/sound/`)。`.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/ 同级(/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 `/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/.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) diff --git a/project/audio.gd.uid b/project/audio.gd.uid deleted file mode 100644 index 36fb0c8f..00000000 --- a/project/audio.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ke6yfuy7eum2 diff --git a/project/audio_miles_test.gd b/project/audio_miles_test.gd deleted file mode 100644 index 6243e744..00000000 --- a/project/audio_miles_test.gd +++ /dev/null @@ -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) diff --git a/project/audio_miles_test.gd.uid b/project/audio_miles_test.gd.uid deleted file mode 100644 index b31ca114..00000000 --- a/project/audio_miles_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ctbdjmg33pe67 diff --git a/project/auth_connect_bridge_test.gd b/project/auth_connect_bridge_test.gd deleted file mode 100644 index 68321f97..00000000 --- a/project/auth_connect_bridge_test.gd +++ /dev/null @@ -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 diff --git a/project/auth_connect_bridge_test.gd.uid b/project/auth_connect_bridge_test.gd.uid deleted file mode 100644 index e57e7595..00000000 --- a/project/auth_connect_bridge_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bdcaheq2nmoqg diff --git a/project/auth_peer_close_bridge_test.gd b/project/auth_peer_close_bridge_test.gd deleted file mode 100644 index 12df80b6..00000000 --- a/project/auth_peer_close_bridge_test.gd +++ /dev/null @@ -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 diff --git a/project/auth_peer_close_bridge_test.gd.uid b/project/auth_peer_close_bridge_test.gd.uid deleted file mode 100644 index 06b54b90..00000000 --- a/project/auth_peer_close_bridge_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://8alojuolcg5f diff --git a/project/bake_asset_index.gd b/project/bake_asset_index.gd deleted file mode 100644 index 64a023f0..00000000 --- a/project/bake_asset_index.gd +++ /dev/null @@ -1,22 +0,0 @@ -# bake_asset_index —— 构建期跑一次:扫 assets_root 建 AssetResolver 索引, -# 写到 /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) diff --git a/project/bake_asset_index.gd.uid b/project/bake_asset_index.gd.uid deleted file mode 100644 index 26458df5..00000000 --- a/project/bake_asset_index.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dyvg0v8ntldha diff --git a/project/bgm_director.gd b/project/bgm_director.gd deleted file mode 100644 index 834c6504..00000000 --- a/project/bgm_director.gd +++ /dev/null @@ -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) diff --git a/project/bgm_director.gd.uid b/project/bgm_director.gd.uid deleted file mode 100644 index 2af70df0..00000000 --- a/project/bgm_director.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://yglonaagb03t diff --git a/project/bgm_test.gd b/project/bgm_test.gd deleted file mode 100644 index b507e5a2..00000000 --- a/project/bgm_test.gd +++ /dev/null @@ -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() diff --git a/project/bgm_test.gd.uid b/project/bgm_test.gd.uid deleted file mode 100644 index 0ce1a5a8..00000000 --- a/project/bgm_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cxhy4wk3451c1 diff --git a/project/channel_status_test.gd b/project/channel_status_test.gd deleted file mode 100644 index a59ddb60..00000000 --- a/project/channel_status_test.gd +++ /dev/null @@ -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() diff --git a/project/channel_status_test.gd.uid b/project/channel_status_test.gd.uid deleted file mode 100644 index 78b56b06..00000000 --- a/project/channel_status_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bo1041j7pwnra diff --git a/project/char_bench_test.gd b/project/char_bench_test.gd deleted file mode 100644 index 106df809..00000000 --- a/project/char_bench_test.gd +++ /dev/null @@ -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 --hair --skin -# -# 目的是把「发型挡脸」拆成几何问题和材质/深度问题:同一机位分别渲染 -# 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 diff --git a/project/char_bench_test.gd.uid b/project/char_bench_test.gd.uid deleted file mode 100644 index ec24e678..00000000 --- a/project/char_bench_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://yu8c7genhjae diff --git a/project/char_create_delete_test.gd b/project/char_create_delete_test.gd deleted file mode 100644 index 79e392b2..00000000 --- a/project/char_create_delete_test.gd +++ /dev/null @@ -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() diff --git a/project/char_create_delete_test.gd.uid b/project/char_create_delete_test.gd.uid deleted file mode 100644 index 682c9b54..00000000 --- a/project/char_create_delete_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b43adptpq1ua4 diff --git a/project/char_select_start_motion_test.gd b/project/char_select_start_motion_test.gd deleted file mode 100644 index 34b5330d..00000000 --- a/project/char_select_start_motion_test.gd +++ /dev/null @@ -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) diff --git a/project/char_select_start_motion_test.gd.uid b/project/char_select_start_motion_test.gd.uid deleted file mode 100644 index 9f85690d..00000000 --- a/project/char_select_start_motion_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ngrh5ra7vqus diff --git a/project/char_select_visual_test.gd b/project/char_select_visual_test.gd deleted file mode 100644 index 4f361730..00000000 --- a/project/char_select_visual_test.gd +++ /dev/null @@ -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 diff --git a/project/char_select_visual_test.gd.uid b/project/char_select_visual_test.gd.uid deleted file mode 100644 index 8600748e..00000000 --- a/project/char_select_visual_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bohd5850be0ex diff --git a/project/char_status_ui_test.gd b/project/char_status_ui_test.gd deleted file mode 100644 index dcde24a2..00000000 --- a/project/char_status_ui_test.gd +++ /dev/null @@ -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 "" % 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") diff --git a/project/char_status_ui_test.gd.uid b/project/char_status_ui_test.gd.uid deleted file mode 100644 index 01cbe8ff..00000000 --- a/project/char_status_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://berxs4vw0s7vd diff --git a/project/character_winding_test.gd b/project/character_winding_test.gd deleted file mode 100644 index 65b4115c..00000000 --- a/project/character_winding_test.gd +++ /dev/null @@ -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) diff --git a/project/character_winding_test.gd.uid b/project/character_winding_test.gd.uid deleted file mode 100644 index 4ace63ab..00000000 --- a/project/character_winding_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dg8ihlbqw6jss diff --git a/project/charselect.gd b/project/charselect.gd deleted file mode 100644 index f7d3c81d..00000000 --- a/project/charselect.gd +++ /dev/null @@ -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 diff --git a/project/charselect.gd.uid b/project/charselect.gd.uid deleted file mode 100644 index 737d3383..00000000 --- a/project/charselect.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://qe5xev5hp4l4 diff --git a/project/chat_mobile_ui_test.gd b/project/chat_mobile_ui_test.gd deleted file mode 100644 index 405c5c99..00000000 --- a/project/chat_mobile_ui_test.gd +++ /dev/null @@ -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() diff --git a/project/chat_mobile_ui_test.gd.uid b/project/chat_mobile_ui_test.gd.uid deleted file mode 100644 index daf41aa9..00000000 --- a/project/chat_mobile_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bhyev28dml0wh diff --git a/project/chat_tail.gd b/project/chat_tail.gd deleted file mode 100644 index 5f30922e..00000000 --- a/project/chat_tail.gd +++ /dev/null @@ -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 diff --git a/project/chat_tail.gd.uid b/project/chat_tail.gd.uid deleted file mode 100644 index a754d48d..00000000 --- a/project/chat_tail.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bilb10438dl5i diff --git a/project/chat_tail_test.gd b/project/chat_tail_test.gd deleted file mode 100644 index 9e248bad..00000000 --- a/project/chat_tail_test.gd +++ /dev/null @@ -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 返回 []") diff --git a/project/chat_tail_test.gd.uid b/project/chat_tail_test.gd.uid deleted file mode 100644 index 7a599a8d..00000000 --- a/project/chat_tail_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dh4ucte7pxc3u diff --git a/project/chat_test.gd b/project/chat_test.gd deleted file mode 100644 index 4fbfb98c..00000000 --- a/project/chat_test.gd +++ /dev/null @@ -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") diff --git a/project/chat_test.gd.uid b/project/chat_test.gd.uid deleted file mode 100644 index 668bcd47..00000000 --- a/project/chat_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwoq34nldm7x3 diff --git a/project/chest_system.gd b/project/chest_system.gd deleted file mode 100644 index 3ac1eeab..00000000 --- a/project/chest_system.gd +++ /dev/null @@ -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_"), - } diff --git a/project/chest_system.gd.uid b/project/chest_system.gd.uid deleted file mode 100644 index 6dcd0bf0..00000000 --- a/project/chest_system.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://8amkno8ks12i diff --git a/project/client_main.gd b/project/client_main.gd index 2440e9e6..010f5158 100644 --- a/project/client_main.gd +++ b/project/client_main.gd @@ -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), diff --git a/project/combat_fx_test.gd b/project/combat_fx_test.gd deleted file mode 100644 index c9b714fe..00000000 --- a/project/combat_fx_test.gd +++ /dev/null @@ -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") diff --git a/project/combat_fx_test.gd.uid b/project/combat_fx_test.gd.uid deleted file mode 100644 index 1fe849dd..00000000 --- a/project/combat_fx_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bisseuypqt88s diff --git a/project/combat_parity_test.gd b/project/combat_parity_test.gd deleted file mode 100644 index ff3efab6..00000000 --- a/project/combat_parity_test.gd +++ /dev/null @@ -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() diff --git a/project/combat_parity_test.gd.uid b/project/combat_parity_test.gd.uid deleted file mode 100644 index 2d65b71a..00000000 --- a/project/combat_parity_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b7cxwyvtq02xd diff --git a/project/complex_item_drop_test.gd b/project/complex_item_drop_test.gd deleted file mode 100644 index 37975b9c..00000000 --- a/project/complex_item_drop_test.gd +++ /dev/null @@ -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) diff --git a/project/complex_item_drop_test.gd.uid b/project/complex_item_drop_test.gd.uid deleted file mode 100644 index 40259550..00000000 --- a/project/complex_item_drop_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://x2vuateiv3w1 diff --git a/project/consumable_system.gd b/project/consumable_system.gd deleted file mode 100644 index c5f3d130..00000000 --- a/project/consumable_system.gd +++ /dev/null @@ -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": "未知的传送卷轴"} diff --git a/project/consumable_system.gd.uid b/project/consumable_system.gd.uid deleted file mode 100644 index b6b8536b..00000000 --- a/project/consumable_system.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ucwyc04610e3 diff --git a/project/damage_effect.gd b/project/damage_effect.gd deleted file mode 100644 index f51e90b2..00000000 --- a/project/damage_effect.gd +++ /dev/null @@ -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/.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/.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 diff --git a/project/damage_effect.gd.uid b/project/damage_effect.gd.uid deleted file mode 100644 index 8582a3f7..00000000 --- a/project/damage_effect.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dlpn6gv5jrh14 diff --git a/project/damage_effect_test.gd b/project/damage_effect_test.gd deleted file mode 100644 index c4442f96..00000000 --- a/project/damage_effect_test.gd +++ /dev/null @@ -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)") diff --git a/project/damage_effect_test.gd.uid b/project/damage_effect_test.gd.uid deleted file mode 100644 index 06e7730e..00000000 --- a/project/damage_effect_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c77cvj7gtqck4 diff --git a/project/damage_local_main_parity_test.gd b/project/damage_local_main_parity_test.gd deleted file mode 100644 index ea67c0ca..00000000 --- a/project/damage_local_main_parity_test.gd +++ /dev/null @@ -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_ 飘字节点。 -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) diff --git a/project/damage_local_main_parity_test.gd.uid b/project/damage_local_main_parity_test.gd.uid deleted file mode 100644 index 33460ff6..00000000 --- a/project/damage_local_main_parity_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cc4w5nbmfocpr diff --git a/project/dds_lifecycle_test.gd b/project/dds_lifecycle_test.gd deleted file mode 100644 index d2744e02..00000000 --- a/project/dds_lifecycle_test.gd +++ /dev/null @@ -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) diff --git a/project/dds_lifecycle_test.gd.uid b/project/dds_lifecycle_test.gd.uid deleted file mode 100644 index 1a29345f..00000000 --- a/project/dds_lifecycle_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://buf6xm7130vgl diff --git a/project/disconnect_world_reset_test.gd b/project/disconnect_world_reset_test.gd deleted file mode 100644 index 6b41b560..00000000 --- a/project/disconnect_world_reset_test.gd +++ /dev/null @@ -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) diff --git a/project/disconnect_world_reset_test.gd.uid b/project/disconnect_world_reset_test.gd.uid deleted file mode 100644 index 47371374..00000000 --- a/project/disconnect_world_reset_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c8ula8khqwrph diff --git a/project/dragon_soul_scope_test.gd b/project/dragon_soul_scope_test.gd deleted file mode 100644 index eaf38383..00000000 --- a/project/dragon_soul_scope_test.gd +++ /dev/null @@ -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) diff --git a/project/dragon_soul_scope_test.gd.uid b/project/dragon_soul_scope_test.gd.uid deleted file mode 100644 index d1c13838..00000000 --- a/project/dragon_soul_scope_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b40nfrlx035b4 diff --git a/project/dragon_soul_test.gd b/project/dragon_soul_test.gd deleted file mode 100644 index 5a94e924..00000000 --- a/project/dragon_soul_test.gd +++ /dev/null @@ -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(), "关闭按钮") diff --git a/project/dragon_soul_test.gd.uid b/project/dragon_soul_test.gd.uid deleted file mode 100644 index f54226ab..00000000 --- a/project/dragon_soul_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c5xpqyrnavvjx diff --git a/project/dump_pixels.gd b/project/dump_pixels.gd deleted file mode 100644 index 73e50f6b..00000000 --- a/project/dump_pixels.gd +++ /dev/null @@ -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) diff --git a/project/dump_pixels.gd.uid b/project/dump_pixels.gd.uid deleted file mode 100644 index b601c6ca..00000000 --- a/project/dump_pixels.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://6bv05bdsw8wh diff --git a/project/dungeon_result_test.gd b/project/dungeon_result_test.gd deleted file mode 100644 index b17b26d3..00000000 --- a/project/dungeon_result_test.gd +++ /dev/null @@ -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() diff --git a/project/dungeon_result_test.gd.uid b/project/dungeon_result_test.gd.uid deleted file mode 100644 index d2c1c45c..00000000 --- a/project/dungeon_result_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://icsacgdld3gb diff --git a/project/dungeon_state.gd b/project/dungeon_state.gd deleted file mode 100644 index a99e23e3..00000000 --- a/project/dungeon_state.gd +++ /dev/null @@ -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() diff --git a/project/dungeon_state.gd.uid b/project/dungeon_state.gd.uid deleted file mode 100644 index cfd90350..00000000 --- a/project/dungeon_state.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://da5ixd8n62gyh diff --git a/project/dungeon_state_test.gd b/project/dungeon_state_test.gd deleted file mode 100644 index 39f0de96..00000000 --- a/project/dungeon_state_test.gd +++ /dev/null @@ -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() diff --git a/project/dungeon_state_test.gd.uid b/project/dungeon_state_test.gd.uid deleted file mode 100644 index 2016e7da..00000000 --- a/project/dungeon_state_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://exadl2wqkpmh diff --git a/project/effect_color_operation_test.gd b/project/effect_color_operation_test.gd deleted file mode 100644 index 842530aa..00000000 --- a/project/effect_color_operation_test.gd +++ /dev/null @@ -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 diff --git a/project/effect_color_operation_test.gd.uid b/project/effect_color_operation_test.gd.uid deleted file mode 100644 index 20841263..00000000 --- a/project/effect_color_operation_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dds0frgva7yxj diff --git a/project/effect_color_test.gd b/project/effect_color_test.gd deleted file mode 100644 index badfaeec..00000000 --- a/project/effect_color_test.gd +++ /dev/null @@ -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]) diff --git a/project/effect_color_test.gd.uid b/project/effect_color_test.gd.uid deleted file mode 100644 index 7c2cd358..00000000 --- a/project/effect_color_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cl2sjnmvtvr3q diff --git a/project/effect_emission_test.gd b/project/effect_emission_test.gd deleted file mode 100644 index 7d4964f2..00000000 --- a/project/effect_emission_test.gd +++ /dev/null @@ -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) diff --git a/project/effect_emission_test.gd.uid b/project/effect_emission_test.gd.uid deleted file mode 100644 index 5f95b014..00000000 --- a/project/effect_emission_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b6olq0ioo7c0f diff --git a/project/effect_faces_test.gd b/project/effect_faces_test.gd deleted file mode 100644 index 2188f2b6..00000000 --- a/project/effect_faces_test.gd +++ /dev/null @@ -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 diff --git a/project/effect_faces_test.gd.uid b/project/effect_faces_test.gd.uid deleted file mode 100644 index 5cb335a8..00000000 --- a/project/effect_faces_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://1mm2fl01mhtn diff --git a/project/effect_lie_test.gd b/project/effect_lie_test.gd deleted file mode 100644 index cef67693..00000000 --- a/project/effect_lie_test.gd +++ /dev/null @@ -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 diff --git a/project/effect_lie_test.gd.uid b/project/effect_lie_test.gd.uid deleted file mode 100644 index c55b58ee..00000000 --- a/project/effect_lie_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://2do65c7c5cj3 diff --git a/project/effect_playback_test.gd b/project/effect_playback_test.gd deleted file mode 100644 index 6ffd7815..00000000 --- a/project/effect_playback_test.gd +++ /dev/null @@ -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 diff --git a/project/effect_playback_test.gd.uid b/project/effect_playback_test.gd.uid deleted file mode 100644 index 41dfdd34..00000000 --- a/project/effect_playback_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://48lu45ir8yo5 diff --git a/project/effect_random_rotation_test.gd b/project/effect_random_rotation_test.gd deleted file mode 100644 index e7e8f37e..00000000 --- a/project/effect_random_rotation_test.gd +++ /dev/null @@ -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 diff --git a/project/effect_random_rotation_test.gd.uid b/project/effect_random_rotation_test.gd.uid deleted file mode 100644 index b66a0e3d..00000000 --- a/project/effect_random_rotation_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://7if0yf3v2fuw diff --git a/project/effect_render_regression_test.gd b/project/effect_render_regression_test.gd deleted file mode 100644 index ceff5329..00000000 --- a/project/effect_render_regression_test.gd +++ /dev/null @@ -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) diff --git a/project/effect_render_regression_test.gd.uid b/project/effect_render_regression_test.gd.uid deleted file mode 100644 index 36e6cd3a..00000000 --- a/project/effect_render_regression_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://sxn37poee1rf diff --git a/project/effect_rotation_test.gd b/project/effect_rotation_test.gd deleted file mode 100644 index a242ac95..00000000 --- a/project/effect_rotation_test.gd +++ /dev/null @@ -1,103 +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() - for kind in [0, 2, 3]: - fx.build({"particles": [{"particle": {"RotationType": kind, "RotationSpeed": 180, - "RotationRandomStartingBegin": 30, "RotationRandomStartingEnd": 60, "BillboardType": 1}}]}) - var g: GPUParticles3D = fx._emitters[-1] - var pm: ParticleProcessMaterial = g.process_material - var speed := 0.0 if kind == 0 else (180.0 if kind == 2 else -180.0) - check(pm.angular_velocity_min == speed and pm.angular_velocity_max == speed, - "constant rotation type %d uses degrees without random slowing" % kind) - check(pm.angle_min == 30.0 and pm.angle_max == 60.0, "initial angle range type %d" % kind) - check(g.material_override.billboard_mode == BaseMaterial3D.BILLBOARD_PARTICLES, - "camera billboard consumes particle rotation type %d" % kind) - check(fx._billboard(0) == BaseMaterial3D.BILLBOARD_DISABLED, "non-billboard unchanged") - check(fx._billboard(1) == BaseMaterial3D.BILLBOARD_ENABLED, "mesh camera billboard unchanged") - check(fx._billboard(2) == BaseMaterial3D.BILLBOARD_FIXED_Y, "source Y enum is two") - check(fx._billboard(4) != BaseMaterial3D.BILLBOARD_FIXED_Y, "two-face is not misclassified as Y") - fx.free() - var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/7budongbakbu_throw.mse") - var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) - check(not spec.get("particles", []).is_empty(), "real shaman rotation resource loaded") - var count := 0 - for particle in spec.get("particles", []): - var prop: Dictionary = particle.particle.duplicate(true) - var kind := int(prop.get("RotationType", 0)) - if kind != 2 and kind != 3: - continue - # Isolate rotation/material configuration without loading original textures. - prop.erase("TextureFiles") - var real_fx := EffectPlayer.new() - real_fx.build({"particles": [{"particle": prop}]}) - var pm: ParticleProcessMaterial = real_fx._emitters[0].process_material - var expected := float(prop.get("RotationSpeed", 0)) * (1.0 if kind == 2 else -1.0) - check(pm.angular_velocity_min == expected and pm.angular_velocity_max == expected, "real constant speed") - check(pm.angle_min == float(prop.get("RotationRandomStartingBegin", 0)) and - pm.angle_max == float(prop.get("RotationRandomStartingEnd", 0)), "real initial angle range") - count += 1 - real_fx.free() - check(count > 0, "real constant-rotation coverage") - print("real rotation tracks: particles=%d" % count) - time_event_rotation() - real_time_event_rotation() - print("effect_rotation_test: failures=%d" % failures) - quit(1 if failures else 0) - -func time_event_rotation() -> void: - var fx := EffectPlayer.new() - fx.build({"particles": [{"particle": {"RotationType": 1, "RotationSpeed": 999, - "TimeEventRotation": [[0.0, 180.0], [0.5, -180.0], [1.0, 0.0]], - "RotationRandomStartingBegin": 30, "RotationRandomStartingEnd": 30}}, - {"particle": {"RotationType": 1, "RotationSpeed": 999}}]}) - var pm: ParticleProcessMaterial = fx._emitters[0].process_material - check(pm.angular_velocity_curve is CurveTexture, "TIME_EVENT has a speed curve") - if pm.angular_velocity_curve is CurveTexture: - var curve: Curve = pm.angular_velocity_curve.curve - # Godot 4.7 curve branch integrates sampled value * base rate directly - # into CUSTOM.x radians; unlike the constant branch it does not convert. - check(is_equal_approx(curve.sample(0.0) * pm.angular_velocity_min, PI), "birth rate is pi radians/sec") - check(is_zero_approx(curve.sample(0.25)), "speed passes through zero") - check(is_equal_approx(curve.sample(0.75) * pm.angular_velocity_min, -PI / 2.0), "negative rate preserved") - check(is_zero_approx(curve.sample(1.0)), "end stops without erasing earlier motion") - var angle := deg_to_rad(pm.angle_min) - for i in range(2000): - angle += curve.sample((float(i) + 0.5) / 2000.0) * pm.angular_velocity_min * 2.0 / 2000.0 - check(absf(angle - deg_to_rad(-60.0)) < 0.0001, "two-second lifetime integrates speed plus initial angle") - var empty: ParticleProcessMaterial = fx._emitters[1].process_material - check(empty.angular_velocity_curve is CurveTexture, "empty TIME_EVENT remains an explicit zero curve") - if empty.angular_velocity_curve is CurveTexture: - check(is_zero_approx(empty.angular_velocity_curve.curve.sample(0.5)), "empty track ignores RotationSpeed fallback") - fx.free() - -func real_time_event_rotation() -> void: - var count := 0 - var samples := 0 - for name in ["jeungsok_blow", "8chojeonbu", "jeungryeok_hand"]: - var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/" + name + ".mse") - var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) - check(not spec.get("particles", []).is_empty(), "real TIME_EVENT file " + name) - for particle in spec.get("particles", []): - if int(particle.particle.get("RotationType", 0)) != 1: - continue - var rows: Array = particle.particle.get("TimeEventRotation", []) - var fx := EffectPlayer.new() - fx.build({"particles": [{"particle": {"RotationType": 1, "TimeEventRotation": rows}}]}) - var pm: ParticleProcessMaterial = fx._emitters[0].process_material - check(pm.angular_velocity_curve is CurveTexture, "real TIME_EVENT material") - if pm.angular_velocity_curve is CurveTexture: - for row in rows: - if float(row[0]) >= 0.0 and float(row[0]) <= 1.0: - var actual: float = pm.angular_velocity_curve.curve.sample(float(row[0])) * pm.angular_velocity_min - check(is_equal_approx(actual, deg_to_rad(float(row[1]))), "real TIME_EVENT radians " + name) - samples += 1 - count += 1 - fx.free() - check(count > 0 and samples > 0, "real time-dependent speed coverage") - print("real time-event tracks: particles=%d keys=%d" % [count, samples]) diff --git a/project/effect_rotation_test.gd.uid b/project/effect_rotation_test.gd.uid deleted file mode 100644 index 4a88d679..00000000 --- a/project/effect_rotation_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cb3kfnretl1a5 diff --git a/project/effect_scale_test.gd b/project/effect_scale_test.gd deleted file mode 100644 index a72066f9..00000000 --- a/project/effect_scale_test.gd +++ /dev/null @@ -1,81 +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() - fx.build({"particles": [{"emitter": {"TimeEventSizeX": [[0, 10]], "TimeEventSizeY": [[0, 30]]}, - "particle": {"TimeEventScaleX": [[0, 0], [1, 4]], "TimeEventScaleY": [[0, 3], [1, 1]]}}, - {"emitter": {"TimeEventSizeX": [[0, 0]], "TimeEventSizeY": [[0, 0]]}, "particle": {}}]}) - var g: GPUParticles3D = fx._emitters[0] - var pm: ParticleProcessMaterial = g.process_material - check(g.draw_pass_1.size.is_equal_approx(Vector2(0.2, 0.6)), "independent half-sizes become full metre dimensions") - check(pm.scale_min == 1.0 and pm.scale_max == 1.0, "no invented random size factor") - check(pm.scale_curve is CurveXYZTexture, "independent XYZ scale texture") - if pm.scale_curve is CurveXYZTexture: - var tex: CurveXYZTexture = pm.scale_curve - check(is_equal_approx(tex.curve_x.sample(0.25), 1.0), "X interpolates linearly") - check(is_equal_approx(tex.curve_y.sample(0.25), 2.5), "Y has independent interpolation") - check(is_equal_approx(tex.curve_x.sample(1.0), 4.0), "scale above two is retained") - check(is_equal_approx(tex.curve_z.sample(0.5), 1.0), "depth scale remains neutral") - var empty: GPUParticles3D = fx._emitters[1] - check(empty.draw_pass_1.size == Vector2.ZERO, "zero source size is not made visible") - var defaults: ParticleProcessMaterial = empty.process_material - if defaults.scale_curve is CurveXYZTexture: - check(defaults.scale_curve.curve_x.sample(1.0) == 1.0 and defaults.scale_curve.curve_y.sample(1.0) == 1.0, - "absent scale tracks stay constant instead of shrinking") - else: - check(false, "default independent curves") - fx.free() - real_resources() - print("effect_scale_test: failures=%d" % failures) - quit(1 if failures else 0) - -func real_resources() -> void: - var count := 0 - var samples := 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 spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) - check(not spec.get("particles", []).is_empty(), "real size resource loaded: " + name) - for particle in spec.get("particles", []): - var fx := EffectPlayer.new() - # Only source size/scale tracks: not a full texture or emission test. - fx.build({"particles": [{"emitter": { - "TimeEventSizeX": particle.emitter.get("TimeEventSizeX", []), - "TimeEventSizeY": particle.emitter.get("TimeEventSizeY", [])}, "particle": { - "TimeEventScaleX": particle.particle.get("TimeEventScaleX", []), - "TimeEventScaleY": particle.particle.get("TimeEventScaleY", [])}}]}) - var texture: Texture2D = fx._emitters[0].process_material.scale_curve - check(texture is CurveXYZTexture, "real XY texture " + name) - if texture is CurveXYZTexture: - for axis in range(2): - var key := "TimeEventScaleX" if axis == 0 else "TimeEventScaleY" - var curve: Curve = texture.curve_x if axis == 0 else texture.curve_y - var rows: Array = particle.particle.get(key, []) - for i in rows.size(): - var time := float(rows[i][0]) - var duplicate := i > 0 and float(rows[i - 1][0]) == time - if time >= 0.0 and time <= 1.0 and not duplicate: - check(is_equal_approx(curve.sample(time), float(rows[i][1])), "real scale key %s %s t=%f got=%f expected=%f" % [name, key, time, curve.sample(time), float(rows[i][1])]) - samples += 1 - if i > 0 and not duplicate: - var mid := (float(rows[i - 1][0]) + time) * 0.5 - var value := (float(rows[i - 1][1]) + float(rows[i][1])) * 0.5 - if mid >= 0.0 and mid <= 1.0: - # Duplicate-key jumps occupy 1e-4 of normalized age; - # permit the resulting small interpolation deviation. - var after_jump := i > 1 and float(rows[i - 2][0]) == float(rows[i - 1][0]) - var matches := absf(curve.sample(mid) - value) < 0.001 if after_jump else is_equal_approx(curve.sample(mid), value) - check(matches, "real linear midpoint %s %s t=%f got=%f expected=%f" % [name, key, mid, curve.sample(mid), value]) - samples += 1 - count += 1 - fx.free() - print("real scale tracks: particles=%d samples=%d" % [count, samples]) diff --git a/project/effect_scale_test.gd.uid b/project/effect_scale_test.gd.uid deleted file mode 100644 index 1adb9524..00000000 --- a/project/effect_scale_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b5703nwexgqtl diff --git a/project/effect_space_test.gd b/project/effect_space_test.gd deleted file mode 100644 index c4c61dca..00000000 --- a/project/effect_space_test.gd +++ /dev/null @@ -1,22 +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 effect := EffectPlayer.new() - var rows := [[0, "MOVING_TYPE_DIRECT", 100, 200, 300], [1, "MOVING_TYPE_DIRECT", 200, 400, 600]] - check(effect._position_at(rows, 0).is_equal_approx(Vector3(1, 3, -2)), "source Z goes up and Y goes south") - check(effect._position_at(rows, 0.5).is_equal_approx(Vector3(1.5, 4.5, -3)), "movement interpolation uses converted vectors") - effect.build({"particles": [{"position": rows, "emitter": {"EmittingDirection": [0, 0, 1], "EmitterShape": 1}}], - "lights": [{"position": rows}]}) - var emitter: GPUParticles3D = effect._emitters[0] - check(emitter.position.is_equal_approx(Vector3(1, 3, -2)), "particle spawn shares movement coordinates") - check(effect._lights[0].position.is_equal_approx(emitter.position), "light and particle coordinates agree") - check(emitter.process_material.direction.is_equal_approx(Vector3.UP), "source upwards emission is upwards") - check(emitter.process_material.emission_box_extents.y < 0.01, "ellipse lies on ground plane") - effect.free() - print("effect_space_test: failures=%d" % failures) - quit(1 if failures else 0) diff --git a/project/effect_space_test.gd.uid b/project/effect_space_test.gd.uid deleted file mode 100644 index 37701323..00000000 --- a/project/effect_space_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cfm5yxb2yufon diff --git a/project/effect_surface_test.gd b/project/effect_surface_test.gd deleted file mode 100644 index 7d76d494..00000000 --- a/project/effect_surface_test.gd +++ /dev/null @@ -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 image := Image.create(2, 2, false, Image.FORMAT_RGBA8) - image.fill(Color.WHITE) - var texture := ImageTexture.create_from_image(image) - var fx := EffectPlayer.new() - fx.build({"particles": [{"particle": {"BillboardType": 0, "SrcBlendType": 5, "DestBlendType": 6}}]}) - var particle_mat: StandardMaterial3D = fx._emitters[0].material_override - # A plain white texture and a static quad isolate material face visibility. - particle_mat.albedo_texture = texture - var materials := [particle_mat, fx._mesh_material({"BlendingSrcType": 5, "BlendingDestType": 6}, [texture])] - for i in materials.size(): - check(materials[i].cull_mode == BaseMaterial3D.CULL_DISABLED, "effect material %d is two-sided" % i) - check(materials[i].depth_draw_mode == BaseMaterial3D.DEPTH_DRAW_DISABLED, "effect material %d does not write depth" % i) - check(not materials[i].no_depth_test, "effect material %d still tests world depth" % i) - if DisplayServer.get_name() != "headless": - materials.append(preload("res://fx/particle_color_material.gd").build(particle_mat, 5)) - materials.append(preload("res://fx/particle_color_material.gd").build(particle_mat, 6)) - await visual(materials) - else: - print("SKIP: pixel checks require a rendering backend") - fx.free() - print("effect_surface_test: failures=%d" % failures) - quit(1 if failures else 0) -func visual(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 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 quad := MeshInstance3D.new() - quad.mesh = QuadMesh.new() - quad.mesh.size = Vector2(2, 2) - viewport.add_child(quad) - var camera := Camera3D.new() - viewport.add_child(camera) - camera.current = true - var blocker := MeshInstance3D.new() - blocker.mesh = BoxMesh.new() - blocker.mesh.size = Vector3(2, 2, 0.1) - var black := StandardMaterial3D.new() - black.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - black.albedo_color = Color.BLACK - blocker.material_override = black - viewport.add_child(blocker) - for i in materials.size(): - quad.material_override = materials[i] - for side in [1.0, -1.0]: - camera.position = Vector3(0, 0, side * 3.0) - camera.look_at(Vector3.ZERO) - blocker.visible = false - var color: Color = await center(viewport) - check(color.r > 0.8, "material %d side %d visible pixel=%s" % [i, int(side), color]) - blocker.position = Vector3(0, 0, side) - blocker.visible = true - color = await center(viewport) - check(color.r < 0.05, "material %d side %d occluded by world" % [i, int(side)]) - viewport.queue_free() - await process_frame -func center(viewport: SubViewport) -> Color: - await process_frame - await process_frame - await RenderingServer.frame_post_draw - return viewport.get_texture().get_image().get_pixel(48, 48) diff --git a/project/effect_surface_test.gd.uid b/project/effect_surface_test.gd.uid deleted file mode 100644 index abfa7f25..00000000 --- a/project/effect_surface_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cqj76kscctrvh diff --git a/project/effect_texture_animation_test.gd b/project/effect_texture_animation_test.gd deleted file mode 100644 index f6e7962e..00000000 --- a/project/effect_texture_animation_test.gd +++ /dev/null @@ -1,83 +0,0 @@ -extends SceneTree -const Materials = preload("res://fx/particle_color_material.gd") -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() - var textures: Array[Texture2D] = [] - for color in [Color.RED, Color.GREEN, Color.BLUE]: - var img := Image.create(4, 4, false, Image.FORMAT_RGBA8) - img.fill(color) - textures.append(ImageTexture.create_from_image(img)) - var frames := fx._texture_array(textures) - check(frames != null and frames.get_layers() == 3, "independent texture-array slices") - var base := StandardMaterial3D.new() - base.blend_mode = BaseMaterial3D.BLEND_MODE_MIX - base.albedo_texture = textures[0] - var mat := Materials.build(base, 4, frames, {"TexAniType": 1, "TexAniDelay": 0.1}, 1.0) - check(mat is ShaderMaterial, "ordinary modulation supports per-particle frames") - if DisplayServer.get_name() != "headless": - await pixels(mat) - else: - print("SKIP: texture animation pixels require a renderer") - fx.free() - print("effect_texture_animation_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 camera := Camera3D.new() - camera.projection = Camera3D.PROJECTION_ORTHOGONAL - camera.size = 3.0 - camera.position.z = 3.0 - viewport.add_child(camera) - var instance := MultiMeshInstance3D.new() - var mesh := QuadMesh.new() - mesh.size = Vector2(0.8, 0.8) - var multi := MultiMesh.new() - multi.transform_format = MultiMesh.TRANSFORM_3D - multi.use_custom_data = true - multi.use_colors = true - multi.mesh = mesh - multi.instance_count = 2 - for i in 2: - multi.set_instance_transform(i, Transform3D(Basis.IDENTITY, Vector3(-0.6 + i * 1.2, 0, 0))) - multi.set_instance_color(i, Color.WHITE) - instance.multimesh = multi - instance.material_override = material - viewport.add_child(instance) - # Same material, different ages: old shared AnimatedTexture fails this. - for type in [0, 1, 2, 3, 4]: - material.set_shader_parameter("animation_type", type) - for age in [0.15, 0.25, 0.35]: - multi.set_instance_custom_data(0, Color(0, 0, 0.2, 0)) - multi.set_instance_custom_data(1, Color(0, age, 0.2, 0)) - await process_frame - await RenderingServer.frame_post_draw - var img := viewport.get_texture().get_image() - var newborn := img.get_pixel(29, 48) - check(newborn.r > 0.9 and newborn.g < 0.05, "newborn always starts at frame zero type=%d" % type) - var aged := img.get_pixel(67, 48) - if type in [0, 1, 2, 4]: - var step := int(floor(age / 0.1)) - var index := 0 if type == 0 else posmod(-step if type == 2 else step, 3) - var channels := [aged.r, aged.g, aged.b] - check(channels[index] > 0.9, "age selects expected frame type=%d age=%s" % [type, age]) - else: - check(maxf(aged.r, maxf(aged.g, aged.b)) > 0.9, "random frame selects one valid texture") - viewport.free() - await process_frame diff --git a/project/effect_texture_animation_test.gd.uid b/project/effect_texture_animation_test.gd.uid deleted file mode 100644 index 63876ff2..00000000 --- a/project/effect_texture_animation_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cedkk28y0ohf7 diff --git a/project/entity_rules.gd b/project/entity_rules.gd deleted file mode 100644 index 0484d324..00000000 --- a/project/entity_rules.gd +++ /dev/null @@ -1,312 +0,0 @@ -# entity_rules.gd —— CInstanceBase::IsAttackableInstance 及其判据的 1:1 迁移(§5.4)。 -# -# 参考(REF/ = /Users/shenlei/Work/mt/40250/ClientVS22/source/): -# UserInterface/InstanceBase.cpp:2147 IsAttackableInstance —— 唯一判据,逐行照抄 -# UserInterface/InstanceBase.cpp:2116 IsConflictAlignmentInstance -# UserInterface/InstanceBase.cpp:470 IsSameEmpire -# UserInterface/InstanceBase.cpp:502 GetAlignmentGrade -# UserInterface/InstanceBase.cpp:524 GetAlignmentType -# UserInterface/InstanceBase.cpp:2059 SetPKMode / :2073 SetKiller / :2086 SetStateFlags -# UserInterface/InstanceBaseEffect.cpp:495 __FindDUELKey -# UserInterface/InstanceBaseEffect.cpp:505 IsPVPInstance -# UserInterface/PythonPlayer.cpp:1445 IsSamePartyMember -# UserInterface/InstanceBase.cpp:2390 IsWoodenDoor(vnum 13000 / 30111..30119) -# -# 纯静态谓词。调用方(net_play.gd)负责把网络快照拼成 self_e / victim_e / ctx; -# 部分依赖在本 40250 客户端的发布源码里没有 setter(同 §3.4/§3.6/§3.8 的处理), -# 在下方逐条标注为 seam。 -extends RefCounted - -# EPKModes(REF/UserInterface/Packet.h:1273) -const PK_MODE_PEACE := 0 -const PK_MODE_REVENGE := 1 -const PK_MODE_FREE := 2 -const PK_MODE_PROTECT := 3 -const PK_MODE_GUILD := 4 - -# alignment type(REF/UserInterface/InstanceBase.h:249) -const ALIGNMENT_TYPE_WHITE := 0 -const ALIGNMENT_TYPE_NORMAL := 1 -const ALIGNMENT_TYPE_DARK := 2 - -# duel mode(REF/UserInterface/InstanceBase.h:378,GetDuelMode 返回 m_dwDuelMode) -const DUEL_NONE := 0 -const DUEL_CANNOTATTACK := 1 -const DUEL_START := 2 - -# ADD_CHARACTER_STATE_*(REF/UserInterface/Packet.h:1264)—— SetStateFlags 从 state_flags -# 位里取 killer / party(InstanceBase.cpp:2086)。 -const STATE_KILLER := 1 << 3 -const STATE_PARTY := 1 << 4 - -# _entity_kind 语义(net_play._entity_kind):0 PC / 1 NPC / 2 MONSTER / 3 STONE / -# 4 WARP / 5 BUILDING / 6 WOODEN_DOOR。 -const KIND_PC := 0 -const KIND_NPC := 1 -const KIND_MONSTER := 2 -const KIND_STONE := 3 -const KIND_WARP := 4 -const KIND_BUILDING := 5 -const KIND_WOODEN_DOOR := 6 - -# CInstanceBase::CanPickInstance / InstanceBase.h affect ordinals. -# EntityStore carries the two affect words as one uint64 bitset. Tests and -# older adapters may also provide the decoded booleans/array; the helper below -# accepts both shapes so the target gate has one authoritative implementation. -const AFFECT_INVISIBILITY := 1 -const AFFECT_EUNHYEONG := 18 -const AFFECT_REVIVE_INVISIBILITY := 27 - -# --- alignment(善恶)--------------------------------------------------------- - -# CInstanceBase::GetAlignmentGrade(InstanceBase.cpp:502) -static func alignment_grade(a: int) -> int: - if a >= 12000: return 0 - elif a >= 8000: return 1 - elif a >= 4000: return 2 - elif a >= 1000: return 3 - elif a >= 0: return 4 - elif a > -4000: return 5 - elif a > -8000: return 6 - elif a > -12000: return 7 - return 8 - -# CInstanceBase::GetAlignmentType(InstanceBase.cpp:524) -static func alignment_type(a: int) -> int: - match alignment_grade(a): - 0, 1, 2, 3: - return ALIGNMENT_TYPE_WHITE - 5, 6, 7, 8: - return ALIGNMENT_TYPE_DARK - return ALIGNMENT_TYPE_NORMAL - -# --- 类别谓词(GraphicThingInstance 的 type 位,见 net_play._entity_kind)--------- - -static func _kind(e: Dictionary) -> int: - return int(e.get("kind", e.get("ch_type", 0))) - -static func _is_poly(e: Dictionary) -> bool: - # m_GraphicThingInstance.IsPoly()(变身:模型换成怪,thing type 变 ENEMY) - return bool(e.get("poly", false)) \ - or int(e.get("polymorph", 0)) != 0 \ - or int(e.get("poly_vnum", 0)) != 0 - -static func _is_pc(e: Dictionary) -> bool: - # CActorInstance::IsPC = (TYPE_PC == m_eActorType)(ActorInstance.cpp:282)。 - # 变身中的玩家仍是 TYPE_PC —— IsPoly() 另经 race >= MAIN_RACE_MAX_NUM 判真, - # 但 IsPC() 不看 race,所以这里**不**排除 poly(否则 IsAttackableInstance 的 - # `else if (IsPC())` 会漏掉变身玩家,与参考端不一致)。 - return _kind(e) == KIND_PC - -static func _is_stone(e: Dictionary) -> bool: - return _kind(e) == KIND_STONE - -static func _is_enemy(e: Dictionary) -> bool: - # CActorInstance::IsEnemy = (TYPE_ENEMY == m_eActorType)(ActorInstance.cpp:298)—— - # 仅普通怪。变身玩家是 TYPE_PC 不是 ENEMY;独立的 TYPE_POLY 实例归 _is_poly。 - return _kind(e) == KIND_MONSTER - -static func _is_wooden_door(e: Dictionary) -> bool: - # CInstanceBase::IsWoodenDoor(InstanceBase.cpp:2390) - if _kind(e) == KIND_WOODEN_DOOR: - return true - var vnum := int(e.get("race", e.get("vnum", 0))) - return vnum == 13000 or (vnum >= 30111 and vnum <= 30119) - -static func _is_building(e: Dictionary) -> bool: - # CInstanceBase::IsBuilding() = m_GraphicThingInstance.IsBuilding()。 - # classic 的 bType / POC fixture 可直接给 kind;旧包仍可用 building 布尔字段。 - return bool(e.get("building", false)) or _kind(e) == KIND_BUILDING \ - or _kind(e) == KIND_WOODEN_DOOR - -static func _has_affect(e: Dictionary, affect_type: int) -> bool: - if bool(e.get("invisibility", false)) and affect_type == AFFECT_INVISIBILITY: - return true - if bool(e.get("eunhyeong", false)) and affect_type == AFFECT_EUNHYEONG: - return true - if bool(e.get("revive_invisibility", false)) and affect_type == AFFECT_REVIVE_INVISIBILITY: - return true - var flags := int(e.get("affect_flags", 0)) - if affect_type < 64 and (flags & (1 << affect_type)) != 0: - return true - var affects: Variant = e.get("affects", []) - if affects is Array: - for a in affects: - if a is Dictionary and int(a.get("type", -1)) == affect_type: - return true - return false - -static func can_pick_instance(e: Dictionary, ctx: Dictionary = {}) -> bool: - # CInstanceBase::__IsInViewFrustum. The native entity layer normally only - # exposes actors after this check; explicit false is retained for UI/script - # selection and headless replay fixtures. - if e.has("in_view_frustum") and not bool(e.get("in_view_frustum", true)): - return false - if e.has("can_pick") and not bool(e.get("can_pick", true)): - return false - - # Door corpses are rejected by the dedicated door branch, then all dead - # instances are rejected by the common IsDead() branch. - if _is_building(e) and _kind(e) == KIND_WOODEN_DOOR and bool(e.get("dead", false)): - return false - if bool(e.get("dead", false)): - return false - - if _is_pc(e): - # __MainCanSeeHiddenThing() in this 40250 build returns false, so - # EUNHYEONG is not pickable. The other two invisibility affects are - # unconditional gates as in InstanceBase.cpp:2275-2284. - if _has_affect(e, AFFECT_EUNHYEONG) and not bool(ctx.get("can_see_hidden", false)): - return false - if _has_affect(e, AFFECT_REVIVE_INVISIBILITY) or _has_affect(e, AFFECT_INVISIBILITY): - return false - return true - -# --- killer / party / empire / duel / pvp ----------------------------------- - -static func _is_killer(e: Dictionary) -> bool: - # m_isKiller,由 SetStateFlags(state_flags & ADD_CHARACTER_STATE_KILLER) 置位。 - return (int(e.get("state_flags", 0)) & STATE_KILLER) != 0 - -static func _is_same_party(a_vid: int, b_vid: int, ctx: Dictionary) -> bool: - # CPythonPlayer::IsSamePartyMember = IsPartyMemberByVID(a) && IsPartyMemberByVID(b) - var vids: Array = ctx.get("party_vids", []) - return a_vid in vids and b_vid in vids - -static func _is_same_empire(self_e: Dictionary, victim_e: Dictionary) -> bool: - # CInstanceBase::IsSameEmpire(InstanceBase.cpp:470) - if int(victim_e.get("empire", 0)) == 0: - return true - # IsGameMaster() —— 本 POC 无 GM 标记 → seam(恒 false)。 - if bool(self_e.get("game_master", false)) or bool(victim_e.get("game_master", false)): - return true - return int(victim_e.get("empire", 0)) == int(self_e.get("empire", 0)) - -static func _find_duel_key(self_vid: int, victim_vid: int, ctx: Dictionary) -> bool: - # CInstanceBase::__FindDUELKey(InstanceBaseEffect.cpp:495)—— g_kSet_dwDUELKey 里 - # 有 MAKE_PVPKEY(min,max) 才算。本 POC 的 get_duel().opponents 是主角的对手 VID 列表。 - var opp: Array = ctx.get("duel_opponents", []) - return victim_vid in opp or self_vid in opp - -static func _pair_in(pairs: Array, a: int, b: int) -> bool: - for p in pairs: - if (int(p[0]) == a and int(p[1]) == b) or (int(p[0]) == b and int(p[1]) == a): - return true - return false - -static func _is_pvp_instance(self_e: Dictionary, victim_e: Dictionary, ctx: Dictionary) -> bool: - # CInstanceBase::IsPVPInstance(InstanceBaseEffect.cpp:505): - # if (GetDuelMode()) return true; - # return __FindPVPKey(vidSrc, vidDst) || __FindGVGKey(guildSrc, guildDst); - if int(ctx.get("duel_mode", 0)) != 0: - return true - var sv := int(self_e.get("vid", 0)) - var vv := int(victim_e.get("vid", 0)) - if _pair_in(ctx.get("pvp_pairs", []), sv, vv): - return true - var sg := int(self_e.get("guild", 0)) - var vg := int(victim_e.get("guild", 0)) - if sg != 0 and vg != 0 and _pair_in(ctx.get("gvg_pairs", []), sg, vg): - return true - return false - -static func is_conflict_alignment_instance(self_e: Dictionary, victim_e: Dictionary) -> bool: - # CInstanceBase::IsConflictAlignmentInstance(InstanceBase.cpp:2116) - if _pk_mode_of(victim_e) == PK_MODE_PROTECT: - return false - var mine := alignment_type(int(self_e.get("alignment", 0))) - var theirs := alignment_type(int(victim_e.get("alignment", 0))) - match mine: - ALIGNMENT_TYPE_NORMAL, ALIGNMENT_TYPE_WHITE: - if theirs == ALIGNMENT_TYPE_DARK: - return true - ALIGNMENT_TYPE_DARK: - if mine != theirs: - return true - return false - -static func _pk_mode_of(e: Dictionary) -> int: - return int(e.get("pk_mode", 0)) - -# --- CInstanceBase::IsAttackableInstance(InstanceBase.cpp:2147)—— 逐行 ----------- - -static func is_attackable_instance(self_e: Dictionary, victim_e: Dictionary, ctx: Dictionary) -> bool: - var sv := int(self_e.get("vid", 0)) - var vv := int(victim_e.get("vid", -1)) - - # if (__IsMainInstance()) { if (rkPlayer.IsObserverMode()) return false; } - if sv == int(ctx.get("main_vid", 0)) and bool(ctx.get("observer", false)): - return false - - # if (GetVirtualID() == rkInstVictim.GetVirtualID()) return false; - if sv == vv: - return false - - var self_pk := int(ctx.get("pk_mode", 0)) - - if _is_stone(self_e): - if _is_pc(victim_e): - return true - elif _is_pc(self_e): - if _is_stone(victim_e): - return true - - if _is_pc(victim_e): - var duel := int(ctx.get("duel_mode", 0)) - if duel != 0: - match duel: - DUEL_CANNOTATTACK: - return false - DUEL_START: - return _find_duel_key(sv, vv, ctx) - - # if (PK_MODE_GUILD == GetPKMode()) if (GetGuildID() == victim.GetGuildID()) return false; - if self_pk == PK_MODE_GUILD: - if int(self_e.get("guild", 0)) == int(victim_e.get("guild", 0)): - return false - - # if (victim.IsKiller()) if (!IsSamePartyMember(...)) return true; - if _is_killer(victim_e): - if not _is_same_party(sv, vv, ctx): - return true - - # if (PK_MODE_PROTECT != GetPKMode()) { ... } - if self_pk != PK_MODE_PROTECT: - if self_pk == PK_MODE_FREE: - if _pk_mode_of(victim_e) != PK_MODE_PROTECT: - if not _is_same_party(sv, vv, ctx): - return true - if self_pk == PK_MODE_GUILD: - if _pk_mode_of(victim_e) != PK_MODE_PROTECT: - if not _is_same_party(sv, vv, ctx): - if int(self_e.get("guild", 0)) != int(victim_e.get("guild", 0)): - return true - - # if (IsSameEmpire(victim)) { ... } else { return true; } - if _is_same_empire(self_e, victim_e): - if _is_pvp_instance(self_e, victim_e, ctx): - return true - if self_pk == PK_MODE_REVENGE: - if not _is_same_party(sv, vv, ctx): - if is_conflict_alignment_instance(self_e, victim_e): - return true - else: - return true - - if _is_enemy(victim_e): - return true - - if _is_wooden_door(victim_e): - return true - elif _is_enemy(self_e): - if _is_pc(victim_e): - return true - if _is_building(victim_e): - return true - elif _is_poly(self_e): - if _is_pc(victim_e): - return true - if _is_enemy(victim_e): - return true - - return false diff --git a/project/entity_rules.gd.uid b/project/entity_rules.gd.uid deleted file mode 100644 index b6f2e6dc..00000000 --- a/project/entity_rules.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://7g66aavury6r diff --git a/project/entity_rules_test.gd b/project/entity_rules_test.gd deleted file mode 100644 index 772bc6a4..00000000 --- a/project/entity_rules_test.gd +++ /dev/null @@ -1,172 +0,0 @@ -# entity_rules_test —— §5.4 CInstanceBase::IsAttackableInstance 1:1 迁移自检。 -# godot --headless --path project --script entity_rules_test.gd -# 覆盖:alignment grade/type 边界、观战门、自打自、PC/怪/NPC/石头/木门、 -# 同帝国 peace、异帝国、PK_FREE/GUILD/REVENGE 分支、killer、决斗三态、 -# PVP 关系表、公会战对表、conflict alignment、变身自身。 -extends SceneTree - -const EntityRules = preload("res://entity_rules.gd") - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _e(kind: int, vid: int, extra: Dictionary = {}) -> Dictionary: - var d := {"kind": kind, "vid": vid, "empire": 1, "alignment": 0, "pk_mode": 0, "guild": 0, "state_flags": 0} - for k in extra: - d[k] = extra[k] - return d - -func _ctx(extra: Dictionary = {}) -> Dictionary: - var d := { - "observer": false, "main_vid": 1, "pk_mode": 0, "duel_mode": 0, - "duel_opponents": [], "pvp_pairs": [], "gvg_pairs": [], "party_vids": [], - } - for k in extra: - d[k] = extra[k] - return d - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: entity_rules_test (§5.4 IsAttackableInstance 1:1)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var R = EntityRules - - # 1) alignment grade / type 边界(InstanceBase.cpp:502 / :524) - _ck(R.alignment_grade(12000) == 0 and R.alignment_grade(11999) == 1, "grade 12000/11999") - _ck(R.alignment_grade(0) == 4 and R.alignment_grade(-1) == 5, "grade 0/-1") - _ck(R.alignment_grade(-12000) == 8 and R.alignment_grade(-11999) == 7, "grade -12000/-11999") - _ck(R.alignment_type(5000) == R.ALIGNMENT_TYPE_WHITE, "type white (grade<=3)") - _ck(R.alignment_type(0) == R.ALIGNMENT_TYPE_NORMAL, "type normal (grade 4)") - _ck(R.alignment_type(-5000) == R.ALIGNMENT_TYPE_DARK, "type dark (grade>=5)") - - var me := _e(R.KIND_PC, 1) - - # 2) 观战模式:主角一律不可攻击 - _ck(not R.is_attackable_instance(me, _e(R.KIND_MONSTER, 2), - _ctx({"observer": true})), "observer -> not attackable") - - # 3) 自己打自己 - _ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 1), _ctx()), "self == victim -> false") - - # 4) PC vs 怪(IsEnemy) - _ck(R.is_attackable_instance(me, _e(R.KIND_MONSTER, 9), _ctx()), "PC vs monster -> true") - - # 5) PC vs NPC —— 无分支命中 - _ck(not R.is_attackable_instance(me, _e(R.KIND_NPC, 9), _ctx()), "PC vs NPC -> false") - - # 6) PC vs 同帝国 peace PC - _ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2), _ctx()), - "PC vs same-empire peace PC -> false") - - # 7) PC vs 异帝国 PC(else 分支) - _ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"empire": 2}), _ctx()), - "PC vs different-empire PC -> true") - # empire==0 的 victim 视为同帝国(IsSameEmpire 首行) - _ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"empire": 0}), _ctx()), - "victim empire 0 -> treated same empire -> false") - - # 8) PK_FREE:victim 非 protect 且不同队 -> true - _ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2), - _ctx({"pk_mode": R.PK_MODE_FREE})), "PK_FREE vs normal PC -> true") - # 9) PK_FREE:victim 是 protect -> false - _ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"pk_mode": R.PK_MODE_PROTECT}), - _ctx({"pk_mode": R.PK_MODE_FREE})), "PK_FREE vs protect victim -> false") - # 10) PK_FREE:同队 -> false - _ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2), - _ctx({"pk_mode": R.PK_MODE_FREE, "party_vids": [1, 2]})), - "PK_FREE same party -> false") - - # 11) PK_GUILD:同公会 -> 提前 false - var me_g := _e(R.KIND_PC, 1, {"guild": 7}) - _ck(not R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 7}), - _ctx({"pk_mode": R.PK_MODE_GUILD})), "PK_GUILD same guild -> false") - # 12) PK_GUILD:不同公会、victim 非 protect、不同队 -> true - _ck(R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 9}), - _ctx({"pk_mode": R.PK_MODE_GUILD})), "PK_GUILD diff guild -> true") - - # 13) killer victim(state_flags bit3):不同队即可打,即便自己 peace - _ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"state_flags": R.STATE_KILLER}), _ctx()), - "killer victim, not party -> true") - # 14) killer victim 但同队 -> false - _ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"state_flags": R.STATE_KILLER}), - _ctx({"party_vids": [1, 2]})), "killer victim, same party -> false") - - # 15) 决斗 DUEL_CANNOTATTACK -> false - _ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2), - _ctx({"duel_mode": R.DUEL_CANNOTATTACK})), "DUEL_CANNOTATTACK -> false") - # 16) 决斗 DUEL_START:对手在名单 -> true;不在 -> false - _ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2), - _ctx({"duel_mode": R.DUEL_START, "duel_opponents": [2]})), - "DUEL_START vs opponent -> true") - _ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 3), - _ctx({"duel_mode": R.DUEL_START, "duel_opponents": [2]})), - "DUEL_START vs non-opponent -> false") - - # 17) PVP 关系表(__FindPVPKey):同帝国 peace 也能打 - _ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2), - _ctx({"pvp_pairs": [[2, 1]]})), "PVP pair present -> true") - - # 18) 公会战对表(__FindGVGKey):双方都有公会且对表命中 - _ck(R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 9}), - _ctx({"gvg_pairs": [[7, 9]]})), "GVG pair present -> true") - _ck(not R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 0}), - _ctx({"gvg_pairs": [[7, 0]]})), "GVG needs both guilds non-zero -> false") - - # 19) PK_REVENGE + conflict alignment(self white, victim dark)不同队 -> true - var me_w := _e(R.KIND_PC, 1, {"alignment": 10000}) - _ck(R.is_attackable_instance(me_w, _e(R.KIND_PC, 2, {"alignment": -10000}), - _ctx({"pk_mode": R.PK_MODE_REVENGE})), "REVENGE + conflict alignment -> true") - # 20) PK_REVENGE 但 alignment 不冲突(都 white)-> false - _ck(not R.is_attackable_instance(me_w, _e(R.KIND_PC, 2, {"alignment": 10000}), - _ctx({"pk_mode": R.PK_MODE_REVENGE})), "REVENGE + non-conflict -> false") - - # 21) 石头自身 vs PC victim -> true - _ck(R.is_attackable_instance(_e(R.KIND_STONE, 5), _e(R.KIND_PC, 2), _ctx({"main_vid": 5})), - "stone self vs PC -> true") - # 22) PC self vs 石头 victim -> true - _ck(R.is_attackable_instance(me, _e(R.KIND_STONE, 5), _ctx()), "PC vs stone -> true") - - # 23) PC vs 木门(race 13000 / 30111..30119) - _ck(R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 13000}, _ctx()), - "PC vs wooden door 13000 -> true") - _ck(R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 30115}, _ctx()), - "PC vs wooden door 30115 -> true") - _ck(not R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 13001}, _ctx()), - "PC vs stone door 13001 -> false") - _ck(R._is_wooden_door({"kind": R.KIND_WOODEN_DOOR, "race": 999}), - "explicit WOODEN_DOOR kind -> IsWoodenDoor") - _ck(R._is_building({"kind": R.KIND_BUILDING}), - "explicit BUILDING kind -> IsBuilding") - _ck(R._is_building({"kind": R.KIND_WOODEN_DOOR}), - "wooden door kind -> IsBuilding") - - # 24) is_conflict_alignment_instance:victim PK_PROTECT 一律 false - _ck(not R.is_conflict_alignment_instance(me_w, - _e(R.KIND_PC, 2, {"alignment": -10000, "pk_mode": R.PK_MODE_PROTECT})), - "conflict alignment: protect victim -> false") - # dark self vs 非 dark victim -> conflict - var me_d := _e(R.KIND_PC, 1, {"alignment": -10000}) - _ck(R.is_conflict_alignment_instance(me_d, _e(R.KIND_PC, 2, {"alignment": 0})), - "conflict alignment: dark vs normal -> true") - _ck(not R.is_conflict_alignment_instance(me_d, _e(R.KIND_PC, 2, {"alignment": -20000})), - "conflict alignment: dark vs dark -> false") - - # 25a) 变身玩家(TYPE_PC + 越界 race):IsPC() 仍为真 → 完全走 PC 分支。 - var me_polypc := _e(R.KIND_PC, 1, {"poly": true}) - _ck(not R.is_attackable_instance(me_polypc, _e(R.KIND_PC, 2), _ctx()), - "polymorphed PC vs peaceful same-empire PC -> false (still a PC)") - _ck(R.is_attackable_instance(me_polypc, _e(R.KIND_MONSTER, 3), _ctx()), - "polymorphed PC vs monster -> true") - # 25b) 真正的 TYPE_POLY 实例(非 PC 类别)走 else-if IsPoly() 分支:vs PC / vs 怪 均可打。 - var poly_self := _e(R.KIND_NPC, 1, {"poly": true}) - _ck(R.is_attackable_instance(poly_self, _e(R.KIND_PC, 2), _ctx()), "TYPE_POLY self vs PC -> true") - _ck(R.is_attackable_instance(poly_self, _e(R.KIND_MONSTER, 3), _ctx()), "TYPE_POLY self vs enemy -> true") diff --git a/project/entity_rules_test.gd.uid b/project/entity_rules_test.gd.uid deleted file mode 100644 index bd6fc790..00000000 --- a/project/entity_rules_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8hnrnm5whxx diff --git a/project/environment_test.gd b/project/environment_test.gd deleted file mode 100644 index 5871d665..00000000 --- a/project/environment_test.gd +++ /dev/null @@ -1,61 +0,0 @@ -# environment_test —— 真实 .msenv -> Godot 运行时断言。 -# 验证参考端的 Filter / Character light / cloud fields are parsed and that -# an existing cloud texture is attached to the appropriate sky material. -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 world: Node = ClassDB.instantiate("Metin2World") - world.set("auto_load", false) - world.set("assets_root", AssetRoot.path()) - world.set("map_path", "OutdoorA1/metin2_map_a1") - world.set("focus_tile", Vector2i(0, 0)) - world.set("load_radius_tiles", 0) - world.set("splat_enabled", false) - world.set("water_enabled", false) - get_root().add_child(world) - _ck(bool(world.call("load_map")), "A1 map loads with .msenv") - var rep: Dictionary = world.call("get_load_report") - _ck(bool(rep.get("env_ok", false)), ".msenv env_ok") - var env_node := world.get_node_or_null("WorldEnv") as WorldEnvironment - _ck(env_node != null and env_node.environment != null, "WorldEnv has Environment") - var background_light := world.get_node_or_null("Sun") as DirectionalLight3D - var character_light := world.get_node_or_null("CharacterLight") as DirectionalLight3D - _ck(background_light != null and character_light != null, "background and character lights exist") - if background_light != null and character_light != null: - _ck(background_light.light_cull_mask == 1, "background light only sees layer 1") - _ck(character_light.light_cull_mask == 2, "character light only sees layer 2") - if env_node != null: - _ck(bool(env_node.get_meta("msenv_filter_enabled", false)) == false, - "A1 filter flag preserved") - _ck(is_equal_approx(float(env_node.get_meta("msenv_wind_strength", -1.0)), 0.2), - "reference wind strength default preserved") - _ck(is_equal_approx(float(env_node.get_meta("msenv_wind_random", -1.0)), 0.0), - "reference wind random default preserved") - _ck(env_node.has_meta("msenv_cloud_scale") and env_node.has_meta("msenv_cloud_speed"), - "cloud scale/speed metadata preserved") - var sky := env_node.environment.sky - _ck(sky != null, "WorldEnv has sky") - if sky != null: - var mat := sky.get_material() as ProceduralSkyMaterial - _ck(mat != null, "sky uses ProceduralSkyMaterial") - if mat != null: - _ck(mat.sky_cover != null, "real A1 cloud texture attached as sky cover") - world.queue_free() - await process_frame - if _fail == 0: - print("PASS: environment_test (.msenv parser + runtime sky cover)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/environment_test.gd.uid b/project/environment_test.gd.uid deleted file mode 100644 index fcb6a534..00000000 --- a/project/environment_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://crv0udnsb76ik diff --git a/project/equip_model_test.gd b/project/equip_model_test.gd deleted file mode 100644 index 9153fcde..00000000 --- a/project/equip_model_test.gd +++ /dev/null @@ -1,401 +0,0 @@ -# equip_model_test —— P2 装备→模型 headless 自检(假 client + 真 item_list.txt + 桩 model)。 -# godot --headless --path project --script equip_model_test.gd -extends SceneTree - -const ItemListDB = preload("res://ui/item_list.gd") -const EquipModel = preload("res://ui/equip_model.gd") -const PlayerView = preload("res://ui/player_view.gd") -const EffectRegistry = preload("res://fx/effect_registry.gd") -const MotionEffectAnchor = preload("res://fx/motion_effect_anchor.gd") - -class FakeClient extends Node: - signal inventory_changed(window: int, cell: int) - signal entity_main_set(vid: int) - signal entity_info(vid: int, entity: Dictionary) - var equip := [] - var main_parts := [0, 0, 0, 5] # parts[3] = hair 5 - func _init(): - for i in 11: equip.append({"vnum": 0, "count": 0, "wear": i}) - func get_equipment() -> Array: return equip - func get_main_vid() -> int: return 1000 - var mount_vnum := 0 - func get_entity(_v) -> Dictionary: return {"race": 0, "parts": main_parts, "mount_vnum": mount_vnum} - func set_wear(idx, vnum): - equip[idx] = {"vnum": vnum, "count": 1, "wear": idx} - inventory_changed.emit(2, 90 + idx) # window EQUIPMENT - -# Node3D 桩:记录 weapon_gr2 / shield_gr2 / gr2_path / hair_gr2 被设成什么 -class StubModel extends Node3D: - var weapon_gr2 := "" - var shield_gr2 := "" - var gr2_path := "" - var hair_gr2 := "" - var hair_skin := "-" - var weapon_bone := "equip_right_hand" - var shield_bone := "equip_left" - var skin_source := "" - var skin_target := "" - var specular_power := -1.0 - func clear_skin_textures(): - skin_source = "" - skin_target = "" - func set_skin_texture(source: String, target: String): - skin_source = source - skin_target = target - func _set(prop, val): - match String(prop): - "weapon_gr2": weapon_gr2 = val; return true - "shield_gr2": shield_gr2 = val; return true - "gr2_path": gr2_path = val; return true - "hair_gr2": hair_gr2 = val; return true - "hair_skin": hair_skin = val; return true - "specular_power": specular_power = val; return true - return false - -class FakeEffectRegistry extends RefCounted: - var calls: Array = [] - func setup(_assets: String) -> void: pass - func spawn(effect: String, parent: Node3D, one_shot := true) -> Node3D: - calls.append({"effect": effect, "parent": parent.name, "one_shot": one_shot}) - var node := Node3D.new() - parent.add_child(node) - return node - -class FakeAnimator extends Node: - func get_effect_bone_pose(_bone: String) -> Dictionary: - return {"transform": Transform3D.IDENTITY} - -# item_proto 桩:只给武器 subtype(WEAPON_DAGGER=1 BOW=2 FAN=5,item_length.h EWeaponSubTypes) -class FakeProto extends Node: - const SUB := {19: 0, 2000: 2, 3151: 3, 3159: 3, 4000: 1, 7000: 5} - func item(vnum: int) -> Dictionary: - if SUB.has(vnum): - return {"type": 1, "sub_type": SUB[vnum], "gain_socket_pct": 3} - return {} - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _test_40250_msm() - await _run() - if _fail == 0: - print("PASS: equip_model_test (item_list + weapon swap)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -# 40250 root/_.msm(playersettingmodule.py LoadLocalRaceData):8 个种族都经 _ensure_spec 找到, -# 0..3(原始职业性别)在 pc/,4..7(另一性别)在 pc2/。 -func _test_40250_msm() -> void: - var root := AssetRoot.client_path().path_join("Eternexus") - if not FileAccess.file_exists(root.path_join("root/warrior_m.msm")): - print(" (skip: no 40250 Eternexus/root)") - return - for r in 8: - var em = EquipModel.new() - em.assets_root = root - em.race = r - em._ensure_spec() - var sh: Dictionary = em._spec.shape(0) if em._spec else {} - _ck(String(sh.get("spec_dir", "")) == root.path_join("root"), "race %d: spec from root/_.msm" % r) - var pc := "pc" if r < 4 else "pc2" - _ck(String(sh.get("path", "")).to_lower().begins_with("d:/ymir work/%s/" % pc), "race %d: shape 0 under %s/" % [r, pc]) - em.free() - -func _run() -> void: - var assets := AssetRoot.path() - var il_path := assets.path_join("locale/locale/common/item_list.txt") - - # item_list.txt 解析 - var il := ItemListDB.new() - if not FileAccess.file_exists(il_path): - print(" (skip: no item_list.txt)") - return - _ck(il.load_file(il_path), "item_list loaded") - _ck(il.count > 1000, "item_list count > 1000 (%d)" % il.count) - _ck(il.model(19) == "d:/ymir work/item/weapon/00010.gr2", "item_list.model(19), got '%s'" % il.model(19)) - _ck(il.icon(19) == "icon/item/00010.tga", "item_list.icon(19)") - _ck(il.type_of(19) == "WEAPON", "item_list.type_of(19)") - - # InstanceBase::__GetRefinedEffect parity. Standard equipment carries - # gain_socket_pct=3, making the final vnum digit its visible refine level. - var heavy_1 := {"type": 1, "sub_type": 3, "gain_socket_pct": 3} - var heavy_9 := {"type": 1, "sub_type": 3, "gain_socket_pct": 3} - _ck(EquipModel.refine_effect_level(3151, heavy_1) == 1, - "3151 -> refine +1") - _ck(EquipModel.refine_effect_specs(3151, heavy_1).is_empty(), - "+1 has no legacy refine particles") - var heavy_fx: Array = EquipModel.refine_effect_specs(3159, heavy_9) - _ck(heavy_fx.size() == 1 and String(heavy_fx[0].effect).ends_with("sword_9.mse") - and String(heavy_fx[0].slot) == "right", - "two-handed +9 -> normal sword_9 on PART_WEAPON") - var dagger_fx: Array = EquipModel.refine_effect_specs(4009, - {"type": 1, "sub_type": 1, "gain_socket_pct": 3}) - _ck(dagger_fx.size() == 2 and String(dagger_fx[0].effect).ends_with("sword_9_s.mse") - and String(dagger_fx[1].slot) == "left", - "dagger +9 -> small-sword effect on both hands") - var bow_fx: Array = EquipModel.refine_effect_specs(2009, - {"type": 1, "sub_type": 2, "gain_socket_pct": 3}) - _ck(bow_fx.size() == 1 and String(bow_fx[0].effect).ends_with("sword_9_b.mse") - and String(bow_fx[0].slot) == "left", - "bow +9 -> bow effect on PART_WEAPON_LEFT") - var blue_steel_fx: Array = EquipModel.refine_effect_specs(12019, - {"type": 2, "sub_type": 0, "gain_socket_pct": 3}) - _ck(blue_steel_fx.size() == 3 and String(blue_steel_fx[2].effect).ends_with("armor_9.mse"), - "Blue Steel +9 -> two special effects plus armor_9") - var real_registry := EffectRegistry.new() - real_registry.setup(assets) - for spec in heavy_fx + dagger_fx + bow_fx + blue_steel_fx: - _ck(real_registry.resolve(String(spec.effect)) != "", - "registered refine MSE resolves: %s" % spec.effect) - - # Character select passes the outer PlayerView to EquipModel. Verify it - # forwards palette calls to the native renderer; a direct stub-only test - # cannot catch a missing integration method here. - var forwarded := StubModel.new() - var view := PlayerView.new() - view.model = forwarded - view.set_skin_texture("base.dds", "blue.dds") - _ck(forwarded.skin_source == "base.dds" and forwarded.skin_target == "blue.dds", - "PlayerView forwards set_skin_texture") - view.clear_skin_textures() - _ck(forwarded.skin_source == "" and forwarded.skin_target == "", - "PlayerView forwards clear_skin_textures") - view.free() - forwarded.free() - - # EquipModel:装备武器 vnum 19 -> model.weapon_gr2 = 解析后的真实路径 - var fc := FakeClient.new() - var model := StubModel.new() - get_root().add_child(fc) - get_root().add_child(model) - var em: Node = EquipModel.new() - get_root().add_child(em) - em.setup(fc, il, func() -> Node: return model, assets) - em.main_getter = func() -> int: return 1000 - - fc.set_wear(4, 19) # WEAR_WEAPON - await process_frame - var wp: String = model.weapon_gr2 - print(" weapon_gr2 -> '", wp, "'") - if wp == "": - # 资产里可能没有这把武器的 gr2 文件;至少确认解析逻辑跑到了(basevnum 约定) - _ck(true, "weapon resolved to '' (gr2 not in assets — resolution logic still exercised)") - else: - _ck(wp.ends_with("00010.gr2") and FileAccess.file_exists(wp), "weapon_gr2 points at a real 00010.gr2") - - # 拆下武器 -> weapon_gr2 = "" - fc.set_wear(4, 0) - await process_frame - _ck(model.weapon_gr2 == "", "unequip -> weapon_gr2 cleared") - - # armor_model_map 注入 -> gr2_path 切换 - em.armor_model_map = {11209: "d:/ymir work/pc/warrior/warrior.gr2"} - fc.set_wear(0, 11209) - await process_frame - _ck(model.gr2_path == "d:/ymir work/pc/warrior/warrior.gr2", "armor_model_map -> gr2_path set") - - # 左右手:ActorInstanceAttach.cpp AttachWeapon - # __IsRightHandWeapon: DAGGER / (FAN && 骑马) -> 右;BOW -> 否;其余 -> 右 - # __IsLeftHandWeapon : DAGGER / (FAN && 骑马) / BOW -> 左(GetSubModelThing = 同一 gr2) - # shield_gr2 槽 = PART_WEAPON_LEFT;参考端不渲染 WEAR_SHIELD(盾在 item_list 没模型)。 - var fch := FakeClient.new() - var mh := StubModel.new() - var mh_view := PlayerView.new() - mh_view.name = "PlayerView" - mh_view.model = mh - mh_view._left_bone = "Bip01 L Hand" - var mh_anim := FakeAnimator.new() - mh_view.anim = mh_anim - var fp := FakeProto.new() - get_root().add_child(fch) - get_root().add_child(mh_view) - mh_view.add_child(mh) - mh_view.add_child(mh_anim) - get_root().add_child(fp) - var emh: Node = EquipModel.new() - get_root().add_child(emh) - emh.setup(fch, il, func() -> Node: return mh_view, assets) - emh.main_getter = func() -> int: return 1000 - emh.proto = fp - var right_part := Node3D.new() - right_part.name = "Weapon" - mh.add_child(right_part) - var fake_effects := FakeEffectRegistry.new() - emh.effect_registry = fake_effects - var sword_p: String = emh._resolve_weapon(19) - var bow_p: String = emh._resolve_weapon(2000) - var dagger_p: String = emh._resolve_weapon(4000) - var fan_p: String = emh._resolve_weapon(7000) - _ck(sword_p != "" and bow_p != "" and dagger_p != "" and fan_p != "", "weapon gr2 resolvable (19/2000/4000/7000)") - fch.set_wear(4, 19) - await process_frame - _ck(mh.weapon_gr2 == sword_p and mh.shield_gr2 == "", "sword -> right hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2]) - fch.set_wear(4, 4000) - await process_frame - _ck(mh.weapon_gr2 == dagger_p and mh.shield_gr2 == dagger_p, "dagger -> both hands (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2]) - fch.set_wear(4, 2000) - await process_frame - _ck(mh.weapon_gr2 == "" and mh.shield_gr2 == bow_p, "bow -> left hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2]) - fch.set_wear(4, 7000) - await process_frame - _ck(mh.weapon_gr2 == fan_p and mh.shield_gr2 == "", "fan on foot -> right hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2]) - fake_effects.calls.clear() - fch.set_wear(4, 3159) - await process_frame - _ck(fake_effects.calls.size() == 1 - and String(fake_effects.calls[0].effect).ends_with("sword_9.mse") - and String(fake_effects.calls[0].parent) == "PlayerView" - and not bool(fake_effects.calls[0].one_shot), - "equipping two-handed +9 spawns persistent sword_9 on character bone path (%s; children=%s)" % - [fake_effects.calls, mh.get_children()]) - _ck(emh._weapon_refine_fx.size() == 1 - and emh._weapon_refine_fx[0].get_child_count() == 1 - and emh._weapon_refine_fx[0].get_child(0) is MotionEffectAnchor, - "+9 effect follows equip_right_hand through MotionEffectAnchor") - fch.set_wear(4, 3151) - await process_frame - _ck(fake_effects.calls.size() == 1 and emh._weapon_refine_fx.is_empty(), - "switching +9 -> +1 removes refine particles (%s; active=%s)" % - [fake_effects.calls, emh._weapon_refine_fx]) - fch.set_wear(4, 7000) - fch.mount_vnum = 20030 - fch.entity_info.emit(1000, {}) - await process_frame - _ck(mh.weapon_gr2 == fan_p and mh.shield_gr2 == fan_p, "fan mounted -> both hands (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2]) - fch.mount_vnum = 0 - fch.set_wear(4, 19) - fch.set_wear(10, 19) # WEAR_SHIELD 不渲染 - await process_frame - _ck(mh.shield_gr2 == "", "WEAR_SHIELD is not rendered (%s)" % mh.shield_gr2) - fch.set_wear(4, 0) - await process_frame - _ck(mh.weapon_gr2 == "" and mh.shield_gr2 == "", "unequip -> both hands cleared") - - # 头盔(WEAR_HEAD=1)有模型 -> 覆盖 hair_gr2;拆下回角色发型(parts[3]=5) - var em2: Node = EquipModel.new() - get_root().add_child(em2) - em2.armor_model_map = {} - em2.setup(fc, il, func() -> Node: return model, assets) - em2.main_getter = func() -> int: return 1000 - model.hair_gr2 = "" - # 头盔 vnum 用一个 item_list 里有 model 的(19 有) - fc.set_wear(1, 19) - await process_frame - _ck(model.hair_gr2 == "" or model.hair_gr2.ends_with(".gr2"), "WEAR_HEAD 有模型 -> 覆盖 hair 槽") - # 拆头盔:应尝试从 parts[3] 恢复发型(有 race_spec 才成,无则保持不炸) - fc.set_wear(1, 0) - await process_frame - _ck(true, "拆头盔 -> 回 parts 发型(不炸)") - - # armor shape 走 item_proto values[3](对齐 __ArmorVnumToShape) - if ClassDB.class_exists("Metin2Proto"): - var pr: Object = ClassDB.instantiate("Metin2Proto") - get_root().add_child(pr) - var ip := AssetRoot.locale_file("item_proto") - if ip != "" and pr.call("load_item_proto", ip): - var em3: Node = EquipModel.new() - get_root().add_child(em3) - em3.setup(fc, il, func() -> Node: return model, assets) - em3.proto = pr - em3.race = 0 # warrior - # 11200 "Monk Plate Armour+0" -> values[3] == 3 - _ck(em3._armor_shape_default(11200) == 3, "armor 11200 -> shape 3 (via proto values[3])") - # 11000 "Wolf Armour" -> values[3] == 0,参考端把 0 当有效基础 shape - _ck(em3._armor_shape_default(11000) == 100, "armor 11000 -> shape 100 (via proto values[3])") - _ck(em3._armor_shape_default(12019) == 26, "armor 12019 -> Blue Steel shape 26") - var heavy: Dictionary = pr.item(3150) - _ck(int(heavy.get("type", -1)) == 1 and int(heavy.get("sub_type", -1)) == 3, - "weapon 3150 -> WEAPON_TWO_HANDED") - var heavy_real_9: Dictionary = pr.item(3159) - _ck(EquipModel.refine_effect_level(3159, heavy_real_9) == 9 - and EquipModel.refine_effect_specs(3159, heavy_real_9).size() == 1, - "real item_proto 3159 -> two-handed +9 refine effect") - var armor_real_9: Dictionary = pr.item(12019) - _ck(EquipModel.refine_effect_level(12019, armor_real_9) == 9 - and EquipModel.refine_effect_specs(12019, armor_real_9).size() == 3, - "real item_proto 12019 -> Blue Steel +9 refine effects") - em3._apply_body(model, 12019) - _ck(model.gr2_path.to_lower().ends_with("warrior_4-1.gr2"), - "armor 12019 -> warrior_4-1 geometry (%s)" % model.gr2_path) - _ck(model.skin_source.to_lower().ends_with("warrior_4-1.dds") and - model.skin_target.to_lower().ends_with("warrior_4-2.dds"), - "armor 12019 -> source/target skin remap (%s -> %s)" % [model.skin_source, model.skin_target]) - _ck(em3._armor_specular(11209) == 100, "armor 11209(+9) -> specular 100") - # FakeProto 的 subtype 与真 item_proto 一致 - for v in FakeProto.SUB: - var it: Dictionary = pr.item(v) - _ck(int(it.get("type", -1)) == 1 and int(it.get("sub_type", -1)) == FakeProto.SUB[v], - "item_proto %d -> WEAPON sub_type %d (got %s/%s)" % [v, FakeProto.SUB[v], it.get("type"), it.get("sub_type")]) - - # 时装 / parts[] 优先:服务器把 costume vnum 写进主角 parts[ARMOR],压过装备槽 - var seen := {"body": -1, "hair": ""} - var em4: Node = EquipModel.new() - get_root().add_child(em4) - # 用一个记录用的桩:_apply_body / hair 走真逻辑但我们看最终 vnum - em4.setup(fc, il, func() -> Node: return model, assets) - em4.main_getter = func() -> int: return 1000 - em4.armor_model_map = {77777: "d:/x/costume.gr2", 11111: "d:/x/armor.gr2"} - # 装备槽是普通盔甲,但 parts[ARMOR] 是时装 -> 用时装 - fc.equip[0] = {"vnum": 11111, "count": 1, "wear": 0} - fc.main_parts = [77777, 0, 0, 5] - em4._last_body_vnum = -1 - em4.refresh() - _ck(model.gr2_path == "d:/x/costume.gr2", "parts[ARMOR] 时装压过装备槽 (%s)" % model.gr2_path) - # parts[ARMOR] 清 0 -> 回退装备槽 - fc.main_parts = [0, 0, 0, 5] - em4._last_body_vnum = -1 - em4.refresh() - _ck(model.gr2_path == "d:/x/armor.gr2", "parts[ARMOR]=0 -> 回退 WEAR_BODY (%s)" % model.gr2_path) - # _eff 直接测 - _ck(em4._eff([9, 0, 0, 0], 0, [{"vnum": 5}], 0) == 9, "_eff: parts 非 0 优先") - _ck(em4._eff([0, 0, 0, 0], 0, [{"vnum": 5}], 0) == 5, "_eff: parts 0 -> 装备槽") - _ck(em4._eff([], 1, [{"vnum": 1}, {"vnum": 2}], 1) == 2, "_eff: 无 parts -> 装备槽") - - # §4.1 修改 4:动物 / 怪物时装(shape 100-103)遮挡武器 + 头发 - var em5: Node = EquipModel.new() - get_root().add_child(em5) - em5.setup(fc, il, func() -> Node: return model, assets) - em5.main_getter = func() -> int: return 1000 - em5.armor_shape_of = func(v: int) -> int: return 101 if v == 88888 else v - fc.equip[4] = {"vnum": 19, "count": 1, "wear": 4} # 有武器 - fc.equip[10] = {"vnum": 19, "count": 1, "wear": 10} # 有盾 - fc.main_parts = [88888, 0, 0, 5] # parts[ARMOR] -> shape 101 - model.weapon_gr2 = "x"; model.shield_gr2 = "x"; model.hair_gr2 = "x" - em5._last_weapon_vnum = -1 - em5._last_body_vnum = -1; em5._last_head_vnum = -2 - em5.refresh() - _ck(model.weapon_gr2 == "", "动物时装 -> weapon_gr2 清空 (%s)" % model.weapon_gr2) - _ck(model.shield_gr2 == "", "动物时装 -> shield_gr2 清空 (%s)" % model.shield_gr2) - _ck(model.hair_gr2 == "", "动物时装 -> hair_gr2 清空 (%s)" % model.hair_gr2) - # 脱下动物时装 -> 武器恢复解析(资产里 00010.gr2 可能不存在,至少不再是遮挡态) - fc.main_parts = [0, 0, 0, 5] - em5._last_weapon_vnum = -999 - em5._last_body_vnum = -1; em5._last_head_vnum = -2 - em5.refresh() - _ck(model.weapon_gr2 == "" or model.weapon_gr2.ends_with("00010.gr2"), - "脱时装 -> weapon 重新解析 (%s)" % model.weapon_gr2) - - # 婚纱(shape 201)遮挡武器,但头发保留;捧花例外 - var em6: Node = EquipModel.new() - get_root().add_child(em6) - em6.setup(fc, il, func() -> Node: return model, assets) - em6.main_getter = func() -> int: return 1000 - em6.armor_shape_of = func(v: int) -> int: return 201 if v == 99999 else v - fc.equip[4] = {"vnum": 19, "count": 1, "wear": 4} - fc.main_parts = [99999, 0, 0, 5] - model.weapon_gr2 = "x"; model.hair_gr2 = "x" - em6._last_weapon_vnum = -1; em6._last_body_vnum = -1; em6._last_head_vnum = -2 - em6.refresh() - _ck(model.weapon_gr2 == "", "婚纱 + 剑 -> weapon_gr2 清空") - _ck(model.hair_gr2 != "", "婚纱 -> 头发保留 (%s)" % model.hair_gr2) - # 换捧花(vnum 50201)-> 武器恢复 - fc.main_parts = [99999, 50201, 0, 5] - em6._last_weapon_vnum = -999; em6._last_body_vnum = -1; em6._last_head_vnum = -2 - em6.refresh() - _ck(em6._last_weapon_vnum == 50201, "婚纱 + 捧花 -> 武器 vnum 保留 (%d)" % em6._last_weapon_vnum) diff --git a/project/equip_model_test.gd.uid b/project/equip_model_test.gd.uid deleted file mode 100644 index 44b8eb7e..00000000 --- a/project/equip_model_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://chnd4ymmuptf0 diff --git a/project/equip_rules.gd b/project/equip_rules.gd deleted file mode 100644 index 3ff95f5d..00000000 --- a/project/equip_rules.gd +++ /dev/null @@ -1,331 +0,0 @@ -# equip_rules.gd —— 装备穿戴合法性校验(纯函数校验器)。 -# -# 语义顺序 1:1 对齐服务器真实链(已核对 40250 源码): -# `char_item.cpp:7462 CanEquipNow`:JOB antiflag → limit(LEVEL/STR/INT/DEX/CON) -# → WEARABLE_UNIQUE 同组去重(结婚戒指另需已婚,客户端不判)。 -# `char_item.cpp:6100 EquipItem`(拿到 `item.cpp:472 FindEquipCell` 的槽位后): -# 燕尾服+骑乘禁止 → 变身中除 WEAR_ARROW 一律禁止 → `FN_check_item_sex`(性别) -# → 骑乘物品已骑乘禁止 → 攻击/施法后 1.5s 冷却(客户端不判)。 -# **帝国 antiflag 在装备路径上从不检查**(只在掉落/商店显示层)——已移出装备门, -# 见 empire_warning(),供 tooltip 提示用。 -# LIMIT_PCBANG / LIMIT_REAL_TIME* 不是装备门禁(item.cpp:1899 PCBANG 仅标记; -# REAL_TIME* 由 socket0/socket1 驱动到期,客户端职责只有 tooltip 倒计时)。 -# -# 参考枚举 1:1 自 `REF/GameLib/ItemData.h`(`EItemAntiFlag` / `EItemWearableFlag` / -# `EWearPositions` / `ELimitTypes` / `EItemType` / `EArmorSubTypes` / `ECostumeSubTypes`)。 -# 40250 客户端本身**不做** `CanEquipNow` 前置门(真正的仲裁在服务器)。这里按 §3.9 -# `__CanShot` 的方式在客户端防御式重建一道前置门,被拦下时给出与服务器一致的拒绝码。 -extends RefCounted - -# --- EItemAntiFlag(ItemData.h,classic 排列:帝国位在 1<<9..1<<11)--- -const ANTIFLAG_FEMALE := 1 << 0 -const ANTIFLAG_MALE := 1 << 1 -const ANTIFLAG_WARRIOR := 1 << 2 -const ANTIFLAG_ASSASSIN := 1 << 3 -const ANTIFLAG_SURA := 1 << 4 -const ANTIFLAG_SHAMAN := 1 << 5 -const ANTIFLAG_EMPIRE_A := 1 << 9 -const ANTIFLAG_EMPIRE_B := 1 << 10 -const ANTIFLAG_EMPIRE_R := 1 << 11 - -# --- EItemWearableFlag --- -const WEARABLE_BODY := 1 << 0 -const WEARABLE_HEAD := 1 << 1 -const WEARABLE_FOOTS := 1 << 2 -const WEARABLE_WRIST := 1 << 3 -const WEARABLE_WEAPON := 1 << 4 -const WEARABLE_NECK := 1 << 5 -const WEARABLE_EAR := 1 << 6 -const WEARABLE_UNIQUE := 1 << 7 -const WEARABLE_SHIELD := 1 << 8 -const WEARABLE_ARROW := 1 << 9 - -# --- EWearPositions(0..10)+ 新装备系统槽(GameType.h,19..23)--- -const WEAR_BODY := 0 -const WEAR_HEAD := 1 -const WEAR_FOOTS := 2 -const WEAR_WRIST := 3 -const WEAR_WEAPON := 4 -const WEAR_NECK := 5 -const WEAR_EAR := 6 -const WEAR_UNIQUE1 := 7 -const WEAR_UNIQUE2 := 8 -const WEAR_ARROW := 9 -const WEAR_SHIELD := 10 -const WEAR_MAX_NUM := 11 -const WEAR_COSTUME_BODY := 19 -const WEAR_COSTUME_HAIR := 20 -const WEAR_RING1 := 21 -const WEAR_RING2 := 22 -const WEAR_BELT := 23 - -# --- ELimitTypes --- -const LIMIT_NONE := 0 -const LIMIT_LEVEL := 1 -const LIMIT_STR := 2 -const LIMIT_DEX := 3 -const LIMIT_INT := 4 -const LIMIT_CON := 5 -const LIMIT_PCBANG := 6 -const LIMIT_REAL_TIME := 7 - -# --- EItemType(相关项)+ 子类 --- -const ITEM_TYPE_WEAPON := 1 -const ITEM_TYPE_ARMOR := 2 -const ITEM_TYPE_UNIQUE := 16 -const ITEM_TYPE_TOTEM := 26 -const ITEM_TYPE_COSTUME := 28 -const ITEM_TYPE_DS := 29 -const ITEM_TYPE_SPECIAL_DS := 30 -const ITEM_TYPE_RING := 33 -const ITEM_TYPE_BELT := 34 - -# --- EUniqueSubTypes(item_length.h:骑乘/坐骑物品,服务器 IsRideItem)--- -const UNIQUE_SPECIAL_RIDE := 2 -const UNIQUE_SPECIAL_MOUNT_RIDE := 3 - -const ARMOR_BODY := 0 -const ARMOR_HEAD := 1 -const ARMOR_SHIELD := 2 -const ARMOR_WRIST := 3 -const ARMOR_FOOTS := 4 -const ARMOR_NECK := 5 -const ARMOR_EAR := 6 - -const COSTUME_BODY := 0 -const COSTUME_HAIR := 1 - -# --- 职业 / 性别 <- race(common length.h `MAIN_RACE_*`:job = race % 4;性别见下表)--- -const JOB_WARRIOR := 0 -const JOB_ASSASSIN := 1 -const JOB_SURA := 2 -const JOB_SHAMAN := 3 -const SEX_MALE := 0 -const SEX_FEMALE := 1 -# race 0..7 = WARRIOR_M / ASSASSIN_W / SURA_M / SHAMAN_W / WARRIOR_W / ASSASSIN_M / SURA_W / SHAMAN_M -const SEX_BY_RACE := [SEX_MALE, SEX_FEMALE, SEX_MALE, SEX_FEMALE, SEX_FEMALE, SEX_MALE, SEX_FEMALE, SEX_MALE] -const JOB_ANTIFLAG := [ANTIFLAG_WARRIOR, ANTIFLAG_ASSASSIN, ANTIFLAG_SURA, ANTIFLAG_SHAMAN] -# 帝国 1/2/3(GC_EMPIRE)→ 对应 anti 位 -const EMPIRE_ANTIFLAG := {1: ANTIFLAG_EMPIRE_A, 2: ANTIFLAG_EMPIRE_B, 3: ANTIFLAG_EMPIRE_R} - - -static func sex_of_race(race: int) -> int: - return int(SEX_BY_RACE[race]) if race >= 0 and race < SEX_BY_RACE.size() else SEX_MALE - - -static func job_of_race(race: int) -> int: - return (race % 4) if race >= 0 else JOB_WARRIOR - - -# 物品默认落在哪个 wear 槽(type / sub_type 决定)。不可穿戴 → -1。 -# 戒指无穿戴表时默认 RING1;有穿戴表请用 find_equip_cell(RING1 空则 1 否则 2, -# 与服务器 item.cpp:472 一致)。 -static func default_wear_for(item: Dictionary) -> int: - var itype := int(item.get("type", -1)) - var sub := int(item.get("sub_type", -1)) - match itype: - ITEM_TYPE_WEAPON: - return WEAR_WEAPON - ITEM_TYPE_COSTUME: - return WEAR_COSTUME_HAIR if sub == COSTUME_HAIR else WEAR_COSTUME_BODY - ITEM_TYPE_RING: - return WEAR_RING1 - ITEM_TYPE_BELT: - return WEAR_BELT - ITEM_TYPE_ARMOR: - match sub: - ARMOR_HEAD: return WEAR_HEAD - ARMOR_SHIELD: return WEAR_SHIELD - ARMOR_WRIST: return WEAR_WRIST - ARMOR_FOOTS: return WEAR_FOOTS - ARMOR_NECK: return WEAR_NECK - ARMOR_EAR: return WEAR_EAR - _: return WEAR_BODY - return -1 - - -static func is_equippable(item: Dictionary) -> bool: - return default_wear_for(item) >= 0 - - -# wear 槽 → 需要的 wearable_flag 位(新装备槽 19..23 无 wearable_flag → 0)。 -static func wear_to_wearable(wear: int) -> int: - match wear: - WEAR_BODY: return WEARABLE_BODY - WEAR_HEAD: return WEARABLE_HEAD - WEAR_FOOTS: return WEARABLE_FOOTS - WEAR_WRIST: return WEARABLE_WRIST - WEAR_WEAPON: return WEARABLE_WEAPON - WEAR_NECK: return WEARABLE_NECK - WEAR_EAR: return WEARABLE_EAR - WEAR_UNIQUE1, WEAR_UNIQUE2: return WEARABLE_UNIQUE - WEAR_ARROW: return WEARABLE_ARROW - WEAR_SHIELD: return WEARABLE_SHIELD - return 0 - - -# 目标槽能否接受这件物品:新装备槽按 item type,旧 11 槽按 wearable_flag 位。 -static func slot_accepts(item: Dictionary, wear: int) -> bool: - var itype := int(item.get("type", -1)) - match wear: - WEAR_COSTUME_BODY, WEAR_COSTUME_HAIR: - return itype == ITEM_TYPE_COSTUME - WEAR_RING1, WEAR_RING2: - return itype == ITEM_TYPE_RING - WEAR_BELT: - return itype == ITEM_TYPE_BELT - var need := wear_to_wearable(wear) - if need == 0: - return false - return (int(item.get("wear_flags", 0)) & need) != 0 - - -# 服务器 `item.cpp:472 CItem::FindEquipCell` 的 1:1 复刻。 -# worn : 当前穿戴表 {wear_cell: vnum}(客户端来自 get_equipment())。 -# else-if 优先级:BODY→HEAD→FOOTS→WRIST→WEAPON→SHIELD→NECK→EAR→ARROW; -# RING→RING1 空则 RING1 否则 RING2;UNIQUE→UNIQUE1 空则 UNIQUE1 否则 UNIQUE2。 -# 龙魂石(DS/SPECIAL_DS)服务器返回 WEAR_MAX_NUM+sub,但客户端走 -# dragon_soul_ui 的专用协议路径,这里返回 -1 不进装备门。 -static func find_equip_cell(item: Dictionary, worn: Dictionary = {}) -> int: - var itype := int(item.get("type", -1)) - var sub := int(item.get("sub_type", -1)) - var flags := int(item.get("wear_flags", 0)) - if (flags == 0 or itype == ITEM_TYPE_TOTEM) and itype != ITEM_TYPE_COSTUME \ - and itype != ITEM_TYPE_DS and itype != ITEM_TYPE_SPECIAL_DS \ - and itype != ITEM_TYPE_RING and itype != ITEM_TYPE_BELT: - return -1 - if itype == ITEM_TYPE_DS or itype == ITEM_TYPE_SPECIAL_DS: - return -1 # 见上:客户端 DS 专用路径 - if itype == ITEM_TYPE_COSTUME: - return WEAR_COSTUME_HAIR if sub == COSTUME_HAIR else WEAR_COSTUME_BODY - if itype == ITEM_TYPE_RING: - return WEAR_RING2 if worn.has(WEAR_RING1) else WEAR_RING1 - if itype == ITEM_TYPE_BELT: - return WEAR_BELT - if flags & WEARABLE_BODY: - return WEAR_BODY - if flags & WEARABLE_HEAD: - return WEAR_HEAD - if flags & WEARABLE_FOOTS: - return WEAR_FOOTS - if flags & WEARABLE_WRIST: - return WEAR_WRIST - if flags & WEARABLE_WEAPON: - return WEAR_WEAPON - if flags & WEARABLE_SHIELD: - return WEAR_SHIELD - if flags & WEARABLE_NECK: - return WEAR_NECK - if flags & WEARABLE_EAR: - return WEAR_EAR - if flags & WEARABLE_ARROW: - return WEAR_ARROW - if flags & WEARABLE_UNIQUE: - return WEAR_UNIQUE2 if worn.has(WEAR_UNIQUE1) else WEAR_UNIQUE1 - return -1 - -# 骑乘 / 坐骑物品(服务器 `item.cpp:1695 IsRideItem`:UNIQUE + RIDE / MOUNT_RIDE)。 -static func is_ride_item(item: Dictionary) -> bool: - return int(item.get("type", -1)) == ITEM_TYPE_UNIQUE \ - and (int(item.get("sub_type", -1)) == UNIQUE_SPECIAL_RIDE \ - or int(item.get("sub_type", -1)) == UNIQUE_SPECIAL_MOUNT_RIDE) - -# 帝国 antiflag:服务器装备路径不检查(显示层语义)。返回冲突的 anti 位(0 = 无冲突), -# tooltip 用它给出提示,不作为装备门。 -static func empire_warning(item: Dictionary, empire: int) -> int: - var bit := int(EMPIRE_ANTIFLAG.get(empire, 0)) - if bit == 0: - return 0 - return int(item.get("anti_flags", 0)) & bit - -static func _fail(code: String, need := 0) -> Dictionary: - return {"ok": false, "code": code, "need": need} - - -# 主校验(顺序 1:1 = 服务器 CanEquipNow → FindEquipCell → EquipItem 后置检查)。 -# item : proto 字典 —— {type, sub_type, anti_flags, wear_flags, limits:[{type,value}], vnum?} -# wear : 目标槽;传 -1 用 find_equip_cell(有 ctx.worn 时含 RING/UNIQUE 占用翻转) -# ctx : {race, sex, job, empire, level, st, dx, ht, iq, -# worn: {cell: vnum}, # 当前穿戴(RING/UNIQUE 占用判断) -# polymorphed: bool, # 变身中(POINT_POLYMORPH / affect 220) -# riding: bool} # 骑乘中 -# —— sex/job 缺省时由 race 推;属性 st/dx/ht/iq 缺省或 < 0 = 不判 -# 返回 {ok: bool, code: String, need: int}。need = 失败 limit 的要求值。 -static func can_equip(item: Dictionary, wear: int, ctx: Dictionary) -> Dictionary: - if item.is_empty(): - return _fail("NO_ITEM") - var anti := int(item.get("anti_flags", 0)) - var race := int(ctx.get("race", 0)) - var sex := int(ctx.get("sex", sex_of_race(race))) - var job := int(ctx.get("job", job_of_race(race))) - var worn: Dictionary = ctx.get("worn", {}) - - # ---- CanEquipNow(char_item.cpp:7462)---- - # 1) anti-flag:职业(服务器只查 JOB;性别在拿到槽位后查,见 5)) - if job >= 0 and job < JOB_ANTIFLAG.size() and (anti & int(JOB_ANTIFLAG[job])): - return _fail("ANTI_JOB") - # 2) limit(等级 / 属性)—— 逐条 GetLimit;PCBANG / REAL_TIME* 不是门禁(见文件头) - for lim in item.get("limits", []): - var t := int(lim.get("type", 0)) - var v := int(lim.get("value", 0)) - if v <= 0: - continue - match t: - LIMIT_LEVEL: - if int(ctx.get("level", 0)) < v: - return _fail("LIMIT_LEVEL", v) - LIMIT_STR: - if _stat(ctx, "st") >= 0 and _stat(ctx, "st") < v: - return _fail("LIMIT_STR", v) - LIMIT_DEX: - if _stat(ctx, "dx") >= 0 and _stat(ctx, "dx") < v: - return _fail("LIMIT_DEX", v) - LIMIT_INT: - if _stat(ctx, "iq") >= 0 and _stat(ctx, "iq") < v: - return _fail("LIMIT_INT", v) - LIMIT_CON: - if _stat(ctx, "ht") >= 0 and _stat(ctx, "ht") < v: - return _fail("LIMIT_CON", v) - _: - pass - # 3) WEARABLE_UNIQUE 同组去重:UNIQUE1/UNIQUE2 已有同组物品则拒绝。 - # 服务器用 IsSameSpecialGroup;穿戴表(worn: cell→vnum)拿不到被占物品的 - # proto,这里退化为同 vnum 判定——只拦下确定的重复,其余交服务器权威。 - # 结婚戒指另需已婚,客户端不判。 - if int(item.get("wear_flags", 0)) & WEARABLE_UNIQUE and item.get("vnum", 0) is int: - for cell in [WEAR_UNIQUE1, WEAR_UNIQUE2]: - if int(worn.get(cell, 0)) == int(item.get("vnum", 0)) \ - and int(item.get("vnum", 0)) != 0: - return _fail("UNIQUE_DUP") - - # ---- FindEquipCell(item.cpp:472)---- - var target := find_equip_cell(item, worn) if wear < 0 else wear - if target < 0: - return _fail("NOT_EQUIPPABLE") - # 显式指定槽时校验物品与槽匹配(服务器对显式 cell 不做这层校验,属客户端防御)。 - if wear >= 0 and not slot_accepts(item, target): - return _fail("NOT_WEARABLE_HERE") - - # ---- EquipItem 后置检查(char_item.cpp:6122+)---- - # 4) 燕尾服(vnum 11901..11904)骑乘中不可穿 - if target == WEAR_BODY and bool(ctx.get("riding", false)): - var vnum := int(item.get("vnum", 0)) - if vnum >= 11901 and vnum <= 11904: - return _fail("RIDE_TUXEDO") - # 5) 变身中除 WEAR_ARROW 一律禁止 - if bool(ctx.get("polymorphed", false)) and target != WEAR_ARROW: - return _fail("POLYMORPH") - # 6) FN_check_item_sex(char_item.cpp:189) - if sex == SEX_FEMALE and (anti & ANTIFLAG_FEMALE): - return _fail("ANTI_SEX") - if sex == SEX_MALE and (anti & ANTIFLAG_MALE): - return _fail("ANTI_SEX") - # 7) 骑乘物品已骑乘禁止(item.cpp:1695 IsRideItem) - if bool(ctx.get("riding", false)) and is_ride_item(item): - return _fail("RIDE_ITEM") - # 攻击/施法后 1.5s 冷却(char_item.cpp:6143)客户端不判,交服务器。 - return {"ok": true, "code": "", "need": 0} - - -static func _stat(ctx: Dictionary, key: String) -> int: - return int(ctx.get(key, -1)) diff --git a/project/equip_rules.gd.uid b/project/equip_rules.gd.uid deleted file mode 100644 index 26acc4ae..00000000 --- a/project/equip_rules.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dq43hpy353dab diff --git a/project/equip_rules_test.gd b/project/equip_rules_test.gd deleted file mode 100644 index c25d018e..00000000 --- a/project/equip_rules_test.gd +++ /dev/null @@ -1,187 +0,0 @@ -# equip_rules_test —— 装备穿戴合法性校验(EquipRules)1:1 自检。 -# 顺序对齐服务器真实链:CanEquipNow(JOB→limit→UNIQUE 去重) → FindEquipCell → -# EquipItem 后置(燕尾服/变身/性别/骑乘)。 -# canonical #17。 godot --headless --path project --script equip_rules_test.gd -extends SceneTree - -const EquipRules = preload("res://equip_rules.gd") - -var failed := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - failed += 1 - printerr("FAIL: " + message) - -func _weapon(extra := {}) -> Dictionary: - var d := {"type": 1, "sub_type": 0, "anti_flags": 0, "wear_flags": 1 << 4, "limits": []} - d.merge(extra, true) - return d - -func _init() -> void: - _test_race_tables() - _test_default_wear() - _test_slot_accepts() - _test_antiflag() - _test_wearable() - _test_limits() - _test_order_and_newslots() - _test_find_equip_cell() - _test_post_checks() - if failed == 0: - print("PASS: equip_rules_test (CanEquipNow→FindEquipCell→EquipItem 后置 1:1)") - quit(0) - else: - quit(1) - -func _test_race_tables() -> void: - # race 0..7 = WARRIOR_M / ASSASSIN_W / SURA_M / SHAMAN_W / WARRIOR_W / ASSASSIN_M / SURA_W / SHAMAN_M - _ck(EquipRules.sex_of_race(0) == EquipRules.SEX_MALE, "race 0 male") - _ck(EquipRules.sex_of_race(1) == EquipRules.SEX_FEMALE, "race 1 female") - _ck(EquipRules.sex_of_race(4) == EquipRules.SEX_FEMALE, "race 4 female (warrior W)") - _ck(EquipRules.sex_of_race(5) == EquipRules.SEX_MALE, "race 5 male (assassin M)") - _ck(EquipRules.job_of_race(0) == EquipRules.JOB_WARRIOR, "race 0 warrior") - _ck(EquipRules.job_of_race(1) == EquipRules.JOB_ASSASSIN, "race 1 assassin") - _ck(EquipRules.job_of_race(6) == EquipRules.JOB_SURA, "race 6 sura") - _ck(EquipRules.job_of_race(7) == EquipRules.JOB_SHAMAN, "race 7 shaman") - -func _test_default_wear() -> void: - _ck(EquipRules.default_wear_for(_weapon()) == EquipRules.WEAR_WEAPON, "weapon -> WEAR_WEAPON") - _ck(EquipRules.default_wear_for({"type": 2, "sub_type": 0}) == EquipRules.WEAR_BODY, "armor body -> WEAR_BODY") - _ck(EquipRules.default_wear_for({"type": 2, "sub_type": 1}) == EquipRules.WEAR_HEAD, "armor head -> WEAR_HEAD") - _ck(EquipRules.default_wear_for({"type": 2, "sub_type": 2}) == EquipRules.WEAR_SHIELD, "armor shield -> WEAR_SHIELD") - _ck(EquipRules.default_wear_for({"type": 2, "sub_type": 5}) == EquipRules.WEAR_NECK, "armor neck -> WEAR_NECK") - _ck(EquipRules.default_wear_for({"type": 28, "sub_type": 0}) == EquipRules.WEAR_COSTUME_BODY, "costume body -> 19") - _ck(EquipRules.default_wear_for({"type": 28, "sub_type": 1}) == EquipRules.WEAR_COSTUME_HAIR, "costume hair -> 20") - _ck(EquipRules.default_wear_for({"type": 33}) == EquipRules.WEAR_RING1, "ring -> WEAR_RING1") - _ck(EquipRules.default_wear_for({"type": 34}) == EquipRules.WEAR_BELT, "belt -> WEAR_BELT") - _ck(EquipRules.default_wear_for({"type": 3}) == -1, "potion -> not equippable") - _ck(not EquipRules.is_equippable({"type": 3}), "is_equippable(potion) false") - _ck(EquipRules.is_equippable(_weapon()), "is_equippable(weapon) true") - -func _test_slot_accepts() -> void: - _ck(EquipRules.slot_accepts(_weapon(), EquipRules.WEAR_WEAPON), "weapon accepted at WEAR_WEAPON") - _ck(not EquipRules.slot_accepts(_weapon(), EquipRules.WEAR_BODY), "weapon rejected at WEAR_BODY") - _ck(EquipRules.slot_accepts({"type": 2, "wear_flags": 1 << 0}, EquipRules.WEAR_BODY), "body armor at WEAR_BODY") - _ck(EquipRules.slot_accepts({"type": 2, "wear_flags": 1 << 7}, EquipRules.WEAR_UNIQUE2), "unique flag at WEAR_UNIQUE2") - _ck(EquipRules.slot_accepts({"type": 33}, EquipRules.WEAR_RING1), "ring type at WEAR_RING1 (no wear_flags)") - _ck(not EquipRules.slot_accepts(_weapon(), EquipRules.WEAR_RING1), "weapon rejected at WEAR_RING1") - _ck(EquipRules.slot_accepts({"type": 34}, EquipRules.WEAR_BELT), "belt type at WEAR_BELT") - -func _test_antiflag() -> void: - var female_only := _weapon({"anti_flags": EquipRules.ANTIFLAG_FEMALE}) - _ck(not EquipRules.can_equip(female_only, -1, {"race": 1})["ok"], "ANTI_FEMALE blocks female (race 1)") - _ck(EquipRules.can_equip(female_only, -1, {"race": 1})["code"] == "ANTI_SEX", "code ANTI_SEX") - _ck(EquipRules.can_equip(female_only, -1, {"race": 0})["ok"], "ANTI_FEMALE ok for male (race 0)") - - var male_only := _weapon({"anti_flags": EquipRules.ANTIFLAG_MALE}) - _ck(not EquipRules.can_equip(male_only, -1, {"race": 0})["ok"], "ANTI_MALE blocks male") - _ck(EquipRules.can_equip(male_only, -1, {"sex": EquipRules.SEX_FEMALE})["ok"], "ANTI_MALE ok when sex override female") - - var no_warrior := _weapon({"anti_flags": EquipRules.ANTIFLAG_WARRIOR}) - _ck(EquipRules.can_equip(no_warrior, -1, {"race": 0})["code"] == "ANTI_JOB", "ANTI_WARRIOR blocks warrior (race 0)") - _ck(EquipRules.can_equip(no_warrior, -1, {"race": 1})["ok"], "ANTI_WARRIOR ok for assassin (race 1)") - - # 帝国 antiflag 不是装备门(服务器装备路径从不检查)——can_equip 放行, - # 冲突位只由 empire_warning() 给 tooltip。 - var no_emp_a := _weapon({"anti_flags": EquipRules.ANTIFLAG_EMPIRE_A}) - _ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 1})["ok"], "ANTI_EMPIRE no longer blocks equip") - _ck(EquipRules.empire_warning(no_emp_a, 1) == EquipRules.ANTIFLAG_EMPIRE_A, "empire_warning reports conflict") - _ck(EquipRules.empire_warning(no_emp_a, 2) == 0, "empire_warning clean for empire 2") - _ck(EquipRules.empire_warning(no_emp_a, 0) == 0, "empire_warning clean without empire") - -func _test_wearable() -> void: - var res := EquipRules.can_equip(_weapon(), EquipRules.WEAR_BODY, {"race": 0}) - _ck(not res["ok"] and res["code"] == "NOT_WEARABLE_HERE", "weapon at WEAR_BODY -> NOT_WEARABLE_HERE") - _ck(EquipRules.can_equip(_weapon(), EquipRules.WEAR_WEAPON, {"race": 0})["ok"], "weapon at WEAR_WEAPON ok") - -func _test_limits() -> void: - var lv30 := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 30}]}) - var r := EquipRules.can_equip(lv30, -1, {"race": 0, "level": 20}) - _ck(not r["ok"] and r["code"] == "LIMIT_LEVEL" and int(r["need"]) == 30, "level 20 < 30 -> LIMIT_LEVEL need 30") - _ck(EquipRules.can_equip(lv30, -1, {"race": 0, "level": 30})["ok"], "level 30 == 30 ok") - _ck(EquipRules.can_equip(lv30, -1, {"race": 0, "level": 99})["ok"], "level 99 ok") - - var str50 := _weapon({"limits": [{"type": EquipRules.LIMIT_STR, "value": 50}]}) - _ck(EquipRules.can_equip(str50, -1, {"race": 0})["ok"], "STR limit skipped when ctx has no st (-1)") - _ck(not EquipRules.can_equip(str50, -1, {"race": 0, "st": 40})["ok"], "st 40 < 50 -> fail") - _ck(EquipRules.can_equip(str50, -1, {"race": 0, "st": 50})["ok"], "st 50 ok") - _ck(EquipRules.can_equip(str50, -1, {"race": 0, "st": 40})["code"] == "LIMIT_STR", "code LIMIT_STR") - - var con20 := _weapon({"limits": [{"type": EquipRules.LIMIT_CON, "value": 20}]}) - _ck(not EquipRules.can_equip(con20, -1, {"race": 0, "ht": 10})["ok"], "ht 10 < 20 -> LIMIT_CON") - - # value <= 0 的 limit 项被忽略 - var zero := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 0}]}) - _ck(EquipRules.can_equip(zero, -1, {"race": 0, "level": 1})["ok"], "limit value 0 ignored") - -func _test_order_and_newslots() -> void: - # limit 先于性别(服务器顺序:CanEquipNow 的 limit 在 EquipItem 的 - # FN_check_item_sex 之前):两个都失败 → 返回 LIMIT_LEVEL - var both := _weapon({"anti_flags": EquipRules.ANTIFLAG_FEMALE, - "limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]}) - _ck(EquipRules.can_equip(both, -1, {"race": 1, "level": 1})["code"] == "LIMIT_LEVEL", - "limit checked before sex antiflag (server order)") - # JOB 仍是最先的 antiflag 检查 - var job_first := _weapon({"anti_flags": EquipRules.ANTIFLAG_WARRIOR, - "limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]}) - _ck(EquipRules.can_equip(job_first, -1, {"race": 0, "level": 1})["code"] == "ANTI_JOB", - "job antiflag checked before limit") - - # 显式槽不匹配(客户端防御层)在 limit 之后 - var wl := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]}) - _ck(EquipRules.can_equip(wl, EquipRules.WEAR_BODY, {"race": 0, "level": 99})["code"] == "NOT_WEARABLE_HERE", - "explicit slot mismatch -> NOT_WEARABLE_HERE") - - # 新装备槽:ring / belt / costume 按 type 判定,无 wear_flags 也能穿 - _ck(EquipRules.can_equip({"type": 33}, EquipRules.WEAR_RING1, {"race": 0})["ok"], "ring at RING1 ok") - _ck(EquipRules.can_equip({"type": 34}, EquipRules.WEAR_BELT, {"race": 0})["ok"], "belt at BELT ok") - _ck(EquipRules.can_equip({"type": 28, "sub_type": 0}, -1, {"race": 0})["ok"], "costume body default slot ok") - _ck(EquipRules.can_equip({"type": 28}, EquipRules.WEAR_BELT, {"race": 0})["code"] == "NOT_WEARABLE_HERE", - "costume at BELT rejected") - - # 空物品 - _ck(EquipRules.can_equip({}, -1, {})["code"] == "NO_ITEM", "empty item -> NO_ITEM") - -func _test_find_equip_cell() -> void: - # item.cpp:472 的 else-if 优先级与 RING/UNIQUE 占用翻转。 - _ck(EquipRules.find_equip_cell(_weapon()) == EquipRules.WEAR_WEAPON, "weapon cell") - _ck(EquipRules.find_equip_cell(_weapon({"wear_flags": (1 << 0) | (1 << 4)})) == EquipRules.WEAR_BODY, - "BODY wins over WEAPON in else-if chain") - _ck(EquipRules.find_equip_cell({"type": 33}) == EquipRules.WEAR_RING1, "ring -> RING1 when free") - _ck(EquipRules.find_equip_cell({"type": 33}, {EquipRules.WEAR_RING1: 1234}) == EquipRules.WEAR_RING2, - "ring -> RING2 when RING1 occupied") - var uniq := {"type": 2, "wear_flags": 1 << 7, "vnum": 70001} - _ck(EquipRules.find_equip_cell(uniq) == EquipRules.WEAR_UNIQUE1, "unique -> UNIQUE1 when free") - _ck(EquipRules.find_equip_cell(uniq, {EquipRules.WEAR_UNIQUE1: 70002}) == EquipRules.WEAR_UNIQUE2, - "unique -> UNIQUE2 when UNIQUE1 occupied") - # 无 wear_flag 且非 COSTUME/DS/RING/BELT → -1(TOTEM 同) - _ck(EquipRules.find_equip_cell({"type": 3}) == -1, "no wear flag -> -1") - _ck(EquipRules.find_equip_cell({"type": 26, "wear_flags": 1 << 0}) == -1, "totem -> -1") - # 龙魂石走客户端专用路径 - _ck(EquipRules.find_equip_cell({"type": 29, "sub_type": 3}) == -1, "DS -> -1 (dedicated path)") - -func _test_post_checks() -> void: - # EquipItem 后置门(char_item.cpp:6122+)。 - # 变身中除 ARROW 外一律拒绝 - var bow := _weapon({"wear_flags": 1 << 9}) - _ck(EquipRules.can_equip(_weapon(), -1, {"race": 0, "polymorphed": true})["code"] == "POLYMORPH", - "polymorphed blocks weapon") - _ck(EquipRules.can_equip(bow, -1, {"race": 0, "polymorphed": true})["ok"], - "polymorphed allows ARROW") - # 燕尾服骑乘禁止(vnum 11901..11904) - var tux := {"type": 2, "sub_type": 0, "wear_flags": 1 << 0, "vnum": 11901} - _ck(EquipRules.can_equip(tux, -1, {"race": 0, "riding": true})["code"] == "RIDE_TUXEDO", - "tuxedo blocked while riding") - _ck(EquipRules.can_equip(tux, -1, {"race": 0, "riding": false})["ok"], "tuxedo ok on foot") - # 骑乘物品已骑乘禁止(UNIQUE + RIDE/MOUNT_RIDE 子类) - var horse := {"type": 16, "sub_type": 2, "wear_flags": 1 << 7, "vnum": 50051} - _ck(EquipRules.can_equip(horse, -1, {"race": 0, "riding": true})["code"] == "RIDE_ITEM", - "ride item blocked while riding") - _ck(EquipRules.can_equip(horse, -1, {"race": 0, "riding": false})["ok"], "ride item ok on foot") - # UNIQUE 同 vnum 去重 - var u := {"type": 2, "wear_flags": 1 << 7, "vnum": 70001} - _ck(EquipRules.can_equip(u, -1, {"race": 0, "worn": {EquipRules.WEAR_UNIQUE1: 70001}})["code"] == "UNIQUE_DUP", - "same vnum in UNIQUE1 -> UNIQUE_DUP") - _ck(EquipRules.can_equip(u, -1, {"race": 0, "worn": {EquipRules.WEAR_UNIQUE1: 70002}})["ok"], - "different vnum in UNIQUE1 ok") diff --git a/project/equip_rules_test.gd.uid b/project/equip_rules_test.gd.uid deleted file mode 100644 index 92ac5e42..00000000 --- a/project/equip_rules_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dl4x22ievekvx diff --git a/project/eterngrn_polish_test.gd b/project/eterngrn_polish_test.gd deleted file mode 100644 index 27161e86..00000000 --- a/project/eterngrn_polish_test.gd +++ /dev/null @@ -1,146 +0,0 @@ -# eterngrn_polish_test —— EterGrnLib 对齐打磨(项 3/5/6)的 headless 自检。 -# godot --headless --path project --script eterngrn_polish_test.gd -# 项 1/2/4(材质 flag / LOD 淡入 / ease 曲线)是 C++ 内部 + 渲染,离线看不出,走目视。 -extends SceneTree - -const CharShadow = preload("res://ui/char_shadow.gd") -const PlayerCtl = preload("res://player_controller.gd") - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: eterngrn_polish_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # ---- 5. CharShadow:Decal + 强制 cast_shadow ---- - var root := Node3D.new() - get_root().add_child(root) - var mi := MeshInstance3D.new() - mi.mesh = BoxMesh.new() - mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF # 模拟被 world OFF 波及 - root.add_child(mi) - CharShadow.attach(root, 1.4) - _ck(mi.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_ON, - "CharShadow 把 mesh cast_shadow 拉回 ON") - var blob := root.get_node_or_null("BlobShadow") - _ck(blob != null and blob is Decal, "CharShadow 加了 BlobShadow Decal") - _ck(blob != null and blob.texture_albedo != null, "Decal 有径向渐变贴图") - _ck(blob != null and absf(blob.size.x - 1.4) < 0.01, "Decal 足印尺寸 = footprint") - CharShadow.attach(root, 1.4) # 幂等 - var n_blob := 0 - for c in root.get_children(): - if c.name == "BlobShadow": n_blob += 1 - _ck(n_blob == 1, "CharShadow.attach 幂等,不重复加 Decal") - - # ---- 6. player_controller._ray_pick_t:射线 vs 网格世界 AABB ---- - var pc = PlayerCtl.new() - get_root().add_child(pc) - var ent := Node3D.new() - ent.position = Vector3(0, 0, -10) - get_root().add_child(ent) - var emi := MeshInstance3D.new() - var bm := BoxMesh.new() - bm.size = Vector3(2, 2, 2) - emi.mesh = bm - emi.position = Vector3(0, 1, 0) # 盒中心 (0,1,-10) - ent.add_child(emi) - await process_frame - # 从原点朝 -Z 打,正中盒子 - var t_hit: float = pc._ray_pick_t(Vector3.ZERO, Vector3(0, 0, -1), ent) - _ck(t_hit > 8.0 and t_hit < 10.0, "_ray_pick_t 命中:进入 t≈9 (got %.2f)" % t_hit) - # 朝 +X 打,不该命中 - var t_miss: float = pc._ray_pick_t(Vector3.ZERO, Vector3(1, 0, 0), ent) - _ck(t_miss < 0.0, "_ray_pick_t 未命中返回 -1 (got %.2f)" % t_miss) - # 擦边未命中(偏 5m) - var t_edge: float = pc._ray_pick_t(Vector3(5, 1, 0), Vector3(0, 0, -1), ent) - _ck(t_edge < 0.0, "_ray_pick_t 偏 5m 不命中") - # 无网格 → 回退胶囊近似(PICK_RADIUS 内命中) - var ent2 := Node3D.new() - ent2.position = Vector3(0, 0, -6) - get_root().add_child(ent2) - var t_cap: float = pc._ray_pick_t(Vector3.ZERO, Vector3(0, 0, -1), ent2) - _ck(t_cap > 0.0, "_ray_pick_t 无网格 → 胶囊近似仍能命中 (got %.2f)" % t_cap) - - # ---- 3. game_scene._on_local_motion_event 分派 ---- - var GS = load("res://game_scene.gd") - var gs = GS.new() - get_root().add_child(gs) - # 桩:cam.shake / fx.spawn_at / _audio.play_at / ui - var calls := {"shake": 0, "spawn": [], "sound": []} - var fake_cam := Camera3D.new() - fake_cam.set_script(_inline_stub(""" -extends Camera3D -var log -func shake(s, d=8.0): log["shake"] += 1 -""")) - fake_cam.set("log", calls) - var fake_fx = RefCounted.new() - fake_fx.set_script(_inline_stub(""" -extends RefCounted -var log -func spawn_at(n, p, gp, o=true): log["spawn"].append(n); return null -""")) - fake_fx.set("log", calls) - var fake_audio := Node.new() - fake_audio.set_script(_inline_stub(""" -extends Node -var log -func set_listener(pos, forward=Vector3(0, 0, -1), up=Vector3.UP): pass -func play_character_sound_3d(x, y, z, rel, check_frequency=false): log["sound"].append(rel); return 1 -func play_at(rel, pos, parent=null): log["sound"].append(rel) -""")) - fake_audio.set("log", calls) - gs.cam = fake_cam - gs.fx = fake_fx - gs._audio = fake_audio - gs.player = Node3D.new() - gs._mount = Node3D.new() - var fake_client := Node.new() - fake_client.set_script(_inline_stub(""" -extends Node -var shots := [] -func shoot(skill): shots.append(skill); return true -""")) - get_root().add_child(fake_client) - gs.client = fake_client - get_root().add_child(gs._mount) - gs.ui = CanvasLayer.new() - get_root().add_child(gs.ui) - gs.hud = Node.new() - - gs._on_local_motion_event(5, "", "sfx/hit.wav", Vector3.ZERO) - _ck(calls["sound"] == ["sfx/hit.wav"], "motion_event: sound → play_at") - gs._on_local_motion_event(1, "effect/sword_trail.mse", "", Vector3(0, 100, 0)) - _ck(calls["spawn"] == ["sword_trail"], "motion_event: effect → fx.spawn_at(basename)") - gs._on_local_motion_event(2, "", "", Vector3.ZERO) # SCREEN_WAVING - _ck(calls["shake"] == 1, "motion_event: type 2 → cam.shake") - gs._on_local_motion_event(3, "", "", Vector3.ZERO) # SCREEN_FLASHING - await process_frame - _ck(gs.ui.get_node_or_null("ColorRect") != null or gs._flash_rect != null, - "motion_event: type 3 → 屏闪 ColorRect") - gs._queue_shot(77) - gs._queue_shot(78) - gs._on_local_motion_event(6, "", "", Vector3.ZERO) - _ck(fake_client.shots == [77], "motion_event: FLY → FIFO pop 一发 CG_SHOOT") - gs._on_local_motion_event(6, "", "", Vector3.ZERO) - _ck(fake_client.shots == [77, 78], "motion_event: FLY 连射按序 pop(AutoClear)") - gs._on_local_motion_event(6, "", "", Vector3.ZERO) - _ck(fake_client.shots == [77, 78], "motion_event: FLY 队列空 → 不再发") - - gs.queue_free() - -func _inline_stub(src: String) -> GDScript: - var s := GDScript.new() - s.source_code = src - s.reload() - return s diff --git a/project/eterngrn_polish_test.gd.uid b/project/eterngrn_polish_test.gd.uid deleted file mode 100644 index 517506c6..00000000 --- a/project/eterngrn_polish_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://4h1l6dtrxggf diff --git a/project/fishing_water_test.gd b/project/fishing_water_test.gd deleted file mode 100644 index f42bca47..00000000 --- a/project/fishing_water_test.gd +++ /dev/null @@ -1,60 +0,0 @@ -# fishing_water_test —— ClientVS22 GetFishingRot / ATTRIBUTE_WATER 回归。 -# godot --headless --path project --script fishing_water_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 world: Node = ClassDB.instantiate("Metin2World") - world.set("auto_load", false) - world.set("assets_root", AssetRoot.path()) - world.set("map_path", "OutdoorA1/metin2_map_a1") - world.set("focus_tile", Vector2i(0, 0)) - # A1(4x5) 的水面不保证位于 focus_tile=(0,0),加载完整地图后 - # 自动寻找 ATTRIBUTE_WATER 样本,测试不绑定某个无水区块。 - world.set("load_radius_tiles", -1) - world.set("splat_enabled", false) - world.set("objects_enabled", false) - world.set("env_enabled", false) - world.set("water_enabled", true) - get_root().add_child(world) - _ck(bool(world.call("load_map")), "A1 map loads for water probe") - - var water := Vector3(-1.0, 0.0, -1.0) - for z in range(5 * 256): - for x in range(4 * 256): - if (int(world.call("sample_attribute", float(x) + 0.5, float(z) + 0.5)) & 2) != 0: - water = Vector3(float(x) + 0.5, 0.0, float(z) + 0.5) - break - if water.x >= 0.0: - break - _ck(water.x >= 0.0, "A1 has an ATTRIBUTE_WATER sample in loaded chunk") - if water.x >= 0.0: - # The POC MapCoord frame maps heading 270 to world +Z. Put the - # player 600cm behind the sample and require the zero-offset probe - # to resolve a valid fishing heading. - var player := Vector3(water.x, 0.0, water.z - 6.0) - var resolved := float(world.call("get_fishing_rotation", player.x, player.z, 270.0)) - _ck(resolved >= 0.0 and resolved < 360.0, - "GetFishingRot resolves a heading over water, got %f" % resolved) - var can := bool(world.call("can_fishing_position", player.x, player.z, 270.0)) - _ck(can, "can_fishing_position agrees with GetFishingRot") - var outside := float(world.call("get_fishing_rotation", -50.0, -50.0, 0.0)) - _ck(outside < 0.0, "map-outside water probe rejects") - world.queue_free() - await process_frame - if _fail == 0: - print("PASS: fishing_water_test (GetFishingRot + ATTRIBUTE_WATER)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/fishing_water_test.gd.uid b/project/fishing_water_test.gd.uid deleted file mode 100644 index de8739b4..00000000 --- a/project/fishing_water_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://faq2wm5101df diff --git a/project/fly_attachment_effect_test.gd b/project/fly_attachment_effect_test.gd deleted file mode 100644 index d272f24c..00000000 --- a/project/fly_attachment_effect_test.gd +++ /dev/null @@ -1,90 +0,0 @@ -# fly_attachment_effect_test —— 40250 BuildAttachInstance/UpdateAttachInstance/ -# __Bomb 的资源消费和 FlyTrace 回归。 -extends SceneTree - -const AssetRoot = preload("res://asset_root.gd") -const EffectRegistry = preload("res://fx/effect_registry.gd") -const FlyObject = preload("res://fly_object.gd") - -var _failures := 0 - -func _ck(condition: bool, message: String) -> void: - if condition: - return - _failures += 1 - printerr("FAIL: ", message) - -func _init() -> void: - await _run() - quit(1 if _failures else 0) - -func _run() -> void: - await process_frame - var assets := AssetRoot.path() - var registry = EffectRegistry.new() - registry.setup(assets) - var spawned_effects: Array[String] = [] - registry.fx_spawned.connect(func(name: String, _id: int, _lifetime: int): spawned_effects.append(name)) - - var mount := Node3D.new() - get_root().add_child(mount) - var geometry_probe = FlyObject.FlyInstance.new() - geometry_probe.pos = Vector3.ZERO - geometry_probe.vel = Vector3(0.0, -1.0, 0.0) - var multi_line := {"fly_type": 2, "distance": 1.0, "roll": 0.0} - _ck(is_equal_approx(geometry_probe._attachment_position(multi_line).z, -1.0), - "AttachData MULTI_LINE keeps 40250's negative local Z offset") - var manager := FlyObject.new() - manager.manual_step = true - mount.add_child(manager) - manager.setup(mount, null, assets) - manager.set_effect_registry(registry) - - var target := Node3D.new() - target.position = Vector3(5.0, 0.0, 0.0) - mount.add_child(target) - await process_frame - - var data = FlyObject.FlyData.load_msf( - "d:/ymir work/pc/assassin/effect/arrow_01_fast.msf", assets) - _ck(data != null, "arrow_01_fast.msf loads") - if data != null: - _ck(data.attach_data.size() == 1, "arrow resource keeps one AttachData group") - var inst = manager.spawn(Vector3.ZERO, target, false, data) - _ck(inst.attachments.size() == 1, "BuildAttachInstance creates one attachment") - if inst.attachments.size() == 1: - var attachment: Dictionary = inst.attachments[0] - _ck(is_instance_valid(attachment.get("effect", null)), - "AttachFile is instantiated through EffectRegistry") - _ck(is_instance_valid(attachment.get("tail", null)), - "TailFlag creates a FlyTrace mesh") - _ck(manager._visuals.get(inst, null) == null, - "resource-driven projectile does not duplicate a generic SphereMesh") - manager.step(0.01) - if inst.attachments.size() == 1: - var trail_history: Array = inst.attachments[0].get("history", []) - _ck(trail_history.size() >= 2, "UpdateAttachInstance records moving tail positions") - - # Arrow reaches the target and must use BombEffect instead of only the - # generic placeholder flash when EffectRegistry has the source resource. - for _i in 20: - if manager.active_count() == 0: - break - manager.step(0.05) - var found_bomb := false - for effect_name in spawned_effects: - if String(effect_name).get_file().get_basename() == "blow_1_low_center": - found_bomb = true - _ck(found_bomb, "__Bomb consumes arrow BombEffect resource") - _ck(manager._flash_nodes.is_empty(), - "resource BombEffect does not receive a duplicate pre-hit flash") - - manager.clear_for_map_change() - _ck(manager.active_count() == 0 and manager._effect_nodes.is_empty(), - "map reset releases resource-driven bomb effects") - manager.free() - target.free() - mount.free() - await process_frame - if _failures == 0: - print("PASS: fly_attachment_effect_test") diff --git a/project/fly_attachment_effect_test.gd.uid b/project/fly_attachment_effect_test.gd.uid deleted file mode 100644 index d6975c26..00000000 --- a/project/fly_attachment_effect_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://6y7or8txgkda diff --git a/project/fly_data_msf_test.gd b/project/fly_data_msf_test.gd deleted file mode 100644 index bba9282e..00000000 --- a/project/fly_data_msf_test.gd +++ /dev/null @@ -1,63 +0,0 @@ -# fly_data_msf_test —— CFlyingData::LoadScriptFile parity. -extends SceneTree - -const AssetRoot = preload("res://asset_root.gd") -const FlyObject = preload("res://fly_object.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 root := AssetRoot.path() - var geom = FlyObject.FlyData.load_msf( - root.path_join("PC/ymir work/pc/warrior/effect/geompung.msf"), root) - check(geom != null, "geompung.msf loads") - if geom != null: - check(is_equal_approx(geom.init_vel, 20.0), "InitialVelocity converts cm/s to m/s") - check(is_equal_approx(geom.flat_range, 1000.0), "Range converts cm to m") - check(is_equal_approx(geom.bomb_range, 0.1), "BombRange converts cm to m") - check(geom.is_homing and is_equal_approx(geom.homing_max_angle, 3.0), - "HomingFlag/HomingMaxAngle match reference") - check(geom.accel.is_equal_approx(Vector3(0.0, -30.0, 0.0)), - "Acceleration converts cm/s² to m/s²") - check(geom.bomb_effect.get_file().get_basename() == "geompung_blow", - "BombEffect resolves relative to the .msf directory") - check(geom.attach_data.size() == 1, "AttachData group is retained") - if geom.attach_data.size() == 1: - var attach: Dictionary = geom.attach_data[0] - check(int(attach.get("type", -1)) == 1 and int(attach.get("fly_type", -1)) == 1, - "AttachData type/fly type match reference") - check(bool(attach.get("has_tail", false)) and - is_equal_approx(float(attach.get("tail_length", 0.0)), 0.15) - and is_equal_approx(float(attach.get("tail_size", 0.0)), 0.07), - "AttachData tail time stays seconds and size converts cm to metres") - - var exp = FlyObject.FlyData.load_msf( - root.path_join("Effect/ymir work/effect/etc/gathering/ga_piece_yellow_small2.msf"), root) - check(exp != null, "indexed EXP resource loads") - if exp != null: - check(exp.spreading and is_equal_approx(exp.init_vel, -1.0) and - is_equal_approx(exp.cone_angle, 90.0), - "EXP resource keeps spreading, velocity and cone") - check(exp.angular_velocity.is_equal_approx(Vector3(0.0, 450.0, 0.0)), - "AngularVelocity remains degrees per second") - - var arrow = FlyObject.FlyData.load_msf( - root.path_join("PC/ymir work/pc/assassin/effect/arrow_01.msf"), root) - check(arrow != null and is_equal_approx(arrow.init_vel, 40.0) and - is_equal_approx(arrow.flat_range, 25.0) and is_equal_approx(arrow.bomb_range, 0.2), - "arrow resource keeps authoritative speed/range/hit radius") - - check(FlyObject.FLY_NONE == 0 and FlyObject.FLY_EXP == 1 and - FlyObject.FLY_HP_SMALL == 15 and FlyObject.FLY_SKILL_MUYEONG == 16, - "indexed fly enum matches PythonEffectModule") - if failures == 0: - print("PASS: fly_data_msf_test") - quit(1 if failures else 0) diff --git a/project/fly_data_msf_test.gd.uid b/project/fly_data_msf_test.gd.uid deleted file mode 100644 index 52a3bea1..00000000 --- a/project/fly_data_msf_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://buf6fihr6be3v diff --git a/project/fly_dynamic_collision_test.gd b/project/fly_dynamic_collision_test.gd deleted file mode 100644 index 8fd064f6..00000000 --- a/project/fly_dynamic_collision_test.gd +++ /dev/null @@ -1,116 +0,0 @@ -# fly_dynamic_collision_test —— 40250 FlyingInstance 动态 Actor 碰撞回归。 -# -# 参考 FlyingInstance.cpp::Update: -# collision sphere = max(move_distance * 2, configured radius) -# hitonanothermonster -> HittedObjectSet 去重 -> OnExplodingAtAnotherTarget -# pierce 后继续飞行,耗尽才爆炸。 -extends SceneTree - -const FlyObject = preload("res://fly_object.gd") - -var _failures := 0 - -func _ck(condition: bool, message: String) -> void: - if condition: - return - _failures += 1 - printerr("FAIL: ", message) - -func _init() -> void: - await _run() - quit(1 if _failures else 0) - -func _run() -> void: - await process_frame - _test_two_actors_and_pierce() - _test_same_actor_is_deduplicated() - if _failures == 0: - print("PASS: fly_dynamic_collision_test") - -func _new_manager() -> Array: - var mount := Node3D.new() - get_root().add_child(mount) - var manager := FlyObject.new() - manager.manual_step = true - mount.add_child(manager) - manager.setup(mount) - return [manager, mount] - -func _test_two_actors_and_pierce() -> void: - var fixture := _new_manager() - var manager: FlyObject = fixture[0] - var mount: Node3D = fixture[1] - var first := Node3D.new() - first.position = Vector3(0.0, 1.0, 5.0) - get_root().add_child(first) - var second := Node3D.new() - second.position = Vector3(0.0, 1.0, 10.0) - get_root().add_child(second) - manager.set_actor_provider(func() -> Array: - return [ - {"vid": 200, "node": first, "center": first.position, "radius": 0.35}, - {"vid": 300, "node": second, "center": second.position, "radius": 0.35}, - ] - ) - - var events: Array[int] = [] - var data := FlyObject.FlyData.new() - data.init_vel = 10.0 - data.flat_range = 30.0 - data.bomb_range = 0.05 - data.collision_sphere_radius = 0.05 - data.hit_on_another_monster = true - data.pierce_count = 1 - var inst = manager.spawn(Vector3(0.0, 1.0, 0.0), Vector3(0.0, 1.0, 30.0), false, data) - inst.handler = func(event: String, _pos: Vector3, vid: int) -> void: - if event == "at_another": - events.append(vid) - - for _i in 100: - if manager.active_count() == 0: - break - manager.step(0.1) - _ck(events == [200, 300], "pierce projectile hits first and second Actor exactly once") - _ck(manager.active_count() == 0, "non-piercing second Actor hit removes projectile") - - manager.clear_for_map_change() - first.free() - second.free() - manager.free() - mount.free() - -func _test_same_actor_is_deduplicated() -> void: - var fixture := _new_manager() - var manager: FlyObject = fixture[0] - var mount: Node3D = fixture[1] - var target := Node3D.new() - target.position = Vector3(0.0, 1.0, 5.0) - get_root().add_child(target) - manager.set_actor_provider(func() -> Array: - return [{"vid": 400, "node": target, "center": target.position, "radius": 0.35}] - ) - - var events: Array[int] = [] - var data := FlyObject.FlyData.new() - data.init_vel = 10.0 - data.flat_range = 12.0 - data.bomb_range = 0.05 - data.collision_sphere_radius = 0.05 - data.hit_on_another_monster = true - data.pierce_count = 2 - var inst = manager.spawn(Vector3(0.0, 1.0, 0.0), Vector3(0.0, 1.0, 30.0), false, data) - inst.handler = func(event: String, _pos: Vector3, vid: int) -> void: - if event == "at_another": - events.append(vid) - - for _i in 100: - if manager.active_count() == 0: - break - manager.step(0.1) - _ck(events == [400], "same dynamic Actor is blocked by HittedObjectSet") - _ck(manager.active_count() == 0, "deduplicated projectile eventually expires") - - manager.clear_for_map_change() - target.free() - manager.free() - mount.free() diff --git a/project/fly_dynamic_collision_test.gd.uid b/project/fly_dynamic_collision_test.gd.uid deleted file mode 100644 index 0038da2d..00000000 --- a/project/fly_dynamic_collision_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d2xauph6vppp4 diff --git a/project/fly_object.gd b/project/fly_object.gd deleted file mode 100644 index 94ead2dc..00000000 --- a/project/fly_object.gd +++ /dev/null @@ -1,847 +0,0 @@ -# fly_object.gd —— 1:1 复刻 GameLib 的飞行物实体系统(CFlyingManager / CFlyingInstance / -# CFlyingData),把原客户端「.msa FLY 帧 → OnShoot → CG_SHOOT」之后、由服务端 GC_CREATE_FLY -# 驱动的那颗真实弹道 + 命中判定搬到 POC。net_world.gd 收到 client.fly_cue 后调 -# FlyManager.spawn(),每帧 step() 推进运动学并做「线段到点」的爆炸判定,命中 / 超程 / 触地 -# 时回调 handler(对齐 IFlyEventHandler)。§3.6 -# -# 参考: -# REF/GameLib/FlyingObjectManager.cpp CFlyingManager::CreateFlyingInstanceFlyTarget / -# Update()(遍历实例,Update() 返 false 即 Delete + erase)/ CreateIndexedFly(NORMAL / -# FIRE_CRACKER / AUTO_FIRE 三种服务端索引飞行) -# REF/GameLib/FlyingInstance.cpp CFlyingInstance::Create → __SetDataPointer(m_v3Position=起点、 -# m_bAlive、m_fStartTime、由 m_fRollAngle-90 yaw + m_fConeAngle roll 建四元数、可选 -# m_bSpreading 随机扩散、m_v3Velocity=m_v3LocalVelocity=(0,-m_fInitVel,0)、m_v3Accel= -# pData->m_v3Accel、m_fRemainRange=m_fRange、m_iPierceCount)+ __SetTargetDirection -# (v3TargetPos=target.GetFlyTargetPosition();m_bMaintainParallel 时 z+=50;方向= -# normalize(target-pos),把局部速度 / 加速度旋到该方向) -# REF/GameLib/FlyingInstance.cpp CFlyingInstance::Update():homing → AdjustDirectionForHoming; -# v3LastPosition=m_v3Position;m_v3Velocity+=m_v3Accel*dt;m_v3Velocity.z+=m_fGravity*dt; -# v3Movement=m_v3Velocity*dt;_fMoveDistance=|v3Movement|;m_fRemainRange-=_fMoveDistance; -# m_v3Position+=v3Movement;m_fRemainRange<0 → OnExplodingOutOfRange + __Explode(false); -# 对象目标且未命中 && square_distance_between_linesegment_and_point(pos,last,targetPos) < -# m_fBombRange² → m_bTargetHitted=TRUE;m_canAttack 时 pVictim->OnShootDamage(); -# handler->OnExplodingAtTarget(skill);m_iPierceCount>0 → --、__Bomb();否则 __Explode(); -# 位置目标同样线段判定 → __Explode();m_bHitOnBackground → GetTerrainHeight(x,-y)>z → 触地 -# REF/GameLib/FlyingInstance.cpp __Explode(bBomb=true):!m_bAlive return;m_bAlive=false; -# bBomb → __Bomb()(在 m_v3Position 生成 m_dwBombEffectID 特效) -# REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point(点到线段的平方距离, -# d<=0 取端点 p1、d>=l 取端点 p2、否则叉积模方 / l) -# REF/GameLib/FlyingData.cpp CFlyingData::__Initialize()(本文件 FlyData 默认值逐项对齐) -# REF/GameLib/FlyHandler.h IFlyEventHandler:OnExplodingOutOfRange / OnExplodingAtBackground / -# OnExplodingAtAnotherTarget(skill,vid) / OnExplodingAtTarget(skill) -extends Node - -const AssetRoot = preload("res://asset_root.gd") -const FlyTargetAnchor = preload("res://fx/target_effect_anchor.gd") - -# CFlyingManager::EIndexFlyType(REF/GameLib/FlyingObjectManager.h) -const INDEX_FLY_TYPE_NORMAL := 0 -const INDEX_FLY_TYPE_FIRE_CRACKER := 1 -const INDEX_FLY_TYPE_AUTO_FIRE := 2 - -# 40250 PythonEffectModule.cpp / playersettingmodule.py / char.h 索引飞行物类型。 -# FLY_NONE 占用 0;经验球从 1 开始,不能把 0 当成 FLY_EXP。 -const FLY_NONE := 0 -const FLY_EXP := 1 -const FLY_HP_MEDIUM := 2 -const FLY_HP_BIG := 3 -const FLY_SP_SMALL := 4 -const FLY_SP_MEDIUM := 5 -const FLY_SP_BIG := 6 -const FLY_FIREWORK1 := 7 -const FLY_FIREWORK2 := 8 -const FLY_FIREWORK3 := 9 -const FLY_FIREWORK4 := 10 -const FLY_FIREWORK5 := 11 -const FLY_FIREWORK6 := 12 -const FLY_FIREWORK_XMAS := 13 -const FLY_CHAIN_LIGHTNING := 14 -const FLY_HP_SMALL := 15 -const FLY_SKILL_MUYEONG := 16 - -# CFlyingData —— .fly 脚本字段。原版文件使用 pixel==cm;加载器在边界处 -# 统一换算为 Godot 米制,因此 init_vel / range / bomb_range / acceleration 等 -# 长度量都按 0.01 转换,角度和时间保持原单位。 -class FlyData extends RefCounted: - var resource_path := "" - var init_vel := 2.0 # m_fInitVel=200(cm/s)→ 2 m/s - var cone_angle := 0.0 # m_fConeAngle - var roll_angle := 0.0 # m_fRollAngle - var gravity := 0.0 # m_fGravity(原版加到速度 .z(=up),本实现映射到 -Y = 向下) - var accel := Vector3.ZERO # m_v3Accel - var angular_velocity := Vector3.ZERO # m_v3AngVel,度/秒 - var flat_range := 5.0 # m_fRange=500(cm)→ 5 m - var bomb_range := 0.1 # m_fBombRange=10(cm)→ 0.1 m - var collision_sphere_radius := 0.0 # m_fCollisionSphereRadius,米 - var pierce_count := 0 # m_iPierceCount - var spreading := false # m_bSpreading - var maintain_parallel := false # m_bMaintainParallel(对象目标点 y 抬 0.5 m ≈ 原版 +50cm) - var is_homing := false # m_bIsHoming - var homing_start_time := 0.0 # m_fHomingStartTime - var homing_max_angle := 0.0 # m_fHomingMaxAngle(度 / 每次修正) - var hit_on_background := false # m_bHitOnBackground - var hit_on_another_monster := false # m_bHitOnAnotherMonster - var bomb_effect := "" # m_strBombEffectName - var attach_data: Array[Dictionary] = [] # CFlyingData::m_AttachDataVector - - static func load_msf(path: String, assets_root: String = ""): - var resolved := _resolve_msf_path(path, assets_root) - if resolved == "": - return null - var file := FileAccess.open(resolved, FileAccess.READ) - if file == null: - return null - var data := FlyData.new() - data.resource_path = resolved - var saw_initial := false - var saw_range := false - var in_attach := false - var attach: Dictionary = {} - while not file.eof_reached(): - var line := file.get_line().strip_edges() - if line == "" or line.begins_with("#") or line.begins_with("//"): - continue - if in_attach and line == "}": - data.attach_data.append(attach) - in_attach = false - attach = {} - continue - if line.to_lower().begins_with("group attachdata"): - in_attach = true - attach = {} - continue - var pair := _split_assignment(line) - if pair.is_empty(): - continue - var key: String = String(pair[0]).to_lower() - var value: String = String(pair[1]).strip_edges() - if in_attach: - _match_attach_value(attach, key, value, resolved, assets_root) - continue - if line == "{" or line == "}": - continue - match key: - "spreadingflag": data.spreading = _as_int(value) != 0 - "maintainparallelflag": data.maintain_parallel = _as_int(value) != 0 - "initialvelocity": - data.init_vel = _as_float(value) * 0.01 - saw_initial = true - "coneangle": data.cone_angle = _as_float(value) - "rollangle": data.roll_angle = _as_float(value) - "angularvelocity": data.angular_velocity = _as_vec3(value) - "gravity": data.gravity = _as_float(value) * 0.01 - "hitonbackground": data.hit_on_background = _as_int(value) != 0 - "hitonanothermonster": data.hit_on_another_monster = _as_int(value) != 0 - "piercecount": data.pierce_count = _as_int(value) - "collisionsphereradius": data.collision_sphere_radius = _as_float(value) * 0.01 - "bombrange": data.bomb_range = _as_float(value) * 0.01 - "bombeffect": data.bomb_effect = _resolve_related_path(resolved, _unquote(value), assets_root) - "homingflag": data.is_homing = _as_int(value) != 0 - "homingstarttime": data.homing_start_time = _as_float(value) - "homingmaxangle": data.homing_max_angle = _as_float(value) - "range": - data.flat_range = _as_float(value) * 0.01 - saw_range = true - "acceleration": data.accel = _as_vec3(value) * 0.01 - if not saw_initial or not saw_range: - return null - return data - - static func _split_assignment(line: String) -> Array: - var normalized := line.replace("\t", " ").strip_edges() - var sep := normalized.find(" ") - if sep < 0: - return [] - return [normalized.substr(0, sep), normalized.substr(sep + 1).strip_edges()] - - static func _unquote(value: String) -> String: - var out := value.strip_edges() - if out.length() >= 2 and out.begins_with("\"") and out.ends_with("\""): - return out.substr(1, out.length() - 2) - return out - - static func _as_float(value: String) -> float: - return value.strip_edges().to_float() - - static func _as_int(value: String) -> int: - return value.strip_edges().to_int() - - static func _as_vec3(value: String) -> Vector3: - var parts := value.replace("\t", " ").split(" ", false) - if parts.size() < 3: - return Vector3.ZERO - return Vector3(parts[0].to_float(), parts[1].to_float(), parts[2].to_float()) - - static func _match_attach_value(attach: Dictionary, key: String, value: String, - resolved: String, assets_root: String) -> void: - match key: - "type": attach["type"] = _as_int(value) - "flytype": attach["fly_type"] = _as_int(value) - "attachfile": attach["file"] = _resolve_related_path(resolved, _unquote(value), assets_root) - "tailflag": attach["has_tail"] = _as_int(value) != 0 - "tailcolor": attach["tail_color"] = _unquote(value).trim_suffix("d").to_int() - # CFlyingData keeps TailLength as seconds; TailSize is a world - # length stored in centimetres and is the only one converted here. - "taillength": attach["tail_length"] = _as_float(value) - "tailsize": attach["tail_size"] = _as_float(value) * 0.01 - "tailshaperect": attach["rect_shape"] = _as_int(value) != 0 - "roll": attach["roll"] = _as_float(value) - "distance": attach["distance"] = _as_float(value) * 0.01 - "period": attach["period"] = _as_float(value) - "amplitude": attach["amplitude"] = _as_float(value) * 0.01 - - static func _resolve_related_path(source_file: String, value: String, assets_root: String) -> String: - if value == "": - return "" - var sibling := source_file.get_base_dir().path_join(value) - if FileAccess.file_exists(sibling): - return sibling - var resolved := _resolve_msf_path(value, assets_root) - return resolved if resolved != "" else value - - static func _resolve_msf_path(path: String, assets_root: String) -> String: - var root := assets_root - if root == "": - root = AssetRoot.path() - var source := path.replace("\\", "/") - if source.length() >= 2 and source[1] == ":": - source = source.substr(2).lstrip("/") - var candidates: Array[String] = [source] - var lower := source.to_lower() - if lower.begins_with("ymir work/"): - candidates.append("Effect/" + source) - candidates.append("PC/" + source) - var marker := lower.find("ymir work/") - if marker >= 0: - var tail := source.substr(marker) - candidates.append("Effect/" + tail) - candidates.append("PC/" + tail) - for rel in candidates: - var candidate := root.path_join(String(rel)) - if FileAccess.file_exists(candidate): - return candidate - return "" - -# 单颗飞行物 —— 对齐 CFlyingInstance。target 可为 Node3D(对象目标,跟随移动)、 -# Vector3(位置目标)、或 { "vid": int, "pos": Vector3 }。 -class FlyInstance extends RefCounted: - var id := 0 - var data: FlyData - var pos := Vector3.ZERO - var last_pos := Vector3.ZERO - var vel := Vector3.ZERO - var accel := Vector3.ZERO - var remain_range := 0.0 - var pierce := 0 - var alive := false - var can_attack := false - var skill_index := 0 - var target_vid := 0 - var target_hitted := false - var hitted_objects := {} # CFlyingInstance::m_HittedObjectSet - var _target_obj: Node3D - var _target_pos := Vector3.ZERO - var _is_object := false - var _elapsed := 0.0 - var _world: Object # 可空:有 sample_height 时做触地 - var _actor_provider := Callable() # func() -> Array[{vid,node,center,radius}] - var _owner_node: Node3D # FCheckAnotherMonsterDuringFlying 排除射手 - var _owner_vid := 0 - var attachments: Array = [] # TAttachEffectInstanceVector - var _attach_rotation := Quaternion.IDENTITY - # handler.call(event: String, world_pos: Vector3, vid: int) —— 对齐 IFlyEventHandler - var handler := Callable() - - func _node_fly_target_position(node: Node3D) -> Vector3: - if not is_instance_valid(node): - return Vector3.ZERO - if node.is_inside_tree(): - return FlyTargetAnchor.fly_target_position(node) - return node.position - - func target_position() -> Vector3: - if _is_object and is_instance_valid(_target_obj): - _target_pos = _node_fly_target_position(_target_obj) - var tp := _target_pos - if data and data.maintain_parallel: - tp.y += 0.5 - return tp - - func notify_target_despawn(node: Node3D) -> void: - if not _is_object or _target_obj != node: - return - # CFlyTarget::NotifyTargetClear changes TYPE_OBJECT to TYPE_POSITION - # while retaining the last cached target position. Read it once before - # dropping the node reference so delayed fade-out cannot move the target. - _target_pos = target_position() - _target_obj = null - _is_object = false - - # CFlyingInstance::Create → __SetDataPointer + __SetTargetDirection。 - func create(d: FlyData, start_world: Vector3, target, attack: bool) -> void: - data = d - can_attack = attack - pos = start_world - last_pos = start_world - alive = true - _elapsed = 0.0 - target_hitted = false - hitted_objects.clear() - pierce = d.pierce_count - remain_range = d.flat_range - if target is Node3D: - _is_object = true - _target_obj = target - _target_pos = _node_fly_target_position(target as Node3D) - elif target is Vector3: - _is_object = false - _target_pos = target - elif target is Dictionary: - target_vid = int(target.get("vid", 0)) - var tp: Variant = target.get("pos", null) - if tp is Vector3: - _target_pos = tp - var tn: Variant = target.get("node", null) - if tn is Node3D: - _is_object = true - _target_obj = tn - _target_pos = _node_fly_target_position(tn as Node3D) - # __SetTargetDirection:方向 = normalize(target - pos),局部速度 (0,-init_vel,0) - # 旋到该方向即 dir*init_vel;加速度同样旋到该方向。 - var dir := (target_position() - pos) - if dir.length() < 0.0001: - dir = Vector3(0, 0, -1) - dir = dir.normalized() - vel = dir * d.init_vel - accel = d.accel - if accel.length_squared() > 0.0: - # 局部帧 -Y → dir 的最小弧旋转(对齐 __SetTargetNormalizedDirection)。 - accel = Quaternion(Vector3(0, -1, 0), dir).normalized() * accel - - func build_attachments(effect_registry, visual_parent: Node3D) -> void: - attachments.clear() - _attach_rotation = Quaternion.IDENTITY - if data == null: - return - for attach_variant in data.attach_data: - if not attach_variant is Dictionary or int(attach_variant.get("type", 0)) != 1: - continue # CFlyingData::FLY_ATTACH_OBJECT is also not implemented in 40250. - var attach: Dictionary = attach_variant - var effect: Node3D = null - var effect_file := String(attach.get("file", "")) - if effect_registry != null and effect_file != "" \ - and effect_registry.has_method("spawn") and visual_parent != null: - effect = effect_registry.spawn(effect_file, visual_parent, false) - var tail: MeshInstance3D = null - if bool(attach.get("has_tail", false)) and visual_parent != null: - tail = _create_tail(visual_parent, attach) - attachments.append({"data": attach, "effect": effect, "tail": tail, "history": []}) - _update_attachments(0.0) - - func clear_attachments() -> void: - for item in attachments: - for key in ["effect", "tail"]: - var node: Variant = item.get(key, null) - if is_instance_valid(node): - node.free() - attachments.clear() - - func _create_tail(visual_parent: Node3D, attach: Dictionary) -> MeshInstance3D: - var tail := MeshInstance3D.new() - tail.name = "FlyTrace" - var mesh := ImmediateMesh.new() - tail.mesh = mesh - var material := StandardMaterial3D.new() - material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - material.vertex_color_use_as_albedo = true - material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED - tail.material_override = material - visual_parent.add_child(tail) - return tail - - func _fly_orientation() -> Quaternion: - var direction := vel.normalized() - if direction.length_squared() < 0.000001: - direction = Vector3(0.0, 0.0, -1.0) - return Quaternion(Vector3(0.0, -1.0, 0.0), direction).normalized() - - func _orientation(include_attach: bool = true) -> Quaternion: - var base := _fly_orientation() - return (_attach_rotation * base).normalized() if include_attach else base - - func _attachment_position(attach: Dictionary) -> Vector3: - var fly_type := int(attach.get("fly_type", 1)) - var roll := deg_to_rad(float(attach.get("roll", 0.0))) - var distance := float(attach.get("distance", 0.0)) - var amplitude := float(attach.get("amplitude", 0.0)) - var period := float(attach.get("period", 1.0)) - var offset_amount := distance - match fly_type: - 3: # FLY_ATTACH_TYPE_SINE - var angle := _elapsed * TAU / maxf(period, 0.0001) - offset_amount = amplitude * sin(angle) - 4: # FLY_ATTACH_TYPE_EXP - var angle := _elapsed / maxf(period, 0.0001) - offset_amount = amplitude * exp(-angle) * angle - 2: # FLY_ATTACH_TYPE_MULTI_LINE - pass - _: # FLY_ATTACH_TYPE_NONE / LINE - return pos - # EffectLib's source horizontal plane maps to Godot x/z; apply the - # current fly orientation after the local roll offset. - # 40250's source plane uses -cos(roll) on the local Z axis. Keeping - # this sign is important for MULTI_LINE/SINE/EXP trace placement. - var local := Vector3(-sin(roll) * offset_amount, 0.0, -cos(roll) * offset_amount) - return pos + (_orientation() * local) - - func _tail_color(value: int) -> Color: - return Color8((value >> 16) & 0xff, (value >> 8) & 0xff, - value & 0xff, (value >> 24) & 0xff) - - func _update_tail(tail: MeshInstance3D, history: Array, attach: Dictionary) -> void: - if tail == null or not is_instance_valid(tail) or history.size() < 2: - return - var mesh := tail.mesh as ImmediateMesh - if mesh == null: - return - mesh.clear_surfaces() - var material := tail.material_override as Material - mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLE_STRIP, material) - var length := maxf(float(attach.get("tail_length", 0.0)), 0.0001) - var width := maxf(float(attach.get("tail_size", 0.0)), 0.001) - var base_color := _tail_color(int(attach.get("tail_color", 0xffffffff))) - for i in range(history.size()): - var item: Dictionary = history[i] - var previous: Vector3 = history[maxi(i - 1, 0)].position - var next: Vector3 = history[mini(i + 1, history.size() - 1)].position - var tangent := (next - previous).normalized() - var side := tangent.cross(Vector3.UP) - if side.length_squared() < 0.000001: - side = tangent.cross(Vector3.RIGHT) - side = side.normalized() * width - var age := maxf(0.0, _elapsed - float(item.time)) - var alpha := clampf(1.0 - age / length, 0.0, 1.0) - mesh.surface_set_color(Color(base_color, base_color.a * alpha)) - mesh.surface_add_vertex(item.position - side) - mesh.surface_set_color(Color(base_color, base_color.a * alpha)) - mesh.surface_add_vertex(item.position + side) - mesh.surface_end() - - func _update_attachments(dt: float) -> void: - if data == null: - return - var delta_q := Quaternion(Vector3.UP, deg_to_rad(data.angular_velocity.y) * dt) - delta_q = delta_q * Quaternion(Vector3.RIGHT, deg_to_rad(data.angular_velocity.x) * dt) - delta_q = delta_q * Quaternion(Vector3.BACK, deg_to_rad(data.angular_velocity.z) * dt) - _attach_rotation = (_attach_rotation * delta_q).normalized() - for item_variant in attachments: - var item: Dictionary = item_variant - var attach: Dictionary = item.data - var at := _attachment_position(attach) - var effect: Node3D = item.get("effect", null) - if is_instance_valid(effect): - # UpdateAttachInstance uses m_qRot for LINE and - # m_qAttachRotation * m_qRot for the offset trace types. - var attach_orientation := _orientation( - int(attach.get("fly_type", 1)) != 1) - effect.global_transform = Transform3D(Basis(attach_orientation), at) - var history: Array = item.history - if bool(attach.get("has_tail", false)): - history.push_front({"time": _elapsed, "position": at}) - var tail_length := maxf(float(attach.get("tail_length", 0.0)), 0.0) - while not history.is_empty() and _elapsed - float(history.back().time) > tail_length: - history.pop_back() - _update_tail(item.get("tail", null), history, attach) - - # CFlyingInstance::AdjustDirectionForHoming —— 把速度朝目标方向转,单次夹在 - # homing_max_angle 度以内。 - func _adjust_homing() -> void: - var tdir := (target_position() - pos) - if tdir.length() < 0.0001: - return - tdir = tdir.normalized() - var vdir := vel.normalized() - var ang := vdir.angle_to(tdir) - if ang < 0.0001: - return - var maxr := deg_to_rad(data.homing_max_angle) if data.homing_max_angle < 180.0 else ang - var t := clampf(maxr / ang, 0.0, 1.0) - var speed := vel.length() - vel = vdir.slerp(tdir, t) * speed - - # CFlyingInstance::Update() —— 返 false 表示该销毁(超程 / 命中且不穿透 / 触地)。 - func update(dt: float) -> bool: - if not alive: - return false - _elapsed += dt - if data.is_homing and _elapsed > data.homing_start_time and _is_object: - _adjust_homing() - last_pos = pos - vel += accel * dt - vel.y += data.gravity * dt - var movement := vel * dt - var move_dist := movement.length() - remain_range -= move_dist - pos += movement - _update_attachments(dt) - - if remain_range < 0.0: - _emit("out_of_range") - _explode(false) - return false - - var br2 := data.bomb_range * data.bomb_range - if _is_object: - if not target_hitted: - if _seg_point_sq_dist(pos, last_pos, target_position()) < br2: - target_hitted = true - if can_attack: - _emit("shoot_damage") - _emit("at_target") - if pierce > 0: - pierce -= 1 - _emit("bomb") - else: - _explode() - return false - return true - else: - if _seg_point_sq_dist(pos, last_pos, target_position()) < br2: - _emit("at_position") - _explode() - return false - - # FlyingInstance.cpp checks another Actor after the primary target branch - # and before background collision. The sphere is expanded by twice this - # frame's movement distance, or the resource-configured radius, whichever - # is larger. HittedObjectSet is pointer identity in 40250; the provider's - # VID is only the observable callback value, so a new node with a reused - # VID is not accidentally treated as the old object. - if data.hit_on_another_monster and _actor_provider.is_valid(): - var collision_radius := maxf(move_dist * 2.0, data.collision_sphere_radius) - var another := _find_another_actor(collision_radius) - if not another.is_empty(): - var actor_node: Node3D = another.get("node", null) - var object_key := actor_node.get_instance_id() if is_instance_valid(actor_node) else int(another.get("vid", 0)) - if not hitted_objects.has(object_key): - hitted_objects[object_key] = true - var another_vid := int(another.get("vid", 0)) - _emit("at_another", pos, another_vid) - if pierce > 0: - pierce -= 1 - _emit("bomb") - else: - _explode() - return false - return true - - if data.hit_on_background: - if _world and _world.has_method("sample_height"): - var gh := float(_world.call("sample_height", pos.x, pos.z)) - if gh > pos.y: - _emit("at_background") - _explode() - return false - # 40250 CFlyingInstance::Update FCheckBackgroundDuringFlying: - # 空间 3D 建筑与静态障碍物碰撞(碰撞层 2: StaticBody3D) - if _world is Node3D and _world.is_inside_tree(): - var w3d: World3D = _world.get_world_3d() - if w3d and w3d.direct_space_state: - var q := PhysicsRayQueryParameters3D.create(last_pos, pos, 2) - var hit := w3d.direct_space_state.intersect_ray(q) - if not hit.is_empty(): - pos = hit.position - _emit("at_background") - _explode() - return false - return true - - func _explode(bomb := true) -> void: - if not alive: - return - alive = false - if bomb: - _emit("bomb") - - func _find_another_actor(projectile_radius: float) -> Dictionary: - var candidates: Variant = _actor_provider.call() - if not candidates is Array: - return {} - for candidate_variant in candidates: - if not candidate_variant is Dictionary: - continue - var candidate: Dictionary = candidate_variant - var node_variant: Variant = candidate.get("node", null) - if not node_variant is Node3D or not is_instance_valid(node_variant): - continue - var node: Node3D = node_variant - if node == _owner_node or int(candidate.get("vid", 0)) == _owner_vid: - continue - if bool(node.get_meta("dead", false)): - continue - var center_variant: Variant = candidate.get("center", null) - var center := _node_fly_target_position(node) - if center_variant is Vector3: - center = center_variant - var actor_radius := maxf(0.0, float(candidate.get("radius", 0.0))) - if _seg_point_sq_dist(pos, last_pos, center) < (projectile_radius + actor_radius) * (projectile_radius + actor_radius): - return candidate - return {} - - func _emit(ev: String, event_pos: Variant = null, event_vid: int = -1) -> void: - var world_pos := pos if event_pos == null else (event_pos as Vector3) - var vid := target_vid if event_vid < 0 else event_vid - if handler.is_valid(): - handler.call(ev, world_pos, vid) - - # REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point - static func _seg_point_sq_dist(p1: Vector3, p2: Vector3, x: Vector3) -> float: - var v := p2 - p1 - var l := v.length_squared() - var w := x - p1 - var d := w.dot(v) - if d <= 0.0: - return w.length_squared() - if d >= l: - return (x - p2).length_squared() - return w.cross(v).length_squared() / l - -var parent: Node3D # 弹道 / 爆点可视节点挂这下面(可空 = 纯逻辑,仅回调) -var world: Object # Metin2World(可空,用于 hit_on_background 贴地) -var _assets_root := "" # 与 NetWorld 的资源根保持同一解析上下文 -var manual_step := false # true 时 _process 不自动推进(headless 测试用 step() 手动驱动) -var actor_provider := Callable() # func() -> Array[{vid,node,center,radius}] -var effect_registry = null # EffectRegistry;可空时保留 headless fallback flash -var _instances: Array = [] # Array[FlyInstance] -var _visuals := {} # FlyInstance -> MeshInstance3D -var _flash_nodes: Array = [] # 短生命周期爆点特效,Loading 时必须一并回收 -var _effect_nodes: Array = [] # EffectRegistry bomb/attach nodes owned by this manager -var _id_counter := 1 - -signal shoot_damage(target_vid: int) -signal exploded(world_pos: Vector3, cause: String) -signal exp_absorbed(world_pos: Vector3) - -func setup(mount: Node3D, w: Object = null, assets_root_path: String = "") -> void: - parent = mount - world = w - _assets_root = assets_root_path - -func set_actor_provider(provider: Callable) -> void: - actor_provider = provider - -func set_effect_registry(registry) -> void: - effect_registry = registry - -func notify_target_despawn(node: Node3D) -> void: - if not is_instance_valid(node): - return - for inst_variant in _instances: - if inst_variant is FlyInstance: - (inst_variant as FlyInstance).notify_target_despawn(node) - -# CFlyingManager::CreateFlyingInstanceFlyTarget —— 建一颗飞行物并入表。 -func spawn(start_world: Vector3, target, can_attack: bool, data: FlyData = null, - skill_index := 0, owner_node: Node3D = null, owner_vid: int = 0) -> FlyInstance: - var d := data if data != null else FlyData.new() - var inst := FlyInstance.new() - inst.id = _id_counter - _id_counter += 1 - inst.skill_index = skill_index - inst._owner_node = owner_node - inst._owner_vid = owner_vid - inst._actor_provider = actor_provider - inst._world = world - inst.handler = func(ev: String, wp: Vector3, vid: int) -> void: - _on_instance_event(inst, ev, wp, vid) - inst.create(d, start_world, target, can_attack) - inst.build_attachments(effect_registry, parent) - _instances.append(inst) - var has_resource_visual := false - for attachment_variant in inst.attachments: - var attached_effect: Variant = attachment_variant.get("effect", null) - if is_instance_valid(attached_effect): - has_resource_visual = true - break - # CFlyingInstance renders its AttachFile/EffectManager instance. The - # SphereMesh is only a headless/asset-missing fallback; do not draw both - # representations when the 40250 resource visual was created successfully. - if parent != null and not has_resource_visual: - var m := MeshInstance3D.new() - var sm := SphereMesh.new() - var mat := StandardMaterial3D.new() - mat.emission_enabled = true - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - - if skill_index == FLY_EXP: - # 40250 官方经验聚能光球: ga_piece_yellow_small2.msf - sm.radius = 0.12 - sm.height = 0.24 - mat.albedo_color = Color(1.0, 0.9, 0.2, 0.95) - mat.emission = Color(1.0, 0.8, 0.1) * 3.0 - elif skill_index in [FLY_HP_SMALL, FLY_HP_MEDIUM, FLY_HP_BIG]: - # 40250 官方生命吸取光球: ga_piece_red_small.msf - sm.radius = 0.12 - sm.height = 0.24 - mat.albedo_color = Color(1.0, 0.25, 0.25, 0.95) - mat.emission = Color(1.0, 0.1, 0.1) * 3.0 - elif skill_index in [FLY_SP_SMALL, FLY_SP_MEDIUM, FLY_SP_BIG]: - # 40250 官方法力吸取光球: ga_piece_blue_small.msf - sm.radius = 0.12 - sm.height = 0.24 - mat.albedo_color = Color(0.25, 0.6, 1.0, 0.95) - mat.emission = Color(0.1, 0.5, 1.0) * 3.0 - else: - sm.radius = 0.08 - sm.height = 0.16 - mat.albedo_color = Color(1.0, 0.9, 0.4) - mat.emission = Color(0.9, 0.7, 0.2) - - m.mesh = sm - m.material_override = mat - parent.add_child(m) - if m.is_inside_tree(): - m.global_position = inst.pos - else: - m.position = inst.pos - _visuals[inst] = m - return inst - -func active_count() -> int: - return _instances.size() - -func _release_instance(inst: FlyInstance) -> void: - if not is_instance_valid(inst): - return - inst.alive = false - # The event Callable closes over `inst` and is itself owned by the - # RefCounted instance. Break that cycle before dropping the manager's - # arrays, matching CFlyingInstance destruction at Loading or expiry. - inst.handler = Callable() - inst.clear_attachments() - inst._target_obj = null - inst._owner_node = null - inst._actor_provider = Callable() - inst._world = null - inst.data = null - -# ClientVS22 的 CFlyingManager::DeleteAllInstances / CEffectManager::DeleteAllInstances。 -# 地图切换发生在下一批 GC_MAIN_CHARACTER / GC_CHARACTER_ADD 到达之前,旧地图的 -# 弹道和爆点不能继续挂在新地图上,否则会产生跨图命中、残留特效和旧目标回调。 -func clear_for_map_change() -> void: - for inst in _instances: - if inst is FlyInstance: - _release_instance(inst) - var vis: MeshInstance3D = _visuals.get(inst, null) - if is_instance_valid(vis): - # ClientVS22's DeleteAllInstances is synchronous at the Loading - # boundary. Free the old visual now so its mesh/material RIDs cannot - # survive into the replacement world or a short-process Destroy. - vis.free() - _instances.clear() - _visuals.clear() - for flash in _flash_nodes: - if is_instance_valid(flash): - flash.free() - _flash_nodes.clear() - for effect in _effect_nodes: - if is_instance_valid(effect): - effect.free() - _effect_nodes.clear() - -# CFlyingManager::Update() —— 遍历实例,Update() 返 false 即删除。 -func step(dt: float) -> void: - for cleanup_index in range(_effect_nodes.size() - 1, -1, -1): - if not is_instance_valid(_effect_nodes[cleanup_index]): - _effect_nodes.remove_at(cleanup_index) - var i := 0 - while i < _instances.size(): - var inst: FlyInstance = _instances[i] - var keep := inst.update(dt) - var vis: MeshInstance3D = _visuals.get(inst, null) - if is_instance_valid(vis): - if vis.is_inside_tree(): - vis.global_position = inst.pos - else: - vis.position = inst.pos - if not keep: - _release_instance(inst) - if is_instance_valid(vis): - vis.queue_free() - _visuals.erase(inst) - _instances.remove_at(i) - else: - i += 1 - -func _process(dt: float) -> void: - if not manual_step: - step(dt) - -func _on_instance_event(inst: FlyInstance, ev: String, wp: Vector3, vid: int) -> void: - match ev: - "shoot_damage": - shoot_damage.emit(vid) - "out_of_range", "at_target", "at_position", "at_background", "at_another": - exploded.emit(wp, ev) - if ev == "at_target" and inst.skill_index == FLY_EXP: - exp_absorbed.emit(wp) - # 40250 emits the hit callback first, then __Explode() emits exactly - # one __Bomb() effect. Do not create a compatibility flash here: the - # following "bomb" event owns both the real BombEffect and its fallback. - "bomb": - if parent != null: - var bomb_spawned := false - if effect_registry != null and inst.data != null: - var bomb_file := String(inst.data.bomb_effect) - if bomb_file != "" and effect_registry.has_method("spawn_at"): - var bomb_fx = effect_registry.spawn_at(bomb_file, parent, wp, true) - bomb_spawned = bomb_fx != null - if bomb_spawned: - _effect_nodes.append(bomb_fx) - if not bomb_spawned: - _spawn_flash(wp, inst.skill_index) - -func _spawn_flash(wp: Vector3, skill_index: int = -1) -> void: - var f := MeshInstance3D.new() - var sm := SphereMesh.new() - sm.radius = 0.06 - sm.height = 0.12 - f.mesh = sm - var mat := StandardMaterial3D.new() - mat.emission_enabled = true - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - - var final_scale := Vector3.ONE * 6.0 - if skill_index == FLY_EXP: - mat.albedo_color = Color(1.0, 0.9, 0.2, 0.95) - mat.emission = Color(1.0, 0.85, 0.2) * 2.5 - final_scale = Vector3.ONE * 8.0 - elif skill_index in [FLY_HP_SMALL, FLY_HP_MEDIUM, FLY_HP_BIG]: - mat.albedo_color = Color(1.0, 0.25, 0.25, 0.95) - mat.emission = Color(1.0, 0.1, 0.1) * 2.5 - final_scale = Vector3.ONE * 8.0 - elif skill_index in [FLY_SP_SMALL, FLY_SP_MEDIUM, FLY_SP_BIG]: - mat.albedo_color = Color(0.25, 0.6, 1.0, 0.95) - mat.emission = Color(0.1, 0.5, 1.0) * 2.5 - final_scale = Vector3.ONE * 8.0 - else: - mat.albedo_color = Color(1.0, 0.75, 0.3, 0.9) - mat.emission = Color(1.0, 0.6, 0.2) - - f.material_override = mat - parent.add_child(f) - _flash_nodes.append(f) - if f.is_inside_tree(): - f.global_position = wp - else: - f.position = wp - var tw := create_tween() - tw.set_parallel(true) - tw.tween_property(f, "scale", final_scale, 0.28) - tw.tween_property(mat, "albedo_color:a", 0.0, 0.28) - tw.chain().tween_callback(func() -> void: - _flash_nodes.erase(f) - if is_instance_valid(f): - f.queue_free() - ) diff --git a/project/fly_object.gd.uid b/project/fly_object.gd.uid deleted file mode 100644 index 5a869567..00000000 --- a/project/fly_object.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ub000mncflac diff --git a/project/fly_reset_test.gd b/project/fly_reset_test.gd deleted file mode 100644 index 472bfc20..00000000 --- a/project/fly_reset_test.gd +++ /dev/null @@ -1,50 +0,0 @@ -# fly_reset_test —— Loading/map change must remove active projectiles and flashes. -# godot --headless --path project --script fly_reset_test.gd -extends SceneTree - -const FlyObject = preload("res://fly_object.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: fly_reset_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var host := Node3D.new() - var manager := FlyObject.new() - get_root().add_child(host) - get_root().add_child(manager) - manager.manual_step = true - manager.setup(host) - - var data := FlyObject.FlyData.new() - data.init_vel = 1.0 - data.flat_range = 10.0 - manager.spawn(Vector3.ZERO, Vector3(0.0, 0.0, -1.0), true, data) - manager._spawn_flash(Vector3.ZERO, FlyObject.FLY_EXP) - _ck(manager.active_count() == 1, "active projectile is tracked before reset") - _ck(host.get_child_count() == 2, "projectile and flash visuals exist before reset") - - manager.clear_for_map_change() - _ck(manager.active_count() == 0, "reset removes all active projectiles") - await process_frame - _ck(host.get_child_count() == 0, "reset removes projectile and flash visuals") - - # clear_for_map_change already removed all visuals. At this point the - # short-lived regression owns empty manager nodes, so destroy them - # synchronously like CFlyingManager::DeleteAllInstances/Destroy instead of - # queueing a free immediately before SceneTree.quit(). - host.free() - manager.free() - await process_frame diff --git a/project/fly_reset_test.gd.uid b/project/fly_reset_test.gd.uid deleted file mode 100644 index 7f4107ed..00000000 --- a/project/fly_reset_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bx7yl1bxctrep diff --git a/project/fly_target_bounds_test.gd b/project/fly_target_bounds_test.gd deleted file mode 100644 index 26ae376f..00000000 --- a/project/fly_target_bounds_test.gd +++ /dev/null @@ -1,42 +0,0 @@ -extends SceneTree -const PlayerView = preload("res://ui/player_view.gd") -const Anchor = preload("res://fx/target_effect_anchor.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 old_mode := OS.get_environment("MTGODOT_GPUSKIN") - var centers := {} - for mode in ["0", "1"]: - OS.set_environment("MTGODOT_GPUSKIN", mode) - for race in 8: - var view := PlayerView.new() - check(view.build(AssetRoot.path(), race), "race builds") - root.add_child(view) - view.anim.set_process(false) - view.anim.set_time(0) - var result: Dictionary = view.model.get_fly_target_bounds() - check(result.valid, "source bone OBBs parsed race=%d mode=%s" % [race, mode]) - var bounds: AABB = result.bounds - check(bounds.size.is_finite() and bounds.size.length() > 50, "nonempty cm bounds") - var center: Vector3 = Anchor.fly_target_position(view) - check(center.distance_to(view.model.to_global(bounds.get_center())) < 0.00001, "source centre transforms once") - if mode == "0": centers[race] = center - else: check(center.distance_to(centers[race]) < 0.0001, "CPU/GPU target centres match") - var extra := MeshInstance3D.new() - extra.mesh = BoxMesh.new() - extra.position = Vector3(1000, 1000, 1000) - view.add_child(extra) - view.set_anim_state("attack") - view.anim.set_time(0.3) - check(Anchor.fly_target_position(view).distance_to(center) < 0.00001, "cached body sphere excludes attachments and later poses") - view.position = Vector3(5, 2, -3) - check(Anchor.fly_target_position(view).distance_to(center + view.position) < 0.00001, "cached local sphere follows actor translation") - view.free() - OS.set_environment("MTGODOT_GPUSKIN", old_mode) - await process_frame - print("fly_target_bounds_test: failures=%d" % failures) - quit(1 if failures else 0) diff --git a/project/fly_target_bounds_test.gd.uid b/project/fly_target_bounds_test.gd.uid deleted file mode 100644 index b21efe3e..00000000 --- a/project/fly_target_bounds_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cayswx15kvish diff --git a/project/fly_target_lifecycle_test.gd b/project/fly_target_lifecycle_test.gd deleted file mode 100644 index 10d9f0f6..00000000 --- a/project/fly_target_lifecycle_test.gd +++ /dev/null @@ -1,64 +0,0 @@ -# fly_target_lifecycle_test —— CFlyTarget 销毁/目标中心回归。 -extends SceneTree - -const FlyObject = preload("res://fly_object.gd") -const FlyTargetAnchor = preload("res://fx/target_effect_anchor.gd") - -var _failures := 0 - -func _ck(condition: bool, message: String) -> void: - if condition: - return - _failures += 1 - printerr("FAIL: ", message) - -func _init() -> void: - await _run() - quit(1 if _failures else 0) - -func _run() -> void: - await process_frame - var mount := Node3D.new() - get_root().add_child(mount) - var manager := FlyObject.new() - manager.manual_step = true - mount.add_child(manager) - manager.setup(mount) - - var target := Node3D.new() - target.position = Vector3(8.0, 0.0, 0.0) - var body := MeshInstance3D.new() - body.mesh = BoxMesh.new() - body.position = Vector3(0.0, 2.0, 0.0) - target.add_child(body) - mount.add_child(target) - await process_frame - - var data := FlyObject.FlyData.new() - data.init_vel = 10.0 - data.flat_range = 30.0 - data.bomb_range = 0.1 - var inst = manager.spawn(Vector3.ZERO, target, false, data) - var expected_center := FlyTargetAnchor.fly_target_position(target) - _ck(inst.target_position().distance_to(expected_center) < 0.0001, - "object target uses OnGetFlyTargetPosition-equivalent body centre") - - if manager.has_method("notify_target_despawn"): - manager.notify_target_despawn(target) - else: - _ck(false, "FlyManager exposes target-despawn notification") - _ck(not inst._is_object and inst._target_obj == null, - "destroyed Actor converts object target to position target immediately") - _ck(inst.target_position().distance_to(expected_center) < 0.0001, - "converted target retains last target position") - - target.free() - manager.step(0.01) - _ck(inst.alive, "projectile remains valid after target node is destroyed") - - manager.clear_for_map_change() - manager.free() - mount.free() - await process_frame - if _failures == 0: - print("PASS: fly_target_lifecycle_test") diff --git a/project/fly_target_lifecycle_test.gd.uid b/project/fly_target_lifecycle_test.gd.uid deleted file mode 100644 index 8c2371e6..00000000 --- a/project/fly_target_lifecycle_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://di44xl88pdh4a diff --git a/project/fly_test.gd b/project/fly_test.gd deleted file mode 100644 index 51f299e3..00000000 --- a/project/fly_test.gd +++ /dev/null @@ -1,241 +0,0 @@ -# fly_test —— §3.6 飞行物实体(fly_object.gd / FlyManager / FlyInstance)+ net_play._can_shot() -# 的 headless 自检。 -# godot --headless --path project --script fly_test.gd -# 退出码 0 = 全过。 -extends SceneTree - -const FlyObject = preload("res://fly_object.gd") -const NetPlay = preload("res://net_play.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: fly_test (§3.6 CFlyingManager/CFlyingInstance + __CanShot)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _mk_mgr() -> Node: - var m := FlyObject.new() - m.manual_step = true - root.add_child(m) - return m - -func _node_at(p: Vector3) -> Node3D: - var n := Node3D.new() - root.add_child(n) - n.global_position = p - return n - -func _drain(mgr: Node, steps: int, dt: float) -> void: - for i in steps: - if mgr.active_count() == 0: - return - mgr.step(dt) - -func _run() -> void: - # SceneTree._init() 跑得比 root 就绪早;等一帧再建 Node3D,避免 !is_inside_tree()。 - await process_frame - _test_hit_object() - _test_out_of_range() - _test_can_attack_damage() - _test_pierce() - _test_position_target() - _test_hit_background() - _test_can_shot() - _test_seg_point_dist() - -# 直线箭命中对象目标 —— CFlyingInstance::Update 的线段-点判定 < m_fBombRange²。 -func _test_hit_object() -> void: - var mgr := _mk_mgr() - var tgt := _node_at(Vector3(10, 1, 0)) - var events: Array = [] - mgr.exploded.connect(func(wp: Vector3, cause: String): events.append([cause, wp])) - var d = FlyObject.FlyData.new() - d.init_vel = 45.0 - d.flat_range = 30.0 - d.bomb_range = 0.6 - mgr.spawn(Vector3(0, 1, 0), tgt, false, d) - _ck(mgr.active_count() == 1, "hit_object: instance registered") - _drain(mgr, 60, 0.05) - _ck(events.size() == 1 and events[0][0] == "at_target", "hit_object: exploded at_target once") - if events.size() == 1: - _ck((events[0][1] as Vector3).distance_to(Vector3(10, 1, 0)) < 2.0, - "hit_object: explosion near the target") - _ck(mgr.active_count() == 0, "hit_object: instance removed after non-pierce hit") - mgr.free() - tgt.free() - -# 超程 —— m_fRemainRange < 0 → OnExplodingOutOfRange + __Explode(false)(不 __Bomb)。 -func _test_out_of_range() -> void: - var mgr := _mk_mgr() - var tgt := _node_at(Vector3(100, 1, 0)) - var causes: Array = [] - var dmg := [0] - mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause)) - mgr.shoot_damage.connect(func(_v: int): dmg[0] += 1) - var d = FlyObject.FlyData.new() - d.init_vel = 45.0 - d.flat_range = 5.0 - d.bomb_range = 0.6 - mgr.spawn(Vector3(0, 1, 0), tgt, true, d) - _drain(mgr, 40, 0.05) - _ck(causes == ["out_of_range"], "out_of_range: exploded out_of_range, no target hit") - _ck(dmg[0] == 0, "out_of_range: no shoot_damage") - _ck(mgr.active_count() == 0, "out_of_range: instance removed") - mgr.free() - tgt.free() - -# canAttack=true 且对象目标 → OnShootDamage(本 POC 转成 shoot_damage(vid) 信号)。 -func _test_can_attack_damage() -> void: - var mgr := _mk_mgr() - var tgt := _node_at(Vector3(5, 1, 0)) - var hits: Array = [] - mgr.shoot_damage.connect(func(v: int): hits.append(v)) - var d = FlyObject.FlyData.new() - d.init_vel = 45.0 - d.flat_range = 20.0 - d.bomb_range = 0.6 - mgr.spawn(Vector3(0, 1, 0), {"vid": 4242, "node": tgt}, true, d) - _drain(mgr, 40, 0.05) - _ck(hits == [4242], "can_attack: shoot_damage(4242) exactly once") - mgr.free() - tgt.free() - -# 穿透 —— m_iPierceCount>0 时命中后 --、__Bomb()、继续飞(Update 返 true),最终超程。 -func _test_pierce() -> void: - var mgr := _mk_mgr() - var tgt := _node_at(Vector3(6, 1, 0)) - var causes: Array = [] - mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause)) - var d = FlyObject.FlyData.new() - d.init_vel = 45.0 - d.flat_range = 15.0 - d.bomb_range = 0.6 - d.pierce_count = 1 - mgr.spawn(Vector3(0, 1, 0), tgt, false, d) - # 命中前几步:命中那一步实例仍存活(pierce 消耗),不立即移除 - var seen_alive_after_hit := false - for i in 60: - mgr.step(0.05) - if causes.size() == 1 and causes[0] == "at_target" and mgr.active_count() == 1: - seen_alive_after_hit = true - if mgr.active_count() == 0: - break - _ck(seen_alive_after_hit, "pierce: instance survives the first hit") - _ck(causes == ["at_target", "out_of_range"], "pierce: at_target then out_of_range") - mgr.free() - tgt.free() - -# 位置目标 —— IsPosition 分支同样线段判定 → __Explode()。 -func _test_position_target() -> void: - var mgr := _mk_mgr() - var causes: Array = [] - mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause)) - var d = FlyObject.FlyData.new() - d.init_vel = 45.0 - d.flat_range = 30.0 - d.bomb_range = 0.6 - mgr.spawn(Vector3(0, 1, 0), Vector3(8, 1, 0), false, d) - _drain(mgr, 40, 0.05) - _ck(causes == ["at_position"], "position_target: exploded at_position") - mgr.free() - -# hit_on_background —— GetTerrainHeight(x,-y) > z(本 POC:world.sample_height(x,z) > pos.y)。 -func _test_hit_background() -> void: - var mgr := _mk_mgr() - var world := _FakeWorld.new() - world.h = 0.9 - mgr.world = world - var causes: Array = [] - mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause)) - var d = FlyObject.FlyData.new() - d.init_vel = 45.0 - d.flat_range = 60.0 - d.bomb_range = 0.05 - d.gravity = -20.0 # 向下拉,几步内 y 跌破地面高度 - d.hit_on_background = true - mgr.spawn(Vector3(0, 1, 0), Vector3(40, 1, 0), false, d) - _drain(mgr, 40, 0.05) - _ck(causes.size() == 1 and causes[0] == "at_background", "hit_background: exploded at_background") - _ck(mgr.active_count() == 0, "hit_background: instance removed") - mgr.free() - -# net_play._can_shot() —— __CanShot 防御式重建(弓模式 && 有箭 && 有可攻击目标 && 在弓程内)。 -func _test_can_shot() -> void: - var fc := _FakeClient.new() - get_root().add_child(fc) - var np: Node = NetPlay.new() - get_root().add_child(np) - np.client = fc - np.net_world = null - np._main_vid = 1 - fc.ents[1] = {"vid": 1, "pos": Vector3.ZERO, "hp": 100, "max_hp": 100} - - np.combo_motion_mode = np.MOTION_MODE_GENERAL - _ck(np._can_shot() == "NOT_BOW_MODE", "can_shot: non-bow -> NOT_BOW_MODE") - - np.combo_motion_mode = np.MOTION_MODE_BOW - np._arrow_count = 0 - _ck(np._can_shot() == "EMPTY_ARROW", "can_shot: bow + 0 arrows -> EMPTY_ARROW") - - np._arrow_count = 5 - np._target_vid = 0 - np._vid_reserved = 0 - _ck(np._can_shot() == "NO_TARGET", "can_shot: bow + arrows + no target -> NO_TARGET") - - np._arrow_count = -1 # 未跟踪 = 无限 - fc.ents[900] = {"vid": 900, "ch_type": 2, "pos": Vector3(5, 0, 0), "hp": 30, "max_hp": 30, "dead": false} - np._target_vid = 900 - _ck(np._can_shot() == "", "can_shot: bow + target in range -> OK") - - fc.ents[901] = {"vid": 901, "ch_type": 2, "pos": Vector3(400, 0, 0), "hp": 30, "max_hp": 30, "dead": false} - np._target_vid = 901 - _ck(np._can_shot() == "OUT_OF_RANGE", "can_shot: target beyond __GetBowRange -> OUT_OF_RANGE") - - fc.ents[902] = {"vid": 902, "ch_type": 2, "pos": Vector3(3, 0, 0), "hp": 0, "max_hp": 30, "dead": true} - np._target_vid = 902 - _ck(np._can_shot() == "NO_TARGET", "can_shot: dead target -> NO_TARGET") - - np.free() - fc.free() - -func _test_seg_point_dist() -> void: - var f = FlyObject.FlyInstance - # 垂足在段内:点到直线距离 - _ck(is_equal_approx(f._seg_point_sq_dist(Vector3(0, 0, 0), Vector3(10, 0, 0), Vector3(5, 3, 0)), 9.0), - "seg_dist: foot inside segment -> perpendicular²") - # 垂足在 p1 外:取端点 p1 - _ck(is_equal_approx(f._seg_point_sq_dist(Vector3(0, 0, 0), Vector3(10, 0, 0), Vector3(-4, 0, 0)), 16.0), - "seg_dist: past p1 -> |x-p1|²") - # 垂足在 p2 外:取端点 p2 - _ck(is_equal_approx(f._seg_point_sq_dist(Vector3(0, 0, 0), Vector3(10, 0, 0), Vector3(13, 4, 0)), 25.0), - "seg_dist: past p2 -> |x-p2|²") - -class _FakeWorld extends RefCounted: - var h := 0.0 - func sample_height(_x: float, _z: float) -> float: - return h - -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 chat(type: int, vid: int, text: String) - signal damage(vid: int, amount: int, flag: int) - var ents := {} - func is_in_game() -> bool: return true - func get_main_vid() -> int: return 1 - func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) - func get_entities() -> Array: return ents.values() diff --git a/project/fly_test.gd.uid b/project/fly_test.gd.uid deleted file mode 100644 index b5d6ab50..00000000 --- a/project/fly_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://312cgt6dprj8 diff --git a/project/forest_map_render_test.gd b/project/forest_map_render_test.gd deleted file mode 100644 index 450d077b..00000000 --- a/project/forest_map_render_test.gd +++ /dev/null @@ -1,596 +0,0 @@ -extends Node - -## MAP-02 地图内离线验收:MT_TEST_MODE=forest_render(包内与编辑器都经 client_main 进入)。 -## -## MT_TEST_MODE=forest_render MT_FOREST_MAPS=[,] godot --path project -## bash script/forest_map_render_test.sh --help # 包内运行、墙钟超时、最终报告 -## -## 地图只来自 MT_FOREST_MAPS 或 MT_PLAYABLE_CONFIG 的 map_key(由 audit_playable_maps 的结果填写), -## 不在脚本里猜地图。每张图:生产 GameScene 装图 -> 机位(夹具确认 / 自动候选)-> 树怪经 -## net_world 的真实生成路径(entity_spawned -> MobView 工厂 -> 贴地)-> 记录 sample_height、 -## 怪物世界位置、ground offset、材质、实际动作路径 -> 固定机位截图。 -## -## 判定边界: -## - 平地(足迹内地形高差 <= FLAT_FOOTPRINT_M)上 wait 各采样帧都悬空/埋地超过 GROUND_GAP_M 才算失败; -## AABB 底部不是足部,坡地只记录数据交人工看脚/根部接触,不据此宣布足部 IK 完成。 -## - 自动候选机位、未确认机位、缺失的传送点、headless(无 Metal 截图)一律 BLOCKED,不当 PASS。 -## - 这里没有服务器刷新:选中/攻击/死亡/拾取的实网证据由 playable_live_test 单独记录。 -## -## 必测用例由 required_case_ids(maps) 决定(script/forest_map_render_test.sh 在启动前写同一份列表), -## Metal 截图的人工签核是发布清单里单独的 manual 项,不由本进程判定。 -## -## 退出码:单独运行 0 PASS / 1 FAIL / 2 BLOCKED;由 runner 启动(有 MT_PLAYABLE_RUN_ID)时写出报告即 0, -## 判定交给 runner。客户端只写 client-report.json、events 与 forest-map-evidence.json。 - -const GameScene = preload("res://game_scene.gd") -const Fixtures = preload("res://gamescene_test.gd") -const Viewpoints = preload("res://testing/forest_viewpoints.gd") -const PlayableReport = preload("res://testing/playable_report.gd") - -## root/npclist.txt 2301–2307 / 2311–2315;未由服务器确认前只是候选。 -const CANDIDATE_RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315] -const WAIT_SAMPLES := [0.0, 0.25, 0.5, 0.75] -const GRID_STEP_M := 4.0 -const FOOTPRINT_HALF_M := 1.5 -## 平地判定:怪物包围盒足迹内地形最高/最低差。超过即交人工接触检查,不做自动悬空判定。 -const FLAT_FOOTPRINT_M := 0.10 -## 平地 wait 姿态 AABB 底部与地形高度的允许差(米)。校准记录见 CALIBRATION。 -const GROUND_GAP_M := 0.10 -const CALIBRATION := "GROUND_GAP_M=0.10 m (2026-09-11, Metal, metin2_map_trent auto viewpoints): MobView grounds the bind-pose bottom via Metin2Model ground_offset; over 16 flat placements of the 12 candidate races the largest wait-loop AABB-bottom gap was 0.026 m (ent_trent, sinks slightly). 0.10 m keeps ~4x margin while still catching a missing/doubled ground offset (0.04-0.15 m per race). Changes need a recorded reason." -const ROOT_GAP_M := 0.001 -const MAIN_VID := 1000 -const MOB_VID_BASE := 60000 -const CHAR_TYPE_MONSTER := 2 -const CAMERA_PITCH_DEG := 28.0 -const DEFAULT_TIMEOUT_SECONDS := 900 - -var _out := "" -var _report := PlayableReport.new() -var _evidence := {} -var _finished := false - -func _ready() -> void: - call_deferred("run") - -func run() -> void: - _out = OS.get_environment("MT_TEST_OUTPUT") - if _out.is_empty(): - _out = OS.get_environment("MT_RENDER_OUTPUT") - if _out.is_empty(): - _out = ProjectSettings.globalize_path("user://forest-map-%d" % Time.get_unix_time_from_system()) - DirAccess.make_dir_recursive_absolute(_out) - var timeout := int(OS.get_environment("MT_FOREST_TIMEOUT_SECONDS")) if OS.get_environment("MT_FOREST_TIMEOUT_SECONDS").is_valid_int() else DEFAULT_TIMEOUT_SECONDS - get_tree().create_timer(timeout).timeout.connect(_on_watchdog) - var headless := DisplayServer.get_name() == "headless" - _evidence = {"schema_version": 1, "scope": "offline production GameScene + MobView placement; no server spawn, no live target/attack/pickup evidence", - "headless": headless, "assets": AssetRoot.path(), "calibration": CALIBRATION, - "thresholds": {"flat_footprint_m": FLAT_FOOTPRINT_M, "ground_gap_m": GROUND_GAP_M, "root_gap_m": ROOT_GAP_M}, - "maps": [], "manual_review": ["Metal screenshots: tree monster silhouettes, materials, foot/root contact on slopes"], - "live_evidence": "separate: playable_live_test per-map enter/select/attack/dead/pickup/leave"} - - var config := {} - var config_path := OS.get_environment("MT_PLAYABLE_CONFIG") - if not config_path.is_empty() and FileAccess.file_exists(config_path): - var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(config_path)) - config = parsed if parsed is Dictionary else {} - _report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "forest_render", config, OS.get_environment("MT_TEST_EVENTS")) - var maps: Array[String] = [] - for key in OS.get_environment("MT_FOREST_MAPS").split(",", false): - if not key.strip_edges().is_empty(): - maps.append(key.strip_edges()) - if maps.is_empty() and not String(config.get("map_key", "")).strip_edges().is_empty(): - maps.append(String(config.map_key).strip_edges()) - _report.set_required_cases(required_case_ids(maps)) - var vp_file := Viewpoints.load_file(OS.get_environment("MT_FOREST_VIEWPOINTS")) - var races := parse_races(OS.get_environment("MT_FOREST_RACES")) - var config_errors: Array[String] = [] - if maps.is_empty(): - config_errors.append("no map: set MT_FOREST_MAPS or config map_key from map-assets.json") - var ids := {} - for map_key in maps: - if not is_safe_map_key(map_key): - config_errors.append("map_key %s must be a relative asset path without '..'" % map_key) - elif ids.has(map_key.get_file()): - config_errors.append("map_key %s repeats map id %s" % [map_key, map_key.get_file()]) - ids[map_key.get_file()] = true - if not vp_file.ok: - config_errors.append_array(vp_file.errors) - if not races.ok: - config_errors.append("MT_FOREST_RACES must be a comma list of positive mob vnums") - if not config_errors.is_empty(): - _report.add_case("MAP-FOREST-CONFIG", "BLOCKED", "; ".join(config_errors)) - else: - _report.add_case("MAP-FOREST-CONFIG", "PASS", "maps=%s viewpoint_fixture=%s" % [",".join(maps), - "yes" if not OS.get_environment("MT_FOREST_VIEWPOINTS").is_empty() else "none"]) - get_tree().root.size = Vector2i(1280, 720) - for map_key in maps: - await _run_map(map_key, vp_file.data, races.races, headless) - _finish() - -static func required_case_ids(maps: Array) -> Array[String]: - var ids: Array[String] = ["MAP-FOREST-CONFIG"] - for map_key in maps: - var map_id := String(map_key).get_file() - ids.append("MAP-FOREST-LOAD-" + map_id) - for kind in Viewpoints.KINDS: - ids.append("MAP-FOREST-VP-%s-%s" % [map_id, kind]) - ids.append("MAP-FOREST-MOTION-" + map_id) - return ids - -static func is_safe_map_key(map_key: String) -> bool: - return not map_key.is_empty() and not map_key.begins_with("/") and not ("\\" in map_key) \ - and not (".." in map_key.split("/")) - -static func parse_races(text: String) -> Dictionary: - var out := {"ok": true, "races": []} - for part in text.split(",", false): - var value := part.strip_edges() - if not value.is_valid_int() or int(value) <= 0: - out.ok = false - continue - out.races.append(int(value)) - return out - -func _on_watchdog() -> void: - if _finished: - return - _report.add_failure("forest render watchdog: run did not finish (script error or hang)") - _finish() - -func _finish() -> void: - if _finished: - return - _finished = true - var report_path := OS.get_environment("MT_TEST_REPORT") - if report_path.is_empty(): - report_path = _out.path_join("client-report.json") - var final := _report.finish() - _evidence["status"] = final.get("status", "FAIL") - # 自动判定之外仍需人工看图;写进报告,避免把自动 PASS 读成视觉签核完成。 - final["manual_signoff"] = {"required": true, "status": "PENDING", "evidence": "forest-*.png + forest-map-evidence.json", - "scope": "tree monster silhouettes/materials, slope foot-root contact, dense-forest occlusion, dead poses"} - _evidence["manual_signoff"] = final.manual_signoff - var f := FileAccess.open(_out.path_join("forest-map-evidence.json"), FileAccess.WRITE) - if f: - f.store_string(JSON.stringify(_evidence, "\t")) - f.close() - var written := _report.write(report_path) - var status := String(final.get("status", "FAIL")) - print("FOREST MAP RENDER: status=%s failures=%d blocked=%d output=%s" % [status, - final.get("failures", []).size(), final.get("blocked", []).size(), _out]) - for line in final.get("failures", []): - printerr("FOREST MAP FAIL: " + String(line)) - var code := 0 if status == "PASS" else (1 if status == "FAIL" else 2) - if not written: - printerr("FOREST MAP RENDER: cannot write client report") - code = 1 - elif not OS.get_environment("MT_PLAYABLE_RUN_ID").is_empty(): - code = 0 # 由父 runner 在真实退出后判定;非零只表示客户端没能写出报告。 - # 被释放的 AudioStreamPlayer3D 的 playback 由音频线程异步移除;立即 quit 会报 ObjectDB 泄漏。 - get_tree().create_timer(0.3, true, false, true).timeout.connect(func() -> void: get_tree().quit(code)) - -func _run_map(map_key: String, vp_data: Dictionary, races_override: Array, headless: bool) -> void: - var map_id := map_key.get_file() - var map_ev := {"map_key": map_key, "viewpoints": []} - _evidence.maps.append(map_ev) - var bounds := {} - var probe := GameScene.new() - for name in ["setting.txt", "Setting.txt"]: - var p := AssetRoot.path().path_join(map_key).path_join(name) - if FileAccess.file_exists(p): - bounds = probe._map_bounds(p) - break - probe.free() - if bounds.is_empty(): - _report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "Setting.txt with BasePosition/MapSize not found for %s" % map_key) - return - var plan := Viewpoints.plan(vp_data, map_key, bounds) - if not plan.errors.is_empty(): - _report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "viewpoint fixture invalid: " + "; ".join(plan.errors)) - return - var size_m := Vector2(bounds.size) * Viewpoints.MAP_CELL_CM * 0.01 - var races: Array = plan.races.duplicate() - var race_source := "fixture" - if not races_override.is_empty(): - races = races_override.duplicate() - race_source = "MT_FOREST_RACES" - if races.is_empty(): - races = CANDIDATE_RACES.duplicate() - race_source = "candidate_only" - map_ev["bounds"] = {"base_cm": [bounds.base.x, bounds.base.y], "size_tiles": [bounds.size.x, bounds.size.y]} - map_ev["races"] = races - map_ev["race_source"] = race_source - - var client := Fixtures.FakeClient.new() - client.name = "ForestFakeClient" - get_tree().root.add_child(client) - var centre_cm: Vector2 = bounds.base + size_m * 50.0 - client.spawn(MAIN_VID, "ForestRender", _net_pos(centre_cm), true) - var scene := GameScene.new() - scene.name = "ForestGameScene" - get_tree().root.add_child(scene) - var started := Time.get_ticks_msec() - await scene.setup(client, AssetRoot.path(), map_key) - var world: Node = scene.world - while world and int(world.call("get_load_report").get("stream_queue", 0)) > 0: - await get_tree().process_frame - var load_report: Dictionary = world.call("get_load_report") if world else {} - map_ev["load"] = {"ms": Time.get_ticks_msec() - started, "report": load_report, "scene_map_path": scene.map_path} - var load_errors: Array[String] = [] - if not scene._map_loaded(): - load_errors.append("map did not load") - if scene.map_path != map_key: - load_errors.append("GameScene resolved %s instead of %s" % [scene.map_path, map_key]) - for key in ["chunks_failed", "objects_missing_model"]: - if int(load_report.get(key, 0)) > 0: - load_errors.append("%s=%d" % [key, int(load_report.get(key, 0))]) - if scene.net_world == null or scene.proto == null: - load_errors.append("net_world/mob_proto not ready; monsters would fall back to capsules") - _report.record("map_loaded", "forest_render", "MAP-FOREST-LOAD-" + map_id, 0, MAIN_VID, 0, - {"map_key": map_key, "elapsed_ms": map_ev.load.ms, "count": int(load_report.get("chunks_built", 0)), - "reason_code": "ok" if load_errors.is_empty() else "load_failed"}) - _report.add_case("MAP-FOREST-LOAD-" + map_id, "FAIL" if not load_errors.is_empty() else "PASS", - "; ".join(load_errors) if not load_errors.is_empty() else "chunks=%d objects=%d trees=%d" % [ - int(load_report.get("chunks_built", 0)), int(load_report.get("objects_placed", 0)), int(load_report.get("trees_placed", 0))]) - if not load_errors.is_empty(): - await _teardown(scene, client) - return - - var viewpoints: Array = plan.viewpoints.duplicate() - var auto_kinds: Array = plan.missing_kinds.filter(func(k: String) -> bool: return k in Viewpoints.AUTO_KINDS) - if not auto_kinds.is_empty(): - var trees := _tree_positions(world) - map_ev["tree_trunks_found"] = trees.size() - if not trees.is_empty(): - var r := Rect2(trees[0], Vector2.ZERO) - for t in trees: - r = r.expand(t) - map_ev["tree_trunk_bounds_m"] = [r.position.x, r.position.y, r.end.x, r.end.y] - # headless 的 RenderingServerDummy 不保存 MultiMesh 实例数据,读回全是单位变换。 - if trees.size() > 1 and r.size.is_zero_approx(): - map_ev["tree_positions"] = "unavailable: MultiMesh instance transforms are not readable from the headless rendering server" - trees = PackedVector2Array() - var candidates := Viewpoints.pick_candidates(_terrain_samples(world, size_m), trees, size_m, auto_kinds) - for c: Dictionary in candidates: - viewpoints.append({"id": "auto-" + String(c.kind), "kind": c.kind, "status": "auto_candidate", "source": "auto", - "confirmed_by": "", "camera_yaw_deg": Viewpoints.DEFAULT_YAW_DEG, "local_m": c.p, - "server_cm": Viewpoints.to_server_cm(bounds, c.p), - "selection": {"range_m": c.range_m, "slope_ratio": c.slope_ratio, "tree_density": c.density}}) - var motion_errors: Array[String] = [] - var fallbacks: Array = [] - var by_kind := {} - for vp: Dictionary in viewpoints: - var vp_ev := await _run_viewpoint(scene, client, world, vp, races, map_id, headless) - map_ev.viewpoints.append(vp_ev) - for line: String in vp_ev.motion_errors: - if not (line in motion_errors): - motion_errors.append(line) - fallbacks.append_array(vp_ev.fallbacks) - if not by_kind.has(vp.kind): - by_kind[vp.kind] = [] - by_kind[vp.kind].append(vp_ev) - _report.record("viewpoint", "forest_render", "MAP-FOREST-VP-%s-%s" % [map_id, vp.kind], 0, 0, 0, - {"map_key": map_key, "state": vp.kind, "reason_code": vp.status, "count": vp_ev.mobs.size(), - "evidence": vp_ev.screenshots, "selected": vp.id}) - for kind in Viewpoints.KINDS: - _add_kind_case(map_id, kind, by_kind.get(kind, []), map_ev, headless) - map_ev["reference_fallbacks"] = fallbacks - if viewpoints.is_empty(): - _report.add_case("MAP-FOREST-MOTION-" + map_id, "BLOCKED", "no viewpoint to place tree monsters") - else: - _report.add_case("MAP-FOREST-MOTION-" + map_id, "FAIL" if not motion_errors.is_empty() else "PASS", - "; ".join(motion_errors) if not motion_errors.is_empty() else "wait/run/dead via net_world; %d reference keep-current-motion fallbacks need live confirmation" % fallbacks.size()) - await _teardown(scene, client) - -## 每种机位一个必测用例:任一机位断言失败即 FAIL;只有“经确认 + Metal 截图 + 全部断言通过”才 PASS。 -func _add_kind_case(map_id: String, kind: String, results: Array, map_ev: Dictionary, headless: bool) -> void: - var case_id := "MAP-FOREST-VP-%s-%s" % [map_id, kind] - var errors: Array[String] = [] - var screenshots: Array = [] - var confirmed: Array[String] = [] - var unconfirmed: Array[String] = [] - for r: Dictionary in results: - screenshots.append_array(r.screenshots) - for e: String in r.errors: - errors.append("%s: %s" % [r.id, e]) - if r.status == "confirmed": - confirmed.append(String(r.id)) - else: - unconfirmed.append("%s(%s @ server_cm %s)" % [r.id, r.status, r.server_cm]) - if not errors.is_empty(): - _report.add_case(case_id, "FAIL", "; ".join(errors), screenshots) - elif results.is_empty(): - var why := "no confirmed viewpoint and no usable terrain candidate" - if kind == "warp": - why = "warp points cannot be derived offline; add a confirmed viewpoint" - elif kind == "dense" and map_ev.has("tree_positions"): - why = "no confirmed viewpoint; tree density needs a Metal run (%s)" % map_ev.tree_positions - _report.add_case(case_id, "BLOCKED", why) - elif headless: - _report.add_case(case_id, "BLOCKED", "headless run has no Metal screenshot", screenshots) - elif confirmed.is_empty() or not unconfirmed.is_empty(): - _report.add_case(case_id, "BLOCKED", "viewpoints not confirmed by the test environment owner: %s" % ", ".join(unconfirmed), screenshots) - else: - _report.add_case(case_id, "PASS", "confirmed %s: %s" % [kind, ", ".join(confirmed)], screenshots) - -func _run_viewpoint(scene: Node, client: Node, world: Node, vp: Dictionary, races: Array, map_id: String, headless: bool) -> Dictionary: - var ev := {"id": vp.id, "kind": vp.kind, "status": vp.status, "source": vp.source, - "server_cm": [vp.server_cm.x, vp.server_cm.y], "local_m": [vp.local_m.x, vp.local_m.y], - "camera_yaw_deg": vp.camera_yaw_deg, "selection": vp.get("selection", {}), - "mobs": [], "screenshots": [], "errors": [], "motion_errors": [], "fallbacks": []} - var centre: Vector2 = vp.local_m - var yaw := deg_to_rad(float(vp.camera_yaw_deg)) - var to_camera := Vector3(sin(yaw), 0.0, cos(yaw)) - var right := Vector3.UP.cross(to_camera).normalized() - var base: Vector2 = vp.server_cm - vp.local_m * 100.0 - # 主角放到怪群侧面,避免挡住机位。 - var player_local := centre + Vector2(right.x, right.z) * 12.0 - client.ents[MAIN_VID].pos = _net_pos(base + player_local * 100.0) - scene._place_player_at_net_pos(client.ents[MAIN_VID].pos) - - var vids: Array[int] = [] - for i in races.size(): - var vid := MOB_VID_BASE + i - vids.append(vid) - client.ents[vid] = {"vid": vid, "name": "Race%d" % races[i], "race": int(races[i]), "ch_type": CHAR_TYPE_MONSTER, - "pos": _net_pos(vp.server_cm), "is_main": false, "func": 0, "moving": false, "angle_deg": 0.0, - "hp": 100, "max_hp": 100, "dead": false} - client.entity_spawned.emit(client.ents[vid]) - await get_tree().process_frame - # 按最大足迹排成面向相机的网格,再让 net_world 按新 pos 贴地。 - var spacing := 3.5 - for vid in vids: - var node: Node3D = scene.net_world.node_for(vid) - if node != null and node.has_method("resolve_motion") and node.model: - # get_visual_aabb 是模型本地单位(未乘 unit_scale),先变到世界米。 - var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) - spacing = maxf(spacing, maxf(box.size.x, box.size.z) + 1.0) - var cols := int(ceil(sqrt(float(races.size())))) - var rows := int(ceil(float(races.size()) / float(cols))) - for i in vids.size(): - var offset := right * (float(i % cols) - (cols - 1) * 0.5) * spacing \ - + to_camera * (float(i / cols) - (rows - 1) * 0.5) * spacing - client.ents[vids[i]].pos = _net_pos(base + (centre + Vector2(offset.x, offset.z)) * 100.0) - for _i in 3: - await get_tree().process_frame - - var cam := Camera3D.new() - cam.name = "ForestFixedCamera" - cam.fov = 50.0 - scene.add_child(cam) - cam.current = true - var top := 0.0 - for i in vids.size(): - var node: Node3D = scene.net_world.node_for(vids[i]) - var mob := await _measure_mob(node, int(races[i]), vids[i], world, ev) - ev.mobs.append(mob) - top = maxf(top, float(mob.get("height_m", 0.0))) - var ground := float(world.call("sample_height", centre.x, centre.y)) - var look := Vector3(centre.x, ground + maxf(1.0, top * 0.5), centre.y) - var distance := maxf(12.0, spacing * float(maxi(cols, rows)) * 1.15) - var pitch := deg_to_rad(CAMERA_PITCH_DEG) - cam.position = look + (to_camera * cos(pitch) + Vector3.UP * sin(pitch)) * distance - cam.position.y = maxf(cam.position.y, float(world.call("sample_height", cam.position.x, cam.position.z)) + 1.5) - cam.look_at(look, Vector3.UP) - ev["camera"] = {"position": [cam.position.x, cam.position.y, cam.position.z], "look_at": [look.x, look.y, look.z], "fov": cam.fov, - "distance_m": distance, "grid_spacing_m": spacing} - for i in vids.size(): - var node: Node3D = scene.net_world.node_for(vids[i]) - if node == null: - continue - var anchor := node.global_position + Vector3.UP * 0.5 - var screen := get_viewport().get_visible_rect().grow(-8.0) - if cam.is_position_behind(anchor) or not screen.has_point(cam.unproject_position(anchor)): - ev.errors.append("race %d is outside the fixed camera frame" % races[i]) - if not headless: - await _screenshot(cam, "forest-%s-%s-wait.png" % [map_id, vp.id], ev) - - # 生产路径动作:移动(资源无 RUN/WALK 时按旧客户端保留当前动作)与死亡。 - for i in vids.size(): - var node: Node3D = scene.net_world.node_for(vids[i]) - if node != null and node.has_method("resolve_motion"): - node.anim.set_process(true) - client.ents[vids[i]].moving = true - for _i in 2: - await get_tree().process_frame - for i in vids.size(): - _check_motion(scene.net_world.node_for(vids[i]), int(races[i]), "run", ev) - client.ents[vids[i]].moving = false - client.ents[vids[i]].dead = true - client.entity_dead.emit(vids[i]) - await get_tree().create_timer(0.4).timeout - for i in vids.size(): - var node: Node3D = scene.net_world.node_for(vids[i]) - _check_motion(node, int(races[i]), "dead", ev) - if node != null and node.has_method("resolve_motion") and String(node.last_motion_resolution().path) != "": - if absf(node.rotation.x) > 0.01 or absf(node.rotation.z) > 0.01: - ev.motion_errors.append("race %d dead motion is tilted by the scene (rotation.x=%.2f); reference Die() only plays DEAD" % [races[i], node.rotation.x]) - node.anim.set_process(false) - node.anim.call("set_time", maxf(0.0, float(node.anim.call("get_duration")) - 1.0 / 60.0)) - var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) - var h := float(world.call("sample_height", node.global_position.x, node.global_position.z)) - for mob: Dictionary in ev.mobs: - if int(mob.vid) == vids[i]: - mob["dead_end_bottom_gap_m"] = box.position.y - h - if not headless: - await _screenshot(cam, "forest-%s-%s-dead.png" % [map_id, vp.id], ev) - for vid in vids: - client.ents.erase(vid) - client.entity_despawned.emit(vid) - cam.queue_free() - for _i in 2: - await get_tree().process_frame - return ev - -func _measure_mob(node: Node3D, race: int, vid: int, world: Node, ev: Dictionary) -> Dictionary: - var out := {"race": race, "vid": vid} - if node == null or not node.has_method("resolve_motion") or node.model == null or node.anim == null: - ev.errors.append("race %d spawned without a MobView model (placeholder capsule)" % race) - out["model"] = false - return out - out["model"] = true - var anim: Node = node.anim - anim.set("blend_time", 0.0) - anim.set_process(false) - var root := node.global_position - var h_root := float(world.call("sample_height", root.x, root.z)) - out["world_position"] = [root.x, root.y, root.z] - out["sample_height_m"] = h_root - out["root_gap_m"] = root.y - h_root - out["ground_offset_m"] = float(node.model.call("get_ground_offset")) - out["model_local_y_m"] = node.model.position.y - out["wait_resolution"] = node.last_motion_resolution() - out["wait_motion_path"] = String(anim.get("anim_path")) - out["materials"] = _material_report(node.model) - if absf(root.y - h_root) > ROOT_GAP_M: - ev.errors.append("race %d root is %.3fm off terrain; net_world grounding broken" % [race, root.y - h_root]) - if String(out.wait_motion_path).is_empty(): - ev.motion_errors.append("race %d has no wait motion after spawn" % race) - if int(out.materials.surfaces) == 0 or int(out.materials.textured) < int(out.materials.surfaces) or int(out.materials.invalid) > 0: - ev.errors.append("race %d materials: %s" % [race, out.materials]) - var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) - var half := Vector2(maxf(0.3, box.size.x * 0.5), maxf(0.3, box.size.z * 0.5)) - var c := Vector2(box.get_center().x, box.get_center().z) - var heights: Array[float] = [] - for d in [Vector2.ZERO, Vector2(half.x, half.y), Vector2(-half.x, half.y), Vector2(half.x, -half.y), Vector2(-half.x, -half.y)]: - heights.append(float(world.call("sample_height", c.x + d.x, c.y + d.y))) - var footprint_range: float = heights.max() - heights.min() - out["footprint_range_m"] = footprint_range - out["height_m"] = box.size.y - var samples: Array = [] - var off := 0 - var duration := float(anim.call("get_duration")) - for f in WAIT_SAMPLES: - anim.call("set_time", clampf(duration * f, 0.0, maxf(0.0, duration - 1.0 / 60.0))) - var b: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) - var gap := b.position.y - h_root - samples.append({"fraction": f, "aabb_bottom_gap_m": gap}) - if not b.position.is_finite() or not b.size.is_finite(): - ev.errors.append("race %d wait sample %.2f has non-finite bounds" % [race, f]) - if absf(gap) > GROUND_GAP_M: - off += 1 - out["wait_samples"] = samples - anim.call("set_time", clampf(duration * 0.5, 0.0, maxf(0.0, duration - 1.0 / 60.0))) - if footprint_range <= FLAT_FOOTPRINT_M: - out["contact_check"] = "flat_auto" - if off == WAIT_SAMPLES.size(): - ev.errors.append("race %d floats/buries on flat ground: every wait sample exceeds %.2fm (%s)" % [race, GROUND_GAP_M, samples]) - else: - out["contact_check"] = "slope_manual" - return out - -func _check_motion(node: Node3D, race: int, state: String, ev: Dictionary) -> void: - if node == null or not node.has_method("resolve_motion"): - return - var res: Dictionary = node.last_motion_resolution() - var path := String(node.anim.get("anim_path")) - if String(res.get("requested_state", "")) != state: - ev.motion_errors.append("race %d: net_world did not request %s (last=%s)" % [race, state, res.get("requested_state", "")]) - return - if String(res.path).is_empty(): - if state == "run" and String(res.fallback_reason) == "reference_keep_current_motion" and not path.is_empty(): - ev.fallbacks.append({"race": race, "state": state, "kept_motion_path": path, "needs_live_confirmation": true}) - else: - ev.motion_errors.append("race %d %s has no motion and is not a reference keep-current fallback" % [race, state]) - return - if path != String(res.path): - ev.motion_errors.append("race %d %s resolved %s but plays %s" % [race, state, res.path, path]) - if state == "dead" and (bool(node.anim.get("loop")) or not String(res.motion).contains("DEAD")): - ev.motion_errors.append("race %d dead plays %s loop=%s" % [race, res.motion, node.anim.get("loop")]) - -func _screenshot(cam: Camera3D, file_name: String, ev: Dictionary) -> void: - cam.current = true - # 证据图只拍 3D 场景:HUD 与血条层会让“非均匀”检查在场景全坏时仍然通过。 - var hidden: Array[CanvasLayer] = [] - for layer in get_tree().root.find_children("*", "CanvasLayer", true, false): - if (layer as CanvasLayer).visible: - (layer as CanvasLayer).visible = false - hidden.append(layer) - for _i in 3: - await get_tree().process_frame - await RenderingServer.frame_post_draw - var image := get_tree().root.get_texture().get_image() - for layer in hidden: - if is_instance_valid(layer): - layer.visible = true - if image == null or image.is_empty() or not _has_detail(image): - ev.errors.append("screenshot %s is empty or uniform" % file_name) - return - if image.save_png(_out.path_join(file_name)) != OK: - ev.errors.append("screenshot %s could not be saved" % file_name) - return - ev.screenshots.append(file_name) - -func _has_detail(image: Image) -> bool: - var first := image.get_pixel(0, 0) - var differing := 0 - var total := 0 - for y in range(0, image.get_height(), 16): - for x in range(0, image.get_width(), 16): - total += 1 - var p := image.get_pixel(x, y) - if absf(p.r - first.r) + absf(p.g - first.g) + absf(p.b - first.b) > 0.05: - differing += 1 - return total > 0 and float(differing) / float(total) > 0.1 - -func _material_report(model: Node) -> Dictionary: - var out := {"surfaces": 0, "textured": 0, "two_sided": 0, "invalid": 0} - for node in model.find_children("*", "MeshInstance3D", true, false): - var instance := node as MeshInstance3D - if instance.mesh == null: - continue - for surface in instance.mesh.get_surface_count(): - out.surfaces += 1 - var material := instance.get_active_material(surface) - if material is ShaderMaterial and (material as ShaderMaterial).shader != null: - var sm := material as ShaderMaterial - if bool(sm.get_shader_parameter("use_texture")) and sm.get_shader_parameter("albedo_tex") != null: - out.textured += 1 - if sm.shader.code.contains("cull_disabled"): - out.two_sided += 1 - elif material is BaseMaterial3D: - if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null: - out.textured += 1 - if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED: - out.two_sided += 1 - else: - out.invalid += 1 - return out - -func _terrain_samples(world: Node, size_m: Vector2) -> Array: - var out: Array = [] - var x := GRID_STEP_M * 0.5 - while x < size_m.x: - var z := GRID_STEP_M * 0.5 - while z < size_m.y: - var lo := INF - var hi := -INF - for d in [Vector2.ZERO, Vector2(FOOTPRINT_HALF_M, 0), Vector2(-FOOTPRINT_HALF_M, 0), Vector2(0, FOOTPRINT_HALF_M), Vector2(0, -FOOTPRINT_HALF_M)]: - var h := float(world.call("sample_height", x + d.x, z + d.y)) - lo = minf(lo, h) - hi = maxf(hi, h) - out.append({"p": Vector2(x, z), "range_m": hi - lo, "span_m": FOOTPRINT_HALF_M * 2.0, - "blocked": bool(world.call("is_blocked", x, z))}) - z += GRID_STEP_M - x += GRID_STEP_M - return out - -func _tree_positions(world: Node) -> PackedVector2Array: - var out := PackedVector2Array() - for node in world.find_children("Trees_*", "MultiMeshInstance3D", true, false): - var mm := (node as MultiMeshInstance3D).multimesh - if mm == null: - continue - for i in mm.instance_count: - var origin := ((node as MultiMeshInstance3D).global_transform * mm.get_instance_transform(i)).origin - out.append(Vector2(origin.x, origin.z)) - return out - -func _net_pos(server_cm: Vector2) -> Vector3: - return Vector3(server_cm.x * 0.01, 0.0, -server_cm.y * 0.01) - -func _teardown(scene: Node, client: Node) -> void: - scene.queue_free() - client.queue_free() - for _i in 3: - await get_tree().process_frame diff --git a/project/forest_map_render_test.gd.uid b/project/forest_map_render_test.gd.uid deleted file mode 100644 index 42eb269f..00000000 --- a/project/forest_map_render_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://eis45v6m735r diff --git a/project/forest_mob_render_test.gd b/project/forest_mob_render_test.gd deleted file mode 100644 index a74a9061..00000000 --- a/project/forest_mob_render_test.gd +++ /dev/null @@ -1,294 +0,0 @@ -extends SceneTree - -## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收(FIRST-MAC-PLAYABLE §8.2)。 -## 这不是服务器刷新证明;真实地图中的选中、战斗和掉落由 playable_live_test 承担。 -## -## 每个 race × CPU/GPU × wait/run/attack/damage/dead,在动作起点/25%/50%/75%/末段取样: -## - 报告 requested_state / resolved_motion / fallback_reason,实际播放路径必须等于解析结果; -## - 资源确无动作时按旧客户端规则(GetMotionKey 失败 -> 保留当前动作)单独判定, -## 只允许移动循环动作走这条回退,并写进 report.fallbacks; -## - 贴图实际加载、材质有 shader、包围盒有限; -## - 同一姿态 CPU/GPU 包围盒差 <= max(1cm, 高度0.5%); -## - 非 headless 时输出正面、侧面和每 race 动作 contact sheet,截图不能是纯背景。 -## env: MT_RENDER_OUTPUT 输出目录;MT_FOREST_RACES 逗号分隔的 race 子集(调试用)。 - -const MobView = preload("res://ui/mob_view.gd") -const RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315] -const STATES := ["wait", "run", "attack", "damage", "dead"] -## 旧客户端 CActorInstance::Move -> SetLoopMotion(RUN/WALK):缺动作时直接返回保留当前循环。 -const MOVE_STATES := ["run"] -const SAMPLES := [0.0, 0.25, 0.5, 0.75, 1.0] -const SIZE := 640 -const THUMB := 128 -const BACKGROUND := Color(0.08, 0.1, 0.14) -const TOLERANCE_FLOOR_M := 0.01 -const TOLERANCE_HEIGHT_RATIO := 0.005 -const MIN_FOREGROUND_RATIO := 0.002 - -var _output := "" -var _failures: Array[String] = [] -var _fallbacks: Array[Dictionary] = [] -var _pose_bounds := {} -var _results: Array[Dictionary] = [] -var _races: Array = RACES - -func _initialize() -> void: - call_deferred("run") - -func _fail(message: String) -> void: - _failures.append(message) - printerr("FOREST MOB FAIL: " + message) - -func run() -> void: - _output = OS.get_environment("MT_RENDER_OUTPUT") - if _output.is_empty(): - _output = ProjectSettings.globalize_path("res://../build/rendering/forest-mobs-%d" % Time.get_unix_time_from_system()) - DirAccess.make_dir_recursive_absolute(_output) - var subset := OS.get_environment("MT_FOREST_RACES").strip_edges() - if not subset.is_empty(): - _races = [] - for part in subset.split(",", false): - _races.append(int(part)) - root.size = Vector2i(SIZE, SIZE) - _build_lighting() - var cam := Camera3D.new() - cam.fov = 38.0 - cam.near = 0.02 - root.add_child(cam) - cam.make_current() - for gpu in ["0", "1"]: - OS.set_environment("MTGODOT_GPUSKIN", gpu) - for race in _races: - await _check_race(int(race), gpu, cam) - OS.set_environment("MTGODOT_GPUSKIN", "0") - var headless := DisplayServer.get_name() == "headless" - var report := {"passed": _failures.is_empty(), "failures": _failures, "races": _races, - "states": STATES, "gpu_modes": ["0", "1"], "sample_fractions": SAMPLES, - "tolerance_rule": "max(%.2fm, %.1f%% of posed height)" % [TOLERANCE_FLOOR_M, TOLERANCE_HEIGHT_RATIO * 100.0], - "fallback_rule": "reference CRaceData::GetMotionKey miss -> SetLoopMotion returns and the current loop keeps playing", - "fallbacks": _fallbacks, "results": _results, - "assets": AssetRoot.path(), "viewport": str(root.size), "headless": headless, - "screenshots": not headless, - "manual_review": "contact sheets and front/side views need one human sign-off; AABB bottom is not foot contact", - "scope": "offline tree-monster assembly; no server spawn or 40250 pixel parity"} - var file := FileAccess.open(_output.path_join("report.json"), FileAccess.WRITE) - if file: - file.store_string(JSON.stringify(report, "\t")) - file.close() - else: - _fail("cannot write report.json") - print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(), - "fallbacks": _fallbacks.size(), "results": _results.size(), "output": _output})) - # Batch marker (rendering_batch_test.sh); the JSON line above keeps the details. - print("PASS: forest_mob_render_test (manual sign-off pending)" if _failures.is_empty() else "FAIL: forest_mob_render_test") - quit(0 if _failures.is_empty() else 1) - -func _check_race(race: int, gpu: String, cam: Camera3D) -> void: - var tag := "race=%d GPU=%s" % [race, gpu] - var view := MobView.new() - view.name = "ForestMob_%d_%s" % [race, gpu] - root.add_child(view) - if not view.build(AssetRoot.path(), null, race): - _fail("%s build returned false" % tag) - view.queue_free() - await process_frame - return - # 交叉淡入只在 _process 里推进;取样要看动作本身的姿态,不看上一动作的残留混合。 - if view.anim: - view.anim.set("blend_time", 0.0) - var row := {"race": race, "gpu_skin": gpu == "1", "directory": view._dir, - "materials": _material_report(view.model, tag), "states": {}, "views": {}, "contact_sheet": ""} - var shoot := DisplayServer.get_name() != "headless" - var sheet: Image = null - if shoot: - sheet = Image.create(THUMB * SAMPLES.size(), THUMB * STATES.size(), false, Image.FORMAT_RGBA8) - sheet.fill(BACKGROUND) - var frame := {} - for state_index in STATES.size(): - var state: String = STATES[state_index] - var resolution: Dictionary = view.resolve_motion(state) - var before := String(view.anim.get("anim_path")) if view.anim else "" - view.set_anim_state(state) - if view.anim == null: - _fail("%s state=%s has no animation player" % [tag, state]) - continue - var entry := {"requested_state": state, "resolved_motion": String(resolution.motion), - "motion_path": String(resolution.path), "fallback_reason": String(resolution.fallback_reason), - "status": "PASS", "samples": []} - row.states[state] = entry - var actual := String(view.anim.get("anim_path")) - if String(resolution.path).is_empty(): - if state in MOVE_STATES and resolution.fallback_reason == "reference_keep_current_motion" and actual == before and not before.is_empty(): - entry.status = "REFERENCE_FALLBACK" - entry["kept_motion_path"] = actual - _fallbacks.append({"race": race, "gpu_skin": gpu == "1", "requested_state": state, - "kept_motion_path": actual, "fallback_reason": resolution.fallback_reason, - "needs_live_confirmation": "server movement of this race shows the kept loop while its position changes"}) - else: - entry.status = "FAIL" - _fail("%s state=%s has no motion (%s; still playing %s)" % [tag, state, resolution.fallback_reason, actual]) - continue - if not FileAccess.file_exists(String(resolution.path)): - entry.status = "FAIL" - _fail("%s state=%s resolved a missing file %s" % [tag, state, resolution.path]) - continue - if actual != String(resolution.path): - entry.status = "FAIL" - _fail("%s state=%s resolved %s but played %s" % [tag, state, resolution.path, actual]) - continue - if state == "dead" and bool(view.anim.get("loop")): - _fail("%s dead motion is looping" % tag) - view.anim.set_process(true) - await process_frame - view.anim.set_process(false) - var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 0.0 - entry["duration"] = duration - if duration <= 0.0: - entry.status = "FAIL" - _fail("%s state=%s has zero duration" % [tag, state]) - continue - for sample_index in SAMPLES.size(): - var fraction: float = SAMPLES[sample_index] - # 末段取最后一帧之前,避免非循环动作 set_time(duration) 被播放器夹回/结束。 - var at := clampf(duration * fraction, 0.0, maxf(0.0, duration - 1.0 / 60.0)) - view.anim.call("set_time", at) - await process_frame - var sample := _sample_pose(view, race, gpu, state, fraction, tag) - sample["time"] = at - entry.samples.append(sample) - if not bool(sample.valid): - entry.status = "FAIL" - if shoot: - if frame.is_empty(): - frame = _frame_for(view) - var image := await _capture(cam, frame, Vector3(0.35, 0.22, 1.0)) - if not _has_foreground(image): - entry.status = "FAIL" - _fail("%s state=%s sample=%.2f screenshot is empty" % [tag, state, fraction]) - image.convert(Image.FORMAT_RGBA8) - image.resize(THUMB, THUMB, Image.INTERPOLATE_BILINEAR) - sheet.blit_rect(image, Rect2i(0, 0, THUMB, THUMB), Vector2i(sample_index * THUMB, state_index * THUMB)) - if state == "wait" and is_equal_approx(fraction, 0.5): - for view_name in ["front", "side"]: - var direction := Vector3(0.0, 0.18, 1.0) if view_name == "front" else Vector3(1.0, 0.18, 0.0) - var shot := await _capture(cam, frame, direction) - var file_name := "mob-%d-%s-%s.png" % [race, view_name, "gpu" if gpu == "1" else "cpu"] - if not _has_foreground(shot) or shot.save_png(_output.path_join(file_name)) != OK: - _fail("%s %s view screenshot failed" % [tag, view_name]) - row.views[view_name] = file_name - if shoot: - var sheet_name := "mob-%d-sheet-%s.png" % [race, "gpu" if gpu == "1" else "cpu"] - if not _has_foreground(sheet) or sheet.save_png(_output.path_join(sheet_name)) != OK: - _fail("%s contact sheet save failed" % tag) - row.contact_sheet = sheet_name - _results.append(row) - view.queue_free() - await process_frame - -## 同一 race/state/fraction:CPU 先记录,GPU 比较。容差 max(1cm, 高度0.5%),米制世界空间。 -func _sample_pose(view: Node3D, race: int, gpu: String, state: String, fraction: float, tag: String) -> Dictionary: - var local: AABB = view.model.call("get_visual_aabb") - var bounds: AABB = view.model.global_transform * local - var out := {"fraction": fraction, "valid": true, - "aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z], - "aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z]} - if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0: - out.valid = false - _fail("%s state=%s sample=%.2f has invalid visual AABB" % [tag, state, fraction]) - return out - var key := "%d/%s/%.2f" % [race, state, fraction] - if gpu == "0": - _pose_bounds[key] = bounds - return out - if not _pose_bounds.has(key): - out.valid = false - _fail("%s state=%s sample=%.2f has no CPU pose to compare" % [tag, state, fraction]) - return out - var cpu: AABB = _pose_bounds[key] - var tolerance := maxf(TOLERANCE_FLOOR_M, maxf(cpu.size.y, bounds.size.y) * TOLERANCE_HEIGHT_RATIO) - var delta := maxf(cpu.position.distance_to(bounds.position), cpu.end.distance_to(bounds.end)) - out["cpu_gpu_delta_m"] = delta - out["tolerance_m"] = tolerance - if delta > tolerance: - out.valid = false - _fail("%s state=%s sample=%.2f CPU/GPU AABB differs %.4fm > %.4fm" % [tag, state, fraction, delta, tolerance]) - return out - -## 贴图实际绑定到材质、材质有 shader。双面/混合按资源记录,不做统一强制。 -func _material_report(model: Node, tag: String) -> Dictionary: - var surfaces := 0 - var textured := 0 - var two_sided := 0 - var invalid := 0 - for node in model.find_children("*", "MeshInstance3D", true, false): - var instance := node as MeshInstance3D - if instance.mesh == null: - continue - for surface in instance.mesh.get_surface_count(): - surfaces += 1 - var material := instance.get_active_material(surface) - var shader_material := material as ShaderMaterial - if shader_material != null: - if shader_material.shader == null: - invalid += 1 - continue - if bool(shader_material.get_shader_parameter("use_texture")) and shader_material.get_shader_parameter("albedo_tex") != null: - textured += 1 - if shader_material.shader.code.contains("cull_disabled"): - two_sided += 1 - elif material is BaseMaterial3D: - if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null: - textured += 1 - if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED: - two_sided += 1 - else: - invalid += 1 - if surfaces == 0: - _fail("%s model has no mesh surfaces" % tag) - if invalid > 0: - _fail("%s has %d surfaces without a valid material" % [tag, invalid]) - if textured < surfaces: - _fail("%s only %d/%d surfaces have a loaded texture" % [tag, textured, surfaces]) - return {"surfaces": surfaces, "textured": textured, "two_sided": two_sided, "invalid": invalid} - -## 按首个采样姿态固定机位,同一 race 的所有截图可直接对比。 -func _frame_for(view: Node3D) -> Dictionary: - var bounds: AABB = view.model.global_transform * (view.model.call("get_visual_aabb") as AABB) - var radius := maxf(0.5, bounds.size.length() * 0.5) - return {"centre": bounds.get_center(), "distance": maxf(3.0, radius * 3.6)} - -func _capture(cam: Camera3D, frame: Dictionary, direction: Vector3) -> Image: - var centre: Vector3 = frame.centre - cam.position = centre + direction.normalized() * float(frame.distance) - cam.look_at(centre, Vector3.UP) - await RenderingServer.frame_post_draw - return root.get_texture().get_image() - -func _has_foreground(image: Image) -> bool: - if image == null or image.is_empty(): - return false - var hits := 0 - var step := 4 - var total := 0 - for y in range(0, image.get_height(), step): - for x in range(0, image.get_width(), step): - total += 1 - var c := image.get_pixel(x, y) - if absf(c.r - BACKGROUND.r) + absf(c.g - BACKGROUND.g) + absf(c.b - BACKGROUND.b) > 0.06: - hits += 1 - return total > 0 and float(hits) / float(total) >= MIN_FOREGROUND_RATIO - -func _build_lighting() -> void: - var sun := DirectionalLight3D.new() - sun.rotation_degrees = Vector3(-42, 155, 0) - sun.light_energy = 1.1 - root.add_child(sun) - var env := WorldEnvironment.new() - var settings := Environment.new() - settings.background_mode = Environment.BG_COLOR - settings.background_color = BACKGROUND - settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - settings.ambient_light_color = Color(0.55, 0.58, 0.65) - settings.ambient_light_energy = 0.8 - env.environment = settings - root.add_child(env) diff --git a/project/forest_mob_render_test.gd.uid b/project/forest_mob_render_test.gd.uid deleted file mode 100644 index c00411a6..00000000 --- a/project/forest_mob_render_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bcvvork7jtu88 diff --git a/project/forest_viewpoints_test.gd b/project/forest_viewpoints_test.gd deleted file mode 100644 index 2b54ccfe..00000000 --- a/project/forest_viewpoints_test.gd +++ /dev/null @@ -1,92 +0,0 @@ -# forest_viewpoints_test —— MAP-02 机位夹具校验与自动候选挑选(不加载地图)。 -# godot --headless --path project --script forest_viewpoints_test.gd -extends SceneTree - -const Viewpoints = preload("res://testing/forest_viewpoints.gd") - -const BOUNDS := {"base": Vector2(1049600, 0), "size": Vector2i(2, 2)} -var _fail := 0 -var _done := false - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - _run() - _ck(_done, "all viewpoint assertions executed") - print("forest_viewpoints_test: failures=%d" % _fail) - if _fail == 0: - print("PASS: forest_viewpoints_test") - quit(1 if _fail else 0) - -func _has_error(result: Dictionary, needle: String) -> bool: - return result.errors.any(func(e: String) -> bool: return needle in e) - -func _run() -> void: - # No fixture file: nothing is confirmed, every kind is missing, nothing fails. - var empty := Viewpoints.plan({}, "m", BOUNDS) - _ck(empty.errors.is_empty() and empty.viewpoints.is_empty(), "missing fixture is not an error") - _ck(empty.missing_kinds == Viewpoints.KINDS, "missing fixture leaves all four kinds unconfirmed") - _ck(Viewpoints.load_file("").ok and not Viewpoints.load_file("/nonexistent/vp.json").ok, - "empty path means no fixture; a named but absent file is an error") - - var good := {"schema_version": 1, "maps": {"m": {"races": [2301, 2306], "viewpoints": [ - {"id": "flat-01", "kind": "flat", "status": "confirmed", "confirmed_by": "test-env-owner", - "server_cm": [1062400, 12800], "camera_yaw_deg": 90}, - {"id": "warp-01", "kind": "warp", "status": "unconfirmed", "server_cm": [1100000, 40000]}, - ]}}} - var planned := Viewpoints.plan(good, "m", BOUNDS) - _ck(planned.errors.is_empty(), "valid fixture accepted: %s" % [planned.errors]) - _ck(planned.missing_kinds == ["slope", "dense"], "configured kinds are not reported missing") - _ck(planned.races == [2301, 2306], "races come from the fixture") - _ck(planned.viewpoints[0].local_m.is_equal_approx(Vector2(128, 128)), "server cm converts through BasePosition") - _ck(Viewpoints.to_server_cm(BOUNDS, Vector2(128, 128)) == Vector2(1062400, 12800), "local metres convert back to server cm") - - var bad := {"schema_version": 2, "maps": {"m": {"races": [0], "viewpoints": [ - {"id": "a", "kind": "flat", "status": "confirmed", "server_cm": [1062400, 12800]}, - {"id": "a", "kind": "cave", "status": "unconfirmed", "confirmed_by": "x", "server_cm": [1062400, 12800]}, - {"id": "b", "kind": "slope", "status": "maybe", "server_cm": [10, 10]}, - {"id": "c", "kind": "dense", "status": "unconfirmed", "server_cm": [1.5, 2]}, - ]}, "password": "x"}} - var rejected := Viewpoints.plan(bad, "m", BOUNDS) - for needle in ["schema_version", "confirmed_by (role) is empty", "duplicates a", "kind must be one of", - "unconfirmed but carries confirmed_by", "status must be", "outside m", "server_cm must be", - "races must contain positive", "credentials must not be stored"]: - _ck(_has_error(rejected, needle), "invalid fixture reports: %s (got %s)" % [needle, rejected.errors]) - - # Candidate picking is deterministic over synthetic terrain samples. - var samples: Array = [] - for x in range(0, 101, 4): - for z in range(0, 101, 4): - var p := Vector2(x, z) - var range_m := 0.02 if p.distance_to(Vector2(40, 40)) < 3.0 else 0.3 - if p.distance_to(Vector2(80, 30)) < 3.0: - range_m = 1.2 # steep but walkable (0.4) - if p.distance_to(Vector2(20, 80)) < 3.0: - range_m = 6.0 # cliff (2.0) must lose to a walkable slope - samples.append({"p": p, "range_m": range_m, "span_m": 3.0, "blocked": p == Vector2(44, 40)}) - var trees := PackedVector2Array() - for i in 8: - trees.append(Vector2(60, 72) + Vector2(4, 0).rotated(TAU * i / 8.0)) - trees.append(Vector2(41, 41)) # tree trunk right next to the flattest point - var picked := Viewpoints.pick_candidates(samples, trees, Vector2(100, 100), Viewpoints.AUTO_KINDS) - var by_kind := {} - for c: Dictionary in picked: - by_kind[c.kind] = c - _ck(by_kind.has("flat") and by_kind.flat.range_m <= 0.3 and by_kind.flat.p.distance_to(Vector2(41, 41)) >= Viewpoints.AUTO_TREE_CLEARANCE_M, - "flat candidate keeps clear of tree trunks: %s" % [by_kind.get("flat", {})]) - _ck(by_kind.has("slope") and (by_kind.slope.p as Vector2).distance_to(Vector2(80, 30)) < 3.0, - "slope candidate is the steepest walkable point, not the cliff: %s" % [by_kind.get("slope", {})]) - _ck(by_kind.has("dense") and by_kind.dense.p.distance_to(Vector2(60, 72)) < Viewpoints.AUTO_DENSITY_RADIUS_M, - "dense candidate sits inside the tree cluster: %s" % [by_kind.get("dense", {})]) - for a: Dictionary in picked: - for b: Dictionary in picked: - _ck(a == b or (a.p as Vector2).distance_to(b.p) >= Viewpoints.AUTO_SEPARATION_M, "candidates are separated") - _ck(a.p.x >= Viewpoints.AUTO_EDGE_MARGIN_M and a.p.y >= Viewpoints.AUTO_EDGE_MARGIN_M, "candidates avoid map edges") - _ck(not picked.any(func(c: Dictionary) -> bool: return c.kind == "warp"), "warp points are never guessed offline") - var flat_only := Viewpoints.pick_candidates([{"p": Vector2(50, 50), "range_m": 0.0, "span_m": 3.0, "blocked": false}], - PackedVector2Array(), Vector2(100, 100), Viewpoints.AUTO_KINDS) - _ck(flat_only.size() == 1 and flat_only[0].kind == "flat", "flat terrain yields no fake slope or dense candidate") - _done = true diff --git a/project/forest_viewpoints_test.gd.uid b/project/forest_viewpoints_test.gd.uid deleted file mode 100644 index 4e2ab9f0..00000000 --- a/project/forest_viewpoints_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://6k7tg6i0mcaj diff --git a/project/fx/effect_player.gd b/project/fx/effect_player.gd deleted file mode 100644 index fd92bc36..00000000 --- a/project/fx/effect_player.gd +++ /dev/null @@ -1,863 +0,0 @@ -# EffectPlayer (P5) —— 解析后的 `.mse` spec → Godot 节点树(GPUParticles3D 为主)。 -# -# var fx := preload("res://fx/effect_player.gd").new() -# fx.build(spec, assets_root) # spec 来自 fx/mse.gd -# add_child(fx) # 挂到要出特效的位置(可 reparent 到骨骼) -# fx.play() # 或 fx.play(true) 一次性 -# -# 覆盖 Particle 组:发射形状 / 速率 / 寿命 / 方向 / 重力 / 缩放曲线 / 颜色渐变 / -# billboard / 加法混合 / 旋转;粒子 TextureFiles 通过 UiAssets + C++ DDS 解码加载。 -# Mesh 组:读取 ClientVS22 的 .mde v1/v2、展开 TriangleList、播放几何帧与 IFL 贴图。 -# SimpleLight:按参考的范围曲线 / 生命周期创建 OmniLight3D;现有资产集仍缺少逐项视觉样本。 -extends Node3D - -const UiAssets = preload("res://ui/ui_assets.gd") -const Mde = preload("res://fx/mde.gd") -const EffectSpace = preload("res://fx/effect_space.gd") -const ParticleColorMaterial = preload("res://fx/particle_color_material.gd") -const ParticleFaces = preload("res://fx/particle_faces.gd") - -var spec := {} -var assets_root := "" -var one_shot := false -var _emitters: Array[GPUParticles3D] = [] -var _particle_states: Array[Dictionary] = [] -var _mesh_nodes: Array[Node3D] = [] -var _mesh_states: Array[Dictionary] = [] -var _lights: Array[OmniLight3D] = [] -var _light_states: Array[Dictionary] = [] -var _bsphere_r := 0.0 -var _playing := false -var _cleanup_remaining := -1.0 -## MSE-defined playback length of the last play(); -1 while looping (owner-bound). -var defined_lifetime_ms := -1 -## Why the node left the tree: "cleanup" when its own playback clock freed it. -var finish_reason := "" - -static var _glow_tex: Texture2D - -func build(mse_spec: Dictionary, assets := "") -> void: - spec = mse_spec - assets_root = assets - _bsphere_r = float(spec.get("bsphere_r", 0.0)) - for p in spec.get("particles", []): - var e := _build_particle(p) - if e: - add_child(e) - _emitters.append(e) - for m in spec.get("meshes", []): - var mn := _build_mesh(m) - if mn: - add_child(mn) - _mesh_nodes.append(mn) - for l in spec.get("lights", []): - var light := _build_light(l) - if light: - add_child(light) - _lights.append(light) - -func _process(delta: float) -> void: - if not _playing: - return - for state in _particle_states: - _advance_particle(state, delta) - for state in _mesh_states: - _advance_mesh(state, delta) - for state in _light_states: - _advance_light(state, delta) - if _cleanup_remaining >= 0.0: - _cleanup_remaining -= maxf(delta, 0.0) - if _cleanup_remaining <= 0.0: - finish_reason = "cleanup" - queue_free() - -func play(force_one_shot := false) -> void: - # One playback clock owns starts and cleanup. No detached SceneTree timers - # may start or delete an effect after stop/replay, or while it is paused. - _playing = true - _cleanup_remaining = maxf(_longest_life() + 0.5, 1.5) if (force_one_shot or one_shot) else -1.0 - defined_lifetime_ms = roundi(_longest_life() * 1000.0) if (force_one_shot or one_shot) else -1 - for state in _particle_states: - state["clock"] = 0.0 - state["emission_stopped"] = false - state["emission_started"] = false - var e: GPUParticles3D = state["node"] - # Effect one_shot controls node cleanup, not a GPU emission cycle. - # Individual lifetime may be much shorter than the MSE emission window. - e.one_shot = false - e.restart() - e.emitting = false - e.position = _position_at(state.get("rows", []), 0.0) - _advance_particle(state, 0.0) - for state in _mesh_states: - state["clock"] = 0.0 - for child in state.get("children", []): - child["frame"] = -1 - child["texture_frame"] = -1 - _advance_mesh(state, 0.0) - for state in _light_states: - state["clock"] = 0.0 - _advance_light(state, 0.0) - -func stop() -> void: - # Existing particles may finish their lifetime; mesh/light emission stops now. - _playing = false - _cleanup_remaining = -1.0 - defined_lifetime_ms = -1 - for e in _emitters: - e.emitting = false - for mesh in _mesh_nodes: - mesh.visible = false - for light in _lights: - light.visible = false - -func _longest_life() -> float: - var m := 1.0 - for e in _emitters: - var start := float(e.get_meta("start_time", 0.0)) - var cycle := float(e.get_meta("cycle_length", 0.0)) - var loop := bool(e.get_meta("cycle_loop", false)) - var loop_count := int(e.get_meta("loop_count", 0)) - var emission_window := 0.0 - if cycle > 0.0 and (not loop or loop_count > 0): - emission_window = cycle * (loop_count if loop else 1) - m = maxf(m, start + emission_window + e.lifetime) - for state in _mesh_states: - m = maxf(m, float(state.get("duration", 0.0))) - for state in _light_states: - m = maxf(m, float(state.get("duration", 0.0))) - return m - -# --- particle --------------------------------------------------------- - -func _build_particle(p: Dictionary) -> GPUParticles3D: - var emit: Dictionary = p.get("emitter", {}) - var prop: Dictionary = p.get("particle", {}) - var g := GPUParticles3D.new() - g.emitting = false - g.set_meta("start_time", float(p.get("start_time", 0.0))) - g.set_meta("emitter_advanced_type", int(_n(emit.get("EmitterAdvancedType", 0)))) - g.set_meta("emitter_emit_from_edge", int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0) - var positions: Array = p.get("position", []) - if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5: - # EffectLib stores effect coordinates in centimetres; the Godot scene is - # metres. The position curve is evaluated again as the effect advances. - g.position = _position_at(positions, 0.0) - g.amount = maxi(1, int(_n(emit.get("MaxEmissionCount", 16)))) - g.explosiveness = 0.0 - - var life := _last_val(emit.get("TimeEventLifeTime", []), 1.0) - g.lifetime = clampf(life, 0.05, 12.0) - # ParticleSystemData.cpp supplies 0.05 when CycleLength is absent. Keep an - # explicit zero distinct: it means the source does not request a cycle gate. - var cycle := _n(emit.get("CycleLength", 0.0)) if emit.has("CycleLength") else 0.05 - var loop := int(_n(emit.get("CycleLoopEnable", 0))) - var loop_count := int(_n(emit.get("LoopCount", 0))) - g.one_shot = false # emission ends at the MSE clock gate, not one particle lifetime - g.local_coords = int(_n(prop.get("AttachEnable", 0))) != 0 - g.set_meta("cycle_length", maxf(cycle, 0.0)) - g.set_meta("cycle_loop", loop != 0) - g.set_meta("loop_count", maxi(loop_count, 0)) - g.set_meta("particle_lifetime", g.lifetime) - g.set_meta("emission_stopped", false) - - var pm := ParticleProcessMaterial.new() - # 发射形状 - var shape := int(_n(emit.get("EmitterShape", 0))) - var emit_from_edge := int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0 - var radius := maxf(0.0, _n(emit.get("EmittingRadius", 10.0))) * 0.01 - var emitting_size := EffectSpace.position(_vec3(emit.get("EmittingSize", [0.0, 0.0, 0.0]))) - match shape: - 1: # CEmitterProperty::EMITTER_SHAPE_ELLIPSE (flat box approximation) - pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX - pm.emission_box_extents = Vector3(maxf(radius, 0.01), 0.005, maxf(radius, 0.01)) - 2: # CEmitterProperty::EMITTER_SHAPE_SQUARE - pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX - pm.emission_box_extents = Vector3(maxf(absf(emitting_size.x) * 0.5, 0.005), - maxf(absf(emitting_size.y) * 0.5, 0.005), maxf(absf(emitting_size.z) * 0.5, 0.005)) - 3: # CEmitterProperty::EMITTER_SHAPE_SPHERE - pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE if emit_from_edge else ParticleProcessMaterial.EMISSION_SHAPE_SPHERE - pm.emission_sphere_radius = maxf(0.01, radius) - _: - pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT - # 方向 / 速度 - var vel := _last_val(emit.get("TimeEventEmittingVelocity", []), 0.0) * 0.01 - var direction := _vec3(emit.get("EmittingDirection", [0.0, 0.0, 0.0])) - direction += Vector3(_last_val(emit.get("TimeEventEmittingDirectionX", []), 0.0), - _last_val(emit.get("TimeEventEmittingDirectionY", []), 0.0), - _last_val(emit.get("TimeEventEmittingDirectionZ", []), 0.0)) - direction = EffectSpace.direction(direction) - pm.direction = direction.normalized() if direction.length_squared() > 0.000001 else Vector3(0, 1, 0) - pm.spread = 25.0 if direction.length_squared() <= 0.000001 else 0.0 - # The reference computes OUTER/INNER velocity from each particle's radial - # position. ParticleProcessMaterial has no radial-velocity primitive, so keep - # the source mode visible and use a broad directional fallback until the - # particle-shader path is implemented. This avoids silently treating those - # modes as FREE while preserving the normal velocity range and gravity. - var advanced_type := int(g.get_meta("emitter_advanced_type", 0)) - if advanced_type == 1 or advanced_type == 2: - pm.spread = 180.0 - pm.initial_velocity_min = vel * 0.6 - pm.initial_velocity_max = vel - # 重力 - var grav := _last_val(p.get("particle", {}).get("TimeEventGravity", []), 0.0) - pm.gravity = Vector3(0, -grav * 0.01, 0) - # EffectLib stores independent half-width/height. Lifetime scale is XY, - # not a uniform random multiplier. Birth-time size tracks remain approximated - # by their last value until per-particle emitter-clock sampling is supported. - var half_width := _last_val(emit.get("TimeEventSizeX", []), 32.0) - var half_height := _last_val(emit.get("TimeEventSizeY", []), 32.0) - pm.scale_min = 1.0 - pm.scale_max = 1.0 - var scale_texture := CurveXYZTexture.new() - scale_texture.curve_x = _scale_curve(prop.get("TimeEventScaleX", [])) - scale_texture.curve_y = _scale_curve(prop.get("TimeEventScaleY", [])) - scale_texture.curve_z = _scale_curve([]) - pm.scale_curve = scale_texture - # Godot multiplies color by color_ramp. Store RGBA only in the ramp so - # channels are not squared, and sample all four tracks over particle age. - pm.color = Color.WHITE - pm.color_ramp = _color_ramp(prop) - # 旋转:ParticleProperty 的 0=NONE、1=TIME_EVENT、2=CW、3=CCW、 - # 4=RANDOM_DIRECTION。TIME_EVENT 按粒子寿命采样转速再积分。 - var rotation_type := int(_n(prop.get("RotationType", 0))) - var angle_begin := _n(prop.get("RotationRandomStartingBegin", 0.0)) - var angle_end := _n(prop.get("RotationRandomStartingEnd", 0.0)) - pm.angle_min = minf(angle_begin, angle_end) - pm.angle_max = maxf(angle_begin, angle_end) - var rspeed := _n(prop.get("RotationSpeed", 0.0)) - if rotation_type == 1: - var rotation_texture := CurveTexture.new() - rotation_texture.curve = _scale_curve(prop.get("TimeEventRotation", []), 0.0) - pm.angular_velocity_curve = rotation_texture - # Godot 4.7's curve branch accumulates directly in radians (CUSTOM.x), - # whereas the constant branch converts degrees internally. The curve - # retains source degrees/sec; this multiplier supplies the conversion. - pm.angular_velocity_min = deg_to_rad(1.0) - pm.angular_velocity_max = deg_to_rad(1.0) - if rotation_type == 3: - rspeed = -rspeed - if rotation_type in [2, 3] and rspeed != 0.0: - # Both EffectLib and Godot's constant angular velocity use degrees/sec. - pm.angular_velocity_min = rspeed - pm.angular_velocity_max = rspeed - if rotation_type == 4: - # Choose a stable per-birth sign in the drawing shader, retaining full - # magnitude in both directions and the initial angle in CUSTOM.x. - pm.anim_speed_min = 0.0 - pm.anim_speed_max = 0.0 - pm.anim_offset_min = 0.0 - pm.anim_offset_max = 1.0 - g.process_material = pm - - # 绘制网格 + 材质(billboard + 混合) - g.draw_pass_1 = ParticleFaces.build(Vector2(maxf(half_width, 0.0), maxf(half_height, 0.0)) * 0.02, - int(_n(prop.get("BillboardType", 1)))) - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - # EffectInstance::OnRender disables culling and depth writes for FX, but - # keeps depth testing so terrain/buildings can still occlude the effect. - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED - mat.billboard_mode = _billboard(int(_n(prop.get("BillboardType", 1)))) - # Only particle billboards consume INSTANCE_CUSTOM.x (rotation). Keep the - # shared mesh-material mapping separate: meshes have no particle custom data. - if int(_n(prop.get("BillboardType", 1))) == 1: - mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES - mat.billboard_keep_scale = true - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.blend_mode = _blend(int(_n(prop.get("SrcBlendType", 5))), int(_n(prop.get("DestBlendType", 2)))) - var textures := _particle_textures(prop) - mat.albedo_texture = textures[0] if not textures.is_empty() else _glow() - var frames: Texture2DArray - if textures.size() > 1: - frames = _texture_array(textures) - # CUSTOM.z is a stable per-birth seed; CUSTOM.y is this particle's age. - # Do not use a shared AnimatedTexture wall clock for all particles. - pm.anim_speed_min = 0.0 - pm.anim_speed_max = 0.0 - pm.anim_offset_min = 0.0 - pm.anim_offset_max = 1.0 - mat.vertex_color_use_as_albedo = true - g.material_override = ParticleColorMaterial.build(mat, int(_n(prop.get("ColorOperationType", 4))), frames, prop, g.lifetime) - _particle_states.append({ - "node": g, - "rows": positions, - "clock": 0.0, - "start_time": float(p.get("start_time", 0.0)), - "cycle_length": maxf(cycle, 0.0), - "cycle_loop": loop != 0, - "loop_count": maxi(loop_count, 0), - "emission_stopped": false, - "emission_started": false, - }) - return g - -func _advance_particle(state: Dictionary, delta: float) -> void: - var g: GPUParticles3D = state.get("node") - if g == null or not is_instance_valid(g): - return - var rows: Array = state.get("rows", []) - # Position tracks are optional; stationary emitters still obey lifecycle gates. - var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0) - state["clock"] = clock - var start := float(state.get("start_time", 0.0)) - if clock < start: - g.emitting = false - return - var local := clock - start - var cycle := float(state.get("cycle_length", 0.0)) - var loop := bool(state.get("cycle_loop", false)) - var loop_count := int(state.get("loop_count", 0)) - var emission_finished := cycle > 0.0 and ((not loop and local >= cycle) or - (loop and loop_count > 0 and local >= cycle * loop_count)) - if emission_finished: - g.emitting = false - state["emission_stopped"] = true - return - if _playing and not bool(state.get("emission_started", false)) and not bool(state.get("emission_stopped", false)): - g.restart() - g.emitting = true - state["emission_started"] = true - if not rows.is_empty() and not bool(state.get("emission_stopped", false)): - g.position = _position_at(rows, local) - -func _build_mesh(m: Dictionary) -> Node3D: - var mde_path := _resolve_mde(String(m.get("mesh_file", ""))) - if mde_path == "": - return null - var mesh_data := Mde.new().parse_file(mde_path) - if mesh_data.is_empty(): - return null - var n := Node3D.new() - n.name = "mesh_" + String(m.get("mesh_file", "?")).get_basename() - var positions: Array = m.get("position", []) - if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5: - n.position = _position_at(positions, 0.0) - - var geometries: Array = mesh_data.get("geometries", []) - var elements: Array = m.get("elements", []) - var states: Array[Dictionary] = [] - for geometry_index in geometries.size(): - var geometry: Dictionary = geometries[geometry_index] - var frames: Array = geometry.get("frames", []) - if frames.is_empty(): - continue - var meshes: Array = [] - for frame in frames: - var array_mesh := _array_mesh(frame) - meshes.append(array_mesh) - var first_mesh: ArrayMesh = null - for candidate in meshes: - if candidate is ArrayMesh: - first_mesh = candidate - break - if first_mesh == null: - continue - var mi := MeshInstance3D.new() - mi.name = String(geometry.get("name", "geometry_%d" % geometry_index)) - mi.mesh = first_mesh - var element: Dictionary = elements[geometry_index] if geometry_index < elements.size() else {} - var textures := _load_mesh_textures(mde_path, String(geometry.get("diffuse", ""))) - mi.material_override = _mesh_material(element, textures) - n.add_child(mi) - states.append({ - "node": mi, - "frames": frames, - "meshes": meshes, - "element": element, - "textures": textures, - "frame": -1, - "texture_frame": -1, - }) - - var frame_delay := maxf(float(m.get("frame_delay", 0.02)), 0.0001) - var frame_count := int(mesh_data.get("frame_count", 0)) - var loop_count := int(m.get("loop_count", 0)) - var loops := loop_count if loop_count > 0 else 1 - var duration := float(m.get("start_time", 0.0)) + frame_count * frame_delay * loops - var state := { - "node": n, - "children": states, - "position": positions, - "clock": 0.0, - "start_time": float(m.get("start_time", 0.0)), - "frame_delay": frame_delay, - "loop": int(m.get("loop", 0)) != 0, - "loop_count": loop_count, - "duration": maxf(duration, frame_delay), - } - _mesh_states.append(state) - n.set_meta("mde_path", mde_path) - n.set_meta("mde_version", int(mesh_data.get("version", 0))) - n.set_meta("mesh_states", states) - n.visible = false - return n - -func _build_light(l: Dictionary) -> OmniLight3D: - var light := OmniLight3D.new() - light.name = "simple_light" - var positions: Array = l.get("position", []) - if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5: - light.position = _position_at(positions, 0.0) - var diffuse: Array = l.get("diffuse", [0.0, 0.0, 0.0, 1.0]) - if diffuse.size() >= 4: - light.light_color = Color(float(diffuse[0]), float(diffuse[1]), float(diffuse[2]), float(diffuse[3])) - var attenuation := float(l.get("attenuation1", 0.1)) + float(l.get("attenuation2", 0.0)) - light.omni_attenuation = clampf(1.0 / maxf(1.0, 1.0 + attenuation * 10.0), 0.01, 1.0) - light.visible = false - var duration := maxf(float(l.get("duration", 1.0)), 0.0001) - var loop_count := int(l.get("loop_count", 0)) - var loops := loop_count if loop_count > 0 else 1 - var state := { - "node": light, - "data": l, - "clock": 0.0, - "start_time": float(l.get("start_time", 0.0)), - "duration": float(l.get("start_time", 0.0)) + duration * loops, - "loop": int(l.get("loop", 0)) != 0, - "loop_count": loop_count, - } - _light_states.append(state) - light.set_meta("simple_light", true) - light.set_meta("ambient", l.get("ambient", [])) - light.set_meta("max_range_cm", float(l.get("max_range", 300.0))) - return light - -func _advance_light(state: Dictionary, delta: float) -> void: - var light: OmniLight3D = state.get("node") - if light == null or not is_instance_valid(light): - return - var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0) - state["clock"] = clock - var start := float(state.get("start_time", 0.0)) - if clock < start: - light.visible = false - return - var data: Dictionary = state.get("data", {}) - var duration := maxf(float(data.get("duration", 1.0)), 0.0001) - var local := clock - start - var loop := bool(state.get("loop", false)) - var loop_count := int(state.get("loop_count", 0)) - if loop: - if loop_count > 0 and local >= duration * loop_count: - light.visible = false - return - local = fmod(local, duration) - elif local >= duration: - light.visible = false - return - light.visible = true - light.position = _position_at(data.get("position", []), local) - var range_ratio := clampf(_event_value(data.get("range", []), local, 1.0), 0.0, 1.0) - light.omni_range = maxf(0.01, float(data.get("max_range", 300.0)) * range_ratio * 0.01) - var diffuse: Array = data.get("diffuse", [0.0, 0.0, 0.0, 1.0]) - if diffuse.size() >= 4: - var color := light.light_color - color.a = float(diffuse[3]) - light.light_color = color - - -func _advance_mesh(state: Dictionary, delta: float) -> void: - var n: Node3D = state.get("node") - if n == null or not is_instance_valid(n): - return - var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0) - state["clock"] = clock - var start := float(state.get("start_time", 0.0)) - if clock < start: - n.visible = false - return - var local := clock - start - n.position = _position_at(state.get("position", []), local) - var children: Array = state.get("children", []) - if children.is_empty(): - return - var frame_delay := maxf(float(state.get("frame_delay", 0.02)), 0.0001) - var frame_count := 0 - for child in children: - frame_count = maxi(frame_count, Array(child.get("frames", [])).size()) - if frame_count < 1: - return - var loop := bool(state.get("loop", false)) - var loop_count := int(state.get("loop_count", 0)) - var frame_number := int(floor(local / frame_delay)) - if loop: - var total_frames := frame_count * (loop_count if loop_count > 0 else 1) - if loop_count > 0 and frame_number >= total_frames: - n.visible = false - return - frame_number = frame_number % frame_count - else: - if frame_number >= frame_count: - n.visible = false - return - n.visible = true - for child in children: - _set_mesh_child_frame(child, frame_number, local) - -func _set_mesh_child_frame(state: Dictionary, frame_number: int, local: float) -> void: - var frames: Array = state.get("frames", []) - var meshes: Array = state.get("meshes", []) - var mi: MeshInstance3D = state.get("node") - if mi == null or frame_number < 0 or frame_number >= frames.size() or frame_number >= meshes.size(): - return - if int(state.get("frame", -1)) != frame_number: - if meshes[frame_number] is ArrayMesh: - mi.mesh = meshes[frame_number] - state["frame"] = frame_number - var frame: Dictionary = frames[frame_number] - var visibility := clampf(float(frame.get("visibility", 1.0)), 0.0, 1.0) - var element: Dictionary = state.get("element", {}) - var alpha := _event_value(element.get("TimeEventAlpha", []), local, 1.0) - var mat := mi.material_override as StandardMaterial3D - if mat: - var color := mat.albedo_color - color.a = clampf(float(element.get("_base_alpha", color.a)) * visibility * alpha, 0.0, 1.0) - mat.albedo_color = color - var textures: Array = state.get("textures", []) - if textures.size() > 0 and mat: - var texture_delay := maxf(float(element.get("TextureAnimationFrameDelay", 0.02)), 0.0001) - var texture_frame := int(element.get("TextureAnimationStartFrame", 0)) + int(floor(local / texture_delay)) - if int(element.get("TextureAnimationLoopEnable", 1)) != 0: - texture_frame = texture_frame % textures.size() - else: - texture_frame = mini(texture_frame, textures.size() - 1) - if int(state.get("texture_frame", -1)) != texture_frame: - mat.albedo_texture = textures[texture_frame] - state["texture_frame"] = texture_frame - -func _array_mesh(frame: Dictionary) -> ArrayMesh: - var vertices: PackedVector3Array = frame.get("vertices", PackedVector3Array()) - var uvs: PackedVector2Array = frame.get("uvs", PackedVector2Array()) - var count := mini(vertices.size(), uvs.size()) - count -= count % 3 - if count < 3: - return null - if count != vertices.size(): - vertices = vertices.slice(0, count) - uvs = uvs.slice(0, count) - var arrays := [] - arrays.resize(Mesh.ARRAY_MAX) - arrays[Mesh.ARRAY_VERTEX] = vertices - arrays[Mesh.ARRAY_TEX_UV] = uvs - var mesh := ArrayMesh.new() - mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) - return mesh - -func _mesh_material(element: Dictionary, textures: Array) -> StandardMaterial3D: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.billboard_mode = _billboard(int(_n(element.get("BillboardType", 0)))) - mat.blend_mode = _blend(int(_n(element.get("BlendingSrcType", 5))), int(_n(element.get("BlendingDestType", 2)))) - var cf = element.get("ColorFactor", [1.0, 1.0, 1.0, 1.0]) - var color := Color(1, 1, 1, 1) - if cf is Array and cf.size() >= 4: - color = Color(float(cf[0]), float(cf[1]), float(cf[2]), float(cf[3])) - element["_base_alpha"] = color.a - mat.albedo_color = color - mat.albedo_texture = textures[0] if textures.size() > 0 else _glow() - return mat - -func _load_mesh_textures(mde_path: String, diffuse: String) -> Array: - var out: Array = [] - if diffuse == "": - return out - var mde_dir := mde_path.get_base_dir() - var diffuse_name := diffuse.replace("\\", "/").get_file() - var diffuse_path := mde_dir.path_join(diffuse_name) - if not FileAccess.file_exists(diffuse_path): - diffuse_path = _resolve_asset_file(diffuse) - if diffuse_path != "" and diffuse_path.get_extension().to_lower() == "ifl": - for line in FileAccess.get_file_as_string(diffuse_path).split("\n"): - var name := line.strip_edges().trim_prefix("\"").trim_suffix("\"") - if name == "" or name.begins_with("#"): - continue - var tex := UiAssets.load_tex(diffuse_path.get_base_dir(), name) - if tex: - out.append(tex) - else: - var tex := UiAssets.load_tex(mde_dir, diffuse_name) - if tex == null: - tex = UiAssets.load_tex(assets_root, diffuse) - if tex: - out.append(tex) - return out - -func _resolve_mde(file_name: String) -> String: - if file_name == "": - return "" - var rel := file_name.replace("\\", "/") - var mse_dir := String(spec.get("dir", "")) - var sibling := mse_dir.path_join(rel.get_file()) if mse_dir != "" else "" - if sibling != "" and FileAccess.file_exists(sibling): - return sibling - var direct := assets_root.path_join(rel.lstrip("/")) if assets_root != "" else "" - if direct != "" and FileAccess.file_exists(direct): - return direct - return _scan_for_mde(assets_root, rel.get_file(), 8) if assets_root != "" else "" - -func _resolve_asset_file(file_name: String) -> String: - var base := file_name.replace("\\", "/").get_file() - var mse_dir := String(spec.get("dir", "")) - var sibling := mse_dir.path_join(base) if mse_dir != "" else "" - if sibling != "" and FileAccess.file_exists(sibling): - return sibling - return _scan_for_mde(assets_root, base, 8) if assets_root != "" else "" - -func _scan_for_mde(dir: String, basename: String, depth: int) -> String: - if dir == "" or depth < 0: - return "" - var da := DirAccess.open(dir) - if da == null: - return "" - for file in da.get_files(): - if file.to_lower() == basename.to_lower(): - return dir.path_join(file) - for sub in da.get_directories(): - if sub.begins_with("."): - continue - var result := _scan_for_mde(dir.path_join(sub), basename, depth - 1) - if result != "": - return result - return "" - -func _position_at(rows, time: float) -> Vector3: - # CEffectElementBase::GetPosition: each row's moving type describes the - # segment starting at that row. Bezier uses P0 + controlPoint as its middle - # control vertex, then keeps the result in the effect's centimetre space. - if not rows is Array or rows.is_empty(): - return Vector3.ZERO - var first = rows[0] - if not first is Array or first.size() < 5: - return Vector3.ZERO - if time <= float(first[0]): - return _position_vector(first) * 0.01 - for i in range(1, rows.size()): - var current = rows[i] - if not current is Array or current.size() < 5: - continue - var previous = rows[i - 1] - if not previous is Array or previous.size() < 5: - return _position_vector(current) * 0.01 - var t0 := float(previous[0]) - var t1 := float(current[0]) - if time > t1: - continue - var t := 1.0 if is_zero_approx(t1 - t0) else clampf((time - t0) / (t1 - t0), 0.0, 1.0) - var p0 := _position_vector(previous) - var p1 := _position_vector(current) - if String(previous[1]) == "MOVING_TYPE_BEZIER_CURVE" and previous.size() >= 8: - var control := EffectSpace.direction(Vector3(float(previous[5]), float(previous[6]), float(previous[7]))) - var middle := p0 + control - var inv := 1.0 - t - return (p0 * inv * inv + middle * 2.0 * inv * t + p1 * t * t) * 0.01 - return p0.lerp(p1, t) * 0.01 - var last = rows[-1] - return _position_vector(last) * 0.01 if last is Array and last.size() >= 5 else Vector3.ZERO - -func _position_vector(row) -> Vector3: - if row is Array and row.size() >= 5: - return EffectSpace.direction(Vector3(float(row[2]), float(row[3]), float(row[4]))) - return Vector3.ZERO - -func _event_value(rows, time: float, fallback: float) -> float: - if not rows is Array or rows.is_empty(): - return fallback - var previous = rows[0] - if previous is Array and previous.size() >= 2 and time <= float(previous[0]): - return float(previous[1]) - for row in rows: - if not row is Array or row.size() < 2: - continue - var x := float(row[0]) - if time <= x: - var x0 := float(previous[0]) - var y0 := float(previous[1]) - var y1 := float(row[1]) - var t := 1.0 if is_zero_approx(x - x0) else clampf((time - x0) / (x - x0), 0.0, 1.0) - return lerpf(y0, y1, t) - previous = row - return float(previous[1]) if previous is Array and previous.size() >= 2 else fallback - -# --- helpers --------------------------------------------------------- - -func _n(v) -> float: - if v is Array: - return float(v[0]) if v.size() > 0 else 0.0 - if v is float or v is int: - return float(v) - return 0.0 - -func _vec3(v) -> Vector3: - if v is Array and v.size() >= 3: - return Vector3(float(v[0]), float(v[1]), float(v[2])) - return Vector3.ZERO - -func _particle_textures(prop: Dictionary) -> Array[Texture2D]: - var rows: Variant = prop.get("TextureFiles", []) - if not rows is Array or rows.is_empty(): - return [] - # ParticleSystemData resolves relative texture names against the .mse - # directory. UiAssets keeps the same behavior and uses the native DDS path. - var mse_dir := String(spec.get("dir", "")) - var textures: Array[Texture2D] = [] - for row in rows: - var file := String(row[0]) if row is Array and not row.is_empty() else String(row) - if file == "": - continue - var tex: Texture2D = UiAssets.load_tex(mse_dir, file) if mse_dir != "" else null - if tex == null: - tex = UiAssets.load_tex(assets_root, file) - if tex: - textures.append(tex) - return textures - -func _texture_array(textures: Array[Texture2D]) -> Texture2DArray: - var images: Array[Image] = [] - var width := 1 - var height := 1 - for texture in textures: - width = maxi(width, texture.get_width()) - height = maxi(height, texture.get_height()) - for texture in textures: - var img := texture.get_image() - if img == null or img.is_empty(): - return null - if img.is_compressed(): - if img.decompress() != OK: - return null - img.convert(Image.FORMAT_RGBA8) - img.clear_mipmaps() - if img.get_width() != width or img.get_height() != height: - img.resize(width, height, Image.INTERPOLATE_BILINEAR) - images.append(img) - var frames := Texture2DArray.new() - return frames if frames.create_from_images(images) == OK else null - -# List 表末行的值(列 idx,默认 col 1 = 时间后第一个数) -func _last_val(rows, def: float, col := 1) -> float: - if rows is Array and rows.size() > 0: - var r = rows[-1] - if r is Array and r.size() > col and (r[col] is float or r[col] is int): - return float(r[col]) - return def - -# Independent lifetime channel, with linear tangents matching EffectLib. -func _scale_curve(rows, fallback := 1.0) -> Curve: - var values := {} - if rows is Array: - for row in rows: - if row is Array and row.size() >= 2 and (row[0] is float or row[0] is int) and (row[1] is float or row[1] is int): - var time := float(row[0]) - var value := float(row[1]) - if is_finite(time) and is_finite(value): - if not values.has(time): - values[time] = [] - values[time].append(value) - var times := values.keys() - times.sort() - var track: Array = [] - for time in times: - for value in values[time]: - track.append([time, value]) - var points := {0.0: _event_value(track, 0.0, fallback), 1.0: _event_value(track, 1.0, fallback)} - for i in times.size(): - var time: float = times[i] - if time > 0.0 and time < 1.0: - points[time] = values[time][0] - if time >= 0.0 and time < 1.0 and values[time].size() > 1: - # EffectLib takes the first value exactly at a duplicate key, then - # departs from the last. A texture cannot encode a zero-width jump; - # preserve both sides using a narrow transition, not a long ramp. - var next_time: float = times[i + 1] if i + 1 < times.size() else 1.0 - # Keep wider than Curve's approximate-equality epsilon. - var after := time + minf(0.0001, (next_time - time) * 0.5) - points[after] = values[time][-1] - var c := Curve.new() - c.min_value = minf(0.0, points.values().min()) - c.max_value = maxf(1.0, points.values().max()) - times = points.keys() - times.sort() - for time in times: - c.add_point(Vector2(time, points[time]), 0.0, 0.0, Curve.TANGENT_LINEAR, Curve.TANGENT_LINEAR) - return c - -func _color_ramp(prop: Dictionary) -> GradientTexture1D: - # Match EffectLib ParticleSystemData: union the RGBA key times, then - # linearly evaluate each channel there. Preserve independent channel knots. - var times := {0.0: true, 1.0: true} - var tracks: Array = [] - for key in ["TimeEventColorRed", "TimeEventColorGreen", "TimeEventColorBlue", "TimeEventAlpha"]: - var points := {} - var rows = prop.get(key, []) - if rows is Array: - for row in rows: - if row is Array and row.size() >= 2 and (row[0] is float or row[0] is int) and (row[1] is float or row[1] is int): - var time := float(row[0]) - var value := float(row[1]) - if is_finite(time) and is_finite(value): - points[time] = value - times[clampf(time, 0.0, 1.0)] = true - var ordered := points.keys() - ordered.sort() - var track: Array = [] - for time in ordered: - track.append([time, points[time]]) - tracks.append(track) - var ordered_times := times.keys() - ordered_times.sort() - var offsets := PackedFloat32Array() - var colors := PackedColorArray() - for time in ordered_times: - offsets.append(time) - colors.append(Color( - clampf(_event_value(tracks[0], time, 1.0), 0.0, 1.0), - clampf(_event_value(tracks[1], time, 1.0), 0.0, 1.0), - clampf(_event_value(tracks[2], time, 1.0), 0.0, 1.0), - clampf(_event_value(tracks[3], time, 1.0), 0.0, 1.0))) - var grad := Gradient.new() - grad.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_LINEAR - grad.offsets = offsets - grad.colors = colors - var gt := GradientTexture1D.new() - gt.gradient = grad - return gt - -func _billboard(bt: int) -> int: - # EffectLib Type.h: 0=NONE, 1=ALL, 2=Y. Particle 3=LIE, - # 4=2FACE and 5=3FACE still use a camera-facing approximation below. - if bt == 2: - return BaseMaterial3D.BILLBOARD_FIXED_Y - if bt == 0: - return BaseMaterial3D.BILLBOARD_DISABLED - return BaseMaterial3D.BILLBOARD_ENABLED - -func _blend(src: int, dst: int) -> int: - # D3DBLEND: 5=SRCALPHA, 6=INVSRCALPHA, 2=ONE, 4=INVSRCCOLOR. - # Ordinary transparency must not fall through to additive glow. - if src == 5 and dst == 6: - return BaseMaterial3D.BLEND_MODE_MIX - # Remaining pairs retain the existing approximations, not exact D3D parity. - if dst == 2: - return BaseMaterial3D.BLEND_MODE_ADD - if dst == 4: - return BaseMaterial3D.BLEND_MODE_MIX - return BaseMaterial3D.BLEND_MODE_ADD - -# 程序化径向渐变(代替 .dds 粒子纹理) -static func _glow() -> Texture2D: - if _glow_tex == null: - var s := 48 - var img := Image.create(s, s, false, Image.FORMAT_RGBA8) - for y in s: - for x in s: - var d := Vector2(x - s / 2.0, y - s / 2.0).length() / (s / 2.0) - var a := clampf(1.0 - d, 0.0, 1.0) - a = a * a - img.set_pixel(x, y, Color(1, 1, 1, a)) - _glow_tex = ImageTexture.create_from_image(img) - return _glow_tex diff --git a/project/fx/effect_player.gd.uid b/project/fx/effect_player.gd.uid deleted file mode 100644 index 65a90dfd..00000000 --- a/project/fx/effect_player.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dusxpcvp74to5 diff --git a/project/fx/effect_registry.gd b/project/fx/effect_registry.gd deleted file mode 100644 index b990d8a7..00000000 --- a/project/fx/effect_registry.gd +++ /dev/null @@ -1,171 +0,0 @@ -# EffectRegistry (P5) —— 按名字 / 路径找 `.mse`,缓存解析结果,生成 EffectPlayer。 -# -# var fxr := preload("res://fx/effect_registry.gd").new() -# fxr.setup(assets_root) -# fxr.spawn("geompung_3_sword", weapon_bone_node) # 挂到某节点下播一次性 -# var pv := fxr.spawn_at("hit_spark", world, global_pos) -extends RefCounted - -## Lifecycle boundaries of every effect this registry creates. lifetime_ms is the -## MSE-defined length (-1 = looping, lives as long as its owner). fx_finished is -## emitted when the node actually leaves the tree; reason "cleanup" means its own -## playback clock freed it, "removed" means the owner/scene removed it earlier. -signal fx_spawned(effect: String, fx_id: int, lifetime_ms: int) -signal fx_finished(effect: String, fx_id: int, lifetime_ms: int, elapsed_ms: int, reason: String) - -const Mse = preload("res://fx/mse.gd") -const EffectPlayer = preload("res://fx/effect_player.gd") - -var assets_root := "" -var _path_cache := {} # name -> abs .mse path ("" = not found) -var _spec_cache := {} # abs path -> parsed spec -# Process-wide so ids stay unique when GameScene rebuilds its registry. -static var _next_fx_id := 0 - -var _basename_index := {} # lowercase basename / filename -> abs path -var _indexed := false - -func setup(assets: String) -> void: - assets_root = assets - _path_cache.clear() - _basename_index.clear() - _indexed = false - -func _ensure_basename_index() -> void: - if _indexed or assets_root == "": - return - _indexed = true - var dirs: Array = [assets_root] - while not dirs.is_empty(): - var dir: String = dirs.pop_back() - var da := DirAccess.open(dir) - if da == null: - continue - for f in da.get_files(): - if f.ends_with(".mse"): - var abs_p := dir.path_join(f) - var fn := f.to_lower() - var bn := f.get_basename().to_lower() - if not _basename_index.has(bn): - _basename_index[bn] = abs_p - if not _basename_index.has(fn): - _basename_index[fn] = abs_p - for sub in da.get_directories(): - if sub.begins_with("."): - continue - var sub_low := sub.to_lower() - if sub_low in ["map", "sound", "music", "property", "terrain", "tree", "textureset"]: - continue - dirs.append(dir.path_join(sub)) - -# name 可为: 裸名 "xxx"(扫 **/effect/ 下 xxx.mse)· 相对 "effect/xxx.mse" · "d:/ymir work/.../xxx.mse" -func resolve(name: String) -> String: - if _path_cache.has(name): - return _path_cache[name] - var p := _resolve_uncached(name) - _path_cache[name] = p - return p - -func _resolve_uncached(name: String) -> String: - if assets_root == "": - return "" - var rel := name.replace("\\", "/") - # FlyData resolves sibling AttachFile/BombEffect paths to absolute files. - # EffectRegistry must accept those resolved paths directly; otherwise the - # second path_join below prefixes assets_root a second time and silently - # drops the 40250 effect. - if rel.begins_with("/") and FileAccess.file_exists(rel): - return rel - if rel.length() >= 2 and rel[1] == ":": - rel = rel.substr(2) - rel = rel.lstrip("/") - if not rel.ends_with(".mse"): - rel += ".mse" - # 直接 / 散包相对 - for cand in [assets_root.path_join(rel)]: - if FileAccess.file_exists(cand): - return cand - var da := DirAccess.open(assets_root) - if da: - for sub in da.get_directories(): - var c := assets_root.path_join(sub).path_join(rel) - if FileAccess.file_exists(c): - return c - # 裸名:从快速特效索引中查找,避免递归全盘扫描造成主线程卡死 - if not name.contains("/"): - _ensure_basename_index() - var bn := rel.get_file().get_basename().to_lower() - if _basename_index.has(bn): - return _basename_index[bn] - var fn := rel.get_file().to_lower() - if _basename_index.has(fn): - return _basename_index[fn] - return "" - -func spec_for(name: String) -> Dictionary: - var p := resolve(name) - if p == "": - return {} - if _spec_cache.has(p): - return _spec_cache[p] - var s := Mse.new().parse_file(p) - _spec_cache[p] = s - return s - -# 挂到 parent 下(跟随 parent 变换),播一次性 -func spawn(name: String, parent: Node3D, one_shot := true) -> Node3D: - var s := spec_for(name) - if s.is_empty() or parent == null: - return null - var fx: Node3D = EffectPlayer.new() - fx.name = "fx_" + name.get_file().get_basename() - fx.one_shot = one_shot - fx.build(s, assets_root) - parent.add_child(fx) - fx.play(one_shot) - _track(fx, name) - return fx - -func _track(fx: Node3D, name: String) -> void: - _next_fx_id += 1 - var fx_id := _next_fx_id - var lifetime_ms: int = fx.defined_lifetime_ms - fx.tree_exiting.connect(_on_fx_exiting.bind(fx, name, fx_id, lifetime_ms, Time.get_ticks_msec()), - CONNECT_ONE_SHOT) - fx_spawned.emit(name, fx_id, lifetime_ms) - -func _on_fx_exiting(fx: Node3D, name: String, fx_id: int, lifetime_ms: int, spawned_ms: int) -> void: - var reason := "removed" - if is_instance_valid(fx) and not String(fx.finish_reason).is_empty(): - reason = fx.finish_reason - fx_finished.emit(name, fx_id, lifetime_ms, Time.get_ticks_msec() - spawned_ms, reason) - -# 在世界某点播一次性 -func spawn_at(name: String, world_parent: Node3D, global_pos: Vector3, one_shot := true) -> Node3D: - var fx := spawn(name, world_parent, one_shot) - if fx: - fx.global_position = global_pos - return fx - -func spawn_motion(event: Dictionary, view: Node3D, world_parent: Node3D) -> Node3D: - var effect := spawn(String(event.get("effect", "")), world_parent, true) - if effect == null: - return null - var anchor := preload("res://fx/motion_effect_anchor.gd").new() - effect.add_child(anchor) - if not anchor.configure(view, event): - effect.free() - return null - return effect - -func spawn_target(event: Dictionary, target: Node3D, world_parent: Node3D) -> Node3D: - if not is_instance_valid(target) or bool(event.get("fishing_effect", false)): - return null # Fishing requires its separate water landing position. - var effect := spawn(String(event.get("effect", "")), world_parent, true) - if effect == null: return null - var anchor := preload("res://fx/target_effect_anchor.gd").new() - effect.add_child(anchor) - if not anchor.configure(target, event.get("pos", Vector3.ZERO), bool(event.get("following", false))): - effect.free() - return null - return effect diff --git a/project/fx/effect_registry.gd.uid b/project/fx/effect_registry.gd.uid deleted file mode 100644 index c1922bde..00000000 --- a/project/fx/effect_registry.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dyu4mphxwm5ti diff --git a/project/fx/effect_space.gd b/project/fx/effect_space.gd deleted file mode 100644 index 14c4d1af..00000000 --- a/project/fx/effect_space.gd +++ /dev/null @@ -1,6 +0,0 @@ -extends RefCounted -# All MSE/MDE vectors are Z-up. Convert orientation before the cm->m scale. -static func direction(source: Vector3) -> Vector3: - return Vector3(source.x, source.z, -source.y) -static func position(source: Vector3) -> Vector3: - return direction(source) * 0.01 diff --git a/project/fx/effect_space.gd.uid b/project/fx/effect_space.gd.uid deleted file mode 100644 index aab82093..00000000 --- a/project/fx/effect_space.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c3htid787d4uy diff --git a/project/fx/mde.gd b/project/fx/mde.gd deleted file mode 100644 index d34429c0..00000000 --- a/project/fx/mde.gd +++ /dev/null @@ -1,197 +0,0 @@ -# Mde (P5) —— ClientVS22 EffectMesh 的 .mde 二进制读取器。 -# -# 40250/ClientVS22/source/EffectLib/EffectMesh.cpp 支持两种布局: -# EffectData —— v1:每个几何体固定顶点/索引/UV 数量,每帧只有 visibility; -# MDEData002 —— v2:每帧带 changed/visibility 以及三组数量。 -# 参考端把索引顶点展开成 TPTVertex,并把 UV.y 取反;这里同样展开, -# 同时把客户端厘米坐标换成 Godot 的米坐标。 -extends RefCounted - -const HEADER_SIZE := 11 -const GEOMETRY_HEADER_SIZE := 160 # object name 32 + diffuse path 128 -const MAX_GEOMETRIES := 256 -const MAX_FRAMES := 4096 -const MAX_ELEMENTS := 1_000_000 - -var last_error := "" - -func parse_file(path: String) -> Dictionary: - last_error = "" - if not FileAccess.file_exists(path): - last_error = "no such file: " + path - return {} - return parse_bytes(FileAccess.get_file_as_bytes(path)) - -func parse_bytes(data: PackedByteArray) -> Dictionary: - last_error = "" - if data.size() < HEADER_SIZE + 8: - last_error = "truncated header" - return {} - - var header := _fixed_string(data, 0, HEADER_SIZE) - var version := 0 - if header == "EffectData": - version = 1 - elif header == "MDEData002": - version = 2 - else: - last_error = "unknown header: " + header - return {} - - var offset := HEADER_SIZE - var geometry_count := int(data.decode_s32(offset)) - offset += 4 - var frame_count := int(data.decode_s32(offset)) - offset += 4 - if geometry_count < 1 or geometry_count > MAX_GEOMETRIES: - last_error = "invalid geometry count: %d" % geometry_count - return {} - if frame_count < 1 or frame_count > MAX_FRAMES: - last_error = "invalid frame count: %d" % frame_count - return {} - - var geometries: Array = [] - for geometry_index in geometry_count: - if not _has(data, offset, GEOMETRY_HEADER_SIZE): - last_error = "truncated geometry header %d" % geometry_index - return {} - var name := _fixed_string(data, offset, 32) - offset += 32 - var diffuse := _fixed_string(data, offset, 128) - offset += 128 - - var fixed_vertex_count := 0 - var fixed_index_count := 0 - var fixed_uv_count := 0 - if version == 1: - if not _has(data, offset, 12): - last_error = "truncated v1 geometry counts %d" % geometry_index - return {} - fixed_vertex_count = int(data.decode_u32(offset)) - fixed_index_count = int(data.decode_u32(offset + 4)) - fixed_uv_count = int(data.decode_u32(offset + 8)) - offset += 12 - if not _valid_counts(fixed_vertex_count, fixed_index_count, fixed_uv_count): - last_error = "invalid v1 geometry counts %d" % geometry_index - return {} - - var frames: Array = [] - for frame_index in frame_count: - var changed := 0 - var visibility := 1.0 - var vertex_count := fixed_vertex_count - var index_count := fixed_index_count - var uv_count := fixed_uv_count - if version == 2: - if not _has(data, offset, 17): # byte + float + 3 x DWORD - last_error = "truncated v2 frame header %d/%d" % [geometry_index, frame_index] - return {} - changed = int(data.decode_u8(offset)) - offset += 1 - visibility = float(data.decode_float(offset)) - offset += 4 - vertex_count = int(data.decode_u32(offset)) - offset += 4 - index_count = int(data.decode_u32(offset)) - offset += 4 - uv_count = int(data.decode_u32(offset)) - offset += 4 - else: - if not _has(data, offset, 4): - last_error = "truncated v1 frame header %d/%d" % [geometry_index, frame_index] - return {} - visibility = float(data.decode_float(offset)) - offset += 4 - if not _valid_counts(vertex_count, index_count, uv_count): - last_error = "invalid frame counts %d/%d" % [geometry_index, frame_index] - return {} - - var bytes_needed := vertex_count * 12 + index_count * 4 + uv_count * 8 + index_count * 4 - if not _has(data, offset, bytes_needed): - last_error = "truncated frame data %d/%d" % [geometry_index, frame_index] - return {} - - var positions := PackedVector3Array() - positions.resize(vertex_count) - for i in vertex_count: - var base := offset + i * 12 - positions[i] = preload("res://fx/effect_space.gd").position(Vector3( - float(data.decode_float(base)), - float(data.decode_float(base + 4)), - float(data.decode_float(base + 8)))) - offset += vertex_count * 12 - - var indices := PackedInt32Array() - indices.resize(index_count) - for i in index_count: - indices[i] = int(data.decode_s32(offset + i * 4)) - offset += index_count * 4 - - var uvs := PackedVector2Array() - uvs.resize(uv_count) - for i in uv_count: - var base := offset + i * 8 - uvs[i] = Vector2( - float(data.decode_float(base)), - -float(data.decode_float(base + 4))) - offset += uv_count * 8 - - var texture_indices := PackedInt32Array() - texture_indices.resize(index_count) - for i in index_count: - texture_indices[i] = int(data.decode_s32(offset + i * 4)) - offset += index_count * 4 - - # EffectMesh.cpp renders a non-indexed TPTVertex array. Keep the - # same order so a frame is directly usable as a TriangleList. - var render_vertices := PackedVector3Array() - var render_uvs := PackedVector2Array() - render_vertices.resize(index_count) - render_uvs.resize(index_count) - for i in index_count: - var vi := indices[i] - var ti := texture_indices[i] - if vi < 0 or vi >= positions.size() or ti < 0 or ti >= uvs.size(): - last_error = "index out of range %d/%d" % [geometry_index, frame_index] - return {} - render_vertices[i] = positions[vi] - render_uvs[i] = uvs[ti] - frames.append({ - "changed": changed, - "visibility": visibility, - "vertex_count": vertex_count, - "index_count": index_count, - "uv_count": uv_count, - "vertices": render_vertices, - "uvs": render_uvs, - }) - - geometries.append({ - "name": name, - "diffuse": diffuse, - "frames": frames, - }) - - return { - "version": version, - "geometry_count": geometry_count, - "frame_count": frame_count, - "geometries": geometries, - } - -func _valid_counts(vertex_count: int, index_count: int, uv_count: int) -> bool: - return vertex_count >= 0 and index_count >= 0 and uv_count >= 0 \ - and vertex_count <= MAX_ELEMENTS and index_count <= MAX_ELEMENTS \ - and uv_count <= MAX_ELEMENTS - -func _has(data: PackedByteArray, offset: int, length: int) -> bool: - return offset >= 0 and length >= 0 and offset <= data.size() - length - -func _fixed_string(data: PackedByteArray, offset: int, length: int) -> String: - var out := "" - for i in length: - var value := int(data[offset + i]) - if value == 0: - break - out += String.chr(value) - return out diff --git a/project/fx/mde.gd.uid b/project/fx/mde.gd.uid deleted file mode 100644 index d3e5f003..00000000 --- a/project/fx/mde.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bs8t4ae40g81s diff --git a/project/fx/motion_effect_anchor.gd b/project/fx/motion_effect_anchor.gd deleted file mode 100644 index 3bf7a26a..00000000 --- a/project/fx/motion_effect_anchor.gd +++ /dev/null @@ -1,44 +0,0 @@ -# 40250 ActorInstanceMotionEvent / ActorInstanceAttach: bone * offset * actor -# in row-vector order. The Godot equivalent is actor * offset * bone. -extends Node - -const SOURCE_TO_METRES := Transform3D(Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0)), Vector3.ZERO) -var model: Node3D -var animator: Node -var event := {} - -func configure(view: Node3D, data: Dictionary) -> bool: - model = view.get("model") - animator = view.get("anim") - event = data.duplicate() - # Sample immediately, then after animation (default priority 0) every frame. - process_priority = 100 - if not update_anchor(): - return false - var attached := bool(event.get("attaching", false)) - set_process(not bool(event.get("independent", false)) and - (not attached or bool(event.get("following", false)))) - return true - -func update_anchor() -> bool: - if not is_instance_valid(model): - return false - if not model.is_inside_tree(): - return true - var bone := Transform3D.IDENTITY - if bool(event.get("attaching", false)) and not bool(event.get("independent", false)): - if not is_instance_valid(animator): - return false - var bname := String(event.get("bone", "")) - if bname == "missing": - return false - var pose: Dictionary = animator.get_effect_bone_pose(bname) - if not pose.is_empty() and "transform" in pose: - bone = pose.transform - var offset := Transform3D(Basis.IDENTITY, event.get("pos", Vector3.ZERO)) - get_parent().global_transform = model.global_transform * offset * bone * SOURCE_TO_METRES.affine_inverse() - return true - -func _process(_delta: float) -> void: - if not update_anchor(): - get_parent().queue_free() diff --git a/project/fx/motion_effect_anchor.gd.uid b/project/fx/motion_effect_anchor.gd.uid deleted file mode 100644 index c80c3c0e..00000000 --- a/project/fx/motion_effect_anchor.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dvib4rwg2v55o diff --git a/project/fx/mse.gd b/project/fx/mse.gd deleted file mode 100644 index a34caa4e..00000000 --- a/project/fx/mse.gd +++ /dev/null @@ -1,256 +0,0 @@ -# Mse (P5) —— 解析 Metin2 `.mse` 特效脚本(EffectLib,非逆向,有完整源码)。 -# -# `.mse` 是 CTextFileLoader 花括号文本树: -# BoundingSphereRadius 120.0 -# Group Particle { -# StartTime 0.0 -# List TimeEventPosition { 0.0 "MOVING_TYPE_DIRECT" 8.0 0.0 25.0 } -# Group EmitterProperty { MaxEmissionCount 5 CycleLength 0.4 List TimeEventSizeX { 0.0 64.0 } ... } -# Group ParticleProperty { SrcBlendType 5 BillboardType 1 List TimeEventAlpha { 0.0 0.71 0.86 0.0 } List TextureFiles { "x.dds" } } -# } -# Group Mesh { MeshFileName "a.mde" MeshElementCount 1 Group MeshElement00 { ... } } -# -# var m := preload("res://fx/mse.gd").new() -# var spec := m.parse_file(path) -# # spec = { bsphere_r, bsphere_pos:Vector3, particles:[Dict], meshes:[Dict], dir } -# -# 每个 particle dict 保留原始键(大小写不变): -# start_time:float, position: [ [t, "MOVING_TYPE", x,y,z], ... ], -# emitter: Dict, particle: Dict (List 键 -> Array[Array],标量键 -> float/String/Array) -extends RefCounted - -var last_error := "" - -var _toks: Array = [] -var _p := 0 - -func parse_file(path: String) -> Dictionary: - last_error = "" - if not FileAccess.file_exists(path): - last_error = "no such file: " + path - return {} - var spec := parse_text(FileAccess.get_file_as_string(path)) - spec["dir"] = path.get_base_dir() - return spec - -func parse_text(src: String) -> Dictionary: - _toks = _tokenize(src) - _p = 0 - var top := _parse_block(true) - var out := { - "bsphere_r": float(_scalar(top.get("BoundingSphereRadius", 0.0))), - "bsphere_pos": _vec3(top.get("BoundingSpherePosition", [0, 0, 0])), - "particles": [], - "meshes": [], - "lights": [], - "dir": "", - } - for g in top.get("__group_Particle", []): - out.particles.append(_norm_particle(g)) - for g in top.get("__group_Mesh", []): - out.meshes.append(_norm_mesh(g)) - for g in top.get("__group_Light", []): - out.lights.append(_norm_light(g)) - return out - -# --- 归一化 ----------------------------------------------------------- - -func _norm_particle(g: Dictionary) -> Dictionary: - return { - "start_time": float(_scalar(g.get("StartTime", 0.0))), - "position": g.get("TimeEventPosition", []), - "emitter": _first(g.get("__group_EmitterProperty", [])), - "particle": _first(g.get("__group_ParticleProperty", [])), - } - -func _norm_mesh(g: Dictionary) -> Dictionary: - var elems := [] - for k in g: - if String(k).begins_with("__group_MeshElement"): - for e in g[k]: - elems.append(e) - return { - "start_time": float(_scalar(g.get("StartTime", 0.0))), - "position": g.get("TimeEventPosition", []), - "mesh_file": String(_scalar(g.get("MeshFileName", ""))), - "loop": int(_scalar(g.get("MeshAnimationLoopEnable", 0))), - "loop_count": int(_scalar(g.get("MeshAnimationLoopCount", 0))), - "frame_delay": float(_scalar(g.get("MeshAnimationFrameDelay", 0.0))), - "elements": elems, - } - -func _norm_light(g: Dictionary) -> Dictionary: - return { - "start_time": float(_scalar(g.get("StartTime", 0.0))), - "position": g.get("TimeEventPosition", []), - "duration": float(_scalar(g.get("Duration", 1.0))), - "loop": int(_scalar(g.get("LoopFlag", 0))), - "loop_count": int(_scalar(g.get("LoopCount", 0))), - "max_range": float(_scalar(g.get("MaxRange", 300.0))), - "ambient": g.get("AmbientColor", [0.5, 0.5, 0.5, 1.0]), - "diffuse": g.get("DiffuseColor", [0.0, 0.0, 0.0, 1.0]), - "attenuation0": float(_scalar(g.get("Attenuation0", 0.0))), - "attenuation1": float(_scalar(g.get("Attenuation1", 0.1))), - "attenuation2": float(_scalar(g.get("Attenuation2", 0.0))), - "range": g.get("TimeEventRange", []), - } - -func _first(a) -> Dictionary: - return a[0] if a is Array and a.size() > 0 else {} - -func _scalar(v): - if v is Array: - return v[0] if v.size() > 0 else 0.0 - return v - -func _vec3(v) -> Vector3: - if v is Array and v.size() >= 3: - return Vector3(float(v[0]), float(v[1]), float(v[2])) - return Vector3.ZERO - -# --- 词法 ----------------------------------------------------------- -# tokens: "{" "}" "\n" · {"s": } · 裸词/数字(String) - -func _tokenize(src: String) -> Array: - var out := [] - var i := 0 - var n := src.length() - while i < n: - var c := src[i] - if c == "\n": - out.append("\n") - i += 1 - elif c in [" ", "\t", "\r"]: - i += 1 - elif c == '"': - var j := i + 1 - var s := "" - while j < n and src[j] != '"': - s += src[j] - j += 1 - out.append({"q": s}) # 带引号的字符串(q 键区分裸词) - i = j + 1 - elif c == "{" or c == "}": - out.append(c) - i += 1 - elif c == "#" or (c == "/" and i + 1 < n and src[i + 1] == "/"): - while i < n and src[i] != "\n": - i += 1 - else: - var j := i - while j < n and not (src[j] in [" ", "\t", "\r", "\n", "{", "}", '"']): - j += 1 - out.append(src.substr(i, j - i)) - i = j - return out - -func _eq(v, ch) -> bool: - return v is String and v == ch - -func _val(t): - if t is Dictionary: - return t.get("q", "") - var s := String(t) - # 数字? - if s.is_valid_float(): - return float(s) - if s.is_valid_int(): - return int(s) - return s - -# 解析一个 { } 块(top=true 时是隐式顶层)。 -func _parse_block(top := false) -> Dictionary: - var d := {} - var guard := 0 - while _p < _toks.size(): - guard += 1 - if guard > 500000: - last_error = "runaway" - break - var t = _toks[_p] - if _eq(t, "}"): - _p += 1 - return d - if _eq(t, "\n"): - _p += 1 - continue - if _eq(t, "{"): - _p += 1 - continue - var word := "" if (t is Dictionary) else String(t) - if word == "Group": - _p += 1 - var name := _next_word() - _skip_newlines() - if _eq(_cur(), "{"): - _p += 1 - var sub := _parse_block() - var key := "__group_" + name - if not d.has(key): - d[key] = [] - d[key].append(sub) - continue - if word == "List": - _p += 1 - var lname := _next_word() - _skip_newlines() - var rows := [] - if _eq(_cur(), "{"): - _p += 1 - rows = _parse_list_rows() - d[lname] = rows - continue - # KEY v1 v2 ... (到行尾) - var key := word - _p += 1 - var vals := [] - while _p < _toks.size() and not _eq(_toks[_p], "\n") and not _eq(_toks[_p], "{") and not _eq(_toks[_p], "}"): - vals.append(_val(_toks[_p])) - _p += 1 - d[key] = vals[0] if vals.size() == 1 else vals - if top and _p >= _toks.size(): - break - return d - -func _parse_list_rows() -> Array: - var rows := [] - var row := [] - var guard := 0 - while _p < _toks.size(): - guard += 1 - if guard > 200000: - break - var t = _toks[_p] - if _eq(t, "}"): - _p += 1 - if not row.is_empty(): - rows.append(row) - break - if _eq(t, "\n"): - _p += 1 - if not row.is_empty(): - rows.append(row) - row = [] - continue - if _eq(t, "{"): - _p += 1 - continue - row.append(_val(t)) - _p += 1 - return rows - -func _cur(): - return _toks[_p] if _p < _toks.size() else null - -func _next_word() -> String: - while _p < _toks.size() and _eq(_toks[_p], "\n"): - _p += 1 - if _p < _toks.size() and not (_toks[_p] is Dictionary): - var w := String(_toks[_p]) - _p += 1 - return w - return "?" - -func _skip_newlines() -> void: - while _p < _toks.size() and _eq(_toks[_p], "\n"): - _p += 1 diff --git a/project/fx/mse.gd.uid b/project/fx/mse.gd.uid deleted file mode 100644 index 97cd94fa..00000000 --- a/project/fx/mse.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c728y7nra8uhh diff --git a/project/fx/particle_color_material.gd b/project/fx/particle_color_material.gd deleted file mode 100644 index f0494880..00000000 --- a/project/fx/particle_color_material.gd +++ /dev/null @@ -1,139 +0,0 @@ -# Particle-only legacy texture-stage operations. Static operation 4 keeps the -# StandardMaterial path; animated textures use per-particle array sampling. -# RGB is saturated before framebuffer blending; -# alpha remains texture alpha * particle alpha, never multiplied by RGB gain. -extends RefCounted - -static var _shaders := {} -const SOURCE := """ -shader_type spatial; -render_mode unshaded, cull_disabled, depth_draw_never, __BLEND__; -uniform sampler2D albedo_texture : source_color, filter_linear_mipmap, repeat_enable; -uniform float color_gain = 2.0; -uniform int color_operation = 5; -uniform int billboard_mode = 0; -uniform bool random_rotation = false; -uniform float rotation_speed = 0.0; -uniform float rotation_lifetime = 1.0; -void vertex() { - float particle_angle = INSTANCE_CUSTOM.x; - if (random_rotation) { - float direction = INSTANCE_CUSTOM.z < 0.5 ? -1.0 : 1.0; - particle_angle += direction * rotation_speed * INSTANCE_CUSTOM.y * rotation_lifetime; - } - if (billboard_mode != 0) { - mat4 facing = mat4(normalize(INV_VIEW_MATRIX[0]), normalize(INV_VIEW_MATRIX[1]), - normalize(INV_VIEW_MATRIX[2]), MODEL_MATRIX[3]); - if (billboard_mode == 2) { - facing = mat4(vec4(normalize(cross(vec3(0.0, 1.0, 0.0), MAIN_CAM_INV_VIEW_MATRIX[2].xyz)), 0.0), - vec4(0.0, 1.0, 0.0, 0.0), - vec4(normalize(cross(MAIN_CAM_INV_VIEW_MATRIX[0].xyz, vec3(0.0, 1.0, 0.0))), 0.0), - MODEL_MATRIX[3]); - } else if (billboard_mode == 5) { - vec3 up = vec3(0.0, 1.0, 0.0); - vec3 right = cross(up, MAIN_CAM_INV_VIEW_MATRIX[2].xyz); - right = length(right) > 0.000001 ? normalize(right) : vec3(1.0, 0.0, 0.0); - // Source Y billboard's non-zero rotation branch (including its 90 degree offset). - float a = particle_angle; - if (a != 0.0) { - vec3 source_cross = -right; - up = up * -sin(a) - source_cross * cos(a); - right = -(source_cross * -sin(a) + vec3(0.0, 1.0, 0.0) * cos(a)); - } - // Source Z rotation becomes world Y rotation after coordinate conversion. - float c = cos(UV2.x), s = sin(UV2.x); - mat3 turn = mat3(vec3(c, 0.0, -s), vec3(0.0, 1.0, 0.0), vec3(s, 0.0, c)); - right = turn * right; - up = turn * up; - facing = mat4(vec4(right, 0.0), vec4(up, 0.0), vec4(cross(right, up), 0.0), MODEL_MATRIX[3]); - } else if (billboard_mode == 4) { - // EffectLib LIE: source Up=(cos(a),-sin(a),0), - // Cross=(sin(a),cos(a),0), converted from Z-up to Y-up. - float a = particle_angle; - facing = mat4(vec4(sin(a), 0.0, -cos(a), 0.0), - vec4(cos(a), 0.0, sin(a), 0.0), - vec4(0.0, 1.0, 0.0, 0.0), MODEL_MATRIX[3]); - } else if (billboard_mode == 3) { - float a = particle_angle; - facing *= mat4(vec4(cos(a), -sin(a), 0.0, 0.0), vec4(sin(a), cos(a), 0.0, 0.0), - vec4(0.0, 0.0, 1.0, 0.0), vec4(0.0, 0.0, 0.0, 1.0)); - } - mat4 scale_matrix = mat4(vec4(length(MODEL_MATRIX[0].xyz), 0.0, 0.0, 0.0), - vec4(0.0, length(MODEL_MATRIX[1].xyz), 0.0, 0.0), - vec4(0.0, 0.0, length(MODEL_MATRIX[2].xyz), 0.0), - vec4(0.0, 0.0, 0.0, 1.0)); - MODELVIEW_MATRIX = VIEW_MATRIX * facing * scale_matrix; - MODELVIEW_NORMAL_MATRIX = mat3(MODELVIEW_MATRIX); - } -} -void fragment() { - vec4 texel = texture(albedo_texture, UV); - vec3 result = texel.rgb * COLOR.rgb * color_gain; - if (color_operation == 2) { result = COLOR.rgb; } - else if (color_operation == 3) { result = texel.rgb; } - else if (color_operation == 8) { result = COLOR.rgb + texel.rgb - vec3(0.5); } - ALBEDO = clamp(result, vec3(0.0), vec3(1.0)); - ALPHA = texel.a * COLOR.a; -} -""" - -const ANIMATION_UNIFORMS := """ -uniform sampler2DArray frame_textures : source_color, filter_linear, repeat_enable; -uniform int frame_count = 1; -uniform int animation_type = 0; -uniform bool random_start = false; -uniform float frame_delay = 0.05; -uniform float particle_lifetime = 1.0; -varying flat float texture_frame; -float frame_random(float seed) { return fract(sin(seed * 127.1 + 311.7) * 43758.5453); } -""" -const ANIMATION_VERTEX := """ - float seed = INSTANCE_CUSTOM.z; - float steps = frame_delay > 0.000001 ? max(ceil(INSTANCE_CUSTOM.y * particle_lifetime / frame_delay) - 1.0, 0.0) : 0.0; - float count = float(frame_count); - float direction = animation_type == 2 ? -1.0 : 1.0; - if (animation_type == 4) { direction = seed < 0.5 ? 1.0 : -1.0; } - float first = animation_type == 4 && direction < 0.0 ? count - 1.0 : 0.0; - if (random_start) { first = floor(frame_random(seed + 1.0) * count); } - texture_frame = first; - if (animation_type != 0 && steps > 0.0) { - texture_frame = animation_type == 3 ? floor(frame_random(seed + steps * 3.17) * count) : mod(first + direction * steps, count); - } -""" - -static func build(base: StandardMaterial3D, operation: int, frames: Texture2DArray = null, - properties: Dictionary = {}, lifetime := 1.0) -> Material: - var lie := int(properties.get("BillboardType", -1)) == 3 - var multiface := int(properties.get("BillboardType", -1)) in [4, 5] - var random_rotation := int(properties.get("RotationType", 0)) == 4 - if operation not in [2, 3, 5, 6, 8] and frames == null and not lie and not multiface and not random_rotation: - return base - var blend := "blend_add" if base.blend_mode == BaseMaterial3D.BLEND_MODE_ADD else "blend_mix" - var key := blend + ("_animated" if frames else "") - if not _shaders.has(key): - var shader := Shader.new() - var source := SOURCE.replace("__BLEND__", blend) - if frames: - source = source.replace("void vertex() {", ANIMATION_UNIFORMS + "\nvoid vertex() {" + ANIMATION_VERTEX) - source = source.replace("texture(albedo_texture, UV)", "texture(frame_textures, vec3(UV, texture_frame))") - shader.code = source - _shaders[key] = shader - var material := ShaderMaterial.new() - material.shader = _shaders[key] - material.set_shader_parameter("albedo_texture", base.albedo_texture) - material.set_shader_parameter("color_operation", operation) - material.set_shader_parameter("color_gain", 2.0 if operation == 5 else (4.0 if operation == 6 else 1.0)) - material.set_shader_parameter("billboard_mode", 5 if multiface else (4 if lie else int(base.billboard_mode))) - if random_rotation and not lie and not multiface: - material.set_shader_parameter("billboard_mode", 5 if int(properties.get("BillboardType", 1)) == 2 else 3) - material.set_shader_parameter("random_rotation", random_rotation) - material.set_shader_parameter("rotation_speed", deg_to_rad(float(properties.get("RotationSpeed", 0)))) - material.set_shader_parameter("rotation_lifetime", lifetime) - if frames: - material.set_shader_parameter("frame_textures", frames) - material.set_shader_parameter("frame_count", frames.get_layers()) - material.set_shader_parameter("animation_type", int(properties.get("TexAniType", 0))) - material.set_shader_parameter("random_start", bool(properties.get("TexAniRandomStartFrameFlag", 0))) - material.set_shader_parameter("frame_delay", float(properties.get("TexAniDelay", 0.05))) - material.set_shader_parameter("particle_lifetime", lifetime) - return material diff --git a/project/fx/particle_color_material.gd.uid b/project/fx/particle_color_material.gd.uid deleted file mode 100644 index 737ef8ff..00000000 --- a/project/fx/particle_color_material.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cu4fhnm5bep65 diff --git a/project/fx/particle_faces.gd b/project/fx/particle_faces.gd deleted file mode 100644 index c68fcae2..00000000 --- a/project/fx/particle_faces.gd +++ /dev/null @@ -1,31 +0,0 @@ -# EffectLib TwoSideRenderer / ThreeSideRenderer angles, carried per face in UV2. -# One particle simulation drives every face (no independent emitter randomness). -extends RefCounted - -static func build(size: Vector2, billboard: int) -> Mesh: - if billboard not in [4, 5]: - var quad := QuadMesh.new() - quad.size = size - return quad - var angles := [-30.0, 30.0] if billboard == 4 else [0.0, -60.0, 60.0] - var vertices := PackedVector3Array() - var normals := PackedVector3Array() - var uv := PackedVector2Array() - var uv2 := PackedVector2Array() - var corners := [Vector2(0, 0), Vector2(0, 1), Vector2(1, 1), - Vector2(0, 0), Vector2(1, 1), Vector2(1, 0)] - for angle in angles: - for corner in corners: - vertices.append(Vector3((corner.x - 0.5) * size.x, (0.5 - corner.y) * size.y, 0)) - normals.append(Vector3(0, 0, 1)) - uv.append(corner) - uv2.append(Vector2(deg_to_rad(angle), 0)) - var arrays := [] - arrays.resize(Mesh.ARRAY_MAX) - arrays[Mesh.ARRAY_VERTEX] = vertices - arrays[Mesh.ARRAY_NORMAL] = normals - arrays[Mesh.ARRAY_TEX_UV] = uv - arrays[Mesh.ARRAY_TEX_UV2] = uv2 - var mesh := ArrayMesh.new() - mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) - return mesh diff --git a/project/fx/particle_faces.gd.uid b/project/fx/particle_faces.gd.uid deleted file mode 100644 index 57cbc78f..00000000 --- a/project/fx/particle_faces.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d2act25qwmh5q diff --git a/project/fx/skill_fx.gd b/project/fx/skill_fx.gd deleted file mode 100644 index bd1ee3da..00000000 --- a/project/fx/skill_fx.gd +++ /dev/null @@ -1,74 +0,0 @@ -# SkillFx (P6 收尾) —— 技能 id / 内建特效 id → .mse 文件,接 P5 EffectRegistry。 -# -# var sfx := preload("res://fx/skill_fx.gd").new() -# sfx.setup(effect_registry, skill_table) -# sfx.spawn_skill(1, 3, caster_node) # 技能 1(samyeon) grade→_4 挂 caster 播 -# sfx.spawn_special(19, node) # GC_SPECIAL_EFFECT type 19 = SE_AUTO_HPUP -> drugup_red -# -# 技能特效表:skilldesc 的 motion_name(col13)就是特效基名;等级后缀 = 2 + master_type(夹 2..4)。 -# effect/pc//effect/_.mse(EffectRegistry 的 basename 扫描能找到散包 / PC / season1) -# 少数技能有 _blow / _head / _foot / _yong 子特效;这里只放主特效,缺就跳过。 -extends RefCounted - -# GC_SPECIAL_EFFECT 的 type(ItemData.h / Packet.h 的 SE_* enum,值 = 下标)-> etc 特效基名。 -# 不全 —— 只填常见的(真机 GM 号狂刷 19/20 自动喝药)。 -const SPECIAL_FX := { - 1: "drugup_red", # SE_HPUP_RED - 2: "drugup_blue", # SE_SPUP_BLUE - 3: "drugup_green", # SE_SPEEDUP_GREEN - 4: "drugup_purple", # SE_DXUP_PURPLE - 9: "firecracker_1", # SE_CHINA_FIREWORK - 11: "firecracker_2", # SE_SUCCESS(凑合) - 12: "fail", # SE_FAIL - 19: "drugup_red", # SE_AUTO_HPUP - 20: "drugup_blue", # SE_AUTO_SPUP -} - -var fxr: RefCounted # EffectRegistry -var table: RefCounted # SkillTable -# 一个 motion 常见的子特效后缀(有就一起播) -const SUB_SUFFIX := ["", "_blow", "_hand", "_head", "_foot", "_yong"] - -func setup(effect_registry: RefCounted, skill_table: RefCounted) -> void: - fxr = effect_registry - table = skill_table - -# skill_id + master_type(0..3) -> 主特效基名(_)。查不到 motion 返回 ""。 -func skill_effect_name(skill_id: int, master: int = 0) -> String: - if table == null: - return "" - var mn := String(table.entry(skill_id).get("motion", "")) - if mn == "": - return "" - var n := clampi(2 + master, 2, 4) - return "%s_%d" % [mn, n] - -# 在 host 下播技能特效(主 + 已存在的子特效各一份)。返回播了几个。 -func spawn_skill(skill_id: int, master: int, host: Node3D) -> int: - if fxr == null or host == null: - return 0 - var base := skill_effect_name(skill_id, master) - if base == "": - return 0 - var parts := base.rsplit("_", false, 1) # "samyeon_4" -> ["samyeon", "4"] - var stem: String = parts[0] - var num: String = parts[1] if parts.size() > 1 else "" - var played := 0 - for suf in SUB_SUFFIX: - var name: String = base - if suf != "": - name = "%s%s_%s" % [stem, suf, num] if num != "" else stem + suf - if fxr.resolve(name) != "": - fxr.spawn(name, host, true) - played += 1 - return played - -# GC_SPECIAL_EFFECT type -> 内建特效。返回是否播了。 -func spawn_special(special_id: int, host: Node3D) -> bool: - if fxr == null or host == null or not SPECIAL_FX.has(special_id): - return false - var name: String = SPECIAL_FX[special_id] - if fxr.resolve(name) == "": - return false - fxr.spawn(name, host, true) - return true diff --git a/project/fx/skill_fx.gd.uid b/project/fx/skill_fx.gd.uid deleted file mode 100644 index 8861daba..00000000 --- a/project/fx/skill_fx.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dit70gssglg2b diff --git a/project/fx/target_effect_anchor.gd b/project/fx/target_effect_anchor.gd deleted file mode 100644 index 5cfb0fe3..00000000 --- a/project/fx/target_effect_anchor.gd +++ /dev/null @@ -1,43 +0,0 @@ -# Target-following effects use actor-local offset. Snapshot effects use the -# fly-target's world position and a world-aligned offset (40250 type 10). -extends Node -const CONVERSION := Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0)) -var target: WeakRef -var offset := Vector3.ZERO -func configure(node: Node3D, source_offset: Vector3, following: bool) -> bool: - if not is_instance_valid(node) or not node.is_inside_tree(): return false - target = weakref(node) - offset = CONVERSION * source_offset - process_priority = 100 - set_process(following) - if following: - _process(0.0) - else: - get_parent().global_position = fly_target_position(node) + offset - return true -static func fly_target_position(node: Node3D) -> Vector3: - # The reference model-index 0 sphere is built from bone OBBs, not the - # union of visible attachments or the rendered vertex AABB. - var models := node.find_children("*", "Metin2Model", true, false) - if node.has_method("get_fly_target_bounds"): models.push_front(node) - for model in models: - var result: Dictionary = model.get_fly_target_bounds() - if result.get("valid", false): - var body_bounds: AABB = result.bounds - return model.to_global(body_bounds.get_center()) - # The reference returns the model bounding sphere centre, not its feet. - var meshes := node.find_children("*", "MeshInstance3D", true, false) - var bounds := AABB() - var found := false - for mesh in meshes: - if mesh.mesh == null or not mesh.is_visible_in_tree(): continue - var box: AABB = mesh.global_transform * mesh.get_aabb() - bounds = bounds.merge(box) if found else box - found = true - return bounds.get_center() if found else node.global_position -func _process(_delta: float) -> void: - var node: Node3D = target.get_ref() if target else null - if not is_instance_valid(node) or not node.is_inside_tree(): - get_parent().queue_free() - return - get_parent().global_transform = node.global_transform * Transform3D(Basis.IDENTITY, offset) diff --git a/project/fx/target_effect_anchor.gd.uid b/project/fx/target_effect_anchor.gd.uid deleted file mode 100644 index f6f6cf73..00000000 --- a/project/fx/target_effect_anchor.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bhnn4cqiuego2 diff --git a/project/fx/weather.gd b/project/fx/weather.gd deleted file mode 100644 index e63ad66f..00000000 --- a/project/fx/weather.gd +++ /dev/null @@ -1,83 +0,0 @@ -# Weather (P9) —— 雪 / 雨 环境粒子。跟随相机,在其上方一片区域内下落。 -# -# var w := preload("res://fx/weather.gd").new() -# add_child(w) -# w.setup(camera) # camera: Camera3D / Node3D -# w.set_weather("snow") # "snow" | "rain" | "none" -# -# 用 GPUParticles3D + ParticleProcessMaterial 的盒子发射体,粒子朝下。 -# 由地图属性或调试驱动;没有专门封包。 -extends Node3D - -var _cam: Node3D -var _p: GPUParticles3D -var _kind := "none" - -func setup(camera: Node3D) -> void: - _cam = camera - _p = GPUParticles3D.new() - _p.amount = 800 - _p.lifetime = 3.0 - _p.visibility_aabb = AABB(Vector3(-30, -30, -30), Vector3(60, 60, 60)) - _p.local_coords = false - _p.emitting = false - add_child(_p) - _build_material() - set_weather("none") - -func kind() -> String: - return _kind - -func set_weather(k: String) -> void: - _kind = k - if _p == null: - return - if k == "none": - _p.emitting = false - return - _p.emitting = true - var m: ParticleProcessMaterial = _p.process_material - if k == "snow": - _p.amount = 700 - _p.lifetime = 6.0 - m.gravity = Vector3(0, -2.0, 0) - m.initial_velocity_min = 0.5 - m.initial_velocity_max = 1.5 - m.scale_min = 0.03 - m.scale_max = 0.08 - m.color = Color(1, 1, 1, 0.9) - m.turbulence_enabled = true - m.turbulence_noise_strength = 0.6 - elif k == "rain": - _p.amount = 1200 - _p.lifetime = 1.2 - m.gravity = Vector3(0, -30.0, 0) - m.initial_velocity_min = 12.0 - m.initial_velocity_max = 18.0 - m.scale_min = 0.015 - m.scale_max = 0.03 - m.color = Color(0.7, 0.8, 0.95, 0.5) - m.turbulence_enabled = false - -func _build_material() -> void: - var m := ParticleProcessMaterial.new() - m.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX - m.emission_box_extents = Vector3(25, 1, 25) - m.direction = Vector3(0, -1, 0) - m.spread = 8.0 - m.gravity = Vector3(0, -2.0, 0) - _p.process_material = m - var mesh := QuadMesh.new() - mesh.size = Vector2(0.1, 0.1) - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED - mat.albedo_color = Color(1, 1, 1, 0.85) - mesh.material = mat - _p.draw_pass_1 = mesh - -func _process(_dt: float) -> void: - if _cam and _p: - # 发射体盒子锚在相机上方 20m - _p.global_position = _cam.global_position + Vector3(0, 20, 0) diff --git a/project/fx/weather.gd.uid b/project/fx/weather.gd.uid deleted file mode 100644 index 6870829e..00000000 --- a/project/fx/weather.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cj5x183tcq55n diff --git a/project/fx_test.gd b/project/fx_test.gd deleted file mode 100644 index 01976048..00000000 --- a/project/fx_test.gd +++ /dev/null @@ -1,227 +0,0 @@ -# fx_test —— P5:.mse 解析 + EffectPlayer 构建 headless 自检。 -# godot --headless --path project --script fx_test.gd -extends SceneTree - -const Mse = preload("res://fx/mse.gd") -const Mde = preload("res://fx/mde.gd") -const EffectPlayer = preload("res://fx/effect_player.gd") -const EffectRegistry = preload("res://fx/effect_registry.gd") - -const FIXTURE := """ -BoundingSphereRadius 120.000000 -BoundingSpherePosition 0.000000 0.000000 100.000000 - -Group Particle -{ - StartTime 0.000000 - List TimeEventPosition - { - 0.000000 "MOVING_TYPE_DIRECT" 8.000000 0.000000 25.000000 - } - Group EmitterProperty - { - MaxEmissionCount 5 - CycleLength 0.400000 - CycleLoopEnable 1 - LoopCount 2 - EmitterShape 3 - EmittingRadius 10.000000 - List TimeEventLifeTime - { - 0.000000 0.077320 - } - List TimeEventSizeX - { - 0.000000 64.000000 - } - } - Group ParticleProperty - { - SrcBlendType 5 - DestBlendType 2 - BillboardType 1 - RotationSpeed 500.000000 - List TimeEventScaleX - { - 0.000000 1.000000 - 1.000000 0.000000 - } - List TimeEventColorRed { 0.000000 0.886275 } - List TimeEventColorGreen { 0.000000 0.086275 } - List TimeEventColorBlue { 0.000000 0.239216 } - List TimeEventAlpha - { - 0.000000 0.710000 - 0.864103 0.000000 - } - List TextureFiles { "bottom_under.dds" } - } -} -Group Particle -{ - StartTime 0.200000 - Group EmitterProperty - { - MaxEmissionCount 10 - EmitterShape 3 - EmittingRadius 20.0 - EmitterAdvancedType 1 - EmitterEmitFromEdgeFlag 1 - } - Group ParticleProperty - { - SrcBlendType 5 - DestBlendType 4 - BillboardType 4 - } -} -Group Light -{ - StartTime 0.100000 - Duration 0.500000 - LoopFlag 0 - LoopCount 0 - MaxRange 300.000000 - AmbientColor 0.100000 0.200000 0.300000 1.000000 - DiffuseColor 0.800000 0.400000 0.100000 1.000000 - Attenuation0 0.000000 - Attenuation1 0.100000 - Attenuation2 0.000000 - List TimeEventRange - { - 0.000000 0.000000 - 0.500000 1.000000 - } -} -""" - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _test_parser() - _test_builder() - _test_mde() - _test_registry() - if _fail == 0: - print("PASS: fx_test (.mse parser + EffectPlayer + registry)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _test_parser() -> void: - var m := Mse.new() - var s := m.parse_text(FIXTURE) - _ck(m.last_error == "", "parse: no error (%s)" % m.last_error) - _ck(abs(float(s.bsphere_r) - 120.0) < 0.01, "bsphere radius") - _ck(s.bsphere_pos == Vector3(0, 0, 100), "bsphere pos = (0,0,100)") - _ck(s.particles.size() == 2, "2 Particle groups (%d)" % s.particles.size()) - var p0: Dictionary = s.particles[0] - _ck(abs(p0.start_time) < 0.01, "p0 start_time 0") - _ck(p0.position.size() == 1 and String(p0.position[0][1]) == "MOVING_TYPE_DIRECT", "p0 TimeEventPosition row parsed with quoted enum") - _ck(int(p0.emitter.get("MaxEmissionCount")) == 5, "p0 emitter MaxEmissionCount 5") - _ck(int(p0.emitter.get("EmitterShape")) == 3, "p0 EmitterShape 3") - var scale_rows = p0.particle.get("TimeEventScaleX") - _ck(scale_rows is Array and scale_rows.size() == 2 and float(scale_rows[1][1]) == 0.0, "p0 ScaleX list = 2 rows, last v=0") - var tex = p0.particle.get("TextureFiles") - _ck(tex is Array and tex.size() == 1 and String(tex[0][0]) == "bottom_under.dds", "p0 TextureFiles") - _ck(abs(float(s.particles[1].start_time) - 0.2) < 0.01, "p1 start_time 0.2") - _ck(s.lights.size() == 1, "1 SimpleLight group") - _ck(abs(float(s.lights[0].duration) - 0.5) < 0.01 and s.lights[0].range.size() == 2, "light duration and range curve") - -func _test_builder() -> void: - var m := Mse.new() - var s := m.parse_text(FIXTURE) - var fx: Node3D = EffectPlayer.new() - get_root().add_child(fx) - fx.build(s, "") - _ck(fx._emitters.size() == 2, "EffectPlayer built 2 GPUParticles3D") - var g0: GPUParticles3D = fx._emitters[0] - _ck(g0.amount == 5, "emitter 0 amount = 5") - _ck(abs(float(g0.get_meta("cycle_length", 0.0)) - 0.4) < 0.001, "emitter 0 cycle length = 0.4") - _ck(bool(g0.get_meta("cycle_loop", false)), "emitter 0 cycle loop enabled") - _ck(int(g0.get_meta("loop_count", 0)) == 2, "emitter 0 loop count = 2") - _ck(abs(g0.lifetime - 0.07732) < 0.01, "particle lifetime is not replaced by cycle length") - _ck(g0.process_material is ParticleProcessMaterial, "emitter 0 has process material") - var pm: ParticleProcessMaterial = g0.process_material - _ck(pm.emission_shape == ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, "emitter 0 sphere emission (shape 3)") - _ck(g0.material_override is StandardMaterial3D, "emitter 0 draw material") - _ck((g0.material_override as StandardMaterial3D).blend_mode == BaseMaterial3D.BLEND_MODE_ADD, "src5/dst2 -> additive blend") - _ck(float(fx._emitters[1].get_meta("start_time")) == 0.2, "emitter 1 start_time meta = 0.2") - _ck(bool(fx._emitters[1].get_meta("emitter_emit_from_edge", false)), "emitter 1 edge emission metadata") - _ck(int(fx._emitters[1].get_meta("emitter_advanced_type", -1)) == 1, "emitter 1 advanced type metadata") - var pm1: ParticleProcessMaterial = fx._emitters[1].process_material - _ck(pm1.emission_shape == ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE, - "emitter 1 edge sphere uses surface emission") - _ck(abs(pm1.spread - 180.0) < 0.01, "OUTER emitter uses radial-velocity fallback spread") - _ck(fx._lights.size() == 1, "EffectPlayer built 1 OmniLight3D") - fx.play() - fx._process(0.11) - _ck(fx._lights[0].visible, "SimpleLight becomes visible after start time") - _ck(abs(fx._lights[0].omni_range - 0.06) < 0.02, "SimpleLight range follows TimeEventRange") - var moving_rows: Array = [ - [0.0, "MOVING_TYPE_DIRECT", 0.0, 0.0, 0.0], - [1.0, "MOVING_TYPE_BEZIER_CURVE", 100.0, 0.0, 0.0, 0.0, 100.0, 0.0], - [2.0, "MOVING_TYPE_DIRECT", 200.0, 0.0, 0.0], - ] - var direct_mid: Vector3 = fx._position_at(moving_rows, 0.5) - var bezier_mid: Vector3 = fx._position_at(moving_rows, 1.5) - _ck(abs(direct_mid.x - 0.5) < 0.001 and abs(direct_mid.y) < 0.001, - "MOVING_TYPE_DIRECT interpolates in metres") - _ck(abs(bezier_mid.x - 1.25) < 0.001 and abs(bezier_mid.z + 0.5) < 0.001 and abs(bezier_mid.y) < 0.001, - "MOVING_TYPE_BEZIER_CURVE uses previous control point") - fx.play() - _ck(g0.emitting, "emitter 0 emitting after play() (start_time 0)") - fx._process(0.81) - _ck(not g0.emitting, "finite particle cycle stops after LoopCount cycles") - fx.queue_free() - -func _test_registry() -> void: - var assets := AssetRoot.path() - if not DirAccess.dir_exists_absolute(assets): - print(" (skip registry: no assets)") - return - var fxr := EffectRegistry.new() - fxr.setup(assets) - # 已知存在: PC/ymir work/pc/warrior/effect/geompung_3_sword.mse - var p := fxr.resolve("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse") - if p == "": - p = fxr.resolve("geompung_3_sword") - _ck(p != "" and FileAccess.file_exists(p), "registry resolved a real .mse (%s)" % p) - if p != "": - var s := fxr.spec_for("PC/ymir work/pc/warrior/effect/geompung.mse") - _ck(s.get("particles", []).size() > 0, "real .mse parsed to >=1 particle (%d)" % s.get("particles", []).size()) - var parent := Node3D.new() - get_root().add_child(parent) - var fx := fxr.spawn("PC/ymir work/pc/warrior/effect/geompung.mse", parent, true) - _ck(fx != null and fx.get_parent() == parent, "spawn attached EffectPlayer") - _ck(fx._mesh_nodes.size() > 0, "spawn built real .mde mesh instead of placeholder") - if fx._mesh_nodes.size() > 0: - var mn: Node3D = fx._mesh_nodes[0] - _ck(int(mn.get_meta("mde_version", 0)) == 1, "real geompung.mde uses EffectData v1") - _ck(mn.get_child_count() > 0 and mn.get_child(0) is MeshInstance3D, "mde geometry is MeshInstance3D") - -func _test_mde() -> void: - var assets := AssetRoot.path() - if not DirAccess.dir_exists_absolute(assets): - print(" (skip mde: no assets)") - return - var path := assets.path_join("PC/ymir work/pc/warrior/effect/geompung.mde") - if not FileAccess.file_exists(path): - print(" (skip mde: fixture missing)") - return - var data := Mde.new() - var mesh := data.parse_file(path) - _ck(data.last_error == "", "mde parses EffectData v1 (%s)" % data.last_error) - _ck(int(mesh.get("version", 0)) == 1, "mde version 1") - _ck(int(mesh.get("geometry_count", 0)) == 3, "mde geometry count = 3") - _ck(int(mesh.get("frame_count", 0)) == 2, "mde frame count = 2") - var geometries: Array = mesh.get("geometries", []) - _ck(geometries.size() == 3 and geometries[0].frames.size() == 2, "mde keeps every geometry frame") - if geometries.size() > 0 and geometries[0].frames.size() > 0: - var frame: Dictionary = geometries[0].frames[0] - _ck(frame.vertices.size() == frame.uvs.size() and frame.vertices.size() > 0, "mde expands indexed vertices with UVs") diff --git a/project/fx_test.gd.uid b/project/fx_test.gd.uid deleted file mode 100644 index dd0447c9..00000000 --- a/project/fx_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bporisusalpvj diff --git a/project/game_camera.gd b/project/game_camera.gd deleted file mode 100644 index 68b95784..00000000 --- a/project/game_camera.gd +++ /dev/null @@ -1,430 +0,0 @@ -# GameCamera —— Metin2 风第三人称轨道相机。 -# 桌面:右键拖拽 -> 环绕(yaw/pitch) 滚轮 -> 缩放 -# 触屏(F7):单指拖拽 -> 环绕 双指捏合 -> 缩放 -# 跟随 target,地形/建筑防穿。 -# 用法:var c = preload("res://game_camera.gd").new(); c.target = player; -# c.world = metin2_world; add_child(c); c.make_current() -extends Camera3D - -enum CameraMode { - NORMAL, - STAND, - BLEND, -} - -# ClientVS22 stores camera settings in centimetres/degrees. The Godot scene -# is metres/radians, so keep the conversion at this boundary instead of -# leaking mixed units into the orbit controls. -const DEFAULT_DISTANCE_CM := 1550.0 -const DEFAULT_PITCH_DEG := 27.0 -const DEFAULT_ROTATION_DEG := 0.0 -const DEFAULT_HEIGHT_CM := 100.0 -const CAMERA_TARGET_STANDARD_CM := 100.0 -const CAMERA_TARGET_FACE_CM := 150.0 -const CAMERA_WHEEL_DELTA_CM := 36.0 # 120 * CCamera's 0.3 resistance -const CAMERA_ZOOM_SPEED := 0.05 # PythonApplication's default ratio speed - -var target: Node3D -var world: Node # Metin2World,用 sample_height 防穿(可空) -var head_offset := Vector3(0, 1.15, 0) - -var yaw := deg_to_rad(DEFAULT_ROTATION_DEG) -var pitch := deg_to_rad(DEFAULT_PITCH_DEG) -var dist := DEFAULT_DISTANCE_CM * 0.01 -var min_pitch := deg_to_rad(12.0) -var max_pitch := deg_to_rad(78.0) -var min_dist := 2.0 -var max_dist := 25.0 -var sensitivity := 0.006 -var follow_lerp := 12.0 - -# F7 触屏手势 -var touch_sensitivity := 0.005 # 单指拖拽 -> yaw/pitch -var pinch_zoom_gain := 0.03 # 捏合像素差 -> dist 变化 -const _ORBIT_DEADZONE := 8.0 # 单指移动超过这么多像素才算「拖拽环绕」(低于视作点击) - -var _dragging := false -var _pos_ready := false -var _touches := {} # index:int -> position:Vector2(当前按下的手指) -var _orbit_touch := -1 # 正在环绕的手指 index,-1 = 无 -var _orbit_active := false # 已越过 deadzone -var _orbit_press := Vector2.ZERO -var _pinch_last := -1.0 # 上一帧两指间距,<0 = 未在捏合 -var _wheel_zoom_velocity_cm := 0.0 # CCamera::m_v3AngularVelocity.y - -# ClientVS22 game.py drives these while Q/E/R/F/T/G are held. Keeping the -# state here (instead of applying one large step per key event) gives the -# same continuous desktop-camera motion on macOS and Windows. -var _key_orbit := 0 -var _key_zoom := 0 -var _key_pitch := 0 - -# PythonApplicationCamera.cpp's NORMAL / STAND / BLEND modes. The normal -# camera follows the player; event cameras use a fixed centre and temporarily -# lock the orbit input. `setting` dictionaries intentionally retain the -# reference field names so quest scripts can be passed through unchanged. -var camera_mode: CameraMode = CameraMode.NORMAL -var _event_setting := {} -var _default_setting := {} -var _blend_from := {} -var _blend_to := {} -var _blend_elapsed := 0.0 -var _blend_duration := 0.0 -var _event_offset := Vector3.ZERO -var _event_center := Vector3.ZERO - -const OCCLUDER_MASK := 1 << 1 # 静态遮挡物层(Metin2World 给建筑的盒碰撞) -const FADE_ALPHA := 0.72 # 挡住玩家时的透明度 -var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D - -func _ready() -> void: - # Match the original third-person framing: farther camera and narrower lens. - fov = 45.0 - far = 4000.0 - _apply_setting(_normal_setting()) - if target: - global_position = _desired_pos(true) - _pos_ready = true - -func _unhandled_input(e: InputEvent) -> void: - if e is InputEventMouseButton: - if e.button_index == MOUSE_BUTTON_RIGHT: - _dragging = e.pressed and not is_event_locked() - elif e.button_index == MOUSE_BUTTON_WHEEL_UP and e.pressed: - if not is_event_locked(): - _wheel_zoom_velocity_cm = _wheel_delta_cm(e.factor) - elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN and e.pressed: - if not is_event_locked(): - _wheel_zoom_velocity_cm = -_wheel_delta_cm(e.factor) - elif e is InputEventMouseMotion and _dragging and not is_event_locked(): - yaw -= e.relative.x * sensitivity - pitch = clampf(pitch + e.relative.y * sensitivity, min_pitch, max_pitch) - elif e is InputEventScreenTouch: - _on_touch(e) - elif e is InputEventScreenDrag: - _on_drag(e) - -func _on_touch(e: InputEventScreenTouch) -> void: - if e.pressed: - _touches[e.index] = e.position - if _touches.size() == 1: - _orbit_touch = e.index - _orbit_active = false - _orbit_press = e.position - elif _touches.size() == 2: - _orbit_touch = -1 # 第二指落下 -> 转捏合,取消环绕 - _pinch_last = _pinch_dist() - else: - _touches.erase(e.index) - if _touches.size() < 2: - _pinch_last = -1.0 - if _touches.size() == 1: - _orbit_touch = _touches.keys()[0] # 抬起一指后,剩下的接管环绕 - _orbit_active = false - _orbit_press = _touches[_orbit_touch] - elif _touches.is_empty(): - _orbit_touch = -1 - -func _on_drag(e: InputEventScreenDrag) -> void: - if _touches.has(e.index): - _touches[e.index] = e.position - if _touches.size() >= 2: - var d := _pinch_dist() - if _pinch_last > 0.0: - # 两指张开(d 变大)-> 拉近;捏拢 -> 推远 - dist = clampf(dist + (_pinch_last - d) * pinch_zoom_gain, min_dist, max_dist) - _pinch_last = d - elif e.index == _orbit_touch: - if not _orbit_active and e.position.distance_to(_orbit_press) > _ORBIT_DEADZONE: - _orbit_active = true - if _orbit_active: - yaw -= e.relative.x * touch_sensitivity - pitch = clampf(pitch + e.relative.y * touch_sensitivity, min_pitch, max_pitch) - -func _pinch_dist() -> float: - var ks := _touches.keys() - if ks.size() < 2: - return 0.0 - return (_touches[ks[0]] as Vector2).distance_to(_touches[ks[1]] as Vector2) - -func _wheel_delta_cm(factor: float) -> float: - # Godot reports a wheel notch as factor=1, while Win32 sends 120. Accept - # both forms so the conversion remains equivalent to CCamera::Wheel(). - var notches := absf(factor) - if notches <= 0.0: - notches = 1.0 - elif notches > 4.0: - notches /= 120.0 - return notches * CAMERA_WHEEL_DELTA_CM - -func _apply_wheel_zoom() -> void: - if absf(_wheel_zoom_velocity_cm) < 0.001: - return - dist = clampf(dist - _wheel_zoom_velocity_cm * 0.01, min_dist, max_dist) - _wheel_zoom_velocity_cm *= 0.5 # CCamera::Update damping - if absf(_wheel_zoom_velocity_cm) < 1.0: - _wheel_zoom_velocity_cm = 0.0 - -func _target_height_m() -> float: - # CameraProcedure.cpp moves the target from chest to face as the camera - # approaches: 100cm at max distance and 150cm at min distance. - var movable := maxf(0.001, max_dist - min_dist) - var close_fraction := clampf((max_dist - dist) / movable, 0.0, 1.0) - return lerpf(CAMERA_TARGET_STANDARD_CM, CAMERA_TARGET_FACE_CM, close_fraction) * 0.01 - -func heading() -> float: - # 相机方位角:相机在 head + (sin yaw, cos yaw) 一侧,视线水平前方是 -(sin yaw, cos yaw)。 - return yaw - -func is_event_locked() -> bool: - return camera_mode != CameraMode.NORMAL - -func is_dragging() -> bool: - return _dragging - -func set_key_orbit(direction: int) -> void: - _key_orbit = clampi(direction, -1, 1) - -func set_key_zoom(direction: int) -> void: - _key_zoom = clampi(direction, -1, 1) - -func set_key_pitch(direction: int) -> void: - _key_pitch = clampi(direction, -1, 1) - -func _desired_pos(snap := false) -> Vector3: - var head := _look_target() - var off := Vector3( - sin(yaw) * cos(pitch), - sin(pitch), - cos(yaw) * cos(pitch)) * dist - var want := head + off + _event_offset - if is_event_locked(): - # Event cameras are deliberately fixed shots. The reference event - # camera does not chase the actor while a quest is being presented. - return want - # 地形防穿:沿 head->want 采样,低于地表就把相机拉回 - if world and world.has_method("sample_height"): - var d := want - head - var steps := 8 - for i in range(1, steps + 1): - var p := head + d * (float(i) / steps) - var gy: float = world.call("sample_height", p.x, p.z) + 0.4 - if p.y < gy: - want = head + d * (float(i - 1) / steps) - break - # §8.3 建筑防穿:head->want 射线撞静态遮挡物就把相机拉到撞点前 - var world3d := get_world_3d() - if world3d: - var q := PhysicsRayQueryParameters3D.create(head, want, OCCLUDER_MASK) - var hit := world3d.direct_space_state.intersect_ray(q) - if hit: - var n := (want - head).normalized() - want = hit.position - n * 0.3 - return want - -func _look_target() -> Vector3: - if is_event_locked(): - return _event_center - if target: - head_offset.y = _target_height_m() - return target.global_position + head_offset - return global_position - -func _normal_setting() -> Dictionary: - var centre := target.global_position if target else Vector3.ZERO - var server := MapCoord.to_server_cm(centre) - return { - "x": server.x, "y": server.y, "z": centre.y * 100.0, - "up": 0.0, "view": 0.0, "cross": 0.0, - "distance": dist * 100.0, - "rot": rad_to_deg(yaw), - "pitch": rad_to_deg(pitch), - } - -static func _setting_value(setting: Dictionary, key: String, fallback: float) -> float: - if not setting.has(key): - return fallback - return float(setting[key]) - -func _setting_world_center(setting: Dictionary) -> Vector3: - # SCameraSetting uses the same x/y ground plane and z-up convention as - # server positions. MapCoord is the single source of truth for the map - # origin and the Y/Z handedness conversion. - var sx := _setting_value(setting, "x", 0.0) - var sy := _setting_value(setting, "y", 0.0) - var sz := _setting_value(setting, "z", DEFAULT_HEIGHT_CM) - return MapCoord.to_world(Vector3(sx * 0.01, sz * 0.01, -sy * 0.01)) - -func _runtime_setting(setting: Dictionary) -> Dictionary: - var s := setting.duplicate(true) - s["_world_center"] = _setting_world_center(s) - s["distance"] = clampf(_setting_value(s, "distance", DEFAULT_DISTANCE_CM) * 0.01, - min_dist, max_dist) - s["pitch"] = clampf(deg_to_rad(_setting_value(s, "pitch", DEFAULT_PITCH_DEG)), - min_pitch, max_pitch) - s["rot"] = deg_to_rad(_setting_value(s, "rot", DEFAULT_ROTATION_DEG)) - s["up"] = _setting_value(s, "up", 0.0) * 0.01 - s["view"] = _setting_value(s, "view", 0.0) * 0.01 - s["cross"] = _setting_value(s, "cross", 0.0) * 0.01 - return s - -func _apply_setting(setting: Dictionary) -> void: - var s := _runtime_setting(setting) - yaw = s["rot"] - pitch = s["pitch"] - dist = s["distance"] - _event_offset = Vector3(s["cross"], s["up"], s["view"]) - -func _setting_for_blend() -> Dictionary: - if camera_mode == CameraMode.NORMAL: - return _normal_setting() - return _event_setting.duplicate(true) - -func _lerp_setting(a: Dictionary, b: Dictionary, t: float) -> Dictionary: - var out := {} - for key in ["x", "y", "z", "up", "view", "cross", "distance", "rot", "pitch"]: - out[key] = lerpf(_setting_value(a, key, 0.0), _setting_value(b, key, 0.0), t) - return out - -# CPythonApplication::SetEventCamera. The first event preserves the player's -# current normal settings so RESTORE_CAMERA returns to the user's view. -func set_event_camera(setting: Dictionary) -> void: - if camera_mode == CameraMode.NORMAL: - _default_setting = _normal_setting() - _event_setting = setting.duplicate(true) - _event_center = _setting_world_center(_event_setting) - _apply_setting(_event_setting) - camera_mode = CameraMode.STAND - _pos_ready = false - -# CPythonApplication::BlendEventCamera. `blendtime` follows the reference -# camera API and is expressed in seconds (the event parser passes it through). -func blend_event_camera(setting: Dictionary, blendtime: float) -> void: - _blend_from = _setting_for_blend() - _blend_to = setting.duplicate(true) - _blend_elapsed = 0.0 - _blend_duration = maxf(0.0, blendtime) - _event_setting = _blend_from.duplicate(true) - _event_center = _setting_world_center(_blend_from) - camera_mode = CameraMode.BLEND - _event_offset = Vector3.ZERO - _event_center = Vector3.ZERO - if _blend_duration <= 0.0: - _event_setting = _blend_to.duplicate(true) - _event_center = _setting_world_center(_event_setting) - _apply_setting(_event_setting) - camera_mode = CameraMode.STAND - _pos_ready = false - -func set_default_camera() -> void: - camera_mode = CameraMode.NORMAL - _blend_elapsed = 0.0 - _blend_duration = 0.0 - _event_offset = Vector3.ZERO - if not _default_setting.is_empty(): - _apply_setting(_default_setting) - _default_setting = {} - _event_setting = {} - _pos_ready = false - -func get_camera_setting() -> Dictionary: - var s := _setting_for_blend() - # During BLEND, expose the interpolated frame, matching GetCameraSetting's - # use by a subsequent camera event. - if camera_mode == CameraMode.BLEND: - var t := 1.0 if _blend_duration <= 0.0 else clampf(_blend_elapsed / _blend_duration, 0.0, 1.0) - s = _lerp_setting(_blend_from, _blend_to, t) - return s - -func snap_to_target() -> void: - if target == null: - return - _pos_ready = false - if camera_mode == CameraMode.NORMAL: - global_position = _desired_pos(true) - else: - global_position = _desired_pos(true) - _look_at_safe() - -# 相机位置与注视点重合时(首帧、传送落点、极近距离)方向向量为零,look_at() 会 -# 报错并且不改变朝向。跳过这一帧,下一帧插值分开后自然恢复。 -func _look_at_safe() -> void: - var look_to := _look_target() - if not global_position.is_equal_approx(look_to): - look_at(look_to, Vector3.UP) - -var _shake := 0.0 # 当前抖动强度(米),指数衰减 -var _shake_decay := 8.0 - -# P4:受击 / 暴击轻抖。strength 米,decay 越大越快停。 -func shake(strength: float, decay := 8.0) -> void: - _shake = maxf(_shake, strength) - _shake_decay = decay - -func _process(dt: float) -> void: - if target == null: - return - if camera_mode == CameraMode.BLEND: - _blend_elapsed += maxf(0.0, dt) - var t := 1.0 if _blend_duration <= 0.0 else clampf(_blend_elapsed / _blend_duration, 0.0, 1.0) - _event_setting = _lerp_setting(_blend_from, _blend_to, clampf(t, 0.0, 1.0)) - _event_center = _setting_world_center(_event_setting) - _apply_setting(_event_setting) - if t >= 1.0: - camera_mode = CameraMode.STAND - _event_setting = _blend_to.duplicate(true) - if camera_mode == CameraMode.NORMAL and _key_orbit != 0: - yaw += float(_key_orbit) * dt * 1.8 - if camera_mode == CameraMode.NORMAL and _key_zoom != 0: - # ZoomCamera() applies a ratio every update, rather than a fixed - # distance step. Positive direction means zoom out in 40250. - dist = clampf(dist * (1.0 + float(_key_zoom) * CAMERA_ZOOM_SPEED), - min_dist, max_dist) - if camera_mode == CameraMode.NORMAL and _key_pitch != 0: - pitch = clampf(pitch + float(_key_pitch) * dt * 1.2, min_pitch, max_pitch) - if camera_mode == CameraMode.NORMAL: - _apply_wheel_zoom() - var want := _desired_pos() - if _pos_ready: - global_position = global_position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0)) - else: - global_position = want - _pos_ready = true - if _shake > 0.001: - global_position += Vector3(randf_range(-1, 1), randf_range(-1, 1), randf_range(-1, 1)) * _shake - _shake = lerpf(_shake, 0.0, clampf(_shake_decay * dt, 0.0, 1.0)) - else: - _shake = 0.0 - _look_at_safe() - if camera_mode == CameraMode.NORMAL: - _fade_occluders() - -# §8.2 相机与玩家之间的建筑半透明淡出。沿 cam->head 逐段射线,逐个 hit 淡出, -# 移过撞点继续;本帧没被挡到的恢复不透明。 -func _fade_occluders() -> void: - var world3d := get_world_3d() - if world3d == null: - return - var head := _look_target() - var from := global_position - var this_frame: Array[Node] = [] - var ss := world3d.direct_space_state - var exclude: Array[RID] = [] - for _i in 4: - var q := PhysicsRayQueryParameters3D.create(from, head, OCCLUDER_MASK) - q.exclude = exclude - var hit := ss.intersect_ray(q) - if hit.is_empty(): - break - exclude.append(hit.rid) - # StaticBody3D 上的 meta 指向它的 MeshInstance3D(Metin2World 设的) - var body: Object = hit.collider - var gi: Node = body.get_meta("occ_mesh", null) if body and body.has_meta("occ_mesh") else null - if gi and gi is GeometryInstance3D and not this_frame.has(gi): - (gi as GeometryInstance3D).transparency = FADE_ALPHA - this_frame.append(gi) - from = hit.position + (head - from).normalized() * 0.05 - for g in _faded: - if is_instance_valid(g) and not this_frame.has(g): - g.transparency = 0.0 - _faded = this_frame diff --git a/project/game_camera.gd.uid b/project/game_camera.gd.uid deleted file mode 100644 index 57e6870f..00000000 --- a/project/game_camera.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://s4yeb23vdw3i diff --git a/project/game_camera_test.gd b/project/game_camera_test.gd deleted file mode 100644 index 9e1357ea..00000000 --- a/project/game_camera_test.gd +++ /dev/null @@ -1,89 +0,0 @@ -# game_camera_test —— CameraProcedure / PythonApplicationCamera 的离线状态回归。 -extends SceneTree - -const GameCamera = preload("res://game_camera.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: game_camera_test (NORMAL/STAND/BLEND + cm/degree conversion)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var root := Node3D.new() - get_root().add_child(root) - var actor := Node3D.new() - actor.position = Vector3(10, 2, 20) - root.add_child(actor) - var camera: Camera3D = GameCamera.new() - camera.target = actor - root.add_child(camera) - await process_frame - - _ck(camera.camera_mode == GameCamera.CameraMode.NORMAL, "starts in NORMAL mode") - _ck(is_equal_approx(camera.dist, 15.5), "default distance is 1550cm") - _ck(is_equal_approx(camera.pitch, deg_to_rad(27.0)), "default pitch is 27 degrees") - _ck(is_equal_approx(camera._look_target().y, actor.position.y + 1.2065217), - "target height follows CameraProcedure distance interpolation") - - var wheel := InputEventMouseButton.new() - wheel.button_index = MOUSE_BUTTON_WHEEL_UP - wheel.factor = 1.0 - wheel.pressed = true - var before_wheel: float = camera.dist - camera._unhandled_input(wheel) - camera._process(1.0 / 60.0) - _ck(camera.dist < before_wheel, "wheel up zooms in") - var after_first_wheel: float = camera.dist - camera._process(1.0 / 60.0) - _ck(camera.dist < after_first_wheel, "wheel zoom uses damped velocity") - - camera._wheel_zoom_velocity_cm = 0.0 - camera.dist = 10.0 - camera.set_key_zoom(1) - camera._process(1.0 / 60.0) - camera.set_key_zoom(0) - _ck(is_equal_approx(camera.dist, 10.5), - "keyboard zoom applies the 40250 0.05 ratio") - camera.dist = camera.min_dist - _ck(is_equal_approx(camera._look_target().y, actor.position.y + 1.5), - "target height reaches face height at minimum distance") - camera.dist = camera.max_dist - _ck(is_equal_approx(camera._look_target().y, actor.position.y + 1.0), - "target height reaches standard height at maximum distance") - camera.dist = 15.5 - - var shot := {"x": 1000, "y": 2000, "z": 300, "distance": 500, "pitch": 40, "rot": 90, - "up": 0, "view": 0, "cross": 0} - camera.set_event_camera(shot) - _ck(camera.camera_mode == GameCamera.CameraMode.STAND, "SET_CAMERA enters STAND") - _ck(camera.is_event_locked(), "event camera locks orbit") - var setting: Dictionary = camera.get_camera_setting() - _ck(is_equal_approx(float(setting.distance), 500.0), "event distance remains centimetres") - camera.snap_to_target() - _ck(camera.global_position.distance_to(Vector3(13.830, 6.214, 20)) < 0.02, - "event centre converts server x/y/z into world axes: %s" % camera.global_position) - - camera.blend_event_camera({"x": 3000, "y": 4000, "z": 500, "distance": 1000, - "pitch": 60, "rot": 180}, 1.0) - camera._process(0.5) - var middle: Dictionary = camera.get_camera_setting() - _ck(camera.camera_mode == GameCamera.CameraMode.BLEND, "blend remains BLEND before deadline") - _ck(is_equal_approx(float(middle.distance), 750.0), "blend is linear at half duration") - camera._process(0.6) - _ck(camera.camera_mode == GameCamera.CameraMode.STAND, "blend settles into STAND") - _ck(is_equal_approx(camera.dist, 10.0), "blend target distance applied") - camera.set_default_camera() - _ck(camera.camera_mode == GameCamera.CameraMode.NORMAL, "RESTORE_CAMERA enters NORMAL") - _ck(is_equal_approx(camera.dist, 15.5), "RESTORE_CAMERA restores saved normal view") - root.queue_free() diff --git a/project/game_camera_test.gd.uid b/project/game_camera_test.gd.uid deleted file mode 100644 index eb1652e0..00000000 --- a/project/game_camera_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://vei6jny5w1m2 diff --git a/project/game_scene.gd b/project/game_scene.gd deleted file mode 100644 index 0b1b9e5f..00000000 --- a/project/game_scene.gd +++ /dev/null @@ -1,2017 +0,0 @@ -# GameScene (P0,简版) —— 进游戏后的场景装配。 -# -# 把已有的件拼成一个能玩的场景: -# Metin2World + 本地玩家 + GameCamera + PlayerController + NetWorld + HUD -# + NetPlay(胶水)+ Audio -# -# 由 login.gd 在 entered_game 时实例化: -# var gs := preload("res://game_scene.gd").new() -# get_root().add_child(gs) -# gs.setup(m2client, assets_root) # map_path 可选,默认 a1 -# -# P0 本地玩家用占位胶囊;P2 装备系统落地后换真 Metin2Model(见 set_player_model)。 -extends Node3D - -const GameCamera = preload("res://game_camera.gd") -const PlayerCtl = preload("res://player_controller.gd") -const NetWorld = preload("res://net_world.gd") -const NetPlay = preload("res://net_play.gd") -const Hud = preload("res://hud.gd") -const UiProfile = preload("res://ui/ui_profile.gd") -const MobileUiRoot = preload("res://ui/mobile/mobile_ui_root.gd") -const Audio = preload("res://audio.gd") -const BgmDirector = preload("res://bgm_director.gd") -const UiManager = preload("res://ui/ui_manager.gd") -const CursorManager = preload("res://ui/cursor_manager.gd") -const MouseController = preload("res://ui/mouse_controller.gd") -const InventoryUI = preload("res://ui/inventory_ui.gd") -const ItemListDB = preload("res://ui/item_list.gd") -const EquipModel = preload("res://ui/equip_model.gd") -const PlayerView = preload("res://ui/player_view.gd") -const RemotePlayerView = preload("res://ui/remote_player_view.gd") -const MobView = preload("res://ui/mob_view.gd") -const GroundItems = preload("res://ui/ground_items.gd") -const ViewEquipmentUI = preload("res://ui/view_equipment_ui.gd") -const ChatUI = preload("res://ui/chat_ui.gd") -const DeathUI = preload("res://ui/death_ui.gd") -const EffectRegistry = preload("res://fx/effect_registry.gd") -const SkillFx = preload("res://fx/skill_fx.gd") -const SkillTable = preload("res://ui/skill_table.gd") -const SkillUI = preload("res://ui/skill_ui.gd") -const CharStatusUI = preload("res://ui/char_status_ui.gd") -const SystemOptionUI = preload("res://ui/system_option_ui.gd") -const GameOptionUI = preload("res://ui/game_option_ui.gd") -const SystemMenuUI = preload("res://ui/system_menu_ui.gd") -const Quickbar = preload("res://ui/quickbar.gd") -const QuestDialog = preload("res://ui/quest_dialog.gd") -const QuestLog = preload("res://ui/quest_log.gd") -const DungeonResultUI = preload("res://ui/dungeon_result_ui.gd") -const SelectItemUI = preload("res://ui/select_item_ui.gd") -const PartyUI = preload("res://ui/party_ui.gd") -const FriendUI = preload("res://ui/friend_ui.gd") -const LoveUI = preload("res://ui/love_ui.gd") -const ShopUI = preload("res://ui/shop_ui.gd") -const ExchangeUI = preload("res://ui/exchange_ui.gd") -const SafeboxUI = preload("res://ui/safebox_ui.gd") -const MallUI = preload("res://ui/mall_ui.gd") -const CubeUI = preload("res://ui/cube_ui.gd") -const PrivateShopUI = preload("res://ui/private_shop_ui.gd") -const GuildUI = preload("res://ui/guild_ui.gd") -const RefineUI = preload("res://ui/refine_ui.gd") -const DragonSoulUI = preload("res://ui/dragon_soul_ui.gd") -const Minimap = preload("res://ui/minimap.gd") -const AtlasUI = preload("res://ui/atlas_ui.gd") -const DungeonState = preload("res://dungeon_state.gd") -const WorldTime = preload("res://world/world_time.gd") -const Weather = preload("res://fx/weather.gd") -const QuestCurtain = preload("res://ui/quest_curtain.gd") - -# game.py::__IsXMasMap — these maps suppress the server xmas_snow command. -const XMAS_SNOW_EXCLUDED_MAPS := { - "metin2_map_n_flame_01": true, - "metin2_map_n_desert_01": true, - "metin2_map_spiderdungeon": true, - "metin2_map_deviltower1": true, -} - -# 龙魂是协议兼容层的一部分,但龙魂 40250 服务器没有这个功能。 -# 保留 DragonSoulUI / ds_refine 代码供后续兼容,当前客户端不创建入口、不消费背包右键。 -const ENABLE_DRAGON_SOUL := false - -var client: Node -var world: Node -var player: Node3D -var cam: Camera3D -var pc: Node -var net_world: Node -var net_play: Node -var hud: Node -var ui: CanvasLayer # UiManager -var quest_curtain: QuestCurtain -var cursor_manager: Node -var mouse_controller: Node -var proto: Node # Metin2Proto -var inventory: Node # InventoryUI -var item_list: RefCounted # ItemList -var equip_model: Node # EquipModel -var ground_items: Node # GroundItems -var view_equipment_ui: Node # ViewEquipmentUI -var chat: Node # ChatUI -var fx: RefCounted # EffectRegistry -var skill_fx: RefCounted # SkillFx -var skill_table: RefCounted # SkillTable -var skills: Node # SkillUI -var char_status_ui: Node # CharStatusUI -var system_option_ui: Node # SystemOptionUI -var game_option_ui: Node # GameOptionUI -var system_menu_ui: Node # SystemMenuUI -var quickbar: Node # Quickbar -var quest_dialog: Node # QuestDialog -var quest_log: Node # QuestLog -var dungeon_result_ui: Node # DungeonResultUI -var select_item_ui: Node # SelectItemUI -var party_ui: Node # PartyUI -var friend_ui: Node # FriendUI -var love_ui: Node # LoveUI -var shop_ui: Node # ShopUI -var exchange_ui: Node # ExchangeUI -var safebox_ui: Node # SafeboxUI -var mall_ui: Node # MallUI -var cube_ui: Node # CubeUI -var private_shop_ui: Node # PrivateShopUI -var guild_ui: Node # GuildUI -var refine_ui: Node # RefineUI -var dragon_soul_ui: Node # DragonSoulUI -var minimap: Node # Minimap -var atlas_ui: Node # AtlasUI -var dungeon_state: Node # GC_DUNGEON destination / compass state -var world_time: Node # WorldTime -var weather: Node3D # Weather -var _xmas_snow_enabled := false -var _assets := "" -var map_path := "" -var _model_built := false -var _main_model_attempted := false -var _main_sync_ready := false -var _main_loading := false -var _main_generation := 0 -var _main_view_key := "" -var _main_attempts := 0 -var _main_retry_at := 0 -var _main_placed_vid := 0 -var _main_map_vid := 0 -var _setup_completed := false -const MAIN_MODEL_MAX_ATTEMPTS := 3 - -# 实机「灰紫虚空 + 蓝胶囊」的取证钩子。离线自检(package_render_test.gd)在导出包里 -# 是全绿的,所以这个故障只出现在服务器驱动的进场路径上,而实机唯一的产物是一张截图。 -# 进游戏 DIAG_AFTER_MS 后如果地图或主角模型任一没落地,就把决定性的几个值打一次到 -# godot.log —— 让下一次实机复现直接给出原因,而不是再来一张截图。 -# MT_DIAG=1 时无论成败都打。 -const DIAG_AFTER_MS := 8000 -const DIAG_REPEAT_MS := 2000 # MT_DIAG=1 时连打几次,看主角装配是"没跑"还是"没跑完" -const DIAG_REPEATS := 6 -var _diag_at := 0 -var _diag_done := false -var _diag_left := 1 -var _diag_started := false -const MAIN_MODEL_RETRY_MS := 1000 -var _audio: Node -var _bgm_director: Node -var _mount: Node3D -var _sun: DirectionalLight3D -var _env: Environment -var _env_from_msenv := false # true = 用 Metin2World 从 .msenv 建的 Sun/WorldEnv -# CNormalBowAttack_FlyEventHandler_AutoClear 的等价:每次弓挥击 / 弓技能起手压一个 -# uSkill,`.msa` MOTION_EVENT_TYPE_FLY 帧(_on_local_motion_event 的 type 6)按 FIFO 弹一个 -# 发 CG_SHOOT —— pop 即自清(AutoClear),队列天然支持连射多箭在途。§3.6 -# 上限兜底:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时丢最旧的,避免无界增长。 -var _pending_shots: Array[int] = [] -const MAX_PENDING_SHOTS := 8 -var _guild_invite_dialog: ConfirmationDialog -var _gift_dialog: AcceptDialog -var _quick_page_mode := false -var _show_names := false -var _ui_mobile := false - -func setup(m2client: Node, assets_root: String, - initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void: - client = m2client - _assets = assets_root - var diag_after := int(OS.get_environment("MT_DIAG_AFTER_MS")) if OS.get_environment("MT_DIAG_AFTER_MS") != "" else DIAG_AFTER_MS - _diag_at = Time.get_ticks_msec() + diag_after - _ui_mobile = UiProfile.resolve() == UiProfile.Mode.MOBILE - var LocaleScript = load("res://locale.gd") - var cur_lang: String = LocaleScript.get_default_lang() if LocaleScript and LocaleScript.has_method("get_default_lang") else "zh" - # GC_MAIN_CHARACTER only carries the server-space coordinates, not the map - # folder. The old fixed A1 fallback puts characters from A2/A3 outside the - # rendered terrain, which looks like an empty map. Resolve the map from the - # character position before constructing Metin2World. - map_path = _resolve_map_path(initial_map_path) - _build_lighting() - # 关键:setup 是协程。每个重活之间让出一帧,好让 M2Client._process 抽 socket - # (否则整段 ~5s 同步阻塞会漏 PONG → 服务器 10s 后 "peer closed")。 - await _yield() - _build_world(assets_root, map_path) - await _yield() - - player = _make_placeholder_player() - add_child(player) - - cam = GameCamera.new() - cam.target = player - cam.world = world - add_child(cam) - cam.make_current() - - pc = PlayerCtl.new() - pc.player = player - pc.camera = cam - pc.world = world - pc.active = false - add_child(pc) - - _mount = Node3D.new() - _mount.name = "Entities" - add_child(_mount) - net_world = NetWorld.new() - add_child(net_world) - net_world.world = world - net_world.setup(client, _mount, _assets) - # §8.8 屏幕空间实体血条:明确绑定轨道相机,避免依赖 viewport 当前相机, - # 也让地图切换 / 多视口时投影来源稳定。 - if net_world.has_method("set_camera"): - net_world.set_camera(cam) - net_world.set_local_node(player) - net_world.entity_added.connect(_on_pickable_added) - net_world.entity_removed.connect(_on_pickable_removed) - if net_world.has_signal("actor_motion_event"): - net_world.actor_motion_event.connect(_on_remote_motion_event) - if client.has_signal("entity_dead"): - client.entity_dead.connect(func(vid: int) -> void: _drop_stale_pickables(vid)) - - if _ui_mobile: - hud = MobileUiRoot.new() - add_child(hud) - hud.setup(world, player, client) - else: - hud = Hud.new() - add_child(hud) - if _map_loaded(): - hud.setup(world, player) # 含小地图 / 快捷栏 / 背包 - else: - hud.call("_build_status") # 无地图兜底:只搭状态栏(血 / 蓝 / 经验 / 等级) - - net_play = NetPlay.new() - add_child(net_play) - net_play.setup(client, pc, net_world, hud, _audio) - net_play.camera = cam - # §8.6 targetBoard.GetTargetVID() -> net_world:选中目标始终强显名字。 - if net_play.has_signal("target_changed") and net_world and net_world.has_method("set_target_vid"): - net_play.target_changed.connect(func(vid: int): net_world.set_target_vid(int(vid))) - # §8.8 game.py OnRender chr.Pick() -> ShowCharacterTextTail:悬停角色也强显名字(pick_show.gd)。 - if pc.has_signal("hover_entity_changed") and net_world and net_world.has_method("set_hover_vid"): - pc.hover_entity_changed.connect(func(vid: int): net_world.set_hover_vid(int(vid))) - # 40250 EFFECT_PICK: 鼠标点地时生成绿色地面水波光圈 click.mse - if pc.has_signal("ground_clicked"): - pc.ground_clicked.connect(_on_ground_clicked) - if _assets != "": - net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表 - var _early_loc: RefCounted = load("res://locale.gd").new() - _early_loc.setup(_assets, cur_lang) - net_play.locale = _early_loc - if net_world and "name_resolver" in net_world: - net_world.name_resolver = net_play._entity_name # 早期绑定:确保最先刷出的实体即显示中文名 - if net_world and "kind_resolver" in net_world: - net_world.kind_resolver = net_play._entity_kind - - # 主角必须先于后续的 proto/UI/远端实体重活建立。实网一次可收到近 200 个 - # GC_CHARACTER_ADD;若等整个 setup() 尾部才订阅,怪物模型同步加载会把本地角色 - # 卡在占位胶囊几十秒,并且装备控制器也会错过 entity_main_set。 - client.entity_main_set.connect(_on_main_set) - _main_sync_ready = true - if client.get_main_vid() != 0: # 信号可能在 GameScene 创建前已经派发 - if net_world and net_world.has_method("set_local_vid"): - net_world.set_local_vid(client.get_main_vid()) - if net_play and net_play.has_method("_on_main_set"): - net_play._on_main_set(client.get_main_vid()) - _on_main_set(client.get_main_vid()) - - await _yield() - _audio = Audio.new() - add_child(_audio) - if assets_root != "": - _audio.setup(assets_root) - # CPythonNetworkStream plays the item-specific use/drop sound immediately - # before the corresponding CG item packet. Keep the native event as the - # single source so classic and m2dev callers cannot diverge in the UI. - if client and client.has_signal("item_action_sound"): - client.item_action_sound.connect(func(sound: String): - if _audio and _audio.has_method("play_ui"): - _audio.play_ui(sound) - ) - await _yield() - - # §9.1 地图 BGM:M2Client.bgm_changed -> Audio。放在独立节点里,W4 补全曲目解析。 - _bgm_director = BgmDirector.new() - add_child(_bgm_director) - _bgm_director.bind(client, _audio) - - # UI 层 + 物品 proto + 背包窗(I 键开关) - ui = UiManager.new() - add_child(ui) - quest_curtain = QuestCurtain.new() - ui.add_child(quest_curtain) - cursor_manager = CursorManager.new() - add_child(cursor_manager) - cursor_manager.setup(assets_root) - mouse_controller = MouseController.new() - add_child(mouse_controller) - mouse_controller.setup(ui, cursor_manager, _audio) - mouse_controller.world_drop_handler = _on_world_item_drop - - # 聊天窗(Enter 聚焦输入) - chat = ChatUI.new() - add_child(chat) - chat.setup(client, ui, _assets) - if net_play and net_play.has_signal("fishing_feedback") and chat.has_method("on_fishing_feedback"): - net_play.fishing_feedback.connect(chat.on_fishing_feedback) - # §8.8 / §5.4 game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时 - # self.interface.OpenWhisperDialog(name)。chat.start_whisper 打开独立私聊窗口。 - if net_play and net_play.has_signal("whisper_requested") and chat.has_method("start_whisper"): - net_play.whisper_requested.connect(chat.start_whisper) - if net_world and net_world.has_signal("main_bubble"): - net_world.main_bubble.connect(_player_bubble) - - # 特效注册表(P5)—— GC_SPECIFIC_EFFECT(.mse 路径) / GC_SPECIAL_EFFECT(内建 id) 在实体上播 - fx = EffectRegistry.new() - fx.setup(_assets) - # §3.7 __ProcessDataAttackSuccess:命中特效(m_dwBattleHitEffectID)挂在实体挂载点下播 - if net_play: - net_play.fx = fx - net_play.fx_parent = _mount - - # 技能(P6)—— K 键技能窗,数字键 1-4 → 快捷栏 0-3 / F1-F4 → 4-7 / Ctrl+数字 = 表情,+ 技能特效表 - if assets_root != "": - skill_table = SkillTable.new() - for lang in ["en", "common"]: - if skill_table.load_file(assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang)): - break - # LoadLocaleData:RegisterSkillDesc 之后 RegisterSkillTable(技能射程 TARGET_RANGE)。 - for lang in ["en", "common"]: - if skill_table.load_table(assets_root.path_join("locale/locale/%s/skilltable.txt" % lang)): - break - skill_fx = SkillFx.new() - skill_fx.setup(fx, skill_table) - if net_world: - net_world.skill_table = skill_table - net_world.fx = fx - skills = SkillUI.new() - add_child(skills) - skills.setup(client, skill_table, ui) - if skills.has_signal("skill_use_requested"): - skills.skill_use_requested.connect(func(sid: int): - if quickbar and quickbar.has_method("activate_skill_direct"): - quickbar.activate_skill_direct(sid) - ) - char_status_ui = CharStatusUI.new() - add_child(char_status_ui) - char_status_ui.setup(ui, client, assets_root, skill_table, mouse_controller) - quickbar = Quickbar.new() - add_child(quickbar) - quickbar.item_mouse = mouse_controller - quickbar.net_play = net_play # §3.8 修改 1:技能三层校验的运行期上下文 - quickbar.setup(client, skill_table, ui, func() -> Node: return player, assets_root) - if hud: - if "quickbar" in hud: - hud.set("quickbar", quickbar) - if hud.has_method("set_status_visible"): - hud.set_status_visible(false) - # §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。 - if net_play: - net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot) - if quickbar.has_signal("skill_rejected"): - quickbar.skill_rejected.connect(func(_sid: int, code: String): - if net_play and net_play.has_signal("cannot_act"): - net_play.cannot_act.emit(code) # HUD 走同一条 OnCannotUseSkill 文案通道 - ) - if net_play and net_play.has_signal("fishing_feedback") and net_play.has_signal("cannot_act"): - net_play.fishing_feedback.connect(func(code: String): net_play.cannot_act.emit(code)) - quickbar.skill_cast_started.connect(_on_skill_cast_started) - if quickbar.has_signal("skill_activated"): - quickbar.skill_activated.connect(func(sid: int): - # 40250 的本地技能视觉来自当前等级 .msa 的 MotionEventData。 - # 这里不再按技能名猜测并整套预播,否则会和动作帧特效重复且位置错误。 - # Bow skills send CG_SHOOT from the .msa FLY frame, matching - # CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot —— 压队列, - # 不再用 1.5s 定时器兜底(AutoClear 由 FLY 帧 pop 自清)。§3.6 - if skill_table and skill_table.has_method("is_ranged") and skill_table.is_ranged(sid): - _queue_shot(sid) - ) - if quickbar.has_signal("character_requested"): - quickbar.character_requested.connect(func(): - if char_status_ui: char_status_ui.toggle() - ) - if quickbar.has_signal("inventory_requested"): - quickbar.inventory_requested.connect(func(): - if inventory: inventory.toggle() - ) - if quickbar.has_signal("messenger_requested"): - quickbar.messenger_requested.connect(func(): - if friend_ui: friend_ui.toggle() - ) - if quickbar.has_signal("safebox_requested"): - quickbar.safebox_requested.connect(_on_safebox_button_clicked) - if quickbar.has_signal("system_requested"): - quickbar.system_requested.connect(func(): - if system_menu_ui: system_menu_ui.toggle() - ) - if quickbar.has_signal("chat_requested"): - quickbar.chat_requested.connect(func(): - if chat and chat.has_method("toggle_log"): - chat.toggle_log() - ) - - # 弓普攻起手(net_play._emit_swing):OnSetFlyTarget 已在那里发过 CG_FLY_TARGETING, - # 这里只把 uSkill 压进 FLY 帧待发队列(普攻恒 0)。§3.6 - if net_play and net_play.has_signal("bow_shot_fired"): - net_play.bow_shot_fired.connect(_queue_shot) - - if client.has_signal("effect_cue"): - client.effect_cue.connect(func(vid: int, name: String, special: int): - var host: Node3D = _fx_host(vid) - if host == null: - return - if name != "": - fx.spawn(name, host, true) # GC_SPECIFIC_EFFECT - elif special >= 0 and skill_fx: - skill_fx.spawn_special(special, host)) # GC_SPECIAL_EFFECT 内建 id - if client.has_signal("emoticon_requested"): - client.emoticon_requested.connect(_play_emoticon) - if client.has_signal("gift_available"): - client.gift_available.connect(_on_gift_available) - if client.has_signal("weather_changed"): - client.weather_changed.connect(_on_weather_changed) - - # 任务 / NPC(P7)—— 对话窗自动弹,J 键任务日志 - quest_dialog = QuestDialog.new() - add_child(quest_dialog) - quest_dialog.setup(client, ui, proto, assets_root) - if quest_dialog.has_signal("camera_event_requested_full"): - quest_dialog.camera_event_requested_full.connect(_on_quest_camera_event) - if quest_dialog.has_signal("fade_event_requested_full"): - quest_dialog.fade_event_requested_full.connect(_on_quest_fade_event) - if quest_dialog.has_signal("dungeon_result_requested"): - quest_dialog.dungeon_result_requested.connect(_on_dungeon_result) - dungeon_result_ui = DungeonResultUI.new() - add_child(dungeon_result_ui) - dungeon_result_ui.setup(ui) - quest_log = QuestLog.new() - add_child(quest_log) - quest_log.setup(client, ui) - if quest_dialog.has_signal("quest_button_received"): - quest_dialog.quest_button_received.connect(func(index: int, title: String, icon_type: String, icon_name: String): - if quest_log and quest_log.has_method("recv_quest"): - quest_log.recv_quest(index, title, icon_type, icon_name)) - quest_dialog.quest_button_cleared.connect(func(index: int): - if quest_log and quest_log.has_method("clear_quest"): - quest_log.clear_quest(index)) - quest_dialog.opened.connect(func(): - if quest_log and quest_log.has_method("set_buttons_suppressed"): - quest_log.set_buttons_suppressed(true) - if quest_curtain: - quest_curtain.open() - ) - quest_dialog.closed.connect(func(): - if quest_log and quest_log.has_method("set_buttons_suppressed"): - quest_log.set_buttons_suppressed(false) - if quest_curtain: - quest_curtain.close() - ) - - # 死亡窗 + 状态图标条(P4) - var death := DeathUI.new() - add_child(death) - death.setup(client, ui) - if client.has_signal("affect_added"): - var refresh_affects := func(_a = null): if hud and hud.has_method("set_affects"): hud.set_affects(client.get_affects()) - client.affect_added.connect(refresh_affects) - client.affect_removed.connect(refresh_affects) - client.affect_added.connect(func(a: Dictionary): - var t := int(a.get("type", 0)) - _set_player_affect(t, true) - ) - client.affect_removed.connect(func(t: int): - _set_player_affect(t, false) - ) - await _yield() - if ClassDB.class_exists("Metin2Proto") and assets_root != "": - proto = ClassDB.instantiate("Metin2Proto") - add_child(proto) - var ip := AssetRoot.locale_file("item_proto", cur_lang) - if ip != "": - proto.call("load_item_proto", ip) - # CPythonPlayer::IsEquipItemInSlot and SetItemData both consult the - # same ItemManager table. Share this authority with native senders so - # equipment action gates do not infer type from the window alone. - if client and client.has_method("set_item_proto"): - client.call("set_item_proto", proto) - if quickbar and "proto" in quickbar: - quickbar.proto = proto - await _yield() - if chat and chat.has_method("set_proto"): - chat.set_proto(proto) - if quest_dialog and quest_dialog.has_method("set_proto"): - quest_dialog.set_proto(proto) - var mp := AssetRoot.locale_file("mob_proto", cur_lang) - if mp != "": - proto.call("load_mob_proto", mp) - await _yield() - net_play.proto = proto # 按 race 分类 NPC/怪(bType 在本 fork 不可靠) - if net_world and "name_resolver" in net_world: - net_world.name_resolver = net_play._entity_name # 头顶名字:怪走 mob_proto - if net_world and "kind_resolver" in net_world: - net_world.kind_resolver = net_play._entity_kind # §8.8 名字色分类:怪 / NPC 走 mob_proto bType - if net_world and "title_name_resolver" in net_world and assets_root != "": - # §8.8 称号名:introloading.__RegisterTitleName -> localeInfo.PVP_LEVEL - var _tt_loc: RefCounted = net_play.locale if net_play.locale != null else load("res://locale.gd").new() - if net_play.locale == null: - _tt_loc.setup(assets_root, cur_lang) - net_play.locale = _tt_loc - net_world.title_name_resolver = func(g: int) -> String: - var k := "PVP_LEVEL%d" % g - return _tt_loc.t(k) if _tt_loc.has(k) else "" - if assets_root != "": - item_list = ItemListDB.new() - item_list.load_file(assets_root.path_join("locale/locale/common/item_list.txt")) - await _yield() - if quest_log and quest_log.has_method("set_item_sources"): - quest_log.set_item_sources(proto, item_list, assets_root) - inventory = InventoryUI.new() - add_child(inventory) - inventory.item_mouse = mouse_controller - inventory.setup(ui, client, proto, assets_root, item_list) - # 装备 → 模型部件:武器 / 盾 / 身体 / 头盔 / 头发 - equip_model = EquipModel.new() - add_child(equip_model) - equip_model.setup(client, item_list, func() -> Node: return player, assets_root) - equip_model.effect_registry = fx - equip_model.main_getter = func() -> int: return client.get_main_vid() - equip_model.proto = proto # armor shape 走 item_proto values[3] + specular - # 主角已在上面的优先路径建立时,装备相关网络信号早于 EquipModel 的订阅。 - # 用当前权威实体与装备快照补一次,保证首次进场立即挂盔甲、武器和发型。 - var current_main := int(client.get_main_vid()) - if current_main != 0: - equip_model.set_race(int(client.get_entity(current_main).get("race", -1))) - equip_model.refresh() - # 地面掉落物 - ground_items = GroundItems.new() - add_child(ground_items) - # SetItemTextTailOwner :722:strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE) - # 为空则字面 "'s"。poc 用 EterLocale 的 POSSESSIVE_MORPHENE 键作对应物(缺则回 "")。 - var _possessive := "" - if assets_root != "": - var _poss_loc: RefCounted = load("res://locale.gd").new() - _poss_loc.setup(assets_root, cur_lang) - if _poss_loc.has("POSSESSIVE_MORPHENE"): - _possessive = _poss_loc.t("POSSESSIVE_MORPHENE") - ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list, - func() -> Camera3D: return cam, _possessive, func() -> Node: return world) - pc.set_input_surfaces(cursor_manager, ui, ground_items, - Callable(net_play, "on_manual_move"), Callable(net_play, "on_ground_click")) - view_equipment_ui = ViewEquipmentUI.new() - add_child(view_equipment_ui) - view_equipment_ui.setup(client, ui, proto, item_list) - # 最后再升级怪/NPC/远端玩家真模型。实网的首次实体 burst 接近 200 个, - # set_model_factory 会同步重建已有节点;放在主角装备和技能 UI 之前会让本地 - # 角色看似长期裸装,并使冒烟测试在控制器建立前误判完成。 - if net_world: - net_world.set_model_factory(_make_entity_model) - - # 选魔石窗(EventManager [SELECT_ITEM] -> interfacemodule.BINARY_OpenSelectItemWindow) - select_item_ui = SelectItemUI.new() - add_child(select_item_ui) - select_item_ui.setup(ui, client, proto, assets_root, item_list) - if quest_dialog and quest_dialog.has_signal("select_item_requested"): - quest_dialog.select_item_requested.connect(select_item_ui.open) - - # 社交 / 商店 / 仓库(P8) - party_ui = PartyUI.new() - add_child(party_ui) - party_ui.setup(client, ui) - # __ArrangeQuestButton 的 xPos 随组队窗可见性移位;party_ui 无信号,用探针轮询。 - if quest_log and quest_log.has_method("set_party_probe"): - quest_log.set_party_probe(func() -> bool: - return party_ui != null and party_ui.has_method("is_open") and party_ui.is_open()) - friend_ui = FriendUI.new() - add_child(friend_ui) - friend_ui.setup(client, ui) - love_ui = LoveUI.new() - add_child(love_ui) - love_ui.setup(client, ui) - if chat and friend_ui.has_signal("whisper_to"): - friend_ui.whisper_to.connect(func(nm: String): - if chat.has_method("start_whisper"): - chat.start_whisper(nm)) - shop_ui = ShopUI.new() - add_child(shop_ui) - shop_ui.item_mouse = mouse_controller - shop_ui.audio = _audio - shop_ui.setup(client, ui, proto, item_list) - exchange_ui = ExchangeUI.new() - add_child(exchange_ui) - exchange_ui.item_mouse = mouse_controller - exchange_ui.setup(client, ui, proto) - safebox_ui = SafeboxUI.new() - add_child(safebox_ui) - safebox_ui.item_mouse = mouse_controller - safebox_ui.setup(client, ui, proto) - mall_ui = MallUI.new() - add_child(mall_ui) - mall_ui.item_mouse = mouse_controller - mall_ui.setup(client, ui, proto) - cube_ui = CubeUI.new() - add_child(cube_ui) - cube_ui.setup(client, ui, proto) - private_shop_ui = PrivateShopUI.new() - add_child(private_shop_ui) - private_shop_ui.item_mouse = mouse_controller - private_shop_ui.setup(client, ui, proto, assets_root) - guild_ui = GuildUI.new() - add_child(guild_ui) - guild_ui.setup(client, ui, skill_table) - guild_ui.mark_image_provider = _guild_mark_upload_image - if client.has_signal("guild_invite_ask"): - client.guild_invite_ask.connect(_on_guild_invite) - refine_ui = RefineUI.new() - add_child(refine_ui) - refine_ui.setup(client, ui, proto) - if ENABLE_DRAGON_SOUL: - dragon_soul_ui = DragonSoulUI.new() - add_child(dragon_soul_ui) - dragon_soul_ui.setup(client, ui, proto) - else: - dragon_soul_ui = null - await _yield() - if inventory and "context_consumer" in inventory: - inventory.context_consumer = _on_inv_context - - # 世界系统(P9)—— 小地图、昼夜、天气、传送 - minimap = Minimap.new() - add_child(minimap) - minimap.setup(client, ui, func() -> Node3D: return player) - dungeon_state = DungeonState.new() - add_child(dungeon_state) - dungeon_state.setup(client, func() -> Node3D: return player) - if dungeon_state.has_signal("destination_changed") and hud and hud.has_method("set_dungeon_destination"): - dungeon_state.destination_changed.connect(hud.set_dungeon_destination) - atlas_ui = AtlasUI.new() - add_child(atlas_ui) - atlas_ui.setup(world, ui, func() -> Node3D: return player, map_path.get_file(), client) - if quest_dialog: - quest_dialog.map_signal_added.connect(func(x: float, y: float): - if minimap and minimap.has_method("add_signal_point"): - minimap.add_signal_point(x, y) - if atlas_ui and atlas_ui.has_method("add_signal_point"): - atlas_ui.add_signal_point(x, y) - if atlas_ui and atlas_ui.has_method("open"): - atlas_ui.open()) - quest_dialog.map_signals_cleared.connect(func(): - if minimap and minimap.has_method("clear_signal_points"): - minimap.clear_signal_points() - if atlas_ui and atlas_ui.has_method("clear_signal_points"): - atlas_ui.clear_signal_points()) - quest_dialog.atlas_center_requested.connect(func(x: int, y: int): - if atlas_ui and atlas_ui.has_method("set_center_position_adjust"): - atlas_ui.set_center_position_adjust(x, y)) - world_time = WorldTime.new() - add_child(world_time) - # .msenv 已经给了固定的白天关照(A1 没有昼夜预设)——别让 world_time 每帧覆盖它。 - # 只在走兜底关照时驱动昼夜。 - world_time.setup(client, _sun if not _env_from_msenv else null, - _env if not _env_from_msenv else null) - weather = Weather.new() - add_child(weather) - weather.setup(cam) - _apply_server_weather() - # 系统设置 / 游戏设置 / ESC 系统菜单(uisystem.SystemDialog + uiSystemOption + uiGameOption) - system_option_ui = SystemOptionUI.new() - add_child(system_option_ui) - system_option_ui.setup(ui, assets_root, _audio, - func() -> Camera3D: return cam, - func() -> Environment: return _env) - game_option_ui = GameOptionUI.new() - add_child(game_option_ui) - game_option_ui.setup(ui, client, assets_root) - # §8.4「总显示名字」radio -> net_world(CPythonSystem::SetAlwaysShowNameFlag)。 - if game_option_ui.has_signal("display_option_changed"): - game_option_ui.display_option_changed.connect(_on_display_option) - if net_world and net_world.has_method("set_always_show_name") and game_option_ui.has_method("display_value"): - net_world.set_always_show_name(game_option_ui.display_value("always_show_name") == 1) - # §8.8 / §5.4「伤害数字」radio -> net_world(CPythonSystem::SetShowDamageFlag)。 - if net_world and net_world.has_method("set_show_damage") and game_option_ui.has_method("display_value"): - net_world.set_show_damage(game_option_ui.display_value("show_damage") == 1) - # §8.4「名字色」radio -> net_world(uigameoption.__SetNameColorMode → chrmgr.SetEmpireNameMode)。 - if net_world and net_world.has_method("set_empire_name_mode") and game_option_ui.has_method("display_value"): - net_world.set_empire_name_mode(game_option_ui.display_value("name_color") == 1) - # §8.4「看他国玩家目标框」radio -> net_play(uitarget.TargetBoard.Open 前置过滤)。 - if net_play and net_play.has_method("set_view_other_empire_target") and game_option_ui.has_method("display_value"): - net_play.set_view_other_empire_target(game_option_ui.display_value("target_board") == 1) - # §8.4「聊天显示」radio -> chat. - if chat and game_option_ui.has_method("display_value"): - var vc: bool = (game_option_ui.display_value("view_chat") == 1) - if chat.has_method("set_view_chat"): - chat.set_view_chat(vc) - elif "visible" in chat: - chat.visible = vc - system_menu_ui = SystemMenuUI.new() - add_child(system_menu_ui) - system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui) - if client.has_signal("warp"): - client.warp.connect(_on_warp) - if client.has_signal("world_reset"): - client.world_reset.connect(_on_world_reset) - if client.has_signal("channel_changed") and hud and hud.has_method("set_channel"): - client.channel_changed.connect(func(c): hud.set_channel(c)) - - if _ui_mobile and hud and hud.has_method("bind_controls"): - _setup_mobile_ui() - - # setup() 是协程 —— 进来时进游戏的 spawn burst 早被 M2Client.pump_game 抽干、 - # net_world 还没连上信号。把当前所有实体(怪 / NPC / 别的玩家)补建一遍。 - if net_world and net_world.has_method("catch_up"): - net_world.catch_up() - - # 40250 IntroLoading 对齐:若主角真模型尚未构建完成,在场景就绪前同步构建 - if not _model_built and client and client.has_method("get_main_vid") and int(client.get_main_vid()) != 0: - _sync_main_character() - - set_process_unhandled_input(true) - _setup_completed = true - if pc: - pc.active = true - if pc.has_method("stop"): - pc.stop() - -func _setup_mobile_ui() -> void: - # MobileUiRoot owns the presentation and touch layer; every feature node in - # this dictionary remains the existing PC/business controller. - hud.bind_controls({ - "client": client, - "ui": ui, - "lifecycle": _lifecycle_node(), - "player_controller": pc, - "net_play": net_play, - "quickbar": quickbar, - "ground_items": ground_items, - "inventory": inventory, - "atlas_ui": atlas_ui, - "quest_log": quest_log, - "party_ui": party_ui, - "char_status_ui": char_status_ui, - "skills": skills, - "chat": chat, - "friend_ui": friend_ui, - "guild_ui": guild_ui, - "love_ui": love_ui, - "refine_ui": refine_ui, - "cube_ui": cube_ui, - "mall_ui": mall_ui, - "shop_ui": shop_ui, - "exchange_ui": exchange_ui, - "safebox_ui": safebox_ui, - "private_shop_ui": private_shop_ui, - "system_menu_ui": system_menu_ui, - "system_option_ui": system_option_ui, - "game_option_ui": game_option_ui, - "minimap": minimap, - }) - set_process(true) - -# 可点选实体列表维护。pickables 是 Array[Node3D](TypedArray),对已释放的实例 -# 调用 erase()/has() 会被类型校验拒绝并报错,所以一律按下标倒序 remove_at()。 -func _drop_stale_pickables(drop_vid: int = -1) -> void: - if pc == null: - return - var list: Array = pc.pickables - for i in range(list.size() - 1, -1, -1): - # 不要把元素赋给 Node3D 类型的变量:已释放的实例会在类型校验时再次报错。 - var n = list[i] - if n == null or not is_instance_valid(n): - list.remove_at(i) - elif n.is_queued_for_deletion(): - list.remove_at(i) - elif bool(n.get_meta("dead", false)): - list.remove_at(i) - elif drop_vid >= 0 and int(n.get_meta("vid", -1)) == drop_vid: - list.remove_at(i) - -# 模型工厂升级会原地替换先前的占位节点,注册新的点选目标前先清掉待删条目。 -func _on_pickable_added(node: Node3D, _vid: int) -> void: - if pc == null or node == null: - return - _drop_stale_pickables() - var list: Array = pc.pickables - for existing in list: - if existing == node: - return - list.append(node) - -func _on_pickable_removed(vid: int) -> void: - _drop_stale_pickables(vid) - -# 40250 EFFECT_PICK: 鼠标点地时生成绿色地面水波光圈 click.mse -func _on_ground_clicked(pos: Vector3) -> void: - if fx and is_instance_valid(_mount): - fx.spawn_at("d:/ymir work/effect/etc/click/click.mse", _mount, pos, true) - -func _lifecycle_node() -> Node: - # AppFlow owns the single lifecycle observer across login/select/game. The - # game scene only borrows it for Android back and focus recovery. - var owner := get_parent() - if owner: - return owner.get_node_or_null("AppLifecycle") - return null - -func _process(_dt: float) -> void: - # `entered_game` is emitted by the phase transition, while the first - # GC_MAIN_CHARACTER / EntityStore change is drained later in the same or a - # following frame. The setup-time catch-up below cannot see that packet in - # all server timings, so keep the event path and add this idempotent fallback. - # Without it the local player stays as the blue CapsuleMesh forever and the - # camera follows an ungrounded placeholder, which makes the map look empty. - _sync_main_character() - _diag_tick() - # MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio - # node keeps its listener at the origin and rebases active 3D instances. - if _audio == null or player == null or not is_instance_valid(player): - return - var basis := player.global_transform.basis - _audio.set_listener(player.global_position, -basis.z, basis.y) - if world and world.has_method("get_ambience_sources") and _audio.has_method("update_ambience_sources"): - _audio.update_ambience_sources(world.call("get_ambience_sources")) - -# 进游戏若干秒后的一次性取证。只在「地图或主角没落地」时输出,正常局内不产生噪音。 -# 打印的每个值都对应 _build_world / _sync_main_character 里的一个具体分支,读日志的人 -# 不用再猜是资源没找到、扩展没注册、race 越界还是坐标落在了别的地图上。 -func _diag_tick() -> void: - if _diag_done or _diag_at == 0 or Time.get_ticks_msec() < _diag_at: - return - var forced := OS.get_environment("MT_DIAG") == "1" - if forced and not _diag_started: - _diag_started = true - _diag_left = DIAG_REPEATS - _diag_left -= 1 - if _diag_left <= 0: - _diag_done = true - else: - _diag_at = Time.get_ticks_msec() + DIAG_REPEAT_MS - var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game() - if not forced and (not in_game or (_model_built and _map_loaded())): - _diag_done = true - return - var vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0 - var e: Dictionary = client.get_entity(vid) if vid != 0 and client.has_method("get_entity") else {} - var d := { - "in_game": in_game, "model_built": _model_built, "map_loaded": _map_loaded(), - "map_path": map_path, "assets": _assets, "assets_exists": DirAccess.dir_exists_absolute(_assets), - "main_vid": vid, "race": e.get("race", null), "pos": str(e.get("pos", "")), - "pos_cm": str(e.get("pos_cm", "")), "entity_empty": e.is_empty(), - "main_attempts": _main_attempts, "main_model_attempted": _main_model_attempted, - "main_sync_ready": _main_sync_ready, "main_loading": _main_loading, - "main_view_key": _main_view_key, "main_map_vid": _main_map_vid, - "main_retry_in_ms": _main_retry_at - Time.get_ticks_msec(), - "t_ms": Time.get_ticks_msec(), - "classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"), - "Metin2World": ClassDB.class_exists("Metin2World")}, - "world_null": world == null, - "map_report": world.call("get_load_report") if world != null else {}, - "player_pos": str(player.position) if player != null else "", - "map_base_set": MapCoord.has_base(), - "map_base_cm": str(MapCoord.to_server_cm(Vector3.ZERO)), - } - printerr("GAMESCENE_DIAG: ", JSON.stringify(d)) - # MT_DIAG_SHOT=:最后一次 tick 存一张实机帧,用于人工核对渲染结果。 - var shot := OS.get_environment("MT_DIAG_SHOT") - var shot_ready := _model_built and is_instance_valid(equip_model) and is_instance_valid(quickbar) - if not shot.is_empty() and (_diag_done or shot_ready): - await RenderingServer.frame_post_draw - var img := get_viewport().get_texture().get_image() - if img != null: - if img.save_png(shot) == OK: - _diag_done = true - -# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。 -func _guild_mark_upload_image() -> Image: - var p := "res://ui/default_guild_mark.png" - if ResourceLoader.exists(p): - var tex: Texture2D = load(p) - if tex: - return tex.get_image() - var img := Image.create(16, 12, false, Image.FORMAT_RGBA8) - for y in 12: - for x in 16: - img.set_pixel(x, y, Color(0.8, 0.2, 0.2) if (x + y) % 2 == 0 else Color(0.9, 0.85, 0.3)) - return img - -func _on_guild_invite(guild_id: int, guild_name: String) -> void: - if _guild_invite_dialog and is_instance_valid(_guild_invite_dialog): - _guild_invite_dialog.queue_free() - _guild_invite_dialog = ConfirmationDialog.new() - _guild_invite_dialog.title = "公会邀请" - _guild_invite_dialog.dialog_text = "加入公会“%s”?" % guild_name - _guild_invite_dialog.ok_button_text = "加入" - _guild_invite_dialog.cancel_button_text = "拒绝" - _guild_invite_dialog.set_meta("mobile_title", "公会邀请") - ui.add_child(_guild_invite_dialog) - if ui.has_method("track_mobile_modal"): - ui.track_mobile_modal(_guild_invite_dialog) - _guild_invite_dialog.confirmed.connect(func(): - client.guild_answer_invite(guild_id, true) - _guild_invite_dialog.queue_free()) - _guild_invite_dialog.canceled.connect(func(): - client.guild_answer_invite(guild_id, false) - _guild_invite_dialog.queue_free()) - _popup_guild_invite() - -func _on_gift_available() -> void: - # 40250's Gift_Show has no corresponding claim packet in the shipped - # PythonNetworkStream. Keep the server notification visible instead of - # silently consuming it; any reward data remains owned by the gift UI/backend. - if ui == null: - return - if _gift_dialog and is_instance_valid(_gift_dialog): - _popup_gift() - return - _gift_dialog = AcceptDialog.new() - _gift_dialog.title = "礼物" - _gift_dialog.dialog_text = "有新的礼物可查看。" - _gift_dialog.ok_button_text = "知道了" - _gift_dialog.set_meta("mobile_title", "礼物") - ui.add_child(_gift_dialog) - if ui.has_method("track_mobile_modal"): - ui.track_mobile_modal(_gift_dialog) - _gift_dialog.confirmed.connect(func(): - if is_instance_valid(_gift_dialog): - _gift_dialog.queue_free()) - _popup_gift() - -func _popup_guild_invite() -> void: - if not is_instance_valid(_guild_invite_dialog): - return - if _ui_mobile: - _guild_invite_dialog.min_size = Vector2i(500, 230) - _guild_invite_dialog.size = Vector2i(500, 230) - _guild_invite_dialog.popup_centered(Vector2i(500, 230)) - else: - _guild_invite_dialog.popup_centered() - -func _popup_gift() -> void: - if not is_instance_valid(_gift_dialog): - return - if _ui_mobile: - _gift_dialog.min_size = Vector2i(460, 210) - _gift_dialog.size = Vector2i(460, 210) - _gift_dialog.popup_centered(Vector2i(460, 210)) - else: - _gift_dialog.popup_centered() - -# .msa MotionEventData 分派(对齐 GameLib/RaceMotionData EMotionEventType): -# 1 EFFECT / 10 EFFECT_TO_TARGET → 挂特效;2 SCREEN_WAVING → 震屏; -# 3 SCREEN_FLASHING → 屏闪;5 SOUND / 有 sound 字段 → 3D 音;6 FLY / 9 WARP → 钩子。 -# 数字在 .msa 文本里与运行时枚举有历史偏差,故先按「字段是否有值」再按 type 兜底。 -func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vector3) -> void: - var anchor: Node3D = player - if sound != "" and _audio: - # Motion sounds are character sounds in ClientVS22 and use the optional - # 0.3s / 5000cm frequency-distance guard. - _audio.play_character_sound_3d(anchor.global_position.x, anchor.global_position.y, - anchor.global_position.z, sound, true) - if effect != "" and fx: - # EffectPosition 是骨骼本地锚点(cm);简版直接挂角色根 + y 偏移 - var gp := anchor.global_position + Vector3(pos.x, pos.y, pos.z) * 0.01 - fx.spawn_at(effect.get_file().get_basename(), _mount, gp, true) - match type: - 2: # SCREEN_WAVING - if cam and cam.has_method("shake"): - cam.shake(0.06, 9.0) - 3: # SCREEN_FLASHING - _screen_flash(0.12) - 6: # FLY —— `.msa` MOTION_EVENT_TYPE_FLY 帧:CActorInstance::ProcessMotionEventFly - # → m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill)(ActorInstanceMotionEvent.cpp:290)。 - # FIFO pop = AutoClear + 连射多箭在途各自一发。 - if client and client.has_method("shoot") and not _pending_shots.is_empty(): - client.shoot(_pending_shots.pop_front()) - 9: # WARP —— CActorInstance::ProcessMotionEventWarp - # 40250 在事件帧把角色放到目标前 270cm,并通过 OnWarp - # 发送一次 FUNC_WAIT;这不是网络 GC_WARP 的换图流程。 - _apply_motion_warp(anchor, _motion_effect_target.get_ref() if _motion_effect_target else null, true) - -func _apply_motion_warp(actor: Node3D, target: Node3D, notify_server: bool = false) -> bool: - # ActorInstanceMotionEvent.cpp 使用 pixel 单位的 270cm;当前世界单位是米。 - # 目标向量保留垂直分量用于落点,朝向则和 LookAt(x, y) 一样只取地面平面。 - if not is_instance_valid(actor) or not is_instance_valid(target) or actor == target: - return false - var actor_pos := actor.global_position - var target_pos := target.global_position - var to_target := target_pos - actor_pos - if to_target.length_squared() <= 0.000001: - return false - var direction := to_target.normalized() - var destination := target_pos - direction * 2.70 - var blocked := false - if world != null and world.has_method("is_blocked"): - blocked = bool(world.call("is_blocked", destination.x, destination.z)) - if not blocked: - actor.global_position = destination - var ground_direction := Vector3(to_target.x, 0.0, to_target.z) - if ground_direction.length_squared() > 0.000001: - actor.rotation.y = atan2(ground_direction.x, ground_direction.z) - if notify_server and net_play != null and net_play.has_method("_send_state"): - net_play.call("_send_state", NetPlay.FUNC_WAIT, 0, actor.global_position) - return not blocked - -var _motion_effect_target: WeakRef - -func _on_skill_cast_started(skill: int, target_vid: int) -> void: - if world and world.has_method("sample_height") and is_instance_valid(player): - player.position.y = float(world.call("sample_height", player.position.x, player.position.z)) - if pc and pc.has_method("stop"): - pc.stop() - _motion_effect_target = null - var target: Node3D = null - if client and target_vid == int(client.get_main_vid()): - target = player - elif net_world and target_vid != 0: - target = net_world.node_for(target_vid) - if is_instance_valid(target): - _motion_effect_target = weakref(target) - if target != player and is_instance_valid(player): - var to: Vector3 = target.global_position - player.global_position - to.y = 0.0 - if to.length() > 0.01: - player.rotation.y = atan2(to.x, to.z) - var pv_node: Node = player if is_instance_valid(player) and player.has_method("play_skill_motion") else (net_play.player_view if net_play and "player_view" in net_play else null) - var dur := 2.0 - var is_charge := false - var cancel_enable := false - var motion_driven := false - var motion_loop_count := 0 - var moving_skill := false - if skill_table: - if skill_table.has_method("is_charge_skill"): - is_charge = skill_table.is_charge_skill(skill) - if skill_table.has_method("is_moving_skill"): - moving_skill = bool(skill_table.call("is_moving_skill", skill)) - if skill_table.has_method("motion_loop_count_of"): - motion_loop_count = int(skill_table.call("motion_loop_count_of", skill, _skill_level(skill))) - if is_instance_valid(pv_node) and pv_node.has_method("play_skill_motion"): - var mname: String = skill_table.motion_name_of(skill) - if mname != "": - var played: bool = pv_node.play_skill_motion(mname, _skill_master(skill), - motion_loop_count, moving_skill) - motion_driven = played - if played and "anim" in pv_node and pv_node.anim: - if pv_node.anim.has_method("get_duration"): - var d: float = float(pv_node.anim.get_duration()) - if d > 0.0: - dur = d - if pv_node.anim.has_method("get_loop_data"): - var ld: Dictionary = pv_node.anim.get_loop_data() - if bool(ld.get("cancel_enable", false)): - cancel_enable = true - if net_play: - if net_play.has_method("start_skill_cast"): - net_play.start_skill_cast(dur, is_charge, cancel_enable, motion_driven) - else: - net_play._using_skill = true - -func _on_local_motion_event_detailed(event: Dictionary) -> void: - # Keep sound/projectile dispatch exactly once, independent of visual routing. - var event_type := int(event.get("type", -1)) - if event_type == 7 or event_type == 8: - # 40250 __ShowEvent / __HideEvent changes the actor render state at the - # exact motion frame; it is not an effect and has no payload of its own. - _on_local_motion_event(event_type, "", String(event.get("sound", "")), - event.get("pos", Vector3.ZERO)) - if is_instance_valid(player) and player.has_method("set_motion_hidden"): - player.set_motion_hidden(event_type == 8) - elif event_type == 1 and String(event.get("effect", "")) != "": - _on_local_motion_event(int(event.type), "", String(event.sound), event.pos) - if fx and is_instance_valid(player): - fx.spawn_motion(event, player, _mount) - elif event_type == 10: - _on_local_motion_event(int(event.type), "", String(event.sound), event.pos) - var target: Node3D = _motion_effect_target.get_ref() if _motion_effect_target else null - if fx and is_instance_valid(target): - fx.spawn_target(event, target, _mount) - elif event_type == 4: - # 40250 ProcessMotionEventSpecialAttacking only establishes the - # actor-local SplashArea here; AttackProcess performs collision and - # OnHit on subsequent frames. Keep sound dispatch separate and do not - # turn this event into a synthetic ordinary attack packet. - _on_local_motion_event(int(event.type), "", String(event.get("sound", "")), - event.get("pos", Vector3.ZERO)) - if net_play and net_play.has_method("start_motion_splash"): - net_play.start_motion_splash(event) - elif event_type == 6: - # 40250 ProcessMotionEventFly creates the local .msf projectile at the - # motion frame, while its handler's OnShoot sends CG_SHOOT separately. - _on_local_motion_event(int(event.type), "", String(event.get("sound", "")), - event.get("pos", Vector3.ZERO)) - var target: Node3D = _motion_effect_target.get_ref() if _motion_effect_target else null - if not is_instance_valid(target) and net_play and net_world: - var target_vid := int(net_play.get("_target_vid")) - var local_vid := -1 - if client and client.has_method("get_main_vid"): - local_vid = int(client.get_main_vid()) - if target_vid == local_vid: - target = player - elif target_vid != 0 and net_world.has_method("node_for"): - target = net_world.node_for(target_vid) - if net_world and net_world.has_method("spawn_motion_fly") and is_instance_valid(target): - var skill_index := int(_pending_shots[0]) if not _pending_shots.is_empty() else 0 - net_world.spawn_motion_fly(player, event, target, skill_index) - elif event_type == 9: - _on_local_motion_event(int(event.type), "", String(event.get("sound", "")), - event.get("pos", Vector3.ZERO)) - else: - _on_local_motion_event(int(event.type), String(event.effect), String(event.sound), event.pos) - -func _on_remote_motion_event(vid: int, event: Dictionary) -> void: - # Remote actors use the same MotionEvent payload, but their visual side - # effects must be rooted at that actor. Never call client.shoot here: in - # 40250 remote projectile creation is server-authoritative GC_CREATE_FLY. - var actor: Node3D = net_world.node_for(vid) if net_world and net_world.has_method("node_for") else null - if not is_instance_valid(actor): - return - var event_type := int(event.get("type", -1)) - if event_type == 4: - # ActorInstanceMotionEvent creates SplashArea for every visible actor; - # remote hits are presentation-only here and must not send CG_ATTACK. - if net_play and net_play.has_method("start_remote_motion_splash"): - net_play.start_remote_motion_splash(vid, event) - elif event_type == 1 and fx and String(event.get("effect", "")) != "": - fx.spawn_motion(event, actor, _mount) - elif event_type == 10 and fx: - var victim_vid := int(actor.get_meta("motion_victim_vid", 0)) - var target: Node3D = net_world.node_for(victim_vid) if victim_vid != 0 else null - if is_instance_valid(target): - fx.spawn_target(event, target, _mount) - elif event_type == 7 or event_type == 8: - if actor.has_method("set_motion_hidden"): - actor.set_motion_hidden(event_type == 8) - elif event_type == 9: - var victim_vid := int(actor.get_meta("motion_victim_vid", 0)) - var target: Node3D = net_world.node_for(victim_vid) if victim_vid != 0 else null - _apply_motion_warp(actor, target, false) - var sound := String(event.get("sound", "")) - if sound != "" and _audio and _audio.has_method("play_character_sound_3d"): - _audio.play_character_sound_3d(actor.global_position.x, actor.global_position.y, - actor.global_position.z, sound, true) - -func _set_player_affect(type: int, visible: bool) -> void: - if player and player.has_method("set_affect"): - player.set_affect(type, visible, fx) - -# 一次弓挥击 / 弓技能起手 → 压一个待发 uSkill(对齐 AutoClear handler 的 Set())。 -# 满了丢最旧的:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时不至无界增长。§3.6 -func _queue_shot(skill: int) -> void: - _pending_shots.append(skill) - while _pending_shots.size() > MAX_PENDING_SHOTS: - _pending_shots.pop_front() - -var _flash_rect: ColorRect -var _quest_fade_rect: ColorRect -var _quest_fade_tween: Tween - -func _on_quest_camera_event(kind: String, setting: Dictionary, blendtime: float) -> void: - if cam == null or not is_instance_valid(cam): - return - match kind: - "SET_CAMERA": - if quest_curtain: - quest_curtain.open() - if cam.has_method("set_event_camera"): - cam.set_event_camera(setting) - "BLEND_CAMERA": - if quest_curtain: - quest_curtain.open(maxf(0.2, blendtime)) - if cam.has_method("blend_event_camera"): - cam.blend_event_camera(setting, blendtime) - "RESTORE_CAMERA": - if quest_curtain: - quest_curtain.close() - if cam.has_method("set_default_camera"): - cam.set_default_camera() - -func _on_quest_fade_event(kind: String, speed: float) -> void: - if ui == null: - if quest_dialog and quest_dialog.has_method("end_event_process"): - quest_dialog.end_event_process() - return - if _quest_fade_rect == null or not is_instance_valid(_quest_fade_rect): - _quest_fade_rect = ColorRect.new() - _quest_fade_rect.name = "QuestFade" - _quest_fade_rect.set_anchors_preset(Control.PRESET_FULL_RECT) - _quest_fade_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE - _quest_fade_rect.z_index = 100 - ui.add_child(_quest_fade_rect) - var white := kind.begins_with("WHITE_") - _quest_fade_rect.color = Color(1, 1, 1, _quest_fade_rect.color.a) if white \ - else Color(0, 0, 0, _quest_fade_rect.color.a) - var fade_in := kind.ends_with("_IN") - var target_alpha := 0.0 if fade_in else 1.0 - _quest_fade_rect.visible = target_alpha > 0.0 or _quest_fade_rect.color.a > 0.001 - if _quest_fade_tween and _quest_fade_tween.is_valid(): - _quest_fade_tween.kill() - # uiquest.py advances alpha by speed once per frame. At 60 FPS this is - # equivalent to 1 / (speed * 60) seconds; malformed zero speeds use the - # reference default to avoid permanently blocking the event set. - var frame_speed := maxf(absf(speed), 0.035) - var duration := absf(target_alpha - _quest_fade_rect.color.a) / (frame_speed * 60.0) - _quest_fade_tween = create_tween() - _quest_fade_tween.tween_property(_quest_fade_rect, "color:a", target_alpha, duration) - _quest_fade_tween.tween_callback(func(): - _quest_fade_rect.visible = target_alpha > 0.0 - if quest_dialog and quest_dialog.has_method("end_event_process"): - quest_dialog.end_event_process()) - -func _on_dungeon_result(result: Dictionary) -> void: - if dungeon_result_ui and dungeon_result_ui.has_method("show_result"): - dungeon_result_ui.show_result(result) - -func _clear_quest_presentation() -> void: - if _quest_fade_tween and _quest_fade_tween.is_valid(): - _quest_fade_tween.kill() - _quest_fade_tween = null - if _quest_fade_rect and is_instance_valid(_quest_fade_rect): - _quest_fade_rect.color.a = 0.0 - _quest_fade_rect.visible = false - if quest_curtain: - quest_curtain.close(0.2) - if cam and cam.has_method("set_default_camera"): - cam.set_default_camera() - -func _screen_flash(secs: float) -> void: - if hud == null: - return - if _flash_rect == null or not is_instance_valid(_flash_rect): - _flash_rect = ColorRect.new() - _flash_rect.color = Color(1, 1, 1, 0) - _flash_rect.set_anchors_preset(Control.PRESET_FULL_RECT) - _flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE - ui.add_child(_flash_rect) - _flash_rect.color.a = 0.5 - var tw := create_tween() - tw.tween_property(_flash_rect, "color:a", 0.0, secs) - -# skill id -> master_type(0 普通 / 1 M / 2 G / 3 P),给 skill_fx 选特效档。 -func _skill_master(sid: int) -> int: - if client and client.has_method("get_skills"): - for s in client.get_skills(): - if int(s.get("id", -1)) == sid: - return int(s.get("master", 0)) - return 0 - -func _skill_level(sid: int) -> int: - if client and client.has_method("get_skills"): - for s in client.get_skills(): - if int(s.get("id", -1)) == sid: - return int(s.get("level", 0)) - return 0 - -# GC_WARP —— 40250 的参考端无论是否“同服”都会重连到 lAddr:wPort; -# world_reset 先清旧实体,随后这里重载地图并把玩家放到新坐标。 -func _on_warp(pos: Vector3, same_server: bool) -> void: - if not same_server: - _reload_map_for_warp(pos) - return - _place_player_at_net_pos(pos) - -func _on_weather_changed(enabled: bool) -> void: - # 40250 game.py receives xmas_snow through ServerCommandParser and applies - # the map exclusion in __IsXMasMap before calling background.EnableSnow. - _xmas_snow_enabled = enabled - _apply_server_weather() - -func _apply_server_weather() -> void: - if weather == null or not is_instance_valid(weather): - return - var map_name := map_path.get_file().to_lower() - var allowed := not XMAS_SNOW_EXCLUDED_MAPS.has(map_name) - weather.set_weather("snow" if _xmas_snow_enabled and allowed else "none") - -func _on_world_reset() -> void: - _motion_effect_target = null - # CNormalBowAttack_FlyEventHandler_AutoClear 是旧 Actor 的生命周期对象。 - # Loading/换图时它不会把旧挥击的 FLY 帧带到新地图;当前端的等价物是 - # 清掉尚未消费的 CG_SHOOT 技能队列,避免新动作误发旧箭。 - _pending_shots.clear() - if net_play and net_play.has_method("clear_for_map_change"): - net_play.clear_for_map_change() - _main_generation += 1 - _main_view_key = "" - _main_attempts = 0 - _main_retry_at = 0 - _main_placed_vid = 0 - _main_map_vid = 0 - _model_built = false - _main_model_attempted = false - if net_world and net_world.has_method("clear_for_map_change"): - net_world.clear_for_map_change() - if ground_items and ground_items.has_method("clear_for_map_change"): - ground_items.clear_for_map_change() - # The reference loading phase closes transient interaction state. Keep - # persistent account data (inventory, quests, friends) intact. - for widget in [shop_ui, exchange_ui, safebox_ui, mall_ui, cube_ui, - private_shop_ui, select_item_ui, quest_dialog, quest_log, party_ui, - friend_ui, refine_ui, dragon_soul_ui]: - if widget == null or not is_instance_valid(widget): - continue - if widget.has_method("close"): - widget.call("close") - elif widget.has_method("_close"): - widget.call("_close") - if atlas_ui and atlas_ui.has_method("close"): - atlas_ui.close() - if dungeon_result_ui and dungeon_result_ui.has_method("close"): - dungeon_result_ui.close() - _clear_quest_presentation() - if ui and ui.has_method("close_top"): - while ui.close_top(): - pass - if _audio and _audio.has_method("stop_bgm"): - _audio.stop_bgm(0.15) - -func _reload_map_for_warp(pos: Vector3) -> void: - var server_pos := Vector2(pos.x * 100.0, -pos.z * 100.0) - var next_map := _resolve_map_path(map_path, server_pos) - var old_world := world - # ClientVS22 unloads/clears the current CMapManager before LoadMap creates - # the replacement. queue_free alone leaves terrain/object roots and native - # map caches alive until a later frame, allowing old/new maps to overlap. - if old_world and is_instance_valid(old_world) and old_world.has_method("unload_map"): - old_world.call("unload_map") - if old_world and is_instance_valid(old_world) and old_world != world: - old_world.queue_free() - map_path = next_map - _build_world(_assets, map_path) - _apply_server_weather() - if cam: - cam.world = world - if pc: - pc.world = world - if net_world: - net_world.world = world - if hud and hud.has_method("reload_world") and _map_loaded(): - hud.reload_world(world) - if atlas_ui and atlas_ui.has_method("reload_map"): - atlas_ui.reload_map(world, map_path.get_file()) - if world_time and world_time.has_method("set_lighting"): - world_time.set_lighting(_sun if not _env_from_msenv else null, - _env if not _env_from_msenv else null) - _place_player_at_net_pos(pos) - -func _place_player_at_net_pos(pos: Vector3) -> void: - if player: - var wp := MapCoord.to_world(pos) - if world and world.has_method("set_focus_position"): - world.call("set_focus_position", wp.x, wp.z) - if world and world.has_method("sample_height"): - wp.y = float(world.call("sample_height", wp.x, wp.z)) - if player.is_inside_tree(): - player.global_position = wp - else: - player.position = wp - if pc and pc.has_method("stop"): - pc.stop() - if net_play: - if "_last_sent_pos" in net_play: - net_play._last_sent_pos = player.global_position if player.is_inside_tree() else player.position - if net_play.has_method("_clear_reserved"): - net_play._clear_reserved() - if net_play.has_method("_clear_auto_attack"): - net_play._clear_auto_attack() - if cam and cam.has_method("snap_to_target") and cam.is_inside_tree() and player.is_inside_tree(): - cam.snap_to_target() - -# 背包道具右键:按当前打开的窗决定动作(商店卖 / 交易放 / 仓库存)。 -# 返回 true = 已消费,inventory_ui 不再 use_item。 -func _on_inv_context(window: int, cell: int) -> bool: - if shop_ui and shop_ui.is_open(): - shop_ui.sell(cell) - return true - if exchange_ui and exchange_ui.is_open(): - exchange_ui.offer(window, cell) - return true - if safebox_ui and safebox_ui.is_open(): - safebox_ui.deposit(window, cell) - return true - if ENABLE_DRAGON_SOUL and dragon_soul_ui and dragon_soul_ui.is_open(): - dragon_soul_ui.add_cell(window, cell) - return true - return false - -func _unhandled_input(e: InputEvent) -> void: - if not (e is InputEventKey) or e.echo: - return - var key: Key = e.keycode - if ui and ui.has_method("blocks_game_input") and ui.blocks_game_input(e): - return - # The reference keeps a different callback for LSHIFT press/release. The - # flag is also useful on macOS where Command is the modifier for emotes. - if key == KEY_SHIFT: - _quick_page_mode = e.pressed - return - # game.py SetPCTargetBoard :807 —— app.IsPressed(app.DIK_LCONTROL)。参考端在点 PC 的 - # 那一刻轮询 LCONTROL 按住态;poc 把按下 / 松开喂给 net_play,点 PC 时再查(seam ⑫)。 - if key == KEY_CTRL: - if net_play and net_play.has_method("set_lcontrol_down"): - net_play.set_lcontrol_down(e.pressed) - return - if not e.pressed: - match key: - KEY_SPACE: - if net_play and net_play.has_method("set_attack_key"): - net_play.set_attack_key(false) - KEY_ALT: - _set_names(false) - KEY_Q, KEY_E: - if cam and cam.has_method("set_key_orbit"): - cam.set_key_orbit(0) - KEY_R, KEY_F: - if cam and cam.has_method("set_key_zoom"): - cam.set_key_zoom(0) - KEY_T, KEY_G: - if cam and cam.has_method("set_key_pitch"): - cam.set_key_pitch(0) - return - - var command: bool = e.ctrl_pressed or e.meta_pressed - match key: - KEY_ENTER, KEY_KP_ENTER: - if chat and not chat.is_typing(): - chat.focus_input() - get_viewport().set_input_as_handled() - KEY_ALT: - _set_names(true) - KEY_PRINT: - _save_screen() - KEY_SPACE: - if net_play and net_play.has_method("set_attack_key"): - net_play.set_attack_key(true) - KEY_I: - if inventory: inventory.toggle() - KEY_K: - if skills: - skills.set_job(_job_name()) - skills.toggle() - KEY_V: - if char_status_ui: char_status_ui.open("SKILL") - KEY_C: - if not command and char_status_ui: char_status_ui.open("STATUS") - KEY_B: - if command: - _send_command("/user_horse_back") - elif char_status_ui: - char_status_ui.open("EMOTICON") - KEY_H: - if command: - _send_command("/user_horse_ride") - elif system_menu_ui: - # game.py __PressHKey: normal H opens the help window. - system_menu_ui.toggle_help() - KEY_J: - _toggle_horse() - KEY_N: - if not command and quest_log: quest_log.toggle() - KEY_F: - if command: - _send_command("/user_horse_feed") - elif cam and cam.has_method("set_key_zoom"): - cam.set_key_zoom(1) - KEY_G: - if command: - _send_command("/ride") - elif _show_names and guild_ui: - guild_ui.toggle() - elif cam and cam.has_method("set_key_pitch"): - cam.set_key_pitch(1) - KEY_Q: - if command: - if quest_log and quest_log.has_method("toggle_buttons"): quest_log.toggle_buttons() - elif cam and cam.has_method("set_key_orbit"): - cam.set_key_orbit(-1) - KEY_E: - if not command and cam and cam.has_method("set_key_orbit"): - cam.set_key_orbit(1) - KEY_R: - if not command and cam and cam.has_method("set_key_zoom"): - cam.set_key_zoom(-1) - KEY_T: - if not command and cam and cam.has_method("set_key_pitch"): - cam.set_key_pitch(-1) - KEY_L: - if not command and chat and chat.has_method("toggle_log"): chat.toggle_log() - KEY_M: - if not command and atlas_ui: atlas_ui.toggle() - KEY_KP_ADD: - if minimap and minimap.has_method("get_scale"): - minimap.set_scale(float(minimap.get_scale()) * 1.15) - KEY_KP_SUBTRACT: - if minimap and minimap.has_method("get_scale"): - minimap.set_scale(float(minimap.get_scale()) / 1.15) - KEY_Z: - if not command and ground_items: ground_items.try_pickup() - KEY_O: - if not command and ENABLE_DRAGON_SOUL and dragon_soul_ui: dragon_soul_ui.toggle() - KEY_ESCAPE: - if system_menu_ui: - # ui_manager consumes topmost windows before this reaches ESC. - system_menu_ui.toggle() - _: - if int(key) == 96 or int(e.physical_keycode) == 96: - if not command and ground_items: ground_items.try_pickup() - elif key >= KEY_F1 and key <= KEY_F4 and quickbar: - # game.py __PressQuickSlot(4..7), independent of page selection. - quickbar.activate(4 + (key - KEY_F1)) - elif key >= KEY_1 and key <= KEY_9: - var n: int = key - KEY_1 - if command: - _emote(n) - elif _quick_page_mode and n < 4 and quickbar: - quickbar.set_page(n) - elif n < 8 and quickbar: - quickbar.activate(n) - -func _set_names(visible: bool) -> void: - _show_names = visible - if net_world and net_world.has_method("set_names_visible"): - net_world.set_names_visible(visible) - -# GameOptionUI 显示开关 radio 选中(uigameoption RefreshAlwaysShowName 系)。 -func _on_display_option(key: String, value: int) -> void: - if key == "always_show_name" and net_world and net_world.has_method("set_always_show_name"): - net_world.set_always_show_name(value == 1) - elif key == "show_damage" and net_world and net_world.has_method("set_show_damage"): - net_world.set_show_damage(value == 1) - elif key == "name_color" and net_world and net_world.has_method("set_empire_name_mode"): - net_world.set_empire_name_mode(value == 1) - elif key == "target_board" and net_play and net_play.has_method("set_view_other_empire_target"): - net_play.set_view_other_empire_target(value == 1) - elif key == "view_chat" and chat: - if chat.has_method("set_view_chat"): - chat.set_view_chat(value == 1) - elif "visible" in chat: - chat.visible = (value == 1) - -func _send_command(text: String) -> void: - if client and client.has_method("say"): - client.say(0, text) - -func _on_safebox_button_clicked() -> void: - if safebox_ui and safebox_ui.has_method("is_open") and safebox_ui.is_open(): - safebox_ui.close() - elif safebox_ui and safebox_ui.has_method("open"): - safebox_ui.open() - # 对齐 40250 interfacemodule.py:OpenSafeboxWindow —— 打开仓库同时打开背包, - # 让玩家能看到自己物品并右键存入仓库。 - if inventory and inventory.has_method("open"): - inventory.open() - elif mall_ui and mall_ui.has_method("is_open") and mall_ui.is_open(): - mall_ui.close() - else: - _send_command("/click_mall") - -func _toggle_horse() -> void: - if client == null: - return - var main: Dictionary = client.get_entity(client.get_main_vid()) if client.has_method("get_entity") else {} - if int(main.get("mount_vnum", 0)) != 0: - _send_command("/unmount") - return - if private_shop_ui and private_shop_ui.has_method("is_open") and private_shop_ui.is_open(): - return - if not client.has_method("get_inventory") or not client.has_method("use_item"): - return - for item in client.get_inventory(): - if int(item.get("vnum", 0)) in [71114, 71116, 71118, 71120]: - client.use_item(1, int(item.get("cell", 0))) - return - -func _save_screen() -> void: - var texture := get_viewport().get_texture() - if texture == null: - return - var dir := OS.get_user_data_dir().path_join("screenshots") - DirAccess.make_dir_recursive_absolute(dir) - texture.get_image().save_png(dir.path_join("screen_%d.png" % Time.get_ticks_msec())) - -# 表情快捷键(§8.6 / §4.8)—— 参考端 Ctrl+1..9 = chrmgr.SetEmoticon(-1, n) + net.SendEmoticon(n)。 -# W3 在 §8.6 里补全:本地立即播表情动作 + 发 classic 表情包。这里先留统一入口。 -func _emote(index: int) -> void: - if client and client.has_method("send_emoticon"): - client.send_emoticon(index) - -func _play_emoticon(index: int) -> void: - # playersettingmodule.py registers these in this exact order (0..11). - const EFFECTS := ["sweat", "money", "happy", "like", "love_s", "angry", - "aha", "gloom", "sorry", "!_mix_back", "question", "fish"] - if fx and player and index >= 0 and index < EFFECTS.size(): - fx.spawn(EFFECTS[index], player, true) - -func _job_name() -> String: - var r := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3 - return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][r] - -func _fx_host(vid: int) -> Node3D: - if vid == client.get_main_vid(): - return player - return net_world.node_for(vid) if net_world else null - -func _player_bubble(text: String) -> void: - if player == null: - return - var old := player.get_node_or_null("bubble") - if old: - old.queue_free() - var lbl := Label3D.new() - lbl.name = "bubble" - lbl.text = text - lbl.position.y = 2.35 - lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED - lbl.no_depth_test = true - lbl.pixel_size = 0.0055 - lbl.outline_size = 6 - player.add_child(lbl) - # §8.8:本地玩家气泡寿命对齐 CPythonTextTail gs_TextTail_LivingTime(默认 5s) - var tw := create_tween() - tw.tween_interval(4.0) - tw.tween_property(lbl, "modulate:a", 0.0, 1.0) - tw.tween_callback(lbl.queue_free) - -# --- 允许 P2 之后注入真模型 -------------------------------------------------- - -# factory: func(entity_dict) -> Node3D(有 set_anim_state(String) 就被驱动) -func set_entity_model_factory(factory: Callable) -> void: - if net_world: - net_world.set_model_factory(factory) - -# Borrowed-node adapter for the packaged playable test. It is only exposed -# from an explicit test mode; the returned nodes must never be serialized or -# retained across _on_world_reset / AppFlow reconnect. -func get_playable_context() -> Dictionary: - var test_mode := OS.get_environment("MT_TEST_MODE") - if test_mode != "playable" and test_mode != "forest_render": - return {} - return {"scene_ready": _model_built and _map_loaded(), "map_path": map_path, - "net_play": net_play, "net_world": net_world, "world": world, - "player": player, "pc": pc, "hud": hud, "quickbar": quickbar, - "ground_items": ground_items, "skill_fx": skill_fx, "fx": fx} - -var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试) -var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性) - -# 默认工厂:远端 PC -> RemotePlayerView;怪 / NPC -> MobView。失败返回 null 用占位胶囊。 -func _make_entity_model(d: Dictionary) -> Node3D: - if bool(d.get("is_main", false)) or _assets == "": - return null - var ch_type := int(d.get("ch_type", -1)) - var race := int(d.get("race", 0)) - var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable() - if ch_type == 0: - if race < 0 or _remote_player_view_cache.get(race, true) == false: - return null - var pv := RemotePlayerView.new() - var parts: Array = d.get("parts", []) if d.get("parts", []) is Array else [] - if pv.build_remote(_assets, race, parts, item_list, proto, pump, fx): - pv.set_audio(_audio) - _remote_player_view_cache[race] = true - return pv - _remote_player_view_cache[race] = false - pv.free() - return null - if proto == null: - return null - if race < 1 or _mob_view_cache.get(race, true) == false: - return null - var mv := MobView.new() - if mv.build(_assets, proto, race, pump): - mv.set_audio(_audio) - _mob_view_cache[race] = true - return mv - _mob_view_cache[race] = false - mv.free() - return null - -func set_player_model(node: Node3D) -> void: - if node == null or player == null: - return - var old := player - node.position = old.position - node.rotation = old.rotation - node.name = "LocalPlayer" - add_child(node) - player = node - cam.target = node - pc.player = node - if pc and pc.has_method("stop"): - pc.stop() - if net_world and net_world.has_method("set_local_node"): - net_world.set_local_node(node) - old.queue_free() - -# --- internals ------------------------------------------------------------- - -# setup() 协程用:让出一帧(在树里才 await,测试里直接返回)。 -func _yield() -> void: - if is_inside_tree() and get_tree() != null: - await get_tree().process_frame - -func _build_world(assets_root: String, map_path: String) -> void: - if not ClassDB.class_exists("Metin2World"): - push_warning("GameScene: Metin2World 未注册(扩展没编?)") - return - _env_from_msenv = false - world = ClassDB.instantiate("Metin2World") - world.set("auto_load", false) # 别让 _ready() 自动加载一遍,下面显式调一次拿返回值 - world.set("assets_root", assets_root) - world.set("map_path", map_path) - add_child(world) - var ok: bool = world.call("load_map") - if not ok: - push_warning("GameScene: load_map 失败:" + map_path + "(在平地上继续)") - # 网络实体坐标要对齐地图本地帧(否则角色飘在几公里外的黑 void) - if world.has_method("get_map_base_cm"): - MapCoord.set_base(world.get_map_base_cm()) - # Metin2World 会从 .msenv 建自己的 Sun / WorldEnv(真实关照参数)。有就用它, - # 丢掉 _build_lighting() 的兜底 —— 两套光 + 两个 WorldEnvironment 会互相打架、 - # 也让 world_time 每帧把 .msenv 的色调冲掉。 - var msenv_sun := world.get_node_or_null("Sun") as DirectionalLight3D - var msenv_we := world.get_node_or_null("WorldEnv") - if msenv_sun != null and msenv_we != null and msenv_we.environment != null: - if is_instance_valid(_sun): - _sun.queue_free() - for c in get_children(): - if c is WorldEnvironment: # 兜底那个(.msenv 的挂在 world 下,不是这里的直接子) - c.queue_free() - _sun = msenv_sun - _env = msenv_we.environment - _env_from_msenv = true - elif _sun == null or not is_instance_valid(_sun) or _sun.get_parent() != self: - # A map without .msenv still needs the fallback lighting. This branch is - # mainly for a cross-map reload from an outdoor .msenv map. - _build_lighting() - # .msenv 换过 Environment 后,把持久化的雾等设置重新贴上去 - if system_option_ui and system_option_ui.has_method("_apply_all"): - system_option_ui._apply_all() - -func _map_loaded() -> bool: - if world == null: - return false - var rep: Dictionary = world.call("get_load_report") - return int(rep.get("map_size_x", 0)) > 0 - -func _resolve_map_path(requested: String, forced_pos: Variant = null) -> String: - var pos: Variant = forced_pos - if pos == null: - if client == null or _assets == "" or not client.has_method("get_main_vid"): - return requested - var vid := int(client.get_main_vid()) - if vid == 0 or not client.has_method("get_entity"): - return requested - var entity: Dictionary = client.get_entity(vid) - pos = entity.get("pos_cm", null) - if not (pos is Vector3): - if not (pos is Vector2): - return requested - var map_pos := Vector2(pos.x, pos.y) - - var settings: Array[String] = [] - _collect_map_settings(_assets, settings) - var best := requested - var best_score := 1000000 - for setting_path: String in settings: - var bounds := _map_bounds(setting_path) - if bounds.is_empty(): - continue - var base: Vector2 = bounds["base"] - var size: Vector2i = bounds["size"] - var max_x := base.x + float(size.x) * 25600.0 - var max_y := base.y + float(size.y) * 25600.0 - if map_pos.x < base.x or map_pos.x >= max_x or map_pos.y < base.y or map_pos.y >= max_y: - continue - var root := _assets.trim_suffix("/") - var rel_setting := setting_path.substr(root.length() + 1) - var candidate := rel_setting.get_base_dir() - var lower := candidate.to_lower() - # Prefer the normal outdoor map over duplicate patch/season copies when - # several map packages advertise the same world-space rectangle. - var score := candidate.split("/").size() - if lower.begins_with("outdoor"): - score -= 20 - if lower.begins_with("season") or lower.begins_with("metin2_patch"): - score += 20 - if candidate == requested: - score -= 1000 - if score < best_score: - best_score = score - best = candidate - - if best != requested: - print("[GameScene] map resolved by server position ", map_pos, " -> ", best) - return best - -func _collect_map_settings(dir_path: String, out: Array[String]) -> void: - var dir := DirAccess.open(dir_path) - if dir == null: - return - for file_name: String in dir.get_files(): - if file_name.to_lower() == "setting.txt": - out.append(dir_path.path_join(file_name)) - for child: String in dir.get_directories(): - if child in [".godot", ".git", "build", "export"]: - continue - _collect_map_settings(dir_path.path_join(child), out) - -func _map_bounds(setting_path: String) -> Dictionary: - var f := FileAccess.open(setting_path, FileAccess.READ) - if f == null: - return {} - var base := Vector2.ZERO - var size := Vector2i.ZERO - var have_base := false - var have_size := false - while not f.eof_reached(): - var fields := f.get_line().replace("\t", " ").strip_edges().split(" ", false) - if fields.size() < 3: - continue - match String(fields[0]).to_lower(): - "baseposition": - base = Vector2(float(fields[1]), float(fields[2])) - have_base = true - "mapsize": - size = Vector2i(int(fields[1]), int(fields[2])) - have_size = size.x > 0 and size.y > 0 - if not have_base or not have_size: - return {} - return {"base": base, "size": size} - -func _build_lighting() -> void: - var sun := DirectionalLight3D.new() - sun.name = "FallbackSun" - sun.rotation = Vector3(deg_to_rad(-50), deg_to_rad(40), 0) - sun.light_energy = 1.1 - sun.shadow_enabled = true - add_child(sun) - _sun = sun - var we := WorldEnvironment.new() - var e := Environment.new() - e.background_mode = Environment.BG_COLOR - e.background_color = Color(0.55, 0.62, 0.72) - e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - e.ambient_light_color = Color(0.5, 0.55, 0.62) - e.ambient_light_energy = 0.42 - e.tonemap_mode = Environment.TONE_MAPPER_LINEAR - e.tonemap_exposure = 0.82 - e.adjustment_enabled = true - e.adjustment_saturation = 1.05 - e.adjustment_contrast = 1.08 - e.adjustment_brightness = 0.88 - we.environment = e - add_child(we) - _env = e - -func _on_main_set(vid: int) -> void: - if not _main_sync_ready or _main_loading or vid <= 0 or client == null: - return - if vid != int(client.get_main_vid()): - return # A queued event may belong to the previous character. - # GC_MAIN_CHARACTER can arrive after GameScene has subscribed. Mark this VID - # as local before building its view so NetWorld removes (and never recreates) - # the generic remote-player capsule for our own character. - if net_world and net_world.has_method("set_local_vid"): - net_world.set_local_vid(vid) - if net_world and net_world.has_method("set_local_node") and player: - net_world.set_local_node(player) - var e: Dictionary = client.get_entity(vid) - if e.is_empty() or player == null: - return - var net_pos: Variant = e.get("pos", null) - if not net_pos is Vector3 or not net_pos.is_finite(): - return - # setup can only choose a map from data already received. Resolve again - # once the late authoritative position is available, before placing/viewing. - if _main_map_vid != vid: - _main_map_vid = vid - if _assets != "": - var next_map := _resolve_map_path(map_path, Vector2(net_pos.x * 100.0, -net_pos.z * 100.0)) - if next_map != map_path: - _main_loading = true - _reload_map_for_warp(net_pos) - _main_loading = false - var key := "%d:%d:%s" % [vid, int(e.get("race", -1)), _assets] - if key != _main_view_key: - _main_view_key = key - _main_attempts = 0 - _main_retry_at = 0 - _main_model_attempted = false - _model_built = false - # 首次拿到 race -> 换成真 Metin2Model - if not _model_built and _assets != "" and int(e.get("race", -1)) in range(8) \ - and _main_attempts < MAIN_MODEL_MAX_ATTEMPTS and Time.get_ticks_msec() >= _main_retry_at: - _main_model_attempted = true - _main_attempts += 1 - _main_loading = true - var generation := _main_generation - var pv = _create_main_view() - var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable() - var built: bool = pv.build(_assets, int(e.get("race", 0)), pump) - _main_loading = false - # build() polls the network. World reset / new main data may arrive - # inside it; do not publish a view belonging to an obsolete generation. - if generation != _main_generation or is_queued_for_deletion() \ - or vid != int(client.get_main_vid()) \ - or int(client.get_entity(vid).get("race", -1)) != int(e.get("race", -1)): - pv.free() - return - if built: - pv.set_audio(_audio) - pv.set_fx(fx) - set_player_model(pv) - _model_built = true - if client and client.has_method("get_affects"): - for a in client.get_affects(): - pv.set_affect(int(a.get("type", 0)), true, fx) - if net_play: - net_play.player_view = pv - if pv.anim and pv.anim.has_signal("motion_event_detailed"): - pv.anim.motion_event_detailed.connect(_on_local_motion_event_detailed) - elif pv.anim and pv.anim.has_signal("motion_event"): - pv.anim.motion_event.connect(_on_local_motion_event) - if pc and pc.has_signal("anim_state") and not pc.anim_state.is_connected(_on_main_anim_state): - pc.anim_state.connect(_on_main_anim_state) - if equip_model: - equip_model.set_race(int(e.get("race", 0))) - equip_model.refresh() - else: - _main_retry_at = Time.get_ticks_msec() + MAIN_MODEL_RETRY_MS - push_warning("GameScene: 主角模型加载失败(vid=%d race=%d attempt=%d/%d)" % [vid, int(e.get("race", 0)), _main_attempts, MAIN_MODEL_MAX_ATTEMPTS]) - pv.free() - # Polling for model readiness must not snap a moving player to the initial - # server position on every frame or on duplicate main-character events. - if _main_placed_vid == vid: - return - _place_player_at_net_pos(net_pos) - _main_placed_vid = vid - if pc: - pc.active = _setup_completed - if pc.has_method("stop"): - pc.stop() - -func _create_main_view() -> Node3D: - return PlayerView.new() - -func _on_main_anim_state(state: String) -> void: - if is_instance_valid(player) and player.has_method("set_anim_state"): - player.set_anim_state(state) - -func _sync_main_character() -> void: - if not _main_sync_ready or _main_loading or player == null or not is_instance_valid(player): - return - if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): - return - var vid := int(client.get_main_vid()) - if vid == 0: - return - var e: Dictionary = client.get_entity(vid) - # Do not mark the attempt until the authoritative character row exists. The - # phase packet can precede the row by several frames on a busy connection. - if e.is_empty(): - return - _on_main_set(vid) - -# AppFlow 的唯一生命周期协调者在 GameScene 装配完成后调用,绑定当前场景的音频对象。 -# GameScene 不创建 AppLifecycle,避免切屏时重复监听暂停 / 恢复通知。 -func lifecycle_audio() -> Node: - return _audio - -func _exit_tree() -> void: - # CPythonApplication::Destroy explicitly releases map-owned resources. The - # native world is a child Node, so make teardown deterministic when this - # scene is freed directly during reconnect or a headless regression. - if _audio and is_instance_valid(_audio) and _audio.has_method("shutdown"): - _audio.call("shutdown") - if world and is_instance_valid(world) and world.has_method("unload_map"): - world.call("unload_map") - -func _make_placeholder_player() -> Node3D: - var root := Node3D.new() - root.name = "LocalPlayer" - return root - -func _on_world_item_drop(payload: Dictionary, screen_pos: Vector2) -> bool: - if pc == null or pc.camera == null or client == null: - return false - var cam: Camera3D = pc.camera - var from := cam.project_ray_origin(screen_pos) - var dir := cam.project_ray_normal(screen_pos) - var best: Node3D = null - var best_t := 1e20 - for n in pc.pickables: - if not is_instance_valid(n) or bool(n.get_meta("dead", false)): - continue - var t := float(pc.call("_ray_pick_t", from, dir, n)) if pc.has_method("_ray_pick_t") else -1.0 - if t >= 0.0 and t < best_t: - best_t = t - best = n - if best != null: - var target_vid := int(best.get_meta("vid", 0)) - if target_vid > 0: - var win := int(payload.get("window", 1)) - var cell := int(payload.get("cell", 0)) - var count := int(payload.get("count", 1)) - if client.has_method("give_item"): - return bool(client.give_item(target_vid, win, cell, count)) - return false diff --git a/project/game_scene.gd.uid b/project/game_scene.gd.uid deleted file mode 100644 index c4c0a423..00000000 --- a/project/game_scene.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://blsfh7xqpjfp5 diff --git a/project/gamescene_test.gd b/project/gamescene_test.gd deleted file mode 100644 index 44171f2a..00000000 --- a/project/gamescene_test.gd +++ /dev/null @@ -1,178 +0,0 @@ -# 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_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") - 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") diff --git a/project/gamescene_test.gd.uid b/project/gamescene_test.gd.uid deleted file mode 100644 index a3055448..00000000 --- a/project/gamescene_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dgk3ohpwqq1h3 diff --git a/project/gamescene_visual_test.gd b/project/gamescene_visual_test.gd deleted file mode 100644 index 3f9c3d37..00000000 --- a/project/gamescene_visual_test.gd +++ /dev/null @@ -1,87 +0,0 @@ -# Real GameScene/assets, delayed authoritative row, no network or account writes. -extends SceneTree - -const GameScene = preload("res://game_scene.gd") -const Fixtures = preload("res://gamescene_test.gd") -var failed := false - -# wait.gr2 @0.5s keeps the warrior planted; measured lowest vertex is +1.5 mm. -# 3 cm leaves room for terrain interpolation without hiding a real float. -const FEET_GAP_MAX := 0.03 - -func _initialize() -> void: - call_deferred("run") - -func run() -> void: - root.size = Vector2i(1280, 720) - var output := OS.get_environment("MT_RENDER_OUTPUT") - if output.is_empty(): - output = ProjectSettings.globalize_path("res://../build/rendering/gamescene-%d" % Time.get_unix_time_from_system()) - DirAccess.make_dir_recursive_absolute(output) - var fc := Fixtures.FakeClient.new() - root.add_child(fc) - var scene := GameScene.new() - root.add_child(scene) - await scene.setup(fc, AssetRoot.path(), "OutdoorA1/metin2_map_a1") - # Choose C1 to exercise late map correction too (setup initially had no VID). - var use_c1 := "--c1" in OS.get_cmdline_user_args() - var server := Vector2(959109, 269267) if use_c1 else Vector2(454600, 934000) - fc.main = 1000 - fc.ents[1000] = {"vid": 1000, "name": "RenderTest", "race": 0, - "pos": Vector3(server.x * 0.01, 0, -server.y * 0.01), - "pos_cm": server, "is_main": true, "angle_deg": 0.0, - "func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false} - for i in 30: await process_frame - var expected := MapCoord.to_world(fc.ents[1000].pos) - expected.y = scene.world.sample_height(expected.x, expected.z) - failed = not scene._model_built or scene.player.position.distance_to(expected) > 0.02 - if use_c1: failed = failed or scene.map_path != "OutdoorC1/metin2_map_c1" - var surfaces := 0 - for mi in scene.player.find_children("*", "MeshInstance3D", true, false): - if mi.mesh: surfaces += mi.mesh.get_surface_count() - failed = failed or surfaces == 0 or not scene._map_loaded() - if scene._model_built and scene.player.anim: - scene.player.anim.set_time(0.5) - scene.player.anim.set_process(false) - await process_frame # let the frozen pose reach cpu_skin before measuring - # R2-01 grounding: the lowest skinned vertex must sit on the terrain, not - # above it. Before the Offset4x4 fix the whole actor hovered one pelvis - # height (warrior 0.994 m) up; this is the numeric guard for that. - var feet_y := INF - for mi in scene.player.find_children("*", "MeshInstance3D", true, false): - if mi.mesh == null: continue - feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y) - var feet_gap := feet_y - expected.y - failed = failed or not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX - scene.cam.snap_to_target() - if DisplayServer.get_name() != "headless": - await RenderingServer.frame_post_draw - failed = failed or root.get_texture().get_image().save_png(output.path_join("game.png")) != OK - var report := {"passed": not failed, "map": scene.map_path, "server_cm": str(server), - "world_position": str(scene.player.position), "expected": str(expected), - "surfaces": surfaces, "feet_y": feet_y, "feet_gap": feet_gap, - "feet_gap_max": FEET_GAP_MAX, "map_report": scene.world.get_load_report(), - "engine": Engine.get_version_info(), "viewport": str(root.size), - "camera_transform": str(scene.cam.global_transform), "fov": scene.cam.fov, - "assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless", - "scope": "real GameScene, delayed main row, no main signal; no server/40250 parity"} - var hashes := {} - for path in ["res://game_scene.gd", "res://ui/player_view.gd", "res://net/mapcoord.gd", - "res://gamescene_visual_test.gd", "res://bin/libmtgodot.macos.template_debug.dylib"]: - hashes[path] = FileAccess.get_sha256(path) - if scene._model_built: - for property in ["gr2_path", "hair_gr2", "hair_skin"]: - var path := String(scene.player.model.get(property)) - if not path.is_empty(): hashes[path] = FileAccess.get_sha256(path) - var motion := String(scene.player.anim.get("anim_path")) if scene.player.anim else "" - if not motion.is_empty(): hashes[motion] = FileAccess.get_sha256(motion) - report["sha256"] = hashes - report["animation_sample_seconds"] = 0.5 - var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE) - file.store_string(JSON.stringify(report, "\t")) - file.close() - print("GAMESCENE_VISUAL: ", JSON.stringify(report)) - scene.queue_free() - fc.queue_free() - await process_frame - quit(1 if failed else 0) diff --git a/project/gamescene_visual_test.gd.uid b/project/gamescene_visual_test.gd.uid deleted file mode 100644 index 71bca934..00000000 --- a/project/gamescene_visual_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bii82ax05bwuj diff --git a/project/gpu_lod_attachment_test.gd b/project/gpu_lod_attachment_test.gd deleted file mode 100644 index d47bb4a2..00000000 --- a/project/gpu_lod_attachment_test.gd +++ /dev/null @@ -1,63 +0,0 @@ -extends SceneTree - -const PlayerView = preload("res://ui/player_view.gd") -var failures: Array[String] = [] -var ghosts_checked := 0 - -func _initialize() -> void: - call_deferred("run") - -func check(ok: bool, message: String) -> void: - if not ok: - failures.append(message) - printerr("FAIL: ", message) - -func run() -> void: - OS.set_environment("MTGODOT_GPUSKIN", "1") - var cam := Camera3D.new() - root.add_child(cam) - cam.make_current() - for race in 8: - cam.position = Vector3(0, 1, 3) - var pv := PlayerView.new() - root.add_child(pv) - if not pv.build(AssetRoot.path(), race): - check(false, "race %d build failed" % race) - pv.queue_free() - continue - pv.anim.set_process(false) - pv.anim.set("blend_time", 0.0) - pv.anim.set("time", 0.5) - var mesh_node: MeshInstance3D = pv.model.get_node("MeshInstance3D") - var reference_hair: PackedVector3Array = mesh_node.mesh.surface_get_arrays(mesh_node.mesh.get_surface_count() - 1)[Mesh.ARRAY_VERTEX] - for level in [0, 1, 2, 3, 2, 1, 0]: - cam.position = Vector3(0, 1, [3.0, 25.0, 60.0, 110.0][level]) - for frame in 3: - await process_frame - check(int(pv.model.call("get_lod_level")) == level, "race %d did not reach LOD %d" % [race, level]) - var mesh := mesh_node.mesh - var last := mesh.get_surface_count() - 1 - check(String(mesh.surface_get_name(last)).begins_with("hair_"), "race %d LOD %d lost hair" % [race, level]) - var vertices: PackedVector3Array = mesh.surface_get_arrays(last)[Mesh.ARRAY_VERTEX] - check(vertices == reference_hair, "race %d LOD %d changed hair geometry" % [race, level]) - var mat: ShaderMaterial = mesh_node.get_active_material(last) - check(int(mat.get_shader_parameter("mode")) == 2, "race %d LOD %d lost hair cutout" % [race, level]) - check(mat.get_shader_parameter("bones_tex") != null, "race %d LOD %d lost skin matrices" % [race, level]) - var ghost: MeshInstance3D = pv.model.get_node_or_null("LodGhost") - if ghost != null: - ghosts_checked += 1 - var old_mat: ShaderMaterial = ghost.get_active_material(0) - var frozen: Texture2D = old_mat.get_shader_parameter("bones_tex") - check(frozen != mat.get_shader_parameter("bones_tex"), "LOD ghost shares live bone texture") - var before := frozen.get_image().get_data() - pv.anim.set("time", 1.0) - check(before == frozen.get_image().get_data(), "LOD ghost pose changed after animation sample") - check(ghost.custom_aabb.size.length_squared() > 0, "LOD ghost lost posed culling bounds") - for surface in ghost.mesh.get_surface_count(): - check(ghost.get_active_material(surface) != null, "LOD ghost lost surface material") - pv.anim.set("time", 0.5) - pv.queue_free() - await process_frame - check(ghosts_checked > 0, "No LOD fade snapshots were exercised") - print("gpu_lod_attachment_test: failures=", failures.size(), " ghosts=", ghosts_checked) - quit(0 if failures.is_empty() else 1) diff --git a/project/gpu_lod_attachment_test.gd.uid b/project/gpu_lod_attachment_test.gd.uid deleted file mode 100644 index e4a81ed2..00000000 --- a/project/gpu_lod_attachment_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://huxe52dmjvo6 diff --git a/project/gpu_pose_bounds_test.gd b/project/gpu_pose_bounds_test.gd deleted file mode 100644 index af54410a..00000000 --- a/project/gpu_pose_bounds_test.gd +++ /dev/null @@ -1,49 +0,0 @@ -extends SceneTree - -const PlayerView = preload("res://ui/player_view.gd") -var failures := 0 - -func _initialize() -> void: - call_deferred("run") - -func run() -> void: - for race in 8: - var views: Array = [] - for mode in ["0", "1"]: - OS.set_environment("MTGODOT_GPUSKIN", mode) - var pv := PlayerView.new() - root.add_child(pv) - if not pv.build(AssetRoot.path(), race): - printerr("FAIL: build race ", race) - quit(1) - return - pv.model.set("lod_enabled", false) - pv.anim.set_process(false) - pv.anim.set("blend_time", 0.0) - views.append(pv) - for motion in ["wait", "walk", "run", "attack", "damage", "dead"]: - for i in 2: - OS.set_environment("MTGODOT_GPUSKIN", str(i)) - # damage has weighted variants (damage / damage_1, CRaceData::GetMotionKey); - # the same seed makes the CPU and GPU views pick the same clip. - seed(race) - views[i].set_anim_state(motion) - views[i].anim.set("loop", false) - for fraction in [0.0, 0.25, 0.5, 0.75, 1.0]: - for i in 2: - OS.set_environment("MTGODOT_GPUSKIN", str(i)) - views[i].anim.set("time", float(views[i].anim.call("get_duration")) * fraction) - var cpu: MeshInstance3D = views[0].model.get_node("MeshInstance3D") - var gpu: MeshInstance3D = views[1].model.get_node("MeshInstance3D") - # Read the actual culling box without invoking get_visual_aabb(), - # which refreshes it on demand and would hide the stale-box bug. - var expected: AABB = cpu.get_aabb() - var actual: AABB = gpu.custom_aabb - if not actual.position.is_finite() or not actual.size.is_finite() or not actual.grow(0.02).encloses(expected): - failures += 1 - printerr("FAIL: GPU culling excludes CPU pose: race=%d motion=%s time=%s" % [race, motion, fraction]) - for pv in views: - pv.queue_free() - await process_frame - print("gpu_pose_bounds_test: failures=", failures, " (8 races x 6 motions x 5 samples)") - quit(0 if failures == 0 else 1) diff --git a/project/gpu_pose_bounds_test.gd.uid b/project/gpu_pose_bounds_test.gd.uid deleted file mode 100644 index 64c0f236..00000000 --- a/project/gpu_pose_bounds_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d4asr8c7kwoep diff --git a/project/guild_creation_ui_test.gd b/project/guild_creation_ui_test.gd deleted file mode 100644 index 73b942e6..00000000 --- a/project/guild_creation_ui_test.gd +++ /dev/null @@ -1,67 +0,0 @@ -# guild_creation_ui_test —— GC_REQUEST_MAKE_GUILD -> 输入 -> CG_ANSWER_MAKE_GUILD。 -# godot --headless --path project --script guild_creation_ui_test.gd -extends SceneTree - -const GuildUI = preload("res://ui/guild_ui.gd") - -class FakeClient extends Node: - signal guild_make_requested() - var calls: Array = [] - func guild_answer_make(name: String) -> bool: - calls.append(name) - return true - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - # Window.popup_centered() requires the dialog to have entered the scene tree; - # defer the UI assertions until the test root has finished attaching nodes. - call_deferred("_run") - -func _run() -> void: - _run_checks() - if _fail == 0: - print("PASS: guild_creation_ui_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run_checks() -> void: - var canvas := CanvasLayer.new() - get_root().add_child(canvas) - var fc := FakeClient.new() - get_root().add_child(fc) - var gu: Node = GuildUI.new() - get_root().add_child(gu) - gu.setup(fc, canvas) - gu.set_mobile_mode(true) - - # 服务端请求应弹出创建窗口,且只复用一个窗口。 - fc.guild_make_requested.emit() - _ck(is_instance_valid(gu._create_dialog), "guild_make_requested -> 创建公会窗口") - _ck(gu._create_dialog.size == Vector2i(520, 270), "移动创建公会弹窗使用横屏尺寸") - _ck(gu._create_name_edit.max_length == 12, "名称输入限制为 12 字") - var first_dialog = gu._create_dialog - fc.guild_make_requested.emit() - _ck(gu._create_dialog == first_dialog, "重复请求复用窗口") - - # 空名称和控制字符在发包前拦截,窗口保持可编辑。 - gu._create_name_edit.text = "" - gu._on_guild_create_confirmed() - _ck(fc.calls.is_empty(), "空名称不发应答") - _ck(gu._create_status.text.contains("不能为空"), "空名称显示校验错误") - gu._create_name_edit.text = "Guild\nName" - gu._on_guild_create_confirmed() - _ck(fc.calls.is_empty(), "控制字符名称不发应答") - _ck(gu._create_status.text.contains("非法字符"), "非法字符显示校验错误") - - # 合法名称回送 CG_ANSWER_MAKE_GUILD 并关闭窗口。 - gu._create_name_edit.text = "Knights" - gu._on_guild_create_confirmed() - _ck(fc.calls == ["Knights"], "合法名称 -> guild_answer_make") - _ck(gu._create_dialog == null, "发送成功后关闭创建窗口") diff --git a/project/guild_creation_ui_test.gd.uid b/project/guild_creation_ui_test.gd.uid deleted file mode 100644 index 15795076..00000000 --- a/project/guild_creation_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dqbhk6lkj6ner diff --git a/project/guild_mark_tail_test.gd b/project/guild_mark_tail_test.gd deleted file mode 100644 index 21db8af1..00000000 --- a/project/guild_mark_tail_test.gd +++ /dev/null @@ -1,114 +0,0 @@ -# guild_mark_tail_test —— 已下载的公会会徽接入角色头顶尾标。 -# 覆盖会徽晚于角色实体到达、参考端 mark/guild/name 的水平排布,以及清空会徽缓存后 -# 不伪留旧 Sprite3D。godot --headless --path project --script guild_mark_tail_test.gd -extends SceneTree - -const NetWorld = preload("res://net_world.gd") -const NameTailLayout = preload("res://name_tail_layout.gd") - -class FakeClient extends Node: - signal entity_spawned(entity: Dictionary) - signal entity_despawned(vid: int) - signal entity_moved(vid: int) - signal entity_main_set(vid: int) - signal entity_info(vid: int, entity: Dictionary) - signal chat(type: int, vid: int, text: String) - signal vitals_changed(vid: int) - signal entity_dead(vid: int) - signal damage(vid: int, amount: int, flag: int) - signal guild_marks_ready(mark_count: int) - signal guild_mark_updated(guild_id: int, img_idx: int) - var entities := {} - var mark: Image - - func get_entity(vid: int) -> Dictionary: return entities.get(vid, {}) - func get_entities() -> Array: return entities.values() - func get_pvp_relations() -> Array: return [] - func get_duel() -> Dictionary: return {} - func get_guild_name(guild_id: int) -> String: return "Wolves" if guild_id == 88 else "" - func get_guild_mark_image(guild_id: int) -> Image: - return mark if guild_id == 88 else null - - func add_entity(vid: int, guild_id: int) -> Dictionary: - var d := {"vid": vid, "name": "Alice", "guild": guild_id, "level": 42, - "alignment": 0, "ch_type": 0, "race": 0, "hp": 100, "max_hp": 100, - "dead": false, "pos": Vector3.ZERO, "pos_cm": Vector3.ZERO, - "func": 0, "moving": false, "angle_deg": 0.0, "parts": [0, 0, 0, 0], - "affect_flags": 0} - entities[vid] = d - return d - -var _fail := 0 -func _ck(condition: bool, message: String) -> void: - if not condition: - _fail += 1 - printerr("FAIL: " + message) - -func _mark_image(color: Color) -> Image: - var bytes := PackedByteArray() - bytes.resize(16 * 12 * 4) - for i in range(0, bytes.size(), 4): - bytes[i] = int(color.r * 255.0) - bytes[i + 1] = int(color.g * 255.0) - bytes[i + 2] = int(color.b * 255.0) - bytes[i + 3] = 255 - return Image.create_from_data(16, 12, false, Image.FORMAT_RGBA8, bytes) - -func _init() -> void: - await _run() - if _fail == 0: - print("PASS: guild_mark_tail_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var mount := Node3D.new() - get_root().add_child(mount) - var client := FakeClient.new() - get_root().add_child(client) - var world: Node = NetWorld.new() - get_root().add_child(world) - world.setup(client, mount) - - # The character is rendered before the side connection finishes downloading marks. - var entity := client.add_entity(7, 88) - client.entity_spawned.emit(entity) - var actor: Node3D = world.node_for(7) - _ck(actor != null, "guild actor is spawned") - _ck(actor.get_node_or_null("GuildMark") == null, "no mark image -> no stale GuildMark") - - # Mark download completion refreshes already-visible actors. - client.mark = _mark_image(Color(0.2, 0.6, 0.9)) - client.guild_marks_ready.emit(1) - var mark := actor.get_node_or_null("GuildMark") as Sprite3D - var guild := actor.get_node_or_null("GuildTag") as Label3D - _ck(mark != null and mark.texture != null, "guild_marks_ready -> world GuildMark Sprite3D") - _ck(mark != null and mark.texture.get_width() == 16 and mark.texture.get_height() == 12, - "GuildMark keeps the 16x12 mark dimensions") - _ck(guild != null and guild.text == "Wolves", "guild tag remains visible beside the mark") - _ck(mark != null and guild != null and not is_zero_approx(guild.position.x), - "mark present -> guild name gets mark-relative x offset") - _ck(mark != null and is_equal_approx(mark.position.y, - world.NAME_TAG_DEFAULT_Y + NameTailLayout.MARK_ABOVE_NAME_PX * actor.get_node("Label3D").pixel_size), - "mark is placed 26px above the name row") - - # An incremental update replaces the texture in place rather than duplicating the node. - var old_mark := mark - client.mark = _mark_image(Color(0.9, 0.2, 0.2)) - client.guild_mark_updated.emit(88, 3) - var refreshed := actor.get_node_or_null("GuildMark") as Sprite3D - _ck(refreshed == old_mark, "guild_mark_updated reuses the existing Sprite3D") - _ck(refreshed.texture != null and refreshed.texture.get_image().get_pixel(0, 0).r > 0.8, - "guild_mark_updated refreshes the texture") - - # GC_CHAR_ADD_INFO can remove a guild; the mark is removed with the guild tag. - entity["guild"] = 0 - client.entity_info.emit(7, entity) - await process_frame - _ck(actor.get_node_or_null("GuildMark") == null, "guild removal deletes the mark instance") - - world.free() - mount.free() - client.free() diff --git a/project/guild_mark_tail_test.gd.uid b/project/guild_mark_tail_test.gd.uid deleted file mode 100644 index 7cb4a9a1..00000000 --- a/project/guild_mark_tail_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ch0kvrxf13dnh diff --git a/project/guild_mark_test.gd b/project/guild_mark_test.gd deleted file mode 100644 index f673d1d2..00000000 --- a/project/guild_mark_test.gd +++ /dev/null @@ -1,133 +0,0 @@ -# guild_mark_test —— 会徽下载:guild_ui 会徽图 + app_flow 触发 / 冷却 headless 自检。 -# godot --headless --path project --script guild_mark_test.gd -extends SceneTree - -const GuildUI = preload("res://ui/guild_ui.gd") -const AppFlow = preload("res://app_flow.gd") - -class FakeClient extends Node: - signal guild_changed() - signal guild_marks_ready(mark_count: int) - signal guild_mark_updated(guild_id: int, img_idx: int) - signal guild_mark_uploaded(ok: bool) - signal char_list(list: Array) - signal entered_game() - signal disconnected(reason: String) - signal login_failed(reason: String) - - var guild := {"in_guild": true, "id": 88, "name": "Wolves", "level": 9, - "member_count": 3, "max_member_count": 32, "gold": 1000} - var members := [] - var grades := [] - var mark_img: Image = null - var dl_calls := [] - var up_calls := [] - var mark_srv := {"host": "", "port": 0} - var connects := [] - var selects := [] - - func is_in_game() -> bool: return true - func get_guild() -> Dictionary: return guild - func get_guild_members() -> Array: return members - func get_guild_grades() -> Array: return grades - func get_guild_mark_image(_gid: int) -> Image: return mark_img - func get_mark_server() -> Dictionary: return mark_srv - func download_guild_marks(host: String, port: int) -> bool: - dl_calls.append([host, port]); return true - func upload_guild_mark(host: String, port: int, gid: int, img: Image) -> bool: - up_calls.append([host, port, gid, img != null]); return true - func connect_to_server(ah, ap, gh, gp, id, pw) -> void: - connects.append([ah, ap, gh, gp, id, pw]) - func select_character(i) -> void: selects.append(i) - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: guild_mark_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _btn(root: Node, label: String) -> Button: - for b in root.find_children("*", "Button", true, false): - if String(b.text) == label: - return b - return null - -func _make_mark() -> Image: - var b := PackedByteArray() - b.resize(16 * 12 * 4) - for i in range(0, b.size(), 4): - b[i] = 0x33; b[i + 1] = 0x99; b[i + 2] = 0xCC; b[i + 3] = 0xFF - return Image.create_from_data(16, 12, false, Image.FORMAT_RGBA8, b) - -func _run() -> void: - var canvas := CanvasLayer.new() - get_root().add_child(canvas) - var fc := FakeClient.new() - get_root().add_child(fc) - - # --- guild_ui 会徽图 --- - var gu: Node = GuildUI.new() - get_root().add_child(gu) - gu.setup(fc, canvas) - gu.toggle() - _ck(gu.is_open(), "公会窗打开") - _ck(gu._mark != null and not gu._mark.visible, "无会徽图 -> TextureRect 隐藏") - - # 会徽下载完成 -> 刷新出图 - fc.mark_img = _make_mark() - fc.guild_marks_ready.emit(2) - _ck(gu._mark.visible and gu._mark.texture != null, "guild_marks_ready -> 会徽显示") - _ck(gu._mark.texture.get_width() == 16 and gu._mark.texture.get_height() == 12, "会徽 16x12") - - # --- 会徽上传按钮 --- - # 没配 mark server -> 无按钮 - gu._set_tab(0) - _ck(_btn(gu._pages[0], "上传会徽") == null, "未配 mark server -> 无上传按钮") - # 配上 server + 图源 -> 出按钮 - fc.mark_srv = {"host": "1.2.3.4", "port": 11002} - gu.mark_image_provider = _make_mark - gu._set_tab(0) - var ub := _btn(gu._pages[0], "上传会徽") - _ck(ub != null, "配好后 -> 有上传按钮") - ub.pressed.emit() - _ck(fc.up_calls == [["1.2.3.4", 11002, 88, true]], "上传 -> upload_guild_mark(host, port, gid, img)") - _ck(gu._upload_status.text.contains("上传中"), "上传中状态") - fc.guild_mark_uploaded.emit(true) - _ck(gu._upload_status.text.contains("已上传"), "guild_mark_uploaded(true) -> 状态更新") - - # 无公会 -> 收起会徽 - fc.guild = {"in_guild": false} - fc.guild_changed.emit() - _ck(not gu._mark.visible, "离开公会 -> 会徽隐藏") - - # --- app_flow 触发下载 + 冷却 --- - var fc2 := FakeClient.new() - var af: Node = AppFlow.new() - af.build_game_scene = false - get_root().add_child(af) - af.start("", fc2) - # mark_port 默认 0 -> 不下载 - fc2.entered_game.emit() - _ck(fc2.dl_calls.is_empty(), "mark_port=0 -> 不下载会徽") - - # 配上 mark_port 再进游戏 - af.serverinfo._servers[0]["mark_port"] = 11002 - af._start_guild_mark_download() - _ck(fc2.dl_calls.size() == 1 and fc2.dl_calls[0][1] == 11002, "配置后 -> download_guild_marks(host, 11002)") - - # guild_mark_updated:冷却过后重下一次 - af._mark_redl_after = 0 - fc2.guild_mark_updated.emit(88, 1) - _ck(fc2.dl_calls.size() == 2, "guild_mark_updated -> 重新下载") - # 立刻再来一次 -> 冷却内不重下 - fc2.guild_mark_updated.emit(88, 1) - _ck(fc2.dl_calls.size() == 2, "1s 冷却内 -> 不重复下载") diff --git a/project/guild_mark_test.gd.uid b/project/guild_mark_test.gd.uid deleted file mode 100644 index bc95cd83..00000000 --- a/project/guild_mark_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ympbq4jbv85r diff --git a/project/guild_refine_test.gd b/project/guild_refine_test.gd deleted file mode 100644 index a12219f5..00000000 --- a/project/guild_refine_test.gd +++ /dev/null @@ -1,107 +0,0 @@ -# guild_refine_test —— 公会窗 + 精炼对话框 headless 自检。 -# godot --headless --path project --script guild_refine_test.gd -extends SceneTree - -const GuildUI = preload("res://ui/guild_ui.gd") -const RefineUI = preload("res://ui/refine_ui.gd") - -class FakeClient extends Node: - signal guild_changed() - signal refine_ask(info: Dictionary) - signal refine_result(ok: bool) - var guild := {"in_guild": false} - var members := [] - var grades := [] - var calls := [] - func is_in_game() -> bool: return true - func get_guild() -> Dictionary: return guild - func get_guild_members() -> Array: return members - func get_guild_grades() -> Array: return grades - func get_main_pid() -> int: return 2 # Newbie(grade 3,auth 0 → 无踢人权) - func guild_deposit_money(v: int) -> bool: calls.append(["deposit", v]); return true - func guild_withdraw_money(v: int) -> bool: calls.append(["withdraw", v]); return true - func refine(pos, type) -> bool: calls.append(["refine", pos, type]); return true - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: guild_refine_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var canvas := CanvasLayer.new() - get_root().add_child(canvas) - var fc := FakeClient.new() - get_root().add_child(fc) - - # --- 公会窗 --- - var gu: Node = GuildUI.new() - get_root().add_child(gu) - gu.setup(fc, canvas) - gu.toggle() - _ck(gu.is_open(), "公会窗打开") - _ck(gu._info.text.contains("未加入"), "无公会 -> 提示") - fc.guild = {"in_guild": true, "name": "Dragons", "level": 12, "member_count": 2, - "max_member_count": 32, "gold": 500000} - fc.grades = [{"name": "会长", "auth": 15}, {"name": "官员", "auth": 7}, {"name": "会员", "auth": 0}] - fc.members = [ - {"pid": 1, "name": "Master", "grade": 1, "level": 90, "offer": 99999, "general": true}, - {"pid": 2, "name": "Newbie", "grade": 3, "level": 5, "offer": 10, "general": false}, - ] - fc.guild_changed.emit() - _ck(gu._info.text.contains("Dragons") and gu._info.text.contains("Lv.12"), "公会信息行") - # 成员页 = 1 资金行 + 2 成员行(成员行是 HBox:Label [+ 踢出按钮]) - var rows: Array = gu._pages[0].get_children() - _ck(rows.size() == 3, "资金行 + 2 个成员行") - _ck(String((rows[0].get_child(0) as Label).text).contains("500000"), "资金行显示公会资金") - var member_labels: Array[String] = [] - for r in rows.slice(1): - member_labels.append(String((r.get_child(0) as Label).text)) - _ck(r.get_child_count() == 1, "无 REMOVE_MEMBER 权限 -> 无踢出按钮") - # 会长排前(grade 1 < grade 3) - _ck(member_labels[0].begins_with("Master"), "按 grade 排序") - _ck(member_labels[0].contains("[会长]"), "grade 名解析") - - # --- 精炼对话框 --- - var ru: Node = RefineUI.new() - get_root().add_child(ru) - ru.setup(fc, canvas) - _ck(not ru.is_open(), "精炼框初始隐藏") - fc.refine_ask.emit({ - "type": 1, "pos": 7, "src_vnum": 11209, "result_vnum": 11219, - "cost": 200000, "prob": 45, - "materials": [{"vnum": 30001, "count": 3}, {"vnum": 30002, "count": 1}], - }) - _ck(ru.is_open(), "refine_ask -> 弹出") - _ck(ru._text.text.contains("45%") and ru._text.text.contains("200000"), "成功率 + 费用") - _ck(ru._text.text.contains("#30001 ×3"), "材料行") - # 找「精炼」按钮 - var ok_btn: Button = null - for b in ru._root.find_children("*", "Button", true, false): - if b.text == "精炼": - ok_btn = b - _ck(ok_btn != null, "有「精炼」按钮") - if ok_btn: - ok_btn.pressed.emit() - _ck(ru._question_dialog.visible, "40250 规则:成功率 45% < 100% 弹出二次确认框") - ru._qd_accept_btn.pressed.emit() - _ck(fc.calls == [["refine", 7, 1]], "精炼 -> client.refine(pos=7, type=1)") - _ck(not ru.is_open(), "确认后关闭") - fc.refine_result.emit(true) - _ck(ru.is_open() and ru._text.text.contains("成功"), "精炼成功结果回显") - ru._root.visible = false - # 取消 - fc.refine_ask.emit({"type": 0, "pos": 3, "src_vnum": 1, "result_vnum": 2, "cost": 0, "prob": 100, "materials": []}) - for b in ru._root.find_children("*", "Button", true, false): - if b.text == "取消": - b.pressed.emit() - _ck(not ru.is_open() and fc.calls.size() == 2 and fc.calls[1] == ["refine", 255, 255], "40250 规则:取消 -> 发送取消包 refine(255, 255)") diff --git a/project/guild_refine_test.gd.uid b/project/guild_refine_test.gd.uid deleted file mode 100644 index 158929e3..00000000 --- a/project/guild_refine_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c7y3tetalldlq diff --git a/project/guild_war_skill_test.gd b/project/guild_war_skill_test.gd deleted file mode 100644 index aeb3f5ba..00000000 --- a/project/guild_war_skill_test.gd +++ /dev/null @@ -1,151 +0,0 @@ -# guild_war_skill_test —— 公会窗「技能 / 公会战」两页 headless 自检。 -# godot --headless --path project --script guild_war_skill_test.gd -extends SceneTree - -const GuildUI = preload("res://ui/guild_ui.gd") - -class FakeTable extends Node: - func for_category(cat: String) -> Array: - return [4051, 4052, 4053] if cat == "GUILD" else [] - func name_of(id: int) -> String: - return {4051: "축복", 4052: "미리내", 4053: "무장"}.get(id, "skill %d" % id) - -class FakeClient extends Node: - signal guild_changed() - signal guild_skill_changed() - signal guild_war_changed() - signal guild_comments_changed() - var guild := {"in_guild": true, "id": 77, "name": "Dragons", "level": 12, - "member_count": 2, "max_member_count": 32, "gold": 5} - var skill := {"valid": false} - var wars := [] - var war := {"opp_guild_id": 0, "opp_name": "", "type": 0, "state": 0} - var comments := [] - var calls := [] - func is_in_game() -> bool: return true - func get_guild() -> Dictionary: return guild - func get_guild_members() -> Array: return [] - func get_guild_grades() -> Array: return [] - func get_guild_skill() -> Dictionary: return skill - func get_guild_wars() -> Array: return wars - func get_guild_war() -> Dictionary: return war - func get_guild_comments() -> Array: return comments - func use_guild_skill(vnum: int, target: int) -> bool: - calls.append(["use", vnum, target]); return true - func declare_guild_war(name: String) -> bool: - calls.append(["war", name]); return true - func guild_post_comment(text: String) -> bool: - calls.append(["post_comment", text]); return true - func guild_refresh_comments() -> bool: - calls.append(["refresh_comments"]); return true - func guild_delete_comment(comment_id: int) -> bool: - calls.append(["delete_comment", comment_id]); 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: guild_war_skill_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _btn(root: Node, label: String) -> Button: - for b in root.find_children("*", "Button", true, false): - if String(b.text) == label: - return b - return null - -func _labels_text(page: Node) -> String: - var s := "" - for c in page.find_children("*", "Label", true, false): - s += String(c.text) + "\n" - return s - -func _run() -> void: - var canvas := CanvasLayer.new() - get_root().add_child(canvas) - var fc := FakeClient.new() - get_root().add_child(fc) - var tbl := FakeTable.new() - get_root().add_child(tbl) - - var gu: Node = GuildUI.new() - get_root().add_child(gu) - gu.setup(fc, canvas, tbl) - gu.toggle() - _ck(gu.is_open(), "公会窗打开") - _ck(gu._pages.size() == 4, "4 页(成员/技能/公会战/公告)") - - # --- 技能页 --- - gu._set_tab(1) - _ck(_labels_text(gu._pages[1]).contains("还没收到"), "技能页:无数据提示") - - fc.skill = {"valid": true, "skill_point": 5, "guild_point": 1200, "max_guild_point": 5000, - "levels": [3, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 7]} - fc.guild_skill_changed.emit() - var st := _labels_text(gu._pages[1]) - _ck(st.contains("技能点 5") and st.contains("1200 / 5000"), "技能页:点数行") - _ck(st.contains("축복") and st.contains("Lv 3"), "技能页:GUILD 技能名 + 等级") - _ck(st.contains("무장") and st.contains("Lv 7"), "技能页:第 12 个技能配到 levels[11]") - # 축복 lv3 -> 有「施放」按钮;미리내 lv0 -> 无 - var cast := _btn(gu._pages[1], "施放") - _ck(cast != null, "技能页:可施放技能有按钮") - cast.pressed.emit() - _ck(fc.calls == [["use", 4051, 0]], "施放 -> use_guild_skill(4051, 0)") - - # --- 公会战页 --- - gu._set_tab(2) - _ck(_labels_text(gu._pages[2]).contains("当前无公会战"), "公会战页:无战提示") - _ck(_labels_text(gu._pages[2]).contains("无"), "公会战页:进行中列表为空") - - # 宣战 - gu._war_name_edit.text = "Tigers" - _btn(gu._pages[2], "宣战").pressed.emit() - _ck(fc.calls[-1] == ["war", "Tigers"], "宣战 -> declare_guild_war(\"Tigers\")") - - # 收到 WAR ON_WAR + WAR_LIST - fc.war = {"opp_guild_id": 88, "opp_name": "Tigers", "type": 1, "state": 6} - fc.wars = [{"src": 77, "dst": 88, "src_name": "Dragons", "dst_name": "Tigers"}] - fc.guild_war_changed.emit() - var wt := _labels_text(gu._pages[2]) - _ck(wt.contains("对 Tigers") and wt.contains("交战中"), "公会战页:当前战状态") - _ck(wt.contains("Dragons vs Tigers") and wt.contains("◀我方"), "公会战页:GvG 行 + 我方标记") - - # --- 公告页 --- - gu._set_tab(3) - _ck(_labels_text(gu._pages[3]).contains("暂无公告"), "公告页:无数据提示") - fc.comments = [{"id": 9, "name": "Alice", "content": "今晚攻城"}] - fc.guild_comments_changed.emit() - await process_frame - var ct := _labels_text(gu._pages[3]) - _ck(ct.contains("Alice:今晚攻城"), "公告页:公告内容") - var edits: Array = gu._pages[3].find_children("*", "LineEdit", true, false) - _ck(edits.size() == 1, "公告页:输入框") - if edits.size() == 1: - edits[0].text = "明晚集合" - _btn(gu._pages[3], "发布").pressed.emit() - _ck(fc.calls[-1] == ["post_comment", "明晚集合"], "公告页:发布") - _btn(gu._pages[3], "刷新").pressed.emit() - _ck(fc.calls[-1] == ["refresh_comments"], "公告页:刷新") - for b in gu._pages[3].find_children("*", "Button", true, false): - if String(b.text) == "删": - b.pressed.emit() - break - _ck(fc.calls[-1] == ["delete_comment", 9], "公告页:删除") - - # 移动端公会页使用横屏高触控密度布局,四页业务仍是同一套控制器。 - gu.close() - gu.set_mobile_mode(true) - gu.toggle() - _ck(gu.is_open() and gu._root.size == Vector2(700, 340), - "移动公会窗使用横屏基准尺寸") - _ck(gu._scroll.size == Vector2(664, 194), "移动公会窗内容区避开导航栏") - _ck(gu._tab_btns[0].custom_minimum_size == Vector2(160, 38), - "移动公会页签使用大触控目标") diff --git a/project/guild_war_skill_test.gd.uid b/project/guild_war_skill_test.gd.uid deleted file mode 100644 index 3f794767..00000000 --- a/project/guild_war_skill_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://xbhde2rhxvgy diff --git a/project/help_ui_test.gd b/project/help_ui_test.gd deleted file mode 100644 index a2bb4ddc..00000000 --- a/project/help_ui_test.gd +++ /dev/null @@ -1,125 +0,0 @@ -# help_ui_test —— §8.3 帮助窗 headless 自检(真 `helpwindow.py` uiscript + 假 client)。 -# godot --headless --path project --script help_ui_test.gd -# 校验:helpwindow.py 装载、19 条帮助行 + 任务栏标签逐字绑定(对齐 -# `locale/locale/en/locale_interface.txt` 的 `HELP_*`)、close_button、open/close/toggle、 -# 以及 SystemMenuUI.open_help() 懒建并打开本窗。缺 uiscript 资产时优雅跳过。 -extends SceneTree - -const UiManager = preload("res://ui/ui_manager.gd") -const HelpUI = preload("res://ui/help_ui.gd") -const SystemMenuUI = preload("res://ui/system_menu_ui.gd") - -class FakeClient extends Node: - func say(_type: int, _text: String) -> bool: - 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: help_ui_test (helpwindow.py + HELP_* rows + close/open/toggle + SystemMenu.open_help)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _txt(hw: Node, nm: String) -> String: - var n: Control = hw._node(nm) - return String(n.text) if n and ("text" in n) else "" % nm - -func _run() -> void: - var assets := AssetRoot.path() - if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/helpwindow.py")): - print(" (skip: no helpwindow.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 hw: Node = HelpUI.new() - get_root().add_child(hw) - hw.setup(ui, fc, assets) - hw.open() - _ck(hw.is_open(), "help window opened") - if not hw.is_open(): - return - - # --- 19 条浮动帮助行都绑定且非占位 --- - var bound := 0 - for i in range(1, 20): - var nm := "help_%02d" % i - var s := _txt(hw, nm) - _ck(not s.begins_with("")), "%s not a placeholder (got %s)" % [nm, s]) - if not s.strip_edges().is_empty() and not (s.begins_with("<") and s.ends_with(">")): - bound += 1 - _ck(bound == 19, "all 19 help rows bound (got %d)" % bound) - - # --- 逐字对照 locale/locale/en/locale_interface.txt --- - _ck(_txt(hw, "help_01") == "Panel: W, A, S, D or arrow keys", - "help_01 = HELP_MOVE_KEY (got %s)" % _txt(hw, "help_01")) - _ck(_txt(hw, "help_06") == "Attack: left mouse button or space bar", - "help_06 = HELP_ATTACK_KEY (got %s)" % _txt(hw, "help_06")) - _ck(_txt(hw, "help_02") == "Camera View: middle or right mouse button", - "help_02 = HELP_CONTROL_CAMERA_BY_MIDDLEBUTTON (2nd dup wins) (got %s)" % _txt(hw, "help_02")) - _ck(_txt(hw, "help_19") == "Display help: H", - "help_19 = HELP_HELP (got %s)" % _txt(hw, "help_19")) - - # --- 任务栏贴纸标签 --- - _ck(_txt(hw, "taskbar_help_02") == "Hit Points (HP)", - "taskbar_help_02 = HELP_HP (got %s)" % _txt(hw, "taskbar_help_02")) - _ck(_txt(hw, "taskbar_help_08b") == "(Character Window, Inventory Window,", - "taskbar_help_08b = HELP_CHARACTER_BUTTON1 (got %s)" % _txt(hw, "taskbar_help_08b")) - - # --- close_button --- - var close_btn: Control = hw._node("close_button") - _ck(close_btn is BaseButton, "close_button is a button") - _ck(_txt(hw, "close_button") == "Close", "close_button text = Close (got %s)" % _txt(hw, "close_button")) - - # --- rows() 便捷器 --- - var rows: Array = hw.rows() - _ck(rows.size() == 19, "rows() returns 19 entries (got %d)" % rows.size()) - var all_nonempty := true - for r in rows: - if String(r).strip_edges().is_empty(): - all_nonempty = false - _ck(all_nonempty, "rows() all non-empty") - - # --- close_button 关窗 --- - close_btn.pressed.emit() - _ck(not hw.is_open(), "close_button closes help window") - - # --- open / toggle / ESC 栈 --- - hw.toggle() - _ck(hw.is_open(), "toggle() re-opens help window") - _ck(ui.top() == hw._win.get("root"), "help window is top of UiManager stack (ESC closes it)") - hw.toggle() - _ck(not hw.is_open(), "toggle() closes help window") - - # --- SystemMenuUI.open_help() 懒建并打开 --- - var sm: Node = SystemMenuUI.new() - get_root().add_child(sm) - sm.setup(ui, fc, assets) - sm.open_help() - _ck(is_instance_valid(sm.help_ui), "SystemMenuUI.open_help() lazily builds help_ui") - _ck(sm.help_ui.is_open(), "SystemMenuUI.open_help() opens the help window") - sm.help_ui.close() - - # 移动端帮助页使用横屏滚动布局,不能把 PC 帮助图缩到不可读。 - ui.set_mobile_mode(true) - hw.set_mobile_mode(true) - hw.open() - _ck(hw.is_open() and hw._mobile_root.size == Vector2(760, 340), - "mobile help window uses landscape base size") - _ck(hw.rows().size() == 19 and not hw.rows()[0].begins_with("<"), - "mobile help keeps all localized rows") - hw.close() diff --git a/project/help_ui_test.gd.uid b/project/help_ui_test.gd.uid deleted file mode 100644 index 49166374..00000000 --- a/project/help_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c0k8knok8mik5 diff --git a/project/hit_collision.gd b/project/hit_collision.gd deleted file mode 100644 index c57c2b6b..00000000 --- a/project/hit_collision.gd +++ /dev/null @@ -1,160 +0,0 @@ -# HitCollision —— CLIENT-GAP §3.5 修改 2:`CActorInstance::__NormalAttackProcess` 用到的碰撞几何, -# 逐行移植(单位 = Metin2 cm,Z 为高度): -# IntersectLineSegments / FindNearestPointOnLineSegment / FindNearestPointOfParallelLineSegments / -# AdjustNearestPoints(EterLib/lineintersect_utils.cpp,MY_EPSILON 0.1) -# DetectCollisionDynamicZCylinderVSDynamicZCylinder(GameLib/GameUtil.cpp) -# .msm AttachingData CollisionType 3 = m_DefendingPointInstanceList 的防御球 -extends RefCounted - -const MY_EPSILON := 0.1 -const COLLISION_TYPE_DEFENDING := 3 # NRaceData::COLLISION_TYPE_DEFENDING -const HUGE_RACE_VNUM := 2493 # IS_HUGE_RACE(InstanceBase.cpp) - -static var _msm_cache := {} - -# FindNearestPointOnLineSegment:|L|² < ε² 时 Nearest = A1,parameter 保持调用方原值。 -# 返回 [Nearest, parameter]。 -static func _nearest_on_segment(a1: Vector3, l: Vector3, b: Vector3, parameter: float) -> Array: - var d := l.length_squared() - if d < MY_EPSILON * MY_EPSILON: - return [a1, parameter] - var p := clampf((b - a1).dot(l) / d, 0.0, 1.0) - return [a1 + p * l, p] - -static func _out_of_range(a: float) -> bool: - return a < 0.0 or a > 1.0 - -# FindNearestPointOfParallelLineSegments(s[] 已被 FindNearestPointOnLineSegment 夹到 [0,1], -# 所以前两个分支在出货代码里实际走不到;照抄保留)。 -static func _parallel(a1: Vector3, a2: Vector3, la: Vector3, b1: Vector3, b2: Vector3, lb: Vector3) -> Array: - var r0 := _nearest_on_segment(a1, la, b1, 0.0) - var out_a: Vector3 = r0[0] - var s0: float = r0[1] - var s1: float = _nearest_on_segment(a1, la, b2, 0.0)[1] - var out_b := Vector3.ZERO - if s0 < 0.0 and s1 < 0.0: - out_a = a1 - out_b = b2 if s0 < s1 else b1 - elif s0 > 1.0 and s1 > 1.0: - out_a = a2 - out_b = b1 if s0 < s1 else b2 - else: - var temp := 0.5 * (clampf(s0, 0.0, 1.0) + clampf(s1, 0.0, 1.0)) - out_a = a1 + temp * la - out_b = _nearest_on_segment(b1, lb, out_a, temp)[0] - return [out_a, out_b] - -static func _adjust(a1: Vector3, la: Vector3, b1: Vector3, lb: Vector3, s: float, t: float) -> Array: - var out_a := a1 + s * la - var out_b := b1 + t * lb - var r: Array - if _out_of_range(s) and _out_of_range(t): - s = clampf(s, 0.0, 1.0) - out_a = a1 + s * la - r = _nearest_on_segment(b1, lb, out_a, t) - out_b = r[0] - t = r[1] - if _out_of_range(t): - t = clampf(t, 0.0, 1.0) - out_b = b1 + t * lb - r = _nearest_on_segment(a1, la, out_b, s) - out_a = r[0] - s = r[1] - r = _nearest_on_segment(b1, lb, out_a, t) - out_b = r[0] - elif _out_of_range(s): - s = clampf(s, 0.0, 1.0) - out_a = a1 + s * la - out_b = _nearest_on_segment(b1, lb, out_a, t)[0] - elif _out_of_range(t): - t = clampf(t, 0.0, 1.0) - out_b = b1 + t * lb - out_a = _nearest_on_segment(a1, la, out_b, s)[0] - return [out_a, out_b] - -# 返回 [OutA, OutB]:两段上的最近点对。 -static func intersect_line_segments(a1: Vector3, a2: Vector3, b1: Vector3, b2: Vector3) -> Array: - var la := a2 - a1 - var lb := b2 - b1 - var l11 := la.length_squared() - var l22 := lb.length_squared() - var eps2 := MY_EPSILON * MY_EPSILON - if l11 < eps2: - return [a1, _nearest_on_segment(b1, lb, a1, 0.0)[0]] - elif l22 < eps2: - return [_nearest_on_segment(a1, la, b1, 0.0)[0], b1] - var ab := b1 - a1 - var l12 := -la.dot(lb) - var det_l := l11 * l22 - l12 * l12 - if absf(det_l) < MY_EPSILON: - return _parallel(a1, a2, la, b1, b2, lb) - var ra := la.dot(ab) - # 出货代码是 +dot(Lb, AB)(旁注写的是负号)——两段都在动时 t 会反号,1:1 保留。 - var rb := lb.dot(ab) - var t := (l11 * rb - ra * l12) / det_l - var s := (ra - l12 * t) / l11 - if _out_of_range(s) or _out_of_range(t): - return _adjust(a1, la, b1, lb, s, t) - return [a1 + s * la, b1 + t * lb] - -# DetectCollisionDynamicZCylinderVSDynamicZCylinder:两个扫掠球都压到 z=0(高度不参与), -# 各自 AABB(按自身半径外扩)不相交直接 false,否则比较两段最近点距离与 r1+r2。 -static func detect_z_cylinder(c1_last: Vector3, c1_pos: Vector3, r1: float, - c2_last: Vector3, c2_pos: Vector3, r2: float) -> bool: - c1_last.z = 0.0 - c1_pos.z = 0.0 - c2_last.z = 0.0 - c2_pos.z = 0.0 - var r := r1 + r2 - var mi1 := Vector3(minf(c1_last.x, c1_pos.x), minf(c1_last.y, c1_pos.y), minf(c1_last.z, c1_pos.z)) - Vector3.ONE * r1 - var mi2 := Vector3(maxf(c1_last.x, c1_pos.x), maxf(c1_last.y, c1_pos.y), maxf(c1_last.z, c1_pos.z)) + Vector3.ONE * r1 - var mi3 := Vector3(minf(c2_last.x, c2_pos.x), minf(c2_last.y, c2_pos.y), minf(c2_last.z, c2_pos.z)) - Vector3.ONE * r2 - var mi4 := Vector3(maxf(c2_last.x, c2_pos.x), maxf(c2_last.y, c2_pos.y), maxf(c2_last.z, c2_pos.z)) + Vector3.ONE * r2 - if mi4.x < mi1.x or mi2.x < mi3.x: - return false - if mi4.y < mi1.y or mi2.y < mi3.y: - return false - if mi4.z < mi1.z or mi2.z < mi3.z: - return false - var o := intersect_line_segments(c1_last, c1_pos, c2_last, c2_pos) - return (Vector3(o[0]) - Vector3(o[1])).length_squared() <= r * r - -# IS_HUGE_RACE(InstanceBase.cpp):出货表只有 2493。 -static func is_huge_race(race: int) -> bool: - return race == HUGE_RACE_VNUM - -# 解析 .msm 的防御球:[{radius, pos: Vector3(cm, 模型本地), bone}]。bone 非空 = isAttaching -# 挂骨骼(本端按模型本地坐标近似,seam)。缺文件 -> []。按路径缓存。 -static func parse_msm_defending(path: String) -> Array: - if _msm_cache.has(path): - return _msm_cache[path] - var out := [] - if FileAccess.file_exists(path): - var f := FileAccess.open(path, FileAccess.READ) - var ctype := -1 - var bone := "" - var attaching := false - var radius := 0.0 - while f and not f.eof_reached(): - var parts := f.get_line().strip_edges().replace("\t", " ").split(" ", false) - if parts.is_empty(): - continue - var key := String(parts[0]).to_lower() - if key == "group" and parts.size() > 1 and String(parts[1]).to_lower().begins_with("attachingdata"): - ctype = -1 - bone = "" - attaching = false - elif key == "collisiontype" and parts.size() > 1: - ctype = int(parts[1]) - elif key == "isattaching" and parts.size() > 1: - attaching = int(parts[1]) != 0 - elif key == "attachingbonename" and parts.size() > 1: - bone = String(parts[1]).trim_prefix("\"").trim_suffix("\"") - elif key == "radius" and parts.size() > 1: - radius = float(parts[1]) - elif key == "position" and parts.size() > 3 and ctype == COLLISION_TYPE_DEFENDING: - out.append({"radius": radius, - "pos": Vector3(float(parts[1]), float(parts[2]), float(parts[3])), - "bone": bone if attaching else ""}) - _msm_cache[path] = out - return out diff --git a/project/hit_collision.gd.uid b/project/hit_collision.gd.uid deleted file mode 100644 index 358e818d..00000000 --- a/project/hit_collision.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://krpfagoqk4vj diff --git a/project/hit_collision_test.gd b/project/hit_collision_test.gd deleted file mode 100644 index 8e5c0df6..00000000 --- a/project/hit_collision_test.gd +++ /dev/null @@ -1,78 +0,0 @@ -# hit_collision_test —— §3.5:__NormalAttackProcess 的碰撞几何 headless 自检。 -# IntersectLineSegments(EterLib/lineintersect_utils.cpp) -# DetectCollisionDynamicZCylinderVSDynamicZCylinder(GameLib/GameUtil.cpp) -# .msm AttachingData CollisionType 3 = m_DefendingPointInstanceList -# godot --headless --path project --script hit_collision_test.gd -extends SceneTree - -const HitCollision = preload("res://hit_collision.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: hit_collision_test (segments / z-cylinder / msm defending)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _near(a: Vector3, b: Vector3) -> bool: - return a.distance_to(b) < 0.01 - -func _run() -> void: - # --- IntersectLineSegments --- - # 静止受击方(L22 < ε²):OutB = B1,OutA = A 上离 B1 最近点。 - var r: Array = HitCollision.intersect_line_segments( - Vector3(-100, 0, 0), Vector3(100, 0, 0), Vector3(0, 50, 0), Vector3(0, 50, 0)) - _ck(_near(r[0], Vector3.ZERO) and _near(r[1], Vector3(0, 50, 0)), "stationary B -> nearest on A = (0,0,0)") - # 退化 A(L11 < ε²) - r = HitCollision.intersect_line_segments( - Vector3(10, 10, 0), Vector3(10, 10, 0), Vector3(0, 0, 0), Vector3(100, 0, 0)) - _ck(_near(r[0], Vector3(10, 10, 0)) and _near(r[1], Vector3(10, 0, 0)), "degenerate A -> OutB (10,0,0)") - # 平行(|DetL| < ε):两段 clamp 参数取中点 - r = HitCollision.intersect_line_segments( - Vector3(0, 0, 0), Vector3(100, 0, 0), Vector3(0, 50, 0), Vector3(100, 50, 0)) - _ck(_near(r[0], Vector3(50, 0, 0)) and _near(r[1], Vector3(50, 50, 0)), "parallel -> midpoints (50,0)/(50,50)") - # 一般情形,s/t 双越界 -> AdjustNearestPoints - r = HitCollision.intersect_line_segments( - Vector3(0, 0, 0), Vector3(100, 0, 0), Vector3(200, -50, 0), Vector3(200, 50, 0)) - _ck(_near(r[0], Vector3(100, 0, 0)) and _near(r[1], Vector3(200, 0, 0)), "both out of range -> (100,0)/(200,0)") - # 参考端 rb = +dot(Lb, AB)(注释里是负号):两条运动段十字相交时 t 被算成 -0.5, - # 经 AdjustNearestPoints 夹到 B1。1:1 保留这个出货行为。 - r = HitCollision.intersect_line_segments( - Vector3(-100, 0, 0), Vector3(100, 0, 0), Vector3(0, -100, 0), Vector3(0, 100, 0)) - _ck(_near(r[0], Vector3.ZERO) and _near(r[1], Vector3(0, -100, 0)), - "shipped rb sign: crossing moving segments -> OutB clamps to B1, got %s / %s" % [r[0], r[1]]) - - # --- DetectCollisionDynamicZCylinderVSDynamicZCylinder --- - # 攻击球扫过静止受击球:只看扫掠段,不看端点;z(高度)清零。 - _ck(HitCollision.detect_z_cylinder(Vector3(-200, 0, 500), Vector3(200, 0, 500), 20.0, - Vector3(0, 60, 0), Vector3(0, 60, 0), 70.0), "sweep passes 60cm from victim, r 20+70 -> hit (height ignored)") - _ck(not HitCollision.detect_z_cylinder(Vector3(-200, 0, 0), Vector3(200, 0, 0), 20.0, - Vector3(0, 100, 0), Vector3(0, 100, 0), 70.0), "sweep passes 100cm away -> no hit") - _ck(not HitCollision.detect_z_cylinder(Vector3(-200, 0, 0), Vector3(200, 0, 0), 20.0, - Vector3(400, 0, 0), Vector3(400, 0, 0), 70.0), "AABB reject -> no hit") - _ck(HitCollision.detect_z_cylinder(Vector3(0, 0, 0), Vector3(0, 0, 0), 20.0, - Vector3(80, 0, 900), Vector3(80, 0, 900), 70.0), "static spheres 80cm apart, r sum 90 -> hit") - - # --- .msm 防御球(CollisionType 3),CollisionType 1 = body 不算 --- - var assets := AssetRoot.path() - var wolf := assets.path_join("Monster/ymir work/monster/wolf/wolf_blue.msm") - var ds: Array = HitCollision.parse_msm_defending(wolf) - _ck(ds.size() == 1, "wolf_blue.msm -> 1 defending sphere, got %d" % ds.size()) - if ds.size() == 1: - _ck(is_equal_approx(float(ds[0].radius), 90.0) and _near(ds[0].pos, Vector3(0, -15, 80)), - "wolf defending sphere r90 (0,-15,80), got %s" % [ds[0]]) - var war := assets.path_join("root/msm/warrior_m.msm") - ds = HitCollision.parse_msm_defending(war) - _ck(ds.size() >= 1 and is_equal_approx(float(ds[0].radius), 70.0) and _near(ds[0].pos, Vector3(0, 0, 100)), - "warrior_m.msm defending sphere r70 (0,0,100), got %s" % [ds]) - _ck(HitCollision.parse_msm_defending(assets.path_join("no/such.msm")).is_empty(), "missing msm -> []") - # IS_HUGE_RACE(InstanceBase.cpp) - _ck(HitCollision.is_huge_race(2493) and not HitCollision.is_huge_race(101), "IS_HUGE_RACE: only 2493") diff --git a/project/hit_collision_test.gd.uid b/project/hit_collision_test.gd.uid deleted file mode 100644 index b30fc720..00000000 --- a/project/hit_collision_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://38yd5ifmjymk diff --git a/project/hit_view_test.gd b/project/hit_view_test.gd deleted file mode 100644 index ec12de4d..00000000 --- a/project/hit_view_test.gd +++ /dev/null @@ -1,259 +0,0 @@ -# hit_view_test —— CLIENT-GAP §3.7 受击方 / 攻击方视图(PlayerView / MobView)headless 自检: -# __HitGood / __HitGreate / __HitStone 动作链(ActorInstanceBattle.cpp:769-890)、__Shake、 -# InsertDelay(动作冻结)、攻击动作按 (motion mode, motion index, fSpeedRatio) 绑定、 -# .msm 防御球、motion_bound(__SetMotion 尾给 net_play 的钩子)。 -# godot --headless --path project --script hit_view_test.gd -extends SceneTree - -const PlayerView = preload("res://ui/player_view.gd") -const MobView = preload("res://ui/mob_view.gd") - -# Metin2AnimPlayer 桩:只记属性(anim_path 每次赋值都记,验证「同路径先清再设」) -class FakeAnim extends Node: - signal playback_finished - var sets := [] - var anim_path := "": - set(v): - anim_path = v - sets.append(v) - var loop := true - var time_scale := 1.0 - var playing := true - var blend_time := 0.15 - # PlayerView uses these timing accessors to decide whether an explicit loop - # request may interrupt a one-shot. A zero-duration stub means the clip is - # already at its boundary, matching the test's manual playback_finished.emit(). - func get_duration() -> float: return 0.0 - func get_time() -> float: return 0.0 - func get_motion_data() -> Dictionary: return {} - -const MSM := "ScriptType CharacterModelScript\nGroup AttachingData\n{\n\tGroup AttachingData00\n\t{\n\t\tAttachingDataType 0\n\t\tIsAttaching 0\n\t\tGroup CollisionData\n\t\t{\n\t\t\tCollisionType 3\n\t\t\tGroup SphereData\n\t\t\t{\n\t\t\t\tSphereDataCount 1\n\t\t\t\tGroup SphereData00\n\t\t\t\t{\n\t\t\t\t\tRadius %.1f\n\t\t\t\t\tPosition 0.0 0.0 %.1f\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n" - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _touch(p: String, body := "") -> void: - DirAccess.make_dir_recursive_absolute(p.get_base_dir()) - var f := FileAccess.open(p, FileAccess.WRITE) - f.store_string(body) - f.close() - -var _done := false - -func _init() -> void: - await _run() - _ck(_done, "test body ran to completion (a SCRIPT ERROR aborted _run)") - if _fail == 0: - print("PASS: hit_view_test (hit reactions / shake / delay / attack motion / defending spheres)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var root := ProjectSettings.globalize_path("user://hit_view_test") - var gen := root.path_join("general") - for n in ["wait", "run", "skill", "attack", "attack_1", "damage", "damage_1", "damage_2", "damage_3", - "damage_flying", "falling_stand", "back_damage_flying", "back_falling_stand"]: - _touch(gen.path_join(n + ".msa")) - for n in ["combo_01", "combo_02", "attack"]: - _touch(root.path_join("onehand_sword/%s.msa" % n)) - _touch(root.path_join("root/msm/warrior_m.msm"), MSM % [50.0, 90.0]) - _touch(root.path_join("root/msm/warrior_w.msm"), MSM % [40.0, 80.0]) - var gen_attack := [gen.path_join("attack.msa"), gen.path_join("attack_1.msa")] - - # --- PlayerView --------------------------------------------------------- - var pv: Node3D = PlayerView.new() - get_root().add_child(pv) - var model := Node3D.new() - pv.add_child(model) - pv.model = model - var anim := FakeAnim.new() - pv.add_child(anim) - pv.anim = anim - anim.playback_finished.connect(pv._on_playback_finished) - pv.motion_dir = gen - pv._assets_root = root - pv._race = 0 - var bound := [] - pv.motion_bound.connect(func(s: String): bound.append(s)) - await process_frame - - pv.set_anim_state("wait") - _ck(anim.anim_path == gen.path_join("wait.msa") and bound == ["wait"], - "set_anim_state binds wait.msa -> motion_bound(wait), got %s" % [bound]) - - # __SetMotion(MAKE_MOTION_KEY(mode, index), fSpeedRatio = m_fAtkSpd) - var c1 := root.path_join("onehand_sword/combo_01.msa") - pv.play_attack_motion(2, 14, 1.5) - _ck(anim.anim_path == c1 and not anim.loop and is_equal_approx(anim.time_scale, 1.5) \ - and bound.back() == "attack", - "ONEHAND_SWORD COMBO_ATTACK_1 -> combo_01.msa one-shot at speed 1.5, got %s" % anim.anim_path) - anim.sets.clear() - pv.play_attack_motion(2, 14, 1.5) - _ck(anim.sets == ["", c1], "same seg again -> re-bound from frame 0, got %s" % [anim.sets]) - pv.play_attack_motion(2, 15, 1.0) - _ck(anim.anim_path == root.path_join("onehand_sword/combo_02.msa"), "index 15 -> combo_02.msa") - pv.play_attack_motion(1, 13, 1.0) - _ck(anim.anim_path in gen_attack, "GENERAL NORMAL_ATTACK -> attack / attack_1, got %s" % anim.anim_path) - pv.play_attack_motion(1, 14, 1.0) - _ck(anim.anim_path in gen_attack, "GENERAL COMBO_ATTACK_1 -> attack / attack_1, got %s" % anim.anim_path) - pv.play_attack_motion(6, 14, 1.0) - _ck(anim.anim_path in gen_attack, "missing mode folder -> general attack fallback, got %s" % anim.anim_path) - _ck(not pv.is_in_hit_reaction(), "attack motion is not a hit reaction") - - # CGraphicThingInstance::InsertDelay:动作冻结 fDelay 秒后恢复 fSpeedRatio - pv.play_attack_motion(2, 14, 1.5) - pv.insert_delay(0.1) - _ck(anim.time_scale == 0.0, "InsertDelay -> motion frozen") - pv._process(0.05) - _ck(anim.time_scale == 0.0, "inside the delay -> still frozen") - pv._process(0.06) - _ck(is_equal_approx(anim.time_scale, 1.5), "delay over -> fSpeedRatio restored, got %s" % anim.time_scale) - - # isLock(攻击动作中):__HitGood 只 __Shake(100) - var atk_path: String = anim.anim_path - pv.hit_good(-1.0, false) - _ck(anim.anim_path == atk_path and pv._hit.shake_left > 0.0, "isLock (attack) -> shake only, no damage motion") - pv._process(0.05) - var off: Vector3 = model.position - _ck(absf(off.x) <= 0.091 and absf(off.y) <= 0.091 and absf(off.z) <= 0.091, - "ShakeProcess offset within rand()%%10 cm, got %s" % off) - pv._process(0.2) - _ck(model.position == Vector3.ZERO, "shake over -> model back on its ground offset, got %s" % model.position) - - # __HitGood:fScalar < 0 -> NAME_DAMAGE;>= 0 -> NAME_DAMAGE_BACK;之后 PushLoopMotion(WAIT) - pv.set_anim_state("wait") - pv._hit.shake_left = 0.0 - pv.hit_good(-1.0, false) - _ck(anim.anim_path in [gen.path_join("damage.msa"), gen.path_join("damage_1.msa")] and not anim.loop \ - and pv.is_in_hit_reaction() and bound.back() == "damage", - "fScalar < 0 -> NAME_DAMAGE (damage / damage_1), got %s" % anim.anim_path) - anim.playback_finished.emit() - _ck(anim.anim_path == gen.path_join("wait.msa") and not pv.is_in_hit_reaction(), "damage done -> WAIT") - pv.hit_good(1.0, false) - _ck(anim.anim_path in [gen.path_join("damage_2.msa"), gen.path_join("damage_3.msa")], - "fScalar >= 0 -> NAME_DAMAGE_BACK (damage_2 / damage_3), got %s" % anim.anim_path) - anim.playback_finished.emit() - pv._process(0.2) - pv._hit.shake_left = 0.0 - var before: String = anim.anim_path - pv.hit_good(-1.0, true) - _ck(anim.anim_path == before and pv._hit.shake_left == 0.0, "stunned -> Die() branch: no shake / damage motion") - - # __HitGreate:DAMAGE_FLYING -> STAND_UP -> 回循环动作;击倒 / 起身中不再受 Good / Greate - pv.set_anim_state("run") - pv.hit_greate(-1.0, false) - _ck(anim.anim_path == gen.path_join("damage_flying.msa") and pv.is_in_hit_reaction(), - "GREAT fScalar < 0 -> NAME_DAMAGE_FLYING, got %s" % anim.anim_path) - before = anim.anim_path - pv.hit_good(-1.0, false) - _ck(anim.anim_path == before, "IsKnockDown -> __HitGood returns") - pv.hit_greate(1.0, false) - _ck(anim.anim_path == before, "IsKnockDown -> __HitGreate returns") - anim.playback_finished.emit() - _ck(anim.anim_path == gen.path_join("falling_stand.msa"), "knockdown done -> PushOnceMotion(NAME_STAND_UP)") - pv.hit_greate(-1.0, false) - _ck(anim.anim_path == gen.path_join("falling_stand.msa"), "__IsStandUpMotion -> __HitGreate returns") - anim.playback_finished.emit() - _ck(anim.anim_path == gen.path_join("run.msa") and not pv.is_in_hit_reaction(), - "stand up done -> loop motion resumes (run), got %s" % anim.anim_path) - pv.hit_greate(1.0, false) - _ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "GREAT fScalar >= 0 -> NAME_DAMAGE_FLYING_BACK") - anim.playback_finished.emit() - _ck(anim.anim_path == gen.path_join("back_falling_stand.msa"), "-> NAME_STAND_UP_BACK") - anim.playback_finished.emit() - pv.hit_greate(1.0, true) - _ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "stunned GREAT -> DAMAGE_FLYING_BACK") - anim.playback_finished.emit() - _ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "stunned knockdown -> m_isRealDead: no stand up") - # IsUsingSkill -> 只 __Shake - pv.set_anim_state("skill") - pv._hit.shake_left = 0.0 - before = anim.anim_path - pv.hit_greate(-1.0, false) - _ck(anim.anim_path == before and pv._hit.shake_left > 0.0, "IsUsingSkill -> __HitGreate shakes only") - pv.set_anim_state("wait") - pv._hit.shake_left = 0.0 - pv.hit_stone(false) - _ck(pv._hit.shake_left > 0.0 and anim.anim_path == gen.path_join("wait.msa"), "__HitStone -> shake only") - pv._hit.shake_left = 0.0 - pv.hit_stone(true) - _ck(pv._hit.shake_left == 0.0, "stunned stone -> Die() branch, no shake") - - # .msm 防御球:root/msm/_.msm - var sp: Array = pv.get_defending_spheres() - _ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 50.0) \ - and Vector3(sp[0]["pos"]).is_equal_approx(Vector3(0, 0, 90)), - "race 0 -> warrior_m.msm defending sphere, got %s" % [sp]) - pv._race = 4 - sp = pv.get_defending_spheres() - _ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 40.0), "race 4 -> warrior_w.msm, got %s" % [sp]) - - # --- MobView ------------------------------------------------------------ - var mdir := root.path_join("mob") - var files := {"WAIT": "00.msa", "RUN": "03.msa", "NORMAL_ATTACK": "20.msa", "FRONT_DAMAGE": "30.msa", - "FRONT_KNOCKDOWN": "32.msa", "FRONT_STANDUP": "33.msa", "BACK_DAMAGE": "34.msa", "BACK_KNOCKDOWN": "35.msa"} - var mv: Node3D = MobView.new() - get_root().add_child(mv) - var mmodel := Node3D.new() - mv.add_child(mmodel) - mv.model = mmodel - var manim := FakeAnim.new() - mv.add_child(manim) - mv.anim = manim - manim.playback_finished.connect(mv._on_playback_finished) - mv._dir = mdir - mv._mesh_stem = "mobx" - for k in files: - _touch(mdir.path_join(files[k])) - mv._motions[k] = mdir.path_join(files[k]) - _touch(mdir.path_join("mobx.msm"), MSM % [60.0, 120.0]) - var mbound := [] - mv.motion_bound.connect(func(s: String): mbound.append(s)) - var M := func(n: String) -> String: return mdir.path_join(files[n]) - - mv.set_anim_state("wait") - _ck(manim.anim_path == M.call("WAIT") and mbound == ["wait"], "mob set_anim_state -> motion_bound(wait)") - mv.hit_good(1.0, false) - _ck(manim.anim_path == M.call("BACK_DAMAGE") and mv.is_in_hit_reaction(), "mob fScalar >= 0 -> BACK_DAMAGE") - manim.playback_finished.emit() - _ck(manim.anim_path == M.call("WAIT") and not mv.is_in_hit_reaction(), "mob damage done -> WAIT") - mv.hit_good(-1.0, false) - _ck(manim.anim_path == M.call("FRONT_DAMAGE"), "mob fScalar < 0 -> FRONT_DAMAGE") - manim.playback_finished.emit() - mv._motions.erase("BACK_DAMAGE") - mv.hit_good(1.0, false) - _ck(manim.anim_path == M.call("FRONT_DAMAGE"), "no DAMAGE_BACK motion -> InterceptOnceMotion(NAME_DAMAGE)") - manim.playback_finished.emit() - mv.hit_greate(1.0, false) - _ck(manim.anim_path == M.call("BACK_KNOCKDOWN"), "mob GREAT fScalar >= 0 -> BACK_KNOCKDOWN") - manim.playback_finished.emit() - _ck(manim.anim_path == M.call("WAIT"), "no BACK_STANDUP -> PushOnceMotion fails -> WAIT, got %s" % manim.anim_path) - mv.hit_greate(-1.0, false) - _ck(manim.anim_path == M.call("FRONT_KNOCKDOWN"), "mob GREAT fScalar < 0 -> FRONT_KNOCKDOWN") - manim.playback_finished.emit() - _ck(manim.anim_path == M.call("FRONT_STANDUP"), "-> FRONT_STANDUP") - manim.playback_finished.emit() - _ck(manim.anim_path == M.call("WAIT"), "-> WAIT") - mv._motions.erase("FRONT_KNOCKDOWN") - mv._motions.erase("BACK_KNOCKDOWN") - before = manim.anim_path - mv.hit_greate(1.0, false) - _ck(manim.anim_path == before, "no knockdown motions -> no motion change") - mv.set_anim_state("attack") - mv.hit_good(-1.0, false) - _ck(manim.anim_path == M.call("NORMAL_ATTACK"), "mob attacking -> isLock, no damage motion") - mv.insert_delay(0.05) - _ck(manim.time_scale == 0.0, "mob InsertDelay -> frozen") - mv._process(0.06) - _ck(manim.time_scale == 1.0, "mob delay over -> speed restored") - sp = mv.get_defending_spheres() - _ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 60.0), "mob -> /.msm, got %s" % [sp]) - - pv.queue_free() - mv.queue_free() - _done = true diff --git a/project/hit_view_test.gd.uid b/project/hit_view_test.gd.uid deleted file mode 100644 index 8a690ed6..00000000 --- a/project/hit_view_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bg0ambcb4w8pi diff --git a/project/hud.gd b/project/hud.gd deleted file mode 100644 index de56698b..00000000 --- a/project/hud.gd +++ /dev/null @@ -1,379 +0,0 @@ -# W7 HUD —— 小地图(拼 minimap.dds)+ 状态栏 / 快捷栏 / 背包。 -# §6.1:窗口/血条/快捷栏用真实 `ETC/ymir work/ui/pattern/` 九宫格贴图(AssetResolver 机制), -# 精确布局仍待 §0 的 ingame-shinsoo-ui.json。SHINSOO §9-W7 / PARITY §6.1。 -# 用法:var hud := preload("res://hud.gd").new(); hud.setup(world, player); add_child(hud) -extends CanvasLayer - -var _world: Node -var _player: Node3D -var _minimap_rect: TextureRect -var _minimap_frame: Control -var _dot: ColorRect -var _map_w_m := 1024.0 -var _map_h_m := 1280.0 -var _inv: Control -var _assets: String -var _pat := "ETC/ymir work/ui/pattern/" -var _tex_cache := {} -var _hp_fill: NinePatchRect -var _mp_fill: NinePatchRect -var _stamina_fill: ColorRect -var _stamina_label: Label -var _exp_fill: ColorRect -var _lvl_label: Label -var _hp_label: Label -var _hp_w := 240.0 -var _energy_fill: ColorRect -var _energy_label: Label -var _status_box: VBoxContainer -var quickbar: Node: - set(v): - quickbar = v - if quickbar: - _sync_vitals_to_quickbar() - -var _cached_hp: int = 0 -var _cached_max_hp: int = 0 -var _cached_sp: int = 0 -var _cached_max_sp: int = 0 -var _cached_st: int = 0 -var _cached_max_st: int = 0 -var _cached_exp: int = 0 -var _cached_next_exp: int = 0 - -func _sync_vitals_to_quickbar() -> void: - if not quickbar: - return - if _cached_max_hp > 0 and quickbar.has_method("set_hp"): - quickbar.set_hp(_cached_hp, _cached_max_hp) - if _cached_max_sp > 0 and quickbar.has_method("set_sp"): - quickbar.set_sp(_cached_sp, _cached_max_sp) - if _cached_max_st > 0 and quickbar.has_method("set_st"): - quickbar.set_st(_cached_st, _cached_max_st) - if _cached_next_exp > 0 and quickbar.has_method("set_exp"): - quickbar.set_exp(_cached_exp, _cached_next_exp) -# 目标血条(P0) -var _target_panel: Control -var _target_name: Label -var _target_fill: ColorRect -# 状态图标条(P4) -var _affect_row: HBoxContainer -# GC_DUNGEON destination compass (ClientVS22: AlarmHaveToGo). -var _dungeon_panel: Control -var _dungeon_label: Label -var _dungeon_arrow: Label -var _dungeon_destination := Vector3.ZERO - -func setup(world: Node, player: Node3D) -> void: - _world = world - _player = player - _assets = String(world.get("assets_root")) - var rep: Dictionary = _world.call("get_load_report") - _map_w_m = float(rep["map_size_x"]) * 256.0 - _map_h_m = float(rep["map_size_y"]) * 256.0 - _build_minimap(int(rep["map_size_x"]), int(rep["map_size_y"])) - _build_status() - # 技能快捷栏由 ui/quickbar.gd 管理:它恢复服务端的 36 个槽位、显示 - # 技能图标并处理冷却。这里以前画的静态空槽会和它重叠,让实际技能看起来 - # 没有加载,因此 HUD 不再创建第二条快捷栏。 - _build_inventory() - _build_dungeon_compass() - -func reload_world(new_world: Node) -> void: - _world = new_world - if _world == null: - return - var rep: Dictionary = _world.call("get_load_report") - _map_w_m = float(rep.get("map_size_x", 1)) * 256.0 - _map_h_m = float(rep.get("map_size_y", 1)) * 256.0 - if _minimap_frame and is_instance_valid(_minimap_frame): - _minimap_frame.queue_free() - _minimap_frame = null - _minimap_rect = null - _dot = null - if int(rep.get("map_size_x", 0)) > 0 and int(rep.get("map_size_y", 0)) > 0: - _build_minimap(int(rep["map_size_x"]), int(rep["map_size_y"])) - -# --- real UI texture loading (§6.1) ------------------------------------------- - -func _ui_tex(name: String) -> Texture2D: - if _tex_cache.has(name): - return _tex_cache[name] - var img := Image.new() - var tex: Texture2D = null - var cand := _assets.path_join(_pat + name + ".tga") - if not FileAccess.file_exists(cand): - # fall back: scan asset packs for "/ymir work/ui/pattern/.tga" - var da := DirAccess.open(_assets) - if da: - for sub in da.get_directories(): - var p := _assets.path_join(sub).path_join("ymir work/ui/pattern/" + name + ".tga") - if FileAccess.file_exists(p): - cand = p - break - if FileAccess.file_exists(cand) and img.load(cand) == OK: - tex = ImageTexture.create_from_image(img) - _tex_cache[name] = tex - return tex - -# Composite a set of pattern pieces into one atlas and return a NinePatchRect. -# pieces = {corner_lt, corner_rt, corner_lb, corner_rb, line_t, line_l, base} -func _nine(prefix: String, m: int, base_size: int) -> NinePatchRect: - var total := m + base_size + m - var atlas := Image.create_empty(total, total, false, Image.FORMAT_RGBA8) - atlas.fill(Color(0, 0, 0, 0)) - var put := func(name: String, x: int, y: int) -> void: - var t := _ui_tex(name) - if t == null: - return - var im := t.get_image() - im.convert(Image.FORMAT_RGBA8) - atlas.blit_rect(im, Rect2i(Vector2i.ZERO, im.get_size()), Vector2i(x, y)) - put.call(prefix + "_corner_lefttop", 0, 0) - put.call(prefix + "_corner_righttop", m + base_size, 0) - put.call(prefix + "_corner_leftbottom", 0, m + base_size) - put.call(prefix + "_corner_rightbottom", m + base_size, m + base_size) - # edges: stretch the piece across the middle band - var edge := func(name: String, r: Rect2i) -> void: - var t := _ui_tex(name) - if t == null: - return - var im := t.get_image() - im.convert(Image.FORMAT_RGBA8) - im.resize(r.size.x, r.size.y, Image.INTERPOLATE_BILINEAR) - atlas.blit_rect(im, Rect2i(Vector2i.ZERO, r.size), r.position) - edge.call(prefix + "_line_top", Rect2i(m, 0, base_size, m)) - edge.call(prefix + "_line_bottom", Rect2i(m, m + base_size, base_size, m)) - edge.call(prefix + "_line_left", Rect2i(0, m, m, base_size)) - edge.call(prefix + "_line_right", Rect2i(m + base_size, m, m, base_size)) - var b := _ui_tex(prefix + "_base") - if b: - var bi := b.get_image() - bi.convert(Image.FORMAT_RGBA8) - bi.resize(base_size, base_size, Image.INTERPOLATE_BILINEAR) - atlas.blit_rect(bi, Rect2i(Vector2i.ZERO, Vector2i(base_size, base_size)), Vector2i(m, m)) - var np := NinePatchRect.new() - np.texture = ImageTexture.create_from_image(atlas) - np.patch_margin_left = m - np.patch_margin_right = m - np.patch_margin_top = m - np.patch_margin_bottom = m - return np - -# horizontal 3-slice gauge (left cap | tiled centre | right cap) as a NinePatchRect -func _gauge(center_name: String, h: int) -> NinePatchRect: - var pieces := [center_name + "_left" if _ui_tex(center_name + "_left") else center_name, - center_name + "_center" if _ui_tex(center_name + "_center") else center_name, - center_name + "_right" if _ui_tex(center_name + "_right") else center_name] - var seg := 16 - var atlas := Image.create_empty(seg * 3, h, false, Image.FORMAT_RGBA8) - atlas.fill(Color(0, 0, 0, 0)) - for i in 3: - var t := _ui_tex(pieces[i]) - if t == null: - continue - var im := t.get_image() - im.convert(Image.FORMAT_RGBA8) - im.resize(seg, h, Image.INTERPOLATE_BILINEAR) - atlas.blit_rect(im, Rect2i(Vector2i.ZERO, Vector2i(seg, h)), Vector2i(i * seg, 0)) - var np := NinePatchRect.new() - np.texture = ImageTexture.create_from_image(atlas) - np.patch_margin_left = seg - np.patch_margin_right = seg - return np - -# --- widgets ---------------------------------------------------------------- - -func _build_minimap(nx: int, ny: int) -> void: - var cell := 64 - var big := Image.create_empty(nx * cell, ny * cell, false, Image.FORMAT_RGBA8) - big.fill(Color(0.08, 0.09, 0.11, 1.0)) - for tx in nx: - for ty in ny: - var p: String = _world.call("chunk_dir", tx, ty) + "/minimap.dds" - if not FileAccess.file_exists(p): - continue - var img: Image = _world.call("load_dds", p) - if img == null: - continue - img.resize(cell, cell, Image.INTERPOLATE_BILINEAR) - big.blit_rect(img, Rect2i(0, 0, cell, cell), Vector2i(tx * cell, ty * cell)) - var mm_h := 192 * ny / nx if nx > 0 else 192 - var frame := _nine("board", 32, 128) - _minimap_frame = frame - frame.set_anchors_preset(Control.PRESET_TOP_RIGHT) - frame.position = Vector2(-192 - 24 - 12, 12) - frame.size = Vector2(192 + 24, mm_h + 24) - add_child(frame) - _minimap_rect = TextureRect.new() - _minimap_rect.texture = ImageTexture.create_from_image(big) - _minimap_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _minimap_rect.stretch_mode = TextureRect.STRETCH_SCALE - _minimap_rect.position = Vector2(12, 12) - _minimap_rect.size = Vector2(192, mm_h) - frame.add_child(_minimap_rect) - _dot = ColorRect.new() - _dot.color = Color(1, 0.9, 0.2) - _dot.size = Vector2(6, 6) - _minimap_rect.add_child(_dot) - -func _build_status() -> void: - # 40250 标准:血量、法力、体力、经验均由底部任务栏 Quickbar 的 Gauge_Board 与 EXP_Gauge_Board 承载, - # HUD 不再在左下角渲染浮动的原型状态条。 - pass - -# --- P0: live data setters ------------------------------------------------- - -func set_status_visible(v: bool) -> void: - if _status_box and is_instance_valid(_status_box): - _status_box.visible = v - -func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void: - _cached_hp = hp - _cached_max_hp = max_hp - _cached_sp = sp - _cached_max_sp = max_sp - if _hp_fill: - _hp_fill.size.x = _hp_w * clampf(float(hp) / maxf(1.0, max_hp), 0.0, 1.0) - if _mp_fill: - _mp_fill.size.x = _hp_w * clampf(float(sp) / maxf(1.0, max_sp), 0.0, 1.0) - if _hp_label: - _hp_label.text = "%d / %d" % [hp, max_hp] - if quickbar: - if quickbar.has_method("set_hp"): - quickbar.set_hp(hp, max_hp) - if quickbar.has_method("set_sp"): - quickbar.set_sp(sp, max_sp) - -func set_exp(xp: int, next_xp: int) -> void: - _cached_exp = xp - _cached_next_exp = next_xp - if _exp_fill: - _exp_fill.size.x = _hp_w * clampf(float(xp) / maxf(1.0, next_xp), 0.0, 1.0) - if quickbar and quickbar.has_method("set_exp"): - quickbar.set_exp(xp, next_xp) - -func set_level(lv: int) -> void: - if _lvl_label: - _lvl_label.text = "Lv %d" % lv - -func set_energy(value: int, max_value: int = 100) -> void: - var cap := maxi(1, max_value) - var v := clampi(value, 0, cap) - if _energy_fill: - _energy_fill.size.x = _hp_w * float(v) / float(cap) - if _energy_label: - _energy_label.text = "%d / %d" % [v, cap] - -func set_stamina(value: int, max_value: int) -> void: - _cached_st = value - _cached_max_st = max_value - var cap := maxi(1, max_value) - var v := clampi(value, 0, cap) - if _stamina_fill: - _stamina_fill.size.x = _hp_w * float(v) / float(cap) - if _stamina_label: - _stamina_label.text = "%d / %d" % [v, cap] - if quickbar and quickbar.has_method("set_st"): - quickbar.set_st(value, max_value) - -# P4:状态图标条。affects = [{type, point_idx, value, flag, duration}, ...] -func set_affects(affects: Array) -> void: - if _affect_row == null: - _affect_row = HBoxContainer.new() - _affect_row.set_anchors_preset(Control.PRESET_TOP_LEFT) - _affect_row.position = Vector2(18, 18) - _affect_row.add_theme_constant_override("separation", 3) - add_child(_affect_row) - for c in _affect_row.get_children(): - c.queue_free() - for a in affects: - var sq := ColorRect.new() - sq.custom_minimum_size = Vector2(22, 22) - var t := int(a.get("type", 0)) - sq.color = Color.from_hsv(fmod(t * 0.13, 1.0), 0.55, 0.9, 0.95) - sq.tooltip_text = "affect #%d pt%d %+d %ds" % [t, int(a.get("point_idx", 0)), - int(a.get("value", 0)), int(a.get("duration", 0))] - _affect_row.add_child(sq) - -func set_target(nm: String, hp_pct: int) -> void: - if _target_panel == null: - _build_target_panel() - _target_panel.visible = true - _target_name.text = nm if nm != "" else "Target" - _target_fill.size.x = 180.0 * clampf(hp_pct / 100.0, 0.0, 1.0) - -func clear_target() -> void: - if _target_panel: - _target_panel.visible = false - -func set_dungeon_destination(active: bool, world_pos: Vector3) -> void: - _dungeon_destination = world_pos - if _dungeon_panel: - _dungeon_panel.visible = active - if not active: - _dungeon_label.text = "" - -func _build_dungeon_compass() -> void: - _dungeon_panel = Control.new() - _dungeon_panel.name = "DungeonCompass" - _dungeon_panel.set_anchors_preset(Control.PRESET_CENTER_TOP) - _dungeon_panel.position = Vector2(-110, 18) - _dungeon_panel.size = Vector2(220, 42) - _dungeon_panel.visible = false - add_child(_dungeon_panel) - var bg := ColorRect.new() - bg.color = Color(0.04, 0.05, 0.08, 0.78) - bg.size = _dungeon_panel.size - bg.mouse_filter = Control.MOUSE_FILTER_IGNORE - _dungeon_panel.add_child(bg) - _dungeon_label = Label.new() - _dungeon_label.position = Vector2(30, 5) - _dungeon_label.add_theme_font_size_override("font_size", 12) - _dungeon_panel.add_child(_dungeon_label) - _dungeon_arrow = Label.new() - _dungeon_arrow.text = "▲" - _dungeon_arrow.position = Vector2(8, 9) - _dungeon_arrow.add_theme_font_size_override("font_size", 18) - _dungeon_panel.add_child(_dungeon_arrow) - -func _build_target_panel() -> void: - _target_panel = Control.new() - _target_panel.set_anchors_preset(Control.PRESET_CENTER_TOP) - _target_panel.position = Vector2(-100, 16) - _target_panel.size = Vector2(200, 40) - add_child(_target_panel) - _target_name = Label.new() - _target_name.add_theme_font_size_override("font_size", 12) - _target_name.position = Vector2(10, 0) - _target_panel.add_child(_target_name) - var slot := ColorRect.new() - slot.color = Color(0, 0, 0, 0.55) - slot.position = Vector2(10, 18) - slot.size = Vector2(180, 12) - _target_panel.add_child(slot) - _target_fill = ColorRect.new() - _target_fill.color = Color(0.85, 0.2, 0.2) - _target_fill.size = Vector2(180, 12) - slot.add_child(_target_fill) - -func _build_inventory() -> void: - # 40250 标准:背包由 ui/inventory_ui.gd 统一管理,不再在此创建原型窗口。 - pass - -func _input(_ev: InputEvent) -> void: - pass - -func _process(_dt: float) -> void: - if _player: - if _dungeon_panel and _dungeon_panel.visible: - var d := _dungeon_destination - _player.global_position - var distance := Vector2(d.x, d.z).length() - _dungeon_label.text = "副本目标 %.0fm" % distance - # Player forward is +Z in this scene; rotate the north-facing arrow - # relative to the current player yaw, keeping the target prompt local. - _dungeon_arrow.rotation = atan2(d.x, d.z) - _player.rotation.y - if _minimap_rect: - var u := clampf(_player.position.x / _map_w_m, 0.0, 1.0) - var v := clampf(_player.position.z / _map_h_m, 0.0, 1.0) - _dot.position = Vector2(u * _minimap_rect.size.x - 3, v * _minimap_rect.size.y - 3) diff --git a/project/hud.gd.uid b/project/hud.gd.uid deleted file mode 100644 index fbe148ce..00000000 --- a/project/hud.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cs3i75781lp3u diff --git a/project/indexed_fly_type_test.gd b/project/indexed_fly_type_test.gd deleted file mode 100644 index eac49f7c..00000000 --- a/project/indexed_fly_type_test.gd +++ /dev/null @@ -1,109 +0,0 @@ -# indexed_fly_type_test —— 40250 CreateIndexedFly 三种索引类型回归。 -# -# 参考:ClientVS22/source/GameLib/FlyingObjectManager.cpp::CreateIndexedFly -# NORMAL : 起点 = 射手位置,终点 = Actor 对象 -# FIRE_CRACKER : 起点 + 200cm,终点 = 随机位置(水平 2000..5000cm、垂直 1000..2500cm) -# AUTO_FIRE : 起点 + 100cm,终点 = Actor 对象 -extends SceneTree - -const FlyObject = preload("res://fly_object.gd") -const NetWorld = preload("res://net_world.gd") -const AssetRoot = preload("res://asset_root.gd") - -var _failures := 0 - -func _ck(condition: bool, message: String) -> void: - if condition: - return - _failures += 1 - printerr("FAIL: ", message) - -func _init() -> void: - await _run() - quit(1 if _failures else 0) - -func _run() -> void: - var mount := Node3D.new() - get_root().add_child(mount) - - var manager := FlyObject.new() - manager.manual_step = true - mount.add_child(manager) - manager.setup(mount, null, AssetRoot.path()) - - var nw := NetWorld.new() - get_root().add_child(nw) - nw.parent = mount - nw._fly_mgr = manager - nw._assets_root = AssetRoot.path() - nw.client = _FakeClient.new() - get_root().add_child(nw.client) - - var shooter := Node3D.new() - shooter.name = "shooter" - shooter.position = Vector3(3.0, 1.5, -4.0) - shooter.rotation.y = 0.0 # 40250 heading 0: +Z / South - mount.add_child(shooter) - nw._by_vid[100] = shooter - - var target := Node3D.new() - target.name = "target" - target.position = Vector3(8.0, 1.5, -4.0) - mount.add_child(target) - nw._by_vid[200] = target - await process_frame - - # NORMAL:不抬高起点,必须绑定对象目标。 - nw._on_fly(FlyObject.FLY_EXP, 100, 200) - _ck(manager.active_count() == 1, "NORMAL indexed fly creates exactly one instance") - if manager.active_count() == 1: - var normal = manager._instances[0] - _ck(normal._is_object, "NORMAL uses object target") - _ck(normal.pos.is_equal_approx(shooter.position), "NORMAL starts at shooter position") - _ck(normal._target_obj == target, "NORMAL keeps the end actor") - manager.clear_for_map_change() - - # FIRE_CRACKER:资源注册类型决定位置目标,不能继续追踪 end actor。 - seed(40250) - nw._on_fly(FlyObject.FLY_FIREWORK1, 100, 200) - _ck(manager.active_count() == 1, "FIRE_CRACKER indexed fly creates exactly one instance") - if manager.active_count() == 1: - var firework = manager._instances[0] - var horizontal_start := Vector2(shooter.position.x, shooter.position.z) - var horizontal_target := Vector2(firework._target_pos.x, firework._target_pos.z) - _ck(not firework._is_object, "FIRE_CRACKER uses position target") - _ck(is_equal_approx(firework.pos.y, shooter.position.y + 2.0), - "FIRE_CRACKER starts 200cm above shooter") - _ck(is_equal_approx(firework.pos.x, shooter.position.x) - and is_equal_approx(firework.pos.z, shooter.position.z), - "FIRE_CRACKER keeps shooter horizontal start") - var horizontal_distance := horizontal_start.distance_to(horizontal_target) - _ck(horizontal_distance >= 20.0 and horizontal_distance <= 50.0, - "FIRE_CRACKER horizontal range is 2000..5000cm") - _ck(firework._target_pos.y >= shooter.position.y + 10.0 - and firework._target_pos.y <= shooter.position.y + 25.0, - "FIRE_CRACKER target height is 1000..2500cm") - manager.clear_for_map_change() - - # AUTO_FIRE:同样绑定对象,但起点仅上移 100cm。 - nw._on_fly(FlyObject.FLY_SKILL_MUYEONG, 100, 200) - _ck(manager.active_count() == 1, "AUTO_FIRE indexed fly creates exactly one instance") - if manager.active_count() == 1: - var auto_fire = manager._instances[0] - _ck(auto_fire._is_object, "AUTO_FIRE uses object target") - _ck(auto_fire.pos.is_equal_approx(shooter.position + Vector3.UP), - "AUTO_FIRE starts 100cm above shooter") - _ck(auto_fire._target_obj == target, "AUTO_FIRE keeps the end actor") - - manager.clear_for_map_change() - nw.queue_free() - manager.queue_free() - mount.queue_free() - await process_frame - - if _failures == 0: - print("PASS: indexed_fly_type_test") - -class _FakeClient extends Node: - func get_entity(_vid: int) -> Dictionary: - return {} diff --git a/project/indexed_fly_type_test.gd.uid b/project/indexed_fly_type_test.gd.uid deleted file mode 100644 index e92cb2ee..00000000 --- a/project/indexed_fly_type_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://yib2louegep5 diff --git a/project/input_key_test.gd b/project/input_key_test.gd deleted file mode 100644 index 104e50d5..00000000 --- a/project/input_key_test.gd +++ /dev/null @@ -1,164 +0,0 @@ -# input_key_test —— ClientVS22 game.py 键盘映射的离线回归。 -# 覆盖 macOS Command / Windows Ctrl 表情键、快捷栏分页、按住攻击、 -# 相机按住/释放、名称显示和几个窗口/坐骑入口。 -extends SceneTree - -const GameScene = preload("res://game_scene.gd") -const UiManager = preload("res://ui/ui_manager.gd") - -class FakeClient extends Node: - var emotes := [] - var commands := [] - var used_items := [] - var main := 1000 - func send_emoticon(index: int) -> bool: emotes.append(index); return true - func say(_type: int, text: String) -> bool: commands.append(text); return true - func get_main_vid() -> int: return main - func get_entity(_vid: int) -> Dictionary: return {"mount_vnum": 123} - func get_inventory() -> Array: return [{"cell": 7, "vnum": 71114}] - func use_item(_window: int, cell: int) -> bool: used_items.append(cell); return true - -class FakeQuickbar extends Node: - var activated := [] - var pages := [] - func activate(slot: int) -> void: activated.append(slot) - func set_page(page: int) -> void: pages.append(page) - -class FakeNetPlay extends Node: - var attack_states := [] - func set_attack_key(down: bool) -> void: attack_states.append(down) - -class FakeCamera extends Camera3D: - var orbit := [] - var zoom := [] - var pitch := [] - func set_key_orbit(direction: int) -> void: orbit.append(direction) - func set_key_zoom(direction: int) -> void: zoom.append(direction) - func set_key_pitch(direction: int) -> void: pitch.append(direction) - -class FakeWorld extends Node: - var names := [] - func set_names_visible(visible: bool) -> void: names.append(visible) - -class FakeChat extends Node: - var toggles := 0 - func is_typing() -> bool: return false - func focus_input() -> void: pass - func toggle_log() -> void: toggles += 1 - -class FakeStatus extends Node: - var states := [] - func open(state: String) -> void: states.append(state) - -class FakeToggle extends Node: - var toggles := 0 - var button_toggles := 0 - func toggle() -> void: toggles += 1 - func toggle_help() -> void: toggles += 1 - func toggle_buttons() -> void: button_toggles += 1 - -var _fail := 0 - -func _ck(condition: bool, message: String) -> void: - if not condition: - _fail += 1 - printerr("FAIL: " + message) - -func _key(code: Key, pressed := true, ctrl := false, meta := false) -> InputEventKey: - var event := InputEventKey.new() - event.keycode = code - event.pressed = pressed - event.echo = false - event.ctrl_pressed = ctrl - event.meta_pressed = meta - return event - -func _init() -> void: - await _run() - if _fail == 0: - print("PASS: input_key_test (ClientVS22 desktop key map)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var client := FakeClient.new() - var qb := FakeQuickbar.new() - var np := FakeNetPlay.new() - var camera := FakeCamera.new() - var world := FakeWorld.new() - var chat := FakeChat.new() - var status := FakeStatus.new() - var help := FakeToggle.new() - var quest := FakeToggle.new() - var gs: Node = GameScene.new() - gs.client = client - gs.quickbar = qb - gs.net_play = np - gs.cam = camera - gs.net_world = world - gs.chat = chat - gs.char_status_ui = status - gs.system_menu_ui = help - gs.quest_log = quest - get_root().add_child(gs) - - gs._unhandled_input(_key(KEY_1, true, true, false)) - gs._unhandled_input(_key(KEY_9, true, false, true)) - _ck(client.emotes == [0, 8], "Ctrl/Command+number -> matching emoticon") - gs._unhandled_input(_key(KEY_1)) - gs._unhandled_input(_key(KEY_F1)) - gs._unhandled_input(_key(KEY_F4)) - _ck(qb.activated == [0, 4, 7], "number/F1-F4 -> reference local quick slots") - gs._unhandled_input(_key(KEY_SHIFT)) - gs._unhandled_input(_key(KEY_4)) - gs._unhandled_input(_key(KEY_SHIFT, false)) - _ck(qb.pages == [3], "held Shift + number -> quick page 4") - - gs._unhandled_input(_key(KEY_SPACE)) - gs._unhandled_input(_key(KEY_SPACE, false)) - _ck(np.attack_states == [true, false], "Space press/release -> attack key state") - gs._unhandled_input(_key(KEY_Q)) - gs._unhandled_input(_key(KEY_Q, false)) - gs._unhandled_input(_key(KEY_R)) - gs._unhandled_input(_key(KEY_R, false)) - _ck(camera.orbit == [-1, 0] and camera.zoom == [-1, 0], "camera Q/R release stops continuous motion") - gs._unhandled_input(_key(KEY_ALT)) - gs._unhandled_input(_key(KEY_ALT, false)) - _ck(world.names == [true, false], "Alt press/release -> ShowName/HideName") - - gs._unhandled_input(_key(KEY_B)) - gs._unhandled_input(_key(KEY_V)) - gs._unhandled_input(_key(KEY_C)) - gs._unhandled_input(_key(KEY_H)) - gs._unhandled_input(_key(KEY_N)) - _ck(status.states == ["EMOTICON", "SKILL", "STATUS"], "character tabs match B/V/C") - _ck(help.toggles == 1 and quest.toggles == 1, "H help and N quest log") - gs._unhandled_input(_key(KEY_Q, true, true)) - _ck(quest.button_toggles == 1, "Ctrl+Q toggles quest buttons") - gs._unhandled_input(_key(KEY_L)) - _ck(chat.toggles == 1, "L toggles chat log") - gs._unhandled_input(_key(KEY_H, true, true)) - gs._unhandled_input(_key(KEY_B, true, false, true)) - gs._unhandled_input(_key(KEY_J, true, true)) - _ck(client.commands == ["/user_horse_ride", "/user_horse_back", "/unmount"], - "Ctrl/Command horse commands follow reference") - - # CWindowManager::RunKeyDown:只有 LockWindow(模态)或返回 True 的 ActiveWindow(输入框) - # 截键;普通打开的窗口(技能窗 / 背包)不吞键,1-4 / F1-F4 照样落到 game.py 快捷栏。 - var ui_manager: CanvasLayer = UiManager.new() - get_root().add_child(ui_manager) - await process_frame - gs.ui = ui_manager - var skill_window := Panel.new() - ui_manager.open(skill_window) - qb.activated.clear() - gs._unhandled_input(_key(KEY_2)) - gs._unhandled_input(_key(KEY_F2)) - _ck(qb.activated == [1, 5], "non-modal window open keeps number/F-key quick slots: %s" % [qb.activated]) - var confirm := Panel.new() - ui_manager.open(confirm, true) - gs._unhandled_input(_key(KEY_3)) - _ck(qb.activated == [1, 5], "modal lock window still swallows quick slot keys") - gs.ui = null diff --git a/project/input_key_test.gd.uid b/project/input_key_test.gd.uid deleted file mode 100644 index 28d5c4f6..00000000 --- a/project/input_key_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://iiiuyg47tudg diff --git a/project/intro_create_phase.gd b/project/intro_create_phase.gd deleted file mode 100644 index d9df215b..00000000 --- a/project/intro_create_phase.gd +++ /dev/null @@ -1,132 +0,0 @@ -# intro_create_phase.gd —— 40250 3D 角色塑形创建系统 1:1 -# 严格对照: -# Client/Eternexus/root/introcreate.py (CreateCharacterWindow) -# ClientVS22/source/UserInterface/InstanceBaseTransform.cpp -# metin2/src/server/game/src/player.cpp -extends RefCounted - -const JOB_WARRIOR := 0 -const JOB_ASSASSIN := 1 -const JOB_SURA := 2 -const JOB_SHAMAN := 3 - -const GENDER_MALE := 0 # MAN -const GENDER_FEMALE := 1 # WOMAN - -const SHAPE_0 := 0 # 经典外观 1 (SHAPE0) -const SHAPE_1 := 1 # 经典外观 2 (SHAPE1) - -# 40250 官方职业初始四维属性分配表 (CON, INT, STR, DEX) -# 对应 introcreate.py: START_STAT -const START_STAT_TABLE := { - JOB_WARRIOR: { "con": 4, "int": 3, "str": 6, "dex": 3 }, # 战士 - JOB_ASSASSIN: { "con": 3, "int": 3, "str": 4, "dex": 6 }, # 刺客 - JOB_SURA: { "con": 3, "int": 5, "str": 5, "dex": 3 }, # 修罗 - JOB_SHAMAN: { "con": 4, "int": 6, "str": 3, "dex": 3 }, # 萨满 -} - -# 违禁词简易过滤器 -const BANNED_NAME_PATTERNS := ["[GM]", "admin", "null", "fuck", "shit"] - -# 当前创建器状态 -var selected_job: int = JOB_WARRIOR -var selected_gender: int = GENDER_MALE -var selected_shape: int = SHAPE_0 -var turntable_rotation_deg: float = 0.0 # 360° 转盘旋转角度 - -func _init(p_job: int = JOB_WARRIOR, p_gender: int = GENDER_MALE) -> void: - selected_job = p_job - selected_gender = p_gender - selected_shape = SHAPE_0 - turntable_rotation_deg = 0.0 - -# 1. 切换职业与属性联动 -func select_job(job_id: int) -> bool: - if not START_STAT_TABLE.has(job_id): - return false - selected_job = job_id - return true - -# 2. 切换性别 -func select_gender(gender_id: int) -> bool: - if gender_id != GENDER_MALE and gender_id != GENDER_FEMALE: - return false - selected_gender = gender_id - return true - -# 3. 切换外观 (Shape 0 / Shape 1) -func select_shape(shape_id: int) -> bool: - if shape_id != SHAPE_0 and shape_id != SHAPE_1: - return false - selected_shape = shape_id - return true - -# 4. 旋转展示台 (360 度循环) -func rotate_turntable(delta_deg: float) -> float: - turntable_rotation_deg = fposmod(turntable_rotation_deg + delta_deg, 360.0) - return turntable_rotation_deg - -# 5. 获取当前职业的基础四维属性 -func get_current_stats() -> Dictionary: - return START_STAT_TABLE.get(selected_job, { "con": 4, "int": 3, "str": 6, "dex": 3 }) - -# 6. 角色名称合规性校验 -func validate_character_name(name_str: String) -> Dictionary: - var res := { "valid": false, "error_code": "OK", "message": "" } - - if name_str.is_empty(): - res["error_code"] = "EMPTY_NAME" - res["message"] = "角色名称不能为空。" - return res - - var length := name_str.length() - if length < 2 or length > 12: - res["error_code"] = "INVALID_NAME_LENGTH" - res["message"] = "角色名称长度需在 2 到 12 字符之间。" - return res - - if name_str.contains(" ") or name_str.contains("\t"): - res["error_code"] = "NAME_CONTAINS_SPACE" - res["message"] = "角色名称中不能包含空格。" - return res - - var lower_name := name_str.to_lower() - for banned in BANNED_NAME_PATTERNS: - if lower_name.contains(banned.to_lower()): - res["error_code"] = "NAME_CONTAINS_BANNED_WORD" - res["message"] = "角色名称中包含非法或保留字符。" - return res - - res["valid"] = true - return res - -# 7. 确认创建角色并打包角色初始数据 -func create_character(name_str: String, empire_id: int = 1) -> Dictionary: - var val = validate_character_name(name_str) - if not val["valid"]: - return { "success": false, "error_code": val["error_code"], "message": val["message"] } - - var stats: Dictionary = get_current_stats() - var char_data := { - "name": name_str, - "empire": empire_id, - "level": 1, - "exp": 0, - "job": selected_job, - "gender": selected_gender, - "shape": selected_shape, - "cur_hp": 1000, - "max_hp": 1000, - "cur_sp": 500, - "max_sp": 500, - "stats": stats, - "horse_level": 0, - "playtime_min": 0, - "delete_code": "1234567" # 默认 7 位删除安全码 - } - - return { - "success": true, - "character": char_data, - "message": "角色 %s 创建成功!" % name_str - } diff --git a/project/intro_create_phase.gd.uid b/project/intro_create_phase.gd.uid deleted file mode 100644 index b6a38c85..00000000 --- a/project/intro_create_phase.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8ru3phcsedjl diff --git a/project/intro_empire_phase.gd b/project/intro_empire_phase.gd deleted file mode 100644 index c26d310d..00000000 --- a/project/intro_empire_phase.gd +++ /dev/null @@ -1,99 +0,0 @@ -# intro_empire_phase.gd —— 40250 三国阵营地貌 3D 运镜巡览系统 1:1 -# 严格对照: -# Client/Eternexus/root/introempire.py (SelectEmpireWindow) -# ClientVS22/source/UserInterface/PythonApplicationCamera.cpp -# Client/Eternexus/root/uiscript/locale/en/empiredesc_*.txt -extends RefCounted - -const EMPIRE_NONE := 0 -const EMPIRE_SHINSOO := 1 # 赤蛇国 (进国 / 红色) -const EMPIRE_CHUNJO := 2 # 白虎国 (顺天 / 黄色) -const EMPIRE_JINNO := 3 # 苍龙国 (忍国 / 蓝色) - -# 三国官方 3D 摄像机观察预设点位 (Position & Rotation Degrees) -const CAMERA_PRESETS := { - EMPIRE_SHINSOO: { - "cam_pos": Vector3(100.0, 50.0, -200.0), - "cam_rot": Vector3(-15.0, 45.0, 0.0), - "name": "赤蛇国 (Shinsoo)", - "capital": "Yongan", - "color": "#FF3333", - "desc": "Shinsoo 帝国雄踞大陆南部,商贸繁荣昌盛,是商旅与财富的汇聚之地。", - "trait": "商贾通达:全帝国交易税率降低 2%,行商收益提升。" - }, - EMPIRE_CHUNJO: { - "cam_pos": Vector3(-150.0, 60.0, -180.0), - "cam_rot": Vector3(-12.0, 135.0, 0.0), - "name": "白虎国 (Chunjo)", - "capital": "Joan", - "color": "#FFCC00", - "desc": "Chunjo 帝国坐落大陆西部,是古老圣灵信仰的发源地,奉行神圣天道。", - "trait": "神圣庇佑:全帝国子民魔法防御力与精神抗性 +5%。" - }, - EMPIRE_JINNO: { - "cam_pos": Vector3(0.0, 80.0, -250.0), - "cam_rot": Vector3(-18.0, 0.0, 0.0), - "name": "苍龙国 (Jinno)", - "capital": "Pyungmoo", - "color": "#3366FF", - "desc": "Jinno 帝国占据大陆东部,崇尚武道强权,铁血军纪森严,武士骁勇善战。", - "trait": "尚武好战:全帝国子民物理基础攻击力 +5%。" - } -} - -var selected_empire: int = EMPIRE_SHINSOO -var current_cam_pos: Vector3 = Vector3.ZERO -var current_cam_rot: Vector3 = Vector3.ZERO -var target_cam_pos: Vector3 = Vector3.ZERO -var target_cam_rot: Vector3 = Vector3.ZERO -var is_transitioning: bool = false -var transition_t: float = 1.0 - -func _init(initial_empire: int = EMPIRE_SHINSOO) -> void: - select_empire(initial_empire) - current_cam_pos = target_cam_pos - current_cam_rot = target_cam_rot - is_transitioning = false - transition_t = 1.0 - -# 1. 选中帝国阵营 (1:1 SelectEmpireWindow.OnSelectEmpire) -func select_empire(empire_id: int) -> bool: - if not CAMERA_PRESETS.has(empire_id): - return false - - selected_empire = empire_id - var preset: Dictionary = CAMERA_PRESETS[empire_id] - target_cam_pos = preset["cam_pos"] - target_cam_rot = preset["cam_rot"] - is_transitioning = true - transition_t = 0.0 - return true - -# 2. 摄像机平滑插值运镜更新 (1:1 PythonApplicationCamera::Update) -func update_camera_transition(delta_t: float) -> void: - if not is_transitioning: - return - - transition_t = clamp(transition_t + delta_t, 0.0, 1.0) - current_cam_pos = current_cam_pos.lerp(target_cam_pos, transition_t) - current_cam_rot = current_cam_rot.lerp(target_cam_rot, transition_t) - - if transition_t >= 1.0: - is_transitioning = false - current_cam_pos = target_cam_pos - current_cam_rot = target_cam_rot - -# 3. 获取当前帝国的详情数据 -func get_selected_empire_info() -> Dictionary: - return CAMERA_PRESETS.get(selected_empire, {}) - -# 4. 确认抉择帝国阵营并过渡到选角界面 -func confirm_empire_selection() -> Dictionary: - var info := get_selected_empire_info() - return { - "success": true, - "empire_id": selected_empire, - "empire_name": info.get("name", ""), - "capital": info.get("capital", ""), - "next_phase": "CHARACTER_SELECT" - } diff --git a/project/intro_empire_phase.gd.uid b/project/intro_empire_phase.gd.uid deleted file mode 100644 index 31b9f270..00000000 --- a/project/intro_empire_phase.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cv1af574ekhuf diff --git a/project/intro_loading_phase.gd b/project/intro_loading_phase.gd deleted file mode 100644 index 2eb2fdb6..00000000 --- a/project/intro_loading_phase.gd +++ /dev/null @@ -1,97 +0,0 @@ -# intro_loading_phase.gd —— 40250 官方插画过场加载与 Tip 轮播系统 1:1 -# 严格对照: -# Client/Eternexus/root/introloading.py (LoadingWindow) -# Client/Eternexus/root/locale/en/locale_game.txt (LOADING_TIP_*) -extends RefCounted - -# 官方预设背景插画壁纸 -const OFFICIAL_LOADING_WALLPAPERS := [ - "d:/ymir work/ui/intro/loading/loading_01.sub", - "d:/ymir work/ui/intro/loading/loading_02.sub", - "d:/ymir work/ui/intro/loading/loading_03.sub", - "d:/ymir work/ui/intro/loading/loading_04.sub" -] - -# 40250 官方新手锦囊提示语库 (Tip Shower) -const OFFICIAL_LOADING_TIPS := [ - "提示:组队状态下开启平等分配模式,全队均可获得丰厚的经验奖励。", - "提示:强化高等级装备时,使用祈愿卷轴或战神卷轴可有效避免装备碎毁风险。", - "提示:生物学家采吉拉布对奇珍异宝极感兴趣,完成研究任务可获得永久性属性加成。", - "提示:善恶值过低将沦为恶徒,死亡时身上的装备与道具将存在掉落风险。", - "提示:研读大师技能书前使用隐士之药,可将顿悟成功率提升至 100%。", - "提示:使用快捷键 Alt+M 可随时呼出好友面板与同伴展开即时私聊。", - "提示:按键盘 M 键可展开全景大地图,点击 NPC 索引即可激活方位指引光标。" -] - -# 资源加载阶段划分 (1:1 introloading.py: loadStepList) -const STEP_INIT := 0 -const STEP_PROPERTY := 1 # 载入 Property 属性模板 (25%) -const STEP_TERRAIN := 2 # 载入 PRTerrain 地貌与 Zone 建筑 (50%) -const STEP_ENTITIES := 3 # 载入角色怪物实体与 NPC (75%) -const STEP_UI_AUDIO := 4 # 载入界面控件与音频音效 (100%) -const STEP_FINISHED := 5 # 加载完毕,进入视口 - -var current_wallpaper: String = "" -var current_tip: String = "" -var current_step: int = STEP_INIT -var progress_percent: float = 0.0 - -func _init() -> void: - reset_loading_phase() - -func reset_loading_phase() -> void: - current_step = STEP_INIT - progress_percent = 0.0 - current_wallpaper = OFFICIAL_LOADING_WALLPAPERS[0] - current_tip = OFFICIAL_LOADING_TIPS[0] - -# 1. 开启过场加载并随机挑选壁纸与 Tip -func start_loading(wallpaper_index: int = -1, tip_index: int = -1) -> void: - reset_loading_phase() - - if wallpaper_index >= 0 and wallpaper_index < OFFICIAL_LOADING_WALLPAPERS.size(): - current_wallpaper = OFFICIAL_LOADING_WALLPAPERS[wallpaper_index] - else: - current_wallpaper = OFFICIAL_LOADING_WALLPAPERS[randi() % OFFICIAL_LOADING_WALLPAPERS.size()] - - if tip_index >= 0 and tip_index < OFFICIAL_LOADING_TIPS.size(): - current_tip = OFFICIAL_LOADING_TIPS[tip_index] - else: - current_tip = OFFICIAL_LOADING_TIPS[randi() % OFFICIAL_LOADING_TIPS.size()] - -# 2. 推进加载阶段进度 -func advance_loading_step() -> Dictionary: - current_step = mini(current_step + 1, STEP_FINISHED) - match current_step: - STEP_PROPERTY: - progress_percent = 0.25 - STEP_TERRAIN: - progress_percent = 0.50 - STEP_ENTITIES: - progress_percent = 0.75 - STEP_UI_AUDIO, STEP_FINISHED: - progress_percent = 1.0 - - var done: bool = (current_step >= STEP_FINISHED) - return { - "step": current_step, - "progress": progress_percent, - "is_complete": done, - "status_text": _get_step_status_text(current_step) - } - -func _get_step_status_text(step: int) -> String: - match step: - STEP_PROPERTY: - return "正在载入地图环境与属性配置 (25%)..." - STEP_TERRAIN: - return "正在构建 3D 地形与场景建筑 (50%)..." - STEP_ENTITIES: - return "正在同步世界生物与 NPC 实体 (75%)..." - STEP_UI_AUDIO, STEP_FINISHED: - return "正在初始化界面与音效资产 (100%)..." - _: - return "准备进入世界..." - -func is_completed() -> bool: - return current_step >= STEP_FINISHED and progress_percent >= 1.0 diff --git a/project/intro_loading_phase.gd.uid b/project/intro_loading_phase.gd.uid deleted file mode 100644 index d4f48bb8..00000000 --- a/project/intro_loading_phase.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bsq7qc4nkj506 diff --git a/project/intro_login_phase.gd b/project/intro_login_phase.gd deleted file mode 100644 index bd25c9a8..00000000 --- a/project/intro_login_phase.gd +++ /dev/null @@ -1,124 +0,0 @@ -# intro_login_phase.gd —— 40250 官方登录、频道探测与凭据存储系统 1:1 -# 严格对照: -# Client/Eternexus/root/intrologin.py (LoginWindow, ConnectingDialog) -# ClientVS22/source/UserInterface/ServerStateChecker.cpp, ServerStateChecker.h -# ClientVS22/source/UserInterface/AccountConnector.cpp -extends RefCounted - -# 官方频道负载状态 (ServerStateChecker) -const STATE_OFFLINE := 0 # 维护中 / 离线 -const STATE_NORMAL := 1 # 畅通 (绿色) -const STATE_BUSY := 2 # 繁忙 (黄色) -const STATE_FULL := 3 # 满载爆满 (红色) - -const CHANNEL_NAMES := ["CH1 (标准一区)", "CH2 (标准二区)", "CH3 (战备三区)", "CH4 (自由四区)"] - -# 模拟预设频道列表 (端口与负载) -var channels: Array = [ - { "id": 1, "name": "CH1", "port": 13001, "state": STATE_NORMAL }, - { "id": 2, "name": "CH2", "port": 13002, "state": STATE_NORMAL }, - { "id": 3, "name": "CH3", "port": 13003, "state": STATE_BUSY }, - { "id": 4, "name": "CH4", "port": 13004, "state": STATE_NORMAL }, -] - -# 模拟注册账户数据库 (用于离线单机与离线校验) -var registered_accounts: Dictionary = { - "admin": { "password": "password", "is_blocked": false, "has_characters": true }, - "player": { "password": "123", "is_blocked": false, "has_characters": true }, - "banned": { "password": "123", "is_blocked": true, "has_characters": false }, -} - -var selected_channel_id: int = 1 -var saved_account_name: String = "" -var is_remember_account: bool = false -var is_connecting: bool = false - -func _init() -> void: - selected_channel_id = 1 - saved_account_name = "" - is_remember_account = false - is_connecting = false - -# 1. 选择服务器频道 (1:1 LoginWindow.__SelectChannel) -func select_channel(channel_id: int) -> bool: - for ch in channels: - if ch["id"] == channel_id: - selected_channel_id = channel_id - return true - return false - -func get_channel_info(channel_id: int) -> Dictionary: - for ch in channels: - if ch["id"] == channel_id: - return ch - return {} - -# 2. 刷新分线状态探测 (ServerStateChecker.Request) -func update_channel_state(channel_id: int, new_state: int) -> void: - for ch in channels: - if ch["id"] == channel_id: - ch["state"] = clamp(new_state, STATE_OFFLINE, STATE_FULL) - break - -# 3. 记住账号复选框控制与持久化 (intrologin.py) -func set_remember_account(remember: bool, username: String = "") -> void: - is_remember_account = remember - if remember and not username.is_empty(): - saved_account_name = username - else: - saved_account_name = "" - -# 4. 账号登录认证 (1:1 AccountConnector::Connect & SendLoginPacket) -func attempt_login(account: String, password_input: String) -> Dictionary: - var res := { - "success": false, - "account": account, - "channel_id": selected_channel_id, - "has_characters": false, - "error_code": "OK", - "message": "" - } - - # 检查目标频道是否离线维护 - var cur_ch = get_channel_info(selected_channel_id) - if cur_ch.get("state", STATE_OFFLINE) == STATE_OFFLINE: - res["error_code"] = "CHANNEL_OFFLINE" - res["message"] = "该频道服务器维护中,请选择其他频道。" - return res - - # 凭据格式检查 - if account.is_empty() or password_input.is_empty(): - res["error_code"] = "EMPTY_CREDENTIALS" - res["message"] = "请输入账号与密码。" - return res - - if account.length() < 3 or account.length() > 16: - res["error_code"] = "INVALID_ACCOUNT_LEN" - res["message"] = "账号长度需在 3 到 16 字符之间。" - return res - - # 账号库匹配 - if not registered_accounts.has(account): - res["error_code"] = "ACCOUNT_NOT_EXIST" - res["message"] = "账号不存在,请检查后重试。" - return res - - var acc_info: Dictionary = registered_accounts[account] - if acc_info.get("is_blocked", false): - res["error_code"] = "ACCOUNT_BLOCKED" - res["message"] = "该账号已被系统封禁,无法登录。" - return res - - if acc_info.get("password") != password_input: - res["error_code"] = "WRONG_PASSWORD" - res["message"] = "密码错误,请核对密码。" - return res - - # 登录成功 - if is_remember_account: - saved_account_name = account - - res["success"] = true - res["has_characters"] = acc_info.get("has_characters", false) - res["message"] = "登录成功,正在进入游戏大厅..." - return res diff --git a/project/intro_login_phase.gd.uid b/project/intro_login_phase.gd.uid deleted file mode 100644 index 122f76d6..00000000 --- a/project/intro_login_phase.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://rhrwgs2y5dhi diff --git a/project/intro_select_phase.gd b/project/intro_select_phase.gd deleted file mode 100644 index deb42860..00000000 --- a/project/intro_select_phase.gd +++ /dev/null @@ -1,130 +0,0 @@ -# intro_select_phase.gd —— 40250 3D 角色选择大厅系统 1:1 -# 严格对照: -# Client/Eternexus/root/introselect.py (SelectCharacterWindow) -# ClientVS22/source/UserInterface/InstanceBaseMotion.cpp (MOTION_INTRO_SELECTED) -# metin2/src/server/game/src/char.cpp -extends RefCounted - -const SLOT_COUNT := 4 # 官方 4 槽位 -const SLOT_ROTATION := [135.0, 225.0, 315.0, 45.0] # 官方 4 槽位旋转视角 (度数) - -const JOB_WARRIOR := 0 -const JOB_ASSASSIN := 1 -const JOB_SURA := 2 -const JOB_SHAMAN := 3 - -const JOB_NAMES := { - JOB_WARRIOR: "战士 (Warrior)", - JOB_ASSASSIN: "刺客 (Assassin)", - JOB_SURA: "修罗 (Sura)", - JOB_SHAMAN: "萨满 (Shaman)", -} - -# 4 个角色槽位存储 (slot 0..3 -> CharacterData or null) -var character_slots: Array = [null, null, null, null] -var selected_slot_index: int = 0 -var is_character_stepped_forward: bool = false - -func _init() -> void: - character_slots = [null, null, null, null] - selected_slot_index = 0 - is_character_stepped_forward = false - -# 1. 注册/载入角色槽位数据 -func set_character_slot(slot_idx: int, char_data: Dictionary) -> bool: - if slot_idx < 0 or slot_idx >= SLOT_COUNT: - return false - character_slots[slot_idx] = char_data.duplicate(true) - return true - -# 2. 选中角色槽位与踏步迈出动画 (1:1 SelectCharacterWindow.SelectSlot) -func select_slot(slot_idx: int) -> Dictionary: - var res := { - "success": false, - "slot_idx": slot_idx, - "is_empty_slot": true, - "character": null, - "rotation_deg": 0.0, - "action": "" - } - - if slot_idx < 0 or slot_idx >= SLOT_COUNT: - return res - - selected_slot_index = slot_idx - res["rotation_deg"] = SLOT_ROTATION[slot_idx] - - if character_slots[slot_idx] == null: - is_character_stepped_forward = false - res["is_empty_slot"] = true - res["action"] = "PROMPT_CREATE" - else: - is_character_stepped_forward = true # 触发 MOTION_INTRO_SELECTED 迈步出列动画 - res["is_empty_slot"] = false - res["character"] = character_slots[slot_idx] - res["action"] = "READY_TO_START" - - res["success"] = true - return res - -# 3. 开始游戏进入世界 (1:1 SelectCharacterWindow.StartGame) -func start_game() -> Dictionary: - var res := { "success": false, "character": null, "error_code": "OK", "message": "" } - - if character_slots[selected_slot_index] == null: - res["error_code"] = "EMPTY_SLOT" - res["message"] = "当前选中槽位为空,无法开始游戏。" - return res - - res["success"] = true - res["character"] = character_slots[selected_slot_index] - res["message"] = "进入游戏世界..." - return res - -# 4. 删除角色与 7 位安全防删 PIN 码校验 (1:1 SelectCharacterWindow.DeleteCharacter) -func delete_character(slot_idx: int, delete_pin_code: String) -> Dictionary: - var res := { "success": false, "error_code": "OK", "message": "" } - - if slot_idx < 0 or slot_idx >= SLOT_COUNT or character_slots[slot_idx] == null: - res["error_code"] = "CHARACTER_NOT_FOUND" - res["message"] = "未找到待删除的角色。" - return res - - # 官方安全码格式验证 (必须为 7 位字符) - if delete_pin_code.length() != 7: - res["error_code"] = "INVALID_PIN_LENGTH" - res["message"] = "防误删安全码必须为 7 位数字。" - return res - - var target_char: Dictionary = character_slots[slot_idx] - var expected_pin: String = str(target_char.get("delete_code", "1234567")) - - if delete_pin_code != expected_pin: - res["error_code"] = "WRONG_DELETE_PIN" - res["message"] = "安全码校验失败,删除角色请求被拒绝。" - return res - - # 删除成功 - var char_name: String = target_char.get("name", "Unknown") - character_slots[slot_idx] = null - if selected_slot_index == slot_idx: - is_character_stepped_forward = false - - res["success"] = true - res["message"] = "角色 %s 已永久删除。" % char_name - return res - -# 5. 获取当前选中槽位的角色卡片详情 -func get_selected_character_card() -> Dictionary: - if character_slots[selected_slot_index] == null: - return {} - var c: Dictionary = character_slots[selected_slot_index] - var job_idx: int = c.get("job", 0) - return { - "name": c.get("name", ""), - "level": c.get("level", 1), - "job_name": JOB_NAMES.get(job_idx, "未知职业"), - "horse_level": c.get("horse_level", 0), - "playtime_min": c.get("playtime_min", 0), - "stats": c.get("stats", { "con": 4, "int": 3, "str": 6, "dex": 3 }) - } diff --git a/project/intro_select_phase.gd.uid b/project/intro_select_phase.gd.uid deleted file mode 100644 index db5ea7e9..00000000 --- a/project/intro_select_phase.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bm5t5s5eu20ww diff --git a/project/inventory_test.gd b/project/inventory_test.gd deleted file mode 100644 index f95d4fa4..00000000 --- a/project/inventory_test.gd +++ /dev/null @@ -1,74 +0,0 @@ -# inventory_test — 扩展装备位和腰带背包的协议坐标 / 锁格规则。 -extends SceneTree - -const InventoryUI = preload("res://ui/inventory_ui.gd") - -class FakeProto extends Node: - func item(_vnum: int) -> Dictionary: - return {"values": [4]} - -class FakeClient extends Node: - var item_ops := [] - func use_item_to_item(sw: int, sc: int, tw: int, tc: int) -> bool: - item_ops.append([sw, sc, tw, tc]) - return true - -var failed := 0 - -func _check(ok: bool, message: String) -> void: - if not ok: - failed += 1 - printerr("FAIL: " + message) - -func _init() -> void: - var inv := InventoryUI.new() - _check(inv._to_wire(0) == [1, 0], "normal inventory uses INVENTORY cell") - # 旧 11 格 wear(BODY..SHIELD)走 EQUIPMENT 窗(window=2, 零基 wear cell)。 - _check(inv._to_wire(90) == [2, 0], "wear body uses EQUIPMENT window cell 0") - _check(inv._to_wire(100) == [2, 10], "wear shield uses EQUIPMENT window cell 10") - # ENABLE_NEW_EQUIPMENT_SYSTEM 的 costume/ring/belt 留在 INVENTORY 窗全局 cell(GameType.h)。 - _check(inv._to_wire(109) == [1, 109], "costume body uses legacy global cell 109") - _check(inv._to_wire(110) == [1, 110], "costume hair uses legacy global cell 110") - _check(inv._to_wire(111) == [1, 111], "ring1 uses legacy global cell 111") - _check(inv._to_wire(112) == [1, 112], "ring2 uses legacy global cell 112") - _check(inv._to_wire(113) == [1, 113], "wear belt uses legacy global cell 113") - _check(inv._to_wire(200) == [1, 152], "belt UI cell 0 uses global cell 152") - _check(inv._to_wire(215) == [1, 167], "belt UI cell 15 uses global cell 167") - - inv.proto = FakeProto.new() - inv.add_child(inv.proto) - for i in 16: - var cell := Panel.new() - inv.add_child(cell) - inv._cells[200 + i] = cell - var eq := [] - for _i in 24: - eq.append({"vnum": 0, "count": 0}) - eq[23] = {"vnum": 12345, "count": 1} - inv._apply_belt_locks(eq) - _check(not bool(inv._cells[200].get_meta("locked", true)), "grade 4 unlocks belt cell 0") - _check(not bool(inv._cells[202].get_meta("locked", true)), "grade 4 unlocks belt cell 2") - _check(bool(inv._cells[203].get_meta("locked", false)), "grade 4 locks belt cell 3 (needs 6)") - _check(not bool(inv._cells[206].get_meta("locked", true)), "grade 4 unlocks belt cell 6") - _check(bool(inv._cells[207].get_meta("locked", false)), "grade 4 locks belt cell 7 (needs 6)") - var fake_client := FakeClient.new() - inv.add_child(fake_client) - inv.client = fake_client - for index in [0, 1]: - var item_cell := Panel.new() - item_cell.set_meta("vnum", 100 + index) - item_cell.set_meta("count", 2) - inv.add_child(item_cell) - inv._cells[index] = item_cell - _check(inv._use_to_item(0, 1), "item-to-item intent accepted") - _check(fake_client.item_ops == [[1, 0, 1, 1]], "item-to-item uses original inventory positions") - inv._begin_combine(0) - _check(inv._combine_from == 0, "shift selects combine source") - inv._begin_combine(0) - _check(inv._combine_from == -1, "shift same source cancels combine") - inv.free() - if failed == 0: - print("PASS: inventory_test (extended equipment + belt mapping)") - quit(0) - else: - quit(1) diff --git a/project/inventory_test.gd.uid b/project/inventory_test.gd.uid deleted file mode 100644 index 796a09d9..00000000 --- a/project/inventory_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://76o26fdux541 diff --git a/project/inventory_ui_test.gd b/project/inventory_ui_test.gd deleted file mode 100644 index 39361724..00000000 --- a/project/inventory_ui_test.gd +++ /dev/null @@ -1,131 +0,0 @@ -# inventory_ui_test —— P2 背包/装备窗 headless 自检(假 client + 假 proto + 真 uiscript)。 -# godot --headless --path project --script inventory_ui_test.gd -extends SceneTree - -const UiManager = preload("res://ui/ui_manager.gd") -const InventoryUI = preload("res://ui/inventory_ui.gd") - -class FakeClient extends Node: - signal inventory_changed(window: int, cell: int) - signal item_highlighted(window: int, cell: int) - var inv := {} # cell -> {vnum,count} - var equip := [] # 11 x {vnum,count} - var used := [] - var moved := [] - func _init(): - for i in 11: equip.append({"vnum": 0, "count": 0, "wear": i}) - func get_inventory() -> Array: - var out := [] - for c in inv: out.append({"cell": c, "vnum": inv[c].vnum, "count": inv[c].count}) - return out - func get_equipment() -> Array: return equip - func use_item(w, c) -> bool: used.append([w, c]); return true - func move_item(fw, fc, tw, tc, n) -> bool: moved.append([fw, fc, tw, tc, n]); return true - func set_inv(cell, vnum, count): - inv[cell] = {"vnum": vnum, "count": count} - inventory_changed.emit(1, cell) - -class FakeProto extends Node: - func item(vnum: int) -> Dictionary: - # type 1 = WEAPON;给出可穿戴位 + 空限制,让 EquipRules 前置门放行。 - return {"vnum": vnum, "name": "Item%d" % vnum, "locale_name": "物品%d" % vnum, - "type": 1, "sub_type": 0, "anti_flags": 0, "wear_flags": 1 << 4, "limits": []} - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: inventory_ui_test (window + fill + refresh + use + move)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var assets := AssetRoot.path() - if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/inventorywindow.py")): - print(" (skip: no inventorywindow.py — assets checkout missing)") - return - - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - var fc := FakeClient.new() - var fp := FakeProto.new() - get_root().add_child(fc) - get_root().add_child(fp) - fc.inv[5] = {"vnum": 19, "count": 1} - fc.inv[6] = {"vnum": 27, "count": 3} - fc.equip[1] = {"vnum": 11209, "count": 1, "wear": 1} - - var iv: Node = InventoryUI.new() - var mouse := preload("res://ui/mouse_controller.gd").new() - get_root().add_child(mouse) - mouse.setup(ui) - iv.item_mouse = mouse - get_root().add_child(iv) - iv.setup(ui, fc, fp, assets) - iv.open() - _ck(iv.is_open(), "inventory window opened") - # 45 背包格 + 12 装备格(equipment 元组) - _ck(iv._cells.size() >= 45, "at least 45 inventory cells indexed (%d)" % iv._cells.size()) - _ck(iv._cells.has(90), "equipment cell 90 present") - - # 填充 - var c5: Panel = iv._cells[5] - _ck(int(c5.get_meta("vnum", 0)) == 19, "cell 5 filled vnum 19") - _ck(c5.tooltip_text.is_empty(), "inventory item has no native duplicate tooltip panel") - _ck(String(c5.get_meta("tooltip_body", "")).contains("物品19"), - "inventory item keeps tooltip body for the single custom panel/mobile detail") - var c6: Panel = iv._cells[6] - _ck(c6.has_node("count") and (c6.get_node("count") as Label).text == "3", "cell 6 shows count 3") - _ck(int(iv._cells[91].get_meta("vnum", 0)) == 11209, "equip wear1 -> ui cell 91 filled") - - # inventory_changed 刷新 - fc.set_inv(8, 100, 1) - await process_frame - _ck(int(iv._cells[8].get_meta("vnum", 0)) == 100, "cell 8 filled after inventory_changed") - fc.item_highlighted.emit(1, 5) - await process_frame - _ck(bool(c5.get_meta("highlighted", false)), "GC_ITEM_SET2 highlight reaches the matching UI slot") - - # 右键用 - iv.use(5) - _ck(fc.used.size() == 1 and fc.used[0] == [1, 5], "use(5) -> use_item(1,5)") - # 拖 5 -> 91(背包 -> 装备) - iv.move_to(5, 91, 1) - _ck(fc.moved.size() == 1 and fc.moved[0] == [1, 5, 2, 1, 1], "move_to(5,91) -> move_item(1,5,2,1,1)") - _ck(iv._drop_mouse_item({"window": 1, "cell": 6, "vnum": 27, "count": 3}, 8), - "mouse drop accepts inventory stack") - _ck(fc.moved.size() == 2 and fc.moved[1] == [1, 6, 1, 8, 3], - "mouse drop preserves attached stack count") - - iv.close() - _ck(not iv.is_open(), "window closed") - - # 移动端长按详情与数量输入:不依赖右键、悬停或 PC SpinBox 微小箭头。 - ui.set_mobile_mode(true) - iv.set_mobile_mode(true) - iv.open() - var long_down := InputEventScreenTouch.new() - long_down.index = 31 - long_down.pressed = true - long_down.position = Vector2(20, 20) - iv._on_cell_input(6, long_down) - await create_timer(0.65).timeout - _ck(iv._mobile_detail_dialog != null, "mobile long-press detail opens") - _ck(ui.top() == iv._mobile_detail_dialog, "detail is a modal window above inventory") - ui.close_top() - _ck(iv._mobile_detail_dialog == null, "mobile detail closes with back") - iv._ask_count("丢弃数量", 3, func(_amount): pass) - var count_dialog: Control = ui.top() - _ck(count_dialog != null and count_dialog.get_node_or_null("CountInput") is LineEdit, - "mobile count dialog uses numeric LineEdit") - ui.close_top() - iv.close() - ui.queue_free() diff --git a/project/inventory_ui_test.gd.uid b/project/inventory_ui_test.gd.uid deleted file mode 100644 index ff46f2a5..00000000 --- a/project/inventory_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ce8ey8voa2h7l diff --git a/project/item_action_sound_parity_test.gd b/project/item_action_sound_parity_test.gd deleted file mode 100644 index 23b8900a..00000000 --- a/project/item_action_sound_parity_test.gd +++ /dev/null @@ -1,57 +0,0 @@ -# item_action_sound_parity_test —— 40250 item use/move 音效事件基线。 -extends SceneTree - -var _fail := 0 - -class FakeProto extends RefCounted: - func item_count() -> int: - return 8 - - func item(vnum: int) -> Dictionary: - match vnum: - 1: return {"type": 1, "sub_type": 0} # sword - 2: return {"type": 1, "sub_type": 2} # bow - 3: return {"type": 1, "sub_type": 6} # arrow - 4: return {"type": 2, "sub_type": 0} # body armor - 5: return {"type": 2, "sub_type": 5} # necklace - 6: return {"type": 3, "sub_type": 7} # ability-up potion - 7: return {"type": 3, "sub_type": 0} # regular potion: no use sound - 8: return {"type": 3, "sub_type": 1} # talisman - return {} - -func _ck(condition: bool, message: String) -> void: - if not condition: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - var client := M2Client.new() - var proto := FakeProto.new() - client.set_item_proto(proto) - _ck(client.has_signal("item_action_sound"), - "native item sender exposes the item_action_sound event") - _ck(client.has_method("item_action_sound_for_item"), - "native item sender exposes the item_proto sound classifier") - _ck(client.item_action_sound_for_item(1, false) == "equip_metal_weapon.wav", - "weapon use selects equip_metal_weapon.wav") - _ck(client.item_action_sound_for_item(2, false) == "equip_bow.wav" and - client.item_action_sound_for_item(2, true) == "equip_bow.wav", - "bow use/drop selects equip_bow.wav") - _ck(client.item_action_sound_for_item(3, false) == "drop.wav", - "arrow use selects the default drop.wav") - _ck(client.item_action_sound_for_item(4, false) == "equip_metal_armor.wav" and - client.item_action_sound_for_item(5, true) == "equip_ring_amulet.wav", - "body/accessory armor selects the reference sounds") - _ck(client.item_action_sound_for_item(6, false) == "eat_potion.wav" and - client.item_action_sound_for_item(8, false) == "potal_scroll.wav", - "ability-up/talisman selects potion/portal sounds") - _ck(client.item_action_sound_for_item(7, false).is_empty(), - "regular USE_POTION has no CPythonItem use sound") - _ck(client.item_action_sound_for_item(999, false).is_empty(), - "unknown vnum does not emit an item sound") - if _fail == 0: - print("PASS: item_action_sound_parity_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/item_action_sound_parity_test.gd.uid b/project/item_action_sound_parity_test.gd.uid deleted file mode 100644 index 24460d2c..00000000 --- a/project/item_action_sound_parity_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cbnsltfq0328l diff --git a/project/item_attr_system.gd b/project/item_attr_system.gd deleted file mode 100644 index 5677422d..00000000 --- a/project/item_attr_system.gd +++ /dev/null @@ -1,231 +0,0 @@ -# item_attr_system.gd —— 装备附魔与属性洗炼系统(40250 官方 1:1 对齐) -# 对齐源码: -# - 40250 char_item.cpp:4658-4720 (USE_ADD_ATTRIBUTE, Vnum 71085) -# - 40250 char_item.cpp:4553-4657 (USE_CHANGE_ATTRIBUTE, Vnum 71084) -# - 40250 char_item.cpp:4728-4780 (USE_ADD_ATTRIBUTE2 / 祝福宝珠, Vnum 70024) -# - 40250 constants.cpp:932 (aiItemAttributeAddPercent = [100, 80, 60, 50, 30, 0, 0]) -# - 40250 item_attribute.cpp (PutAttributeWithLevel, HasAttr, GetAttributeSetIndex) -class_name ItemAttrSystem -extends RefCounted - -# 核心 Vnum 常量(40250 官方标准) -const VNUM_ATTR_ADD := 71085 # 物品属性添加秘籍 (USE_ADD_ATTRIBUTE, 添加第 1~4 条) -const VNUM_ATTR_CHANGE := 71084 # 物品属性转换秘籍 (USE_CHANGE_ATTRIBUTE, 重置现有全部属性) -const VNUM_BLESSING_MARBLE := 70024 # 祝福宝珠 / 第五属性石 (USE_ADD_ATTRIBUTE2, 添加第 5 条) - -# 40250 constants.cpp:932 官方附魔成功率表(前 4 条及第 5 条宝珠) -# 索引:现有词条数 -> 成功概率百分比 -const ADD_PERCENT_TABLE := [100, 80, 60, 50, 30] - -# 装备类型大类(ItemData.h) -const ITEM_TYPE_WEAPON := 1 -const ITEM_TYPE_ARMOR := 2 - -# 属性池配置:针对武器与防具的标准 40250 词条池与数值范围(5 档位) -# 每个 apply_type: { name, values: [lv1, lv2, lv3, lv4, lv5], prob_weight } -const WEAPON_ATTR_POOL := { - 1: {"name": "最大生命", "values": [500, 1000, 1500, 2000, 2500], "weight": 10}, - 2: {"name": "最大法力", "values": [100, 200, 300, 500, 800], "weight": 10}, - 3: {"name": "体质", "values": [2, 4, 6, 8, 12], "weight": 8}, - 4: {"name": "智力", "values": [2, 4, 6, 8, 12], "weight": 8}, - 5: {"name": "力量", "values": [2, 4, 6, 8, 12], "weight": 8}, - 6: {"name": "敏捷", "values": [2, 4, 6, 8, 12], "weight": 8}, - 7: {"name": "攻击速度", "values": [2, 4, 6, 8, 10], "weight": 8}, - 9: {"name": "施法速度", "values": [4, 8, 12, 16, 20], "weight": 8}, - 13: {"name": "眩晕概率", "values": [2, 3, 5, 8, 10], "weight": 6}, - 14: {"name": "缓慢概率", "values": [2, 3, 5, 8, 10], "weight": 6}, - 15: {"name": "暴击概率", "values": [2, 4, 6, 8, 10], "weight": 7}, - 16: {"name": "穿透概率", "values": [2, 4, 6, 8, 10], "weight": 7}, - 17: {"name": "对人族伤害", "values": [2, 4, 6, 8, 10], "weight": 6}, - 19: {"name": "对兽人伤害", "values": [4, 6, 10, 14, 20], "weight": 8}, - 21: {"name": "对不死族伤害", "values": [4, 6, 10, 14, 20], "weight": 8}, - 22: {"name": "对恶魔伤害", "values": [4, 6, 10, 14, 20], "weight": 8}, - 53: {"name": "攻击等级", "values": [10, 20, 30, 40, 50], "weight": 7}, -} - -const ARMOR_ATTR_POOL := { - 1: {"name": "最大生命", "values": [500, 1000, 1500, 2000, 2500], "weight": 12}, - 2: {"name": "最大法力", "values": [100, 200, 300, 500, 800], "weight": 10}, - 7: {"name": "攻击速度", "values": [2, 4, 6, 8, 10], "weight": 8}, - 8: {"name": "移动速度", "values": [2, 4, 6, 8, 10], "weight": 8}, - 9: {"name": "施法速度", "values": [4, 8, 12, 16, 20], "weight": 8}, - 23: {"name": "生命吸收", "values": [2, 3, 5, 8, 10], "weight": 7}, - 24: {"name": "法力吸收", "values": [2, 3, 5, 8, 10], "weight": 7}, - 27: {"name": "格挡", "values": [2, 4, 6, 8, 10], "weight": 7}, - 29: {"name": "剑防御", "values": [4, 6, 10, 12, 15], "weight": 6}, - 30: {"name": "双手防御", "values": [4, 6, 10, 12, 15], "weight": 6}, - 31: {"name": "匕首防御", "values": [4, 6, 10, 12, 15], "weight": 6}, - 34: {"name": "弓防御", "values": [4, 6, 10, 12, 15], "weight": 6}, - 35: {"name": "火抗性", "values": [4, 6, 10, 12, 15], "weight": 7}, - 37: {"name": "魔法抗性", "values": [4, 6, 10, 12, 15], "weight": 7}, - 38: {"name": "风抗性", "values": [4, 6, 10, 12, 15], "weight": 7}, - 54: {"name": "防御等级", "values": [10, 15, 20, 30, 40], "weight": 8}, -} - -## 判定物品是否是附魔相关卷轴 -static func is_attr_scroll(vnum: int) -> bool: - return vnum in [VNUM_ATTR_ADD, VNUM_ATTR_CHANGE, VNUM_BLESSING_MARBLE] - -## 判定目标物品是否可附魔/洗炼(武器或防具装备,对应 40250 GetAttributeSetIndex != -1) -static func can_have_attributes(target_proto: Dictionary) -> bool: - var item_type := int(target_proto.get("type", 0)) - if item_type == ITEM_TYPE_WEAPON: - var sub_type := int(target_proto.get("sub_type", 0)) - return sub_type != 5 # 箭矢 (WEAPON_ARROW) 不可附魔 - elif item_type == ITEM_TYPE_ARMOR: - return true - return false - -## 获取目标装备对应的属性池 -static func get_attr_pool(target_proto: Dictionary) -> Dictionary: - var item_type := int(target_proto.get("type", 0)) - if item_type == ITEM_TYPE_WEAPON: - return WEAPON_ATTR_POOL - return ARMOR_ATTR_POOL - -## 执行附魔添加属性(USE_ADD_ATTRIBUTE 71085) -## 40250 原生逻辑:词条数 < 4;根据已有条数判定成功率;成功则随机一条不重复属性与档次 -static func apply_add_attribute(target_item: Dictionary, target_proto: Dictionary, force_success := false) -> Dictionary: - if not can_have_attributes(target_proto): - return {"ok": false, "code": "CANNOT_ATTACH_ATTR", "msg": "该物品无法添加属性。"} - - var attrs: Array = target_item.get("attrs", []).duplicate(true) - if attrs.size() >= 4: - return {"ok": false, "code": "MAX_ATTR_REACHED", "msg": "此物品已有 4 条或更多属性,无法再添加基础属性。"} - - var cur_count := attrs.size() - var rate: int = int(ADD_PERCENT_TABLE[cur_count]) if cur_count < ADD_PERCENT_TABLE.size() else 0 - var roll := 0 if force_success else (randi() % 100 + 1) - - if not force_success and roll > rate: - return { - "ok": false, - "code": "ADD_ATTR_FAIL", - "msg": "属性添加失败。", - "consumed": true, - } - - var pool := get_attr_pool(target_proto) - var existing_types := {} - for a in attrs: - existing_types[int(a.get("type", 0))] = true - - var avail_keys: Array[int] = [] - for k in pool.keys(): - if not existing_types.has(int(k)): - avail_keys.append(int(k)) - - if avail_keys.is_empty(): - return {"ok": false, "code": "NO_AVAILABLE_ATTR", "msg": "无可添加的属性。"} - - # 按权重随机选取属性 - var chosen_type: int = _pick_weighted_type(avail_keys, pool) - var grade := randi() % 5 # 0..4 (对应 1~5 档) - var value: int = int(pool[chosen_type]["values"][grade]) - - attrs.append({"type": chosen_type, "value": value}) - target_item["attrs"] = attrs - - return { - "ok": true, - "code": "SUCCESS", - "msg": "属性添加成功!", - "consumed": true, - "added_attr": {"type": chosen_type, "value": value, "name": pool[chosen_type]["name"]}, - } - -## 执行洗炼重置全部属性(USE_CHANGE_ATTRIBUTE 71084) -## 40250 原生逻辑:词条数 > 0;保留现有词条总数,全部重新随机生成类型与数值 -static func apply_change_attribute(target_item: Dictionary, target_proto: Dictionary) -> Dictionary: - if not can_have_attributes(target_proto): - return {"ok": false, "code": "CANNOT_ATTACH_ATTR", "msg": "该物品无法重置属性。"} - - var attrs: Array = target_item.get("attrs", []).duplicate(true) - var count := attrs.size() - if count == 0: - return {"ok": false, "code": "NO_ATTRIBUTES", "msg": "此物品没有任何额外属性,无法洗炼。"} - - var pool := get_attr_pool(target_proto) - var new_attrs: Array = [] - var used_types := {} - - for _i in count: - var avail_keys: Array[int] = [] - for k in pool.keys(): - if not used_types.has(int(k)): - avail_keys.append(int(k)) - if avail_keys.is_empty(): - break - var chosen_type: int = _pick_weighted_type(avail_keys, pool) - used_types[chosen_type] = true - var grade := randi() % 5 - var val: int = int(pool[chosen_type]["values"][grade]) - new_attrs.append({"type": chosen_type, "value": val}) - - target_item["attrs"] = new_attrs - return { - "ok": true, - "code": "SUCCESS", - "msg": "属性重置成功!", - "consumed": true, - "attrs": new_attrs, - } - -## 执行第五属性宝珠(USE_ADD_ATTRIBUTE2 / 祝福宝珠 70024) -## 40250 原生逻辑:严格要求现有词条数 == 4;成功率 30%(40250 ADD_PERCENT_TABLE[4]) -static func apply_blessing_marble(target_item: Dictionary, target_proto: Dictionary, force_success := false) -> Dictionary: - if not can_have_attributes(target_proto): - return {"ok": false, "code": "CANNOT_ATTACH_ATTR", "msg": "该物品无法添加第五属性。"} - - var attrs: Array = target_item.get("attrs", []).duplicate(true) - if attrs.size() < 4: - return {"ok": false, "code": "NEED_FOUR_ATTRS", "msg": "装备必须先拥有 4 条属性,才能使用祝福宝珠添加第 5 条属性。"} - if attrs.size() >= 5: - return {"ok": false, "code": "ALREADY_FIVE_ATTRS", "msg": "该物品已达 5 条属性上限。"} - - var rate: int = int(ADD_PERCENT_TABLE[4]) # 30% - var roll := 0 if force_success else (randi() % 100 + 1) - if not force_success and roll > rate: - return { - "ok": false, - "code": "MARBLE_FAIL", - "msg": "祝福宝珠开启第 5 条属性失败。", - "consumed": true, - } - - var pool := get_attr_pool(target_proto) - var existing_types := {} - for a in attrs: - existing_types[int(a.get("type", 0))] = true - - var avail_keys: Array[int] = [] - for k in pool.keys(): - if not existing_types.has(int(k)): - avail_keys.append(int(k)) - - var chosen_type: int = _pick_weighted_type(avail_keys, pool) - var grade := randi() % 5 - var val: int = int(pool[chosen_type]["values"][grade]) - - attrs.append({"type": chosen_type, "value": val}) - target_item["attrs"] = attrs - - return { - "ok": true, - "code": "SUCCESS", - "msg": "祝福宝珠成功开启第 5 条属性!", - "consumed": true, - "added_attr": {"type": chosen_type, "value": val, "name": pool[chosen_type]["name"]}, - } - -static func _pick_weighted_type(avail_keys: Array[int], pool: Dictionary) -> int: - var total_weight := 0 - for k in avail_keys: - total_weight += int(pool[k].get("weight", 1)) - var r := randi() % maxi(1, total_weight) - var accum := 0 - for k in avail_keys: - accum += int(pool[k].get("weight", 1)) - if r < accum: - return k - return avail_keys[0] diff --git a/project/item_attr_system.gd.uid b/project/item_attr_system.gd.uid deleted file mode 100644 index bcada041..00000000 --- a/project/item_attr_system.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://coqdb85vn201c diff --git a/project/item_tooltip_test.gd b/project/item_tooltip_test.gd deleted file mode 100644 index e14746af..00000000 --- a/project/item_tooltip_test.gd +++ /dev/null @@ -1,69 +0,0 @@ -# item_tooltip_test —— item_proto 固定字段 + itemdesc + 实例 socket/attribute 的回归。 -extends SceneTree - -const ItemTooltip = preload("res://ui/item_tooltip.gd") - -class FakeMobProto extends RefCounted: - func mob(vnum: int) -> Dictionary: - return {"vnum": vnum, "name": "Wolf", "locale_name": "Wolf"} - -var _fail := 0 - -func _ck(condition: bool, message: String) -> void: - if not condition: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - var tooltip := ItemTooltip.new() - var assets := AssetRoot.path() - _ck(tooltip.setup(assets, "en"), "itemdesc en loaded") - var proto := { - "vnum": 71114, - "name": "Wild Boar Seal", - "locale_name": "Wild Boar Seal", - "type": 2, - "values": [12, 30, 0, 0, 0, 2], - "limits": [{"type": 1, "value": 15}, {"type": 0, "value": 0}], - "applies": [{"type": 1, "value": 100}, {"type": 0, "value": 0}, {"type": 0, "value": 0}], - "refined_vnum": 71115, - "refine_set": 42, - } - var instance := { - "count": 3, - "sockets": [28030, 0, -1], - "attrs": [{"type": 5, "value": 7}], - } - var text := tooltip.format(71114, 3, proto, instance) - _ck(text.begins_with("Wild Boar Seal\n数量:3"), "title and stack count") - _ck(text.contains("This seal's magic"), "itemdesc description included") - _ck(text.contains("等级要求:15"), "limit included") - _ck(text.contains("防御力:34"), "armor value formula included") - _ck(text.contains("最大生命:+100"), "proto apply included") - _ck(text.contains("附加力量:+7"), "instance attribute included") - _ck(text.contains("孔 1:#28030") and text.contains("孔 3:损坏"), "instance sockets included") - _ck(text.contains("精炼后:#71115") and text.contains("精炼套件:42"), "refine links included") - var rod_proto := {"locale_name": "Fishing Rod", "type": 13, "values": [30, 0, 120]} - var rod_text := tooltip.format(20000, 1, rod_proto, {"sockets": [120, 0, 0]}) - _ck(rod_text.contains("等级:3") and rod_text.contains("经验:120 / 120") and rod_text.contains("可升级"), - "rod level and experience included") - var fish_text := tooltip.format(27800, 1, {"locale_name": "Fish", "type": 12}, {"sockets": [235, 0, 0]}) - _ck(fish_text.contains("长度:2.35 cm"), "fish length included") - var ds_text := tooltip.format(12345, 1, {"locale_name": "Dragon Soul", "type": 29}, {}) - _ck(ds_text.contains("龙魂石阶段:4") and ds_text.contains("龙魂石强化:4"), "dragon soul stage included") - var special := ItemTooltip.new() - _ck(special.setup(assets, "en", FakeMobProto.new()), "special tooltip sources loaded") - var book_text := special.format(50300, 1, {"locale_name": "Skill Book", "type": 17}, {"sockets": [1, 0, 0]}) - _ck(book_text.begins_with("Three-Way Cut Skill Book"), "skill book title includes skill name") - var poly_text := special.format(70103, 1, {"locale_name": "Polymorph Marble", "type": 19}, {"sockets": [77, 0, 0]}) - _ck(poly_text.begins_with("Wolf Polymorph Marble"), "polymorph title includes monster name") - var timed := special.format(90001, 1, {"locale_name": "Timed", "type": 18, - "limits": [{"type": 7, "value": 0}, {"type": 0, "value": 0}]}, - {"sockets": [int(Time.get_unix_time_from_system()) + 65, 0, 0]}) - _ck(timed.contains("剩余时间:1分5秒"), "realtime limit renders remaining time") - if _fail == 0: - print("PASS: item_tooltip_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/item_tooltip_test.gd.uid b/project/item_tooltip_test.gd.uid deleted file mode 100644 index 7d844a8c..00000000 --- a/project/item_tooltip_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bmarcd40nexwg diff --git a/project/keyboard_motion_timeline_test.gd b/project/keyboard_motion_timeline_test.gd deleted file mode 100644 index 31e9382e..00000000 --- a/project/keyboard_motion_timeline_test.gd +++ /dev/null @@ -1,224 +0,0 @@ -# keyboard_motion_timeline_test —— 40250 键盘事件 -> 本地移动/停止时序回归。 -# godot --headless --path project --script keyboard_motion_timeline_test.gd -extends SceneTree - -const PlayerController = preload("res://player_controller.gd") -const GameCamera = preload("res://game_camera.gd") - -var _fail := 0 - -func _ck(condition: bool, message: String) -> void: - if not condition: - _fail += 1 - printerr("FAIL: " + message) - -func _key(code: Key, pressed: bool) -> InputEventKey: - var event := InputEventKey.new() - event.keycode = code - event.pressed = pressed - return event - -func _init() -> void: - var player := Node3D.new() - var pc := PlayerController.new() - get_root().add_child(player) - get_root().add_child(pc) - pc.player = player - pc.default_run = false - pc.force_walk = true - var moved := [] - var states := [] - pc.moved.connect(func(pos: Vector3): moved.append(pos)) - pc.anim_state.connect(func(state: String): states.append(state)) - - # A key pressed while the game phase is inactive must not be inherited. - pc.active = false - pc._unhandled_input(_key(KEY_W, true)) - pc.active = true - pc._process(0.1) - _ck(is_zero_approx(player.position.length()), "inactive key-down does not move on game entry") - - # 40250 updates direction immediately on each key event. W wins while W+S - # overlap; releasing W exposes the still-held S direction. - pc._unhandled_input(_key(KEY_W, true)) - pc._process(0.1) - var after_w := player.position.z - _ck(after_w < -0.01 and not moved.is_empty(), "W key-down starts motion and emits moved") - pc._unhandled_input(_key(KEY_S, true)) - pc._process(0.1) - _ck(player.position.z < after_w, "W keeps priority while W and S overlap") - pc._input(_key(KEY_W, false)) - pc._process(0.1) - var after_release_w := player.position.z - _ck(after_release_w > after_w - 0.01, "releasing W switches to held S") - pc._input(_key(KEY_S, false)) - var before_stop := player.position - pc._process(0.1) - _ck(player.position == before_stop, "releasing the last direction stops translation") - _ck(not states.is_empty() and states[states.size() - 1] == "wait", - "releasing the last direction emits wait state") - - # Holding one direction must consume the reference's fixed 300-pixel target; - # it must not keep extending the destination every frame. - pc._unhandled_input(_key(KEY_W, true)) - var fixed_target_z := pc._direction_dst.z - _ck(is_equal_approx(fixed_target_z, player.position.z - 3.0), - "W creates a fixed 300-pixel destination from the event position") - for _i in range(40): - pc._process(0.1) - _ck(absf(player.position.z - fixed_target_z) <= 0.06, - "held W stops at the fixed 300-pixel destination") - _ck(not pc._direction_active, "fixed keyboard destination is consumed once") - pc._input(_key(KEY_W, false)) - - # Reference CPythonPlayer keeps the direction bit while movement is locked - # and retries it from Update after the lock is released. - pc.locked = true - pc._unhandled_input(_key(KEY_W, true)) - var locked_pos := player.position - pc._process(0.2) - _ck(player.position == locked_pos and pc._direction_resume_pending, - "locked held direction is deferred, not discarded") - pc.locked = false - pc._process(0.1) - _ck(player.position.z < locked_pos.z and not pc._direction_resume_pending, - "held direction resumes on the first unlocked update") - pc._input(_key(KEY_W, false)) - - # A moving skill owns the same motion edge: it may turn, but its old walking - # destination must not resume until the held direction is retried afterward. - pc.stop() - pc._unhandled_input(_key(KEY_W, true)) - pc._process(0.1) - pc.moving_skill = true - var skill_pos := player.position - pc._process(0.1) - _ck(player.position == skill_pos and pc._direction_resume_pending, - "moving skill suppresses translation and defers held direction") - pc.moving_skill = false - pc._process(0.1) - _ck(player.position.z < skill_pos.z and not pc._direction_resume_pending, - "held direction resumes after moving skill ends") - pc._input(_key(KEY_W, false)) - - # 40250 __CanMove also rejects a main actor that owns a private shop or is - # processing an emotion. The direction bits remain latched and Update() - # retries after the server/entity state releases either gate. - pc.stop() - pc.set_private_shop_open(true) - pc._unhandled_input(_key(KEY_W, true)) - var shop_pos := player.position - pc._process(0.1) - _ck(player.position == shop_pos and pc._direction_resume_pending, - "private shop blocks translation without losing held direction") - pc.set_private_shop_open(false) - pc._process(0.1) - _ck(player.position.z < shop_pos.z and not pc._direction_resume_pending, - "held direction resumes after private shop closes") - pc._input(_key(KEY_W, false)) - - pc.stop() - pc.set_processing_emotion(true) - pc._unhandled_input(_key(KEY_W, true)) - var emotion_pos := player.position - pc._process(0.1) - _ck(player.position == emotion_pos and pc._direction_resume_pending, - "emotion processing blocks translation without losing held direction") - pc.set_processing_emotion(false) - pc._process(0.1) - _ck(player.position.z < emotion_pos.z and not pc._direction_resume_pending, - "held direction resumes after emotion ends") - pc._input(_key(KEY_W, false)) - - pc._input(_key(KEY_W, false)) - - # 40250 CPythonPlayer::NEW_MoveToDirection m_isCmrRot: holding a pure - # forward/backward direction must not roll the camera (fRotRat == 0 at the - # axis), while pure left/right rolls it fastest, and diagonals roll at - # half rate. See player_controller.gd::_camera_auto_rotate. - pc.stop() - var cam := GameCamera.new() - pc.camera = cam - cam.yaw = 0.0 - pc._unhandled_input(_key(KEY_W, true)) - pc._process(0.1) - _ck(is_zero_approx(cam.yaw), "holding pure forward does not roll the camera") - pc._input(_key(KEY_W, false)) - - cam.yaw = 0.0 - pc._unhandled_input(_key(KEY_S, true)) - pc._process(0.1) - _ck(is_zero_approx(cam.yaw), "holding pure backward does not roll the camera") - pc._input(_key(KEY_S, false)) - - cam.yaw = 0.0 - pc._unhandled_input(_key(KEY_A, true)) - var yaw_after_one := 0.0 - for _i in range(5): - pc._process(0.1) - if _i == 0: - yaw_after_one = cam.yaw - _ck(yaw_after_one > 0.001, "holding left starts rolling the camera immediately") - _ck(is_equal_approx(yaw_after_one * 5.0, cam.yaw), "camera roll rate is constant while held") - var left_yaw := cam.yaw - pc._input(_key(KEY_A, false)) - - cam.yaw = 0.0 - pc._unhandled_input(_key(KEY_D, true)) - pc._process(0.1) - pc._process(0.1) - pc._process(0.1) - pc._process(0.1) - pc._process(0.1) - _ck(is_equal_approx(cam.yaw, -left_yaw), - "holding right rolls the camera the same rate opposite left") - pc._input(_key(KEY_D, false)) - - cam.yaw = 0.0 - pc._unhandled_input(_key(KEY_W, true)) - pc._unhandled_input(_key(KEY_A, true)) - for _i in range(5): - pc._process(0.1) - _ck(is_equal_approx(cam.yaw, left_yaw * 0.5), - "a forward-left diagonal rolls the camera at half the pure-left rate") - pc._input(_key(KEY_A, false)) - pc._input(_key(KEY_W, false)) - - # The camera-roll side effect is gated exactly like translation: no roll - # while the actor cannot move. - cam.yaw = 0.0 - pc.stop() - pc.locked = true - pc._unhandled_input(_key(KEY_A, true)) - pc._process(0.2) - _ck(is_zero_approx(cam.yaw), "locked movement gate also suppresses the camera roll") - pc.locked = false - pc._input(_key(KEY_A, false)) - pc.stop() - - # CInstanceBase::SetMoveSpeed(UINT uMovSpd) applies uMovSpd/100.0f with no - # lower/upper clamp besides the >1100 -> 0 (frozen) guard; a haste stack or a - # heavy slow debuff must scale local prediction exactly like it already - # scales remote actors in EntityStore::motion_move_speed. - pc.set_server_speed(10) - _ck(is_equal_approx(pc.server_speed_scale, 0.1), - "set_server_speed is unclamped below the old 0.25 floor") - pc.set_server_speed(500) - _ck(is_equal_approx(pc.server_speed_scale, 5.0), - "set_server_speed is unclamped above the old 3.0 ceiling") - pc.set_server_speed(1200) - _ck(is_equal_approx(pc.server_speed_scale, 0.0), - "set_server_speed freezes to 0 past the 1100 SetMoveSpeed guard") - pc.set_server_speed(100) - _ck(is_equal_approx(pc.server_speed_scale, 1.0), - "set_server_speed keeps the 100 baseline at scale 1.0") - - player.free() - pc.free() - cam.free() - if _fail == 0: - print("PASS: keyboard_motion_timeline_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/keyboard_motion_timeline_test.gd.uid b/project/keyboard_motion_timeline_test.gd.uid deleted file mode 100644 index e0595646..00000000 --- a/project/keyboard_motion_timeline_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b5vsnlwtxy2i3 diff --git a/project/live_smoke_test.gd b/project/live_smoke_test.gd deleted file mode 100644 index de6d56ed..00000000 --- a/project/live_smoke_test.gd +++ /dev/null @@ -1,357 +0,0 @@ -# live_smoke_test —— 真实服务器端到端冒烟测试。 -# -# 由 script/live_smoke_test.sh 以 MT_TEST_MODE=smoke 启动。测试流程: -# 自动登录 → 角色列表 → 进入游戏 → 世界/属性读取 → 移动/停止 → 聊天 -# → 可选重连 → 输出 JSON 报告并以 PASS/FAIL 退出。 -# -# 只使用可逆的常规操作,不执行删角、丢弃、强化、交易等破坏性动作。 -extends Node - -const FUNC_WAIT := 0 -const FUNC_MOVE := 1 -const CHAT_TALKING := 0 -const SETTLE_SECONDS := 2.5 -const STEP_TIMEOUT_SECONDS := 25.0 -const TOTAL_TIMEOUT_SECONDS := 120.0 - -var _flow: Node -var _client: Node -var _state := "boot" -var _state_since_ms := 0 -var _started_ms := 0 -var _entered_count := 0 -var _phase_history: Array[String] = [] -var _checks: Array[Dictionary] = [] -var _failures: Array[String] = [] -var _events := { - "char_list": 0, - "entered_game": 0, - "entity_moved": 0, - "chat": 0, - "disconnected": 0, -} -var _origin := Vector3.ZERO -var _origin_valid := false -var _mutations := true -var _reconnect := true -var _warp_command := "" -var _phase_command := "" -var _finished := false -var _finish_queued := false -var _report_path := "" - -func setup(flow: Node, m2client: Node) -> void: - _flow = flow - _client = m2client - _started_ms = Time.get_ticks_msec() - _state_since_ms = _started_ms - _mutations = OS.get_environment("MT_SMOKE_MUTATIONS") != "0" - _reconnect = OS.get_environment("MT_SMOKE_RECONNECT") != "0" - _warp_command = OS.get_environment("MT_SMOKE_WARP_COMMAND").strip_edges() - _phase_command = OS.get_environment("MT_SMOKE_PHASE_COMMAND").strip_edges() - _report_path = OS.get_environment("MT_TEST_REPORT").strip_edges() - if _report_path.is_empty(): - _report_path = "user://live_smoke_report.json" - - if _client == null: - _fail("M2Client 未创建") - _finish_later() - return - if _client.has_signal("stage_changed"): - _client.stage_changed.connect(_on_stage_changed) - if _client.has_signal("phase_changed"): - _client.phase_changed.connect(_on_phase_changed) - if _client.has_signal("char_list"): - _client.char_list.connect(_on_char_list) - if _client.has_signal("entered_game"): - _client.entered_game.connect(_on_entered_game) - if _client.has_signal("login_failed"): - _client.login_failed.connect(_on_login_failed) - if _client.has_signal("disconnected"): - _client.disconnected.connect(_on_disconnected) - if _client.has_signal("entity_moved"): - _client.entity_moved.connect(_on_entity_moved) - if _client.has_signal("chat"): - _client.chat.connect(_on_chat) - - _state = "waiting_login" - _log("START mutations=%s reconnect=%s warp=%s phase_command=%s report=%s" % - [_mutations, _reconnect, not _warp_command.is_empty(), _phase_command, _report_path]) - _check(OS.get_environment("MT_PROTOCOL") == "classic", "classic 协议已启用") - _check(OS.get_environment("MT_ACCOUNT") != "", "账号环境变量已提供") - _check(OS.get_environment("MT_PASSWORD") != "", "密码环境变量已提供") - -func _process(_delta: float) -> void: - if _finished: - return - var now := Time.get_ticks_msec() - var total_s := float(now - _started_ms) / 1000.0 - if total_s > TOTAL_TIMEOUT_SECONDS: - _fail("总超时,当前状态=%s" % _state) - _finish_later() - return - - match _state: - "settle_first": - if _seconds_in_state(now) >= SETTLE_SECONDS and _game_scene_ready(): - _run_world_checks() - elif _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS: - _fail("进场后真实角色模型、装备或快捷栏未在时限内建立") - _finish_later() - "wait_move": - if _seconds_in_state(now) >= 1.5: - _send_stop() - "wait_chat": - if _seconds_in_state(now) >= 0.5: - _send_chat() - "wait_warp": - if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS: - _fail("GC_WARP 后未重新进入游戏") - _finish_later() - "wait_reconnect": - if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS: - _fail("重连后未重新进入游戏") - _finish_later() - "wait_phase_close": - if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS: - _fail("服务器命令断线后未回到 LoginPhase owner") - _finish_later() - "settle_second": - if _seconds_in_state(now) >= SETTLE_SECONDS: - _check(_client != null and _client.is_in_game(), "重连后客户端仍处于游戏状态") - var vid := int(_client.get_main_vid()) - _check(vid != 0 and not _client.get_entity(vid).is_empty(), "重连后主角重新建立") - _check(not _client.get_points().is_empty() and _has_method_and_array("get_skills") - and _has_method_and_array("get_quickslots"), "重连后属性/技能/快捷栏可读取") - var scene: Node = _flow.get("_game") - _check(is_instance_valid(scene) and bool(scene.get("_model_built")), "重连后真实角色模型建立") - _finish_later() - "waiting_login", "waiting_first_game": - if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS: - _fail("阶段超时:%s" % _state) - _finish_later() - -func _seconds_in_state(now: int) -> float: - return float(now - _state_since_ms) / 1000.0 - -func _set_state(next_state: String) -> void: - _state = next_state - _state_since_ms = Time.get_ticks_msec() - _log("STATE %s" % _state) - -func _on_stage_changed(stage: String) -> void: - _log("stage=%s" % stage) - -func _on_phase_changed(phase: String) -> void: - _phase_history.append(phase) - _log("phase=%s" % phase) - -func _on_char_list(characters: Array) -> void: - _events["char_list"] += 1 - _check(not characters.is_empty(), "角色列表非空") - if characters.is_empty(): - _fail("服务器返回空角色列表") - _finish_later() - else: - _log("char_list count=%d" % characters.size()) - -func _on_entered_game() -> void: - _entered_count += 1 - _events["entered_game"] = _entered_count - _check(true, "第 %d 次进入游戏" % _entered_count) - if _entered_count == 1: - _set_state("settle_first") - elif _state == "wait_reconnect" or _state == "wait_warp": - _set_state("settle_second") - -func _on_login_failed(reason: String) -> void: - _fail("登录失败:%s" % reason) - _finish_later() - -func _on_disconnected(reason: String) -> void: - _events["disconnected"] += 1 - _log("disconnected=%s" % reason) - if _state == "wait_phase_close": - _check(_flow != null and int(_flow.state()) == 0, - "server peer close returns the AppFlow LoginPhase owner") - _check(_flow == null or _flow.get("_game") == null, - "server peer close releases the AppFlow GameScene") - _check(_flow == null or not bool(_flow.get("_reconnecting_game")), - "server peer close does not arm a retained reconnect owner") - _finish_later() - return - if _state != "wait_reconnect" and _state != "wait_warp" and _state != "settle_second": - _fail("测试期间意外断线:%s" % reason) - _finish_later() - -func _on_entity_moved(_vid: int) -> void: - _events["entity_moved"] += 1 - -func _on_chat(_type: int, _vid: int, _text: String) -> void: - _events["chat"] += 1 - -func _run_world_checks() -> void: - var in_game: bool = _client != null and _client.has_method("is_in_game") and _client.is_in_game() - _check(in_game, "客户端处于 in_game") - var main_vid := int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0 - _check(main_vid != 0, "主角色 VID 有效") - if main_vid == 0: - _fail("主角色不存在,无法继续常规测试") - _finish_later() - return - - var main_entity: Dictionary = _client.get_entity(main_vid) - _check(not main_entity.is_empty(), "主角色实体可读取") - var entities: Array = _client.get_entities() - _check(not entities.is_empty(), "世界实体列表可读取") - var points: Dictionary = _client.get_points() - _check(not points.is_empty(), "属性数据可读取") - _check(_has_method_and_array("get_inventory"), "背包接口可读取") - _check(_has_method_and_array("get_skills"), "技能接口可读取") - _check(_has_method_and_array("get_quickslots"), "快捷栏接口可读取") - _check(_has_method_and_array("get_affects"), "状态接口可读取") - var scene: Node = _flow.get("_game") - _check(is_instance_valid(scene) and bool(scene.get("_model_built")), "真实角色模型建立") - _check(is_instance_valid(scene) and is_instance_valid(scene.get("equip_model")), "装备渲染控制器建立") - _check(is_instance_valid(scene) and is_instance_valid(scene.get("quickbar")), "技能快捷栏建立") - - var p: Variant = main_entity.get("pos_cm", null) - if p is Vector3: - _origin = p - _origin_valid = true - _log("main_vid=%d pos_cm=%s entities=%d" % [main_vid, p, entities.size()]) - else: - _fail("主角色缺少 pos_cm") - - if not _mutations: - _set_state("capture") - await _capture_diagnostic_frame() - _start_reconnect() - return - if not _phase_command.is_empty(): - _send_phase_command() - return - if not _warp_command.is_empty(): - _send_warp() - return - _send_move() - -func _capture_diagnostic_frame() -> void: - var path := OS.get_environment("MT_DIAG_SHOT").strip_edges() - if path.is_empty(): - return - await RenderingServer.frame_post_draw - var image := get_viewport().get_texture().get_image() - if image == null or image.save_png(path) != OK: - _fail("无法保存联网渲染诊断截图") - -func _game_scene_ready() -> bool: - if _flow == null: - return false - var scene: Node = _flow.get("_game") - return is_instance_valid(scene) and bool(scene.get("_model_built")) \ - and is_instance_valid(scene.get("equip_model")) \ - and is_instance_valid(scene.get("quickbar")) - -func _has_method_and_array(method_name: String) -> bool: - if _client == null or not _client.has_method(method_name): - return false - var value: Variant = _client.call(method_name) - return value is Array - -func _send_move() -> void: - if not _origin_valid: - _finish_later() - return - var ok := bool(_client.move(FUNC_MOVE, 0, 0.0, int(_origin.x) + 100, int(_origin.y))) - _check(ok, "发送 1 米移动包") - _set_state("wait_move") - -func _send_stop() -> void: - var ok := bool(_client.move(FUNC_WAIT, 0, 0.0, int(_origin.x), int(_origin.y))) - _check(ok, "发送停止移动包") - _set_state("wait_chat") - -func _send_chat() -> void: - var ok := bool(_client.say(CHAT_TALKING, "[smoke] client live smoke test")) - _check(ok, "发送普通聊天包") - _start_reconnect() - -func _send_warp() -> void: - var ok := bool(_client.say(CHAT_TALKING, _warp_command)) - _check(ok, "发送 GC_WARP 测试命令") - if ok: - _set_state("wait_warp") - else: - _finish_later() - -func _send_phase_command() -> void: - var ok := bool(_client.say(CHAT_TALKING, _phase_command)) - _check(ok, "发送阶段关闭测试命令") - if ok: - _set_state("wait_phase_close") - else: - _finish_later() - -func _start_reconnect() -> void: - if not _reconnect: - _finish_later() - return - var reconnect_ok := bool(_client.reconnect()) - _check(reconnect_ok, "调用客户端重连") - if reconnect_ok: - _set_state("wait_reconnect") - else: - _finish_later() - -func _check(ok: bool, name: String) -> void: - _checks.append({"name": name, "ok": ok}) - if ok: - print("LIVE_SMOKE PASS: " + name) - else: - _fail(name) - -func _fail(message: String) -> void: - if not _failures.has(message): - _failures.append(message) - printerr("LIVE_SMOKE FAIL: " + message) - -func _log(message: String) -> void: - print("LIVE_SMOKE: " + message) - -func _finish_later() -> void: - if _finish_queued or _finished: - return - _finish_queued = true - call_deferred("_finish") - -func _finish() -> void: - if _finished: - return - _finished = true - var passed := _failures.is_empty() - var report := { - "status": "PASS" if passed else "FAIL", - "started_at_ms": _started_ms, - "duration_seconds": float(Time.get_ticks_msec() - _started_ms) / 1000.0, - "entered_game_count": _entered_count, - "phase_history": _phase_history, - "events": _events, - "checks": _checks, - "failures": _failures, - "mutations": _mutations, - "reconnect": _reconnect, - "warp_command": not _warp_command.is_empty(), - "phase_command": _phase_command, - } - var file := FileAccess.open(_report_path, FileAccess.WRITE) - if file: - file.store_string(JSON.stringify(report, " ")) - file.close() - else: - printerr("LIVE_SMOKE: 无法写入报告:" + _report_path) - if passed: - print("LIVE_SMOKE RESULT: PASS") - else: - printerr("LIVE_SMOKE RESULT: FAIL (%d failures)" % _failures.size()) - get_tree().quit(0 if passed else 1) diff --git a/project/live_smoke_test.gd.uid b/project/live_smoke_test.gd.uid deleted file mode 100644 index 5b04b146..00000000 --- a/project/live_smoke_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d4kxv31wxrhj5 diff --git a/project/locale.gd b/project/locale.gd deleted file mode 100644 index a65d1d14..00000000 --- a/project/locale.gd +++ /dev/null @@ -1,118 +0,0 @@ -# Locale —— EterLocale 字符串表(`locale/locale//*.txt`,KEY\tVALUE,UTF-8)。 -# Godot 原生处理 UTF-8 + 拉丁/西里尔/希腊/阿拉伯;CJK 需外挂字体(apply_font)。 -# 用 preload("res://locale.gd").new();setup() 后 t("KEY", [args]). -extends RefCounted - -var _lang := "zh" -var _tab := {} # KEY -> value -var _item_names := {} # int vnum -> String name -var _mob_names := {} # int vnum -> String name -var _base := "" - -static func get_default_lang() -> String: - var env_lang := OS.get_environment("MT_LOCALE").strip_edges().to_lower() - if not env_lang.is_empty(): - return env_lang - return "zh" - -func setup(assets_root: String, lang := "") -> void: - _base = assets_root.path_join("locale/locale") - if lang.is_empty(): - lang = get_default_lang() - set_lang(lang) - -func set_lang(lang: String) -> void: - _lang = lang - _tab.clear() - _item_names.clear() - _mob_names.clear() - var dir := _base.path_join(lang) - if not DirAccess.dir_exists_absolute(dir): - push_warning("[locale] no such lang: " + dir) - return - for f in ["locale_interface.txt", "locale_game.txt", "itemdesc.txt", "skilldesc.txt"]: - _load_table(dir.path_join(f)) - _load_name_table(dir.path_join("item_names.txt"), _item_names) - _load_name_table(dir.path_join("mob_names.txt"), _mob_names) - -func _load_name_table(path: String, target_dict: Dictionary) -> void: - if not FileAccess.file_exists(path): - return - var fa := FileAccess.open(path, FileAccess.READ) - if fa == null: - return - while not fa.eof_reached(): - var line := fa.get_line().strip_edges() - if line.is_empty() or line.begins_with("#") or line.begins_with("VNUM"): - continue - var tab := line.find("\t") - if tab < 0: - tab = line.find(" ") - if tab < 0: - continue - var k_str := line.substr(0, tab).strip_edges() - var val := line.substr(tab + 1).strip_edges() - if k_str.is_valid_int(): - var vnum := int(k_str) - target_dict[vnum] = val - _tab["ITEM_%d" % vnum] = val - -func item_name(vnum: int, fallback: String = "") -> String: - if _item_names.has(vnum): - return String(_item_names[vnum]) - return fallback if not fallback.is_empty() else ("物品 #%d" % vnum) - -func mob_name(vnum: int, fallback: String = "") -> String: - if _mob_names.has(vnum): - return String(_mob_names[vnum]) - return fallback if not fallback.is_empty() else ("怪物 #%d" % vnum) - -func has_item_name(vnum: int) -> bool: - return _item_names.has(vnum) - -func has_mob_name(vnum: int) -> bool: - return _mob_names.has(vnum) - -func _load_table(path: String) -> void: - if not FileAccess.file_exists(path): - return - var fa := FileAccess.open(path, FileAccess.READ) - if fa == null: - return - while not fa.eof_reached(): - var line := fa.get_line() - if line.is_empty() or line.begins_with("#"): - continue - var tab := line.find("\t") - if tab < 0: - continue - var key := line.substr(0, tab).strip_edges() - var val := line.substr(tab + 1).strip_edges() - if not key.is_empty(): - _tab[key] = val - -func lang() -> String: - return _lang - -func has(key: String) -> bool: - return _tab.has(key) - -# t("SELECT_LEVEL") -> "Level";t("AFF_LOVE_POINT", [42]) -> "Love points: 42%" -# 缺失时返回 ""(开发期一眼可见)。 -func t(key: String, args: Array = []) -> String: - if not _tab.has(key): - return "<" + key + ">" - var s: String = _tab[key] - if args.is_empty(): - return s - return s % args if args.size() > 1 else (s % args[0]) - -# 给一个 Control / Window 挂 CJK(或其它)字体。ttf_path 为空则不动。 -func apply_font(node: Control, ttf_path: String, size := 0) -> void: - if ttf_path.is_empty() or not FileAccess.file_exists(ttf_path): - return - var f := FontFile.new() - f.load_dynamic_font(ttf_path) - node.add_theme_font_override("font", f) - if size > 0: - node.add_theme_font_size_override("font_size", size) diff --git a/project/locale.gd.uid b/project/locale.gd.uid deleted file mode 100644 index 87f98e9d..00000000 --- a/project/locale.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cmkg44eg1hfh0 diff --git a/project/login.gd b/project/login.gd deleted file mode 100644 index 6c994a26..00000000 --- a/project/login.gd +++ /dev/null @@ -1,180 +0,0 @@ -# 登录界面 —— 服务器/账号/密码输入 + 连接,接 M2Client。 -# 成功(char_list)后打印槽位;实际项目里在此切到 charselect.gd。 -# 用法:godot --path project --script login.gd (--auto 用默认值直接连) -extends SceneTree - -const UiKit = preload("res://ui_kit.gd") -const Audio = preload("res://audio.gd") -const Locale = preload("res://locale.gd") -const AppLifecycle = preload("res://app_lifecycle.gd") -const GameScene = preload("res://game_scene.gd") - -var _assets: String -var _client: Node -var _audio: Node -var _life: Node -var _layer: CanvasLayer -var _game: Node3D -var _auto_select := false -var _closing := false -var _loc: RefCounted -var _host: LineEdit -var _id: LineEdit -var _pw: LineEdit -var _status: Label -var _t := 0.0 - -func _init() -> void: - _assets = AssetRoot.path() - _loc = Locale.new() - _loc.setup(_assets, Locale.get_default_lang()) - _audio = Audio.new() - get_root().add_child(_audio) - _audio.setup(_assets) - # F5:登录界面没有游戏逻辑要暂停,但后台时仍停 BGM / 挂起连接 - _life = AppLifecycle.new() - _life.pause_tree_on_background = false - get_root().add_child(_life) - _life.bind(null, _audio) - # The legacy standalone login entry owns its own AppLifecycle, so it must - # consume desktop close requests as well as the AppFlow entry. - _life.close_requested.connect(_on_close_requested) - _layer = CanvasLayer.new() - var layer := _layer - get_root().add_child(layer) - - var bg := ColorRect.new() - bg.color = Color(0.08, 0.09, 0.12) - bg.set_anchors_preset(Control.PRESET_FULL_RECT) - layer.add_child(bg) - - var panel := UiKit.board(_assets, "board", 32, 128) - panel.set_anchors_preset(Control.PRESET_CENTER) - panel.position = Vector2(-220, -150) - panel.size = Vector2(440, 300) - layer.add_child(panel) - - var v := VBoxContainer.new() - v.position = Vector2(40, 36) - v.custom_minimum_size = Vector2(360, 0) - v.add_theme_constant_override("separation", 12) - panel.add_child(v) - - var title := Label.new() - title.text = " " + _loc.t("LOGIN_CONNECT") - title.add_theme_font_size_override("font_size", 20) - v.add_child(title) - - _host = _field(v, _loc.t("LOGIN_DEFAULT_SERVERADDR").split(",")[0], "192.168.21.203") - _id = _field(v, _loc.t("LOGIN_ID"), OS.get_environment("MT_ACCOUNT")) - _pw = _field(v, _loc.t("LOGIN_PASSWORD"), OS.get_environment("MT_PASSWORD")) - _pw.secret = true - - var btn := Button.new() - btn.text = _loc.t("LOGIN_CONNECT") - btn.custom_minimum_size = Vector2(0, 40) - btn.pressed.connect(_connect) - v.add_child(btn) - - _status = Label.new() - _status.text = _loc.t("LOGIN_CONNECTING") - v.add_child(_status) - - if "--auto" in OS.get_cmdline_user_args() or "--auto" in OS.get_cmdline_args(): - _connect() - -func _field(parent: Control, label: String, val: String) -> LineEdit: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 10) - var l := Label.new() - l.text = label - l.custom_minimum_size = Vector2(64, 0) - row.add_child(l) - var le := LineEdit.new() - le.text = val - le.custom_minimum_size = Vector2(260, 0) - row.add_child(le) - parent.add_child(row) - return le - -func _connect() -> void: - if not ClassDB.class_exists("M2Client"): - _status.text = "M2Client 未注册(扩展没编?)" - return - if _client: - _client.call("disconnect_from_server") - _client.queue_free() - _client = ClassDB.instantiate("M2Client") - get_root().add_child(_client) - _client.connect("stage_changed", func(s): _msg("阶段: " + s)) - _client.connect("auth_ok", func(k): - _msg("认证成功 login_key=0x%08X" % k) - _audio.play_ui("loginok")) - _client.connect("login_failed", func(r): - _msg("失败: " + r) - _audio.play_ui("loginfail")) - if _client.has_signal("connect_failed"): - _client.connect("connect_failed", func(r): - _msg("连接失败: " + r)) - _client.connect("phase_changed", func(p): _msg("游戏相位: " + p)) - _client.connect("char_list", _on_chars) - _client.connect("entered_game", _on_entered_game) - _client.connect("disconnected", func(r): _msg("断开: " + r)) - _client.connect("resumed", func(): - # 回前台:若后台期间 OS 掐了 TCP,下一次 pump 会发 disconnected; - # 曾在游戏里就自动重连。 - if _client.call("is_in_game"): - _msg("回前台,尝试重连…") - _client.call("reconnect")) - _life.bind(_client, _audio) - var h := _host.text - _client.call("connect_to_server", h, 11000, h, 13002, _id.text, _pw.text) - _msg("连接 %s ..." % h) - -func _on_chars(list: Array) -> void: - _msg("角色列表: %d 个" % list.size()) - for d in list: - print("[login] slot %d: %s (job %d, Lv.%d) @ (%d,%d)" % [ - d.get("index", -1), d.get("name", "?"), d.get("job", 0), - d.get("level", 0), d.get("x", 0), d.get("y", 0)]) - # 简版:自动选第一个非空槽进游戏。正式版切到 charselect.gd。 - if list.size() > 0 and (_auto_select or "--auto" in OS.get_cmdline_args()): - var idx: int = int(list[0].get("index", 0)) - _msg("自动选择槽位 %d ..." % idx) - if _client.has_method("enter_game"): - _client.call("enter_game", idx) - else: - _client.call("select_character", idx) - -func _on_entered_game() -> void: - _msg("已进入游戏 —— 加载场景") - if _layer: - _layer.visible = false - # 兼容启动器没有 AppFlow,因此由这里创建的生命周期协调者贯穿 - # 登录→游戏;GameScene 不再创建第二个协调者,也不能在切屏时销毁它。 - if _life: - _life.bind(_client, _audio) - _game = GameScene.new() - get_root().add_child(_game) - _game.setup(_client, _assets) - -func _msg(t: String) -> void: - if _status: - _status.text = t - print("[login] ", t) - -func _on_close_requested() -> void: - if _closing: - return - _closing = true - if _client and is_instance_valid(_client) and _client.has_method("disconnect_from_server"): - _client.disconnect_from_server() - if _life and is_instance_valid(_life): - _life.bind(null, null) - if _game and is_instance_valid(_game): - _game.queue_free() - _game = null - quit() - -func _process(_dt: float) -> bool: - return false diff --git a/project/login.gd.uid b/project/login.gd.uid deleted file mode 100644 index 9bfb5122..00000000 --- a/project/login.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://6u27nmch6k60 diff --git a/project/love_ui_test.gd b/project/love_ui_test.gd deleted file mode 100644 index 48569667..00000000 --- a/project/love_ui_test.gd +++ /dev/null @@ -1,59 +0,0 @@ -# love_ui_test — 情侣状态窗随 GC_LOVER_INFO / LOVE_POINT_UPDATE 的桥接信号更新。 -extends SceneTree - -const LoveUI = preload("res://ui/love_ui.gd") - -class FakeClient extends Node: - signal lover_changed(lover: Dictionary) - var lover := {"valid": false, "name": "", "love_point": 0} - func get_lover() -> Dictionary: - return lover - -var failed := 0 -func _check(ok: bool, message: String) -> void: - if not ok: - failed += 1 - printerr("FAIL: " + message) - -func _init() -> void: - await _run() - if failed == 0: - print("PASS: love_ui_test (lover state panel)") - quit(0) - else: - quit(1) - -func _run() -> void: - var canvas := CanvasLayer.new() - var client := FakeClient.new() - var love := LoveUI.new() - get_root().add_child(canvas) - get_root().add_child(client) - get_root().add_child(love) - love.setup(client, canvas) - _check(not love._root.visible, "no lover keeps panel hidden") - client.lover = {"valid": true, "name": "Partner", "love_point": 63, - "online": false, "near": false} - client.lover_changed.emit(client.lover) - await process_frame - _check(love._root.visible, "lover info shows panel") - _check(love._name.text == "♥ Partner(离线)", "lover name + offline state rendered") - _check(int(love._bar.value) == 63, "lover point rendered") - client.lover["love_point"] = 88 - client.lover_changed.emit(client.lover) - await process_frame - _check(int(love._bar.value) == 88, "love point update refreshes bar") - # lover_login / lover_near(CHAT_TYPE_COMMAND,marriage.cpp)→ 状态后缀。 - client.lover["online"] = true - client.lover_changed.emit(client.lover) - await process_frame - _check(love._name.text == "♥ Partner(在线)", "lover_online state rendered") - client.lover["near"] = true - client.lover_changed.emit(client.lover) - await process_frame - _check(love._name.text == "♥ Partner(在身边)", "lover_near state rendered") - # lover_divorce → store 清空 LoverInfo → 面板隐藏。 - client.lover = {"valid": false, "name": "", "love_point": 0} - client.lover_changed.emit(client.lover) - await process_frame - _check(not love._root.visible, "lover divorce hides panel") diff --git a/project/love_ui_test.gd.uid b/project/love_ui_test.gd.uid deleted file mode 100644 index 4d13dccf..00000000 --- a/project/love_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://7w5o7rxr2wmk diff --git a/project/main.gd b/project/main.gd deleted file mode 100644 index 6d1db436..00000000 --- a/project/main.gd +++ /dev/null @@ -1,356 +0,0 @@ -extends Node3D -## mtgodot-poc harness — M1 (static render) + M2 (skeletal animation). -## -## Loads a Metin2 .gr2 via the Metin2Model GDExtension node, renders it with -## Godot's Forward+ renderer, and (if an anim .gr2 is given) drives it with -## Metin2AnimPlayer. -## -## Asset root resolves via AssetRoot (res://../assets, or the MT_ASSETS env var). -## Per-file overrides: MTGODOT_MODEL / MTGODOT_ANIM / MTGODOT_TEXDIR env vars. -## -## Keys: drag = orbit wheel = zoom Space = play/pause B = bind pose -## [ / ] = scrub R = reload F2 = screenshot - -const ORBIT_SPEED := 0.01 -const ZOOM_STEP := 0.15 -const PITCH_LIMIT := 1.45 - -var _assets_root := "" -var _cam: Camera3D -var _model: Node3D # Metin2Model -var _anim: Node3D # Metin2AnimPlayer -var _yaw := 0.7 -var _pitch := 0.25 -var _dist := 3.0 -var _target := Vector3(0.0, 1.0, 0.0) -var _dragging := false - - -func _ready() -> void: - _assets_root = AssetRoot.path() - _build_environment() - _build_sun() - _build_camera() - - var stress := OS.get_environment("MTGODOT_STRESS") - if stress != "": - await _run_stress(int(stress)) - return - - _load_content() - _frame_model() - print("[mtgodot] harness ready") - - var autoshot := OS.get_environment("MTGODOT_AUTOSHOT") - if autoshot != "": - # let a few frames render (and the anim settle) before capturing - for i in 8: - await get_tree().process_frame - var img := get_viewport().get_texture().get_image() - img.save_png(autoshot) - print("[mtgodot] autoshot -> ", autoshot) - get_tree().quit() - - -func _build_environment() -> void: - var we := WorldEnvironment.new() - var env := Environment.new() - var sky := Sky.new() - sky.sky_material = ProceduralSkyMaterial.new() - env.background_mode = Environment.BG_SKY - env.sky = sky - env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY - env.ambient_light_energy = 0.6 - env.tonemap_mode = Environment.TONE_MAPPER_FILMIC - # T2.5.4 — distance fog via the engine (not per-shader). - if OS.get_environment("MTGODOT_FOG") == "1": - env.fog_enabled = true - env.fog_mode = Environment.FOG_MODE_DEPTH - env.fog_light_color = Color(0.55, 0.55, 0.62) - env.fog_sun_scatter = 0.1 - env.fog_density = 0.0 - env.fog_depth_begin = 3.0 - env.fog_depth_end = 12.0 - we.environment = env - add_child(we) - - -func _build_sun() -> void: - var sun := DirectionalLight3D.new() - sun.rotation_degrees = Vector3(-50.0, -40.0, 0.0) - sun.shadow_enabled = true - sun.light_energy = 1.1 - add_child(sun) - - -func _build_camera() -> void: - _cam = Camera3D.new() - _cam.current = true - _cam.fov = 45.0 - _cam.near = 0.01 - add_child(_cam) - _update_camera() - - -func _env(name: String, fallback: String) -> String: - var v := OS.get_environment(name) - return v if v != "" else fallback - - -func _load_content() -> void: - if not ClassDB.class_exists("Metin2Model"): - push_error("[mtgodot] Metin2Model not registered — GDExtension failed to load") - return - - var model_path := _env("MTGODOT_MODEL", - _assets_root.path_join("PC/ymir work/pc/warrior/warrior_cheongrin.gr2")) - var tex_dir := _env("MTGODOT_TEXDIR", - _assets_root.path_join("PC/ymir work/pc/warrior")) - # dance_1 and other emotes are fine now that CPU skinning is the default - # (GPU/Skeleton3D dropped the shear Granny bakes onto the shoulder/front-robe - # bones → head/neck collapse; see docs/MIDREVIEW.md §4). Force GPU with - # MTGODOT_GPUSKIN=1 for perf testing. - var anim_path := _env("MTGODOT_ANIM", - _assets_root.path_join("PC/ymir work/pc/warrior/action/dance_1.msa")) - - _model = ClassDB.instantiate("Metin2Model") - _model.name = "Metin2Model" - _model.set("texture_dir", tex_dir) - _model.set("gr2_path", model_path) - add_child(_model) - if _model.has_method("get_info"): - print("[mtgodot] model: ", _model.call("get_info")) - if _model.has_method("get_hair_options"): - var hs: Array = _model.call("get_hair_options") - if not hs.is_empty(): - print("[mtgodot] .msm hair options: %d (e.g. %s)" % [hs.size(), hs[0]]) - - if anim_path != "-" and FileAccess.file_exists(anim_path) and ClassDB.class_exists("Metin2AnimPlayer"): - _anim = ClassDB.instantiate("Metin2AnimPlayer") - _anim.name = "Metin2AnimPlayer" - _anim.set("model_path", _anim.get_path_to(_model) if false else NodePath("../Metin2Model")) - _anim.set("anim_path", anim_path) - var loop_env := OS.get_environment("MTGODOT_LOOP") - if loop_env != "": - _anim.set("loop", loop_env != "0") - var speed_env := OS.get_environment("MTGODOT_TIME_SCALE") - if speed_env != "": - _anim.set("time_scale", float(speed_env)) - add_child(_anim) - if _anim.has_signal("motion_event"): - _anim.connect("motion_event", _on_motion_event) - if _anim.has_signal("playback_finished"): - _anim.connect("playback_finished", _on_playback_finished) - if _anim.has_method("get_info"): - print("[mtgodot] anim: ", _anim.call("get_info")) - if _anim.has_method("get_events"): - var evs: Array = _anim.call("get_events") - if not evs.is_empty(): - print("[mtgodot] msa events: ", evs, " accum=", _anim.call("get_accumulation")) - if _anim.has_method("selfcheck"): - print("[mtgodot] ", _anim.call("selfcheck", 24)) - _anim.call("reload") - var fixed_t := OS.get_environment("MTGODOT_ANIM_T") - if fixed_t != "": - _anim.set("playing", false) - _anim.call("set_time", float(fixed_t)) - print("[mtgodot] fixed t=", _anim.call("get_time"), " dur=", _anim.call("get_duration")) - - -func _frame_model() -> void: - # Use trimmed vertex percentiles: a single malformed/far-away vertex (or a - # distant attachment bone) must not make the character microscopic. - var mi := _model.get_node_or_null("MeshInstance3D") if _model else null - var aabb := _robust_mesh_frame_aabb(mi as MeshInstance3D) if mi is MeshInstance3D else AABB() - var frame_source := "trimmed-mesh" - if aabb.size.y <= 0.01 and mi and mi is VisualInstance3D: - aabb = _model.transform * (mi as VisualInstance3D).get_aabb() - frame_source = "mesh" - if aabb.size.y > 0.01: - var h := maxf(aabb.size.y, 0.5) - _target = Vector3(aabb.get_center().x, aabb.position.y + h * 0.5, aabb.get_center().z) - _dist = h * 3.2 - _pitch = 0.12 - var yenv := OS.get_environment("MTGODOT_YAW") - if yenv != "": - _yaw = float(yenv) - print("[mtgodot] frame[", frame_source, "]: aabb=", aabb, - " target=", _target, " dist=", _dist) - _update_camera() - - -func _robust_mesh_frame_aabb(mi: MeshInstance3D) -> AABB: - if not _model or not mi or not mi.mesh: - return AABB() - var xs := PackedFloat32Array() - var ys := PackedFloat32Array() - var zs := PackedFloat32Array() - for surface in range(mi.mesh.get_surface_count()): - var arrays := mi.mesh.surface_get_arrays(surface) - if arrays.size() <= Mesh.ARRAY_VERTEX: - continue - var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array - for local_pos in vertices: - var p: Vector3 = _model.transform * local_pos - if p.is_finite(): - xs.append(p.x) - ys.append(p.y) - zs.append(p.z) - if xs.size() < 8: - return AABB() - xs.sort() - ys.sort() - zs.sort() - var trim := int(xs.size() * 0.01) - var hi := xs.size() - trim - 1 - var lo_p := Vector3(xs[trim], ys[trim], zs[trim]) - var hi_p := Vector3(xs[hi], ys[hi], zs[hi]) - var bounds := AABB(lo_p, hi_p - lo_p) - return bounds.grow(maxf(bounds.size.y * 0.08, 0.05)) - - -func _update_camera() -> void: - var offset := Vector3( - cos(_pitch) * sin(_yaw), - sin(_pitch), - cos(_pitch) * cos(_yaw)) * _dist - _cam.position = _target + offset - _cam.look_at(_target, Vector3.UP) - - -func _unhandled_input(event: InputEvent) -> void: - if event is InputEventMouseButton: - var mb := event as InputEventMouseButton - match mb.button_index: - MOUSE_BUTTON_LEFT: - _dragging = mb.pressed - MOUSE_BUTTON_WHEEL_UP: - _dist = maxf(0.2, _dist * (1.0 - ZOOM_STEP)) - _update_camera() - MOUSE_BUTTON_WHEEL_DOWN: - _dist = minf(200.0, _dist * (1.0 + ZOOM_STEP)) - _update_camera() - elif event is InputEventMouseMotion and _dragging: - var mm := event as InputEventMouseMotion - _yaw -= mm.relative.x * ORBIT_SPEED - _pitch = clampf(_pitch + mm.relative.y * ORBIT_SPEED, -PITCH_LIMIT, PITCH_LIMIT) - _update_camera() - elif event is InputEventKey and event.pressed and not event.echo: - _on_key(event.keycode) - - -func _on_key(kc: int) -> void: - match kc: - KEY_F2: - _screenshot() - KEY_SPACE: - if _anim: - _anim.set("playing", not _anim.get("playing")) - KEY_B: - if _anim: - _anim.set("playing", false) - _anim.call("set_time", 0.0) - KEY_R: - if _model: - _model.call("reload") - if _anim: - _anim.call("reload") - _frame_model() - KEY_BRACKETLEFT: - if _anim: - _anim.call("set_time", _anim.call("get_time") - 0.05) - KEY_BRACKETRIGHT: - if _anim: - _anim.call("set_time", _anim.call("get_time") + 0.05) - - -## MTGODOT_STRESS=N : spawn N animated warriors in a grid, measure frame time -## over ~180 frames, write test/godot-macos-stress.json, quit. -## Baseline for Phase-2 perf: default CPU LBS vs MTGODOT_GPUSKIN=1 vertex shader. -func _run_stress(n: int) -> void: - DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) - Engine.max_fps = 0 - var model_path := _assets_root.path_join("PC/ymir work/pc/warrior/warrior_cheongrin.gr2") - var tex_dir := _assets_root.path_join("PC/ymir work/pc/warrior") - var anim_path := _assets_root.path_join("PC/ymir work/pc/warrior/action/dance_1.gr2") - var cols := int(ceil(sqrt(float(n)))) - var t_build_start := Time.get_ticks_usec() - for i in n: - var m: Node3D = ClassDB.instantiate("Metin2Model") - m.name = "M%d" % i - m.set("texture_dir", tex_dir) - m.set("gr2_path", model_path) - m.position = Vector3((i % cols) * 1.2 - cols * 0.6, 0, (i / cols) * 1.2) - add_child(m) - var a: Node3D = ClassDB.instantiate("Metin2AnimPlayer") - a.set("model_path", NodePath("../M%d" % i)) - a.set("anim_path", anim_path) - a.set("time_scale", 1.0 + 0.01 * i) # desync - add_child(a) - a.call("reload") - a.call("set_time", 0.13 * i) - var build_ms := (Time.get_ticks_usec() - t_build_start) / 1000.0 - - _target = Vector3(0, 1.0, cols * 0.6) - _dist = cols * 2.2 - _pitch = 0.35 - _update_camera() - - # warm-up - for i in 40: - await get_tree().process_frame - var total: Array[float] = [] - var proc: Array[float] = [] - for i in 240: - await get_tree().process_frame - total.append(1000.0 / maxf(Engine.get_frames_per_second(), 1.0)) - proc.append(Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0) - total.sort() - proc.sort() - var tsum := 0.0 - for s in total: - tsum += s - var psum := 0.0 - for s in proc: - psum += s - var report := { - "characters": n, - "build_ms_total": build_ms, - "build_ms_per_char": build_ms / n, - "frames": total.size(), - "frame_ms_avg": tsum / total.size(), - "frame_ms_p95": total[int(total.size() * 0.95)], - "frame_ms_max": total[-1], - "fps_avg": 1000.0 / (tsum / total.size()), - "cpu_process_ms_avg": psum / proc.size(), - "cpu_process_ms_p95": proc[int(proc.size() * 0.95)], - "renderer": RenderingServer.get_video_adapter_name(), - "method": ("per-frame GPU LBS via bone-texture vertex shader (full affine)" - if OS.get_environment("MTGODOT_GPUSKIN") == "1" - else "per-frame CPU LBS, mesh rebuilt each frame (B, default)"), - } - var out := OS.get_environment("MTGODOT_STRESS_OUT") - if out == "": - out = ProjectSettings.globalize_path("res://../test/godot-macos-stress.json") - var f := FileAccess.open(out, FileAccess.WRITE) - if f: - f.store_string(JSON.stringify(report, " ")) - f.close() - print("[mtgodot] stress ", n, " -> ", report) - get_tree().quit() - - -func _on_motion_event(type: int, effect: String, sound: String, pos: Vector3) -> void: - print("[mtgodot] motion_event t=%.3f type=%d effect=%s sound=%s pos=%s" - % [_anim.call("get_time") if _anim else 0.0, type, effect, sound, pos]) - - -func _on_playback_finished() -> void: - print("[mtgodot] playback finished at t=", _anim.call("get_time") if _anim else -1.0) - - -func _screenshot() -> void: - var img := get_viewport().get_texture().get_image() - var path := "user://shot_%d.png" % Time.get_ticks_msec() - img.save_png(path) - print("[mtgodot] screenshot -> ", ProjectSettings.globalize_path(path)) diff --git a/project/main.gd.uid b/project/main.gd.uid deleted file mode 100644 index e3d067fe..00000000 --- a/project/main.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://clyxqop8cvbbe diff --git a/project/main.tscn b/project/main.tscn deleted file mode 100644 index a2d273d3..00000000 --- a/project/main.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://main.gd" id="1_main"] - -[node name="Main" type="Node3D"] -script = ExtResource("1_main") diff --git a/project/main_character_sync_test.gd b/project/main_character_sync_test.gd deleted file mode 100644 index a09f16af..00000000 --- a/project/main_character_sync_test.gd +++ /dev/null @@ -1,116 +0,0 @@ -extends SceneTree - -const Scene = preload("res://game_scene.gd") -const View = preload("res://ui/player_view.gd") - -class Client extends Node: - var main := 0 - var rows := {} - func get_main_vid() -> int: return main - func get_entity(vid: int) -> Dictionary: return rows.get(vid, {}) - -class StubView extends Node3D: - var anim: Node - var succeeds := true - var during_build := Callable() - func build(_assets: String, _race: int, _pump: Callable) -> bool: - if during_build.is_valid(): during_build.call() - return succeeds - func set_audio(_node: Node) -> void: pass - -class Harness extends Scene: - var builds := 0 - var succeeds := true - var during_build := Callable() - func _create_main_view() -> Node3D: - builds += 1 - var view := StubView.new() - view.succeeds = succeeds - view.during_build = during_build - return view - -class Controller extends Node: - signal anim_state(state: String) - var player: Node3D - -class Camera extends Camera3D: - var target: Node3D - -var failures := 0 -func check(value: bool, label: String) -> void: - if not value: - failures += 1 - printerr("FAIL: " + label) - -func _initialize() -> void: - call_deferred("run") - -func run() -> void: - var client := Client.new() - var scene := Harness.new() - root.add_child(client) - root.add_child(scene) - scene.set_process(false) - scene.client = client - scene._assets = "synthetic-assets" - scene.player = Node3D.new() - scene.add_child(scene.player) - scene.pc = Controller.new() - scene.add_child(scene.pc) - scene.cam = Camera.new() - scene.add_child(scene.cam) - scene._sync_main_character() - check(scene.builds == 0, "setup incomplete does not build") - scene._main_sync_ready = true - client.main = 10 - scene._sync_main_character() - check(scene.builds == 0, "late entity is awaited") - client.rows[10] = {"pos": Vector3(20, 0, -30)} - scene._sync_main_character() - check(scene.builds == 0, "missing race does not build") - check(scene.player.position == Vector3(20, 0, 30), "late row places placeholder") - client.rows[10].race = 0 - scene.during_build = func(): scene._on_main_set(10) - scene._sync_main_character() - check(scene.builds == 1 and scene._model_built, "reentrant network event creates one view") - check(scene.cam.target == scene.player and scene.pc.player == scene.player, "replacement updates consumers") - scene.player.position = Vector3(25, 0, 35) - for i in 5: - scene._sync_main_character() - scene._on_main_set(10) - check(scene.builds == 1, "duplicate main events are idempotent") - check(scene.player.position == Vector3(25, 0, 35), "polling does not undo movement") - client.main = 20 - client.rows[20] = {"race": 1, "pos": Vector3(40, 0, -50)} - scene.during_build = Callable() - scene.succeeds = false - scene._sync_main_character() - check(scene.builds == 2 and not scene._model_built, "failed replacement is not marked ready") - for i in 5: scene._sync_main_character() - check(scene.builds == 2, "failure does not retry every frame") - scene._main_retry_at = 0 - scene.succeeds = true - scene._sync_main_character() - check(scene.builds == 3 and scene._model_built, "transient failure recovers") - check(scene.pc.anim_state.get_connections().size() == 1, "replacement does not duplicate animation subscriptions") - var previous = scene.player - client.main = 30 - client.rows[30] = {"race": 0, "pos": Vector3.ZERO} - scene.during_build = func(): client.main = 40 - scene._sync_main_character() - check(scene.player == previous, "obsolete build is discarded") - client.rows[40] = {"race": 0, "pos": Vector3.ZERO} - scene.during_build = Callable() - scene.succeeds = false - for i in 5: - scene._main_retry_at = 0 - scene._sync_main_character() - check(scene._main_attempts == 3, "permanent failure has bounded attempts") - var view := View.new() - check(not view.build("missing-assets", 8), "unsupported race rejected") - view.free() - scene.queue_free() - client.queue_free() - await process_frame - print("main_character_sync_test: failures=", failures) - quit(1 if failures else 0) diff --git a/project/main_character_sync_test.gd.uid b/project/main_character_sync_test.gd.uid deleted file mode 100644 index 3ca4f67e..00000000 --- a/project/main_character_sync_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dp333tqyl0dcg diff --git a/project/metin_socket_system.gd b/project/metin_socket_system.gd deleted file mode 100644 index 5a807bd5..00000000 --- a/project/metin_socket_system.gd +++ /dev/null @@ -1,204 +0,0 @@ -# metin_socket_system.gd —— 魔石镶嵌与打孔清除系统(40250 官方 1:1 对齐) -# 对齐源码: -# - 40250 char_item.cpp:5004-5080 (case ITEM_METIN: 镶嵌魔石、30% 成功率、碎石 28960) -# - 40250 char_item.cpp:4510-4540 (case USE_CLEAN_SOCKET: 卷轴 71006 清除 28960 碎石残渣) -# - 40250 char_item.cpp:53 (const int ITEM_BROKEN_METIN_VNUM = 28960) -# - 40250 ItemData.h (ITEM_METIN, ITEM_WEAPON, ITEM_ARMOR) -class_name MetinSocketSystem -extends RefCounted - -const ITEM_BROKEN_METIN_VNUM := 28960 # 碎石残片 Vnum (40250 官方标准) -const VNUM_CLEAN_SOCKET_SCROLL := 71006 # 提石卷轴 / 清除残渣卷轴 (USE_CLEAN_SOCKET) - -const ITEM_TYPE_WEAPON := 1 -const ITEM_TYPE_ARMOR := 2 -const ITEM_TYPE_METIN := 10 - -const WEARABLE_WEAPON := 1 << 0 -const WEARABLE_BODY := 1 << 1 - -# 魔石基础分类(根据 40250 官方属性定义,同一分类不可在同一装备上重复镶嵌) -# 例如:突击石、穿透石、致命石、冷却石、怪物石、保护石、防御石、回避石、生命石等 -const METIN_GROUPS := { - # 穿透石 (28030..28430) - "PENETRATE": [28030, 28130, 28230, 28330, 28430], - # 致命石 / 暴击石 (28031..28431) - "CRITICAL": [28031, 28131, 28231, 28331, 28431], - # 冷却石 / 施法石 (28032..28432) - "COOLDOWN": [28032, 28132, 28232, 28332, 28432], - # 战士突击石 (28033..28433) - "WARRIOR": [28033, 28133, 28233, 28333, 28433], - # 刺客突击石 (28034..28434) - "ASSASSIN": [28034, 28134, 28234, 28334, 28434], - # 武士突击石 (28035..28435) - "SURA": [28035, 28135, 28235, 28335, 28435], - # 萨满突击石 (28036..28436) - "SHAMAN": [28036, 28136, 28236, 28336, 28436], - # 怪物石 (28037..28437) - "MONSTER": [28037, 28137, 28237, 28337, 28437], - # 躲闪石 / 回避石 (28038..28438) - "EVASION": [28038, 28138, 28238, 28338, 28438], - # 抵挡石 / 格挡石 (28039..28439) - "BLOCK": [28039, 28139, 28239, 28339, 28439], - # 坚韧石 / 魔法石 (28040..28440) - "MAGIC": [28040, 28140, 28240, 28340, 28440], - # 活力石 / 生命石 (28041..28441) - "VITALITY": [28041, 28141, 28241, 28341, 28441], - # 防御石 (28042..28442) - "DEFENSE": [28042, 28142, 28242, 28342, 28442], - # 速度石 (28043..28443) - "SPEED": [28043, 28143, 28243, 28343, 28443], -} - -# 官方默认镶嵌成功率:30%(40250 char_item.cpp:5070 number(1, 100) <= 30) -const DEFAULT_METIN_SUCCESS_RATE := 30 - -## 检查是否为魔石 -static func is_metin_stone(vnum: int, proto: Dictionary = {}) -> bool: - if proto and int(proto.get("type", 0)) == ITEM_TYPE_METIN: - return true - return vnum >= 28000 and vnum <= 28450 - -## 检查是否为清除魔石残渣卷轴 -static func is_clean_socket_scroll(vnum: int) -> bool: - return vnum == VNUM_CLEAN_SOCKET_SCROLL - -## 获取魔石的类别分组(用于防同类同插槽重复) -static func get_metin_group(vnum: int) -> String: - for grp in METIN_GROUPS.keys(): - if vnum in METIN_GROUPS[grp]: - return grp - return str(vnum / 100) - -## 检查装备能否镶嵌该魔石(武器魔石 vs 防具魔石) -static func can_attach_metin(stone_vnum: int, stone_proto: Dictionary, target_proto: Dictionary) -> Dictionary: - var target_type := int(target_proto.get("type", 0)) - if target_type not in [ITEM_TYPE_WEAPON, ITEM_TYPE_ARMOR]: - return {"ok": false, "code": "CANNOT_SOCKET_TYPE", "msg": "此物品类型无法镶嵌魔石。"} - - var stone_wear := int(stone_proto.get("wear_flag", 0)) - if target_type == ITEM_TYPE_ARMOR: - # 防具魔石必须匹配 WEARABLE_BODY (衣服) - var sub := int(target_proto.get("sub_type", 0)) - if sub != 0: # 0 = ARMOR_BODY - return {"ok": false, "code": "ARMOR_NOT_BODY", "msg": "防具类魔石只能镶嵌在盔甲上。"} - elif target_type == ITEM_TYPE_WEAPON: - var sub := int(target_proto.get("sub_type", 0)) - if sub == 5: # WEAPON_ARROW - return {"ok": false, "code": "WEAPON_ARROW", "msg": "箭矢无法镶嵌魔石。"} - - return {"ok": true} - -## 执行魔石镶嵌 (40250 char_item.cpp:5004-5080) -static func socket_metin_stone(stone_vnum: int, stone_proto: Dictionary, - target_item: Dictionary, target_proto: Dictionary, - force_success := false, force_fail := false) -> Dictionary: - - var check := can_attach_metin(stone_vnum, stone_proto, target_proto) - if not bool(check.get("ok", false)): - return check - - var sockets: Array = target_item.get("sockets", [1, 1, 1]).duplicate() - while sockets.size() < 3: - sockets.append(1) - - var stone_grp := get_metin_group(stone_vnum) - - # 检查装备上是否已有同类魔石 (40250 char_item.cpp:5035 item->GetValue(5) == p->alValues[5]) - for s in sockets: - var sv := int(s) - if sv > 2 and sv != ITEM_BROKEN_METIN_VNUM: - if get_metin_group(sv) == stone_grp: - return { - "ok": false, - "code": "DUPLICATE_METIN", - "msg": "装备上已经镶嵌了同类型的魔石,无法重复镶嵌!" - } - - # 寻找可用孔位 (40250: socket >= 1 && socket <= 2 表示开孔可用状态) - var target_slot := -1 - for i in sockets.size(): - var sv := int(sockets[i]) - if sv >= 1 and sv <= 2: - target_slot = i - break - - if target_slot == -1: - return { - "ok": false, - "code": "NO_AVAILABLE_SOCKET", - "msg": "此装备没有可用的空魔石插槽(已插满或被碎石堵塞)。" - } - - # 判定成功/失败概率(40250 官方 30% 成功) - var success := false - if force_success: - success = true - elif force_fail: - success = false - else: - success = (randi() % 100 + 1) <= DEFAULT_METIN_SUCCESS_RATE - - if success: - sockets[target_slot] = stone_vnum - target_item["sockets"] = sockets - return { - "ok": true, - "code": "SUCCESS", - "msg": "魔石成功镶嵌到了装备上!", - "consumed": true, - "socket_index": target_slot, - "result_vnum": stone_vnum, - } - else: - # 镶嵌失败:碎石堵塞孔位 (ITEM_BROKEN_METIN_VNUM = 28960) - sockets[target_slot] = ITEM_BROKEN_METIN_VNUM - target_item["sockets"] = sockets - return { - "ok": false, - "code": "BROKEN_METIN", - "msg": "魔石破碎了,碎石残渣堵塞了插槽!", - "consumed": true, - "socket_index": target_slot, - "result_vnum": ITEM_BROKEN_METIN_VNUM, - } - -## 执行提石卷轴 / 清除碎石残渣 (40250 char_item.cpp:4510 USE_CLEAN_SOCKET) -static func clean_broken_sockets(target_item: Dictionary) -> Dictionary: - var sockets: Array = target_item.get("sockets", [1, 1, 1]).duplicate() - while sockets.size() < 3: - sockets.append(1) - - var has_broken := false - for s in sockets: - if int(s) == ITEM_BROKEN_METIN_VNUM: - has_broken = true - break - - if not has_broken: - return { - "ok": false, - "code": "NO_BROKEN_STONE", - "msg": "该装备插槽内没有碎石残渣,无需清除。" - } - - # 40250 char_item.cpp:4526 算法: - # 1. 把所有有效魔石(不等于 28960 且不为 0)按序向前压实 - # 2. 剩余的孔位重新置为 1(可用空孔) - var cleaned: Array = [] - for s in sockets: - var sv := int(s) - if sv != ITEM_BROKEN_METIN_VNUM and sv > 2: - cleaned.append(sv) - - while cleaned.size() < sockets.size(): - cleaned.append(1) # 恢复为空孔位 1 - - target_item["sockets"] = cleaned - return { - "ok": true, - "code": "SUCCESS", - "msg": "成功清除了装备上的碎石残渣,插槽已恢复可用!", - "consumed": true, - "sockets": cleaned, - } diff --git a/project/metin_socket_system.gd.uid b/project/metin_socket_system.gd.uid deleted file mode 100644 index bacd25dd..00000000 --- a/project/metin_socket_system.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dadu6ruv5bhbq diff --git a/project/mob_death_drop_test.gd b/project/mob_death_drop_test.gd deleted file mode 100644 index 06016a67..00000000 --- a/project/mob_death_drop_test.gd +++ /dev/null @@ -1,198 +0,0 @@ -extends SceneTree - -const NetWorld = preload("res://net_world.gd") -const GroundItems = preload("res://ui/ground_items.gd") -const PlayerController = preload("res://player_controller.gd") - -class MockClient extends Node: - signal entity_spawned(e: Dictionary) - signal entity_despawned(vid: int) - signal entity_dead(vid: int) - signal entity_moved(vid: int) - signal entity_main_set(vid: int) - signal vitals_updated(vid: int) - signal vitals_changed(vid: int) - signal warp_requested(x: float, y: float) - signal ground_item_added(item: Dictionary) - signal ground_item_removed(vid: int) - signal chat(mode: int, text: String) - signal chat_bubble(vid: int, text: String) - signal damage(target_vid: int, attacker_vid: int, damage_val: int, flag: int) - signal whisper_received(author: String, text: String) - - var _main_vid := 1 - var picked_vids: Array[int] = [] - - func get_main_vid() -> int: - return _main_vid - - func get_entity(vid: int) -> Dictionary: - # The ground item owner is the local player. Keep the fixture aligned with - # the 40250 ownership pre-check instead of accidentally testing rejection. - return {"vid": vid, "pos": Vector3.ZERO, "race": 101, "name": "Hero" if vid == _main_vid else "Wild Dog", "hp": 100, "max_hp": 100} - - func get_ground_items() -> Array: - return [] - - func pickup_item(vid: int) -> bool: - picked_vids.append(vid) - return true - -class MockWorld extends Node: - func sample_height(x: float, z: float) -> float: - return 5.5 - -func _init() -> void: - print("--- Running mob_death_drop_test ---") - test_mob_death_and_despawn() - test_player_controller_ignores_dead() - test_ground_items_coordinate_mapping() - print("mob_death_drop_test passed with 0 failures!") - await process_frame - await process_frame - call_deferred("quit", 0) - -func test_mob_death_and_despawn() -> void: - var client = MockClient.new() - var mount = Node3D.new() - root.add_child(mount) - - var net_world = NetWorld.new() - root.add_child(net_world) - net_world.setup(client, mount) - - # Spawn a mob with tags - var mob_vid := 100 - var mob_node := Node3D.new() - mob_node.name = "entity_%d" % mob_vid - var name_tag := Label3D.new() - name_tag.name = "NameTag" - name_tag.text = "Wild Dog" - mob_node.add_child(name_tag) - var shadow := Decal.new() - shadow.name = "Shadow" - mob_node.add_child(shadow) - mount.add_child(mob_node) - - net_world._by_vid[mob_vid] = mob_node - net_world._make_hp_bar(mob_vid, {"hp": 100, "max_hp": 100, "name": "Wild Dog"}) - net_world.set_target_vid(mob_vid) - net_world.set_hover_vid(mob_vid) - - assert(net_world._hp_bars.has(mob_vid), "HP bar should exist initially") - assert(net_world._target_vid == mob_vid, "Target should be mob_vid") - assert(net_world._hover_vid == mob_vid, "Hover should be mob_vid") - assert(name_tag.visible == true, "NameTag should be visible initially") - - # Trigger death - net_world._on_dead(mob_vid) - - assert(not net_world._hp_bars.has(mob_vid), "HP bar should be removed immediately on death") - assert(name_tag.visible == false, "NameTag should be detached/hidden on death") - assert(shadow.visible == false, "Shadow should be hidden on death") - assert(bool(mob_node.get_meta("dead", false)) == true, "Mob should have dead meta") - assert(net_world._target_vid == 0, "Target should be cleared on death") - assert(net_world._hover_vid == 0, "Hover should be cleared on death") - - # Trigger despawn - net_world._on_despawn(mob_vid) - assert(not net_world._by_vid.has(mob_vid), "Mob should be erased from _by_vid on despawn") - print("OK: Mob death detach & despawn lifecycle verified") - - net_world.free() - mount.free() - client.free() - -func test_player_controller_ignores_dead() -> void: - var pc = PlayerController.new() - var player = Node3D.new() - player.position = Vector3(0, 0, 0) - root.add_child(player) - pc.player = player - - var dead_mob = Node3D.new() - dead_mob.position = Vector3(0, 0, 2) - dead_mob.set_meta("dead", true) - root.add_child(dead_mob) - - pc.pickables.clear() - pc.pickables.append(dead_mob) - - # Test collision check (line 317) - var blocked: bool = pc._actor_blocked(Vector3(0, 0, 1.8)) - assert(not blocked, "Dead mob should not block player movement") - - print("OK: PlayerController ignores dead mobs for collision and picking") - pc.free() - player.free() - dead_mob.free() - -func test_ground_items_coordinate_mapping() -> void: - # Set map base position (e.g. Map 1: 400000 cm X, 300000 cm Y) - MapCoord.set_base(Vector2(400000, 300000)) - - var client = MockClient.new() - var mount = Node3D.new() - root.add_child(mount) - - var world = MockWorld.new() - root.add_child(world) - - var player = Node3D.new() - player.position = Vector3(100, 5.5, 200) # Player is at world (100, 5.5, 200) - root.add_child(player) - - var gi = GroundItems.new() - root.add_child(gi) - - gi.setup(client, mount, func() -> Node: return player, null, null, - Callable(), "", func() -> Node: return world) - - # Net position from server: - # Metin2 global: X = 410000 cm, Y = 320000 cm (local world should be: X = 100m, Z = 200m) - # M2Client position_to_godot produces: - # net.x = 4100.0, net.y = 0.0, net.z = -3200.0 - var net_drop_pos := Vector3(4100.0, 0.0, -3200.0) - var drop_vid := 9999 - var drop_dict := { - "vid": drop_vid, - "vnum": 1, # Yang / Gold - "owner": "Hero", - "pos": net_drop_pos - } - - gi._on_added(drop_dict) - - assert(gi._by_vid.has(drop_vid), "Ground item should be registered in _by_vid") - var drop_node: Node3D = gi._by_vid[drop_vid] - assert(drop_node != null, "Drop node should exist") - - # Check world coordinate translation - assert(is_equal_approx(drop_node.position.x, 100.0), "X should be translated to 100m") - assert(is_equal_approx(drop_node.position.z, 200.0), "Z should be translated to 200m") - # Check terrain height snapping (+0.1m above 5.5m = 5.6m) - assert(is_equal_approx(drop_node.position.y, 5.6), "Y should be snapped to terrain height + 0.1m") - - # Check item name for vnum 1 - var tag := drop_node.get_node("tag") as Label3D - assert(tag != null and tag.text == "Yang", "Vnum 1 name should be Yang") - - # Test pickup with player near (distance = 0.1m < PICKUP_RANGE 3.0m) - var picked_vid := gi.try_pickup() - assert(picked_vid == drop_vid, "try_pickup should pick nearby item") - assert(client.picked_vids.has(drop_vid), "client.pickup_item should be called") - - # Test remove - gi._on_removed(drop_vid) - assert(not gi._by_vid.has(drop_vid), "Item should be removed from _by_vid") - - print("OK: Ground items coordinate mapping, height snapping, Yang naming, and pickup verified") - - # Reset MapCoord base - MapCoord.set_base(Vector2.ZERO) - - gi.free() - world.free() - player.free() - mount.free() - client.free() diff --git a/project/mob_death_drop_test.gd.uid b/project/mob_death_drop_test.gd.uid deleted file mode 100644 index f563a0be..00000000 --- a/project/mob_death_drop_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bys5ua0nphks7 diff --git a/project/mob_drop_scatter.gd b/project/mob_drop_scatter.gd deleted file mode 100644 index 8b3f478a..00000000 --- a/project/mob_drop_scatter.gd +++ /dev/null @@ -1,64 +0,0 @@ -# mob_drop_scatter.gd —— 40250 怪物死亡爆装与辐射散射 1:1 规则引擎 -# 严格对照: -# metin2/src/server/game/src/char_battle.cpp:840-930 (pos.x = number(-7, 7) * 20) -# metin2/src/server/game/src/item.cpp:SetOwnership (ITEM_OWNERSHIP_TIME = 30) -extends RefCounted - -const ITEM_OWNERSHIP_DURATION := 30.0 # 40250 官方 30 秒拾取归属保护期 -const CM_TO_METERS := 0.01 - -# 单个掉落物基础散射偏移区间:[-140cm, +140cm],即 [-1.4m, +1.4m] -const MIN_SCATTER_CM := -140 -const MAX_SCATTER_CM := 140 - -const SOUND_MONEY := "sound/ui/money.wav" -const SOUND_DROP := "sound/ui/drop.wav" - -# 计算第 index 个掉落物相对于怪物中心的世界偏移量 -# 1:1 对齐 char_battle.cpp: number(-7, 7) * 20cm -static func calculate_scatter_offset(index: int, total_count: int, seed_val := -1) -> Vector3: - if total_count <= 1: - # 单个物品:在 [-1.4m, +1.4m] 范围内轻微偏移 - var rng := RandomNumberGenerator.new() - if seed_val >= 0: - rng.seed = seed_val - else: - rng.randomize() - var dx: float = (rng.randi_range(-7, 7) * 20) * CM_TO_METERS - var dz: float = (rng.randi_range(-7, 7) * 20) * CM_TO_METERS - return Vector3(dx, 0.0, dz) - - # 多个物品(首领爆装):360 度环状辐射发散,半径 1.0m ~ 1.8m - var angle_step := TAU / float(maxi(1, total_count)) - var base_angle := angle_step * float(index) - var rng := RandomNumberGenerator.new() - if seed_val >= 0: - rng.seed = seed_val + index * 37 - else: - rng.randomize() - - # 半径在 1.0m ~ 1.6m 之间带微量抖动 - var radius := 1.0 + rng.randf_range(0.0, 0.6) - var jitter_angle := rng.randf_range(-0.2, 0.2) - var angle := base_angle + jitter_angle - - var dx: float = cos(angle) * radius - var dz: float = sin(angle) * radius - return Vector3(dx, 0.0, dz) - -# 计算最终落地世界坐标 -static func calculate_drop_position(center_pos: Vector3, index: int, total_count: int, seed_val := -1) -> Vector3: - var offset := calculate_scatter_offset(index, total_count, seed_val) - return center_pos + offset - -# 归属保护期判定 -static func is_ownership_protected(drop_time: float, current_time: float) -> bool: - return (current_time - drop_time) < ITEM_OWNERSHIP_DURATION - -# 剩余归属秒数 -static func remaining_ownership_time(drop_time: float, current_time: float) -> float: - return maxf(0.0, ITEM_OWNERSHIP_DURATION - (current_time - drop_time)) - -# 匹配掉落音效 -static func get_drop_sound(is_gold: bool) -> String: - return SOUND_MONEY if is_gold else SOUND_DROP diff --git a/project/mob_drop_scatter.gd.uid b/project/mob_drop_scatter.gd.uid deleted file mode 100644 index 877563f8..00000000 --- a/project/mob_drop_scatter.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dkahpfg32tsx1 diff --git a/project/mob_view_test.gd b/project/mob_view_test.gd deleted file mode 100644 index 45e93f59..00000000 --- a/project/mob_view_test.gd +++ /dev/null @@ -1,215 +0,0 @@ -# mob_view_test —— 怪 / NPC 真模型(MobView)headless 自检。 -# godot --headless --path project --script mob_view_test.gd -# 需要 Metin2Model 扩展 + Metin2 资产(AssetRoot.path());缺就跳过对应检查。 -extends SceneTree - -const MobView = preload("res://ui/mob_view.gd") -const Audio = preload("res://audio.gd") -const MsaMotion = preload("res://ui/msa_motion.gd") - -var _fail := 0 -var _resolution_done := false -var _move_speed_done := false -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: mob_view_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -class FakeAnim: - extends Node - var anim_path := "" - var loop := false - var time_scale := 1.0 - -# 纯解析:不需要扩展和资产。旧客户端 CRaceData::GetMotionKey 找不到动作时 -# SetLoopMotion/InterceptMotion 直接返回、保留当前动作;不能静默换成 WAIT。 -func _check_motion_resolution() -> void: - var mv: Node3D = MobView.new() - mv._dir = "res://__no_such_mob_dir__" - mv._motions = {"WAIT": "wait.msa", "NORMAL_ATTACK": "attack.msa", "FRONT_DEAD": "dead.msa"} - var dead: Dictionary = mv.resolve_motion("dead") - _ck(dead.get("requested_state") == "dead" and dead.get("motion") == "FRONT_DEAD" \ - and dead.get("path") == "dead.msa" and dead.get("fallback_reason") == "", "dead -> FRONT_DEAD 无回退") - var run: Dictionary = mv.resolve_motion("run") - _ck(run.get("path") == "" and run.get("motion") == "" \ - and run.get("fallback_reason") == "reference_keep_current_motion", "缺 RUN/WALK -> 保留当前动作(%s)" % run) - _ck(mv.get_motion_path("run") == "", "缺 RUN/WALK 时 get_motion_path 不返回 WAIT") - mv._motions = {"WAIT": "wait.msa"} - _ck(mv.get_motion_path("dead") == "", "缺死亡动作时不能解析成 WAIT") - mv._motions = {"WAIT": "wait.msa", "WALK": "walk.msa"} - var alias: Dictionary = mv.resolve_motion("run") - _ck(alias.get("path") == "" and alias.get("fallback_reason") == "reference_keep_current_motion", - "缺 RUN 时不能把 WALK 当作 RUN") - mv._motions = {"WAIT": "wait.msa"} - var anim := FakeAnim.new() - mv.anim = anim - mv.set_anim_state("wait") - mv.set_anim_state("run") - _ck(anim.anim_path == "wait.msa" and anim.loop, "缺 run 时保留当前 WAIT 播放") - _ck(mv.last_motion_resolution().get("fallback_reason") == "reference_keep_current_motion", "记录最后一次解析的回退原因") - mv.anim = null - anim.free() - mv.free() - _resolution_done = true - -# CActorInstance::Move:走 / 跑按 m_fMovSpd = movSpd/100 播放;根运动速度 -# = |.msa Accumulation| / MotionDuration(wolf 03.msa:255.45 / 0.6 = 425.75 cm/s)。 -func _write_msa(path: String, duration: float, ay: float) -> void: - var f := FileAccess.open(path, FileAccess.WRITE) - f.store_string("ScriptType MotionData\n\nMotionFileName \"x.gr2\"\n" + - "MotionDuration %f\nAccumulation 0.00\t%.2f\t0.00\n" % [duration, ay]) - f.close() - -func _check_move_speed() -> void: - var walk_p := "user://__mob_view_test_walk.msa" - var run_p := "user://__mob_view_test_run.msa" - _write_msa(walk_p, 0.866667, -75.81) - _write_msa(run_p, 0.6, -255.45) - _ck(absf(MsaMotion.move_speed(run_p) - 425.75) < 0.01, "run msa 速度 425.75(%f)" % MsaMotion.move_speed(run_p)) - _ck(absf(MsaMotion.move_speed(walk_p) - 87.47) < 0.01, "walk msa 速度 87.47(%f)" % MsaMotion.move_speed(walk_p)) - _ck(MsaMotion.move_speed("user://__no_such.msa") == 0.0, "缺文件 -> 0") - var mv: Node3D = MobView.new() - mv._dir = "res://__no_such_mob_dir__" - mv._motions = {"WAIT": "wait.msa", "WALK": walk_p, "RUN": run_p} - var sp: Vector2 = mv.get_move_motion_speeds() - _ck(absf(sp.x - 87.47) < 0.01 and absf(sp.y - 425.75) < 0.01, "get_move_motion_speeds(%s)" % sp) - var anim := FakeAnim.new() - mv.anim = anim - mv.set_move_speed(200) - mv.set_anim_state("wait") - _ck(anim.time_scale == 1.0, "wait 不受移动速度影响") - mv.set_anim_state("run") - _ck(anim.anim_path == run_p and anim.time_scale == 2.0, "run 按 movSpd/100 播放(%f)" % anim.time_scale) - mv.set_move_speed(150) - _ck(anim.time_scale == 1.5, "跑动中改速立即生效(%f)" % anim.time_scale) - mv.set_move_speed(1200) - _ck(anim.time_scale == 1.0, ">1100 保持原速(%f)" % anim.time_scale) - mv.anim = null - anim.free() - mv.free() - DirAccess.remove_absolute(ProjectSettings.globalize_path(walk_p)) - DirAccess.remove_absolute(ProjectSettings.globalize_path(run_p)) - _move_speed_done = true - -func _check_motlist_registry() -> void: - var root := ProjectSettings.globalize_path("user://mob_motion_registry_test") - DirAccess.make_dir_recursive_absolute(root) - var body := "GENERAL WAIT wait.msa 60\n" + \ - "GENERAL WAIT1 wait1.msa 20\n" + \ - "GENERAL WAIT3 wait3.msa 20\n" + \ - "GENERAL WALK walk.msa 100\n" + \ - "GENERAL COMBO_ATTACK combo1.msa 100\n" + \ - "GENERAL COMBO_ATTACK1 combo2.msa 100\n" + \ - "GENERAL NORMAL_ATTACK normal.msa 100\n" + \ - "GENERAL FRONT_DAMAGE front.msa 40\n" + \ - "GENERAL DAMAGE unknown.msa 100\n" + \ - "GENERAL FRONT_DAMAGE2 front2.msa 37\n" - for file in ["wait.msa", "wait1.msa", "wait3.msa", "walk.msa", "combo1.msa", - "combo2.msa", "normal.msa", "front.msa", "unknown.msa", "front2.msa"]: - var f := FileAccess.open(root.path_join(file), FileAccess.WRITE) - f.store_string("") - f.close() - var motlist := root.path_join("motlist_range.txt") - var mf := FileAccess.open(motlist, FileAccess.WRITE) - mf.store_string(body) - mf.close() - var msm := FileAccess.open(root.path_join("range.msm"), FileAccess.WRITE) - msm.store_string("MotionListFileName \"motlist_range.txt\"\n") - msm.close() - - var mv: Node3D = MobView.new() - mv._dir = root - var selected_list: String = mv._motion_list_for_dir(root, "range") - _ck(selected_list == motlist, "MSM MotionListFileName 优先于固定 motlist.txt") - mv._load_motlist(selected_list) - _ck(mv._motlist_loaded and mv._motion_variants.has(1), "加载 NPC motlist 注册表") - var waits: Array = mv._motion_variants[1] - _ck(waits.size() == 3 and int(waits[0].weight) == 60 and int(waits[1].weight) == 20 and - int(waits[2].weight) == 20, "WAIT/WAIT1/WAIT3 归并到同一索引并保留权重") - _ck(not mv._motion_variants.has(33), "未注册 SPECIAL 不凭空生成技能动作") - _ck(not mv._motion_variants.has(0) and not mv._motions.has("DAMAGE"), - "未知 DAMAGE 不注册到动作表") - _ck(mv._motion_variants.has(14) and mv._motion_variants[14].size() == 1 and - mv._motion_variants.has(15) and mv._motion_variants[15].size() == 1, - "COMBO_ATTACK 与 COMBO_ATTACK1 使用不同动作索引") - var wait: Dictionary = mv.resolve_motion("wait") - _ck(String(wait.path) in [root.path_join("wait.msa"), root.path_join("wait1.msa"), root.path_join("wait3.msa")], - "WAIT 从注册 vector 中按权重选择") - var run: Dictionary = mv.resolve_motion("run") - _ck(run.path == "" and run.fallback_reason == "reference_keep_current_motion", - "缺 RUN 注册时保持当前动作") - _ck(mv._motion_variants.has(25) and mv._motion_variants[25][0].path == root.path_join("front2.msa") and - int(mv._motion_variants[25][0].weight) == 37, - "缺 SPAWN 时复用最后一条 DAMAGE 资源及解析出的权重") - mv.free() - for file in ["range.msm", "motlist_range.txt", "wait.msa", "wait1.msa", "wait3.msa", "walk.msa", - "combo1.msa", "combo2.msa", "normal.msa", "front.msa", "unknown.msa", "front2.msa"]: - DirAccess.remove_absolute(root.path_join(file)) - DirAccess.remove_absolute(root) - -func _run() -> void: - _check_motion_resolution() - _check_move_speed() - _check_motlist_registry() - _ck(_move_speed_done, "移动速度断言全部执行") - # 脚本运行时错误只会中止函数、不计失败;用哨兵保证整段断言都执行过。 - _ck(_resolution_done, "动作解析断言全部执行") - if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2Proto"): - print(" (skip: 扩展未注册)") - return - var assets := AssetRoot.path() - if not DirAccess.dir_exists_absolute(assets.path_join("Monster")): - print(" (skip: 无 Monster 资产)") - return - var proto: Object = ClassDB.instantiate("Metin2Proto") - get_root().add_child(proto) - var audio: Node = Audio.new() - get_root().add_child(audio) - audio.setup(assets) - var mp := AssetRoot.locale_file("mob_proto") - if mp == "" or not proto.call("load_mob_proto", mp): - print(" (skip: 无 mob_proto)") - return - - # race 102 = Wolf -> assets/*/ymir work/monster/wolf/ - var mv: Node3D = MobView.new() - get_root().add_child(mv) - var ok: bool = mv.build(assets, proto, 102) - _ck(ok, "MobView.build(102 = Wolf) 成功") - if not ok: - return - mv.set_audio(audio) - _ck(mv._dir.get_file() == "wolf", "解到 wolf 目录(%s)" % mv._dir) - _ck(mv.model != null and mv.model.get_class() == "Metin2Model", "Metin2Model 建好") - _ck(mv._motions.size() >= 3, "motlist.txt 解出 %d 个动作" % mv._motions.size()) - _ck(mv._motions.has("WAIT"), "有 WAIT 动作") - mv.set_display_name("Wolf") - _ck(mv.get_node_or_null("Label3D") != null and - (mv.get_node("Label3D") as Label3D).text == "Wolf", - "set_display_name -> nameplate") - # 状态切换:run -> RUN / attack -> NORMAL_ATTACK(存在才断言路径变化) - mv.set_anim_state("run") - var s1: String = mv._state - mv.set_anim_state("attack") - _ck(mv._state == "attack" and s1 == "run", "set_anim_state 切换记录") - _ck(mv._sound_instances.size() > 0, "MobView attack motion loads paired .mss instances") - - # 未知 race -> build 失败(返回 null 用占位) - var mv2: Node3D = MobView.new() - get_root().add_child(mv2) - _ck(not mv2.build(assets, proto, 999999), "未知 race -> build 失败") - - # race 20009 = Old Man(NPC)——若有目录就该建成 - var mv3: Node3D = MobView.new() - get_root().add_child(mv3) - var npc_ok: bool = mv3.build(assets, proto, 20009) - print(" NPC 20009 (Old Man) build = %s (%s)" % [npc_ok, mv3._dir]) diff --git a/project/mob_view_test.gd.uid b/project/mob_view_test.gd.uid deleted file mode 100644 index e3c1e7bc..00000000 --- a/project/mob_view_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b5dp6gbugrc1t diff --git a/project/mob_winding_test.gd b/project/mob_winding_test.gd deleted file mode 100644 index b6f05d8a..00000000 --- a/project/mob_winding_test.gd +++ /dev/null @@ -1,43 +0,0 @@ -extends SceneTree -const MobView = preload("res://ui/mob_view.gd") -var failures := 0 -func _init() -> void: - call_deferred("run") -func run() -> void: - for mode in ["0", "1"]: - OS.set_environment("MTGODOT_GPUSKIN", mode) - # 101/110/20001 是原全怪物回归基线;2301-2315 是首测鬼木林树怪(MAP-01)。 - for race in [101, 110, 20001, 2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]: - var view := MobView.new() - if not view.build(AssetRoot.path(), null, race): - failures += 1 - view.free() - continue - root.add_child(view) - view.anim.set_process(false) - view.anim.set_time(0.3) - var signed_area := 0.0 - var total_area := 0.0 - for instance in view.model.find_children("*", "MeshInstance3D", true, false): - for surface in instance.mesh.get_surface_count(): - var arrays: Array = instance.mesh.surface_get_arrays(surface) - var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] - var normals: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL] - var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] - for i in range(0, indices.size(), 3): - var a := indices[i] - var b := indices[i + 1] - var c := indices[i + 2] - var cross := (vertices[b] - vertices[a]).cross(vertices[c] - vertices[a]) - signed_area += cross.dot((normals[a] + normals[b] + normals[c]).normalized()) - total_area += cross.length() - var ratio := signed_area / maxf(total_area, 0.00001) - print("MOB winding race=%d GPU=%s normal_alignment=%f" % [race, mode, ratio]) - # Godot's outward front face is clockwise; geometric cross is -normal. - if ratio >= -0.5: - failures += 1 - printerr("FAIL: NPC/monster faces disagree with outward normals") - view.free() - await process_frame - print("mob_winding_test: failures=%d" % failures) - quit(1 if failures else 0) diff --git a/project/mob_winding_test.gd.uid b/project/mob_winding_test.gd.uid deleted file mode 100644 index 7b9aee3a..00000000 --- a/project/mob_winding_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dai84hb3pgfqd diff --git a/project/mobile_gesture_test.gd b/project/mobile_gesture_test.gd deleted file mode 100644 index 0cdd219a..00000000 --- a/project/mobile_gesture_test.gd +++ /dev/null @@ -1,212 +0,0 @@ -# mobile_gesture_test —— 移动端长按、瞄准、快捷栏交换和物品触摸拖放。 -# godot --display-driver headless --audio-driver Dummy --rendering-method gl_compatibility --path project --script mobile_gesture_test.gd -extends SceneTree - -const TouchButton = preload("res://ui/mobile/mobile_touch_button.gd") -const Quickbar = preload("res://ui/quickbar.gd") -const SkillTable = preload("res://ui/skill_table.gd") -const SkillUI = preload("res://ui/skill_ui.gd") -const UiManager = preload("res://ui/ui_manager.gd") -const InventoryUI = preload("res://ui/inventory_ui.gd") -const MouseController = preload("res://ui/mouse_controller.gd") - -class FakeClient extends Node: - signal quickslots_changed - var inventory := [{"cell": 5, "vnum": 19, "count": 2}] - var skills := [{"id": 1, "level": 5, "master": 0}, {"id": 16, "level": 3, "master": 0}] - var quickslots := [] - var calls := [] - var casts := [] - var main := 1000 - func get_main_vid() -> int: return main - func get_entity(_vid: int) -> Dictionary: return {"vid": _vid, "kind": 2, "race": 0} - func get_target() -> Dictionary: return {"vid": 2000} - func get_inventory() -> Array: return inventory - func get_equipment() -> Array: return [] - func get_skills() -> Array: return skills - func get_skill_group() -> int: return 1 - func get_points() -> Dictionary: return {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2} - func get_quickslots() -> Array: return quickslots - func use_skill(id: int, target: int) -> bool: - calls.append(["use_skill", id, target]); return true - func cast_skill(motion: int, heading: float, x: int, y: int, _arg: int = 0) -> bool: - casts.append([motion, heading, x, y]); return true - func quickslot_add(pos: int, kind: int, ref: int) -> bool: - calls.append(["quickslot_add", pos, kind, ref]) - quickslots = quickslots.filter(func(q): return int(q.get("pos", -1)) != pos) - quickslots.append({"pos": pos, "type": kind, "ref": ref}) - quickslots_changed.emit() - return true - func quickslot_swap(a: int, b: int) -> bool: - calls.append(["quickslot_swap", a, b]) - var ai := -1 - var bi := -1 - for i in quickslots.size(): - if int(quickslots[i].get("pos", -1)) == a: ai = i - if int(quickslots[i].get("pos", -1)) == b: bi = i - if ai >= 0 and bi >= 0: - var tmp: Dictionary = quickslots[ai] - quickslots[ai] = quickslots[bi] - quickslots[bi] = tmp - quickslots[ai]["pos"] = a - quickslots[bi]["pos"] = b - elif ai >= 0: - quickslots[ai]["pos"] = b - elif bi >= 0: - quickslots[bi]["pos"] = a - quickslots_changed.emit() - return true - func use_item(window: int, cell: int) -> bool: - calls.append(["use_item", window, cell]); return true - func move_item(fw: int, fc: int, tw: int, tc: int, count: int) -> bool: - calls.append(["move_item", fw, fc, tw, tc, count]); return true - -class FakeProto extends Node: - func item(vnum: int) -> Dictionary: - return {"vnum": vnum, "name": "Item%d" % vnum, "locale_name": "物品%d" % vnum, - "type": 1, "sub_type": 0, "anti_flags": 0, "wear_flags": 1 << 4, - "limits": []} - -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: mobile_gesture_test (long press + aim + quickbar swap + item touch drag)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - await process_frame - # 1) 长按按钮必须保留同一 touch index,并在拖动松手时发出完成信号。 - var gesture_host := Control.new() - get_root().add_child(gesture_host) - var gesture := TouchButton.new() - gesture.setup("技能", Color(0.4, 0.7, 1.0)) - gesture.set_gesture_enabled(true) - gesture.size = Vector2(72, 72) - gesture_host.add_child(gesture) - var press_count := [0] - var long_count := [0] - var release_count := [0] - gesture.pressed.connect(func(): press_count[0] += 1) - gesture.long_pressed.connect(func(): long_count[0] += 1) - gesture.drag_released.connect(func(_delta: Vector2): release_count[0] += 1) - var down := InputEventScreenTouch.new() - down.index = 41 - down.pressed = true - down.position = Vector2(30, 30) - gesture._gui_input(down) - await create_timer(0.65).timeout - var drag := InputEventScreenDrag.new() - drag.index = 41 - drag.position = Vector2(52, 12) - gesture._gui_input(drag) - var up := InputEventScreenTouch.new() - up.index = 41 - up.pressed = false - up.position = drag.position - gesture._gui_input(up) - _ck(press_count[0] == 1, "长按手势只触发一次按下") - _ck(long_count[0] == 1, "超过阈值后触发 long_pressed") - _ck(release_count[0] == 1 and not gesture.active, "拖动松手发出 drag_released 并清理状态") - - # 2) 快捷栏保持服务器槽位交换,同时瞄准方向进入 cast_skill heading。 - var assets := AssetRoot.path() - var table := SkillTable.new() - var loaded := false - for lang in ["en", "common"]: - if table.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)): - loaded = true - break - if not loaded: - print(" (skip: no skilldesc.txt)") - return - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - ui.set_mobile_mode(true) - var client := FakeClient.new() - get_root().add_child(client) - var player := Node3D.new() - get_root().add_child(player) - var quickbar: Node = Quickbar.new() - get_root().add_child(quickbar) - quickbar.setup(client, table, ui, func() -> Node: return player) - var skill_ui: Node = SkillUI.new() - get_root().add_child(skill_ui) - skill_ui.setup(client, table, ui) - skill_ui.set_mobile_mode(true) - skill_ui.set_mobile_quickbar(quickbar) - skill_ui.open() - await process_frame - _ck(skill_ui._mobile_drop_slots.size() == 8, "技能页创建 8 个移动快捷栏落点") - # 用真实技能行按钮的拖放结束坐标,验证技能可放入指定槽位。 - var source: TouchButton = null - for row in skill_ui._win.get_node("SkillScroll/list").get_children(): - if row.get_child_count() > 0 and row.get_child(0) is TouchButton: - source = row.get_child(0) - break - if source: - var target_pos: Vector2 = skill_ui._mobile_drop_slots[2].get_global_rect().get_center() - source._last_position = target_pos - skill_ui._mobile_begin_skill_drag(1, source) - skill_ui._mobile_end_skill_drag(1, source) - _ck(quickbar.mobile_slot_state(2).get("id", 0) == 1, - "长按技能拖放到第 3 格快捷栏") - quickbar.assign(0, "skill", 16) - quickbar.mobile_swap(0, 2) - _ck(quickbar.mobile_slot_state(0).get("id", 0) == 1 - and quickbar.mobile_slot_state(2).get("id", 0) == 16, - "移动快捷栏交换更新本地槽位") - _ck(client.calls.has(["quickslot_swap", 0, 2]), - "移动快捷栏交换沿用 CG_QUICKSLOT_SWAP") - quickbar.activate_aimed(0, Vector2(1, 0)) - _ck(client.casts.size() == 1 and client.calls.has(["use_skill", 1, 2000]), - "技能长按瞄准仍走 use_skill + cast_skill") - - # 3) 背包长按详情后继续拖动,松手由 MouseController 落到目标格。 - var proto := FakeProto.new() - get_root().add_child(proto) - var mouse: Node = MouseController.new() - get_root().add_child(mouse) - mouse.setup(ui) - var inventory: Node = InventoryUI.new() - inventory.item_mouse = mouse - get_root().add_child(inventory) - inventory.setup(ui, client, proto, assets) - inventory.set_mobile_mode(true) - inventory.open() - await process_frame - var source_cell: Panel = inventory._cells.get(5) - var target_cell: Panel = inventory._cells.get(8) - if source_cell and target_cell: - var item_down := InputEventScreenTouch.new() - item_down.index = 52 - item_down.pressed = true - item_down.position = source_cell.get_global_rect().get_center() - inventory._on_cell_input(5, item_down) - await create_timer(0.65).timeout - _ck(inventory._mobile_detail_dialog != null, "物品长按先打开详情卡") - var item_drag := InputEventScreenDrag.new() - item_drag.index = 52 - item_drag.position = target_cell.get_global_rect().get_center() - inventory._handle_mobile_cell_drag(5, item_drag) - _ck(mouse.is_attached(), "继续拖动后进入全局物品拖放状态") - var item_up := InputEventScreenTouch.new() - item_up.index = 52 - item_up.pressed = false - item_up.position = item_drag.position - inventory._input(item_up) - mouse._input(item_up) - _ck(client.calls.has(["move_item", 1, 5, 1, 8, 2]), - "物品触摸松手落到目标格并保留堆叠数量") - else: - _ck(false, "inventory source/target cells exist") diff --git a/project/mobile_gesture_test.gd.uid b/project/mobile_gesture_test.gd.uid deleted file mode 100644 index 0cc60f18..00000000 --- a/project/mobile_gesture_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://j1ufi6gp5au7 diff --git a/project/mobile_ui_test.gd b/project/mobile_ui_test.gd deleted file mode 100644 index a1310921..00000000 --- a/project/mobile_ui_test.gd +++ /dev/null @@ -1,116 +0,0 @@ -# mobile_ui_test —— mobile presentation smoke test without a server or assets. -# godot --headless --path project --script mobile_ui_test.gd -extends SceneTree - -const UiProfile := preload("res://ui/ui_profile.gd") -const MobileHud := preload("res://ui/mobile/mobile_hud.gd") -const MobileUiRoot := preload("res://ui/mobile/mobile_ui_root.gd") -const MobileMenuDrawer := preload("res://ui/mobile/mobile_menu_drawer.gd") -const MobileWindowHost := preload("res://ui/mobile/mobile_window_host.gd") -const PlayerCtl := preload("res://player_controller.gd") - -class FakeClient extends Node: - signal party_changed - signal vitals_changed(vid: int) - var main := 7 - var entity := {"vid": 7, "name": "测试角色", "race": 0, "level": 12} - var party := [{"vid": 7, "name": "测试角色", "hp_pct": 100, "leader": true}] - func get_main_vid() -> int: return main - func get_entity(_vid: int) -> Dictionary: return entity - func get_party() -> Array: return party - func set_target(_vid: int) -> bool: return true - -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: mobile_ui_test (profile / landscape HUD / menu / controls)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - await process_frame - _ck(UiProfile.parse("mobile") == UiProfile.Mode.MOBILE, "profile parses mobile") - _ck(UiProfile.parse("desktop") == UiProfile.Mode.DESKTOP, "profile parses desktop") - _ck(UiProfile.parse("auto") == UiProfile.Mode.AUTO, "profile parses auto") - _ck(MobileUiRoot.requires_landscape(Vector2(844, 390)) == false, - "landscape design size is accepted") - _ck(MobileUiRoot.requires_landscape(Vector2(390, 844)), - "portrait viewport is rejected") - var safe := MobileWindowHost.safe_rect_for(Vector2(844, 390), - Rect2(20, 8, 804, 374)) - _ck(safe == Rect2(20, 8, 804, 374), "reported safe area is preserved") - _ck(MobileWindowHost.safe_rect_for(Vector2(844, 390), - Rect2(0, 0, 1920, 1080)) == Rect2(0, 0, 844, 390), - "physical-screen safe area is rejected for a logical viewport") - - var client := FakeClient.new() - get_root().add_child(client) - var root := MobileUiRoot.new() - get_root().add_child(root) - root.setup(null, null, client) - root.bind_controls({"client": client}) - await process_frame - - _ck(root.hud_view is MobileHud, "mobile root owns mobile HUD") - _ck(root.input_overlay != null, "mobile root owns touch overlay") - _ck(root.hud_view._player_name.text == "测试角色", "avatar card uses server player name") - root.set_vitals(50, 100, 20, 40) - _ck(root.hud_view._hp_bar.value == 50 and root.hud_view._mp_bar.value == 20, - "mobile HUD updates HP/SP through compatibility setters") - root.set_target("目标", 35) - _ck(root.hud_view._target_panel.visible and root.hud_view._target_bar.value == 35, - "mobile HUD shows target status") - - root.menu_drawer.open() - _ck(root.menu_drawer.is_open(), "mobile menu opens") - _ck(MobileMenuDrawer.CATEGORIES.size() == 5, "mobile menu has five top-level categories") - _ck(not MobileMenuDrawer.ITEMS["社交"].has("队伍"), "social menu has no party entry") - root.menu_drawer.close() - - var pc := PlayerCtl.new() - get_root().add_child(pc) - pc.player = Node3D.new() - get_root().add_child(pc.player) - root.input_overlay.bind_controls(pc, null, null, null) - await process_frame - var touch_pos: Vector2 = root.input_overlay._joystick.get_global_rect().get_center() - var ui_down := InputEventScreenTouch.new() - ui_down.index = 21 - ui_down.pressed = true - ui_down.position = touch_pos - root.input_overlay._input(ui_down) - _ck(pc._mobile_ui_touches.has(21), "UI touch is registered by world-touch arbiter") - var world_down := InputEventScreenTouch.new() - world_down.index = 22 - world_down.pressed = true - world_down.position = Vector2(600, 210) - pc._on_touch(world_down) - var world_up := InputEventScreenTouch.new() - world_up.index = 22 - world_up.pressed = false - world_up.position = world_down.position - pc._on_touch(world_up) - _ck(pc._multi_gesture, "joystick plus world touch cannot become tap-to-move") - var ui_up := InputEventScreenTouch.new() - ui_up.index = 21 - ui_up.pressed = false - ui_up.position = touch_pos - root.input_overlay._input(ui_up) - _ck(not pc._mobile_ui_touches.has(21), "UI touch arbiter clears on release") - pc.set_mobile_axis(Vector2(2, 0)) - _ck(pc._mobile_axis == Vector2(1, 0), "mobile movement axis is normalized") - pc.clear_mobile_input() - _ck(pc._mobile_axis == Vector2.ZERO, "mobile movement axis clears on cancellation") - - root.queue_free() - pc.queue_free() - client.queue_free() diff --git a/project/mobile_ui_test.gd.uid b/project/mobile_ui_test.gd.uid deleted file mode 100644 index effadd22..00000000 --- a/project/mobile_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b1vbbltrqfkes diff --git a/project/mobile_window_host_test.gd b/project/mobile_window_host_test.gd deleted file mode 100644 index 20cdb282..00000000 --- a/project/mobile_window_host_test.gd +++ /dev/null @@ -1,74 +0,0 @@ -# mobile_window_host_test —— 移动窗口安全区/按钮触摸/模态返回自检。 -# godot --display-driver headless --rendering-method gl_compatibility --path project --script mobile_window_host_test.gd -extends SceneTree - -const UiManager = preload("res://ui/ui_manager.gd") -const MobileWindowHost = preload("res://ui/mobile/mobile_window_host.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: mobile_window_host_test (safe area + touch bridge + modal back)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - await process_frame - ui.set_mobile_mode(true) - var win := Panel.new() - win.name = "TouchWindow" - win.size = Vector2(420, 280) - var button := Button.new() - button.text = "触摸按钮" - button.position = Vector2(20, 60) - button.size = Vector2(160, 52) - win.add_child(button) - var count := [0] - button.pressed.connect(func(): count[0] += 1) - ui.open(win) - MobileWindowHost.wire_buttons(win) - var down := InputEventScreenTouch.new() - down.index = 11 - down.pressed = true - down.position = Vector2(100, 100) - button.gui_input.emit(down) - var up := InputEventScreenTouch.new() - up.index = 11 - up.pressed = false - up.position = down.position - button.gui_input.emit(up) - _ck(count[0] == 1, "standard Button screen touch emits one press") - - var modal := Panel.new() - modal.name = "MobileModal" - modal.size = Vector2(200, 100) - modal.set_meta("mobile_modal", true) - modal.visible = true - win.add_child(modal) - _ck(ui.blocks_game_input(down), "mobile modal blocks screen touch from world") - _ck(ui.close_top(), "back closes the visible mobile modal first") - _ck(not modal.visible and ui.top() == win, "modal closes without closing its parent window") - _ck(ui.close_top(), "second back closes hosted feature window") - var native_modal := ConfirmationDialog.new() - native_modal.name = "NativeModal" - var canceled := [0] - native_modal.canceled.connect(func(): canceled[0] += 1) - ui.add_child(native_modal) - ui.track_mobile_modal(native_modal) - native_modal.popup_centered() - _ck(ui.blocks_game_input(down), "tracked native dialog blocks world input") - _ck(ui.close_top(), "back closes tracked native dialog first") - _ck(canceled[0] == 1, "tracked native dialog uses cancel action on back") - native_modal.queue_free() - ui.queue_free() diff --git a/project/mobile_window_host_test.gd.uid b/project/mobile_window_host_test.gd.uid deleted file mode 100644 index e51e806c..00000000 --- a/project/mobile_window_host_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://j5tq3hj1d8jk diff --git a/project/model_render_test.gd b/project/model_render_test.gd deleted file mode 100644 index e8fe7d13..00000000 --- a/project/model_render_test.gd +++ /dev/null @@ -1,437 +0,0 @@ -# model_render_test —— 不启动游戏、不连服务器的模型渲染自动回归。 -# -# godot --path project --script model_render_test.gd -# godot --path project --script model_render_test.gd -- --race 0 -# godot --path project --script model_render_test.gd -- --bless # 重建基线 -# -# 与 char_bench_test.gd 的区别:bench 只产图、判定靠人眼;这里对渲染结果算数值 -# 指标并断言,退出码即结论。覆盖范围仅限「离线装配的单角色」——没有地图、没有 -# 光照环境的真实动态、没有 40250 像素比对,因此它不能替代 gamescene_visual_test -# 和导出包验收,只用来挡住模型/发型/材质改动引入的回归。 -# -# 指标(每个 race): -# feet_gap_m 最低蒙皮顶点到 y=0 的距离(悬空/陷地) -# silhouette_px 正面剪影像素数(模型是否真的画出来了) -# hair_mean_lum 只画发型时,发型像素的平均亮度(多余高光会把它抬上去) -# hair_mean_lum_ctrl 同上,但换回旧的 StandardMaterial3D(roughness=1) —— 负对照, -# 用来证明这个指标现在仍然分得开好坏,而不是恒过 -# head_cover full 与 no_hair 在近景机位的差异像素占比(发型遮挡量) -# -# 绝对断言不依赖基线;head_cover / silhouette_px 这类因模型而异的量对比 -# test/rendering/model_baseline.json。改模型或改光照后用 --bless 重建基线, -# 并在计划书执行记录里写清为什么重建。 -extends SceneTree - -const PlayerView = preload("res://ui/player_view.gd") -const BASELINE := "res://../test/rendering/model_baseline.json" - -# 渲染尺寸:指标在 GDScript 里逐像素统计,512 是精度和耗时的折中。 -const SIZE := 512 -const BG := Color(1, 0, 1) # 洋红背景,模型不会出现这个颜色 - -# --- 绝对阈值(不依赖基线)------------------------------------------------ -# 脚底:bench 无地形,模型自带 ground_offset,实测四职业 |gap| <= 0.003 m。 -const FEET_GAP_MAX := 0.03 -# 剪影:正面 512x512 里角色至少占这么多像素,防止「什么都没画」也算过。 -const SILHOUETTE_MIN := 3000 -# 发型高光用「发型像素的平均亮度」度量。发色本身明暗差别很大(武士近黑、 -# 修罗银白),所以没有跨职业通用的绝对上限:绝对判定只用下面的负对照差, -# 具体数值按职业记进基线。旧材质相对新材质的实测抬升是 +0.011 ~ +0.037 -# (八个职业,见 test/rendering/README.md)。 -const LUM_BRIGHT := 0.20 # hair_lum_p99 只作参考量,不参与判定 -# 负对照必须比生产材质亮这么多,否则说明这条指标已经不分好坏了(例如有人把 -# 灯光调暗,两边都测不出高光)。八职业最小实测差 0.0109。 -const HAIR_CTRL_MARGIN := 0.008 - -# --- 基线容差 ------------------------------------------------------------- -const TOL_HEAD_COVER := 0.03 # 绝对 -const TOL_SILHOUETTE := 0.03 # 相对 -const TOL_HAIR_LUM := 0.006 # 绝对;最小的「旧材质」信号是 0.0109 - -var _out := "" -var _bless := false -var _fail: Array[String] = [] - -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 run() -> void: - root.size = Vector2i(SIZE, SIZE) - _bless = "--bless" in OS.get_cmdline_user_args() - if _bless and ("--legacy-winding" in OS.get_cmdline_user_args() or not _arg("race").is_empty()): - printerr("Baseline updates require all eight production races; remove diagnostic/race filters.") - quit(1) - return - _out = OS.get_environment("MT_RENDER_OUTPUT") - if _out.is_empty(): - _out = ProjectSettings.globalize_path("res://../build/rendering/modeltest-%d" - % Time.get_unix_time_from_system()) - DirAccess.make_dir_recursive_absolute(_out) - - var races: Array = [] - var only := _arg("race") - if only.is_empty(): - races = [0, 1, 2, 3, 4, 5, 6, 7] - else: - races = [int(only)] - - var baseline := _load_baseline() - var results := {} - _light() - var cam := Camera3D.new() - cam.near = 0.02 - root.add_child(cam) - cam.make_current() - - for race in races: - var m := await _measure(int(race), cam) - if m.is_empty(): - _fail.append("race %d: build failed" % race) - continue - results[str(race)] = m - _check_absolute(int(race), m) - if not _bless: - _check_baseline(int(race), m, baseline) - - var report := { - "passed": _fail.is_empty(), - "failures": _fail, - "blessed": _bless, - "races": results, - "thresholds": { - "feet_gap_max": FEET_GAP_MAX, "silhouette_min": SILHOUETTE_MIN, - "hair_ctrl_margin": HAIR_CTRL_MARGIN, "tol_hair_lum": TOL_HAIR_LUM, - "tol_head_cover": TOL_HEAD_COVER, "tol_silhouette": TOL_SILHOUETTE, - }, - "engine": Engine.get_version_info(), - "viewport": str(root.size), - "assets": AssetRoot.path(), - "headless": DisplayServer.get_name() == "headless", - "scope": "offline single-actor assembly; no map, no network, no 40250 pixel parity", - "sha256": {"res://bin/libmtgodot.macos.template_debug.dylib": - FileAccess.get_sha256("res://bin/libmtgodot.macos.template_debug.dylib"), - "res://model_render_test.gd": FileAccess.get_sha256("res://model_render_test.gd")}, - } - var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE) - f.store_string(JSON.stringify(report, "\t")) - f.close() - if _bless and _fail.is_empty(): - _write_baseline(results) - print("MODELRENDER: baseline written -> ", ProjectSettings.globalize_path(BASELINE)) - print("MODELRENDER: ", _out) - print(JSON.stringify(report)) - for msg in _fail: - printerr("MODELRENDER FAIL: ", msg) - print("MODELRENDER: ", "PASS" if _fail.is_empty() else "FAIL") - quit(0 if _fail.is_empty() else 1) - -# --- 单个 race 的测量 ----------------------------------------------------- - -func _measure(race: int, cam: Camera3D) -> Dictionary: - var pv := PlayerView.new() - root.add_child(pv) - if not pv.build(AssetRoot.path(), race): - pv.queue_free() - return {} - pv.set_anim_state("wait") - # Reproduce the pre-merge CPU index order to audit historical baselines. - if "--legacy-winding" in OS.get_cmdline_user_args(): - pv.model.set("flip_winding", false) - await process_frame - await process_frame - if pv.anim: - pv.anim.set("blend_time", 0.0) - pv.anim.call("set_time", 0.5) - pv.anim.set_process(false) - pv.rotation = Vector3.ZERO - await process_frame - - var body: MeshInstance3D = null - for mi in pv.find_children("*", "MeshInstance3D", true, false): - if mi.name == "MeshInstance3D": - body = mi - if body == null or body.mesh == null: - pv.queue_free() - return {} - var total := body.mesh.get_surface_count() - var base_mats: Array = [] - for s in total: - base_mats.append(body.get_surface_override_material(s)) - var hair_first := _hair_surface_first(pv, body, total) - - # 脚底:最低蒙皮顶点(bench 里地面就是 y=0) - var feet_y := INF - for mi in pv.find_children("*", "MeshInstance3D", true, false): - if mi.mesh: - feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y) - - var m := { - "body_sha256": FileAccess.get_sha256(String(pv.model.get("gr2_path"))), - "hair_sha256": FileAccess.get_sha256(String(pv.model.get("hair_gr2"))), - "body_gr2": String(pv.model.get("gr2_path")), - "hair_gr2": String(pv.model.get("hair_gr2")), - "surfaces": total, "hair_surface_first": hair_first, - "feet_gap_m": feet_y if is_finite(feet_y) else NAN, - } - - # 1) 正面剪影 - _aim(cam, Vector3(0, 1.0, 3.1), Vector3(0, 0.9, 0), 40.0) - var front := await _grab("race%d-front" % race) - m["silhouette_px"] = _count_fg(front) - - # 2) 近景:full / no_hair 的差异 = 发型遮挡量 - _aim(cam, Vector3(0.75, 1.68, 0.05), Vector3(0, 1.62, 0), 32.0) - var full := await _grab("race%d-face_side" % race) - if hair_first >= 0: - for s in range(hair_first, total): - body.set_surface_override_material(s, _hidden()) - var nohair := await _grab("race%d-face_side-no_hair" % race) - _restore(body, base_mats) - var fg := _count_fg(nohair) - m["head_cover"] = (float(_count_diff(full, nohair)) / float(maxi(fg, 1))) if hair_first >= 0 else 0.0 - m["head_px"] = fg - - # 3) 发型亮度:只画发型,量 99 分位亮度;再用旧材质做负对照 - if hair_first >= 0: - for s in range(0, hair_first): - body.set_surface_override_material(s, _hidden()) - var hair_only := await _grab("race%d-hair_only" % race) - var hs := _lum_stats(hair_only) - m["hair_lum_p99"] = hs["p99"] - m["hair_bright_frac"] = hs["bright_frac"] - m["hair_mean_lum"] = hs["mean"] - var legacy := _legacy_hair_material(base_mats[hair_first]) - for s in range(hair_first, total): - body.set_surface_override_material(s, legacy) - var ctrl := await _grab("race%d-hair_only-legacy_ctrl" % race) - var cs := _lum_stats(ctrl) - m["hair_lum_ctrl"] = cs["p99"] - m["hair_bright_frac_ctrl"] = cs["bright_frac"] - m["hair_mean_lum_ctrl"] = cs["mean"] - _restore(body, base_mats) - else: - m["hair_lum_p99"] = 0.0 - m["hair_lum_ctrl"] = 0.0 - m["hair_bright_frac"] = 0.0 - m["hair_bright_frac_ctrl"] = 0.0 - m["hair_mean_lum"] = 0.0 - m["hair_mean_lum_ctrl"] = 0.0 - - pv.queue_free() - await process_frame - return m - -# --- 断言 ----------------------------------------------------------------- - -func _check_absolute(race: int, m: Dictionary) -> void: - var gap: float = m["feet_gap_m"] - if not is_finite(gap) or absf(gap) > FEET_GAP_MAX: - _fail.append("race %d: feet_gap_m=%s exceeds %.3f" % [race, gap, FEET_GAP_MAX]) - if int(m["silhouette_px"]) < SILHOUETTE_MIN: - _fail.append("race %d: silhouette_px=%d below %d" % [race, int(m["silhouette_px"]), SILHOUETTE_MIN]) - if int(m["hair_surface_first"]) < 0: - return - var margin: float = float(m["hair_mean_lum_ctrl"]) - float(m["hair_mean_lum"]) - if margin < HAIR_CTRL_MARGIN: - _fail.append(("race %d: legacy control only %.4f above production; " % [race, margin]) - + "hair_mean_lum no longer discriminates") - -func _check_baseline(race: int, m: Dictionary, baseline: Dictionary) -> void: - var key := str(race) - if not baseline.has(key): - _fail.append("race %d: no baseline entry (run with --bless after reviewing the render)" % race) - return - var b: Dictionary = baseline[key] - for field in ["body_sha256", "hair_sha256"]: - if b.has(field) and b[field] != m[field]: - _fail.append("race %d: %s differs from reviewed asset baseline" % [race, field]) - var cover_delta: float = absf(float(m["head_cover"]) - float(b.get("head_cover", 0.0))) - if cover_delta > TOL_HEAD_COVER: - _fail.append("race %d: head_cover %.4f vs baseline %.4f (delta %.4f > %.3f)" - % [race, float(m["head_cover"]), float(b.get("head_cover", 0.0)), cover_delta, TOL_HEAD_COVER]) - var base_px: float = float(b.get("silhouette_px", 0.0)) - if base_px > 0.0: - var rel: float = absf(float(m["silhouette_px"]) - base_px) / base_px - if rel > TOL_SILHOUETTE: - _fail.append("race %d: silhouette_px %d vs baseline %d (%.1f%% > %.1f%%)" - % [race, int(m["silhouette_px"]), int(base_px), rel * 100.0, TOL_SILHOUETTE * 100.0]) - var lum_delta: float = absf(float(m["hair_mean_lum"]) - float(b.get("hair_mean_lum", 0.0))) - if lum_delta > TOL_HAIR_LUM: - _fail.append("race %d: hair_mean_lum %.4f vs baseline %.4f (delta %.4f > %.4f)" - % [race, float(m["hair_mean_lum"]), float(b.get("hair_mean_lum", 0.0)), - lum_delta, TOL_HAIR_LUM]) - -# --- 像素统计 ------------------------------------------------------------- -# get_pixel 逐点调用在 512x512 上太慢,直接读 RGBA8 字节。 - -func _fg_mask(img: Image) -> PackedByteArray: - var d := img.get_data() - var n := img.get_width() * img.get_height() - var mask := PackedByteArray() - mask.resize(n) - for i in n: - var o := i * 4 - # 背景是纯洋红;模型不会出现 R 高 / G 近 0 / B 高 的组合 - mask[i] = 0 if (d[o] > 240 and d[o + 1] < 24 and d[o + 2] > 240) else 1 - return mask - -func _count_fg(img: Image) -> int: - var mask := _fg_mask(img) - var c := 0 - for v in mask: - c += v - return c - -# 「发型盖住了脑袋多少」:只数 no_hair 里属于人体的像素中被 full 改写的那些, -# 不含发型溢出到背景的部分(那属于轮廓,不是遮挡)。 -func _count_diff(a: Image, b: Image) -> int: - var da := a.get_data() - var db := b.get_data() - var n := mini(da.size(), db.size()) / 4 - var c := 0 - for i in n: - var o := i * 4 - var br := int(db[o]); var bg := int(db[o + 1]); var bb := int(db[o + 2]) - if br > 240 and bg < 24 and bb > 240: - continue # no_hair 里是背景 —— 不算遮挡 - if absi(int(da[o]) - br) > 12 or absi(int(da[o + 1]) - bg) > 12 \ - or absi(int(da[o + 2]) - bb) > 12: - c += 1 - return c - -# 前景像素亮度统计(256 桶直方图)。返回 {p99, bright_frac, mean}。 -# 判定用 mean:它对「整片发型被额外高光抬亮」最敏感,且不受发色基准影响 -# (基准差异由按职业的基线吸收)。p99/bright_frac 只写进报告备查。 -func _lum_stats(img: Image) -> Dictionary: - var d := img.get_data() - var n := img.get_width() * img.get_height() - var hist := PackedInt32Array() - hist.resize(256) - var total := 0 - for i in n: - var o := i * 4 - var r := int(d[o]); var g := int(d[o + 1]); var bl := int(d[o + 2]) - if r > 240 and g < 24 and bl > 240: - continue - var lum := int((0.2126 * float(r) + 0.7152 * float(g) + 0.0722 * float(bl))) - hist[clampi(lum, 0, 255)] += 1 - total += 1 - if total == 0: - return {"p99": 0.0, "bright_frac": 0.0, "mean": 0.0} - var cut := int(float(total) * 0.99) - var acc := 0 - var p99 := 1.0 - for v in 256: - acc += hist[v] - if acc >= cut: - p99 = float(v) / 255.0 - break - var bright := 0 - var sum := 0 - for v in 256: - sum += hist[v] * v - if v >= int(LUM_BRIGHT * 255.0): - bright += hist[v] - return {"p99": p99, "bright_frac": float(bright) / float(total), - "mean": float(sum) / float(total) / 255.0} - -# --- 场景辅助 ------------------------------------------------------------- - -func _aim(cam: Camera3D, eye: Vector3, at: Vector3, fov: float) -> void: - cam.global_transform = Transform3D(Basis(), eye).looking_at(at, Vector3.UP) - cam.fov = fov - -func _grab(name: String) -> Image: - await RenderingServer.frame_post_draw - await RenderingServer.frame_post_draw - var img := root.get_texture().get_image() - img.convert(Image.FORMAT_RGBA8) - img.save_png(_out.path_join(name + ".png")) - return img - -func _light() -> void: - var sun := DirectionalLight3D.new() - sun.rotation_degrees = Vector3(-42, 155, 0) - sun.light_energy = 1.1 - sun.set_layer_mask(0xFFFFF) - root.add_child(sun) - var env := WorldEnvironment.new() - var e := Environment.new() - e.background_mode = Environment.BG_COLOR - e.background_color = BG - 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 _hidden() -> StandardMaterial3D: - var m := StandardMaterial3D.new() - m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - m.albedo_color = Color(0, 0, 0, 0) - m.no_depth_test = false - m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - return m - -func _restore(body: MeshInstance3D, base_mats: Array) -> void: - for s in base_mats.size(): - body.set_surface_override_material(s, base_mats[s]) - -# 负对照:R2-02 修复前 _load_hair() 用的那一套。 -func _legacy_hair_material(production: Material) -> StandardMaterial3D: - var m := StandardMaterial3D.new() - m.roughness = 1.0 - m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR - m.alpha_scissor_threshold = 0.5 - m.cull_mode = BaseMaterial3D.CULL_DISABLED - if production is ShaderMaterial: - var tex = (production as ShaderMaterial).get_shader_parameter("albedo_tex") - if tex != null: - m.albedo_texture = tex - return m - -# 发型折在 body mesh 尾部;get_info() 报的 surfaces= 是身体自己的 surface 数。 -func _hair_surface_first(pv: Node3D, body: MeshInstance3D, total: int) -> int: - if String(pv.model.get("hair_gr2")).is_empty(): - return -1 - 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: - return -1 - return body_surfaces - -# --- 基线读写 ------------------------------------------------------------- - -func _load_baseline() -> Dictionary: - if not FileAccess.file_exists(BASELINE): - return {} - var txt := FileAccess.get_file_as_string(BASELINE) - var parsed = JSON.parse_string(txt) - return parsed.get("races", {}) if parsed is Dictionary else {} - -func _write_baseline(results: Dictionary) -> void: - var slim := {} - for k in results: - var m: Dictionary = results[k] - slim[k] = {"body_sha256": m["body_sha256"], "hair_sha256": m["hair_sha256"], - "body_gr2": String(m["body_gr2"]).get_file(), - "hair_gr2": String(m["hair_gr2"]).get_file(), - "head_cover": m["head_cover"], "silhouette_px": m["silhouette_px"], - "head_px": m["head_px"], "hair_mean_lum": m["hair_mean_lum"], - "hair_mean_lum_ctrl": m["hair_mean_lum_ctrl"], - "hair_lum_p99": m["hair_lum_p99"], "hair_bright_frac": m["hair_bright_frac"]} - DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(BASELINE).get_base_dir()) - var f := FileAccess.open(BASELINE, FileAccess.WRITE) - f.store_string(JSON.stringify({ - "note": "godot --path project --script model_render_test.gd -- --bless", - "engine": Engine.get_version_info()["string"], - "races": slim}, "\t")) - f.close() diff --git a/project/model_render_test.gd.uid b/project/model_render_test.gd.uid deleted file mode 100644 index c1928e6a..00000000 --- a/project/model_render_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://fb0dr5ulbc3g diff --git a/project/motion_effect_anchor_test.gd b/project/motion_effect_anchor_test.gd deleted file mode 100644 index 9ad60531..00000000 --- a/project/motion_effect_anchor_test.gd +++ /dev/null @@ -1,61 +0,0 @@ -extends SceneTree -const Anchor = preload("res://fx/motion_effect_anchor.gd") -const PlayerView = preload("res://ui/player_view.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 view := PlayerView.new() - check(view.build(AssetRoot.path(), 0), "real warrior builds") - root.add_child(view) - view.position = Vector3(7, 2, -4) - view.rotation.y = 0.7 - view.anim.set_process(false) - view.anim.set_time(0.2) - var effect := Node3D.new() - root.add_child(effect) - var anchor := Anchor.new() - effect.add_child(anchor) - var event := {"attaching": true, "following": true, "independent": false, - "bone": "equip_right_hand", "pos": Vector3(10, 20, 30)} - check(anchor.configure(view, event), "real equip_right_hand resolves") - check(anchor.is_processing(), "following bone updates") - var pose: Dictionary = view.anim.get_effect_bone_pose(event.bone) - if not pose.is_empty(): - var expected: Vector3 = view.model.global_transform * (pose.transform.origin + event.pos) - check(effect.global_position.distance_to(expected) < 0.00001, "bone + actor-local cm offset, then actor rotation") - var before := effect.global_transform - view.anim.set_time(1.0) - anchor.update_anchor() - check(not effect.global_transform.is_equal_approx(before), "anchor uses sampled animation pose") - event.following = false - check(anchor.configure(view, event) and not anchor.is_processing(), "snapshot bone does not follow") - before = effect.global_transform - view.position.x += 3.0 - await process_frame - check(effect.global_transform.is_equal_approx(before), "snapshot remains in world after actor moves") - event.independent = true - event.bone = "missing" - check(anchor.configure(view, event), "independent ignores bone") - check(effect.global_position.distance_to(view.model.global_transform * event.pos) < 0.00001, - "independent offset converts source Z to world up") - event.independent = false - check(not anchor.configure(view, event), "missing bone rejected") - event.attaching = false - check(anchor.configure(view, event) and anchor.is_processing(), "root attachment follows regardless of FollowingEnable") - # Metadata from the real file must survive the file-reader parser and native API. - view.anim.set_anim_path(AssetRoot.path().path_join("PC/ymir work/pc/warrior/skill/gigongcham.msa")) - var events: Array = view.anim.get_events() - check(not events.is_empty(), "real skill events loaded") - if not events.is_empty(): - check(events[0].attaching and events[0].bone == "Bip01 Spine1", "file parser retains bone metadata") - check(String(events[0].effect).contains("/"), "full effect path retained") - effect.free() - view.free() - await process_frame - print("motion_effect_anchor_test: failures=%d" % failures) - quit(1 if failures else 0) diff --git a/project/motion_effect_anchor_test.gd.uid b/project/motion_effect_anchor_test.gd.uid deleted file mode 100644 index 76034c1e..00000000 --- a/project/motion_effect_anchor_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dmqy41vlghvjy diff --git a/project/motion_event_fly_test.gd b/project/motion_event_fly_test.gd deleted file mode 100644 index 1cdf3c4e..00000000 --- a/project/motion_event_fly_test.gd +++ /dev/null @@ -1,97 +0,0 @@ -# motion_event_fly_test —— 40250 local MotionEventType=6 parity。 -# `.msa` FLY 帧必须按 FlyFileName 创建本地弹道;CG_SHOOT 仍由 GameScene -# 单独发送,服务器 GC_CREATE_FLY 不应被拿来替代这条本地视觉路径。 -extends SceneTree - -const AssetRoot = preload("res://asset_root.gd") -const FlyObject = preload("res://fly_object.gd") -const NetWorld = preload("res://net_world.gd") -const GameScene = preload("res://game_scene.gd") - -class Client extends Node: - var shots: Array[int] = [] - func get_main_vid() -> int: - return 1 - func shoot(skill: int) -> bool: - shots.append(skill) - return true - -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: - await process_frame - var mount := Node3D.new() - get_root().add_child(mount) - var world := NetWorld.new() - get_root().add_child(world) - world.parent = mount - world._assets_root = AssetRoot.path() - var manager := FlyObject.new() - manager.manual_step = true - get_root().add_child(manager) - manager.setup(mount, null, AssetRoot.path()) - world._fly_mgr = manager - - var actor := Node3D.new() - actor.position = Vector3(0, 1, 0) - get_root().add_child(actor) - var target := Node3D.new() - target.position = Vector3(0, 1, -8) - get_root().add_child(target) - var event := { - "type": 6, - "fly_file": "d:/ymir work/pc/assassin/effect/arrow_01_fast.msf", - "fly_pos": Vector3.ZERO, - "fly_bone": "", - "fly_attaching": false, - } - check(world.spawn_motion_fly(actor, event, target, 7), - "valid FlyFileName creates the local motion projectile") - check(manager.active_count() == 1, "local motion projectile enters FlyManager once") - if manager.active_count() == 1: - var before: Vector3 = manager._instances[0].pos - manager.step(0.01) - check(manager._instances[0].pos != before, "local motion projectile advances") - var missing := event.duplicate() - missing.fly_file = "d:/ymir work/pc/assassin/effect/missing.msf" - check(not world.spawn_motion_fly(actor, missing, target), - "missing FlyFileName follows reference create failure") - check(manager.active_count() == 1, "missing fly resource does not add a duplicate instance") - - # GameScene keeps the two 40250 side effects separate: one local visual - # instance plus one queued CG_SHOOT at the same motion frame. - manager.clear_for_map_change() - var game := GameScene.new() - var client := Client.new() - get_root().add_child(game) - get_root().add_child(client) - game.client = client - game.net_world = world - game.player = actor - game._mount = mount - game._motion_effect_target = weakref(target) - game._queue_shot(7) - game._on_local_motion_event_detailed(event) - check(client.shots == [7], "FLY event still sends exactly one queued CG_SHOOT") - check(manager.active_count() == 1, "FLY event creates exactly one local projectile") - game.free() - client.free() - - manager.clear_for_map_change() - manager.free() - actor.free() - target.free() - world.free() - mount.free() - await process_frame - if failures == 0: - print("PASS: motion_event_fly_test") - quit(1 if failures else 0) diff --git a/project/motion_event_fly_test.gd.uid b/project/motion_event_fly_test.gd.uid deleted file mode 100644 index 913cf29b..00000000 --- a/project/motion_event_fly_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://qbxri2nyitq7 diff --git a/project/motion_event_payload_test.gd b/project/motion_event_payload_test.gd deleted file mode 100644 index c973fee3..00000000 --- a/project/motion_event_payload_test.gd +++ /dev/null @@ -1,66 +0,0 @@ -# motion_event_payload_test —— native .msa event dictionary parity. -# 40250 RaceMotionDataEvent payloads must survive the native parser all the -# way to motion_event_detailed/get_events before any visual routing is added. -extends SceneTree - -const AssetRoot = preload("res://asset_root.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: - if not ClassDB.class_exists("Metin2AnimPlayer"): - print("motion_event_payload_test: skipped (native extension unavailable)") - quit(0) - return - var assets := AssetRoot.path() - await _test_asset(assets.path_join("PC/ymir work/pc/warrior/skill/daejin_2.msa"), 2, 2.0) - await _test_asset(assets.path_join("PC/ymir work/pc/warrior/skill/gihyeol.msa"), 4, 2.0) - await _test_asset(assets.path_join("PC/ymir work/pc/warrior/skill/geompung.msa"), 6, 1.4) - await _test_asset(assets.path_join("PC/ymir work/pc/assassin/skill/gungsin_3.msa"), 7, 1.5) - if failures == 0: - print("PASS: motion_event_payload_test") - else: - printerr("motion_event_payload_test: %d failure(s)" % failures) - quit(1 if failures else 0) - -func _test_asset(path: String, expected_type: int, expected_duration: float) -> void: - if not FileAccess.file_exists(path): - check(false, "reference asset exists: %s" % path) - return - var anim: Node = ClassDB.instantiate("Metin2AnimPlayer") - get_root().add_child(anim) - anim.set("anim_path", path) - await process_frame - var events: Array = anim.call("get_events") - var motion_data: Dictionary = anim.call("get_motion_data") - check(is_equal_approx(float(motion_data.get("duration", 0.0)), expected_duration), - "%s uses MSA MotionDuration %.3f as motion timing" % [path.get_file(), expected_duration]) - var found: Dictionary = {} - for event in events: - if event is Dictionary: - found[int(event.get("type", -1))] = event - check(found.has(expected_type), "%s exposes type %d" % [path.get_file(), expected_type]) - if found.has(2): - var wave: Dictionary = found[2] - check(wave.has("duration_time") and wave.has("power") and wave.has("affecting_range"), - "SCREEN_WAVING keeps DuringTime/Power/AffectingRange") - if found.has(4): - var attack: Dictionary = found[4] - check(attack.has("enable_hit_process") and attack.has("collision_spheres") and - attack.collision_spheres.size() > 0, "SPECIAL_ATTACKING keeps collision spheres") - if found.has(6): - var fly: Dictionary = found[6] - check(String(fly.get("fly_file", "")).to_lower().ends_with(".msf") and - fly.has("fly_pos") and fly.has("fly_bone"), - "FLY keeps FlyFileName/FlyPosition/bone") - if found.has(7): - check(found[7].has("duration_time") and found[7].has("collision_spheres"), - "no-payload event types retain a stable detailed dictionary") - anim.free() diff --git a/project/motion_event_payload_test.gd.uid b/project/motion_event_payload_test.gd.uid deleted file mode 100644 index c071b437..00000000 --- a/project/motion_event_payload_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bh8tfkc2kyu4m diff --git a/project/motion_event_timing_test.gd b/project/motion_event_timing_test.gd deleted file mode 100644 index 74a7de64..00000000 --- a/project/motion_event_timing_test.gd +++ /dev/null @@ -1,64 +0,0 @@ -# motion_event_timing_test —— 40250 ActorInstanceMotionEvent 帧游标回归。 -# 覆盖:frame 0 事件、普通动作不重复,以及一个大 delta 跨越多个循环时 -# 每个 LoopData/整段循环边界都能触发事件。 -extends SceneTree - -const AssetRoot = preload("res://asset_root.gd") - -var _fail := 0 - -func _ck(ok: bool, msg: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + msg) - -func _init() -> void: - call_deferred("_run") - -func _run() -> void: - if not ClassDB.class_exists("Metin2AnimPlayer"): - print("motion_event_timing_test: skipped (native extension unavailable)") - quit(0) - return - var msa := AssetRoot.path().path_join("PC/ymir work/pc/shaman/fishing/fishing_catch.msa") - if not FileAccess.file_exists(msa): - print("motion_event_timing_test: skipped (fishing_catch.msa unavailable)") - quit(0) - return - - var once: Node = ClassDB.instantiate("Metin2AnimPlayer") - get_root().add_child(once) - var once_events: Array = [] - once.motion_event_detailed.connect(func(event: Dictionary): once_events.append(event)) - once.set("loop", false) - once.set("anim_path", msa) - for _i in range(3): - await process_frame - _ck(once_events.size() == 1 and int(once_events[0].get("type", -1)) == 10, - "StartingTime=0 在首个动作帧触发一次(实际 %d)" % once_events.size()) - once.free() - await process_frame - - var looping: Node = ClassDB.instantiate("Metin2AnimPlayer") - get_root().add_child(looping) - var loop_events: Array = [] - looping.motion_event_detailed.connect(func(event: Dictionary): loop_events.append(event)) - looping.set("loop", true) - looping.set("time_scale", 1000.0) - looping.set("anim_path", msa) - for _i in range(3): - await process_frame - var duration := float(looping.call("get_duration")) - var elapsed := float(looping.call("get_time")) - var expected_min := maxi(1, int(floor(elapsed / duration)) + 1) - _ck(loop_events.size() >= expected_min, - "大时间步跨多次循环不漏 MotionEvent(实际 %d,至少 %d,t=%.3f/d=%.3f)" % - [loop_events.size(), expected_min, elapsed, duration]) - looping.free() - await process_frame - - if _fail == 0: - print("PASS: motion_event_timing_test") - else: - printerr("motion_event_timing_test: %d failure(s)" % _fail) - quit(1 if _fail else 0) diff --git a/project/motion_event_timing_test.gd.uid b/project/motion_event_timing_test.gd.uid deleted file mode 100644 index 350d8422..00000000 --- a/project/motion_event_timing_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://7rsn5860yg66 diff --git a/project/motion_event_visibility_test.gd b/project/motion_event_visibility_test.gd deleted file mode 100644 index bc025e9e..00000000 --- a/project/motion_event_visibility_test.gd +++ /dev/null @@ -1,38 +0,0 @@ -# motion_event_visibility_test —— 40250 CHARACTER_SHOW/HIDE local consumer. -extends SceneTree - -const GameScene = preload("res://game_scene.gd") - -class Actor extends Node3D: - var hidden := false - func set_motion_hidden(value: bool) -> void: - hidden = value - func is_motion_hidden() -> bool: - return hidden - -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 game := GameScene.new() - var actor := Actor.new() - get_root().add_child(game) - get_root().add_child(actor) - game.player = actor - game._on_local_motion_event_detailed({"type": 8, "sound": "", "pos": Vector3.ZERO}) - check(actor.hidden, "CHARACTER_HIDE reaches the local actor") - game._on_local_motion_event_detailed({"type": 7, "sound": "", "pos": Vector3.ZERO}) - check(not actor.hidden, "CHARACTER_SHOW restores the local actor") - game.free() - actor.free() - await process_frame - if failures == 0: - print("PASS: motion_event_visibility_test") - quit(1 if failures else 0) diff --git a/project/motion_event_visibility_test.gd.uid b/project/motion_event_visibility_test.gd.uid deleted file mode 100644 index 7f1bd6c5..00000000 --- a/project/motion_event_visibility_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cv7h4d6cylto5 diff --git a/project/motion_event_warp_test.gd b/project/motion_event_warp_test.gd deleted file mode 100644 index 09880cc1..00000000 --- a/project/motion_event_warp_test.gd +++ /dev/null @@ -1,104 +0,0 @@ -# motion_event_warp_test —— 40250 MotionEventType 9 / ProcessMotionEventWarp. -# The reference moves the actor to target - normalize(target-actor)*270cm, -# rejects the move on a blocked map cell, always turns toward the target, and -# calls OnWarp -> SendCharacterStatePacket(FUNC_WAIT). -extends SceneTree - -const AssetRoot = preload("res://asset_root.gd") -const GameScene = preload("res://game_scene.gd") -const NetPlay = preload("res://net_play.gd") - -class WarpWorld extends Node: - var blocked := false - var probes: Array[Vector2] = [] - - func is_blocked(x: float, z: float) -> bool: - probes.append(Vector2(x, z)) - return blocked - -class WarpNetPlay extends Node: - var states: Array = [] - - func _send_state(func_id: int, arg: int, position: Vector3) -> void: - states.append([func_id, arg, position]) - -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 game := GameScene.new() - var world := WarpWorld.new() - var net_play := WarpNetPlay.new() - var actor := Node3D.new() - var target := Node3D.new() - get_root().add_child(game) - get_root().add_child(world) - get_root().add_child(net_play) - get_root().add_child(actor) - get_root().add_child(target) - game.player = actor - game.world = world - game.net_play = net_play - actor.global_position = Vector3(0.0, 1.0, 0.0) - target.global_position = Vector3(10.0, 3.0, 0.0) - game._motion_effect_target = weakref(target) - - # The native parser must expose a real type-9 event from the reference asset. - if ClassDB.class_exists("Metin2AnimPlayer"): - var anim: Node = ClassDB.instantiate("Metin2AnimPlayer") - get_root().add_child(anim) - anim.set("anim_path", AssetRoot.path().path_join("PC/ymir work/pc/assassin/skill/seomjeon.msa")) - await process_frame - var found := false - for event in anim.call("get_events"): - if event is Dictionary and int(event.get("type", -1)) == 9: - found = true - check(found, "seomjeon.msa exposes MotionEventType 9") - anim.free() - - # Allowed path: target - 2.70m along the complete 3D target vector. - game._on_local_motion_event_detailed({"type": 9, "sound": "", "pos": Vector3.ZERO}) - var expected := target.global_position - (target.global_position - Vector3(0.0, 1.0, 0.0)).normalized() * 2.70 - check(actor.global_position.is_equal_approx(expected), - "WARP moves to target minus the 270cm reference distance") - check(is_equal_approx(actor.rotation.y, PI * 0.5), - "WARP turns toward target on the ground plane") - check(net_play.states.size() == 1 and int(net_play.states[0][0]) == NetPlay.FUNC_WAIT, - "WARP emits one FUNC_WAIT state through OnWarp") - check(world.probes.size() == 1 and world.probes[0].is_equal_approx(Vector2(expected.x, expected.z)), - "WARP checks the exact destination against map blocking") - - # Blocked path: orientation and OnWarp still happen, but SetPixelPosition does not. - world.blocked = true - actor.global_position = Vector3.ZERO - actor.rotation.y = 0.0 - net_play.states.clear() - game._on_local_motion_event_detailed({"type": 9, "sound": "", "pos": Vector3.ZERO}) - check(actor.global_position == Vector3.ZERO, "blocked WARP keeps the actor position") - check(is_equal_approx(actor.rotation.y, PI * 0.5), "blocked WARP still turns toward target") - check(net_play.states.size() == 1, "blocked WARP still reports OnWarp once") - - # No target: the reference returns without moving or reporting OnWarp. - game._motion_effect_target = null - net_play.states.clear() - actor.global_position = Vector3.ZERO - game._on_local_motion_event_detailed({"type": 9, "sound": "", "pos": Vector3.ZERO}) - check(actor.global_position == Vector3.ZERO and net_play.states.is_empty(), - "WARP without a valid fly target is a no-op") - - game.free() - world.free() - net_play.free() - actor.free() - target.free() - await process_frame - if failures == 0: - print("PASS: motion_event_warp_test") - quit(1 if failures else 0) diff --git a/project/motion_event_warp_test.gd.uid b/project/motion_event_warp_test.gd.uid deleted file mode 100644 index 416165cc..00000000 --- a/project/motion_event_warp_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://clc5lb3v2uuo diff --git a/project/motion_registry_test.gd b/project/motion_registry_test.gd deleted file mode 100644 index d93ccb81..00000000 --- a/project/motion_registry_test.gd +++ /dev/null @@ -1,69 +0,0 @@ -# motion_registry_test —— 40250 RaceData 注册顺序/权重的资源回归。 -# godot --headless --path project --script motion_registry_test.gd -extends SceneTree - -const MotionRegistry = preload("res://ui/motion_registry.gd") - -var _fail := 0 - -func _ck(ok: bool, msg: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + msg) - -func _find(entries: Array, name: String) -> Dictionary: - for entry in entries: - if String(entry.get("name", "")) == name: - return entry - return {} - -func _init() -> void: - var assets := AssetRoot.path() - var registry: RefCounted = MotionRegistry.load_for(assets, 0) - _ck(bool(registry.get("source_loaded")), "warrior registry reads playersettingmodule.py") - for race in range(8): - var race_registry: RefCounted = MotionRegistry.load_for(assets, race) - var race_wait: Array = race_registry.variants(1, 1) - var expected_wait_count := 2 if (race & 3) in [0, 1] else 1 - _ck(race_wait.size() == expected_wait_count, - "race %d keeps the source-defined GENERAL wait registration count" % race) - var race_attack: Array = race_registry.variants(1, 14) - _ck(race_attack.size() == 2 and int(race_attack[0].get("weight", -1)) == 50 and - int(race_attack[1].get("weight", -1)) == 50, - "race %d keeps the source-defined GENERAL attack weights" % race) - - var general_wait: Array = registry.variants(1, 1) - _ck(general_wait.size() == 2, "GENERAL WAIT preserves two registered variants") - _ck(int(_find(general_wait, "wait.msa").get("weight", -1)) == 70, - "SetMotionRandomWeight changes GENERAL wait.msa to 70") - _ck(int(_find(general_wait, "wait_1.msa").get("weight", -1)) == 30, - "GENERAL wait_1.msa keeps registered weight 30") - - var onehand_wait: Array = registry.variants(2, 1) - _ck(onehand_wait.size() == 2, "ONEHAND WAIT preserves registration order") - _ck(String(onehand_wait[0].get("name", "")) == "wait.msa" and - String(onehand_wait[1].get("name", "")) == "wait_1.msa", - "ONEHAND WAIT keeps wait then wait_1 order") - _ck(int(onehand_wait[0].get("weight", -1)) == 50 and - int(onehand_wait[1].get("weight", -1)) == 50, - "ONEHAND WAIT keeps 50/50 weights") - - var horse_wait: Array = registry.variants(9, 1) - _ck(horse_wait.size() == 3, "HORSE WAIT preserves all three variants") - _ck(int(horse_wait[0].get("weight", -1)) == 90 and - int(horse_wait[1].get("weight", -1)) == 9 and - int(horse_wait[2].get("weight", -1)) == 1, - "HORSE WAIT keeps 90/9/1 weighted selection") - - var general_attack: Array = registry.variants(1, 14) - _ck(general_attack.size() == 2, "GENERAL COMBO_ATTACK_1 has two attack resources") - _ck(int(_find(general_attack, "attack.msa").get("weight", -1)) == 50 and - int(_find(general_attack, "attack_1.msa").get("weight", -1)) == 50, - "GENERAL attack resources keep 50/50 weights") - - if _fail == 0: - print("PASS: motion_registry_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/motion_registry_test.gd.uid b/project/motion_registry_test.gd.uid deleted file mode 100644 index c2e762f4..00000000 --- a/project/motion_registry_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cuf5cpbrn0a0f diff --git a/project/motion_splash.gd b/project/motion_splash.gd deleted file mode 100644 index 885dc3e3..00000000 --- a/project/motion_splash.gd +++ /dev/null @@ -1,107 +0,0 @@ -# MotionSplash —— 40250 CActorInstance::SSplashArea 的无渲染状态与几何。 -# -# 参考链路: -# ActorInstanceMotionEvent.cpp::ProcessMotionEventSpecialAttacking -# -> ActorInstanceCollisionDetection.cpp::__SplashAttackProcess -# -> CheckCollisionDetection / __ProcessDataAttackSuccess -# -# 坐标仍使用客户端的 Metin2 cm 约定做碰撞(X、Y 为地面平面,Z 为高度); -# 对外只在 begin/try_hit 边界转换为 Godot 米,避免把攻击球误当成普通特效。 -class_name MotionSplash - -const CM := 100.0 -const ATTACK_TYPE_SNIPE := 1 # NRaceData::ATTACK_TYPE_SNIPE - -# ActorInstanceMotionEvent.cpp 的原式: -# fRadian = D3DXToRadian(270.0f + 360.0f - GetRotation()) -# x = actor.x + src.x*sin(fRadian) + src.y*cos(fRadian) -# y = actor.y + src.x*cos(fRadian) - src.y*sin(fRadian) -# z = actor.z + src.z -static func _motion_event_sphere(owner_world: Vector3, yaw: float, - source: Vector3, radius: float) -> Dictionary: - var owner_cm := Vector3(owner_world.x * CM, -owner_world.z * CM, owner_world.y * CM) - var rad := deg_to_rad(270.0 + 360.0 - rad_to_deg(yaw)) - var center_cm := Vector3( - owner_cm.x + source.x * sin(rad) + source.y * cos(rad), - owner_cm.y + source.x * cos(rad) - source.y * sin(rad), - owner_cm.z + source.z) - # M2 (x, y, z) -> Godot (x, y, z): world z is -M2 y. - var center_world := Vector3(center_cm.x / CM, center_cm.z / CM, -center_cm.y / CM) - return {"radius": radius, "pos": center_cm, "world_pos": center_world, - "last": center_cm} - -static func begin(owner_vid: int, skill: int, owner_world: Vector3, yaw: float, - event: Dictionary, now: float) -> Dictionary: - var spheres: Array = [] - var raw: Variant = event.get("collision_spheres", []) - if raw is Array: - for item in raw: - if not item is Dictionary: - continue - var src_variant: Variant = item.get("pos", Vector3.ZERO) - var source: Vector3 = src_variant if src_variant is Vector3 else Vector3.ZERO - spheres.append(_motion_event_sphere(owner_world, yaw, source, - float(item.get("radius", 0.0)))) - return { - "owner_vid": owner_vid, - "skill": skill, - "enable_hit_process": bool(event.get("enable_hit_process", true)), - "attack_type": int(event.get("attack_type", 0)), - "hitting_type": int(event.get("hitting_type", 0)), - "stiffen_time": float(event.get("stiffen_time", 0.0)), - "invisible_time": float(event.get("invisible_time", 0.0)), - "external_force": float(event.get("external_force", 0.0)), - "hit_limit_count": int(event.get("hit_limit_count", 0)), - "started_at": now, - "expires_at": now + float(event.get("duration_time", 0.0)), - "spheres": spheres, - "hitted": {}, - } - -static func is_active(area: Dictionary, now: float) -> bool: - return not area.is_empty() and now < float(area.get("expires_at", 0.0)) - -static func _sphere_position(item: Dictionary, key: String) -> Vector3: - var v: Variant = item.get(key, Vector3.ZERO) - return v if v is Vector3 else Vector3.ZERO - -# CheckCollisionDetection:攻击球与受击方 m_DefendingPointInstanceList 的每个球 -# 做完整三维 DynamicSphere 相交;SpecialAttack 的参考实现不是 Z 圆柱扫描。 -static func try_hit(area: Dictionary, victim_vid: int, defending_spheres: Array, - now: float) -> Dictionary: - if not is_active(area, now) or victim_vid <= 0 or defending_spheres.is_empty(): - return {"hit": false} - var hitted: Dictionary = area.get("hitted", {}) - if hitted.has(victim_vid): - return {"hit": false} - var hit_pos := Vector3.ZERO - var collided := false - for attack_item in area.get("spheres", []): - if not attack_item is Dictionary: - continue - var attack_pos := _sphere_position(attack_item, "pos") - var attack_radius := float(attack_item.get("radius", 0.0)) - for victim_item in defending_spheres: - if not victim_item is Dictionary: - continue - var victim_pos := _sphere_position(victim_item, "pos") - var victim_radius := float(victim_item.get("radius", 0.0)) - var limit := attack_radius + victim_radius - if attack_pos.distance_squared_to(victim_pos) <= limit * limit: - hit_pos = (attack_pos + victim_pos) * 0.5 - collided = true - break - if collided: - break - if not collided: - return {"hit": false} - # __SplashAttackProcess inserts before checking the per-event hit cap. Keep - # that detail: later frames must not retry a victim rejected by the cap. - hitted[victim_vid] = now - area["hitted"] = hitted - var max_count := int(area.get("hit_limit_count", 0)) - if max_count == 0: - max_count = 16 - if hitted.size() > max_count: - return {"hit": false, "hit_position": hit_pos, "overflow": true} - return {"hit": true, "hit_position": hit_pos, "overflow": false} diff --git a/project/motion_splash.gd.uid b/project/motion_splash.gd.uid deleted file mode 100644 index fbd1edd6..00000000 --- a/project/motion_splash.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cak8abim3accj diff --git a/project/motion_splash_test.gd b/project/motion_splash_test.gd deleted file mode 100644 index 74602c9d..00000000 --- a/project/motion_splash_test.gd +++ /dev/null @@ -1,60 +0,0 @@ -# motion_splash_test —— 40250 CActorInstance::SSplashArea / __SplashAttackProcess -# 的几何、生命周期、命中去重和命中上限回归。 -extends SceneTree - -const MotionSplash = preload("res://motion_splash.gd") - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _near(a: Vector3, b: Vector3, eps := 0.01) -> bool: - return a.distance_to(b) <= eps - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: motion_splash_test (transform / lifetime / dedup / limit)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var event := { - "type": 4, - "duration_time": 0.5, - "enable_hit_process": false, - "attack_type": 0, - "hitting_type": 1, - "stiffen_time": 0.1, - "invisible_time": 0.2, - "external_force": 30.0, - "hit_limit_count": 1, - "collision_spheres": [{"radius": 40.0, "pos": Vector3(100, 0, 0)}], - } - var area: Dictionary = MotionSplash.begin(77, 122, Vector3.ZERO, 0.0, event, 10.0) - _ck(area.get("enable_hit_process", true) == false, "EnableHitProcess is retained") - _ck(area.get("skill", 0) == 122, "uSkill is retained") - _ck(area.get("spheres", []).size() == 1, "one splash sphere is created") - # ActorInstanceMotionEvent.cpp: fRadian = 270 + 360 - GetRotation(). - # At rotation 0, source (100,0) becomes M2 (-100,0), i.e. Godot (-1,0,0). - var sphere: Dictionary = area.spheres[0] - _ck(_near(sphere.world_pos, Vector3(-1.0, 0.0, 0.0)), - "reference 270-degree event-frame transform") - - var victim := [{"radius": 20.0, "pos": Vector3(-100, 0, 0), "last": Vector3(-100, 0, 0)}] - var hit: Dictionary = MotionSplash.try_hit(area, 1001, victim, 10.1) - _ck(bool(hit.get("hit", false)), "overlapping defending sphere hits") - _ck(not bool(MotionSplash.try_hit(area, 1001, victim, 10.2).get("hit", false)), - "same victim is deduplicated for the splash lifetime") - var second_victim := [{"radius": 20.0, "pos": Vector3(-100, 0, 0), "last": Vector3(-100, 0, 0)}] - _ck(not bool(MotionSplash.try_hit(area, 1002, second_victim, 10.3).get("hit", false)), - "HitLimitCount rejects the next victim after the cap") - _ck(MotionSplash.is_active(area, 10.49), "splash remains active during DuringTime") - _ck(not MotionSplash.is_active(area, 10.5), "splash expires at DuringTime") - _ck(not bool(MotionSplash.try_hit(area, 1003, victim, 10.6).get("hit", false)), - "expired splash cannot hit") diff --git a/project/motion_splash_test.gd.uid b/project/motion_splash_test.gd.uid deleted file mode 100644 index ef5ef4dd..00000000 --- a/project/motion_splash_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cbjsemr0pmrfx diff --git a/project/mouse_controller_test.gd b/project/mouse_controller_test.gd deleted file mode 100644 index faf2ac84..00000000 --- a/project/mouse_controller_test.gd +++ /dev/null @@ -1,68 +0,0 @@ -extends SceneTree - -var _failed := false - -class CursorStub extends Node: - var current := "NORMAL" - func set_cursor(shape: String) -> void: - current = shape - func reset() -> void: - current = "NORMAL" - -class AudioStub extends Node: - var calls: Array[String] = [] - func play_ui(name: String) -> void: - calls.append(name) - -func _init() -> void: - var host := Node.new() - root.add_child(host) - var cur := CursorStub.new() - host.add_child(cur) - var audio := AudioStub.new() - host.add_child(audio) - var mouse := preload("res://ui/mouse_controller.gd").new() - host.add_child(mouse) - mouse.setup(host, cur, audio) - _check(not mouse.is_attached(), "starts detached") - _check(mouse.attach_item(1, 7, 1001, 3), "attach inventory item") - _check(audio.calls == ["pick.wav"], "attach plays pick sound") - _check(mouse.is_attached(), "attached state visible") - _check(int(mouse.attached().get("cell", -1)) == 7, "attached source cell") - _check(cur.current == "ITEM", "item cursor state") - var target := Control.new() - target.position = Vector2.ZERO - target.size = Vector2(32, 32) - host.add_child(target) - mouse.register_target(target, func(payload: Dictionary): - host.set_meta("dropped_cell", int(payload.get("cell", -1))) - return int(payload.get("cell", -1)) == 7) - mouse._drop_at(target.get_global_rect().position + Vector2(4, 4)) - _check(int(host.get_meta("dropped_cell", -1)) == 7, "drop callback receives source payload") - _check(not mouse.is_attached(), "successful drop clears attached state") - _check(audio.calls == ["pick.wav", "drop.wav"], "successful drop plays drop sound") - mouse.attach_money(123) - _check(mouse.is_attached() and int(mouse.attached().get("vnum", 0)) == -1 - and int(mouse.attached().get("count", 0)) == 123, "money has independent attached state") - mouse.cancel() - mouse.cancel() - _check(not mouse.is_attached(), "cancel clears attached state") - _check(cur.current == "NORMAL", "cancel resets cursor") - mouse.attach_item(1, 7, 1001, 1) - mouse._drop_at(Vector2(999, 999)) - _check(not mouse.is_attached(), "invalid drop cancels attached state") - mouse._notification(Node.NOTIFICATION_WM_WINDOW_FOCUS_OUT) - mouse.attach_item(1, 7, 1001, 1) - mouse._notification(Node.NOTIFICATION_WM_WINDOW_FOCUS_OUT) - _check(not mouse.is_attached(), "focus loss cancels drag") - _check(audio.calls == ["pick.wav", "drop.wav", "pick.wav", "pick.wav", "loginfail.wav", "pick.wav"], - "invalid drop plays cancel sound and focus loss cancels") - host.queue_free() - print("PASS: mouse_controller (global item drag state + cancel)") - quit(1 if _failed else 0) - -func _check(ok: bool, label: String) -> void: - if ok: - return - _failed = true - push_error("FAIL: " + label) diff --git a/project/mouse_controller_test.gd.uid b/project/mouse_controller_test.gd.uid deleted file mode 100644 index 7879326b..00000000 --- a/project/mouse_controller_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://0uv8hln06oex diff --git a/project/movement_parity_test.gd b/project/movement_parity_test.gd deleted file mode 100644 index 2e278698..00000000 --- a/project/movement_parity_test.gd +++ /dev/null @@ -1,113 +0,0 @@ -# movement_parity_test.gd —— 验证角色与怪物移动对齐 40250 的实现机制: -# 1. PlayerController 转向 45° 阻尼减速(5/12)与 ARRIVE_EPS 到达精度。 -# 2. PlayerController 遇障碍物切线滑动判定。 -# 3. EntityStore 网络命令队列 EMA 滤波与动作速度 fallback。 -# 4. NetWorld 远端实体朝向收敛 45° 阻尼。 -# -# godot --headless --path project --script movement_parity_test.gd -extends SceneTree - -const PlayerController = preload("res://player_controller.gd") -const NetWorld = preload("res://net_world.gd") -const MobView = preload("res://ui/mob_view.gd") - -var _fail := 0 - -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: movement_parity_test (40250 rotation damping + arrival epsilon + collision tangent + EMA network gap)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # ========================================================================= - # 1. PlayerController: 转向 45° 阻尼减速测试 (40250 InstanceBaseMovement.cpp:142-154) - # ========================================================================= - var pc := PlayerController.new() - var player := Node3D.new() - get_root().add_child(player) - get_root().add_child(pc) - pc.player = player - pc.rotation_speed_deg = 1200.0 - - # 角度差 > 45° 时(例如 90° = PI/2):转向速度应为全速 1200°/s - player.rotation.y = 0.0 - var target_90 := deg_to_rad(90.0) - pc._turn_toward(target_90, 0.01) # dt = 10ms -> 预期前进 12° - var turned_90: float = rad_to_deg(player.rotation.y) - _ck(is_equal_approx(turned_90, 12.0), "Over 45 deg: full speed 1200 deg/s (turned %.2f, expected 12.0)" % turned_90) - - # 角度差 <= 45° 时(例如 30°):转向速度应降为 5/12(500°/s) - player.rotation.y = 0.0 - var target_30 := deg_to_rad(30.0) - pc._turn_toward(target_30, 0.01) # dt = 10ms -> 预期前进 5° (12 * 5/12 = 5) - var turned_30: float = rad_to_deg(player.rotation.y) - _ck(is_equal_approx(turned_30, 5.0), "Under 45 deg: damped speed 500 deg/s (turned %.2f, expected 5.0)" % turned_30) - - # ========================================================================= - # 2. PlayerController: ARRIVE_EPS 精准到达测试 (40250 InstanceBase.cpp:1600) - # ========================================================================= - _ck(pc.ARRIVE_EPS <= 0.05, "ARRIVE_EPS is <= 0.05 (got %f)" % pc.ARRIVE_EPS) - player.position = Vector3(10.0, 0.0, 10.0) - pc._goto(Vector3(12.0, 0.0, 10.0)) # 距离目标点 2m (> 1m MOVABLE_GROUND_DISTANCE_M) - _ck(pc.is_going(), "_goto started (is_going == true)") - # 模拟角色走到了距目标 3cm 处 - player.position = Vector3(11.97, 0.0, 10.0) - pc._process(0.016) - _ck(not pc.is_going(), "Within ARRIVE_EPS (3cm <= 5cm) stops immediately (is_going == false)") - _ck(is_equal_approx(player.position.x, 12.0), "Player position snapped to exact clicked target X") - - # ========================================================================= - # 3. PlayerController: 切线滑动测试 (40250 AdjustDynamicCollisionMovement) - # ========================================================================= - var obstacle := Node3D.new() - get_root().add_child(obstacle) - # 障碍物放在 (11.0, 0, 10.0),半径 0.55m -> combined radius 1.1m - obstacle.position = Vector3(11.0, 0.0, 10.0) - obstacle.set_meta("kind", 0) - pc.pickables.append(obstacle) - player.position = Vector3(10.0, 0.0, 10.0) - - # 阻挡判定:朝向 (10.5, 0.0, 10.0) 会撞击障碍物(距离 0.5m < 1.1m 且更趋近) - _ck(pc._actor_blocked(Vector3(10.5, 0.0, 10.0)), "Blocked when moving into obstacle body radius") - _ck(pc._get_blocking_actor(Vector3(10.5, 0.0, 10.0)) == obstacle, "_get_blocking_actor returns obstacle") - - # ========================================================================= - # 4. NetWorld: 远端实体朝向 45° 阻尼收敛验证 - # ========================================================================= - var nw := NetWorld.new() - get_root().add_child(nw) - var remote_node := Node3D.new() - remote_node.rotation.y = 0.0 - var target_yaw := deg_to_rad(30.0) - var turn_rate := 1200.0 - var diff: float = wrapf(target_yaw - remote_node.rotation.y, -PI, PI) - if absf(diff) <= deg_to_rad(45.0): - turn_rate *= 5.0 / 12.0 - remote_node.rotation.y = rotate_toward(remote_node.rotation.y, target_yaw, deg_to_rad(turn_rate) * 0.01) - var nw_turned: float = rad_to_deg(remote_node.rotation.y) - _ck(is_equal_approx(nw_turned, 5.0), "NetWorld rotation damped within 45 deg: turned %.2f, expected 5.0" % nw_turned) - - # ========================================================================= - # 5. M2Client / EntityStore: 单动作 speed fallback 与 EMA 滤波测试 - # ========================================================================= - if ClassDB.class_exists("M2Client"): - var client = ClassDB.instantiate("M2Client") - get_root().add_child(client) - _ck(client.has_method("set_entity_motion_speed"), "M2Client has set_entity_motion_speed") - client.call("set_entity_motion_speed", 12345, 0.0, 350.0) # 仅有 run 速度 - client.queue_free() - - pc.queue_free() - player.queue_free() - obstacle.queue_free() - remote_node.queue_free() - nw.queue_free() diff --git a/project/movement_parity_test.gd.uid b/project/movement_parity_test.gd.uid deleted file mode 100644 index a1e0f4ee..00000000 --- a/project/movement_parity_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://xhyiac4gmabk diff --git a/project/musicinfo.gd b/project/musicinfo.gd deleted file mode 100644 index a45178e5..00000000 --- a/project/musicinfo.gd +++ /dev/null @@ -1,47 +0,0 @@ -# MusicInfo —— 对标 POC/assets/root/musicinfo.py:默认曲目常量 + 上次战场曲目持久化。 -# -# 参考(musicinfo.py): -# METIN2THEMA = "M2BG.mp3" -# loginMusic = "login_window.mp3" -# createMusic = "characterselect.mp3" -# selectMusic = "characterselect.mp3" -# fieldMusic = METIN2THEMA -# SaveLastPlayFieldMusic() / LoadLastPlayFieldMusic() → 文件 "BGM/lastplay.inf" -# -# 用法: -# const MusicInfo = preload("res://musicinfo.gd") -# MusicInfo.fieldMusic # -> "M2BG.mp3" -# MusicInfo.save_last_play_field_music("x.mp3") -# MusicInfo.load_last_play_field_music() # -> "x.mp3" 或 "" -extends RefCounted - -const METIN2THEMA := "M2BG.mp3" -const loginMusic := "login_window.mp3" -const createMusic := "characterselect.mp3" -const selectMusic := "characterselect.mp3" # 选人页 -const fieldMusic := METIN2THEMA - -# 参考写死 "BGM/lastplay.inf"(相对客户端目录);Godot 侧落到用户可写目录。 -const LAST_PLAY_DIR := "user://BGM" -const LAST_PLAY_PATH := "user://BGM/lastplay.inf" - -# musicInfo.SaveLastPlayFieldMusic(name) -static func save_last_play_field_music(name: String) -> void: - if name == "": - return - DirAccess.make_dir_recursive_absolute(LAST_PLAY_DIR) - var f := FileAccess.open(LAST_PLAY_PATH, FileAccess.WRITE) - if f: - f.store_string(name) - f.close() - -# musicInfo.LoadLastPlayFieldMusic() -> 曲目名(无记录时空串) -static func load_last_play_field_music() -> String: - if not FileAccess.file_exists(LAST_PLAY_PATH): - return "" - var f := FileAccess.open(LAST_PLAY_PATH, FileAccess.READ) - if f == null: - return "" - var s := f.get_as_text() - f.close() - return s.strip_edges() diff --git a/project/musicinfo.gd.uid b/project/musicinfo.gd.uid deleted file mode 100644 index e94fe58f..00000000 --- a/project/musicinfo.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ektx487ombx1 diff --git a/project/name_color.gd b/project/name_color.gd deleted file mode 100644 index da573e38..00000000 --- a/project/name_color.gd +++ /dev/null @@ -1,175 +0,0 @@ -# name_color.gd —— CInstanceBase::GetNameColorIndex / GetNameColor 的 1:1 迁移(§8.8) -# -# 参考(REF/ = /Users/shenlei/Work/mt/40250/ClientVS22/source/): -# UserInterface/InstanceBaseEffect.cpp:525 GetNameColorIndex —— 逐行照抄 -# UserInterface/InstanceBaseEffect.cpp:520 GetNameColor -# UserInterface/InstanceBaseEffect.cpp:52 SetEmpireNameMode -# UserInterface/InstanceBaseEffect.cpp:75 GetIndexedNameColor -# UserInterface/InstanceBase.h:216 NAMECOLOR_* 枚举(EMPIRE_NUM = 4) -# UserInterface/PythonCharacterManagerModule.cpp:767 chrmgr.NAMECOLOR_* -> C++ 枚举重映射 -# assets/root/introloading.py:264 IntroLoading.__RegisterColor(RGB 注册) -# assets/root/colorInfo.py:11 CHR_NAME_RGB_* -# assets/root/constinfo.py:126 SET_DEFAULT_CHRNAME_COLOR(默认 CHRNAME_COLOR_INDEX = 0 → 帝国名色关) -# -# 纯静态。调用方把网络快照拼成 self_e(目标实例)/ main_e(主角,空 = 无主角实例)/ -# ctx,语义与 entity_rules.gd 完全一致(net_play._attack_ctx / net_world._name_color_ctx -# 同款 ctx:main_vid / duel_mode / duel_opponents / pvp_pairs / gvg_pairs / party_vids)。 -extends RefCounted - -const EntityRules = preload("res://entity_rules.gd") - -# InstanceBase.h 的匿名枚举,EMPIRE_NUM = 4(EMPIRE_NONE/A/B/C)。 -const EMPIRE_NUM := 4 - -# NAMECOLOR_*(EMPIRE_NUM 展开后的实际序号) -const NAMECOLOR_MOB := 0 -const NAMECOLOR_NPC := 1 -const NAMECOLOR_PC := 2 # + GetEmpireID()(0..3)→ 2..5 -const NAMECOLOR_PC_END := 6 -const NAMECOLOR_NORMAL_MOB := 7 -const NAMECOLOR_NORMAL_NPC := 8 -const NAMECOLOR_NORMAL_PC := 9 -const NAMECOLOR_NORMAL_PC_END := 13 -const NAMECOLOR_EMPIRE_MOB := 14 -const NAMECOLOR_EMPIRE_NPC := 15 -const NAMECOLOR_EMPIRE_PC := 16 # + uEmpire → 16..20 -const NAMECOLOR_EMPIRE_PC_END := 20 -const NAMECOLOR_FUNC := 21 -const NAMECOLOR_PK := 22 -const NAMECOLOR_PVP := 23 -const NAMECOLOR_PARTY := 24 -const NAMECOLOR_WARP := 25 -const NAMECOLOR_WAYPOINT := 26 -const NAMECOLOR_NUM := 41 - -const _WHITE := Color(1, 1, 1) - -# IntroLoading.__RegisterColor 通过 chrmgr.RegisterNameColor 写进 g_akD3DXClrName 的槽。 -# chrmgr.NAMECOLOR_PC/NPC/MOB 三个常量经 PythonCharacterManagerModule.cpp:767 重映射 -# 到 NORMAL_PC/NORMAL_NPC/NORMAL_MOB,其余(PK/PVP/PARTY/WARP/WAYPOINT/EMPIRE_*)直通。 -# 未在此表里的索引 = D3DXCOLOR() 默认(透明黑),但 GetNameColorIndex 不会命中它们。 -static func _registered_slots() -> Dictionary: - return { - NAMECOLOR_NORMAL_MOB: Color8(235, 22, 9), - NAMECOLOR_NORMAL_NPC: Color8(122, 231, 93), - NAMECOLOR_NORMAL_PC: Color8(255, 215, 76), - NAMECOLOR_PK: Color8(180, 100, 0), - NAMECOLOR_PVP: Color8(238, 54, 223), - NAMECOLOR_PARTY: Color8(128, 192, 255), - NAMECOLOR_WARP: Color8(136, 218, 241), - NAMECOLOR_WAYPOINT: Color8(255, 255, 255), - NAMECOLOR_EMPIRE_MOB: Color8(235, 22, 9), - NAMECOLOR_EMPIRE_NPC: Color8(122, 231, 93), - NAMECOLOR_EMPIRE_PC + 1: Color8(157, 0, 0), - NAMECOLOR_EMPIRE_PC + 2: Color8(222, 160, 47), - NAMECOLOR_EMPIRE_PC + 3: Color8(23, 30, 138), - } - -# g_akD3DXClrName 的当前解析态(SetEmpireNameMode 重写 MOB/NPC + PC..PC+3 槽)。 -static var _palette := {} -static var _empire_name_mode := false - -static func _ensure_palette() -> void: - if _palette.is_empty(): - set_empire_name_mode(false) - -static func is_empire_name_mode() -> bool: - _ensure_palette() - return _empire_name_mode - -# CInstanceBase::SetEmpireNameMode(InstanceBaseEffect.cpp:52)。默认关(constinfo -# .CHRNAME_COLOR_INDEX == 0 → SET_DEFAULT_CHRNAME_COLOR → SetEmpireNameMode(0))。 -static func set_empire_name_mode(enabled: bool) -> void: - _empire_name_mode = enabled - var p := _registered_slots() - if enabled: - p[NAMECOLOR_MOB] = p[NAMECOLOR_EMPIRE_MOB] - p[NAMECOLOR_NPC] = p[NAMECOLOR_EMPIRE_NPC] - p[NAMECOLOR_PC] = p[NAMECOLOR_NORMAL_PC] - for u in range(1, EMPIRE_NUM): - p[NAMECOLOR_PC + u] = p.get(NAMECOLOR_EMPIRE_PC + u, _WHITE) - else: - p[NAMECOLOR_MOB] = p[NAMECOLOR_NORMAL_MOB] - p[NAMECOLOR_NPC] = p[NAMECOLOR_NORMAL_NPC] - for u in range(0, EMPIRE_NUM): - p[NAMECOLOR_PC + u] = p[NAMECOLOR_NORMAL_PC] - _palette = p - -# CInstanceBase::GetIndexedNameColor(InstanceBaseEffect.cpp:75) -static func indexed_name_color(index: int) -> Color: - _ensure_palette() - if index < 0 or index >= NAMECOLOR_NUM: - return _WHITE - return _palette.get(index, _WHITE) - -# --- 类别谓词(entity_rules 已有 _is_pc/_is_enemy/_is_poly;NPC 这里补) --------- - -static func _is_npc(e: Dictionary) -> bool: - return EntityRules._kind(e) == EntityRules.KIND_NPC - -static func _empire_id(e: Dictionary) -> int: - # GetEmpireID() → m_dwEmpireID(0 = 无,1..3)。防御性 clamp 到合法槽。 - return clampi(int(e.get("empire", 0)), 0, EMPIRE_NUM - 1) - -# --- CInstanceBase::GetNameColorIndex(InstanceBaseEffect.cpp:525)—— 逐行 -------- - -static func name_color_index(self_e: Dictionary, main_e: Dictionary, ctx: Dictionary) -> int: - if EntityRules._is_pc(self_e): - # if (m_isKiller) return NAMECOLOR_PK; - if EntityRules._is_killer(self_e): - return NAMECOLOR_PK - - var self_vid := int(self_e.get("vid", 0)) - var main_vid := int(ctx.get("main_vid", int(main_e.get("vid", 0)))) - var is_main := self_vid != 0 and self_vid == main_vid - - # if (__IsExistMainInstance() && !__IsMainInstance()) - if not main_e.is_empty() and not is_main: - var vid_main := int(main_e.get("vid", main_vid)) - - # if (pkInstMain->GetDuelMode()) switch(...) - var duel := int(ctx.get("duel_mode", 0)) - if duel != 0: - match duel: - EntityRules.DUEL_CANNOTATTACK: - return NAMECOLOR_PC + _empire_id(self_e) - EntityRules.DUEL_START: - if EntityRules._find_duel_key(vid_main, self_vid, ctx): - return NAMECOLOR_PVP - return NAMECOLOR_PC + _empire_id(self_e) - - # if (pkInstMain->IsSameEmpire(*this)) - if EntityRules._is_same_empire(main_e, self_e): - if EntityRules._pair_in(ctx.get("pvp_pairs", []), vid_main, self_vid): - return NAMECOLOR_PVP - var guild_main := int(main_e.get("guild", 0)) - var guild_self := int(self_e.get("guild", 0)) - # __FindGVGKey:guild 0 的 PVPKEY 恒为 0,参考端集合里不会有 → - # 这里与 entity_rules._is_pvp_instance 一样加 !=0 防御。 - if guild_main != 0 and guild_self != 0 \ - and EntityRules._pair_in(ctx.get("gvg_pairs", []), guild_main, guild_self): - return NAMECOLOR_PVP - # __FindDUELKey 分支在参考端此处被 /* */ 注释掉,故不实现。 - else: - return NAMECOLOR_PVP - - # IAbstractPlayer::IsPartyMemberByVID(GetVirtualID()) - if self_vid in ctx.get("party_vids", []): - return NAMECOLOR_PARTY - - return NAMECOLOR_PC + _empire_id(self_e) - - elif _is_npc(self_e): - return NAMECOLOR_NPC - elif EntityRules._is_enemy(self_e): - return NAMECOLOR_MOB - elif EntityRules._is_poly(self_e): - return NAMECOLOR_MOB - - # 参考端 fallthrough:`return D3DXCOLOR(0xffffffff);` 被当成 UINT 传给 - # GetIndexedNameColor → 越界 → 白。这里用 -1 表达同一含义。 - return -1 - -# CInstanceBase::GetNameColor(InstanceBaseEffect.cpp:520) -static func name_color(self_e: Dictionary, main_e: Dictionary, ctx: Dictionary) -> Color: - return indexed_name_color(name_color_index(self_e, main_e, ctx)) diff --git a/project/name_color.gd.uid b/project/name_color.gd.uid deleted file mode 100644 index 8247bf34..00000000 --- a/project/name_color.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bqvbpbcp4338q diff --git a/project/name_color_test.gd b/project/name_color_test.gd deleted file mode 100644 index 77718084..00000000 --- a/project/name_color_test.gd +++ /dev/null @@ -1,180 +0,0 @@ -# name_color_test —— §8.8 头顶名字色 CInstanceBase::GetNameColorIndex / GetNameColor 1:1: -# NAMECOLOR_* 序号、GetNameColorIndex 逐分支、SetEmpireNameMode 调色板重写、 -# GetIndexedNameColor 越界回退,以及 introloading.__RegisterColor + colorInfo.py 的 RGB。 -# godot --headless --path project --script name_color_test.gd -# 退出码 0 = 全过。 -extends SceneTree - -const NameColor = preload("res://name_color.gd") -const EntityRules = preload("res://entity_rules.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: name_color_test (§8.8 GetNameColorIndex / SetEmpireNameMode 1:1)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _pc(vid: int, empire := 1, extra := {}) -> Dictionary: - var d := {"vid": vid, "kind": EntityRules.KIND_PC, "empire": empire, "guild": 0, - "alignment": 0, "pk_mode": 0, "state_flags": 0} - for k in extra: - d[k] = extra[k] - return d - -func _ctx(extra := {}) -> Dictionary: - var c := {"main_vid": 1, "duel_mode": 0, "duel_opponents": [], - "pvp_pairs": [], "gvg_pairs": [], "party_vids": []} - for k in extra: - c[k] = extra[k] - return c - -func _run() -> void: - # --- 1. NAMECOLOR_* 序号(InstanceBase.h,EMPIRE_NUM = 4)------------------ - _ck(NameColor.NAMECOLOR_MOB == 0, "NAMECOLOR_MOB == 0") - _ck(NameColor.NAMECOLOR_NPC == 1, "NAMECOLOR_NPC == 1") - _ck(NameColor.NAMECOLOR_PC == 2, "NAMECOLOR_PC == 2") - _ck(NameColor.NAMECOLOR_NORMAL_MOB == 7, "NAMECOLOR_NORMAL_MOB == 7") - _ck(NameColor.NAMECOLOR_NORMAL_NPC == 8, "NAMECOLOR_NORMAL_NPC == 8") - _ck(NameColor.NAMECOLOR_NORMAL_PC == 9, "NAMECOLOR_NORMAL_PC == 9") - _ck(NameColor.NAMECOLOR_EMPIRE_MOB == 14, "NAMECOLOR_EMPIRE_MOB == 14") - _ck(NameColor.NAMECOLOR_EMPIRE_NPC == 15, "NAMECOLOR_EMPIRE_NPC == 15") - _ck(NameColor.NAMECOLOR_EMPIRE_PC == 16, "NAMECOLOR_EMPIRE_PC == 16") - _ck(NameColor.NAMECOLOR_FUNC == 21, "NAMECOLOR_FUNC == 21") - _ck(NameColor.NAMECOLOR_PK == 22, "NAMECOLOR_PK == 22") - _ck(NameColor.NAMECOLOR_PVP == 23, "NAMECOLOR_PVP == 23") - _ck(NameColor.NAMECOLOR_PARTY == 24, "NAMECOLOR_PARTY == 24") - _ck(NameColor.NAMECOLOR_WARP == 25, "NAMECOLOR_WARP == 25") - _ck(NameColor.NAMECOLOR_WAYPOINT == 26, "NAMECOLOR_WAYPOINT == 26") - _ck(NameColor.NAMECOLOR_NUM == 41, "NAMECOLOR_NUM == 41") - - # --- 2. 帝国名色模式关(默认):GetNameColorIndex 逐分支 -------------------- - NameColor.set_empire_name_mode(false) - _ck(not NameColor.is_empire_name_mode(), "default empire-name-mode OFF") - - var main := _pc(1, 1) - - # 普通 PC(同帝国、无关系)→ NAMECOLOR_PC + empire - _ck(NameColor.name_color_index(_pc(2, 1), main, _ctx()) == NameColor.NAMECOLOR_PC + 1, - "normal PC (same empire) -> NAMECOLOR_PC + empire(1)") - _ck(NameColor.name_color_index(_pc(2, 3), _pc(1, 3), _ctx()) == NameColor.NAMECOLOR_PC + 3, - "normal PC empire 3 (same-empire main) -> NAMECOLOR_PC + 3") - # 关模式下 PC..PC+3 槽全解析成 NORMAL_PC(黄) - _ck(NameColor.name_color(_pc(2, 1), main, _ctx()).is_equal_approx(Color8(255, 215, 76)), - "empire-off: PC name colour == CHR_NAME_RGB_PC (255,215,76)") - _ck(NameColor.name_color(_pc(2, 3), _pc(1, 3), _ctx()).is_equal_approx(Color8(255, 215, 76)), - "empire-off: empire-3 PC still yellow") - - # m_isKiller → PK(最先判,压过一切) - var killer := _pc(2, 1, {"state_flags": EntityRules.STATE_KILLER}) - _ck(NameColor.name_color_index(killer, main, _ctx()) == NameColor.NAMECOLOR_PK, - "killer PC -> NAMECOLOR_PK") - _ck(NameColor.name_color(killer, main, _ctx()).is_equal_approx(Color8(180, 100, 0)), - "PK name colour == CHR_NAME_RGB_PK (180,100,0)") - - # 队伍成员 → PARTY - _ck(NameColor.name_color_index(_pc(7, 1), main, _ctx({"party_vids": [7]})) == NameColor.NAMECOLOR_PARTY, - "party member -> NAMECOLOR_PARTY") - _ck(NameColor.name_color(_pc(7, 1), main, _ctx({"party_vids": [7]})).is_equal_approx(Color8(128, 192, 255)), - "PARTY name colour == CHR_NAME_RGB_PARTY (128,192,255)") - # 但 killer 优先于 party - _ck(NameColor.name_color_index(_pc(7, 1, {"state_flags": EntityRules.STATE_KILLER}), - main, _ctx({"party_vids": [7]})) == NameColor.NAMECOLOR_PK, - "killer beats party") - - # 异帝国 → PVP - _ck(NameColor.name_color_index(_pc(2, 2), _pc(1, 1), _ctx()) == NameColor.NAMECOLOR_PVP, - "different empire -> NAMECOLOR_PVP") - _ck(NameColor.name_color(_pc(2, 2), _pc(1, 1), _ctx()).is_equal_approx(Color8(238, 54, 223)), - "PVP name colour == CHR_NAME_RGB_PVP (238,54,223)") - # victim empire 0 视作同帝国(IsSameEmpire 短路 true)→ 不判 PVP - _ck(NameColor.name_color_index(_pc(2, 0), _pc(1, 1), _ctx()) == NameColor.NAMECOLOR_PC + 0, - "victim empire 0 -> same-empire, NAMECOLOR_PC + 0") - - # 同帝国 + PVP 对表命中 → PVP - _ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1), _ctx({"pvp_pairs": [[1, 2]]})) == NameColor.NAMECOLOR_PVP, - "same empire + pvp pair -> NAMECOLOR_PVP") - # 同帝国 + 公会战对表命中(两边 guild 非 0)→ PVP - _ck(NameColor.name_color_index(_pc(2, 1, {"guild": 20}), _pc(1, 1, {"guild": 10}), - _ctx({"gvg_pairs": [[10, 20]]})) == NameColor.NAMECOLOR_PVP, - "same empire + gvg pair -> NAMECOLOR_PVP") - # guild 0 不触发公会战(防御性 !=0) - _ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1), _ctx({"gvg_pairs": [[0, 0]]})) == NameColor.NAMECOLOR_PC + 1, - "guild 0 gvg pair ignored") - - # 决斗:主角 DUEL_CANNOTATTACK → 恒 NAMECOLOR_PC + empire(即便异帝国) - _ck(NameColor.name_color_index(_pc(2, 2), _pc(1, 1), - _ctx({"duel_mode": EntityRules.DUEL_CANNOTATTACK})) == NameColor.NAMECOLOR_PC + 2, - "main DUEL_CANNOTATTACK -> NAMECOLOR_PC + empire, no PVP") - # 决斗:主角 DUEL_START + 对手命中 → PVP;未命中 → NAMECOLOR_PC + empire - _ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1), - _ctx({"duel_mode": EntityRules.DUEL_START, "duel_opponents": [2]})) == NameColor.NAMECOLOR_PVP, - "main DUEL_START + opponent -> NAMECOLOR_PVP") - _ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1), - _ctx({"duel_mode": EntityRules.DUEL_START, "duel_opponents": [99]})) == NameColor.NAMECOLOR_PC + 1, - "main DUEL_START, not an opponent -> NAMECOLOR_PC + empire") - - # 无主角实例(main_e 空)→ 跳过相对块,直接 party / NAMECOLOR_PC + empire - _ck(NameColor.name_color_index(_pc(2, 2), {}, _ctx()) == NameColor.NAMECOLOR_PC + 2, - "no main instance -> NAMECOLOR_PC + empire (no PVP)") - # 自己(vid == main_vid)→ 跳过相对块 - _ck(NameColor.name_color_index(_pc(1, 2), _pc(1, 2), _ctx({"main_vid": 1})) == NameColor.NAMECOLOR_PC + 2, - "self -> NAMECOLOR_PC + empire") - - # NPC / MOB / poly / 未知 - _ck(NameColor.name_color_index({"kind": EntityRules.KIND_NPC}, main, _ctx()) == NameColor.NAMECOLOR_NPC, - "NPC -> NAMECOLOR_NPC") - _ck(NameColor.name_color({"kind": EntityRules.KIND_NPC}, main, _ctx()).is_equal_approx(Color8(122, 231, 93)), - "NPC name colour == CHR_NAME_RGB_NPC (122,231,93)") - _ck(NameColor.name_color_index({"kind": EntityRules.KIND_MONSTER}, main, _ctx()) == NameColor.NAMECOLOR_MOB, - "MONSTER -> NAMECOLOR_MOB") - _ck(NameColor.name_color({"kind": EntityRules.KIND_MONSTER}, main, _ctx()).is_equal_approx(Color8(235, 22, 9)), - "MOB name colour == CHR_NAME_RGB_MOB (235,22,9)") - _ck(NameColor.name_color_index({"kind": 99, "poly_vnum": 101}, main, _ctx()) == NameColor.NAMECOLOR_MOB, - "standalone poly -> NAMECOLOR_MOB") - _ck(NameColor.name_color_index({"kind": 99}, main, _ctx()) == -1, - "unknown kind -> -1 (fallthrough)") - _ck(NameColor.name_color({"kind": 99}, main, _ctx()).is_equal_approx(Color(1, 1, 1)), - "unknown kind name colour -> white") - - # --- 3. SetEmpireNameMode(true):调色板重写 ------------------------------- - NameColor.set_empire_name_mode(true) - _ck(NameColor.is_empire_name_mode(), "empire-name-mode ON") - # 序号不变,但槽解析改了 - _ck(NameColor.name_color_index(_pc(2, 1), main, _ctx()) == NameColor.NAMECOLOR_PC + 1, - "empire-on: normal PC index unchanged (PC + empire)") - _ck(NameColor.name_color(_pc(2, 1), main, _ctx()).is_equal_approx(Color8(157, 0, 0)), - "empire-on: empire-1 PC -> CHR_NAME_RGB_EMPIRE_PC_A (157,0,0)") - _ck(NameColor.name_color(_pc(2, 2), _pc(1, 2), _ctx()).is_equal_approx(Color8(222, 160, 47)), - "empire-on: empire-2 PC -> CHR_NAME_RGB_EMPIRE_PC_B (222,160,47)") - _ck(NameColor.name_color(_pc(2, 3), _pc(1, 3), _ctx()).is_equal_approx(Color8(23, 30, 138)), - "empire-on: empire-3 PC -> CHR_NAME_RGB_EMPIRE_PC_C (23,30,138)") - # empire 0 的 PC 槽(NAMECOLOR_PC + 0)在 ON 分支仍是 NORMAL_PC(黄) - _ck(NameColor.name_color(_pc(2, 0), _pc(1, 1), _ctx()).is_equal_approx(Color8(255, 215, 76)), - "empire-on: empire-0 PC slot stays NORMAL_PC yellow") - # MOB / NPC 在这套 colorInfo 里帝国色 == 普通色 - _ck(NameColor.name_color({"kind": EntityRules.KIND_MONSTER}, main, _ctx()).is_equal_approx(Color8(235, 22, 9)), - "empire-on: MOB colour unchanged (235,22,9)") - - # 还原默认,避免污染后续(静态状态) - NameColor.set_empire_name_mode(false) - _ck(NameColor.name_color(_pc(2, 1), main, _ctx()).is_equal_approx(Color8(255, 215, 76)), - "restore empire-off: PC yellow again") - - # --- 4. GetIndexedNameColor 越界 → 白 ----------------------------------- - _ck(NameColor.indexed_name_color(-1).is_equal_approx(Color(1, 1, 1)), "indexed_name_color(-1) -> white") - _ck(NameColor.indexed_name_color(NameColor.NAMECOLOR_NUM).is_equal_approx(Color(1, 1, 1)), - "indexed_name_color(NUM) -> white") - _ck(NameColor.indexed_name_color(9999).is_equal_approx(Color(1, 1, 1)), "indexed_name_color(9999) -> white") - _ck(NameColor.indexed_name_color(NameColor.NAMECOLOR_WARP).is_equal_approx(Color8(136, 218, 241)), - "indexed_name_color(WARP) == CHR_NAME_RGB_WARP (136,218,241)") - _ck(NameColor.indexed_name_color(NameColor.NAMECOLOR_WAYPOINT).is_equal_approx(Color8(255, 255, 255)), - "indexed_name_color(WAYPOINT) == CHR_NAME_RGB_WAYPOINT (255,255,255)") diff --git a/project/name_color_test.gd.uid b/project/name_color_test.gd.uid deleted file mode 100644 index cccc3343..00000000 --- a/project/name_color_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b3jl5bspgvnf2 diff --git a/project/name_show.gd b/project/name_show.gd deleted file mode 100644 index de6eb2d6..00000000 --- a/project/name_show.gd +++ /dev/null @@ -1,77 +0,0 @@ -# name_show —— §8.4 / §8.6 头顶名字「是否显示」1:1:CPythonSystem::IsAlwaysShowName / -# SetAlwaysShowNameFlag + game.py::__IsShowName + CPythonTextTail::ShowAllTextTail 的 -# 3500 距离裁剪 + ShowCharacterTextTail 的资格判定 + UpdateAllTextTail 的 bNameFlag 强显。 -# (与 name_color.gd / text_tail.gd / chat_tail.gd 同一套纯静态镜像套路。) -# -# REF(40250 ClientVS22 source/ 为唯一基线): -# UserInterface/PythonSystem.cpp:5 #define DEFAULT_VALUE_ALWAYS_SHOW_NAME true -# UserInterface/PythonSystem.cpp:307 m_Config.bAlwaysShowName = DEFAULT_VALUE_ALWAYS_SHOW_NAME -# UserInterface/PythonSystem.cpp:327 IsAlwaysShowName() -> m_Config.bAlwaysShowName -# UserInterface/PythonSystem.cpp:334 SetAlwaysShowNameFlag(iFlag) -> bAlwaysShowName = (iFlag == 1) -# UserInterface/PythonSystem.cpp:449 config "ALWAYS_VIEW_NAME" -> atoi(value) == 1 -# UserInterface/PythonSystem.cpp:535 save "ALWAYS_VIEW_NAME %d"(仅当 != 默认值时落盘) -# UserInterface/PythonSystemModule.cpp:181/214 systemSetAlwaysShowNameFlag / systemIsAlwaysShowName -# root/uigameoption.py:220 __OnClickAlwaysShowNameOnButton -> systemSetting.SetAlwaysShowNameFlag(True) + RefreshAlwaysShowName() -# root/game.py:323 onPressKeyDict[app.DIK_LALT] -> ShowName() -# root/game.py:406 onClickKeyDict[app.DIK_LALT] -> HideName() -# root/game.py:1143 ShowName() -> self.ShowNameFlag = True -# root/game.py:1149 __IsShowName() -> if IsAlwaysShowName(): return True; if ShowNameFlag: return True; return False -# root/game.py:1533 OnRender:chr.Pick() 命中 -> textTail.ShowCharacterTextTail(idx) -# root/game.py:1535 OnRender:targetBoard.GetTargetVID() != 0 -> textTail.ShowCharacterTextTail(vid) -# root/game.py:1550 OnRender:if __IsShowName(): textTail.ShowAllTextTail() -# UserInterface/PythonTextTail.cpp:416 UpdateDistance:fDistanceFromPlayer = |(ownerX-centerX, -ownerY-centerY)| -# UserInterface/PythonTextTail.cpp:422 ShowAllTextTail:m_CharacterTextTailMap 里 fDistanceFromPlayer < 3500.0f 才 ShowCharacterTextTail -# UserInterface/PythonTextTail.cpp:440 ShowCharacterTextTail:不在图 / 已入列 / !pOwner->isShow() / 无实例 / IsGuildWall() -> return;CanPickInstance() 才入列 -# UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:chat 尾标 bNameFlag -> ShowCharacterTextTail(不受 3500 限制) -# -# seam ⑩:参考端「名字显示」是每帧重建 m_CharacterTextTailList(Pick / target / ShowAll) -# 再 Render / Arrange(防重叠)/ Hide 的屏幕空间流程;本移植是 net_world 里每个实体一块 -# billboard Label3D,靠本模块谓词逐帧 set .visible。鼠标悬停 chr.Pick() 未接(net_world -# 无悬停拾取)、屏幕空间 ArrangeTextTail 防重叠未做 —— 记为 seam ⑩。 -# 另:参考 UpdateDistance 因 owner 在渲染帧(Y 取反)、center 在像素帧才写成 -# (ownerX-centerX, -ownerY-centerY);本移植所有坐标统一在 pos_cm 帧,等价于同帧平面距。 -extends RefCounted - -const ALWAYS_SHOW_NAME_DEFAULT := true # DEFAULT_VALUE_ALWAYS_SHOW_NAME -const SHOW_ALL_DISTANCE := 3500.0 # ShowAllTextTail 的 fDistanceFromPlayer < 3500(px==cm) -const CFG_KEY := "ALWAYS_VIEW_NAME" # PythonSystem 配置行 / 存盘键 - -# SetAlwaysShowNameFlag(int iFlag): m_Config.bAlwaysShowName = iFlag == 1 -static func always_show_from_flag(flag: int) -> bool: - return flag == 1 - -# config "ALWAYS_VIEW_NAME" 行:atoi(value) == 1 -static func always_show_from_cfg(value: Variant) -> bool: - return int(value) == 1 - -# __IsShowName() = IsAlwaysShowName() or ShowNameFlag(LALT 按住) -static func is_show_name(always_show_name: bool, alt_held: bool) -> bool: - return always_show_name or alt_held - -# ShowAllTextTail:只有平面距离 < 3500 的角色尾标才 ShowCharacterTextTail -static func within_show_all(dist_cm: float) -> bool: - return dist_cm < SHOW_ALL_DISTANCE - -# UpdateDistance 的平面距。net_world 的 pos_cm 已是统一帧,直接同帧平面距 -# (参考端的 -ownerY 是渲染帧 / 像素帧混用的产物,见文件头 seam ⑩ 注)。 -static func planar_distance_cm(a_cm: Vector2, b_cm: Vector2) -> float: - return a_cm.distance_to(b_cm) - -# ShowCharacterTextTail 的资格判定(把早退条件取反): -# pOwner->isShow()(可见 / 未死透)、!IsGuildWall()、CanPickInstance() -static func eligible_character(is_shown: bool, is_guild_wall: bool, can_pick: bool) -> bool: - return is_shown and not is_guild_wall and can_pick - -# 综合:某实体这一帧的头顶名字是否显示。 -# forced —— chr.Pick() 命中 / targetBoard VID / chat 尾标 bNameFlag:都走 ShowCharacterTextTail, -# 但绕过 ShowAllTextTail 的 3500 裁剪(资格判定仍要过)。 -# 非 forced —— 需 __IsShowName() 为真 且 距离 < 3500 且 资格通过。 -static func name_visible_for(forced: bool, show_name: bool, dist_cm: float, - is_shown: bool, is_guild_wall: bool, can_pick: bool) -> bool: - if not eligible_character(is_shown, is_guild_wall, can_pick): - return false - if forced: - return true - if not show_name: - return false - return within_show_all(dist_cm) diff --git a/project/name_show.gd.uid b/project/name_show.gd.uid deleted file mode 100644 index 6f2ddee4..00000000 --- a/project/name_show.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cfmkostitpvvu diff --git a/project/name_show_test.gd b/project/name_show_test.gd deleted file mode 100644 index 02a69bcb..00000000 --- a/project/name_show_test.gd +++ /dev/null @@ -1,79 +0,0 @@ -# name_show_test —— §8.4 / §8.6 头顶名字「是否显示」1:1: -# CPythonSystem::IsAlwaysShowName / SetAlwaysShowNameFlag(flag / 配置行 == 1)、 -# game.py::__IsShowName(IsAlwaysShowName or ShowNameFlag)、 -# CPythonTextTail::ShowAllTextTail 的 fDistanceFromPlayer < 3500、 -# ShowCharacterTextTail 的 isShow / !IsGuildWall / CanPickInstance 资格判定, -# 以及 forced(Pick / target / bNameFlag)绕过 3500 但仍过资格判定。 -# godot --headless --path project --script name_show_test.gd -# 退出码 0 = 全过。 -extends SceneTree - -const NameShow = preload("res://name_show.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: name_show_test (§8.4/§8.6 IsAlwaysShowName / __IsShowName / ShowAllTextTail 3500 1:1)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # --- 0. 常量(PythonSystem.cpp / PythonTextTail.cpp)------------------------ - _ck(NameShow.ALWAYS_SHOW_NAME_DEFAULT == true, "DEFAULT_VALUE_ALWAYS_SHOW_NAME == true") - _ck(is_equal_approx(NameShow.SHOW_ALL_DISTANCE, 3500.0), "ShowAllTextTail 距离阈 == 3500") - _ck(NameShow.CFG_KEY == "ALWAYS_VIEW_NAME", "配置键 == ALWAYS_VIEW_NAME") - - # --- 1. SetAlwaysShowNameFlag(iFlag): bAlwaysShowName = iFlag == 1 ---------- - _ck(NameShow.always_show_from_flag(1) == true, "flag 1 -> true") - _ck(NameShow.always_show_from_flag(0) == false, "flag 0 -> false") - _ck(NameShow.always_show_from_flag(2) == false, "flag 2 -> false(严格 == 1)") - - # --- 2. config "ALWAYS_VIEW_NAME" -> atoi(value) == 1 --------------------- - _ck(NameShow.always_show_from_cfg("1") == true, "cfg \"1\" -> true") - _ck(NameShow.always_show_from_cfg("0") == false, "cfg \"0\" -> false") - _ck(NameShow.always_show_from_cfg("2") == false, "cfg \"2\" -> false") - _ck(NameShow.always_show_from_cfg(1) == true, "cfg int 1 -> true") - - # --- 3. __IsShowName() = IsAlwaysShowName() or ShowNameFlag --------------- - _ck(NameShow.is_show_name(false, false) == false, "都关 -> 不显示") - _ck(NameShow.is_show_name(true, false) == true, "总显示名字开 -> 显示") - _ck(NameShow.is_show_name(false, true) == true, "LALT 按住 -> 显示") - _ck(NameShow.is_show_name(true, true) == true, "都开 -> 显示") - - # --- 4. ShowAllTextTail:fDistanceFromPlayer < 3500.0f ------------------- - _ck(NameShow.within_show_all(0.0) == true, "距 0 在范围内") - _ck(NameShow.within_show_all(3499.9) == true, "距 3499.9 在范围内") - _ck(NameShow.within_show_all(3500.0) == false, "距 3500.0 严格 < 不含边界") - _ck(NameShow.within_show_all(5000.0) == false, "距 5000 超范围") - - # --- 5. UpdateDistance 平面距(本移植同帧)------------------------------ - _ck(is_equal_approx(NameShow.planar_distance_cm(Vector2(0, 0), Vector2(3, 4)), 5.0), "(0,0)-(3,4) == 5") - _ck(is_equal_approx(NameShow.planar_distance_cm(Vector2(10, 10), Vector2(10, 10)), 0.0), "同点距 0") - _ck(is_equal_approx(NameShow.planar_distance_cm(Vector2(-100, 0), Vector2(200, 0)), 300.0), "x 轴距 300") - - # --- 6. ShowCharacterTextTail 资格:isShow && !IsGuildWall && CanPickInstance - _ck(NameShow.eligible_character(true, false, true) == true, "可见 / 非墙 / 可拾取 -> 合格") - _ck(NameShow.eligible_character(false, false, true) == false, "!isShow() -> 早退") - _ck(NameShow.eligible_character(true, true, true) == false, "IsGuildWall() -> 早退") - _ck(NameShow.eligible_character(true, false, false) == false, "!CanPickInstance() -> 不入列") - - # --- 7. name_visible_for 综合 ------------------------------------------ - # 公会墙:无论 forced 与否都不显示 - _ck(NameShow.name_visible_for(true, true, 0.0, true, true, true) == false, "公会墙即便 forced 也不显示") - # forced(Pick / target / bNameFlag):绕过 3500,但资格仍要过 - _ck(NameShow.name_visible_for(true, false, 9999.0, true, false, true) == true, "forced + 合格 + 超距 -> 显示(绕过 3500)") - _ck(NameShow.name_visible_for(true, true, 0.0, false, false, true) == false, "forced 但 !isShow() -> 不显示") - _ck(NameShow.name_visible_for(true, true, 0.0, true, false, false) == false, "forced 但 !CanPickInstance() -> 不显示") - # 非 forced:要 __IsShowName() 且 < 3500 且 合格 - _ck(NameShow.name_visible_for(false, false, 100.0, true, false, true) == false, "非 forced + __IsShowName 假 -> 不显示") - _ck(NameShow.name_visible_for(false, true, 1000.0, true, false, true) == true, "非 forced + 显示开 + 距 1000 + 合格 -> 显示") - _ck(NameShow.name_visible_for(false, true, 4000.0, true, false, true) == false, "非 forced + 显示开 但 距 4000 -> 被 3500 裁掉") - _ck(NameShow.name_visible_for(false, true, 1000.0, false, false, true) == false, "非 forced + 显示开 + 近 但 死透 -> 不显示") diff --git a/project/name_show_test.gd.uid b/project/name_show_test.gd.uid deleted file mode 100644 index 7332ba7d..00000000 --- a/project/name_show_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b0tssfrjgab4y diff --git a/project/name_tail_layout.gd b/project/name_tail_layout.gd deleted file mode 100644 index 0b93a4f0..00000000 --- a/project/name_tail_layout.gd +++ /dev/null @@ -1,150 +0,0 @@ -# name_tail_layout —— §8.8 seam ⑨ 收口:头顶名字尾标「有聊天气泡时上移贴气泡」1:1。 -# CPythonTextTail::UpdateShowingTextTail 里,每条角色名字尾标投影完屏幕坐标后, -# 若该 VID 同时有一条 chat 尾标且 `pChatTail->bNameFlag`(= RegisterChatTail 注册的 -# 「聊天」气泡,非 RegisterInfoTail 的「信息」气泡),就把名字尾标的屏幕 y 顶到 -# `chatTail.y - 17.0f`(屏幕 y 向下,减 17px = 上移一行,正好贴在气泡上沿)。 -# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd / -# target_board.gd / pick_show.gd 同一套纯静态镜像套路,本模块是第 28 个 canonical。) -# -# REF(40250 ClientVS22 source/ 为唯一基线): -# UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f -# UserInterface/PythonTextTail.cpp:509 RegisterCharacterTextTail(..., float fAddHeight=10.0f) -# :519 fHeight = GetHeight() + fAddHeight —— 名字尾标世界高=角色高 + 10 -# UserInterface/PythonTextTail.cpp:581 RegisterChatTail:新建时 fHeight = GetHeight() + 10.0f、 -# :603 / :618 pTextTail->bNameFlag = TRUE —— 「聊天」气泡(白) -# UserInterface/PythonTextTail.cpp:623 RegisterInfoTail:同高, -# :646 / :661 pTextTail->bNameFlag = FALSE —— 「信息」气泡(淡红),不顶名字 -# UserInterface/PythonTextTail.cpp:88 UpdateShowingTextTail() -# :101 for (角色名字尾标 : m_CharacterTextTailList) -# :104 UpdateTextTail(pTextTail) —— 先投影出屏幕 x/y/z -# :106 itor = m_ChatTailMap.find(pTextTail->dwVirtualID) -# :110 if (m_ChatTailMap.end() != itor) -# :112 if (pChatTail->bNameFlag) -# :114 pTextTail->y = pChatTail->y - 17.0f —— 名字屏幕 y 顶到气泡上方 17px -# UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:同一个 bNameFlag 还驱动 -# ShowCharacterTextTail(dwVID) 强显(增量 112/113 已接 chat_tail.gd / name_show.gd)。 -# UserInterface/PythonTextTail.cpp:151 ArrangeTextTail() -# :211 for (pTextTail : m_CharacterTextTailList) —— 逐条角色名字尾标 -# :228 pGuildNameInstance->SetPosition(x + iImageHalfSize, pTextTail->y - c_fyGuildNamePosition, z) -# :21 const float c_fyGuildNamePosition = 15.0f —— 公会名锚在名字 y 上方 15px -# :258 pTitle->SetPosition(x - iNameWidth/2 - fxAdd, pTextTail->y, z) —— 称号与名字同一行 -# :283 pLevel->SetPosition(x - iNameWidth/2 - fxAdd - iTitleWidth, pTextTail->y, z) —— 等级同一行 -# —— 公会名 / 称号 / 等级三个子标签的屏幕 y 全部取自 `pTextTail->y`,而这个 y 已在 -# UpdateShowingTextTail :114 被聊天气泡顶过 -17。所以名字尾标一旦上移,子标签整体跟随。 -# -# seam ⑨(已收口):参考端名字与气泡默认投影到同一世界点(GetHeight()+10),靠这条屏幕 -# -17px 把名字从「和气泡重叠」顶成「贴气泡上沿」。poc 是每实体一个 billboard Label3D、 -# 气泡默认在名字上方(局部 y 2.35 vs 2.1),本模块把屏幕 y 向下的 -17px 换成世界 y -# 向上的 +17px*pixel_size:有「聊天」气泡时名字世界 y 顶到气泡 y 之上 17px,气泡消失 -# (或只是「信息」气泡)时回默认 y。像素 vs 世界的换算按名字 Label3D 的 pixel_size, -# 与参考的字体无关常量 17 对齐。 -# seam ⑦(部分收口 · 增量 119):子标签(GuildTag/TitleTag/LevelTag)随名字尾标纵向位移 -# 已 1:1(name_tail_lift_delta / subtag_world_y:名字上移多少子标签整体上移多少)。 -# seam ⑦(水平排版 · 增量 121):ArrangeTextTail 角色块(PythonTextTail.cpp:211-320)非 -# EUROPE 分支的逐像素水平排布也已 1:1(下方 name_x_shift / title_x_offset / -# level_x_offset / guild_name_x_offset / mark_x_offset / subtag_world_x): -# :236 fxAdd = 0 —— 无称号无等级 -# :242 if (pTitle) fxAdd = 8.0f -# :258 pTitle->SetPosition(x - iNameWidth/2 - fxAdd, y, z) (称号,居中对齐) -# :263 if (pLevel) —— 有称号时等级在称号左侧 -# :282 pLevel->SetPosition(x - iNameWidth/2 - fxAdd - iTitleWidth, y, z)(等级,右对齐) -# :288 else fxAdd = 4.0f —— 只有等级 -# :312 pLevel->SetPosition(x - iNameWidth/2 - fxAdd, y, z) -# :320 pTextInstance->SetPosition(x + fxAdd, y, z) —— 名字本身右移 fxAdd -# :224 iImageHalfSize = pMarkInstance->GetWidth()/2 + c_fxMarkPosition(1.5) -# :227 pMarkInstance->SetPosition(x - guildW/2 - iImageHalfSize, y - c_fyMarkPosition(26)) -# :228 pGuildNameInstance->SetPosition(x + iImageHalfSize, y - c_fyGuildNamePosition(15), z) -# —— 公会名的水平锚点只在「有公会徽记 pMarkInstance」时才被 ArrangeTextTail 改写。 -# 仍近似(seam ⑦ 遗留):① poc 无公会徽记 mark 实体 —— 参考端 :220 的 -# `if (pMarkInstance && pGuildNameInstance)` 不成立时公会名水平锚点不动,poc 亦让 -# GuildTag 留在名字中线正上方(x 偏移 0),不做 +markW/2+1.5;② 文本宽度 poc 用 -# 「字符数 * 约 7px」估,参考端是 GetTextSize 精确像素;③ 三子标签竖排 vs 参考同一 -# 水平行(poc 3D billboard 里三条挤在一行会互相糊,保留竖排换 x 偏移的折衷)。 -extends RefCounted - -# PythonTextTail.cpp:114 的 `17.0f`。屏幕坐标下是「名字 y 减 17」(上移一行贴气泡)。 -const NAME_OFFSET_ABOVE_BUBBLE_PX := 17.0 - -# PythonTextTail.cpp:21 的 `c_fyGuildNamePosition = 15.0f`:参考端公会名锚在名字尾标 -# 屏幕 y 上方 15px(称号 / 等级则与名字同一行)。poc 竖排近似不用它做像素排版,仅作 -# 1:1 记录——子标签真正跟随的是「名字尾标位移量」,见 subtag_world_y。 -const GUILD_NAME_ABOVE_NAME_PX := 15.0 - -# UpdateShowingTextTail :110 `m_ChatTailMap.end() != itor` && :112 `pChatTail->bNameFlag`。 -# has_chat_tail —— 该 VID 在 m_ChatTailMap 里有一条(聊天或信息); -# chat_name_flag —— 那条的 bNameFlag(RegisterChatTail=TRUE / RegisterInfoTail=FALSE)。 -static func name_follows_bubble(has_chat_tail: bool, chat_name_flag: bool) -> bool: - return has_chat_tail and chat_name_flag - -# 参考端字面公式(屏幕坐标,y 向下):follows 时 `pChatTail->y - 17.0f`,否则名字投影原值。 -static func name_tail_screen_y(default_y: float, bubble_y: float, follows: bool) -> float: - if follows: - return bubble_y - NAME_OFFSET_ABOVE_BUBBLE_PX - return default_y - -# poc 3D 适配(世界坐标,y 向上):屏幕的 -17px 变成 +17px*pixel_size —— follows 时名字 -# 世界 y 顶到气泡世界 y 之上 17 个名字像素,否则回默认世界 y。 -static func name_tail_world_y(default_world_y: float, bubble_world_y: float, - follows: bool, px_size: float) -> float: - if follows: - return bubble_world_y + NAME_OFFSET_ABOVE_BUBBLE_PX * px_size - return default_world_y - -# 名字尾标相对默认世界 y 的位移量(follows 时 = 顶到气泡上方后的净上移,否则 0)。 -# ArrangeTextTail 里公会名 / 称号 / 等级都锚在 pTextTail->y 上,位移量就是子标签要跟随的量。 -static func name_tail_lift_delta(default_world_y: float, bubble_world_y: float, - follows: bool, px_size: float) -> float: - return name_tail_world_y(default_world_y, bubble_world_y, follows, px_size) - default_world_y - -# ArrangeTextTail :228 / :258 / :283:子标签屏幕 y 取自 pTextTail->y(已被聊天气泡顶过)。 -# poc 里子标签各有自己的默认局部 y(竖排近似),名字尾标上移 lift_delta 时三者整体加同一段。 -static func subtag_world_y(default_subtag_y: float, name_tail_lift_delta: float) -> float: - return default_subtag_y + name_tail_lift_delta - -# --- seam ⑦ 水平排版(ArrangeTextTail 角色块,PythonTextTail.cpp:211-320 非 EUROPE 分支)--- - -# :242 `fxAdd = 8.0f`(if pTitle)。屏幕像素,名字锚点 x 与三子标签公用。 -const NAME_X_ADD_TITLE := 8.0 -# :288 `fxAdd = 4.0f`(else,只有等级)。 -const NAME_X_ADD_LEVEL_ONLY := 4.0 -# :20 `const float c_fxMarkPosition = 1.5f`。 -const MARK_X_PAD := 1.5 -# :22 `const float c_fyMarkPosition = 15.0f + 11.0f`。公会徽记锚在名字 y 上方 26px。 -const MARK_ABOVE_NAME_PX := 26.0 - -# :320 `pTextInstance->SetPosition(pTextTail->x + fxAdd, ...)`:名字本身相对锚点右移 fxAdd。 -# 有称号 -> 8;无称号只有等级 -> 4;都没有 -> 0。 -static func name_x_shift(has_title: bool, has_level: bool) -> float: - if has_title: - return NAME_X_ADD_TITLE - if has_level: - return NAME_X_ADD_LEVEL_ONLY - return 0.0 - -# :258 `pTitle->SetPosition(pTextTail->x - (iNameWidth/2) - fxAdd, ...)`(fxAdd = 8)。 -# 返回称号中心相对名字锚点 x 的像素偏移(负 = 在名字左侧)。 -static func title_x_offset(name_w: float) -> float: - return -(name_w / 2.0) - NAME_X_ADD_TITLE - -# 等级相对名字锚点 x 的像素偏移(右对齐实例,故这是其右边缘位置): -# 有称号(:282,fxAdd = 8):x - iNameWidth/2 - 8 - iTitleWidth —— 再往称号左边挪一个称号宽; -# 只有等级(:312,fxAdd = 4):x - iNameWidth/2 - 4。 -static func level_x_offset(name_w: float, title_w: float, has_title: bool) -> float: - if has_title: - return -(name_w / 2.0) - NAME_X_ADD_TITLE - title_w - return -(name_w / 2.0) - NAME_X_ADD_LEVEL_ONLY - -# :224/:228 公会名中心 = x + iImageHalfSize,iImageHalfSize = markW/2 + c_fxMarkPosition(1.5)。 -# 仅在有公会徽记时 ArrangeTextTail 才改写公会名 x;无徽记(poc 现状)应传 mark_w = 0 -# 并按「参考端此路不改写」处理(调用方直接留 0 偏移),此函数仅供有徽记时用。 -static func guild_name_x_offset(mark_w: float) -> float: - return mark_w / 2.0 + MARK_X_PAD - -# :227 徽记位置 = x - guildW/2 - iImageHalfSize(相对名字锚点 x 的像素偏移,负 = 左侧)。 -static func mark_x_offset(guild_w: float, mark_w: float) -> float: - return -(guild_w / 2.0) - (mark_w / 2.0 + MARK_X_PAD) - -# poc 3D 适配:参考端在屏幕像素空间摆位(+x 向右),poc 子标签是 billboard Label3D —— -# billboard 本地 +x 恒等于屏幕右,按该 Label3D 的 pixel_size 把像素偏移折成世界本地 x。 -static func subtag_world_x(px_offset: float, px_size: float) -> float: - return px_offset * px_size diff --git a/project/name_tail_layout.gd.uid b/project/name_tail_layout.gd.uid deleted file mode 100644 index 377acbdf..00000000 --- a/project/name_tail_layout.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cjink4dvgp0qu diff --git a/project/name_tail_layout_test.gd b/project/name_tail_layout_test.gd deleted file mode 100644 index 2e626cb2..00000000 --- a/project/name_tail_layout_test.gd +++ /dev/null @@ -1,134 +0,0 @@ -# name_tail_layout_test —— §8.8 seam ⑨:名字尾标「有聊天气泡时顶到气泡上方 17px」1:1。 -# name_follows_bubble(有 chat 尾标 && bNameFlag)、name_tail_screen_y(参考端字面 -# `chat.y - 17`)、name_tail_world_y(poc 世界坐标 `bubble_y + 17*pixel_size`)、 -# 信息气泡(bNameFlag FALSE)不顶名字、无气泡回默认。 -# 增量 119(seam ⑦ 纵向):name_tail_lift_delta / subtag_world_y —— 公会名 / 称号 / 等级 -# 子标签随名字尾标纵向位移整体平移同一段(ArrangeTextTail :228/:258/:283)。 -# 增量 121(seam ⑦ 水平):name_x_shift / title_x_offset / level_x_offset / -# guild_name_x_offset / mark_x_offset / subtag_world_x —— 逐像素水平排布 -# (ArrangeTextTail :211-320 非 EUROPE 分支:称号 / 等级在名字左侧、名字右移 fxAdd)。 -# godot --headless --path project --script name_tail_layout_test.gd -# 退出码 0 = 全过。 -extends SceneTree - -const NameTailLayout = preload("res://name_tail_layout.gd") - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _feq(a: float, b: float, m: String) -> void: - _ck(absf(a - b) < 0.0001, "%s (got %f want %f)" % [m, a, b]) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: name_tail_layout_test (§8.8 seam ⑨ 名字尾标贴气泡 1:1)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - _feq(NameTailLayout.NAME_OFFSET_ABOVE_BUBBLE_PX, 17.0, "常量 17.0(PythonTextTail.cpp:114)") - - # --- 0. name_follows_bubble(:110 有尾标 && :112 bNameFlag)------------------ - _ck(NameTailLayout.name_follows_bubble(true, true) == true, "有聊天气泡(bNameFlag TRUE) -> 顶名字") - _ck(NameTailLayout.name_follows_bubble(true, false) == false, "有信息气泡(bNameFlag FALSE) -> 不顶") - _ck(NameTailLayout.name_follows_bubble(false, true) == false, "无气泡 -> 不顶(即便 flag 传 true)") - _ck(NameTailLayout.name_follows_bubble(false, false) == false, "无气泡无 flag -> 不顶") - - # --- 1. name_tail_screen_y(参考端字面:屏幕 y 向下,follows -> bubble_y - 17)--- - _feq(NameTailLayout.name_tail_screen_y(300.0, 250.0, true), 233.0, "follows -> 气泡屏幕 y 250 - 17 = 233") - _feq(NameTailLayout.name_tail_screen_y(300.0, 250.0, false), 300.0, "!follows -> 名字投影原值 300") - # 与 name_follows_bubble 串起来:信息气泡不顶 -> 用默认 - var follows_info := NameTailLayout.name_follows_bubble(true, false) - _feq(NameTailLayout.name_tail_screen_y(300.0, 250.0, follows_info), 300.0, "信息气泡 -> 名字留默认屏幕 y") - - # --- 2. name_tail_world_y(poc:世界 y 向上,follows -> bubble_y + 17*px_size)--- - # 名字 Label3D pixel_size = 0.006 -> 17px = 0.102 世界单位;气泡局部 y = 2.35。 - _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.006), 2.35 + 17.0 * 0.006, - "follows -> 名字世界 y = 气泡 2.35 + 17*0.006") - _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.006), 2.452, "= 2.452") - _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, false, 0.006), 2.1, "!follows -> 回默认世界 y 2.1") - # 顶起来后名字确实在气泡之上(世界 y 更大) - _ck(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.006) > 2.35, "顶起后名字在气泡上方") - # 不同 pixel_size 线性缩放 - _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, true, 0.012), 2.35 + 0.204, "px_size 翻倍 -> 偏移翻倍") - - # --- 3. 组合:完整判定链 -------------------------------------------------- - # 聊天气泡在,名字顶上去 - var f1 := NameTailLayout.name_follows_bubble(true, true) - _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, f1, 0.006), 2.452, "聊天气泡 -> 名字 2.452") - # 气泡过期(has=false),名字回落 - var f2 := NameTailLayout.name_follows_bubble(false, false) - _feq(NameTailLayout.name_tail_world_y(2.1, 2.35, f2, 0.006), 2.1, "气泡过期 -> 名字回 2.1") - - # --- 4. seam ⑦ 收口(增量 119):子标签随名字尾标纵向位移 ------------------- - _feq(NameTailLayout.GUILD_NAME_ABOVE_NAME_PX, 15.0, "常量 15.0(c_fyGuildNamePosition)") - # name_tail_lift_delta:follows -> 顶起后的净上移量;否则 0。 - _feq(NameTailLayout.name_tail_lift_delta(2.1, 2.35, true, 0.006), 2.452 - 2.1, - "follows -> lift_delta = 2.452 - 2.1 = 0.352") - _feq(NameTailLayout.name_tail_lift_delta(2.1, 2.35, false, 0.006), 0.0, - "!follows -> lift_delta = 0") - _feq(NameTailLayout.name_tail_lift_delta(2.1, 2.35, true, 0.012), (2.35 + 0.204) - 2.1, - "px_size 翻倍 -> lift_delta 随之变大") - # subtag_world_y:默认局部 y 加上位移量。三个子标签整体平移同一段。 - var lift := NameTailLayout.name_tail_lift_delta(2.1, 2.35, true, 0.006) - _feq(NameTailLayout.subtag_world_y(2.34, lift), 2.34 + 0.352, "GuildTag 2.34 -> 2.692") - _feq(NameTailLayout.subtag_world_y(1.86, lift), 1.86 + 0.352, "TitleTag 1.86 -> 2.212") - _feq(NameTailLayout.subtag_world_y(1.74, lift), 1.74 + 0.352, "LevelTag 1.74 -> 2.092") - # 三者之间的相对间距不变(整体平移,不是各自缩放) - var g := NameTailLayout.subtag_world_y(2.34, lift) - var t := NameTailLayout.subtag_world_y(1.86, lift) - _feq(g - t, 2.34 - 1.86, "平移后 GuildTag/TitleTag 相对间距不变") - # 气泡过期 -> lift 0 -> 子标签回默认 - var lift0 := NameTailLayout.name_tail_lift_delta(2.1, 2.35, false, 0.006) - _feq(NameTailLayout.subtag_world_y(2.34, lift0), 2.34, "气泡过期 -> GuildTag 回默认 2.34") - _feq(NameTailLayout.subtag_world_y(1.74, lift0), 1.74, "气泡过期 -> LevelTag 回默认 1.74") - - # --- 5. seam ⑦ 水平排版(增量 121 / ArrangeTextTail :211-320 非 EUROPE 分支)-------- - _feq(NameTailLayout.NAME_X_ADD_TITLE, 8.0, "常量 8.0(:242 fxAdd when pTitle)") - _feq(NameTailLayout.NAME_X_ADD_LEVEL_ONLY, 4.0, "常量 4.0(:288 fxAdd when 仅等级)") - _feq(NameTailLayout.MARK_X_PAD, 1.5, "常量 1.5(:20 c_fxMarkPosition)") - _feq(NameTailLayout.MARK_ABOVE_NAME_PX, 26.0, "常量 26.0(:22 c_fyMarkPosition = 15 + 11)") - - # name_x_shift(:320 名字本身右移 fxAdd) - _feq(NameTailLayout.name_x_shift(true, true), 8.0, "有称号 -> 名字右移 8") - _feq(NameTailLayout.name_x_shift(true, false), 8.0, "有称号无等级 -> 名字右移 8") - _feq(NameTailLayout.name_x_shift(false, true), 4.0, "只有等级 -> 名字右移 4") - _feq(NameTailLayout.name_x_shift(false, false), 0.0, "都没有 -> 名字不移") - - # title_x_offset(:258 x - iNameWidth/2 - 8) - _feq(NameTailLayout.title_x_offset(40.0), -(40.0 / 2.0) - 8.0, "称号 x 偏移 = -nameW/2 - 8 = -28") - _ck(NameTailLayout.title_x_offset(40.0) < 0.0, "称号在名字左侧") - - # level_x_offset:有称号 -> :282 x - nameW/2 - 8 - titleW;只有等级 -> :312 x - nameW/2 - 4 - _feq(NameTailLayout.level_x_offset(40.0, 30.0, true), -(40.0 / 2.0) - 8.0 - 30.0, - "有称号:等级 x 偏移 = -nameW/2 - 8 - titleW = -58") - _feq(NameTailLayout.level_x_offset(40.0, 30.0, false), -(40.0 / 2.0) - 4.0, - "只有等级:等级 x 偏移 = -nameW/2 - 4 = -24(titleW 被忽略)") - _ck(NameTailLayout.level_x_offset(40.0, 30.0, true) < NameTailLayout.title_x_offset(40.0), - "有称号时等级比称号更靠左") - - # guild_name_x_offset / mark_x_offset(:224/:227/:228,仅有公会徽记时用) - _feq(NameTailLayout.guild_name_x_offset(20.0), 20.0 / 2.0 + 1.5, "公会名 x 偏移 = markW/2 + 1.5 = 11.5") - _feq(NameTailLayout.guild_name_x_offset(0.0), 1.5, "无徽记宽 -> 1.5(调用方实际按此路不改写处理)") - _feq(NameTailLayout.mark_x_offset(60.0, 20.0), -(60.0 / 2.0) - (20.0 / 2.0 + 1.5), - "徽记 x 偏移 = -guildW/2 - (markW/2 + 1.5) = -41.5") - - # subtag_world_x:像素偏移 * pixel_size,线性;符号保留(左负右正) - _feq(NameTailLayout.subtag_world_x(-28.0, 0.006), -28.0 * 0.006, "-28px @ 0.006 -> -0.168 世界本地 x") - _feq(NameTailLayout.subtag_world_x(8.0, 0.006), 0.048, "8px @ 0.006 -> +0.048") - _feq(NameTailLayout.subtag_world_x(-28.0, 0.012), -0.336, "pixel_size 翻倍 -> 偏移翻倍") - _feq(NameTailLayout.subtag_world_x(0.0, 0.006), 0.0, "0 偏移 -> 0") - - # 组合:名字 40px、称号 30px、等级 12px,都在 -> 三者从左到右 = 等级、称号、名字 - var name_px := 40.0 - var title_px := 30.0 - var nx := NameTailLayout.name_x_shift(true, true) # +8 - var tx := NameTailLayout.title_x_offset(name_px) # -28 - var lx := NameTailLayout.level_x_offset(name_px, title_px, true) # -58 - _ck(lx < tx and tx < nx, "水平顺序:等级(-58) < 称号(-28) < 名字(+8)") diff --git a/project/name_tail_layout_test.gd.uid b/project/name_tail_layout_test.gd.uid deleted file mode 100644 index 84938e28..00000000 --- a/project/name_tail_layout_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bwqn6xii0x5xs diff --git a/project/native_tree_test.gd b/project/native_tree_test.gd deleted file mode 100644 index 5405b18c..00000000 --- a/project/native_tree_test.gd +++ /dev/null @@ -1,107 +0,0 @@ -extends SceneTree -var failures: Array[String] = [] -var report := {"species": [], "maps": [], "scope": "native static tree geometry/materials; not SpeedTree wind/LOD parity"} -func check(ok: bool, label: String) -> void: - if not ok: - failures.append(label) - printerr("FAIL: " + label) -func _init() -> void: call_deferred("run") -func run() -> void: - var assets := AssetRoot.path() - var directory := assets.path_join("TreeGeometry") - var manifest: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(directory.path_join("manifest.json"))) - check(manifest.trees.size() == 87, "all 87 extracted species staged") - var sample_mesh: ArrayMesh - for hash in manifest.trees: - var record: Dictionary = manifest.trees[hash] - var path := directory.path_join(record.glb) - check(FileAccess.get_sha256(assets.path_join(record.source)) == hash, "SPT provenance " + record.source) - check(FileAccess.get_sha256(path) == record.glb_sha256, "GLB provenance " + record.source) - var state := GLTFState.new() - var doc := GLTFDocument.new() - check(doc.append_from_file(path, state) == OK, "GLB loads " + record.source) - if state.get_meshes().size() != 1: - check(false, "one native combined mesh") - continue - var mesh: ArrayMesh = state.get_meshes()[0].get_mesh().get_mesh() - check(mesh.get_surface_count() == int(record.primitives), "all branch/frond/leaf surfaces retained") - var masked := 0 - var vertices := 0 - for surface in mesh.get_surface_count(): - var arrays := mesh.surface_get_arrays(surface) - vertices += arrays[Mesh.ARRAY_VERTEX].size() - var material: StandardMaterial3D = mesh.surface_get_material(surface) - check(material != null and material.albedo_texture != null, "real texture retained") - if material and material.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR: - masked += 1 - check(material.cull_mode == BaseMaterial3D.CULL_DISABLED, "leaf cards double-sided") - check(is_equal_approx(material.alpha_scissor_threshold, 0.3), "native alpha cutoff retained") - check(vertices > 0 and mesh.get_aabb().size.is_finite(), "finite native geometry") - report.species.append({"source": record.source, "vertices": vertices, "surfaces": mesh.get_surface_count(), "masked": masked, "height_m": mesh.get_aabb().size.y}) - if String(record.source).ends_with("b1_beech_rt2.spt"): sample_mesh = mesh - for map in ["OutdoorA1/metin2_map_a1", "OutdoorA3/metin2_map_a3", "OutdoorB1/metin2_map_b1", - "OutdoorB3/metin2_map_b3", "OutdoorC1/metin2_map_c1", "OutdoorC3/metin2_map_c3"]: - var world := Metin2World.new() - world.auto_load = false - world.env_enabled = false - world.assets_root = assets - world.map_path = map - root.add_child(world) - check(world.load_map(), "map loads " + map) - var stats: Dictionary = world.get_load_report() - check(stats.native_trees_placed > 0 and stats.proxy_trees_placed == 0, "all placed trees native " + map) - check(stats.native_trees_placed == stats.trees_placed, "tree placement count preserved") - for node in world.find_children("*", "MultiMeshInstance3D", true, false): - if node.has_meta("tree_geometry"): - check(node.get_meta("tree_geometry") == "native_spt", "renderer uses native path") - check(node.multimesh.mesh.get_meta("source_sha256", "") != "", "mesh provenance retained") - report.maps.append({"map": map, "native": stats.native_trees_placed, "proxy": stats.proxy_trees_placed}) - world.free() - await process_frame - var output := OS.get_environment("MT_TREE_OUTPUT") - if output == "": output = ProjectSettings.globalize_path("res://../build/rendering/native-trees") - DirAccess.make_dir_recursive_absolute(output) - if sample_mesh and DisplayServer.get_name() != "headless": await render_sample(sample_mesh, output) - report["failures"] = failures - FileAccess.open(output.path_join("report.json"), FileAccess.WRITE).store_string(JSON.stringify(report, "\t")) - print("native_tree_test: failures=%d report=%s" % [failures.size(), output]) - quit(1 if not failures.is_empty() else 0) -func render_sample(mesh: ArrayMesh, output: String) -> void: - var viewport := SubViewport.new() - viewport.size = Vector2i(640, 640) - 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(0.12, 0.16, 0.23) - environment.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - environment.environment.ambient_light_color = Color.WHITE - environment.environment.ambient_light_energy = 0.8 - viewport.add_child(environment) - var instance := MeshInstance3D.new() - instance.mesh = mesh - viewport.add_child(instance) - var light := DirectionalLight3D.new() - light.rotation_degrees = Vector3(-40, -30, 0) - viewport.add_child(light) - var camera := Camera3D.new() - camera.projection = Camera3D.PROJECTION_ORTHOGONAL - camera.size = 28 - viewport.add_child(camera) - for angle in [0.0, PI / 2]: - camera.position = Vector3(sin(angle) * 35, 12, cos(angle) * 35) - camera.look_at(Vector3(0, 10, 0)) - await process_frame - await RenderingServer.frame_post_draw - var image := viewport.get_texture().get_image() - var background := image.get_pixel(0, 0) - var visible := 0 - for y in 640: - for x in 640: - if image.get_pixel(x, y) != background: visible += 1 - check(visible > 5000 and visible < 200000, "native tree silhouette is visible without opaque atlas rectangle") - check(image.save_png(output.path_join("beech-%d.png" % int(rad_to_deg(angle)))) == OK, "native tree screenshot") - viewport.free() - await process_frame diff --git a/project/native_tree_test.gd.uid b/project/native_tree_test.gd.uid deleted file mode 100644 index 2cb6f8fe..00000000 --- a/project/native_tree_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://brr0ihehpik0y diff --git a/project/net/channel_status.gd b/project/net/channel_status.gd deleted file mode 100644 index 2790cee2..00000000 --- a/project/net/channel_status.gd +++ /dev/null @@ -1,181 +0,0 @@ -# ChannelStatus (P10 收尾) —— 频道负载查询(对齐 CServerStateChecker)。 -# -# var cs := preload("res://net/channel_status.gd").new() -# add_child(cs) -# cs.query(host, port, func(map): ...) # map: { port:int -> status:int } -# -# 明文 TCP(无 libsodium):m2dev 发 CG_STATE_CHECKER(0x000F,len 4),40250 classic -# 发单字节 CG_STATE_CHECKER(206);服务端均返回频道条目。classic 响应是 -# [210][int32 count][count × {i16 port, u8 status}],m2dev 则是带 u16 header/length 的包。 -# status:0 关 / 1 正常 / 2 忙 / 3 满(Metin2 约定,非在线人数——那要改服务器)。 -extends Node - -const CG_STATE_CHECKER := 0x000F -const GC_RESPOND_CHANNELSTATUS := 0x0010 -const CLASSIC_CG_STATE_CHECKER := 206 -const CLASSIC_GC_RESPOND_CHANNELSTATUS := 210 - -const STATUS_TEXT := {0: "关闭", 1: "正常", 2: "拥挤", 3: "爆满"} - -signal done(status_by_port: Dictionary) - -var _peer: StreamPeerTCP -var _cb: Callable -var _deadline := 0.0 -var _sent := false -var _classic := false -var _classic_rx := PackedByteArray() -var _status_body_rx := PackedByteArray() - -func query(host: String, port: int, callback: Callable = Callable(), timeout_s := 3.0) -> void: - # ServerStateChecker owns one probe stream. Cancel the previous transport - # before replacing it so a repeated request cannot leave an old socket alive - # behind the new callback. - if _peer != null: - _peer.disconnect_from_host() - _peer = null - set_process(false) - _cb = callback - _peer = StreamPeerTCP.new() - _sent = false - _classic = OS.get_environment("MT_PROTOCOL") == "classic" - _classic_rx = PackedByteArray() - _status_body_rx = PackedByteArray() - _deadline = _now() + timeout_s - if _peer.connect_to_host(host, port) != OK: - _finish({}) - return - set_process(true) - -func text_for(status: int) -> String: - return STATUS_TEXT.get(status, "?") - -func _now() -> float: - return Time.get_ticks_msec() / 1000.0 - -func _process(_dt: float) -> void: - if _peer == null: - return - _peer.poll() - var st := _peer.get_status() - if st == StreamPeerTCP.STATUS_ERROR or st == StreamPeerTCP.STATUS_NONE or _now() > _deadline: - _finish({}) - return - if st != StreamPeerTCP.STATUS_CONNECTED: - return - if not _sent: - var req := PackedByteArray([CLASSIC_CG_STATE_CHECKER]) if _classic else PackedByteArray() - if not _classic: - req.resize(4) - req.encode_u16(0, CG_STATE_CHECKER) - req.encode_u16(2, 4) - _peer.put_data(req) - _sent = true - # 攒够一个包就解析 - if _classic: - _try_parse_classic() - else: - _try_parse() - -func _try_parse_classic() -> void: - if _peer == null or _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED: - _finish({}) - return - var avail := _peer.get_available_bytes() - if avail > 0: - var r: Array = _peer.get_data(avail) - if r[0] != OK: - _finish({}) - return - _classic_rx.append_array(r[1]) - - # The stock checker reads a byte header, then an int32 count. A regular - # game handshake may arrive while waiting, so discard known fixed packets. - while not _classic_rx.is_empty() and _classic_rx[0] != CLASSIC_GC_RESPOND_CHANNELSTATUS: - var skip := 1 - match int(_classic_rx[0]): - 0xFE: skip = 13 # GC_HANDSHAKE - 0xFC: skip = 1 # GC_TIME_SYNC - if _classic_rx.size() < skip: - return - _classic_rx = _classic_rx.slice(skip) - if _classic_rx.size() < 5: - return - var count := _classic_rx.decode_s32(1) - if count < 0 or count > 4096: - _finish({}) - return - var total := 5 + count * 3 - if _classic_rx.size() < total: - return - var out := {} - var off := 5 - for i in count: - out[_classic_rx.decode_s16(off)] = _classic_rx.decode_u8(off + 2) - off += 3 - _finish(out) - -func _try_parse() -> void: - if _peer == null or _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED: - _finish({}) - return - var avail := _peer.get_available_bytes() - if avail < 4: - return - # 逐包扫,跳到 RESPOND_CHANNELSTATUS - while _peer.get_available_bytes() >= 4: - var head: Array = _peer.get_partial_data(4) - if head[0] != OK: - return - var hb: PackedByteArray = head[1] - var header := hb.decode_u16(0) - var length := hb.decode_u16(2) - if header == GC_RESPOND_CHANNELSTATUS: - _read_body() - return - # 别的包:按 length 跳过剩余(已吃掉 4 字节头) - var rest := maxi(0, length - 4) - if rest > 0: - if _peer.get_available_bytes() < rest: - return # 等更多数据(简化:不缓存半包,靠 timeout 兜底) - _peer.get_data(rest) - -func _read_body() -> void: - # 已消费 4 字节头;接着 int32 count + count × {i16 port, u8 status}。 - # StreamPeerTCP 可能把 body 分成多个到达批次,必须保留已收到的 - # 字节,不能像旧实现一样在第一条不完整 record 时提前 finish。 - var avail := _peer.get_available_bytes() - if avail > 0: - var chunk: Array = _peer.get_data(avail) - if chunk[0] != OK: - _finish({}) - return - _status_body_rx.append_array(chunk[1]) - if _status_body_rx.size() < 4: - return - var count: int = _status_body_rx.decode_s32(0) - if count < 0 or count > 4096: - _finish({}) - return - var total := 4 + count * 3 - if _status_body_rx.size() < total: - return - var out := {} - for i in count: - var off := 4 + i * 3 - out[_status_body_rx.decode_s16(off)] = _status_body_rx.decode_u8(off + 2) - _finish(out) - -func _finish(m: Dictionary) -> void: - # Capture the callback before emitting `done`: a listener may immediately - # start the next probe. Calling the mutable `_cb` afterwards would deliver - # the old result to the new request (a stale re-entrant callback). - var callback := _cb - _cb = Callable() - set_process(false) - if _peer: - _peer.disconnect_from_host() - _peer = null - done.emit(m) - if callback.is_valid(): - callback.call(m) diff --git a/project/net/channel_status.gd.uid b/project/net/channel_status.gd.uid deleted file mode 100644 index 89e1f6e6..00000000 --- a/project/net/channel_status.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://015nea7sbodm diff --git a/project/net/mapcoord.gd b/project/net/mapcoord.gd deleted file mode 100644 index 85f99f1f..00000000 --- a/project/net/mapcoord.gd +++ /dev/null @@ -1,41 +0,0 @@ -# MapCoord —— 网络实体坐标 ↔ Metin2World 本地坐标。 -# -# M2Client 给的实体 pos 是「全局服务器 cm」经 position_to_godot 后的结果: -# net = (sx*0.01, h, -sy*0.01) (sx/sy 含地图 BasePosition) -# 而 Metin2World 的地形/物件是「地图本地」帧(tile 0 在原点,+Z 朝南): -# world = ((sx-bx)*0.01, h, (sy-by)*0.01) -# 两者差一个 BasePosition 平移 + Z 轴翻向。没对齐时角色会飘在几公里外的黑void里。 -# -# MapCoord.set_base(world.get_map_base_cm()) # Vector2,cm -# var p := MapCoord.to_world(client.get_entity(vid).pos) -# var sc := MapCoord.to_server_cm(player.position) # -> Vector2 (sx_cm, sy_cm) -class_name MapCoord -extends RefCounted - -static var _bx := 0.0 # BasePosition.x, cm -static var _by := 0.0 - -static func set_base(base_cm: Vector2) -> void: - _bx = base_cm.x - _by = base_cm.y - -static func has_base() -> bool: - return _bx != 0.0 or _by != 0.0 - -# net 帧 (sx*.01, h, -sy*.01) -> world 本地帧 ((sx-bx)*.01, h, (sy-by)*.01) -static func to_world(net_pos: Vector3) -> Vector3: - return Vector3(net_pos.x - _bx * 0.01, net_pos.y, -net_pos.z - _by * 0.01) - -# world 本地帧 -> 服务器全局 cm (sx, sy) -static func to_server_cm(world_pos: Vector3) -> Vector2: - return Vector2(world_pos.x * 100.0 + _bx, world_pos.z * 100.0 + _by) - -# 服务器 heading(度) -> world 帧 Godot yaw(弧度)。 -# Metin2 heading: 0°=南, 90°=东, 180°=北, 270°=西。 -# Godot yaw: atan2(x, z) 中 +Z(南)=0, +X(东)=PI/2(90°), -Z(北)=PI(180°), -X(西)=-PI/2(270°)。 -# 两者定义完全一致,无需额外偏移。 -static func heading_to_yaw(angle_deg: float) -> float: - return deg_to_rad(angle_deg) - -static func yaw_to_heading(yaw_rad: float) -> float: - return fposmod(rad_to_deg(yaw_rad), 360.0) diff --git a/project/net/mapcoord.gd.uid b/project/net/mapcoord.gd.uid deleted file mode 100644 index 6f5e3226..00000000 --- a/project/net/mapcoord.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c36ncgt70wf0a diff --git a/project/net/serverinfo.gd b/project/net/serverinfo.gd deleted file mode 100644 index 2d975111..00000000 --- a/project/net/serverinfo.gd +++ /dev/null @@ -1,92 +0,0 @@ -# ServerInfo (P10) —— 服务器列表 + 频道 → (host, port)。 -# -# var si := ServerInfo.new() -# si.load_file("res://serverlist.txt") # 可选;没有就用内置默认 -# for s in si.servers(): ... -# var addr := si.address(server_index, channel) # {auth_host,auth_port,game_host,game_port} -# -# 文件格式(TSV,# 注释): -# name auth_host auth_port game_host game_port channels(csv) [port_step] [mark_port] -# 测试服 192.168.21.203 11000 192.168.21.203 13002 1,2,3,4 10 0 -# -# 频道 N 的 game 端口 = game_port + (N-1)*port_step。 -# 这套 40250 配置的 Game2 端口是 13002/13012/13022/13032(step=10)。 -# 13000/13010/... 是 first 入口;admin 测试角色在 Game2 地图,直接连 Game2 才能完成进入游戏。 -# 第 8 列 mark_port = 公会会徽服端口(0 = 不下载会徽)。 -class_name ServerInfo -extends RefCounted - -const DEFAULT_PORT_STEP := 1 - -var _servers: Array = [] - -func _init() -> void: - # 内置默认:40250 服务器(认证 11000,channel1/Game2 端口 13002) - _servers = [{ - "name": "测试服", - "auth_host": "192.168.21.203", "auth_port": 11000, - "game_host": "192.168.21.203", "game_port": 13002, - "channels": [1, 2, 3, 4], "port_step": 10, "mark_port": 0, - }] - -func load_file(path: String) -> bool: - if not FileAccess.file_exists(path): - return false - var f := FileAccess.open(path, FileAccess.READ) - if f == null: - return false - var out: Array = [] - while not f.eof_reached(): - var line := f.get_line().strip_edges() - if line == "" or line.begins_with("#"): - continue - var c := line.split("\t", false) - if c.size() < 5: - c = line.split(" ", false) # 容忍空格分隔 - if c.size() < 5: - continue - var chans: Array = [] - if c.size() >= 6: - for t in String(c[5]).split(",", false): - chans.append(int(t)) - if chans.is_empty(): - chans = [1] - var step := DEFAULT_PORT_STEP - if c.size() >= 7 and int(c[6]) > 0: - step = int(c[6]) - var mark_port := 0 - if c.size() >= 8 and int(c[7]) > 0: - mark_port = int(c[7]) - out.append({ - "name": String(c[0]), - "auth_host": String(c[1]), "auth_port": int(c[2]), - "game_host": String(c[3]), "game_port": int(c[4]), - "channels": chans, "port_step": step, "mark_port": mark_port, - }) - if out.is_empty(): - return false - _servers = out - return true - -func servers() -> Array: - return _servers - -func count() -> int: - return _servers.size() - -func server(idx: int) -> Dictionary: - return _servers[idx] if idx >= 0 and idx < _servers.size() else {} - -# 返回 {auth_host, auth_port, game_host, game_port},game_port 已按频道偏移。 -func address(server_idx: int, channel: int = 1) -> Dictionary: - var s := server(server_idx) - if s.is_empty(): - return {} - var ch: int = maxi(1, channel) - var step: int = int(s.get("port_step", DEFAULT_PORT_STEP)) - return { - "auth_host": s["auth_host"], "auth_port": s["auth_port"], - "game_host": s["game_host"], - "game_port": int(s["game_port"]) + (ch - 1) * step, - "mark_host": s["game_host"], "mark_port": int(s.get("mark_port", 0)), - } diff --git a/project/net/serverinfo.gd.uid b/project/net/serverinfo.gd.uid deleted file mode 100644 index 26dbdd49..00000000 --- a/project/net/serverinfo.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dgvdccv3267fh diff --git a/project/net_client_fake.gd b/project/net_client_fake.gd deleted file mode 100644 index 4041cf42..00000000 --- a/project/net_client_fake.gd +++ /dev/null @@ -1,221 +0,0 @@ -extends Node - -## Shared headless client fixture for scene/skill-flow tests. -## -## It models the public M2Client surface used by GameScene, NetPlay and -## Quickbar. Quickslot mutations are server-confirmed immediately by -## emitting quickslots_changed; tests that need delayed confirmation should -## use their dedicated fixture and emit the signal manually. - -signal entity_spawned(entity: Dictionary) -signal entity_despawned(vid: int) -signal entity_moved(vid: int) -signal entity_info(vid: int, entity: Dictionary) -signal entity_main_set(vid: int) -signal entity_dead(vid: int) -signal vitals_changed(vid: int) -signal point_changed(vid: int, point_type: int, value: int, amount: int) -signal points_changed(points: Dictionary) -signal target_info(vid: int, hp_percent: int) -signal observer_mode_changed(enabled: bool) -signal fishing_event(subheader: int, info: int, dir: 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 effect_cue(vid: int, name: String, special: int) -signal emoticon_requested(index: int) -signal gift_available() -signal script_dialog(skin: int, text: String) -signal affect_added(affect: Dictionary) -signal affect_removed(type: int) -signal guild_invite_ask(guild_id: int, guild_name: String, leader: String) -signal guild_make_requested() -signal warp(pos: Vector3, same_server: bool) -signal world_reset() -signal channel_changed(channel: int) -signal disconnected(reason: String) -signal entered_game() -signal login_failed(reason: String) -signal skills_changed() -signal skill_group_changed(group: int) -signal skill_cooldown_end(skill: int) -signal quickslots_changed() - -var in_game := true -var main := 1000 -var entities: Dictionary = { - 1000: {"vid": 1000, "name": "Me", "race": 0, "kind": 0, "ch_type": 0, - "pos": Vector3.ZERO, "pos_cm": Vector3.ZERO, "moving_speed": 100, - "attack_speed": 100, "hp": 100, "max_hp": 100, "dead": false} -} -var ground_items: Array = [] -var inventory: Array = [] -var equipment: Array = [] -var affects: Array = [] -var skills: Array = [{"id": 1, "level": 5, "master": 0}] -var skill_group := 1 -var points: Dictionary = { - "level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2, - "casting_speed": 100, "hp": 100, "max_hp": 100, "sp": 100, "max_sp": 100, - "exp": 0, "next_exp": 1000, -} -var target_vid := 0 -var quickslots: Array = [] -var calls: Array = [] - -func is_in_game() -> bool: - return in_game - -func get_main_vid() -> int: - return main - -func get_entity(vid: int) -> Dictionary: - return entities.get(int(vid), {}) - -func get_entities() -> Array: - return entities.values() - -func get_ground_items() -> Array: - return ground_items - -func get_inventory() -> Array: - return inventory - -func get_equipment() -> Array: - return equipment - -func get_affects() -> Array: - return affects - -func get_skills() -> Array: - return skills - -func get_skill_group() -> int: - return skill_group - -func get_points() -> Dictionary: - return points - -func get_quickslots() -> Array: - return quickslots - -func get_target() -> Dictionary: - return {"vid": target_vid} - -func spawn(vid: int, name: String, pos: Vector3, is_main := false) -> void: - var entity := {"vid": vid, "name": name, "race": 0, "kind": 2, - "ch_type": 2, "pos": pos, "pos_cm": pos * 100.0, "moving_speed": 100, - "attack_speed": 100, "hp": 100, "max_hp": 100, "dead": false, - "is_main": is_main} - entities[vid] = entity - if is_main: - main = vid - entity_main_set.emit(vid) - entity_spawned.emit(entity) - -func move(func_id: int, arg: int, rot: float, x: int, y: int) -> bool: - calls.append(["move", func_id, arg, rot, x, y]) - return in_game - -func attack(motion_id: int, victim: int) -> bool: - calls.append(["attack", motion_id, victim]) - return in_game - -func set_target(vid: int) -> bool: - target_vid = vid - calls.append(["set_target", vid]) - return in_game - -func click_npc(vid: int) -> bool: - calls.append(["click_npc", vid]) - return in_game - -func pickup_item(vid: int) -> bool: - calls.append(["pickup_item", vid]) - return in_game - -func add_fly_targeting(vid: int, x: int, y: int) -> bool: - calls.append(["add_fly_targeting", vid, x, y]) - return in_game - -func fishing(rot_deg: float) -> bool: - calls.append(["fishing", rot_deg]) - return in_game - -func cast_skill(motion_id: int, rot: float, x: int, y: int, arg := 0) -> bool: - calls.append(["cast_skill", motion_id, rot, x, y, arg]) - return in_game - -func use_skill(skill_id: int, victim: int) -> bool: - calls.append(["use_skill", skill_id, victim]) - return in_game - -func use_item(window: int, cell: int) -> bool: - calls.append(["use_item", window, cell]) - return in_game - -func send_emoticon(index: int) -> bool: - calls.append(["send_emoticon", index]) - return in_game - -func say(type: int, text: String) -> bool: - calls.append(["say", type, text]) - return in_game - -func shoot(skill_id: int) -> bool: - calls.append(["shoot", skill_id]) - return in_game - -func skill_up(skill_id: int) -> bool: - calls.append(["skill_up", skill_id]) - return in_game - -func quickslot_add(pos: int, type: int, ref: int) -> bool: - calls.append(["quickslot_add", pos, type, ref]) - quickslots = quickslots.filter(func(q: Dictionary) -> bool: return int(q.get("pos", -1)) != pos) - quickslots.append({"pos": pos, "type": type, "ref": ref}) - quickslots_changed.emit() - return in_game - -func quickslot_del(pos: int) -> bool: - calls.append(["quickslot_del", pos]) - quickslots = quickslots.filter(func(q: Dictionary) -> bool: return int(q.get("pos", -1)) != pos) - quickslots_changed.emit() - return in_game - -func quickslot_swap(pos: int, change_pos: int) -> bool: - calls.append(["quickslot_swap", pos, change_pos]) - var a := -1 - var b := -1 - for i in quickslots.size(): - if int(quickslots[i].get("pos", -1)) == pos: - a = i - if int(quickslots[i].get("pos", -1)) == change_pos: - b = i - if a >= 0 and b >= 0: - var tmp: Dictionary = quickslots[a] - quickslots[a] = quickslots[b] - quickslots[b] = tmp - quickslots[a]["pos"] = pos - quickslots[b]["pos"] = change_pos - elif a >= 0: - quickslots[a]["pos"] = change_pos - elif b >= 0: - quickslots[b]["pos"] = pos - quickslots_changed.emit() - return in_game - -func get_channel() -> int: - return 1 - -func set_item_proto(_proto: Object) -> void: - pass - -func get_item_proto(_vnum: int) -> Dictionary: - return {} - -func guild_answer_invite(_guild_id: int, _accept: bool) -> bool: - return in_game - -func guild_answer_make(_name: String) -> bool: - return in_game diff --git a/project/net_client_fake.gd.uid b/project/net_client_fake.gd.uid deleted file mode 100644 index ec4b3666..00000000 --- a/project/net_client_fake.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwqc0bqmp1758 diff --git a/project/net_play.gd b/project/net_play.gd deleted file mode 100644 index 513d8e12..00000000 --- a/project/net_play.gd +++ /dev/null @@ -1,2826 +0,0 @@ -# NetPlay (P0) —— 本地输入 ←→ 网络的闭环胶水。 -# -# 点地移动 -> M2Client.move(FUNC_MOVE, …) (节流:>1m 位移或 >0.2s) -# 停下 -> M2Client.move(FUNC_WAIT, …) -# 点实体 -> M2Client.set_target(vid);按住 SPACE 才在攻击距离内按攻速 attack() -# 收到自己的 GC_MOVE -> 位置校正(差 >snap 瞬移,否则 lerp) -# points_changed / vitals_changed / target_info -> HUD -# -# 需要:M2Client、PlayerController(`player_controller.gd`)、NetWorld(`net_world.gd`)。 -# 可选:HUD(有 set_vitals / set_exp / set_level / set_target / clear_target 就调)。 -# -# 用法: -# var np := preload("res://net_play.gd").new() -# add_child(np) -# np.setup(m2client, player_ctl, net_world, hud) -extends Node - -const FlyTargetAnchor = preload("res://fx/target_effect_anchor.gd") - -signal level_up(new_level: int) -signal money_picked(amount: int) -signal status_refresh_requested() -signal skill_refresh_requested() - -signal target_changed(vid: int) -# game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时请求开悄悄话对话框 -# (参考端 self.interface.OpenWhisperDialog(name))。game_scene 接到 chat.start_whisper()。 -signal whisper_requested(name: String) -# OnCannotAttack / OnCannotShot / OnCannotUseSkill 的字符串码(game.py 同名回调), -# 供 HUD 弹 localeInfo 文案。§3.4 -signal cannot_act(code: String) -# PythonPlayerInput::OnFishingWrongPlace 的独立反馈通道。当前场景若没有 -# 水面查询器不会误报,等 Metin2World 接上水面采样后由这里统一上抛。 -signal fishing_feedback(code: String) -# 弓动作起手:本次挥击的 uSkill(普攻 0)。对齐 -# CNormalBowAttack_FlyEventHandler_AutoClear —— OnSetFlyTarget() 已在 _emit_swing 里 -# 直接发 CG_FLY_TARGETING,剩下的 OnShoot(uSkill) 要等 `.msa` MOTION_EVENT_TYPE_FLY 帧, -# 由 game_scene 收到本信号后入队、在 _on_local_motion_event(6) 里 pop 出来发 CG_SHOOT。§3.6 -signal bow_shot_fired(skill: int) - -# CPythonPlayer 的预约动作模式(PythonPlayerInput.cpp:820-856)。每帧在 -# _refresh_reserved()(≈ NEW_RefreshMouseWalkingDirection)里按模式分派。 -enum ReservedMode { NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL } - -# 扇形 / 圆形多目标 fly-targeting 的范围形状(skilldesc / .msk 的 -# SKILL_ATTRIBUTE_FLAG_FAN_RANGE / _CIRCLE_RANGE,PythonSkill.cpp:1103/1111)。 -enum FlyShape { SINGLE, FAN, CIRCLE } -# NEW_GetInstanceVectorInFanRange(InstanceBaseBattle.cpp:192)的扇形半角随距离收窄: -# fHalfFanRot = (MAX-MIN) - RPM * min(dist, MIN_DIST) + MIN, RPM = (MAX-MIN)/MIN_DIST -const FAN_HALF_ROT_MIN := 20.0 -const FAN_HALF_ROT_MAX := 40.0 -const FAN_HALF_ROT_MIN_DISTANCE := 1000.0 -# NEW_GetRandomPositionInFanRange(:124):目标方位 ±10° 抖动、沿该方向 8000 单位取点。 -const FAN_RANDOM_JITTER_DEG := 10.0 -const FAN_RANDOM_REACH_CM := 8000.0 -# MODE_CLICK_ITEM 的直接点击阈值(40250 __OnPressItem -> -# NEW_IsClickableDistanceDestPixelPosition:fDistance > 150cm 时预约走路)。 -const CLICK_ITEM_PICKUP_CM := 150.0 -# MODE_USE_SKILL 的巨型种族距离修正(PythonPlayerInputMouse.cpp:352 `fDistance -= 200.0f`)。 -const HUGE_RACE_DIST_FIX_CM := 200.0 -# _is_huge_race 的 mob_proto 近似阈值(size / rank 见 proto_node.cpp 的 mob() 字典)。 -const HUGE_RACE_SIZE := 3 # MOBSIZE_HUGE -const HUGE_RACE_RANK := 4 # MOB_RANK_BOSS -# __ReserveUseSkill(PythonPlayerInput.cpp:854):range > 100 时先减 10 再存。 -const RESERVE_SKILL_RANGE_TRIM_CM := 10.0 - -const FUNC_WAIT := 0 -const FUNC_MOVE := 1 -const FUNC_COMBO := 3 -const FUNC_SKILL := 0x80 -const CM := 100.0 # 米 -> 厘米 - -# EPointTypes from 40250 Packet.h. RecvPointChange uses these exact branches -# to decide which status/skill views are refreshed after the typed point event. -const POINT_LEVEL := 1 -const POINT_ST := 12 -const POINT_HT := 13 -const POINT_DX := 14 -const POINT_IQ := 15 -const POINT_LEVEL_STEP := 25 -const POINT_SUB_SKILL := 27 -const POINT_SKILL := 28 -const POINT_STAT_RESET_COUNT := 112 -const POINT_HORSE_SKILL := 113 -const POINT_GOLD := 11 -const POINT_ENERGY := 128 - -# §5.4 —— PVP / PK / 决斗可攻击判据(CInstanceBase::IsAttackableInstance 1:1,逐行)。 -const EntityRules = preload("res://entity_rules.gd") -# §5.4 / §8.8 —— RecvTargetPacket 的目标框收包分派(判定树 + CanViewTargetHP)1:1。 -const TargetBoard = preload("res://target_board.gd") -# §3.2 上行节流表(对齐 `PythonPlayerEventHandler.cpp` 的 6 个回调): -# OnMove —— 首次开走 / 改向:立即发 FUNC_MOVE,并重置 moving 窗口 -# OnMoving —— 移动中周期上行:300 ms 节流,发 FUNC_MOVE -# OnWaiting—— 仅在走/跑动作中本帧无位移时触发(InstanceBase.cpp:1930);未移植, -# 见 audit/port-map/UserInterface/PythonPlayerEventHandler.json -# OnStop —— 停下收尾:不节流,发 FUNC_WAIT -# OnAttack —— 不节流,发 FUNC_COMBO + motion index(见 _do_attack_swing) -# OnUseSkill—— 不节流,发 FUNC_SKILL|motion + arg(§3.8 施法路径) -const SEND_MOVING_INTERVAL := 0.3 # OnMoving 最小间隔(秒) -# 可点击 / 攻击距离(厘米),照 `InstanceBaseBattle.cpp:294` -# `NEW_IsClickableDistanceDestInstance`:默认 150,NPC 500,采集物 100,弓箭走 __GetBowRange。 -const CLICK_DIST_DEFAULT_CM := 150.0 -const CLICK_DIST_NPC_CM := 500.0 -const CLICK_DIST_RESOURCE_CM := 100.0 -# CInstanceBase::__GetBowRange(InstanceBase.cpp:694):基础 2500-100 cm, -# 主角再加 GetStatus(POINT_BOW_DISTANCE)(参考端直接按 cm 相加,不 ×100)。 -const BOW_RANGE_BASE_CM := 2500.0 - 100.0 -const CORRECTION_SNAP := 6.0 # 服务器位置差超过就瞬移 -const CORRECTION_LERP := 0.25 # 否则每次校正插值比例 - -# §3.5 修改 4 —— 命中判定(对齐 `ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess` -# + `InstanceBaseBattle.cpp:413 AttackProcess` + `PythonPlayerEventHandler.cpp:135 OnHit` / -# `:194 FlushVictimList`)。`NRaceData::EMotionType`(GameType.h:52): -const MOTION_TYPE_NONE := 0 -const MOTION_TYPE_NORMAL := 1 -const MOTION_TYPE_COMBO := 2 -const MOTION_TYPE_SKILL := 3 -const HIT_ATTACK_RADIUS_CM := 20.0 # c_fAttackRadius(ActorInstanceCollisionDetection.cpp:355) -const SYNC_POSITION_LIMIT := 16 # FlushVictimList 的 SYNC_POSITION_COUNT_LIMIT -# NRaceData::EHitType(GameType.h) -const HIT_TYPE_NONE := 0 -const HIT_TYPE_GREAT := 1 -const HIT_TYPE_GOOD := 2 -const HIT_DISTANCE_CM := 300.0 # __NormalAttackProcess:fDistance >= 300² -> FALSE -const HIT_DISTANCE_HUGE_CM := 500.0 # IS_HUGE_RACE(victim):>= 500² -> FALSE -const COMBO_HIT_LIMIT := 16 # MOTION_TYPE_COMBO / NORMAL 每个命中窗最多 16 个目标 -const PUSH_OWNER_TIME := 3.0 # __CanPushDestActor:__GetOwnerTime() > 3.0f -> 不推 -const COMBO_SKILL_ID := 122 # SetComboSkillFlag 的 c_iSkillIndex_Combo -# m_dwBattleHitEffectID(playersettingmodule.py RegisterCacheEffect(EFFECT_HIT, ...)) -const EFFECT_HIT := "d:/ymir work/effect/hit/blow_1/blow_1_low.mse" -# 占位胶囊(没有 .msm 防御球)退化用的一个防御球(seam) -const HIT_FALLBACK_SPHERE := {"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""} -const HitCollision = preload("res://hit_collision.gd") -const MotionSplash = preload("res://motion_splash.gd") - -var client: Node # M2Client -var pc: Node # PlayerController -var net_world: Node # NetWorld -var hud: Node # 可空 -var target_ui: Node # 40250 uitarget.py TargetBoard (可空) -var audio: Node # 可空 -var locale: RefCounted = null # 语言包 Locale 实例(查 mob_names.txt / 翻译) -var _last_level := -1 -var _last_exp := -1 -var _last_dead_mob_pos := Vector3.INF -# 参考端没有硬编码攻击间隔——节奏来自当前攻击 `.msa` 的 `ComboInputData.DirectInputTime` -# (`CRaceMotionData::GetNextComboTime`),无 combo 数据时退化为 `MotionDuration * 0.9`。 -# `DEFAULT_ATTACK_PERIOD` 只在拿不到动作数据(如 headless 无 player_view)时兜底。 -const DEFAULT_ATTACK_PERIOD := 0.6 -var attack_period := DEFAULT_ATTACK_PERIOD # 上一次解析出的普攻节奏(秒),供 HUD / 测试读取 -var _atk_speed_factor := 1.0 # m_fAtkSpd = bAttackSpeed / 100(>1100 -> 0),动作 fSpeedRatio - -var camera: Node # GameCamera(可空,用于受击抖屏) -# PlayerView(可空)。motion_bound(state) = __SetMotion 尾(清命中表 / 连击段号)。 -var player_view: Node: - set(v): - if is_instance_valid(player_view) and player_view.has_signal("motion_bound") \ - and player_view.motion_bound.is_connected(_on_motion_bound): - player_view.motion_bound.disconnect(_on_motion_bound) - player_view = v - if v and v.has_signal("motion_bound") and not v.motion_bound.is_connected(_on_motion_bound): - v.motion_bound.connect(_on_motion_bound) - if v and v.has_method("set_motion_mode"): - v.set_motion_mode(combo_motion_mode) -var fx # EffectRegistry(可空):命中特效 EFFECT_HIT -var fx_parent: Node3D # 命中特效挂载点(game_scene._mount) -var proto: Node: # Metin2Proto(可空,按 race 分类 NPC/怪 + 武器动作模式) - set(v): - proto = v - # GameScene receives the initial entity/equipment burst before item_proto - # finishes loading. Re-evaluate here or a 3150 weapon remains GENERAL until - # the next inventory packet, visually leaving the left hand off the grip. - if v: - _refresh_motion_mode() - -const DAMAGE_DODGE := 1 << 2 -const DAMAGE_CRITICAL := 1 << 5 - -# --- §3.5 连击段(对齐 CActorInstance,ActorInstanceBattle.cpp:157-330)--- -# chr.MOTION_MODE_*(CRaceMotionData::EMode,RaceMotionData.h:30)——完整枚举 -const MOTION_MODE_RESERVED := 0 -const MOTION_MODE_GENERAL := 1 -const MOTION_MODE_ONEHAND_SWORD := 2 -const MOTION_MODE_TWOHAND_SWORD := 3 -const MOTION_MODE_DUALHAND_SWORD := 4 -const MOTION_MODE_BOW := 5 -const MOTION_MODE_FAN := 6 -const MOTION_MODE_BELL := 7 -const MOTION_MODE_FISHING := 8 -const MOTION_MODE_HORSE := 9 -const MOTION_MODE_HORSE_ONEHAND_SWORD := 10 -const MOTION_MODE_HORSE_TWOHAND_SWORD := 11 -const MOTION_MODE_HORSE_DUALHAND_SWORD := 12 -const MOTION_MODE_HORSE_BOW := 13 -const MOTION_MODE_HORSE_FAN := 14 -const MOTION_MODE_HORSE_BELL := 15 -const MOTION_MODE_WEDDING_DRESS := 16 -const MOTION_MODE_HORSE_FIRST := 9 # HORSE..HORSE_BELL = 9..15 -const MOTION_MODE_HORSE_LAST := 15 -# CItemData::EItemType(ItemData.h:22)子集 —— 只用到武器 / 钓竿 / 镐 -const ITEM_TYPE_WEAPON := 1 -const ITEM_TYPE_ROD := 13 -const ITEM_TYPE_PICK := 24 -# CItemData::EWeaponSubTypes(ItemData.h:63)—— GetWeaponType() 返回的 bSubType -const WEAPON_SUB_SWORD := 0 -const WEAPON_SUB_DAGGER := 1 -const WEAPON_SUB_BOW := 2 -const WEAPON_SUB_TWO_HANDED := 3 -const WEAPON_SUB_BELL := 4 -const WEAPON_SUB_FAN := 5 -const WEAPON_SUB_ARROW := 6 -# 装备槽 / parts[] 下标(对齐 EquipModel) -const WEAR_WEAPON := 4 -const PART_WEAPON := 1 -# 与 Packet.h 的 FISHING_SUBHEADER_GC_* 一致。 -const FISHING_START := 0 -const FISHING_STOP := 1 -const FISHING_REACT := 2 -const FISHING_SUCCESS := 3 -const FISHING_FAIL := 4 -const FISHING_FISH := 5 -const FISHING_CANCEL_COOLDOWN := 0.5 -# 段号 = CRaceMotionData::NAME_* ;ComboIndexVector 里存的就是这些 -const NAME_NORMAL_ATTACK := 13 -const NAME_COMBO_ATTACK_1 := 14 - -var _target_vid := 0 -var _main_vid := 0 -var _main_dead := false -# game-option「看他国玩家目标框」(constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1)。 -# 关掉时 TargetBoard.Open 前置过滤:异阵营 PC 目标框直接不开(uitarget.py:189-193)。 -var _view_other_empire_target := true -# game.py SetPCTargetBoard :807 —— app.IsPressed(app.DIK_LCONTROL)。poc 侧由 game_scene -# 的 KEY_CTRL 按下 / 松开喂 set_lcontrol_down(),点 PC 时轮询(对齐参考端 IsPressed 语义)。 -var _lcontrol_down := false -var _last_sent_pos := Vector3.ZERO # 上一次上行时的玩家世界坐标 -var _last_moving_sent_t := 0.0 # 上一次 OnMoving 上行时刻(秒) -var _was_moving := false -var _attack_cd := 0.0 -var _last_click_actor_t := -10.0 -var _last_click_actor_vid := 0 -var _target_end_time := 0.0 # CPythonPlayer::m_dwTargetEndTime (seconds) -var _knock_down := false # 受击/击退动作播放中(对齐 CActorInstance::IsPushing) -var _using_skill := false # 施法中(§3.8 由 player_skill 驱动) -var _skill_cast_start_time := -1.0 # 技能动作起播时间 -var _skill_cast_duration := 0.0 # 技能动作时长超时门控 -var _skill_motion_driven := false # 有效 MSA 时由 motion_bound(wait) 结束;无动作时才用时长兜底 -var _skill_cancel_enable := false # 40250 IsCancelEnableSkill() 可取消后摇打断门控 -# §3.5 连击状态机(1:1 复刻 InputComboAttackCommand / ComboProcess / __RunNextCombo / -# __OnEndCombo / __ClearCombo) -var combo_class := 0 # 0=warrior 1=assassin 2=sura 3=shaman(race & 3) -var combo_motion_mode := MOTION_MODE_GENERAL # m_wcurMotionMode(§3.5 修改 6:按装备武器种类 + 骑乘态) -var _combo_type := 0 # m_wcurComboType(SetComboType;连击技能激活时 = MIN(lv,2)) -var _combo_index := 0 # m_dwcurComboIndex(0 = 不在连击中) -var _is_pre_input := false # m_isPreInput(已过 InputStartTime、待 NextComboTime 触发) -var _is_next_pre_input := false # m_isNextPreInput(弓箭:输入超限后排队下一击) -var _combo_tables := {} # { class:int -> { key:int -> PackedInt32Array } } -# CGraphicThingInstance 本地时间:UpdateTime 每帧 += elapsed,InsertDelay 冻结(命中硬直)。 -var _local_time := 0.0 # GetLocalTime() -var _delay := 0.0 # m_fDelay -var _motion_start_t := 0.0 # m_kCurMotNode.fStartTime(本地时间;GetAttackingElapsedTime 基准) -var _is_attacking := false # 当前是否处于挥击攻击动作中(40250 isLock 门) -# §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer -# + m_HitDataMap + m_kVctkVictim 的等价) -var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置) -var _has_attack_motion_data := false # m_pkCurRaceMotionData->isAttackingMotion() / AttackingData 是否存在 -var _hit_windows := [] # get_motion_data().hit_windows(THitDataContainer) -var _hit_motion_type := 0 # get_motion_data().motion_type(NRaceData::EMotionType) -var _hit_limit_count := 0 # get_motion_data().hit_limit_count(SKILL 类的每窗命中上限) -var _hit_invisible_time := 0.0 # get_motion_data().invisible_time(fInvisibleTime) -var _hit_type := HIT_TYPE_NONE # get_motion_data().hitting_type(iHittingType) -var _hit_stiffen := 0.0 # get_motion_data().stiffen_time(fStiffenTime) -var _hit_external_force := 0.0 # get_motion_data().external_force(fExternalForce) -var _hit_dedup := {} # m_HitDataMap:{ window_idx:int -> { victim_vid:int -> 本地时间 + fInvisibleTime } } -var _motion_splash := {} # CActorInstance::m_kSplashArea(本地主角) -var _remote_motion_splashes := {} # vid:int -> CActorInstance::m_kSplashArea(远端 actor) -var _victim_flush := [] # 帧末 CG_SYNC_POSITION 的被击退者列表 [{vid,x,y}] -var _victim_invisible_until := {} # 受击方 m_fInvisibleTime:{ vid -> 秒(_now())} -var _def_last := {} # 防御球 v3LastPosition:{ vid -> {frame, pos: [Vector3 cm]} } -var _atk_frame := 0 # _attack_process 帧号(判断 _def_last 是否是上一帧) -var _owner_seen := {} # m_fOwnerBaseTime 的客户端观测:{ vid -> [owner_vid, 秒] } -var _observer_mode := false -var _attack_key_down := false -var _fishing_active := false # IsFishing(): 当前处于 WAIT/REACT 的本地主角 -var _last_fishing_cancel_t := -1e9 # NEW_CancelFishing 的 500ms 节流 - -# --- §3.4 预约动作状态机(对齐 PythonPlayerInput.cpp:820-865 / :877 __ReserveProcess_ClickActor -# + PythonPlayer.cpp:207 __Update_AutoAttack)--- -var _reserved_mode: int = ReservedMode.NONE -var _vid_reserved := 0 # m_dwVIDReserved -var _iid_reserved := 0 # m_dwIIDReserved(拾取物,§3.4 后续) -var _ppos_reserved := Vector3.ZERO -var _skill_slot_reserved := -1 -var _skill_range_reserved := 0.0 -var _reserved_delay := 0.0 # 拾取 / 到点前的等待(CLICK_ITEM / CLICK_POSITION) -var _auto_attack_vid := 0 # m_dwAutoAttackTargetVID -var _last_cannot := "" # 最近一次 OnCannot* 码(供测试 / HUD) -# 服务端尚未下发的字段的本地占位(与 knock_down 同套做法,真值待后续增量 / 真服): -var _arrow_count := -1 # -1 = 未知/无限;0 = EMPTY_ARROW -var _bow_distance_bonus := 0.0 # GetStatus(POINT_BOW_DISTANCE) —— __GetBowRange() 加成 -var _dash_active := false # __CheckDashAffect -var _charge_skill := false # 预约技能 IsChargeSkill() - -func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null, audio_node: Node = null) -> void: - client = m2client - pc = player_ctl - net_world = nw - hud = hud_node - audio = audio_node - pc.target_selected.connect(_on_pick) - if pc.has_signal("ground_item_clicked"): - pc.ground_item_clicked.connect(pick_ground_item) - pc.moved.connect(_on_local_moved) - pc.anim_state.connect(_on_anim_state) - client.entity_main_set.connect(_on_main_set) - client.entity_moved.connect(_on_net_moved) - if client.has_signal("entity_info"): - client.entity_info.connect(_on_entity_info) - if client.has_signal("inventory_changed"): - client.inventory_changed.connect(_on_inv_changed_mode) # §3.5 修改 6:换武器 → motion mode - if client.has_signal("mount_changed"): - client.mount_changed.connect(func(v): - if v == _main_vid: _refresh_motion_mode()) # §3.5 修改 6:上/下马 → HORSE_* - client.points_changed.connect(_on_points) - if client.has_signal("point_changed"): - client.point_changed.connect(_on_point_changed) - client.vitals_changed.connect(_on_vitals) - client.target_info.connect(_on_target_info) - client.entity_despawned.connect(_on_entity_gone) - client.entity_dead.connect(_on_entity_dead) - if client.has_signal("damage"): - client.damage.connect(_on_damage) - if client.has_signal("fishing_event"): - client.fishing_event.connect(_on_fishing_event) - if client.has_signal("combo_changed"): - client.combo_changed.connect(_on_combo_changed) - if client.has_signal("observer_mode_changed"): - client.observer_mode_changed.connect(_on_observer_mode) - if client.has_method("is_observer_mode"): - _observer_mode = bool(client.is_observer_mode()) - if pc and "skip_actor_collision" in pc: - pc.skip_actor_collision = _observer_mode - # GameScene 的资源加载会让首个 entity_main_set 早于 NetPlay.setup 到达;补读当前 - # 主角,和 NetWorld.catch_up 一样避免丢失进场状态(动作模式、本地主角 VID、目标)。 - if client.has_method("get_main_vid"): - var current_main := int(client.get_main_vid()) - if current_main > 0: - _on_main_set(current_main) - # setup 通常发生在网络已完成首轮 POINTS 同步之后。先建立经验/等级基线, - # 否则进入游戏后的第一次击杀只会把 _last_exp 从 -1 初始化,表现为没有经验反馈。 - if client.has_method("get_points"): - var initial_points: Dictionary = client.get_points() - if not initial_points.is_empty(): - _on_points(initial_points) - -func _on_observer_mode(enabled: bool) -> void: - _observer_mode = enabled - # 观战 = 自由镜头穿行 → CanSkipCollision 语义:本地移动整段跳过 Actor 碰撞。 - if pc and "skip_actor_collision" in pc: - pc.skip_actor_collision = enabled - if enabled: - _target_vid = 0 - _attack_cd = 0.0 - target_changed.emit(0) - if hud and hud.has_method("clear_target"): - hud.clear_target() - -# PythonPlayerInputKeyboard::SetAttackKeyState keeps m_isAtkKey; fishing mode -# diverts the press to NEW_Fishing and must not start ordinary combat. -func set_attack_key(down: bool) -> void: - if down and combo_motion_mode == MOTION_MODE_FISHING: - _new_fishing() - return - _attack_key_down = down - if not down: - _attack_cd = 0.0 - _clear_auto_attack() - _clear_reserved() - if pc and pc.has_method("stop"): - pc.stop() - -# 键盘 / 摇杆手动移动:对齐 NEW_MoveToDirection,清预约并取消钓鱼 -func on_manual_move() -> bool: - _clear_reserved() - _clear_auto_attack() - return cancel_fishing() - -# CPythonPlayer::Clear() 的 Loading 等价入口。 -# 这些状态属于当前 Actor/动作生命周期,不能跟着 NetPlay 节点跨 GC_WARP、重连或 -# 新的 GC_MAIN_CHARACTER 保留;账号级物品、任务和朋友数据仍由 EntityStore 保留。 -func clear_for_map_change() -> void: - if pc and pc.has_method("stop"): - pc.stop() - if pc and pc.has_method("set_dead"): - pc.set_dead(false) - # CPythonPlayer::Clear / SetMainCharacterIndex 会清掉只属于旧主角生命周期 - # 的 m_isOpenPrivateShop 与 m_bisProcessingEmotion,不能让换图期间的旧 - # entity_info 延迟包继续挡住新角色输入。 - if pc and pc.has_method("set_private_shop_open"): - pc.set_private_shop_open(false) - if pc and pc.has_method("set_processing_emotion"): - pc.set_processing_emotion(false) - _target_vid = 0 - _main_vid = 0 - _main_dead = false - _last_sent_pos = Vector3.ZERO - _last_moving_sent_t = 0.0 - _was_moving = false - _attack_cd = 0.0 - _last_click_actor_t = -10.0 - _last_click_actor_vid = 0 - _target_end_time = 0.0 - _knock_down = false - _using_skill = false - _skill_cast_start_time = -1.0 - _skill_cast_duration = 0.0 - _skill_motion_driven = false - _skill_cancel_enable = false - _combo_type = 0 - _clear_combo() - _local_time = 0.0 - _delay = 0.0 - _motion_start_t = 0.0 - _swing_skill = 0 - _has_attack_motion_data = false - _hit_windows = [] - _hit_dedup.clear() - _motion_splash.clear() - _remote_motion_splashes.clear() - _victim_flush.clear() - _victim_invisible_until.clear() - _def_last.clear() - _atk_frame = 0 - _owner_seen.clear() - _attack_key_down = false - _fishing_active = false - _last_fishing_cancel_t = -1e9 - _last_level = -1 - _last_exp = -1 - _last_dead_mob_pos = Vector3.INF - _arrow_count = -1 - _bow_distance_bonus = 0.0 - _dash_active = false - _charge_skill = false - _clear_reserved() - _clear_auto_attack() - target_changed.emit(0) - if hud and hud.has_method("clear_target"): - hud.clear_target() - if target_ui and target_ui.has_method("close"): - target_ui.close() - -# PythonPlayerInput.cpp:506-549 / PythonPlayerInputKeyboard.cpp:6-16。 -# 返回码只供快捷栏和测试使用;普通攻击键会消费这次输入但不弹技能错误。 -func activate_fishing() -> String: - if combo_motion_mode != MOTION_MODE_FISHING: - return "EQUIP_FISHING_ROD" - return _new_fishing() - -func is_fishing() -> bool: - return _fishing_active - -# 地面点击前的动作清理顺序:__OnPressGround 先清预约 / 自动攻击,再取消钓鱼。 -func on_ground_click() -> bool: - _clear_reserved() - _auto_attack_vid = 0 - return cancel_fishing() - -# NEW_CancelFishing:只在 IsFishing() 时发 CG_FISHING(0),并按参考端节流 500ms。 -func cancel_fishing() -> bool: - if not _fishing_active or client == null or not client.has_method("fishing"): - return false - var now := _now() - if now < _last_fishing_cancel_t + FISHING_CANCEL_COOLDOWN: - return false - if not bool(client.fishing(0.0)): - return false - _last_fishing_cancel_t = now - return true - -func _fishing_rotation_deg() -> float: - if pc == null or pc.player == null: - return 0.0 - # GetFishingRot 返回客户端原生方向;MapCoord 与普通 CG_MOVE 使用同一 - # world-yaw -> server-heading 换算,M2Client 再按 5 度量化为 BYTE。 - return _heading_deg(pc.player.rotation.y) - -func _new_fishing() -> String: - if client == null or not client.has_method("fishing"): - return "FISHING_UNAVAILABLE" - if _fishing_active: - return "OK" if bool(client.fishing(0.0)) else "FISHING_SEND_FAILED" - if not _can_process_network_state() or _using_skill: - return "CANNOT_ACT" - if pc == null or pc.player == null: - return "CANNOT_ACT" - # InstanceBaseMotion::GetFishingRot:优先取水面扫描选出的 heading;旧的 - # can_fishing_position 只返回 bool,供尚未升级的 world provider 兼容。 - var fishing_dir := _fishing_rotation_deg() - if net_world and net_world.has_method("get_fishing_rotation"): - var resolved := float(net_world.call("get_fishing_rotation", - pc.player.position.x, pc.player.position.z, fishing_dir)) - if is_equal_approx(resolved, -2.0): - pass # NetWorld has no map provider (headless / protocol-only client). - elif resolved < 0.0: - fishing_feedback.emit("FISHING_WRONG_PLACE") - return "FISHING_WRONG_PLACE" - else: - fishing_dir = resolved - elif net_world and net_world.has_method("can_fishing_position"): - if not bool(net_world.call("can_fishing_position", - pc.player.position.x, pc.player.position.z, fishing_dir)): - fishing_feedback.emit("FISHING_WRONG_PLACE") - return "FISHING_WRONG_PLACE" - if bool(client.fishing(fishing_dir)): - return "OK" - return "FISHING_SEND_FAILED" - -# §3.5:从 assets/root/playersettingmodule.py 解析 PC 连击段表(不在 .msm 里, -# 而是 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData)。 -func set_asset_root(root: String) -> void: - if not ClassDB.class_exists("Metin2AnimPlayer"): - return - var p := root.trim_suffix("/") + "/root/playersettingmodule.py" - var t: Variant = Metin2AnimPlayer.parse_combo_tables(p) - if t is Dictionary: - _combo_tables = t - -# PythonPlayerSkill.cpp:940 —— 连击技能激活时 SetComboType(MIN(skillLv, 2)),否则 0。 -func set_combo_type(t: int) -> void: - _combo_type = clampi(t, 0, 2) - -# CPythonPlayer::SetComboSkillFlag(PythonPlayerSkill.cpp:916):GC 连击开关到达时查连击技能 -# 122 的槽位 / 等级,缺槽或等级 <= 0 直接 return(不改 combo type),否则 -# SetComboType(bFlag ? MIN(iLevel, 2) : 0)。 -func _on_combo_changed(enabled: bool) -> void: - if client == null or not client.has_method("get_skills"): - return - var level := -1 - for skill in client.get_skills(): - if int(skill.get("id", 0)) == COMBO_SKILL_ID: - level = int(skill.get("level", 0)) - break - if level <= 0: - return - _combo_type = mini(level, 2) if enabled else 0 - -# CActorInstance::__SetMotion 尾(ActorInstanceMotion.cpp):绑定带 MotionAttackData 的动作 -> -# m_HitDataMap.clear();连击中换到没有 ComboInputData 的动作 -> m_dwcurComboIndex = 0。 -func _on_motion_bound(_state: String) -> void: - var md := _motion_data() - if bool(md.get("has_attacking_data", false)): - _hit_dedup.clear() - if _combo_index != 0 and not bool(md.get("has_combo_input", false)): - _combo_index = 0 - if _state == "wait": - _is_attacking = false - _using_skill = false - _charge_skill = false - _skill_motion_driven = false - _skill_cancel_enable = false - -# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310) / InterceptOnceMotion -# 40250 PythonPlayerSkill.cpp:751: __ClearReservedAction(); __ClearAutoAttackTargetActorID(); NEW_Stop(); -func start_skill_cast(duration: float = 2.0, is_charge: bool = false, - cancel_enable: bool = false, motion_driven: bool = false) -> void: - _using_skill = true - _charge_skill = is_charge - _skill_cancel_enable = cancel_enable - _skill_motion_driven = motion_driven - _skill_cast_start_time = _local_time - _skill_cast_duration = duration if duration > 0.0 else 2.0 - _clear_reserved() - _clear_auto_attack() - if pc and pc.has_method("stop"): - pc.stop() - -func set_combo_motion_mode(mode: int) -> void: - if mode != combo_motion_mode: - combo_motion_mode = mode - _clear_combo() - if player_view and player_view.has_method("set_motion_mode"): - player_view.set_motion_mode(mode) - -# §3.5 修改 6 —— CInstanceBase::RefreshState(InstanceBase.cpp:2851)里 SetMotionMode 的分支: -# Poly → GENERAL;婚纱 → WEDDING_DRESS;镐 → 骑乘 HORSE / 否则 GENERAL;钓竿 → 骑乘 HORSE / -# 否则 FISHING;骑乘时按武器 subtype 走 HORSE_* ;否则按 subtype 走 ONEHAND / TWOHAND / -# DUALHAND / BOW / FAN / BELL,ARROW 及其余 → GENERAL。 -static func motion_mode_for(item_type: int, weapon_sub: int, mounted: bool, - poly: bool, dress: bool) -> int: - if poly: - return MOTION_MODE_GENERAL - if dress: - return MOTION_MODE_WEDDING_DRESS - if item_type == ITEM_TYPE_PICK: - return MOTION_MODE_HORSE if mounted else MOTION_MODE_GENERAL - if item_type == ITEM_TYPE_ROD: - return MOTION_MODE_HORSE if mounted else MOTION_MODE_FISHING - if mounted: - match weapon_sub: - WEAPON_SUB_SWORD: return MOTION_MODE_HORSE_ONEHAND_SWORD - WEAPON_SUB_TWO_HANDED: return MOTION_MODE_HORSE_TWOHAND_SWORD - WEAPON_SUB_DAGGER: return MOTION_MODE_HORSE_DUALHAND_SWORD - WEAPON_SUB_FAN: return MOTION_MODE_HORSE_FAN - WEAPON_SUB_BELL: return MOTION_MODE_HORSE_BELL - WEAPON_SUB_BOW: return MOTION_MODE_HORSE_BOW - _: return MOTION_MODE_HORSE - match weapon_sub: - WEAPON_SUB_SWORD: return MOTION_MODE_ONEHAND_SWORD - WEAPON_SUB_TWO_HANDED: return MOTION_MODE_TWOHAND_SWORD - WEAPON_SUB_DAGGER: return MOTION_MODE_DUALHAND_SWORD - WEAPON_SUB_BOW: return MOTION_MODE_BOW - WEAPON_SUB_FAN: return MOTION_MODE_FAN - WEAPON_SUB_BELL: return MOTION_MODE_BELL - _: return MOTION_MODE_GENERAL - -# 当前装备武器 vnum:主角 parts[WEAPON] 优先(时装 / GC_CHARACTER_UPDATE 写这里), -# 为 0 回退 EQUIPMENT 窗 WEAR_WEAPON 槽(对齐 EquipModel._eff)。 -func _equipped_weapon_vnum() -> int: - var e := _main_entity() - var parts: Variant = e.get("parts", []) - if parts is Array and parts.size() > PART_WEAPON and int(parts[PART_WEAPON]) != 0: - return int(parts[PART_WEAPON]) - if client and client.has_method("get_equipment"): - var eq: Variant = client.get_equipment() - if eq is Array and eq.size() > WEAR_WEAPON: - return int(eq[WEAR_WEAPON].get("vnum", 0)) - return 0 - -func _is_main_mounted() -> bool: - var main := _main_entity() - return int(main.get("mount_vnum", 0)) != 0 or bool(main.get("mounting", false)) - -static func _is_new_mount(mount: int) -> bool: - return (mount >= 20205 and mount <= 20212) or mount in [20214, 20215, 20217, 20218, 20220] - -func _horse_skill_unlocked(min_level: int) -> bool: - if client == null or not client.has_method("get_skills"): - return false - for skill in client.get_skills(): - if int(skill.get("id", 0)) == 109: - return int(skill.get("master", 0)) >= 1 or int(skill.get("level", 0)) >= min_level - return false - -# 读装备 / 骑乘 / 变身状态 -> motion_mode_for -> set_combo_motion_mode(换武器 / 上下马时调用)。 -func _refresh_motion_mode() -> void: - var e := _main_entity() - var mounted := int(e.get("mount_vnum", 0)) != 0 or bool(e.get("mounting", false)) - var poly := int(e.get("polymorph", 0)) != 0 or int(e.get("poly_vnum", 0)) != 0 - var dress := bool(e.get("wedding_dress", false)) - var it := 0 - var sub := 0 - var vnum := _equipped_weapon_vnum() - if vnum > 0 and proto and proto.has_method("item"): - var d: Dictionary = proto.item(vnum) - it = int(d.get("type", 0)) - sub = int(d.get("sub_type", 0)) - set_combo_motion_mode(motion_mode_for(it, sub, mounted, poly, dress)) - # CInstanceBase::MountHorse → SetRotationSpeed(c_fDefaultHorseRotationSpeed 300); - # 下马恢复 c_fDefaultRotationSpeed 1200(InstanceBase.cpp:954 / 3020)。 - if pc and "rotation_speed_deg" in pc: - pc.rotation_speed_deg = pc.ROT_SPEED_HORSE_DEG if mounted else pc.ROT_SPEED_DEFAULT_DEG - -func _on_inv_changed_mode(window: int, _cell: int) -> void: - if window == 2: # mtnet::WINDOW_EQUIPMENT - _refresh_motion_mode() - -func _is_bow_mode() -> bool: - return combo_motion_mode == MOTION_MODE_BOW or combo_motion_mode == MOTION_MODE_HORSE_BOW - -# __CanShot 弓箭合法性 —— 40250 源码树没有成品 `__CanShot` / `CanShoot` 定义 -# (`grep -rn "CanShot|CanShoot"` 全树无匹配),按能门控射箭的三处反推: -# ① CNormalBowAttack_FlyEventHandler_AutoClear(REF/UserInterface/PythonPlayerEventHandler.cpp:235) -# 的 OnSetFlyTarget / OnShoot 之外有 OnNoTarget() / OnNoArrow() 两个失败回调(REF/GameLib/FlyHandler.h); -# ② NEW_Attack(REF/GameLib/InstanceBaseBattle.cpp:355)的 IsDead / IsStun / IsKnockDown / IsUsingSkill 门; -# ③ 弓普攻可点击 / 攻击距离走 __GetBowRange(InstanceBase.cpp:694),空箭走 EMPTY_ARROW 文案。 -# → 弓模式 && 有箭 && 有可攻击目标 && 目标在弓程内。返回 "" = 可射;否则返回 cannot 文案码。 -func _can_shot(target_vid := -1) -> String: - if not _is_bow_mode(): - return "NOT_BOW_MODE" - if _arrow_count == 0: # IFlyEventHandler::OnNoArrow —— -1=未跟踪/无限 - return "EMPTY_ARROW" - var tv := target_vid if target_vid >= 0 else _target_vid - if tv <= 0: - tv = _vid_reserved - if tv <= 0: # IFlyEventHandler::OnNoTarget - return "NO_TARGET" - var te: Dictionary = client.get_entity(tv) if client else {} - # 实体数据缺失(headless / 未进 store)时不据此否决——沿用 _send_fly_target 的「有节点即可」, - # 只在确知目标不可攻击 / 已死时否决。 - if not te.is_empty() and not _is_attackable(te): - return "NO_TARGET" - var tp = _entity_world_pos(tv) - var mp = _entity_world_pos(_main_vid) - if tp is Vector3 and mp is Vector3: - if (tp as Vector3).distance_to(mp) * CM > _bow_range_cm(): - return "OUT_OF_RANGE" - return "" - -# CActorInstance::__GetCurrentComboType(ActorInstance.cpp:164)——弓 / 徒手 / 骑乘模式恒 0。 -func _current_combo_type() -> int: - if _is_bow_mode() or combo_motion_mode == MOTION_MODE_GENERAL \ - or (combo_motion_mode >= MOTION_MODE_HORSE_FIRST and combo_motion_mode <= MOTION_MODE_HORSE_LAST): - return 0 - return _combo_type - -# GetComboDataPointer(m_wcurMotionMode, wComboType) 的等价:命中返回段号数组,否则空。 -func _combo_vec() -> PackedInt32Array: - var cls: Dictionary = _combo_tables.get(combo_class, {}) - var key := (combo_motion_mode << 16) | _current_combo_type() - return cls.get(key, PackedInt32Array()) - -func _motion_data() -> Dictionary: - var view := _combat_view() - var pv_anim: Object = view.get("anim") if view and "anim" in view else null - if pv_anim and pv_anim.has_method("get_motion_data"): - return pv_anim.get_motion_data() - return {} - -func _combat_view() -> Node: - if is_instance_valid(player_view): - return player_view - # GameScene 会在占位角色替换成 PlayerView 后注入 player_view;替换同帧发生攻击时, - # pc.player 已经是权威本地模型,因此用它兜底,避免只发包却没有攻击动作。 - if pc and "player" in pc and is_instance_valid(pc.player): - var candidate: Node = pc.player - if candidate.has_method("play_attack_motion") or candidate.has_method("set_anim_state"): - return candidate - return null - -# --- helpers -------------------------------------------------------------- - -func _vid_of(node: Object) -> int: - if node and node.has_meta("vid"): - return int(node.get_meta("vid")) - return 0 - -# 玩家节点在 Metin2World 本地帧 -> 服务器全局 cm(含 BasePosition) -func _server_xy(world_pos: Vector3) -> Vector2: - return MapCoord.to_server_cm(world_pos) - -func _heading_deg(yaw_rad: float) -> float: - return MapCoord.yaw_to_heading(yaw_rad) - -func _player_yaw() -> float: - return pc.player.rotation.y if pc.player else 0.0 - -# CHRTYPE 分类:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP / 5 BUILDING / -# 6 WOODEN_DOOR。门类型既可由实体 fixture 的 building / kind 给出,也可由 -# mob_proto 的 bType=4 + 13000 / 30111..30119 race 识别。 -# 本 fork 的 GC_CHARACTER_ADD.bType 对 NPC/怪都是 0,按 race 查 mob_proto 补。 -func _entity_kind(e: Dictionary) -> int: - if bool(e.get("building", false)): - return EntityRules.KIND_BUILDING - var explicit_kind := int(e.get("kind", -1)) - if explicit_kind >= EntityRules.KIND_BUILDING: - return explicit_kind - var race := int(e.get("race", 0)) - if EntityRules._is_wooden_door(e): - return EntityRules.KIND_WOODEN_DOOR - # 优先查 mob_proto:服务端 GC_CHARACTER_ADD 在经典协议 / 部分服务端实现下 bType - # 经常全发 0(导致被客户端转为 MONSTER=2),权威分类必须以 mob_proto(bType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR)为准。 - if race >= 1 and proto and proto.has_method("mob"): - var m: Dictionary = proto.mob(race) - if not m.is_empty(): - match int(m.get("type", 0)): - 0: return 2 - 1: return 1 - 2: return 3 - 3: return 4 - 4: return EntityRules.KIND_WOODEN_DOOR \ - if EntityRules._is_wooden_door(e) else EntityRules.KIND_BUILDING - var ct := int(e.get("ch_type", 0)) - if ct != 0: - return ct - if explicit_kind >= 0: - return explicit_kind - return 0 - -func _get_locale() -> RefCounted: - if locale != null: - return locale - var ar_class = load("res://asset_root.gd") - var root_path: String = ar_class.path() if ar_class and ar_class.has_method("path") else "" - if root_path != "": - var loc_script = load("res://locale.gd") - if loc_script: - var loc: RefCounted = loc_script.new() - var cur_lang: String = loc_script.get_default_lang() if loc_script.has_method("get_default_lang") else "zh" - loc.setup(root_path, cur_lang) - locale = loc - return locale - return null - -# 实体显示名: -# 1. 玩家实体(PC):使用其自定义角色名(含变身中玩家、主玩家) -# 2. 怪物 / NPC / 陨石 / 建筑等(或 race >= 1 的非PC实体):优先查本地化 mob_names (locale),其次 mob_proto -# 3. 兜底回退:实体字典自带名称 -func _entity_name(e: Dictionary) -> String: - var race := int(e.get("race", 0)) - var kind := _entity_kind(e) - var is_main := bool(e.get("is_main", false)) or (client and client.has_method("get_main_vid") and int(e.get("vid", 0)) == int(client.get_main_vid())) - var is_poly := EntityRules._is_poly(e) - - # 1. 玩家实体(PC):使用其自定义角色名(含变身中玩家、主玩家) - if is_main or is_poly or kind == EntityRules.KIND_PC or (race < 8 and not EntityRules._is_enemy(e)): - var nm := String(e.get("name", "")) - if nm != "": - return nm - - # 2. 怪物 / NPC / 陨石 / 建筑等:优先查本地化词表 (mob_names.txt) - if race >= 1: - var loc := _get_locale() - if loc and loc.has_method("has_mob_name") and loc.has_mob_name(race): - return loc.mob_name(race) - if proto and proto.has_method("mob"): - var m: Dictionary = proto.mob(race) - if not m.is_empty(): - var ln := String(m.get("locale_name", "")) - if ln != "": - return ln - var pn := String(m.get("name", "")) - if pn != "": - return pn - - # 3. 兜底回退:实体字典自带名称 - return String(e.get("name", "")) - -# --- movement ---------------------------------------------------------------- - -# `pc.moved` 在平移的每一帧发。第一帧 = OnMove,之后各帧 = OnMoving。 -func _on_local_moved(pos: Vector3) -> void: - if _observer_mode or client == null or not client.is_in_game(): - return - var now := _now() - if not _was_moving: - # OnMove:立即上行,并把 moving 节流窗口推到「刚发过」(PythonPlayerEventHandler.cpp:76)。 - _send_state(FUNC_MOVE, 0, pos) - _was_moving = true - _last_moving_sent_t = now - return - # OnMoving:300 ms 节流(:62)。 - if now - _last_moving_sent_t < SEND_MOVING_INTERVAL: - return - _send_state(FUNC_MOVE, 0, pos) - _last_moving_sent_t = now - -func _on_anim_state(state: String) -> void: - if _observer_mode: - return - # OnStop:停下时不节流,落一包 FUNC_WAIT 收尾(PythonPlayerEventHandler.cpp:88)。 - if state == "wait" and _was_moving: - _was_moving = false - if client and client.is_in_game() and pc.player: - _send_state(FUNC_WAIT, 0, pc.player.position) - -# 统一上行出口 —— 对齐 `SendCharacterStatePacket` / `TPacketCGMove` -# (PythonNetworkStreamPhaseGame.cpp:1111):bFunc / bArg / bRot / lX / lY / dwTime。 -# rot 的 /5.0 与坐标的 __LocalPositionToGlobalPosition 由 C++ 侧 client.move() 负责。 -func _send_state(func_id: int, arg: int, world_pos: Vector3) -> void: - if client == null or not client.is_in_game(): - return - var xy := _server_xy(world_pos) - client.move(func_id, arg, _heading_deg(_player_yaw()), int(xy.x), int(xy.y)) - _last_sent_pos = world_pos - -# OnUseSkill —— 施法瞬间的状态上行:FUNC_SKILL|uMotSkill + uArg(不节流, -# PythonPlayerEventHandler.cpp:120)。调用点(技能合法性 / CD / charge)属 §3.8 的 -# `player_skill.gd`,这里只留 1:1 的上行出口,做法同 §3.4 的预留桩。 -func on_use_skill(motion_skill: int, arg: int) -> void: - if _observer_mode or pc == null or pc.player == null: - return - _was_moving = false - _send_state(FUNC_SKILL | (motion_skill & 0x7F), arg, pc.player.position) - -func _on_main_set(vid: int) -> void: - _main_vid = vid - _main_dead = false - _clear_reserved() - _clear_auto_attack() - if pc and pc.has_method("set_private_shop_open"): - pc.set_private_shop_open(false) - if pc and pc.has_method("set_processing_emotion"): - pc.set_processing_emotion(false) - if pc and pc.has_method("set_dead"): - pc.set_dead(false) - if pc and pc.has_method("stop"): - pc.stop() - if client and client.has_method("get_entity"): - _on_entity_info(vid, client.get_entity(vid)) - # net_world 会给主角也生成一个节点;本地由 player_controller 的 player 代表,去掉重复的 - if net_world and net_world.has_method("set_local_vid"): - net_world.set_local_vid(vid) - -func _on_entity_info(vid: int, entity: Dictionary) -> void: - if entity.has("owner_vid"): - _note_owner(vid, int(entity.get("owner_vid", 0))) - if vid != _main_vid: - return - if pc and pc.has_method("set_server_speed"): - var speed := int(entity.get("moving_speed", 0)) - if speed > 0: - pc.set_server_speed(speed) - # 40250 BINARY_PrivateShop_Appear/Disappear 最终通过主角状态落地到 - # CPythonPlayer::m_isOpenPrivateShop;GC_SHOP_SIGN 是当前实体的权威 - # 摆摊标志。只对主角读取,避免打开别人的商店时锁住本地输入。 - if pc and pc.has_method("set_private_shop_open"): - pc.set_private_shop_open(String(entity.get("shop_sign", "")) != "") - # 主角情绪动作同样由实体的 acting_emotion 状态闭合到 - # CPythonPlayer::m_bisProcessingEmotion。方向键在动作期间保留,动作尾 - # 到达后下一帧恢复,和原客户端 Update() 的 m_isDirKey 重试一致。 - if pc and pc.has_method("set_processing_emotion"): - pc.set_processing_emotion(bool(entity.get("acting_emotion", false))) - _apply_attack_speed(int(entity.get("attack_speed", 0))) - if entity.has("race"): - combo_class = int(entity.get("race", 0)) & 3 # §3.5 连击段表按职业分块 - _refresh_motion_mode() # §3.5 修改 6:parts[WEAPON] / 骑乘 / 变身变化 → combo_motion_mode - -func _on_entity_dead(vid: int) -> void: - if vid == _target_vid: - if is_instance_valid(net_world) and net_world.has_method("_entity_pos"): - var mpos: Vector3 = net_world._entity_pos(vid) - if mpos != Vector3.INF: - _last_dead_mob_pos = mpos - _clear_target_with_packet(true) - if vid != _main_vid: - return - _main_dead = true - _was_moving = false - _attack_cd = 0.0 - _attack_key_down = false - _using_skill = false - _charge_skill = false - _skill_motion_driven = false - _skill_cast_start_time = -1.0 - _skill_cast_duration = 0.0 - _skill_cancel_enable = false - _knock_down = false - _fishing_active = false - _clear_reserved() - _clear_auto_attack() - if pc: - if pc.has_method("set_dead"): - pc.set_dead(true) - elif pc.has_method("stop"): - pc.stop() - pc.set("frozen", true) - -func _apply_attack_speed(speed: int) -> void: - # `CInstanceBase::SetAttackSpeed`:bAttackSpeed / 100 只缩放动作播放速率, - # 不是一个独立的固定间隔。节奏本身由 `.msa` combo 时间给出(见 _current_attack_period)。 - # 出货代码:uAtkSpd > 1100 -> 0,否则 uAtkSpd / 100,没有上下限。speed 0 = 数据未到,保留旧值。 - if speed > 0: - _atk_speed_factor = 0.0 if speed > 1100 else float(speed) / 100.0 - -# 当前攻击 `.msa` 的普攻节奏(秒),已按攻速缩放。拿不到动作数据时用兜底常量。 -func _current_attack_period() -> float: - var base := DEFAULT_ATTACK_PERIOD - var nc := float(_motion_data().get("next_combo", 0.0)) - if nc > 0.0: - base = nc - if _atk_speed_factor <= 0.0: - return base # fSpeedRatio 0 = 动作冻结;节奏只作 HUD / 冷却兜底,不除零 - return base / _atk_speed_factor - -# CGraphicThingInstance::UpdateTime:m_fDelay 先吃掉本帧 elapsed,余下才推进本地时间。 -func _advance_local_time(dt: float) -> void: - var elapsed := dt - if _delay > elapsed: - _delay -= elapsed - elapsed = 0.0 - else: - elapsed -= _delay - _delay = 0.0 - _local_time += elapsed - -# CActorInstance::GetAttackingElapsedTime:(GetLocalTime() - fStartTime) * fSpeedRatio -func _attacking_elapsed() -> float: - return (_local_time - _motion_start_t) * _atk_speed_factor - -# CInstanceBase::__GetBowRange(InstanceBase.cpp:694)——基础 + 主角 POINT_BOW_DISTANCE 加成。 -func _bow_range_cm() -> float: - return BOW_RANGE_BASE_CM + _bow_distance_bonus - -# 可点击 / 攻击距离(米),逐分支对齐 NEW_IsClickableDistanceDestInstance -# (InstanceBaseBattle.cpp:294):默认 150 → 弓箭 __GetBowRange() → NPC 500 → 采集物 100, -# 后面的 if 覆盖前面的(NPC / 采集物优先于弓箭距离)。 -func _clickable_distance(te: Dictionary) -> float: - var cm := CLICK_DIST_DEFAULT_CM - if _is_bow_mode(): - cm = _bow_range_cm() - match _entity_kind(te): - 1, 4: cm = CLICK_DIST_NPC_CM # NPC / 传送门 - 3: cm = CLICK_DIST_RESOURCE_CM # 石头 / 采集物 - return cm / CM - -# 40250 CActorInstance::IsClickableDistanceDestInstance (ActorInstanceBattle.cpp:117) -# 遍历目标防御球,若存在防御球,判断 ||rkSphere.v3Position - kD3DVct3Src|| < rkSphere.fRadius + fDistance -func _is_clickable_distance(tnode: Node3D, te: Dictionary) -> bool: - if pc == null or pc.player == null or tnode == null: - return false - var base_dist := _clickable_distance(te) - var my_pos := _world_pos(pc.player) - var target_pos := _world_pos(tnode) - var flat_dist := Vector2(my_pos.x - target_pos.x, my_pos.z - target_pos.z).length() - if flat_dist <= base_dist: - return true - var spheres := _defending_spheres(int(te.get("vid", 0)), tnode, _actor_cm(target_pos)) - if not spheres.is_empty(): - for s in spheres: - if s is Dictionary: - var spos_cm: Vector3 = s.get("pos", Vector3.ZERO) - var spos_m := _godot_m(spos_cm) - var srad_m: float = float(s.get("radius", 0.0)) / CM - var d_sph := Vector2(my_pos.x - spos_m.x, my_pos.z - spos_m.z).length() - if d_sph < (base_dist + srad_m): - return true - return false - return flat_dist <= base_dist - -func _on_points(p: Dictionary) -> void: - _apply_attack_speed(int(p.get("attack_speed", 0))) - _bow_distance_bonus = float(p.get("bow_distance", 0)) # POINT_BOW_DISTANCE → __GetBowRange() - - var cur_level := int(p.get("level", 0)) - if cur_level > 0: - if _last_level > 0 and cur_level > _last_level: - _trigger_level_up(cur_level, p) - _last_level = cur_level - - var cur_exp := int(p.get("exp", 0)) - var packet_gain := int(p.get("exp_gain", 0)) - var delta_exp := packet_gain if packet_gain > 0 else (cur_exp - _last_exp if _last_exp >= 0 else 0) - if delta_exp > 0: - _trigger_exp_gain(delta_exp, p) - _last_exp = cur_exp - - if hud == null: - return - if hud.has_method("set_vitals"): - hud.set_vitals(int(p.get("hp", 0)), int(p.get("max_hp", 0)), - int(p.get("sp", 0)), int(p.get("max_sp", 0))) - if hud.has_method("set_stamina"): - hud.set_stamina(int(p.get("stamina", 0)), int(p.get("max_stamina", 0))) - if hud.has_method("set_exp"): - hud.set_exp(int(p.get("exp", 0)), int(p.get("next_exp", 0))) - if hud.has_method("set_level"): - hud.set_level(cur_level) - if hud.has_method("set_energy"): - hud.set_energy(int(p.get("energy", 0)), 100) - -# PythonNetworkStreamPhaseGame::RecvPointChange — this is intentionally a -# separate path from the aggregate points_changed snapshot. 40250 first -# dispatches ShowPointEffect(type, vid), then updates the main point table and -# refreshes status/skill windows according to the point type. -func _on_point_changed(vid: int, point_type: int, _value: int, amount: int) -> void: - if point_type == POINT_GOLD and amount > 0 and vid == _main_vid: - money_picked.emit(amount) - if hud and hud.has_method("on_pick_money"): - hud.on_pick_money(amount) - - if vid != _main_vid: - return - - match point_type: - POINT_STAT_RESET_COUNT, POINT_ENERGY: - status_refresh_requested.emit() - POINT_LEVEL, POINT_ST, POINT_HT, POINT_DX, POINT_IQ: - status_refresh_requested.emit() - skill_refresh_requested.emit() - POINT_SUB_SKILL, POINT_SKILL, POINT_HORSE_SKILL: - skill_refresh_requested.emit() - _: - # RecvPointChange's default branch calls __RefreshStatus(). - status_refresh_requested.emit() - -func _trigger_exp_gain(delta_exp: int, _p: Dictionary) -> void: - print("[经验] 击杀怪物获得 %d 点经验值。" % delta_exp) - - # 40250 的经验球由服务端 `from->CreateFly(FLY_EXP, to)` 下发 GC_CREATE_FLY, - # NetWorld._on_fly 负责唯一一次播放。这里不能再合成一组,否则正常收包时会出现双份。 - _last_dead_mob_pos = Vector3.INF - - # 经验数值反馈音效;飞行和吸收闪光由权威 GC_CREATE_FLY 驱动。 - if audio and audio.has_method("play_ui"): - audio.play_ui("money.wav") - -func _trigger_level_up(new_level: int, p: Dictionary) -> void: - level_up.emit(new_level) - print("[升级] 恭喜!等级提升至 Lv. %d!" % new_level) - - # 1. 40250 EFFECT_LEVELUP 冲天金光特效。真实网络链路由 - # NetWorld._on_point_changed 按注册的 level_up.mse 播放;保留几何光柱 - # 作为无特效注册表的 headless / 旧适配器回退,避免测试和轻量宿主丢反馈。 - var has_exact_effect: bool = false - if net_world != null and net_world.has_method("_on_point_changed") \ - and fx != null and fx.has_method("spawn"): - has_exact_effect = true - if not has_exact_effect: - _spawn_level_up_effect() - - # 2. 40250 播放专属升级号角音效 - if audio and audio.has_method("play_ui"): - audio.play_ui("levelup1_1.wav") - - # 3. 40250 规则:升级瞬间 HP、SP 立即 100% 满状态恢复 - var max_hp := int(p.get("max_hp", 0)) - var max_sp := int(p.get("max_sp", 0)) - if max_hp > 0 and hud and hud.has_method("set_vitals"): - hud.set_vitals(max_hp, max_hp, max_sp, max_sp) - - # 4. 通知 HUD 刷新属性加点 - if hud and hud.has_method("on_level_up"): - hud.on_level_up(new_level) - -func _spawn_level_up_effect() -> void: - var pl: Node3D = pc.player if (pc and "player" in pc) else null - if pl == null or not is_instance_valid(pl): - return - var beam := MeshInstance3D.new() - beam.name = "level_up_beam" - var cyl := CylinderMesh.new() - cyl.top_radius = 0.45 - cyl.bottom_radius = 0.65 - cyl.height = 7.0 - beam.mesh = cyl - beam.position.y = 3.5 - var mat := StandardMaterial3D.new() - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.albedo_color = Color(1.0, 0.85, 0.25, 0.75) - mat.emission_enabled = true - mat.emission = Color(1.0, 0.88, 0.3) - beam.material_override = mat - pl.add_child(beam) - - var tw := pl.create_tween() - tw.set_parallel(true) - tw.tween_property(beam, "scale:y", 1.4, 1.2) - tw.tween_property(mat, "albedo_color:a", 0.0, 1.2) - tw.chain().tween_callback(beam.queue_free) - -func _on_net_moved(vid: int) -> void: - if client == null or vid != _main_vid or pc.player == null: - return - var e: Dictionary = client.get_entity(vid) - if e.is_empty(): - return - var sp: Vector3 = MapCoord.to_world(e.get("pos", pc.player.position)) - var w: Node = pc.world if (pc and "world" in pc and pc.world) else (net_world.world if (net_world and "world" in net_world) else null) - if w and w.has_method("sample_height"): - sp.y = float(w.call("sample_height", sp.x, sp.z)) - else: - sp.y = pc.player.position.y # 无地形时的兜底 - var d: float = pc.player.position.distance_to(sp) - # 40250 对齐:CInstanceBase::PushTCPState 对主玩家显式忽略(if (__IsMainInstance()) return)。 - # 本地移动由客户端权威推进,常规移动不作平滑插值(避免网络 RTT 滞后引发反拉与按键对抗)。 - # 仅在距离超出瞬移阈值时(服务端传送、换图、防作弊重置)直接 Snap 同步。 - if d > CORRECTION_SNAP: - pc.player.position = sp - if pc and pc.has_method("stop"): - pc.stop() - -# --- targeting / attack ----------------------------------------------------- - -static func _world_pos(n: Node3D) -> Vector3: - if n == null: - return Vector3.ZERO - return n.global_position if n.is_inside_tree() else n.position - -# 40250 CPythonPlayer::__SendClickActorPacket (PythonPlayer.cpp:1196) -# 节流保护:s_dwNextTCPTime = dwCurTime + 1000(同目标 1 秒内不重复发送点击包,防止狂点刷包) -func _send_click_actor_packet(vid: int) -> bool: - if vid <= 0 or client == null or not client.has_method("click_npc"): - return false - var now := Time.get_ticks_msec() / 1000.0 - if vid == _last_click_actor_vid and (now - _last_click_actor_t) < 1.0: - return false - _last_click_actor_t = now - _last_click_actor_vid = vid - return client.click_npc(vid) - -func _on_pick(node: Node3D) -> void: - if _observer_mode: - return - var vid := _vid_of(node) - if vid == 0 or vid == _main_vid: - return - # __OnPressActor 特例(PythonPlayerInput.cpp:324):预约技能 + 冲锋 affect 生效时, - # 只改预约目标、不立即选中 / 施法。 - if _reserved_mode == ReservedMode.USE_SKILL and _dash_active: - _vid_reserved = vid - return - var e: Dictionary = client.get_entity(vid) - if e.is_empty(): - return - - var kind := _entity_kind(e) - var tnode: Node3D = net_world.node_for(vid) if (net_world and net_world.node_for(vid)) else node - - # 不可攻击目标(NPC / warp / 同阵营PC等) -> 交互或目标框 - if not _is_attackable(e): - _auto_attack_vid = 0 - if kind in [EntityRules.KIND_NPC, EntityRules.KIND_WARP]: - if not _is_clickable_distance(tnode, e): - # 40250 __OnClickActor: !NEW_IsClickableDistanceDestInstance -> __ReserveClickActor (走近 NPC) - _reserve_click_actor(vid) - if pc and pc.has_method("walk_to") and tnode: - pc.walk_to(_world_pos(tnode)) - return - # 已在交互距离内(<= 5m):停止移动、朝向 NPC、发送点击包(40250 __SendClickActorPacket + NEW_Stop) - _clear_reserved() - if pc and pc.has_method("stop"): - pc.stop() - if pc and pc.player and tnode: - var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player) - if to.length() > 0.01: - pc.player.rotation.y = atan2(to.x, to.z) - _send_click_actor_packet(vid) - return - _clear_reserved() - _change_target_to_picked_instance(vid) - if kind == EntityRules.KIND_PC: - var same_empire := EntityRules._is_same_empire(_main_entity(), e) - if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false): - var nm := _entity_name(e) - if nm != "": - whisper_requested.emit(nm) - return - - # --- 40250 __OnPressActor: 可攻击实体(怪物、陨石、敌对PC等)--- - _attack_cd = 0.0 - _change_target_to_picked_instance(vid) - # 40250 __SetAutoAttackTargetActorID: 点击可攻击实体开启持续连击/追击 - _auto_attack_vid = vid - _clear_reserved() - - if not _can_attack(): - return - - if not _is_clickable_distance(tnode, e): - # 40250: !NEW_IsClickableDistanceDestInstance -> __ReserveClickActor (走近) - _reserve_click_actor(vid) - if pc and pc.has_method("walk_to") and tnode: - pc.walk_to(_world_pos(tnode)) - return - - # 已在攻击距离内:立即朝向目标出刀 - if tnode != null: - _do_attack_swing(tnode, e) - -# poc 版 RecvTargetPacket(PythonNetworkStreamPhaseGame.cpp:2424):C++ 侧从 EntityStore -# 抽出 (vid, hp%),这里照参考端的判定树分派目标框——目标实例缺失 -> 关框;已死透 -> 不动框; -# PC / building 目标 -> 只在框上是别的 VID 时关掉(不显血条);石头 / 敌对怪等「可看血」 -# 目标 -> SetHPTargetBoard;NPC / warp 等不可看血目标 -> 关框。经 target_board.gd 纯静态镜像。 -func _on_target_info(vid: int, hp_pct: int) -> void: - var e: Dictionary = client.get_entity(vid) if vid != 0 else {} - var exists := vid != 0 and not e.is_empty() - var dead := exists and bool(e.get("dead", false)) - var kind := _entity_kind(e) if exists else -1 - var is_pc := kind == EntityRules.KIND_PC - var is_building := EntityRules._is_building(e) - # uitarget.py:189-193 前置过滤:game-option 关「看他国目标框」时异阵营目标不开框。 - # empire 0 的怪/NPC/石头在 _is_same_empire 里算同阵营 → 不受影响,只挡真·他国 PC。 - if exists: - var same_empire := EntityRules._is_same_empire(_main_entity(), e) - if TargetBoard.hidden_as_other_empire(_view_other_empire_target, same_empire): - _clear_target() - return - var can_view := TargetBoard.can_view_target_hp( - kind == EntityRules.KIND_STONE, EntityRules._is_wooden_door(e), - kind == EntityRules.KIND_MONSTER) - match TargetBoard.classify(exists, dead, is_pc, is_building, can_view): - TargetBoard.Action.CLOSE: - _clear_target() - TargetBoard.Action.CLOSE_IF_DIFFERENT: - if TargetBoard.should_close_if_different(vid, _target_vid): - _clear_target() - TargetBoard.Action.SET_HP: - if TargetBoard.needs_reset(vid, _target_vid): - _target_vid = vid - target_changed.emit(vid) - var nm := _entity_name(e) - var hp_clamped := TargetBoard.clamp_hp_pct(hp_pct) - if hud and hud.has_method("set_target"): - hud.set_target(nm if nm != "" else "目标", hp_clamped) - if target_ui and target_ui.has_method("open"): - var lvl := int(e.get("level", -1)) - var grade := int(e.get("grade", -1)) - var mob_race := int(e.get("race", 0)) - if not is_pc and mob_race >= 1 and proto and proto.has_method("mob"): - var m: Dictionary = proto.mob(mob_race) - if not m.is_empty(): - if lvl <= 0: - lvl = int(m.get("level", -1)) - if grade < 0: - grade = int(m.get("rank", -1)) - target_ui.open(vid, nm if nm != "" else "目标", lvl, grade, is_pc) - target_ui.set_hp(hp_clamped) - TargetBoard.Action.NONE: - pass - -# game_option_ui 的「看他国玩家目标框」radio(display_option_changed "target_board")。 -# uigameoption.py → constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)。 -func set_view_other_empire_target(on: bool) -> void: - _view_other_empire_target = on - -# game_scene 的 KEY_CTRL 按下 / 松开 → app.IsPressed(app.DIK_LCONTROL) 语义(game.py:807)。 -func set_lcontrol_down(down: bool) -> void: - _lcontrol_down = down - -func _clear_target() -> void: - _clear_target_with_packet(false) - -func _clear_target_with_packet(send_packet: bool) -> void: - if _target_vid == 0: - if send_packet and client and client.has_method("set_target"): - client.set_target(0) - return - _auto_attack_vid = 0 - _target_vid = 0 - _clear_reserved() - if pc and pc.has_method("stop"): - pc.stop() - target_changed.emit(0) - if send_packet and client and client.has_method("set_target"): - client.set_target(0) - if hud and hud.has_method("clear_target"): - hud.clear_target() - if target_ui and target_ui.has_method("close"): - target_ui.close() - -# 受击:40250 RecvDamageInfoPacket(PythonNetworkStreamPhaseGame.cpp:2415) -# 仅展示伤害飘字和受击动作;普通伤害不晃动摄像机、不锁死移动控制(非 DAMAGE_FLYING 击飞)。 -func _on_damage(vid: int, amount: int, flag: int) -> void: - if vid != _main_vid: - return - if (flag & DAMAGE_DODGE) != 0: - return - _knock_down = true - if camera and camera.has_method("shake"): - camera.shake(0.2, 0.5) - if player_view and player_view.has_method("set_anim_state"): - player_view.set_anim_state("damage") - -# GC_FISHING motion events for the local player. `info` is the fisher VID for -# START/STOP/REACT/SUCCESS/FAIL; FISH carries an item vnum and is UI-only. -func _on_fishing_event(subheader: int, info: int, _dir: int) -> void: - if info != _main_vid: - return - # CActorInstance::IsFishing() 仅在 FISHING_WAIT / FISHING_REACT 为真; - # START 到达时先记录意图,随后 STOP/SUCCESS/FAIL 收尾清掉。 - match subheader: - FISHING_START, FISHING_REACT: - _fishing_active = true - FISHING_STOP, FISHING_SUCCESS, FISHING_FAIL, FISHING_FISH: - _fishing_active = false - if player_view == null or not player_view.has_method("set_anim_state"): - return - if subheader == FISHING_FISH: - return - var state := "fishing" - match subheader: - FISHING_STOP: state = "wait" - FISHING_REACT: state = "fishing_react" - FISHING_SUCCESS: state = "fishing_catch" - FISHING_FAIL: state = "fishing_fail" - player_view.set_anim_state(state) - -# --- 行动闸门(对齐 InstanceBase.cpp:1247 / InstanceBaseBattle.cpp:355 / :310)--- - -func _main_entity() -> Dictionary: - if client and client.has_method("get_entity") and _main_vid != 0: - return client.get_entity(_main_vid) - return {} - -# CanProcessNetworkStatePacket:死亡 / 晕眩 / 击退中都不接受状态输入(≈ __IsSyncing)。 -func _can_process_network_state() -> bool: - if _main_dead or _knock_down: - return false - var e := _main_entity() - return not (bool(e.get("dead", false)) or bool(e.get("stunned", false)) \ - or bool(e.get("knock_down", false))) - -# __CanAttack:可处理状态 + 不在施法中。 -func _can_attack() -> bool: - if not _can_process_network_state() or _using_skill: - return false - # CPythonPlayer::__CanAttack() 的新坐骑等级 11 门(:759), - # 只作用于 IsNewMount;普通坐骑仍由 CanAttackHorseLevel() 的 level<=1 门处理。 - var mount := int(_main_entity().get("mount_vnum", 0)) - if _is_new_mount(mount) and not _horse_skill_unlocked(11): - return false - return true - -# __CanUseSkill:同上(技能合法性 / CD 归 §3.8)。 -func _can_use_skill() -> bool: - return _can_process_network_state() and not _using_skill - -# 兼容旧调用:真正语义 = 「不能处理网络状态包 / 不能行动」。 -func is_stunned() -> bool: - return not _can_process_network_state() - -# 40250 CInstanceBase::NEW_GetFrontInstance (InstanceBaseBattle.cpp:145) -# 在正前方扇面检索最近的可攻击存活目标(最大射程默认 2000cm / 20m) -func get_front_instance(f_distance_cm: float = 2000.0) -> int: - if pc == null or pc.player == null or client == null: - return 0 - var my_pos: Vector3 = _world_pos(pc.player) - var my_yaw: float = pc.player.rotation.y - # Godot 空间中 yaw=0 面向 -Z,朝向向量为 (-sin(yaw), 0, -cos(yaw)) - var fwd := Vector3(-sin(my_yaw), 0, -cos(my_yaw)).normalized() - - var best_vid := 0 - var min_dist_m := 1e9 - var max_dist_m := f_distance_cm / CM - - for e in client.get_entities(): - var vid := int(e.get("vid", 0)) - if vid == 0 or vid == _main_vid: - continue - if not _is_attackable(e) or bool(e.get("dead", false)) or bool(e.get("in_safe", false)): - continue - var tnode: Node3D = net_world.node_for(vid) if net_world else null - var pos := Vector3.ZERO - if tnode != null: - pos = _world_pos(tnode) - else: - pos = MapCoord.to_world(e.get("pos", my_pos)) - var to_target := pos - my_pos - to_target.y = 0.0 - var d := to_target.length() - if d > max_dist_m or d < 0.01: - continue - var to_dir := to_target.normalized() - var dot := fwd.dot(to_dir) - # 40250 扇形检测(HALF_FAN_ROT 10°~50°,约 60°~70° 锥角内) - if dot > 0.35: - if d < min_dist_m: - min_dist_m = d - best_vid = vid - return best_vid - -# §3.8 修改 1 —— 供 player_skill.gd 三层校验的运行期上下文快照。 -# 对齐原客户端 __CanUseSkill / __CheckSkillUsable 从 CInstanceBase / m_sysIs* / -# GetStatus() 读到的那些值;服务端未下发的走 net_play 本地占位。 -func skill_context() -> Dictionary: - var e := _main_entity() - var mounted := int(e.get("mount_vnum", 0)) != 0 or bool(e.get("mounting", false)) - var parts: Array = e.get("parts", []) if e.get("parts", []) is Array else [] - var weapon_part := int(parts[1]) if parts.size() > 1 else 0 - var polymorphed := EntityRules._is_poly(e) - var wearing_dress := bool(e.get("wedding_dress", false)) or int(e.get("shape", 0)) == 201 - var holding_pickaxe := bool(e.get("holding_pickaxe", false)) \ - or (weapon_part >= 29101 and weapon_part <= 29110) - var guild_war_active := false - if client and client.has_method("get_guild_war"): - var war: Dictionary = client.get_guild_war() - # Packet.h: GUILD_WAR_ON_WAR = 6; other non-zero states are setup/list - # notifications, not the IsDoingGuildWar() state used by UseGuildSkill. - guild_war_active = int(war.get("state", 0)) == 6 - var wsub := -1 - var vnum := _equipped_weapon_vnum() - if vnum > 0 and proto and proto.has_method("item"): - var d: Dictionary = proto.item(vnum) - if int(d.get("type", -1)) == ITEM_TYPE_WEAPON: - wsub = int(d.get("sub_type", -1)) - # §3.8 修改 3:GetStatus(POINT_HP/SP) —— __CheckShortLife / __CheckShortMana 的即时值。 - var cur_hp := -1 - var cur_sp := -1 - if client and client.has_method("get_points"): - var pts: Dictionary = client.get_points() - if pts.has("hp"): - cur_hp = int(pts.get("hp", -1)) - if pts.has("sp"): - cur_sp = int(pts.get("sp", -1)) - return { - "observer_mode": _observer_mode, - "mounting": mounted, - "in_safe": bool(e.get("in_safe", false)), - "is_fishing": combo_motion_mode == MOTION_MODE_FISHING, - "is_using_skill": _using_skill, - "can_act": _can_process_network_state(), - "polymorphed": polymorphed, - "wearing_dress": wearing_dress, - "holding_pickaxe": holding_pickaxe, - "horse_can_use": bool(e.get("horse_can_use_skill", true)), - "graphic_can_use": bool(e.get("graphic_can_use_skill", true)), - "guild_war_active": guild_war_active, - "level_limit": true, - "cooltime_check": true, - "dash_active": _dash_active, - "weapon_sub_type": wsub, - "arrow_count": _arrow_count, - "cur_hp": cur_hp, - "cur_sp": cur_sp, - "bow_distance": int(_bow_distance_bonus), - "target_distance": Callable(self, "skill_target_distance_cm"), - "get_front_instance": Callable(self, "get_front_instance"), - } - -# rkInstMain.GetDistance(&rkInstTarget)(含 IS_HUGE_RACE −200)——施法射程判定与 -# MODE_USE_SKILL 预约趋近共用同一量度,保证预约触发时 __UseSkill 必然在射程内。 -func skill_target_distance_cm(vid: int) -> float: - if pc == null or pc.player == null or client == null: - return -1.0 - var tnode: Node3D = net_world.node_for(vid) if net_world else null - if tnode == null: - return -1.0 - var dist_cm: float = _world_pos(pc.player).distance_to(_world_pos(tnode)) * CM - if _is_huge_race(int(client.get_entity(vid).get("race", 0))): - dist_cm -= HUGE_RACE_DIST_FIX_CM - return dist_cm - -# __IsReservedUseSkill(dwSkillSlotIndex) -func is_use_skill_reserved(slot: int) -> bool: - return _reserved_mode == ReservedMode.USE_SKILL and _skill_slot_reserved == slot - -# 40250 CActorInstance::isLock()(ActorInstanceMotion.cpp:472) -# 挥击动作(普攻/连击/技能/钓鱼)期间,严格锁定移动(PythonPlayerInput.cpp:457)。 -# 连击段号仅用于连击输入窗口判断,动作播放结束自动切回 wait 状态并解锁。 -func is_lock() -> bool: - if _using_skill: - # 40250 ActorInstanceMotion.cpp:541: if (m_pkCurRaceMotionData->IsCancelEnableSkill()) return FALSE; - if _skill_cancel_enable: - return false - if not _skill_motion_driven and _skill_cast_duration > 0.0 \ - and _skill_cast_start_time >= 0.0 \ - and (_local_time - _skill_cast_start_time) >= _skill_cast_duration: - _using_skill = false - _charge_skill = false - _skill_motion_driven = false - _skill_cancel_enable = false - if player_view and player_view.has_method("reset_skill_motion"): - player_view.reset_skill_motion() - elif not _charge_skill: - return true - if combo_motion_mode == MOTION_MODE_FISHING: - return true - if _is_attacking: - var md := _motion_data() - var dur := float(md.get("duration", attack_period)) - if dur <= 0.0: - dur = attack_period - if _attacking_elapsed() < dur: - return true - else: - _is_attacking = false - _combo_index = 0 - return false - -func _process(dt: float) -> void: - _advance_local_time(dt) - _remote_splash_attack_process() - if _using_skill and not _skill_motion_driven and _skill_cast_duration > 0.0 \ - and _skill_cast_start_time >= 0.0: - if (_local_time - _skill_cast_start_time) >= _skill_cast_duration: - _using_skill = false - _charge_skill = false - _skill_motion_driven = false - _skill_cancel_enable = false - if not _can_process_network_state(): - _using_skill = false - _charge_skill = false - _skill_motion_driven = false - _skill_cancel_enable = false - # 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。 - if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \ - and not player_view.is_in_hit_reaction(): - _knock_down = false - if pc: - pc.set("frozen", not _can_process_network_state()) - pc.set("locked", is_lock()) - if _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null: - _clear_reserved() - _auto_attack_vid = 0 - return - _attack_cd -= dt - if _reserved_delay > 0.0: - _reserved_delay -= dt - _combo_process() - _attack_process(dt) # AttackProcess:命中窗几何判定 -> OnHit - _splash_attack_process(dt) # SPECIAL_ATTACKING:SplashArea -> OnHit - _update_auto_attack() - _refresh_reserved() - _flush_victim_list() # 帧末:被击退者 CG_SYNC_POSITION - -# CPythonPlayer::__Update_AutoAttack()(PythonPlayer.cpp:207) -# 以及 NEW_Attack()(PythonPlayerInput.cpp:550,空格键自由挥击) -func _update_auto_attack() -> void: - if _attack_key_down: - if _auto_attack_vid == 0 and _target_vid != 0: - _auto_attack_vid = _target_vid - elif _auto_attack_vid == 0 and _target_vid == 0: - # 40250 NEW_Attack(): 无目标按下空格键时朝当前面向自由挥剑 - # 连击 / 普攻节奏由动作数据 ComboInputData / 90% duration 自行 gate,不使用外层 cd 吞键 - if _can_attack(): - _do_attack_swing(null, {}) - return - - if _auto_attack_vid == 0: - return - - if not _can_process_network_state(): # ≈ !NEW_GetMainActorPtr() / __IsSyncing() - return - if _dash_active or (_using_skill and _charge_skill): # __IsUsingChargeSkill() - return - var v: Dictionary = client.get_entity(_auto_attack_vid) - if v.is_empty() or bool(v.get("dead", false)): - _clear_auto_attack() - return - if _is_main_mounted() and not _can_attack_horse_level(): - _clear_auto_attack() - return - if not _is_attackable(v): - _clear_auto_attack() - return - if bool(_main_entity().get("sleeping", false)): - return - # 对齐 40250 CPythonPlayer::__Update_AutoAttack():只要目标有效存活且可攻击,每帧 __ReserveClickActor - if _reserved_mode == ReservedMode.NONE and _can_attack(): - _reserve_click_actor(_auto_attack_vid) - -# NEW_RefreshMouseWalkingDirection()(PythonPlayerInputMouse.cpp:287)里的模式分派—— -# 四个 case 逐一照抄 :288-370(含 MODE_CLICK_ITEM 的 `< 20.0f` 拾取阈值、 -# MODE_USE_SKILL 的 IS_HUGE_RACE 距离 −200 修正)。 -func _refresh_reserved() -> void: - match _reserved_mode: - ReservedMode.CLICK_ITEM: - _reserve_process_click_item() - ReservedMode.CLICK_ACTOR: - _reserve_process_click_actor() - ReservedMode.CLICK_POSITION: - _reserve_process_click_position() - ReservedMode.USE_SKILL: - _reserve_process_use_skill() - -func _reserve_click_actor(vid: int) -> void: - _reserved_mode = ReservedMode.CLICK_ACTOR - _vid_reserved = vid - -func _clear_reserved() -> void: - _reserved_mode = ReservedMode.NONE - _vid_reserved = 0 - _iid_reserved = 0 - _ppos_reserved = Vector3.ZERO - _skill_slot_reserved = -1 - _skill_range_reserved = 0.0 - _reserved_delay = 0.0 - -func _clear_auto_attack() -> void: - _auto_attack_vid = 0 - -func _emit_cannot(code: String) -> void: - _last_cannot = code - cannot_act.emit(code) - -# CInstanceBase::IsAttackableInstance(InstanceBase.cpp:2147)—— 逐行实现在 entity_rules.gd。 -# 这里只负责把网络快照拼成 self_e / victim_e / ctx。参考端此函数**不查死亡**(死亡另在各 -# 调用点判:__ReserveProcess_ClickActor 第 4 步、_attack_process 等)——本 POC 保留一个 -# dead 提前返回作为便利,多数调用点本来也会再查一次。 -func _is_attackable(e: Dictionary) -> bool: - if bool(e.get("dead", false)): - return false - var se := _main_entity() - if se.is_empty(): - # headless / 主角未就位:退化到「PC / 怪可打」的旧近似。 - return _entity_kind(e) in [EntityRules.KIND_PC, EntityRules.KIND_MONSTER] - var self_e := se.duplicate() - self_e["kind"] = _entity_kind(se) - var victim_e := e.duplicate() - victim_e["kind"] = _entity_kind(e) - return EntityRules.is_attackable_instance(self_e, victim_e, _attack_ctx(self_e)) - -# IsAttackableInstance 需要的会话级上下文:观战、自身 PK 模式、决斗、PVP 关系表、 -# 公会战对表、队伍 VID 集合。数据全部来自已有的 M2Client 访问器。 -func _attack_ctx(self_e: Dictionary) -> Dictionary: - var duel: Dictionary = client.get_duel() if client and client.has_method("get_duel") else {} - var duel_mode := EntityRules.DUEL_NONE - if bool(duel.get("active", false)): - duel_mode = EntityRules.DUEL_CANNOTATTACK if bool(duel.get("cannot_attack", false)) \ - else EntityRules.DUEL_START - var pvp_pairs := [] - if client and client.has_method("get_pvp_relations"): - for r in client.get_pvp_relations(): - if int(r.get("mode", 0)) != 0: - pvp_pairs.append([int(r.get("src_vid", 0)), int(r.get("dst_vid", 0))]) - var gvg_pairs := [] - if client and client.has_method("get_guild_wars"): - for w in client.get_guild_wars(): - gvg_pairs.append([int(w.get("src", 0)), int(w.get("dst", 0))]) - var party_vids := [] - if client and client.has_method("get_party"): - for m in client.get_party(): - party_vids.append(int(m.get("vid", 0))) - return { - "observer": _observer_mode, - "main_vid": _main_vid, - "pk_mode": int(self_e.get("pk_mode", 0)), - "duel_mode": duel_mode, - "duel_opponents": duel.get("opponents", []), - "pvp_pairs": pvp_pairs, - "gvg_pairs": gvg_pairs, # __FindGVGKey(公会战),来自 GUILD_GC_WAR 对表 - "party_vids": party_vids, - } - -func _can_attack_horse_level() -> bool: - # CInstanceBase::CanAttackHorseLevel() -> SHORSE::CanAttack() - # (InstanceBaseBattle.cpp:623, InstanceBase.cpp:216):骑乘等级 1 不能攻击; - # 未知坐骑沿用参考端 GetLevel()==0 的放行行为,避免凭空拒绝新坐骑。 - var main := _main_entity() - var mount := int(main.get("mount_vnum", 0)) - if mount == 0 and not bool(main.get("mounting", false)): - return true - return _horse_level_for_mount(mount) != 1 - -static func _horse_level_for_mount(mount: int) -> int: - match mount: - 20101, 20102, 20103: - return 1 - 20104, 20105, 20106, 20119, 20219, 20220, 20221, 20222: - return 2 - 20107, 20108, 20109, 20110, 20111, 20112, 20113, 20114, 20115, 20116, 20117, 20118, 20120, 20121, 20122, 20123, 20124, 20125: - return 3 - _ when mount >= 20205 and mount <= 20208: - return 2 - _ when mount >= 20209 and mount <= 20212: - return 3 - 20214, 20217, 20224, 20229: - return 2 - 20215, 20218, 20225, 20230: - return 3 - return 0 - -# CPythonPlayer::__ReserveProcess_ClickActor()(PythonPlayerInput.cpp:877)——顺序不可改。 -func _reserve_process_click_actor() -> void: - var main: Dictionary = _main_entity() - var rv: Dictionary = client.get_entity(_vid_reserved) - # 1 main / reserved 任一为空 - if main.is_empty() or rv.is_empty(): - _clear_reserved() - return - var tnode: Node3D = net_world.node_for(_vid_reserved) if net_world else null - if tnode == null: - _clear_reserved() - return - # 2 不在可点击距离 -> 继续靠近(NEW_MoveToDestInstanceDirection) - if not _is_clickable_distance(tnode, rv): - if pc.has_method("walk_to"): - pc.walk_to(_world_pos(tnode)) - return - elif pc and pc.has_method("is_going") and pc.is_going(): - if pc.has_method("stop"): - pc.stop() - # 3 不可攻击 -> 停 + 朝向 NPC + 发点击包(NPC 对话)+ 清预约 - if not _is_attackable(rv): - pc.set("frozen", false) - if pc and pc.has_method("stop"): - pc.stop() - if pc and pc.player and tnode: - var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player) - if to.length() > 0.01: - pc.player.rotation.y = atan2(to.x, to.z) - _send_click_actor_packet(_vid_reserved) - _clear_reserved() - return - # 4 目标已死 - if bool(rv.get("dead", false)): - _clear_reserved() - return - # 5 自己在安全区 - if bool(main.get("in_safe", false)): - _emit_cannot("IN_SAFE") - _clear_reserved() - return - # 6 目标在安全区 - if bool(rv.get("in_safe", false)): - _emit_cannot("DEST_IN_SAFE") - _clear_reserved() - return - # 7 冲锋 / 弹射 affect 生效 -> 保持预约 - if _dash_active: - return - # 8 弓箭模式且箭不足 - if bool(main.get("bow_mode", false)) and _arrow_count == 0: - _emit_cannot("EMPTY_ARROW") - _clear_reserved() - return - # 9 目标未选中 -> 选中 - if _target_vid != _vid_reserved: - _target_vid = _vid_reserved - client.set_target(_vid_reserved) - target_changed.emit(_vid_reserved) - _refresh_target_hud() - # 10 攻击 + 清预约(下一帧由 __Update_AutoAttack 重新预约)。 - # 对齐 40250 PythonPlayerInput.cpp:947:直接调用攻击,由动作状态机判定连击推进与输入窗 - _do_attack_swing(tnode, rv) - _clear_reserved() - -# --- §3.4 其余三种预约模式(NEW_RefreshMouseWalkingDirection 的其它 case)------------ - -# MODE_CLICK_ITEM(PythonPlayerInputMouse.cpp:290):走到掉落物、到手距离内先补一包 -# 站立状态再发 CG_ITEM_PICKUP,物品消失则清预约。 -func _reserve_process_click_item() -> void: - if pc == null or pc.player == null: - _clear_reserved() - return - var item_pos = _ground_item_world_pos(_iid_reserved) - if item_pos == null: - _clear_reserved() - return - if pc.player.global_position.distance_to(item_pos) <= CLICK_ITEM_PICKUP_CM / CM: - _send_state(FUNC_WAIT, 0, pc.player.position) - if client.has_method("pickup_item"): - client.pickup_item(_iid_reserved) - pc.set("frozen", false) - _clear_reserved() - elif pc.has_method("walk_to"): - pc.walk_to(item_pos) - -# MODE_CLICK_POSITION(PythonPlayerInputMouse.cpp:320):预约延时耗尽、本地未被锁, -# 再朝目标点起步并清预约(_reserved_delay 的递减在 _process 里,≈ NEW_IsEmptyReservedDelayTime)。 -func _reserve_process_click_position() -> void: - if pc and pc.has_method("_can_translate") and not pc._can_translate(): - return - if _reserved_delay > 0.0: - return - if pc and pc.has_method("walk_to"): - pc.walk_to(_ppos_reserved) - _clear_reserved() - -# MODE_USE_SKILL(PythonPlayerInputMouse.cpp:340):目标进入技能射程(含 IS_HUGE_RACE -# 的 −200cm 修正)就选中并施法,否则继续贴近。__UseSkill 的真正施法走 -# use_skill_hook(game_scene 注入的 quickbar 那条链),未注入则退化成只选中、留预约。 -func _reserve_process_use_skill() -> void: - if pc == null or pc.player == null: - _clear_reserved() - return - var rv: Dictionary = client.get_entity(_vid_reserved) - var tnode: Node3D = net_world.node_for(_vid_reserved) if net_world else null - if rv.is_empty() or tnode == null: - _clear_reserved() - return - var dist_cm := skill_target_distance_cm(_vid_reserved) - if dist_cm < _skill_range_reserved: - if _target_vid != _vid_reserved: - _target_vid = _vid_reserved - client.set_target(_vid_reserved) - target_changed.emit(_vid_reserved) - _refresh_target_hud() - if _invoke_reserved_skill(): - _clear_reserved() - elif pc.has_method("walk_to"): - pc.walk_to(tnode.global_position) - -# __UseSkill(m_dwSkillSlotIndexReserved):实际槽位施法在 quickbar,由 game_scene 通过 -# use_skill_hook(Callable(global_slot:int) -> bool)注入。未注入 -> 返回 false(预约保持, -# 下一帧再试,与参考端 __UseSkill 失败留预约一致)。 -var use_skill_hook := Callable() - -func _invoke_reserved_skill() -> bool: - if _skill_slot_reserved < 0: - return false - if use_skill_hook.is_valid(): - return bool(use_skill_hook.call(_skill_slot_reserved)) - return false - -# NEW_GetGroundItemPosition(PythonPlayerInputMouse.cpp)——按 IID 在地面掉落表查世界坐标。 -# M2Client.get_ground_items() 的 pos 是 net 帧(同实体 pos),需过 MapCoord 到 world 帧。 -func _ground_item_world_pos(iid: int): - if pc and "ground_items" in pc and pc.ground_items != null and pc.ground_items.has_method("get_item_world_pos"): - var p = pc.ground_items.get_item_world_pos(iid) - if p != null: - return p - if client == null or not client.has_method("get_ground_items"): - return null - for gi in client.get_ground_items(): - if int(gi.get("vid", 0)) == iid: - var p: Variant = gi.get("pos", null) - return MapCoord.to_world(p) if p is Vector3 else null - return null - -# __ReserveClickItem(PythonPlayerInput.cpp)——预约「走到掉落物再拾取」。 -func reserve_click_item(iid: int) -> void: - _clear_reserved() - _reserved_mode = ReservedMode.CLICK_ITEM - _iid_reserved = iid - -# __ReserveClickGround(PythonPlayerInput.cpp:836)——预约「延时 0.1s 后走到该点」。 -func reserve_click_ground(world_pos: Vector3) -> void: - _clear_reserved() - _reserved_mode = ReservedMode.CLICK_POSITION - _ppos_reserved = world_pos - _reserved_delay = 0.1 - -# __ReserveUseSkill(PythonPlayerInput.cpp:854)——预约「进射程再施法」。range > 100 时 -# 先减 10(参考端同款收边,避免边界处反复趋近)。slot 为全局快捷栏索引。 -func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void: - _clear_reserved() - _reserved_mode = ReservedMode.USE_SKILL - _vid_reserved = vid - _skill_slot_reserved = slot - if range_cm > 100.0: - range_cm -= RESERVE_SKILL_RANGE_TRIM_CM - _skill_range_reserved = range_cm - -# __OnPressItem(PythonPlayerInput.cpp)——点地面掉落物:清预约 + auto-attack,在拾取 -# 距离内直接 CG_ITEM_PICKUP + 停,否则预约走过去。 -func pick_ground_item(iid: int) -> void: - if _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null: - return - _clear_reserved() - _auto_attack_vid = 0 - var item_pos = _ground_item_world_pos(iid) - if item_pos == null: - return - if pc.player.global_position.distance_to(item_pos) * CM <= CLICK_ITEM_PICKUP_CM: - _send_state(FUNC_WAIT, 0, pc.player.position) - var gi = pc.ground_items if (pc and "ground_items" in pc) else null - if gi != null and gi.has_method("try_pickup_vid"): - gi.try_pickup_vid(iid) - elif client.has_method("pickup_item"): - client.pickup_item(iid) - pc.set("frozen", false) - else: - reserve_click_item(iid) - -# IS_HUGE_RACE(vnum)(参考端是一张未随源码发布的硬编码 vnum 白名单)——用 mob_proto -# 的体型 / 阶级位近似:MOBSIZE_HUGE(3) 或 BOSS(4) 以上视为巨型。查不到 proto -> false。 -func _is_huge_race(race: int) -> bool: - if race <= 0 or proto == null or not proto.has_method("mob"): - return false - var m: Dictionary = proto.mob(race) - if m.is_empty(): - return false - return int(m.get("size", 0)) >= HUGE_RACE_SIZE or int(m.get("rank", 0)) >= HUGE_RACE_RANK - -# CActorInstance::CanChangeTarget(ActorInstanceFly.cpp:111):当前动作含 -# WARP/FLY/EFFECT_TO_TARGET 事件时不能换目标。get_events() 是当前 .msa 的原始事件表; -# 无动作资源时沿用参考端「没有 motion data -> 需要飞行目标动作」的保守结果。 -func _motion_blocks_target_change() -> bool: - var view := _combat_view() - var anim = view.get("anim") if view and "anim" in view else null - if anim and anim.has_method("get_events"): - var events: Variant = anim.call("get_events") - if events is Array: - for event in events: - if event is Dictionary and int(event.get("type", -1)) in [6, 9, 10]: - return true - return false - # The reference returns true when the current motion data is unavailable. - return _using_skill or combo_motion_mode == MOTION_MODE_FISHING - -func _can_change_target() -> bool: - return not _motion_blocks_target_change() - -func _is_targetable_instance(e: Dictionary) -> bool: - if e.is_empty(): - return false - var target := e.duplicate() - target["kind"] = _entity_kind(e) - # This 40250 build's __MainCanSeeHiddenThing() returns false. Keep the - # context explicit so a future detector/GM rule cannot accidentally turn - # stealth into a client-only visual check. - return EntityRules.can_pick_instance(target, {"can_see_hidden": false}) - -# __ChangeTargetToPickedInstance(PythonPlayerInput.cpp:186)→ SetTarget(:112): -# 先过 CanChangeTarget,再过同目标时间窗和 IsTargetableInstance/CanPickInstance; -# 失败统一发 CG_TARGET(0),成功才发 CG_TARGET(vid)。force_change 对应 SetTarget -# 的 bForceChange(点击/脚本为 TRUE,受击自动换目标为 FALSE)。 -func _change_target_to_picked_instance(vid: int, force_change := true) -> bool: - if not _can_change_target(): - return false - var now := _now() - if vid == _target_vid: - # SetTarget(main VID) 是清除主角自选目标的特殊分支;其它重复点击 - # 只延长 1 秒保护窗,不重复发送 CG_TARGET。 - if vid == _main_vid: - _clear_target_with_packet(true) - return false - _target_end_time = now + 1.0 - return true - if not force_change and _target_end_time > now: - return false - _target_end_time = now + (2.0 if force_change else 1.0) - - var e: Dictionary = client.get_entity(vid) - var kind := _entity_kind(e) - # __ChangeTargetToPickedInstance is only used by the combat/PC target - # paths here; NPC/warp interaction is owned by __OnClickActor's click-NPC - # branch. The targetability gate still runs before this higher-level kind - # filter, matching SetTarget's CanPickInstance contract without turning an - # NPC click into a combat target. - if kind not in [EntityRules.KIND_PC, EntityRules.KIND_MONSTER, - EntityRules.KIND_STONE, EntityRules.KIND_WOODEN_DOOR] \ - or not _is_targetable_instance(e): - _clear_target_with_packet(true) - return false - _target_vid = vid - client.set_target(vid) - target_changed.emit(vid) - _refresh_target_hud() - return true - -# Public test/automation adapter for the same target path used by a real -# pointer click. It keeps all combat eligibility checks and sends the normal -# CG_TARGET through _change_target_to_picked_instance; it does not synthesize -# a hit or alter EntityStore state. -func select_target(vid: int) -> bool: - if _observer_mode or client == null or not client.has_method("is_in_game") or not client.is_in_game(): - return false - _attack_cd = 0.0 - return _change_target_to_picked_instance(vid) - -# --- §3.4 扇形 / 圆形多目标 fly-targeting(PythonPlayerSkill.cpp:680-746)---------- - -# 主角 → 某点的水平方位角(度)。扇形过滤 / 随机点抖动只比较角差,与坐标系无关, -# 只要 target 与 each 用同一函数(对齐 NEW_Get*InFanRange 里 fDirRot 的比较)。 -func _bearing_deg(from_world: Vector3, to_world: Vector3) -> float: - var d := to_world - from_world - return rad_to_deg(atan2(d.x, -d.z)) - -func _dir_from_bearing(deg: float) -> Vector3: - var r := deg_to_rad(deg) - return Vector3(sin(r), 0.0, -cos(r)) - -# 场景节点优先(已在 world 帧),否则实体 pos 过 MapCoord。 -func _entity_world_pos(vid: int): - if net_world and net_world.has_method("node_for"): - var n: Node3D = net_world.node_for(vid) - if n != null: - return n.global_position - var e: Dictionary = client.get_entity(vid) - if e.is_empty() or not (e.get("pos", null) is Vector3): - return null - return MapCoord.to_world(e.get("pos")) - -# NEW_GetInstanceVectorInCircleRange(InstanceBaseBattle.cpp:243)——射程内全部可攻击 -# 实体,按距离升序。 -func _instances_in_circle_range(range_cm: float) -> Array: - var out := [] - if client == null or pc == null or pc.player == null: - return out - var main_pos: Vector3 = pc.player.global_position - var reach_m := range_cm / CM - for e in client.get_entities(): - var vid := int(e.get("vid", 0)) - if vid == 0 or vid == _main_vid or not _is_attackable(e): - continue - var wp = _entity_world_pos(vid) - if wp == null: - continue - var dist_m: float = main_pos.distance_to(wp) - if dist_m <= reach_m: - out.append({"vid": vid, "dist_m": dist_m, "pos": wp}) - out.sort_custom(func(a, b): return a["dist_m"] < b["dist_m"]) - return out - -# NEW_GetInstanceVectorInFanRange(InstanceBaseBattle.cpp:192)——先按圆形取,再按 -# 「随距离收窄的半角」过滤:fHalfFanRot = (MAX-MIN) - RPM*min(dist,1000) + MIN。 -func _instances_in_fan_range(range_cm: float, target_vid: int) -> Array: - var circle := _instances_in_circle_range(range_cm) - var tp = _entity_world_pos(target_vid) - if tp == null: - return [] - var main_pos: Vector3 = pc.player.global_position - var dst_rot := deg_to_rad(_bearing_deg(main_pos, tp)) - var rpm := (FAN_HALF_ROT_MAX - FAN_HALF_ROT_MIN) / FAN_HALF_ROT_MIN_DISTANCE - var kept := [] - for c in circle: - var each_dist_cm: float = min(c["dist_m"] * CM, FAN_HALF_ROT_MIN_DISTANCE) - var half_fan := (FAN_HALF_ROT_MAX - FAN_HALF_ROT_MIN) - rpm * each_dist_cm + FAN_HALF_ROT_MIN - var each_rot := deg_to_rad(_bearing_deg(main_pos, c["pos"])) - if absf(angle_difference(dst_rot, each_rot)) <= deg_to_rad(half_fan): - kept.append(c) - return kept - -# NEW_GetRandomPositionInFanRange(InstanceBaseBattle.cpp:124)——目标方位 ±10° 抖动、 -# 沿该方向 8000cm 取点(参考端绕 Z 转 (0,8000,0);这里在 world 帧等价展开)。 -func _random_pos_in_fan_range(target_vid: int) -> Vector3: - var main_pos: Vector3 = pc.player.global_position - var tp = _entity_world_pos(target_vid) - var base_bearing: float = _bearing_deg(main_pos, tp) if tp != null else rad_to_deg(pc.player.rotation.y) - var rot := base_bearing + randf_range(-FAN_RANDOM_JITTER_DEG, FAN_RANDOM_JITTER_DEG) - return main_pos + _dir_from_bearing(rot) * (FAN_RANDOM_REACH_CM / CM) - -# __UseSkill(PythonPlayerSkill.cpp:684-746)里 GetTargetCount > 0 时的扇形 / 圆形补目标。 -# 主目标的 CG_FLY_TARGETING 已由 M2Client::use_skill 发过,这里只补「附加」目标—— -# 命中范围内的其他敌人(AddFlyTargetInstance)+ 名额不足时的随机落点(AddFlyTargetPosition, -# vid = 0)。返回补发的附加 fly-target 包数量。 -func send_fly_targeting(target_vid: int, range_cm: float, max_count: int, shape: int) -> int: - if client == null or not client.has_method("add_fly_targeting"): - return 0 - if max_count <= 1 or target_vid == 0 or shape == FlyShape.SINGLE: - return 0 - if pc == null or pc.player == null: - return 0 - # __GetSkillTargetRange:基础射程再叠加主角 POINT_BOW_DISTANCE(×100,参考端此处 - # 与 __GetBowRange 的「不 ×100」不一致,照抄各自的写法)。 - var reach_cm := range_cm + _bow_distance_bonus * 100.0 - var cands: Array = _instances_in_fan_range(reach_cm, target_vid) if shape == FlyShape.FAN \ - else _instances_in_circle_range(reach_cm) - var count := 1 - var extras := 0 - for c in cands: - if count >= max_count: - break - var vid := int(c["vid"]) - if vid == target_vid: - continue - if bool(client.get_entity(vid).get("dead", false)): - continue - var sc := _server_xy(c["pos"]) - client.add_fly_targeting(vid, int(sc.x), int(sc.y)) - count += 1 - extras += 1 - while count < max_count: - var sc2 := _server_xy(_random_pos_in_fan_range(target_vid)) - client.add_fly_targeting(0, int(sc2.x), int(sc2.y)) - count += 1 - extras += 1 - return extras - -# NEW_AttackToDestInstanceDirection → CActorInstance::InputComboAttackCommand -# (ActorInstanceBattle.cpp:157):一次攻击输入按连击段表 + `.msa` ComboInputData 推进。 -func _do_attack_swing(tnode: Node3D, _rv: Dictionary) -> void: - if tnode != null and is_instance_valid(tnode) and pc and pc.player: - var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player) - to.y = 0.0 - if to.length() > 0.01: - pc.player.rotation.y = atan2(to.x, to.z) - _was_moving = false - - var vec := _combo_vec() - if vec.is_empty(): - # 没有连击段表(headless / 缺资源 / 该 mode×type 未登记)→ 对齐 40250 __CanInputNormalAttackCommand - if _is_attacking and _attack_cd > 0.0: - var md := _motion_data() - var dur := float(md.get("duration", attack_period)) - if dur <= 0.0: - dur = attack_period - if _attacking_elapsed() <= dur * 0.9: - return - _emit_swing(NAME_NORMAL_ATTACK, false) - return - - # m_isPreInput 挂起时忽略本次输入(等 ComboProcess 到点自动触发)。 - if _is_pre_input: - return - if _combo_index == 0: - _run_next_combo(vec) - return - - var t := _attacking_elapsed() - var md := _motion_data() - if bool(md.get("has_combo_input", false)): - var t_start := float(md.get("pre_input_time", 0.0)) # GetComboInputStartTime - var t_next := float(md.get("direct_input_time", 0.0)) # GetNextComboTime - var t_end := float(md.get("input_limit_time", 0.0)) # GetComboInputEndTime - if t > t_end: - if _is_bow_mode(): - _is_next_pre_input = true - return - if t > t_next: - _run_next_combo(vec) - elif t > t_start: - _is_pre_input = true - else: - var dur := float(md.get("duration", attack_period)) - if t > dur * 0.9: - _run_next_combo(vec) - -# CActorInstance::__RunNextCombo(ActorInstanceBattle.cpp:256) -func _run_next_combo(vec: PackedInt32Array) -> void: - _combo_index += 1 - var arr_idx := _combo_index - 1 - if arr_idx < 0 or arr_idx >= vec.size(): - _combo_index -= 1 # 段号越界:参考端 TraceError + return,不推进 - return - _emit_swing(int(vec[arr_idx]), _combo_index > 1) - if _combo_index == vec.size(): - _on_end_combo() - -# __OnEndCombo:骑乘时索引回 1,否则不复位(复位在动作回 Wait 时的 __ClearCombo)。 -func _on_end_combo() -> void: - if combo_motion_mode >= MOTION_MODE_HORSE_FIRST and combo_motion_mode <= MOTION_MODE_HORSE_LAST: - _combo_index = 1 - -# __ClearCombo:动作回到 Wait 时清空连击状态。 -func _clear_combo() -> void: - _combo_index = 0 - _is_pre_input = false - _is_next_pre_input = false - _is_attacking = false - _hit_windows = [] - _hit_dedup.clear() - -# CActorInstance::ComboProcess(ActorInstanceBattle.cpp:213)——每帧推进挂起输入 / 收尾。 -func _combo_process() -> void: - if _combo_index != 0: - var t := _attacking_elapsed() - var md := _motion_data() - var has_ci := bool(md.get("has_combo_input", false)) - var full := float(md.get("duration", attack_period)) - if full <= 0.0: - full = attack_period - if _is_pre_input: - var t_next := float(md.get("direct_input_time", 0.0)) if has_ci else full * 0.9 - if t > t_next: - _is_pre_input = false - var vec := _combo_vec() - if not vec.is_empty(): - _run_next_combo(vec) - return - # 参考端这里没有「超过整段时长就清连击」:段号在动作回 Wait 时由 __SetMotion 尾 - # (_on_motion_bound)清零。 - else: - _is_pre_input = false - if _is_next_pre_input and not _using_skill: - var vec := _combo_vec() - if not vec.is_empty(): - _run_next_combo(vec) - _is_next_pre_input = false - -# OnAttack 状态包 + 缓存命中窗 + 播动作 + 刷新节奏。§3.5 -# OnHit(SendAttackPacket)不在这里:它只能由 `_attack_process()` 按有效 `.msa` -# AttackingData/HitDataContainer 的几何判定触发(1:1 对齐 `isValidAttacking` → -# `__NormalAttackProcess` → `__ProcessDataAttackSuccess` → `OnHit`)。40250 在当前 -# 动作没有有效 `m_pkCurRaceMotionData` 或 `AttackingData` 时不会合成一包 CG_ATTACK。 -func _emit_swing(motion_index: int, is_combo: bool) -> void: - # 40250 CActorInstance::ComboAttack 的顺序是:先 InterceptOnceMotion(绑定 - # motion、清 m_HitDataMap、触发弓箭 OnSetFlyTarget),再 __OnAttack(发送 - # FUNC_COMBO)。此前这里先发状态包再绑定动作,服务端收到状态时本地命中窗尚未 - # 建立,和参考端的动作/网络事件顺序相反。 - var view := _combat_view() - var motion_played := false - if view and view.has_method("play_attack_motion"): - # 旧视图/测试桩的方法没有返回值;只有新版 PlayerView 明确返回 true 时才认为 - # 已成功绑定 .msa,其余情况继续走 set_anim_state 兼容兜底。 - motion_played = view.play_attack_motion(combo_motion_mode, motion_index, _atk_speed_factor) == true - if not motion_played and view and view.has_method("set_anim_state"): - view.set_anim_state("combo" if is_combo else "attack") - _begin_hit_windows() - # 弓动作起手 = CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget。 - # OnShoot(uSkill) 仍等 `.msa` FLY 帧,由 game_scene 入队发送。§3.6 - if _is_bow_mode(): - var why := _can_shot() - if why != "": - _emit_cannot(why) - else: - _send_fly_target() - bow_shot_fired.emit(_swing_skill) - # OnAttack → SendCharacterStatePacket(FUNC_COMBO, wMotionIndex):这一包带连击段号。 - _send_state(FUNC_COMBO, motion_index, pc.player.position) - # 节奏来自动作数据(.msa ComboInputData.DirectInputTime / next_combo), - # 除以攻速系数;缺组时退化为 DEFAULT_ATTACK_PERIOD。§3.3 - attack_period = _current_attack_period() - _attack_cd = attack_period - -# CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget(PythonPlayerEventHandler.cpp:235): -# SendFlyTargetingPacket(target->GetVirtualID(), target->OnGetFlyTargetPosition()) -# OnGetFlyTargetPosition(ActorInstanceFly.cpp:4)返回目标包围球中心的世界坐标;其平面 -# 坐标与角色脚点一致,而 CG_FLY_TARGETING 只带平面坐标,故直接取目标节点位置换算 server-XY。 -func _send_fly_target() -> void: - var vid := _target_vid if _target_vid != 0 else _vid_reserved - if vid == 0 or client == null or net_world == null: - return - var tnode: Node3D = net_world.node_for(vid) - if tnode == null: - return - # CNormalBowAttack_FlyEventHandler sends OnGetFlyTargetPosition(), which is - # the model body-sphere centre rather than the actor feet/root position. - var sp := _server_xy(FlyTargetAnchor.fly_target_position(tnode)) - # 40250 uses SendFlyTargetingPacket here. CG_ADD_FLY_TARGETING is reserved - # for the extra targets in PythonPlayerSkill::__UseSkill. - if client.has_method("fly_targeting"): - client.fly_targeting(vid, int(sp.x), int(sp.y)) - elif client.has_method("add_fly_targeting"): - # Compatibility for headless/test clients predating the split API. - client.add_fly_targeting(vid, int(sp.x), int(sp.y)) - -# 挥击起播:从刚绑定的 `.msa` 取 TMotionAttackData(THitDataContainer + iMotionType + -# iHitLimitCount + fInvisibleTime + iHittingType + fStiffenTime + fExternalForce), -# m_kCurMotNode.fStartTime = 本地时间,清空上一击的 m_HitDataMap。 -func _begin_hit_windows() -> void: - var md := _motion_data() - _has_attack_motion_data = bool(md.get("has_attacking_data", false)) - var w: Variant = md.get("hit_windows", []) - _hit_windows = w if w is Array else [] - _hit_motion_type = int(md.get("motion_type", MOTION_TYPE_NONE)) - _hit_limit_count = int(md.get("hit_limit_count", 0)) - _hit_invisible_time = float(md.get("invisible_time", 0.0)) - _hit_type = int(md.get("hitting_type", HIT_TYPE_NONE)) - _hit_stiffen = float(md.get("stiffen_time", 0.0)) - _hit_external_force = float(md.get("external_force", 0.0)) - _hit_dedup.clear() - _motion_start_t = _local_time - _is_attacking = true - -# ActorInstanceMotionEvent.cpp::ProcessMotionEventSpecialAttacking:事件帧只负责 -# 建立 / 替换当前 actor 的 SplashArea;真正的目标遍历发生在下一次 Update 的 -# AttackProcess 中。这样 DuringTime、EnableHitProcess、uSkill 和 CollisionData -# 都属于同一个动作事件,而不是被重新合成成一个普通攻击窗。 -func start_motion_splash(event: Dictionary) -> void: - if int(event.get("type", -1)) != 4 or pc == null or pc.player == null: - return - _motion_splash = MotionSplash.begin(_main_vid, _swing_skill, - _world_pos(pc.player), _player_yaw(), event, _local_time) - -# CActorInstance::MotionEventProcess / InstanceBase::AttackProcess runs for -# every visible actor. Remote actors use the same collision data, but their -# event handler is not CPythonPlayerEventHandler: the local client must never -# synthesize a CG_ATTACK from a remote splash hit. -func start_remote_motion_splash(owner_vid: int, event: Dictionary) -> void: - if int(event.get("type", -1)) != 4 or owner_vid <= 0 or owner_vid == _main_vid: - return - if net_world == null or client == null: - return - var actor: Node3D = net_world.node_for(owner_vid) if net_world.has_method("node_for") else null - if not is_instance_valid(actor): - return - var owner_e: Dictionary = client.get_entity(owner_vid) if client.has_method("get_entity") else {} - if owner_e.is_empty() or bool(owner_e.get("dead", false)) or bool(owner_e.get("in_safe", false)): - return - var skill := int(event.get("skill_index", actor.get_meta("motion_skill_index", 0))) - _remote_motion_splashes[owner_vid] = MotionSplash.begin(owner_vid, skill, - _world_pos(actor), actor.rotation.y, event, _local_time) - -func _remote_actor_node(vid: int) -> Node3D: - if vid == _main_vid and pc != null and is_instance_valid(pc.player): - return pc.player - if net_world != null and net_world.has_method("node_for"): - return net_world.node_for(vid) - return null - -func _remote_entity(vid: int) -> Dictionary: - if vid == _main_vid: - return _main_entity() - return client.get_entity(vid) if client and client.has_method("get_entity") else {} - -func _is_attackable_from(attacker: Dictionary, victim: Dictionary) -> bool: - if attacker.is_empty() or victim.is_empty() or bool(victim.get("dead", false)): - return false - var self_e := attacker.duplicate() - self_e["kind"] = _entity_kind(attacker) - var victim_e := victim.duplicate() - victim_e["kind"] = _entity_kind(victim) - return EntityRules.is_attackable_instance(self_e, victim_e, _attack_ctx(self_e)) - -func _remote_splash_attack_process() -> void: - if _remote_motion_splashes.is_empty() or client == null or net_world == null: - return - for owner_vid in _remote_motion_splashes.keys().duplicate(): - var area: Dictionary = _remote_motion_splashes.get(owner_vid, {}) - if not MotionSplash.is_active(area, _local_time): - _remote_motion_splashes.erase(owner_vid) - continue - var attacker_vid := int(owner_vid) - var attacker := _remote_entity(attacker_vid) - var attacker_node := _remote_actor_node(attacker_vid) - if attacker.is_empty() or not is_instance_valid(attacker_node) \ - or bool(attacker.get("dead", false)) or bool(attacker.get("in_safe", false)): - _remote_motion_splashes.erase(attacker_vid) - continue - var attacker_cm := _actor_cm(_world_pos(attacker_node)) - for victim in client.get_entities(): - if not victim is Dictionary: - continue - var victim_vid := int(victim.get("vid", 0)) - if victim_vid <= 0 or victim_vid == attacker_vid or not _is_attackable_from(attacker, victim): - continue - var victim_node := _remote_actor_node(victim_vid) - if not is_instance_valid(victim_node): - continue - if _now() < float(_victim_invisible_until.get(victim_vid, 0.0)): - continue - var victim_cm := _actor_cm(_world_pos(victim_node)) - var dx := victim_cm.x - attacker_cm.x - var dz := victim_cm.z - attacker_cm.z - if dx * dx + 2.0 * dz * dz >= 1000.0 * 1000.0: - continue - if int(area.get("attack_type", 0)) == MotionSplash.ATTACK_TYPE_SNIPE \ - and _entity_kind(victim) == EntityRules.KIND_PC \ - and victim_vid != int(attacker_node.get_meta("fly_target_vid", 0)): - continue - var defenders := _defending_spheres(victim_vid, victim_node, victim_cm) - if defenders.is_empty(): - continue - var result := MotionSplash.try_hit(area, victim_vid, defenders, _local_time) - if not bool(result.get("hit", false)): - continue - _process_attack_success(victim, victim_vid, victim_node, - result.get("hit_position", (attacker_cm + victim_cm) * 0.5), - HitCollision.is_huge_race(int(victim.get("race", 0))), - int(area.get("hitting_type", HIT_TYPE_NONE)), - float(area.get("stiffen_time", 0.0)), - float(area.get("invisible_time", 0.0)), - float(area.get("external_force", 0.0)), - int(area.get("skill", 0)), false, attacker_node, attacker_vid) - -# CInstanceBase::AttackProcess -> CActorInstance::__SplashAttackProcess。 -# 40250 只在主角色的本地输入处理器里把 isEnableHitProcess=true 的命中转成 -# CG_ATTACK;EnableHitProcess=false 仍然执行命中特效 / 硬直 / 击退,但不伪造上行包。 -func _splash_attack_process(_dt: float) -> void: - if _motion_splash.is_empty(): - return - if not MotionSplash.is_active(_motion_splash, _local_time): - _motion_splash.clear() - return - if client == null or not client.is_in_game() or pc == null or pc.player == null \ - or net_world == null: - return - if bool(_main_entity().get("in_safe", false)): - return - var attacker_pos := _actor_cm(_world_pos(pc.player)) - var dx_limit := 1000.0 # __SplashAttackProcess: 1000cm broad-phase limit - for e in client.get_entities(): - var vid := int(e.get("vid", 0)) - if vid == 0 or vid == _main_vid: - continue - if not _is_attackable(e) or bool(e.get("dead", false)) or bool(e.get("in_safe", false)): - continue - var vnode: Node3D = net_world.node_for(vid) - if vnode == null or not is_instance_valid(vnode): - continue - # AttackingProcess 的 __isInvisible() 门;命中的受击方在 - # __ProcessDataAttackSuccess 中写入 m_fInvisibleTime。 - if _now() < float(_victim_invisible_until.get(vid, 0.0)): - continue - var victim_pos := _actor_cm(_world_pos(vnode)) - # 参考端这里的 v3Distance 是 (dx, dz, dz),保持其出货行为。 - var dx := victim_pos.x - attacker_pos.x - var dz := victim_pos.z - attacker_pos.z - if dx * dx + 2.0 * dz * dz >= dx_limit * dx_limit: - continue - # ATTACK_TYPE_SNIPE 对 PC 只接受当前飞行目标;PVE 目标不受这条 - # PC 专用限制影响。 - if int(_motion_splash.get("attack_type", 0)) == MotionSplash.ATTACK_TYPE_SNIPE \ - and _entity_kind(e) == EntityRules.KIND_PC and vid != _target_vid: - continue - var defenders := _defending_spheres(vid, vnode, victim_pos) - if defenders.is_empty(): - continue - var result: Dictionary = MotionSplash.try_hit(_motion_splash, vid, defenders, _local_time) - if not bool(result.get("hit", false)): - continue - var hit_pos: Vector3 = result.get("hit_position", (attacker_pos + victim_pos) * 0.5) - _process_attack_success(e, vid, vnode, hit_pos, - HitCollision.is_huge_race(int(e.get("race", 0))), - int(_motion_splash.get("hitting_type", HIT_TYPE_NONE)), - float(_motion_splash.get("stiffen_time", 0.0)), - float(_motion_splash.get("invisible_time", 0.0)), - float(_motion_splash.get("external_force", 0.0)), - int(_motion_splash.get("skill", 0)), - bool(_motion_splash.get("enable_hit_process", true))) - -# CInstanceBase::AttackProcess(InstanceBaseBattle.cpp:413)-> CActorInstance::AttackingProcess -> -# __NormalAttackProcess(ActorInstanceCollisionDetection.cpp:333):挥击动作播放中,每帧用 .msa -# 刀尖扫掠球(半径 20)对每个可攻击实体的 .msm 防御球做 Z 圆柱检测,命中即 -# __ProcessDataAttackSuccess(硬直 / 击退 / 特效 / 受击反应 / OnHit)。 -func _attack_process(dt: float) -> void: - # CActorInstance::isValidAttacking() rejects a missing current motion or a motion - # without AttackingData before __NormalAttackProcess reads HitDataContainer. - # An existing AttackingData with zero windows also produces no local hit; it is - # intentionally different from the old synthetic forward-cone fallback. - if not _has_attack_motion_data or _hit_windows.is_empty() or client == null or not client.is_in_game(): - return - if pc == null or pc.player == null or net_world == null: - return - _atk_frame += 1 - var t := _attacking_elapsed() - var eff_dt := dt * _atk_speed_factor - # 动作已越过所有命中窗 -> 收起(等同 !CanCheckAttacking) - var latest_end := 0.0 - for w in _hit_windows: - latest_end = maxf(latest_end, float(w.get("end_time", 0.0))) - if t - eff_dt > latest_end: - _hit_windows = [] - _hit_dedup.clear() - return - # CheckAttacking:自己在安全区直接不判定 - if bool(_main_entity().get("in_safe", false)): - return - var a_pos := _actor_cm(_world_pos(pc.player)) - var yaw := _player_yaw() - for e in client.get_entities(): - var vid := int(e.get("vid", 0)) - if vid == 0 or vid == _main_vid: - continue - if not _is_attackable(e) or bool(e.get("dead", false)) or bool(e.get("in_safe", false)): - continue - var vnode: Node3D = net_world.node_for(vid) - if vnode == null: - continue - # AttackingProcess:rVictim.__isInvisible() -> 跳过 - if _now() < float(_victim_invisible_until.get(vid, 0.0)): - continue - _normal_attack_process(e, vid, vnode, a_pos, yaw, t, dt) - -# Godot 世界 m -> 角色世界 cm(X, Y, 高度) -static func _actor_cm(g: Vector3) -> Vector3: - return Vector3(g.x * CM, -g.z * CM, g.y * CM) - -static func _godot_m(a: Vector3) -> Vector3: - return Vector3(a.x / CM, a.z / CM, -a.y / CM) - -# 模型本地 cm 按 Godot yaw 转到角色世界朝向(= Metin2 的 Z 轴旋转) -static func _rot_cm(v: Vector3, yaw: float) -> Vector3: - var c := cos(yaw) - var s := sin(yaw) - return Vector3(v.x * c - v.y * s, v.x * s + v.y * c, v.z) - -func _attack_reach_scale() -> float: - # CActorInstance::__GetReachScale:当前视图持有 AFFECT_GEOMGYEONG - # 的 m_fReachScale;没有模型(headless/旧测试桩)时保持默认 1.0。 - if player_view != null and player_view.has_method("weapon_reach_scale"): - return maxf(float(player_view.call("weapon_reach_scale")), 0.0) - return 1.0 - -func _attack_sample_positions(last_pos: Vector3, pos: Vector3, yaw: float, - actor_pos: Vector3) -> Dictionary: - # ActorInstanceCollisionDetection.cpp 只缩放刀尖球从 last 到 current 的 - # 位移向量,防御球的 last 点和攻击者位置不缩放。 - var scaled_pos := last_pos + (pos - last_pos) * _attack_reach_scale() - return { - "last": _rot_cm(last_pos, yaw) + actor_pos, - "pos": _rot_cm(scaled_pos, yaw) + actor_pos, - } - -func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vector3, yaw: float, - t: float, dt: float) -> void: - var v_pos := _actor_cm(_world_pos(vnode)) - var huge := HitCollision.is_huge_race(int(e.get("race", 0))) - # 出货代码 v3Distance = (dX, dZ, dZ):平面 Y 不参与、高度差算两次(1:1 保留) - var dx := v_pos.x - a_pos.x - var dz := v_pos.z - a_pos.z - var dist2 := dx * dx + 2.0 * dz * dz - var lim := HIT_DISTANCE_HUGE_CM if huge else HIT_DISTANCE_CM - if dist2 >= lim * lim: - return - var spheres := _defending_spheres(vid, vnode, v_pos) - # CActorInstance::__NormalAttackProcess only iterates the victim's actual - # m_DefendingPointInstanceList. A node without parsed .msm collision data is - # not a valid collision target; do not invent a capsule or a proximity hit. - if spheres.is_empty(): - return - var eff_dt := dt * _atk_speed_factor - for wi in _hit_windows.size(): - var w: Dictionary = _hit_windows[wi] - if t < float(w.get("start_time", 0.0)) or (t - eff_dt) > float(w.get("end_time", 0.0)): - continue - # m_HitDataMap:同窗已命中过 -> COMBO 永不再判;其余要等 fInvisibleTime 过去 - var m: Dictionary = _hit_dedup.get(wi, {}) - if m.has(vid) and (_hit_motion_type == MOTION_TYPE_COMBO or float(m[vid]) > _local_time): - continue - var samples: Variant = w.get("samples", []) - var hit_detected := false - if samples is Array and not (samples as Array).is_empty(): - for smp in samples: - var st := float(smp.get("time", 0.0)) - if st < t - eff_dt or st > t: - continue - var p: Vector3 = smp.get("pos", Vector3.ZERO) - var lp: Vector3 = smp.get("last_pos", p) - var sweep := _attack_sample_positions(lp, p, yaw, a_pos) - var d_pos: Vector3 = sweep["pos"] - var d_last: Vector3 = sweep["last"] - for sp in spheres: - if HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM, - sp["last"], sp["pos"], float(sp["radius"])): - hit_detected = true - break - if hit_detected: - break - if hit_detected: - if not _register_hit(wi, vid): - return - var hit_pos: Vector3 = (a_pos + (spheres[0]["pos"] as Vector3)) * 0.5 if huge and not spheres.is_empty() else (a_pos + v_pos) * 0.5 - _process_attack_success(e, vid, vnode, hit_pos, huge) - return - -# 受击方 m_DefendingPointInstanceList:模型本地球心按受击方朝向旋转 + 受击方位置; -# 若挂骨骼(isAttached)且包含动画与骨骼姿态,使用实时骨骼位姿(matBone * matWorld,ActorInstanceCollisionDetection.cpp:66); -# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 防御球时返回空,保持 -# __NormalAttackProcess 对空 m_DefendingPointInstanceList 的无命中结果。 -func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array: - if vnode == null or not vnode.has_method("get_defending_spheres"): - return [] - var raw: Array = vnode.get_defending_spheres() - if raw.is_empty(): - return [] - var vyaw := vnode.rotation.y - var anim: Node = vnode.get("anim") if vnode else null - var model: Node3D = vnode.get("model") as Node3D if vnode else null - var cur: Array = [] - for sp in raw: - var spos: Vector3 = sp.get("pos", Vector3.ZERO) - var bone: String = sp.get("bone", "") - var sphere_pos_cm := Vector3.ZERO - var has_bone_transform := false - if bone != "" and anim != null and anim.has_method("get_effect_bone_pose") and model != null: - var pose: Dictionary = anim.get_effect_bone_pose(bone) - if pose.has("transform"): - var bone_xform: Transform3D = pose["transform"] - # 40250 ActorInstanceCollisionDetection.cpp:40: matDefending = matBone * matWorld, D3DXVec3TransformCoord - # spos in .msm is in cm, bone_xform / model transform is in meters - var world_m: Vector3 = model.global_transform * (bone_xform * (spos / 100.0)) - sphere_pos_cm = _actor_cm(world_m) - has_bone_transform = true - if not has_bone_transform: - sphere_pos_cm = _rot_cm(spos, vyaw) + v_pos - cur.append(sphere_pos_cm) - var prev: Dictionary = _def_last.get(vid, {}) - var has_prev := int(prev.get("frame", -1)) == _atk_frame - 1 and (prev.get("pos", []) as Array).size() == cur.size() - var out: Array = [] - for i in raw.size(): - out.append({"radius": float(raw[i].get("radius", 0.0)), "pos": cur[i], - "last": prev["pos"][i] if has_prev else cur[i]}) - _def_last[vid] = {"frame": _atk_frame, "pos": cur} - return out - -# m_HitDataMap 登记(__NormalAttackProcess 命中分支):窗第一次命中 -> 新建 {vid: 本地时间 + -# fInvisibleTime} 直接处理;否则 map::insert(已在就不覆盖),NORMAL/COMBO 超 16、SKILL 超 -# iHitLimitCount -> FALSE。 -func _register_hit(wi: int, vid: int) -> bool: - if not _hit_dedup.has(wi): - _hit_dedup[wi] = {vid: _local_time + _hit_invisible_time} - return true - var m: Dictionary = _hit_dedup[wi] - if not m.has(vid): - m[vid] = _local_time + _hit_invisible_time - var cap := _hit_limit_count - if _hit_motion_type == MOTION_TYPE_COMBO or _hit_motion_type == MOTION_TYPE_NORMAL: - cap = COMBO_HIT_LIMIT - return m.size() <= cap - -# CActorInstance::__ProcessDataAttackSuccess(ActorInstanceBattle.cpp) -func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Vector3, huge: bool, - hit_type_override: int = -1, stiffen_override: float = -1.0, - invisible_override: float = -1.0, external_force_override: float = -1.0, - skill_override: int = -1, send_packet: bool = true, - attacker_override: Node3D = null, attacker_vid: int = 0) -> void: - var old_hit_type := _hit_type - var old_hit_stiffen := _hit_stiffen - var old_hit_invisible := _hit_invisible_time - var old_hit_external_force := _hit_external_force - var old_swing_skill := _swing_skill - if hit_type_override >= 0: - _hit_type = hit_type_override - if stiffen_override >= 0.0: - _hit_stiffen = stiffen_override - if invisible_override >= 0.0: - _hit_invisible_time = invisible_override - if external_force_override >= 0.0: - _hit_external_force = external_force_override - if skill_override >= 0: - _swing_skill = skill_override - if _hit_type == HIT_TYPE_NONE: - _hit_type = old_hit_type - _hit_stiffen = old_hit_stiffen - _hit_invisible_time = old_hit_invisible - _hit_external_force = old_hit_external_force - _swing_skill = old_swing_skill - return - # InsertDelay(fStiffenTime):命中硬直属于真正的攻击者;远端攻击不能 - # 锁住本地主角的输入时间,只能锁远端 actor 的动作。 - var attacker_node: Node3D = attacker_override if is_instance_valid(attacker_override) else pc.player - if attacker_vid == 0 or attacker_vid == _main_vid: - _delay = _hit_stiffen - if player_view and player_view.has_method("insert_delay"): - player_view.insert_delay(_hit_stiffen) - elif is_instance_valid(attacker_node) and attacker_node.has_method("insert_delay"): - attacker_node.insert_delay(_hit_stiffen) - var a_g: Vector3 = _world_pos(attacker_node) - var v_g: Vector3 = _world_pos(vnode) - var to := v_g - a_g - # __PushCircle + IncreaseExternalForce - if _can_push(e, vid, huge, attacker_vid) and _hit_external_force > 0.0 and net_world.has_method("push_victim"): - net_world.push_victim(vid, Vector2(to.x, -to.z).normalized(), _hit_external_force) - # 受击方 m_fInvisibleTime = 现在 + fInvisibleTime;受击方 InsertDelay - _victim_invisible_until[vid] = _now() + _hit_invisible_time - if vnode.has_method("insert_delay"): - vnode.insert_delay(_hit_stiffen) - var kind := _entity_kind(e) - var building := kind == EntityRules.KIND_BUILDING - var door := kind == EntityRules.KIND_WOODEN_DOOR - # 命中特效 m_dwBattleHitEffectID:建筑 / 门在攻击者身前 30 cm、不转;其余在受击方(巨型:命中点) - if fx and fx_parent and fx.has_method("spawn_at"): - var at := v_g - var rot := atan2(-to.x, -to.z) - if building or door: - var flat := Vector3(to.x, 0.0, to.z) - at = a_g + (flat.normalized() if flat.length() > 0.0 else Vector3.ZERO) * 0.3 - rot = 0.0 - elif huge: - at = _godot_m(hit_pos) - var eff: Node3D = fx.spawn_at(EFFECT_HIT, fx_parent, at, true) - if eff: - eff.rotation.y = rot - # __HitStone / __HitGood / __HitGreate - if not building: - var stunned := bool(e.get("stunned", false)) - var attacker_yaw := attacker_node.rotation.y if is_instance_valid(attacker_node) else _player_yaw() - var scalar := cos(attacker_yaw - vnode.rotation.y) - var resist_fallen := _is_resist_fallen(e) - # 40250 CActorInstance::__Shake(100) - if vnode.has_method("shake"): - vnode.shake() - if kind == EntityRules.KIND_STONE or door: - if vnode.has_method("hit_stone"): - vnode.hit_stone(stunned) - elif _hit_type == HIT_TYPE_GOOD or resist_fallen: - # 40250 ActorInstanceBattle.cpp:687: if (NRaceData::HIT_TYPE_GOOD == c_rAttackData.iHittingType || rVictim.IsResistFallen()) - if vnode.has_method("hit_good"): - vnode.hit_good(scalar, stunned) - elif _hit_type == HIT_TYPE_GREAT: - if vnode.has_method("hit_greate"): - vnode.hit_greate(scalar, stunned) - if send_packet: - _on_hit(vid, huge, true) - _hit_type = old_hit_type - _hit_stiffen = old_hit_stiffen - _hit_invisible_time = old_hit_invisible - _hit_external_force = old_hit_external_force - _swing_skill = old_swing_skill - -const AFFECT_CHEONGEUN_BIT := 1 << 16 -const AFFECT_FALLEN_CHEONGEUN_BIT := 1 << 33 - -# 40250 CActorInstance::IsResistFallen (ActorInstance.cpp:405 / InstanceBaseEffect.cpp:891) -# 铁布衫 / 天罡气 (AFFECT_CHEONGEUN)、Boss 或巨型怪物拥有霸体抗击退状态 -func _is_resist_fallen(e: Dictionary) -> bool: - var flags: int = int(e.get("affect_flags", 0)) - if (flags & AFFECT_CHEONGEUN_BIT) != 0 or (flags & AFFECT_FALLEN_CHEONGEUN_BIT) != 0: - return true - if bool(e.get("resist_fallen", false)): - return true - var race: int = int(e.get("race", 0)) - if HitCollision.is_huge_race(race) or _is_huge_race(race): - return true - return false - -# CActorInstance::__CanPushDestActor(ActorInstanceBattle.cpp) -func _can_push(e: Dictionary, vid: int, huge: bool, attacker_vid: int = 0) -> bool: - var kind := _entity_kind(e) - if kind in [EntityRules.KIND_BUILDING, EntityRules.KIND_WOODEN_DOOR, EntityRules.KIND_STONE, EntityRules.KIND_NPC]: - return false - if huge or _is_resist_fallen(e): - return false - if bool(e.get("stunned", false)): - return true - var owner := int(e.get("owner_vid", 0)) - _note_owner(vid, owner) - var source_vid := attacker_vid if attacker_vid != 0 else _main_vid - if owner != source_vid: - return false - return _now() - float(_owner_seen[vid][1]) <= PUSH_OWNER_TIME - -# m_dwOwnerVID / m_fOwnerBaseTime:服务端 owner 变化时记下客户端观测时刻(__GetOwnerTime 的近似)。 -func _note_owner(vid: int, owner: int) -> void: - var seen: Array = _owner_seen.get(vid, []) - if seen.is_empty() or int(seen[0]) != owner: - _owner_seen[vid] = [owner, _now()] - -# CPythonPlayerEventHandler::OnHit:SetTarget(victim, FALSE) + SendAttackPacket(uSkill, victim); -# 受击方正被击退且不是巨型 -> 记进帧末 CG_SYNC_POSITION 列表(GetBlendingPosition)。 -func _on_hit(vid: int, huge: bool, send_packet: bool = true) -> void: - # CPythonPlayerEventHandler::OnHit uses SetTarget(victim, FALSE), so the - # automatic target update obeys the non-forced time window and target gate. - _change_target_to_picked_instance(vid, false) - if send_packet: - client.attack(_swing_skill, vid) - if huge or not net_world.has_method("is_pushing") or not net_world.is_pushing(vid): - return - var sp := _server_xy(net_world.blending_position(vid)) - _victim_flush.append({"vid": vid, "x": int(sp.x), "y": int(sp.y)}) - -func _on_entity_gone(vid: int) -> void: - _owner_seen.erase(vid) - _victim_invisible_until.erase(vid) - _def_last.erase(vid) - if vid == _target_vid: - # PythonPlayer::NotifyCharacterDead/Update reaches SetTarget(0), which - # also emits the explicit CG_TARGET(0) instead of only hiding the UI. - _clear_target_with_packet(true) - -# CPythonPlayerEventHandler::FlushVictimList(PythonPlayerEventHandler.cpp:194)——帧末把 -# 被击退目标的位置一次性 CG_SYNC_POSITION 上行,上限 SYNC_POSITION_COUNT_LIMIT=16。 -func _flush_victim_list() -> void: - if _victim_flush.is_empty(): - return - if client and client.is_in_game(): - # slice 返回新数组:既做 16 上限截断,也避免把马上要 clear() 的引用传出去。 - client.sync_positions(_victim_flush.slice(0, SYNC_POSITION_LIMIT)) - _victim_flush.clear() - -func _now() -> float: - return Time.get_ticks_msec() / 1000.0 - -# --- HUD ----------------------------------------------------------------- - - -func _on_vitals(vid: int) -> void: - var e: Dictionary = client.get_entity(vid) - if e.is_empty(): - return - if vid == _main_vid and int(e.get("hp", 0)) > 0 and not bool(e.get("dead", false)): - _main_dead = false - if pc and pc.has_method("set_dead"): - pc.set_dead(false) - if vid == _main_vid and hud and hud.has_method("set_vitals"): - hud.set_vitals(int(e.get("hp", 0)), int(e.get("max_hp", 0)), - int(e.get("sp", 0)), int(e.get("max_sp", 0))) - if vid == _target_vid: - _refresh_target_hud() - -func _refresh_target_hud() -> void: - if hud == null or not hud.has_method("set_target"): - return - var e: Dictionary = client.get_entity(_target_vid) - if e.is_empty(): - return - var mh := int(e.get("max_hp", 0)) - var pct := int(100.0 * float(e.get("hp", 0)) / float(mh)) if mh > 0 else 0 - hud.set_target(_entity_name(e), pct) diff --git a/project/net_play.gd.uid b/project/net_play.gd.uid deleted file mode 100644 index 9b61ae7c..00000000 --- a/project/net_play.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://k8vtf51x130l diff --git a/project/net_trace.gd b/project/net_trace.gd deleted file mode 100644 index 7801bd63..00000000 --- a/project/net_trace.gd +++ /dev/null @@ -1,101 +0,0 @@ -# net_trace —— 主角 vid 生命周期取证。 -# -# 实机进游戏后画面全灰、主角是蓝胶囊:GAMESCENE_DIAG 显示地图正常装载, -# 但 main_vid=0、主角实体为空 —— 主角在 setup() 之后被从 EntityStore 里 -# 摘掉了。C++ 侧能把 m_main_vid 清零的路只有三条(mut_despawn / -# m2dev GC_CHARACTER_DEL / reset_for_map_change),前两条会发 -# entity_despawned 信号,第三条会清空 m_changes、悄悄归零。 -# 这个节点每帧轮询 main_vid 并记录信号,于是「哪条路」直接可读: -# MAIN_LOST reason=despawn → GC_CHARACTER_DEL 命中了主角 -# MAIN_LOST reason=silent → reset_for_map_change(warp)或别的静默清零 -# -# 常驻但极廉价(一次 get_main_vid + 一个整数比较);只在事件发生时打印。 -extends Node - -const RING := 24 - -var _client: Node = null -var _main_vid := 0 -var _t0 := 0 -var _ring: Array[String] = [] -var _verbose := false - -func setup(client: Node) -> void: - _client = client - _t0 = Time.get_ticks_msec() - # 主角在进场那几秒会被服务器 DEL+ADD 若干次(参考端同样如此),逐条打太吵, - # 默认只报异常;MT_NETTRACE=1 才打全量。 - _verbose = OS.get_environment("MT_NETTRACE") == "1" - for sig in ["entity_main_set", "entity_despawned", "entity_spawned", - "entered_game", "disconnected", "stage_changed", "phase_changed", - "world_reset", "warp"]: - if client.has_signal(sig): - client.connect(sig, Callable(self, "_on_" + sig)) - -func _ms() -> int: - return Time.get_ticks_msec() - _t0 - -func _note(line: String) -> void: - var s := "+%dms %s" % [_ms(), line] - _ring.append(s) - if _ring.size() > RING: - _ring.pop_front() - -func _dump(head: String) -> void: - printerr("NETTRACE: ", head) - for s in _ring: - printerr("NETTRACE: ", s) - -func _on_entity_main_set(vid: int) -> void: - _note("main_set vid=%d" % vid) - if _verbose: - printerr("NETTRACE: MAIN_SET vid=%d t=+%dms" % [vid, _ms()]) - -func _on_entity_spawned(entity: Dictionary) -> void: - var vid := int(entity.get("vid", 0)) - if vid == _main_vid or bool(entity.get("is_main", false)): - _note("spawn vid=%d race=%s is_main=%s" % [vid, - str(entity.get("race", null)), str(entity.get("is_main", false))]) - -func _on_entity_despawned(vid: int) -> void: - _note("despawn vid=%d" % vid) - if vid == _main_vid: - _note("*** despawn hit MAIN vid=%d" % vid) - -func _on_entered_game() -> void: - _note("entered_game") - -func _on_disconnected(reason: String) -> void: - _note("disconnected " + reason) - printerr("NETTRACE: DISCONNECTED %s t=+%dms" % [reason, _ms()]) - -func _on_world_reset() -> void: - _note("world_reset (reset_for_map_change)") - -func _on_warp(pos: Vector3, _reconnect: bool) -> void: - _note("warp " + str(pos)) - -func _on_stage_changed(stage: String) -> void: - _note("stage=" + stage) - -func _on_phase_changed(phase: String) -> void: - _note("phase=" + phase) - -func _process(_dt: float) -> void: - if _client == null or not _client.has_method("get_main_vid"): - return - var vid := int(_client.get_main_vid()) - if vid == _main_vid: - return - if vid != 0 and _main_vid == 0: - _main_vid = vid - _note("main_vid 0 -> %d" % vid) - return - # 丢主角:把最近的事件环一起打出来,能读出是 despawn 还是静默清零。 - var was := _main_vid - _main_vid = vid - var reason := "silent" - for s in _ring: - if s.ends_with("despawn hit MAIN vid=%d" % was): - reason = "despawn" - _dump("MAIN_LOST was=%d now=%d reason=%s t=+%dms" % [was, vid, reason, _ms()]) diff --git a/project/net_trace.gd.uid b/project/net_trace.gd.uid deleted file mode 100644 index 1cc3e21c..00000000 --- a/project/net_trace.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bln1u7gitn4jw diff --git a/project/net_world.gd b/project/net_world.gd deleted file mode 100644 index fc56eadc..00000000 --- a/project/net_world.gd +++ /dev/null @@ -1,2413 +0,0 @@ -# NetWorld —— 把 M2Client 的网络实体镜像成 Godot 场景节点。 -# -# 消费 M2Client 的 entity_spawned / entity_despawned / entity_moved / -# entity_main_set / chat 信号:每个 vid 一个子节点,位置向 client.get_entity(vid).pos -# 平滑逼近,rotation.y 取自 angle_deg,动画状态取自 func(FUNC_*)。 -# -# 默认生成「占位胶囊 + 名字牌」(快、无依赖,先跑通同步/插值/状态循环)。 -# 要换成真模型:set_model_factory(func(entity_dict) -> Node3D),返回的节点若有 -# set_anim_state(name:String) 方法就会被驱动。 -# -# 用法: -# var nw := preload("res://net_world.gd").new() -# add_child(nw) -# nw.setup(m2client, self) # self = 实体挂载父节点(Node3D) -# nw.world = metin2_world # 可选:贴地 -# nw.main_entity_ready.connect(func(node, vid): _cam.target = node) -extends Node - -const FlyObject = preload("res://fly_object.gd") -const NameColor = preload("res://name_color.gd") -const TextTail = preload("res://text_tail.gd") -const ChatTail = preload("res://chat_tail.gd") -const NameShow = preload("res://name_show.gd") -const DamageEffect = preload("res://damage_effect.gd") -const EntityRules = preload("res://entity_rules.gd") -const PickShow = preload("res://pick_show.gd") -const NameTailLayout = preload("res://name_tail_layout.gd") -const TextMetrics = preload("res://text_metrics.gd") -const ScreenHpBar = preload("res://screen_hp_bar.gd") -const PhysicsPush = preload("res://physics_push.gd") -const UiAssets = preload("res://ui/ui_assets.gd") -const FlyTargetAnchor = preload("res://fx/target_effect_anchor.gd") - -signal entity_added(node: Node3D, vid: int) -signal entity_removed(vid: int) -signal main_entity_ready(node: Node3D, vid: int) -signal chat_line(type: int, vid: int, text: String) -signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -signal fly_exploded(world_pos: Vector3, cause: String) # §3.6 飞行物命中 / 超程 / 触地 -signal fishing_event(vid: int, subheader: int, dir: int) -signal motion_event(vid: int, victim_vid: int, motion: int) -signal actor_motion_event(vid: int, event: Dictionary) -signal vitals(vid: int, hp: int, max_hp: int, dead: bool) -signal damage_number(vid: int, amount: int, crit: bool, dodge: bool) -signal point_effect(vid: int, point_type: int) - -# CInstanceBase::FUNC_* -const FUNC_WAIT := 0 -const FUNC_MOVE := 1 -const FUNC_ATTACK := 2 -const FUNC_COMBO := 3 -const FUNC_MOB_SKILL := 4 -const FUNC_EMOTION := 5 -const FUNC_SKILL := 0x80 -# CRaceMotionData::NAME_NORMAL_ATTACK; combo segments start at 14. -const MOTION_NORMAL_ATTACK := 13 - -# `playersettingmodule.py` registers these exact indexed resources before the -# game phase starts; GC_CREATE_FLY carries the index, not a replacement speed. -const INDEXED_FLY_FILES := { - FlyObject.FLY_EXP: "d:/ymir work/effect/etc/gathering/ga_piece_yellow_small2.msf", - FlyObject.FLY_HP_MEDIUM: "d:/ymir work/effect/etc/gathering/ga_piece_red_small.msf", - FlyObject.FLY_HP_BIG: "d:/ymir work/effect/etc/gathering/ga_piece_red_big.msf", - FlyObject.FLY_SP_SMALL: "d:/ymir work/effect/etc/gathering/ga_piece_blue_warrior_small.msf", - FlyObject.FLY_SP_MEDIUM: "d:/ymir work/effect/etc/gathering/ga_piece_blue_small.msf", - FlyObject.FLY_SP_BIG: "d:/ymir work/effect/etc/gathering/ga_piece_blue_big.msf", - FlyObject.FLY_FIREWORK1: "d:/ymir work/effect/etc/firecracker/firecracker_1.msf", - FlyObject.FLY_FIREWORK2: "d:/ymir work/effect/etc/firecracker/firecracker_2.msf", - FlyObject.FLY_FIREWORK3: "d:/ymir work/effect/etc/firecracker/firecracker_3.msf", - FlyObject.FLY_FIREWORK4: "d:/ymir work/effect/etc/firecracker/firecracker_4.msf", - FlyObject.FLY_FIREWORK5: "d:/ymir work/effect/etc/firecracker/firecracker_5.msf", - FlyObject.FLY_FIREWORK6: "d:/ymir work/effect/etc/firecracker/firecracker_6.msf", - FlyObject.FLY_FIREWORK_XMAS: "d:/ymir work/effect/etc/firecracker/firecracker_xmas.msf", - FlyObject.FLY_CHAIN_LIGHTNING: "d:/ymir work/pc/shaman/effect/pokroe.msf", - FlyObject.FLY_HP_SMALL: "d:/ymir work/effect/etc/gathering/ga_piece_red_smallest.msf", - FlyObject.FLY_SKILL_MUYEONG: "d:/ymir work/pc/sura/effect/muyeong_fly.msf", -} -# CFlyingManager::TIndexFlyData::byType,必须和 playersettingmodule.py 的 -# RegisterIndexedFlyData 第二个参数保持一一对应。资源路径相同不代表创建语义相同: -# FIRE_CRACKER 使用随机位置目标,AUTO_FIRE 只改变起点高度。 -const INDEXED_FLY_TYPES := { - FlyObject.FLY_EXP: FlyObject.INDEX_FLY_TYPE_NORMAL, - FlyObject.FLY_HP_MEDIUM: FlyObject.INDEX_FLY_TYPE_NORMAL, - FlyObject.FLY_HP_BIG: FlyObject.INDEX_FLY_TYPE_NORMAL, - FlyObject.FLY_SP_SMALL: FlyObject.INDEX_FLY_TYPE_NORMAL, - FlyObject.FLY_SP_MEDIUM: FlyObject.INDEX_FLY_TYPE_NORMAL, - FlyObject.FLY_SP_BIG: FlyObject.INDEX_FLY_TYPE_NORMAL, - FlyObject.FLY_FIREWORK1: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER, - FlyObject.FLY_FIREWORK2: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER, - FlyObject.FLY_FIREWORK3: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER, - FlyObject.FLY_FIREWORK4: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER, - FlyObject.FLY_FIREWORK5: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER, - FlyObject.FLY_FIREWORK6: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER, - FlyObject.FLY_FIREWORK_XMAS: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER, - FlyObject.FLY_CHAIN_LIGHTNING: FlyObject.INDEX_FLY_TYPE_NORMAL, - FlyObject.FLY_HP_SMALL: FlyObject.INDEX_FLY_TYPE_NORMAL, - FlyObject.FLY_SKILL_MUYEONG: FlyObject.INDEX_FLY_TYPE_AUTO_FIRE, -} -# packet.h EWalkModes(GC_WALK_MODE / GC_CHARACTER_ADD_INFO.bWalkMode) -const WALKMODE_RUN := 0 -const WALKMODE_WALK := 1 -# CActorInstance c_fDefaultRotationSpeed / c_fDefaultHorseRotationSpeed(度/秒) -const ROTATION_SPEED := 1200.0 -const ROTATION_SPEED_HORSE := 300.0 - -# EDamageFlag / IsShowDamage / ProcessDamage 分类见 damage_effect.gd(DamageEffect.DAMAGE_*)。 - -# §8.8 头顶名字 / 聊天气泡 billboard 的默认局部 y(世界单位)。seam ⑨(name_tail_layout.gd): -# 有「聊天」气泡时名字尾标顶到气泡上方 17px(换算按名字 Label3D 的 pixel_size)。 -const NAME_TAG_DEFAULT_Y := 2.1 -const BUBBLE_TAG_Y := 2.35 - -# §8.8 公会名 / 称号 / 等级子标签的默认局部 y(竖排近似:公会名在名字上、称号 / 等级在下)。 -# seam ⑦(name_tail_layout.gd):参考端 ArrangeTextTail 把它们锚在名字尾标 pTextTail->y 上, -# 名字因聊天气泡上移时三者整体跟随——_reposition_name_tag 按 name_tail_lift_delta 补这段位移。 -const SUBTAG_DEFAULT_Y := {"GuildTag": 2.34, "TitleTag": 1.86, "LevelTag": 1.74} -# seam ⑦ 水平排版(增量 121):ArrangeTextTail 按 GetTextSize 精确像素排称号 / 等级, -# poc 无字体度量 -> 按「字符数 * 约 7px」估文本宽(与 ground_items 的 TAG_CHAR_W_PX 同源)。 -const SUBTAG_CHAR_W_PX := 7.0 - -# §8.8:参考端的实体 HP 条属于屏幕尾标,而不是 3D billboard。条的锚点 -# 保持在原占位条高度附近;实际像素位置由 Camera3D.unproject_position() 计算。 -const HP_BAR_WORLD_Y := 1.95 -const HP_BAR_SCREEN_MARGIN := 32.0 - -# CLIENT-GAP §2.1 two-layer model. The data layer (M2Client / EntityStore) always -# holds every known entity; a scene node under `parent` exists only while the -# entity is visible: it is the main character, force-visible, a wall, or within -# CHAR_STAGE_VIEW_BOUND of the main character. -# CPythonCharacterManager.cpp:13 -> CHAR_STAGE_VIEW_BOUND = 200*100 (cm; px==cm) -# PythonCharacterManager.cpp:182 -> cull past (CHAR_STAGE_VIEW_BOUND + 10) -const CHAR_STAGE_VIEW_BOUND := 200 * 100 -# CInstanceBase AFFECT enum, ordinal 7 (bit 7) == AFFECT_SHOW_ALWAYS. The 40250 -# ClientVS22 tree keeps the enum in AffectFlagContainer.h and does not spell the -# member out; this mirrors the m2dev sibling source (InstanceBase.h) and is the -# force-visible bit checked by CNetworkActorManager::__IsVisibleActor. -const AFFECT_SHOW_ALWAYS := 1 << 7 -# CInstanceBase::IsWall race ids (InstanceBase.cpp) — always mirrored regardless -# of distance so map geometry never pops. -const WALL_RACES := [14201, 14202, 14203, 14204] - -var client: Node # M2Client -var parent: Node3D # 实体挂这下面 -var world: Node # Metin2World(可空,用 sample_height 贴地) -# §3.2 后位置插值全在 C++ EntityStore::tick() 里做,这里直接跟随 e.pos。 -# 两个字段保留只为兼容外部读取 / 旧测试,_process 不再使用。 -var follow_lerp := 14.0 -var snap_dist := 6.0 - -var _by_vid := {} # vid:int -> Node3D -# §3.7 受击击退:vid -> {obj: PhysicsPush(CPhysicsObject), off: Vector2 累计位移 cm, base: 推开时的 e.pos} -var _push := {} -# GC_SYNC_POSITION / CActorInstance::TEMP_Push. The authoritative entity -# already contains the server target; this map keeps only the presentation -# offset that eases the old actor position into that target for one second. -var _sync_push := {} -var skill_table: RefCounted -var fx: RefCounted -var _main_vid := 0 -var _local_vid := 0 # 由 net_play 设:这个 vid 由本地 player 代表,不生成节点 -var _local_node: Node3D # 本地玩家的真模型(不挂在 _mount 下) -var _model_factory: Callable # func(Dictionary) -> Node3D -var _upgrade_queue: Array[int] = [] # Throttled queue for replacing placeholder capsules with real models -const MAX_UPGRADES_PER_FRAME := 4 -const MAX_UPGRADE_TIME_MSEC := 4 -var name_resolver: Callable = Callable(): # func(entity_dict) -> String(怪名走 mob_proto) - set(v): - name_resolver = v - _refresh_all_names() -var kind_resolver: Callable = Callable() # §8.8 func(entity_dict) -> int(0 PC/1 NPC/2 怪/3 石/4 warp;net_play._entity_kind) -var title_name_resolver: Callable = Callable() # §8.8 func(grade:int) -> String(PVP_LEVEL 本地化;未注入回退裸 key,seam ⑧) -var _name_color_accum := 0.0 # §8.8 头顶名字色 / 称号标签的节流刷新累加器(~2Hz) -var _chat_tails := {} # §8.8 vid:int -> {text,color,name_flag,living_time}(CPythonTextTail::m_ChatTailMap) -var _pvp_relations := {} # unordered pair key -> {src_vid,dst_vid,mode} -var _duel_opponents := {} # server-provided duel opponent VID set -var _duel_cannot_attack := false -var _fading := {} # vid:int -> true while an old node tweens out; a new same-VID spawn may replace it -# §8.4 / §8.6 头顶名字「是否显示」(name_show.gd)。game.py::__IsShowName() 的两个来源: -# _always_show_name —— uigameoption「总显示名字」radio(CPythonSystem::IsAlwaysShowName) -# _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag) -# 另加 forced 路径:_target_vid(targetBoard.GetTargetVID())+ chat 尾标 bNameFlag + -# _hover_vid(game.py OnRender chr.Pick() -> ShowCharacterTextTail,见 pick_show.gd)。 -var _always_show_name := NameShow.ALWAYS_SHOW_NAME_DEFAULT -var _alt_held := false -var _target_vid := 0 -var _target_effect: Node3D = null -var _hover_vid := 0 # player_controller._update_hover_cursor 射线拾到的角色 vid(0 = 无) -# §8.8 / §5.4 伤害飘字(damage_effect.gd)。CPythonSystem::IsShowDamage 门(uigameoption -# 「伤害数字」radio)+ CInstanceBase::m_DamageQueue(每帧 pop 一条)+ ProcessDamage 分类。 -var _show_damage := DamageEffect.SHOW_DAMAGE_DEFAULT -var _damage_queue: Array = [] # m_DamageQueue:{vid, amount, flag, self, target} -var _dmg_self_toggle := false # m_bDamageEffectType(SELFDAMAGE / SELFDAMAGE2 交替) -var _fly_mgr: Node # FlyManager(fly_object.gd)—— GC_CREATE_FLY 的真实弹道 + 命中判定。§3.6 -var _assets_root := "" -var _fly_data_cache := {} -var _hp_layer: CanvasLayer # 屏幕空间尾标层(低于 HUD / UiManager) -var _hp_overlay: Control -var _hp_bars := {} # vid:int -> ScreenHpBar -var _camera: Camera3D - -# Fishing surface provider bridge. NetWorld owns entity presentation while -# Metin2World owns map data; keep the ClientVS22 GetFishingRot query on the -# latter and expose it without making NetPlay depend on the native class. -func get_fishing_rotation(gx_m: float, gz_m: float, heading_deg: float) -> float: - if world == null or not world.has_method("get_fishing_rotation"): - return -2.0 # provider unavailable; NetPlay keeps the protocol-only fallback - return float(world.call("get_fishing_rotation", gx_m, gz_m, heading_deg)) - -func can_fishing_position(gx_m: float, gz_m: float, heading_deg: float) -> bool: - if world == null or not world.has_method("can_fishing_position"): - return true - return bool(world.call("can_fishing_position", gx_m, gz_m, heading_deg)) - -func setup(m2client: Node, mount: Node3D, assets_root_path: String = "") -> void: - client = m2client - parent = mount - _assets_root = assets_root_path - client.entity_spawned.connect(_on_spawn) - client.entity_despawned.connect(_on_despawn) - client.entity_main_set.connect(_on_main_set) - if client.has_signal("entity_info"): - client.entity_info.connect(_on_info) - client.chat.connect(func(t, v, s): - chat_line.emit(t, v, s) - # §8.8 RecvChatPacket:1372 —— 带 VID 的聊天里,除 SHOUT / COMMAND 外都进头顶「聊天尾标」 - # (line 去掉 "名字: " 前缀,INFO/NOTICE 类不去) - if int(v) != 0 and ChatTail.wants_chat_tail(int(t)): - _bubble(int(v), ChatTail.line_for_tail(int(t), String(s)))) - # entity_moved 不强依赖:_process 每帧统一插值。留个钩子便于调试。 - client.entity_moved.connect(_on_moved) - client.vitals_changed.connect(_on_vitals) - client.entity_dead.connect(_on_dead) - if client.has_signal("point_changed"): - client.point_changed.connect(_on_point_changed) - client.damage.connect(_on_damage) - if client.has_signal("motion"): - client.motion.connect(_on_motion) - if client.has_signal("dig_motion"): - client.dig_motion.connect(_on_dig_motion) - if client.has_signal("fly_cue"): - client.fly_cue.connect(_on_fly) - if client.has_signal("fly_targeting"): - client.fly_targeting.connect(_on_fly_targeting) - if client.has_signal("fishing_event"): - client.fishing_event.connect(_on_fishing_event) - if client.has_signal("pvp_changed"): - client.pvp_changed.connect(_on_pvp_changed) - if client.has_signal("duel_changed"): - client.duel_changed.connect(_on_duel_changed) - # Guild marks are downloaded by AppFlow on the side connection. The mark - # store is independent from the entity stream, so a character can already - # be visible when its 16x12 image arrives; refresh the affected text tails - # on both the initial completion and an incremental GC_MARK_UPDATE. - if client.has_signal("guild_marks_ready"): - client.guild_marks_ready.connect(func(_count: int): _retint_all_text_tails()) - if client.has_signal("guild_mark_updated"): - client.guild_mark_updated.connect(func(guild_id: int, _img_idx: int): _refresh_guild_mark(guild_id)) - # §3.6 飞行物实体:GC_CREATE_FLY 之后由 FlyManager 跑真实弹道 + 线段命中判定。 - _fly_mgr = FlyObject.new() - _fly_mgr.name = "FlyManager" - add_child(_fly_mgr) - _fly_mgr.setup(parent, world, _assets_root) - _fly_mgr.set_actor_provider(Callable(self, "_fly_collision_actors")) - if fx != null: - _fly_mgr.set_effect_registry(fx) - _fly_mgr.exploded.connect(func(wp: Vector3, cause: String): fly_exploded.emit(wp, cause)) - _ensure_hp_overlay() - -# GameScene normally calls this after creating the orbit camera. Keeping the -# setter explicit makes camera hand-off / map reload deterministic and leaves -# headless tests independent of the active viewport camera. -func set_camera(camera: Camera3D) -> void: - _camera = camera - _update_hp_bars() - -# 已在局内(重连 / setup 是协程,进来时 spawn burst 已被 pump 抽干)—— -# 把当前所有实体补建一遍,别漏掉进游戏那一批怪 / NPC / 玩家。 -func catch_up() -> void: - if client == null or not client.has_method("get_entities"): - return - for e in client.get_entities(): - var vid := int(e.get("vid", 0)) - if vid != 0 and not _by_vid.has(vid) and vid != _local_vid: - _on_spawn(e, true) - if client.has_method("get_pvp_relations"): - for relation in client.get_pvp_relations(): - _on_pvp_changed(int(relation.get("src_vid", 0)), int(relation.get("dst_vid", 0)), - int(relation.get("mode", 0))) - if client.has_method("get_duel"): - _on_duel_changed(client.get_duel()) - -func set_model_factory(f: Callable) -> void: - _model_factory = f - # The game server can send the initial actor burst before GameScene has - # finished loading mob/item proto tables. Those actors were necessarily made - # as capsules; once the real factory is available, replace them from the - # authoritative EntityStore rows instead of leaving mixed placeholder/GR2 - # models on screen. - _refresh_existing_model_views() - -func _refresh_existing_model_views() -> void: - if not _model_factory.is_valid() or client == null or not client.has_method("get_entity"): - return - for vid in _by_vid.keys().duplicate(): - var id := int(vid) - if id == _local_vid: - continue - var n: Node3D = _by_vid.get(id, null) - # Only enqueue if it's currently a placeholder or needing model rebuild - if is_instance_valid(n) and not _upgrade_queue.has(id): - _upgrade_queue.append(id) - -# 本地玩家用 player_controller 的节点代表,这里就不要再画一个。 -func set_local_vid(vid: int) -> void: - _local_vid = vid - var n: Node3D = _by_vid.get(vid, null) - if n: - _by_vid.erase(vid) - n.queue_free() - _remove_hp_bar(vid) - -func set_local_node(node: Node3D) -> void: - _local_node = node - -# game.py ShowName/HideName toggles actor name plates without changing the -# network data layer. Shop/PVP labels and chat bubbles remain independent. -# LALT 按 / 抬 -> game.py ShowNameFlag(__IsShowName 的一半)。 -func set_names_visible(visible: bool) -> void: - _alt_held = visible - _refresh_all_name_visibility() - -func names_visible() -> bool: - return _alt_held - -# uigameoption「总显示名字」radio -> CPythonSystem::SetAlwaysShowNameFlag(__IsShowName 的另一半)。 -func set_always_show_name(on: bool) -> void: - _always_show_name = on - _refresh_all_name_visibility() - -# uigameoption「伤害数字」radio -> CPythonSystem::SetShowDamageFlag(AddDamageEffect 的入队门)。 -func set_show_damage(on: bool) -> void: - _show_damage = on - -func _detach_target_effect() -> void: - if is_instance_valid(_target_effect): - _target_effect.queue_free() - _target_effect = null - -func _attach_target_effect(node: Node3D) -> void: - _detach_target_effect() - if not is_instance_valid(node) or fx == null or not fx.has_method("spawn"): - return - # 40250 EFFECT_TARGET (click_glow_select.mse): 挂在目标脚下的循环旋转发光光圈 - _target_effect = fx.spawn("d:/ymir work/effect/etc/click/click_glow_select.mse", node, false) - if _target_effect: - _target_effect.position = Vector3.ZERO - -# targetBoard.GetTargetVID():目标始终强显名字(game.py OnRender ShowCharacterTextTail(targetVID))。 -# 40250 __AttachTargetEffect / __DetachTargetEffect (EFFECT_TARGET: click_glow_select.mse) -func set_target_vid(vid: int) -> void: - if _target_vid == vid: - return - var prev := _target_vid - _target_vid = vid - _detach_target_effect() - if vid != 0 and _by_vid.has(vid): - _attach_target_effect(_by_vid[vid]) - for v in [prev, vid]: - if v != 0 and _by_vid.has(v): - _set_name_visibility(_by_vid[v]) - -# game.py OnRender:wndMgr.IsPickedWindow 门内 chr.Pick() 命中的角色 -> ShowCharacterTextTail -# (强显名字,绕过 __IsShowName())。poc 由 player_controller 的射线悬停拾取喂入。 -# 见 pick_show.gd(第 27 个 canonical)与增量 113 的 _target_vid 同路数。 -func set_hover_vid(vid: int) -> void: - if _hover_vid == vid: - return - var prev := _hover_vid - _hover_vid = vid - for v in [prev, vid]: - if v != 0 and _by_vid.has(v): - _set_name_visibility(_by_vid[v]) - -func _refresh_all_name_visibility() -> void: - for node in _by_vid.values(): - _set_name_visibility(node) - -# game.py OnRender:本地主角平面坐标(ShowAllTextTail 的距离中心);未知返回 (INF,INF)。 -func _main_planar_cm() -> Vector2: - if client and client.has_method("get_entity") and _main_vid != 0: - var m: Dictionary = client.get_entity(_main_vid) - if not m.is_empty() and m.get("pos_cm", null) is Vector3: - var mc: Vector3 = m["pos_cm"] - return Vector2(mc.x, mc.y) - return Vector2(INF, INF) - -# name_show.name_visible_for 的 net_world 侧组装:forced(target / hover / chat bNameFlag)+ -# __IsShowName()(_always_show_name or _alt_held)+ 3500 平面距 + isShow/wall/CanPick 资格。 -func _name_shown(root: Node3D) -> bool: - if root == null or not is_instance_valid(root): - return false - var vid := int(root.get_meta("vid", 0)) - var e: Dictionary = {} - if vid != 0 and client and client.has_method("get_entity"): - e = client.get_entity(vid) - var dead := bool(e.get("dead", false)) - var is_wall := _is_wall(int(e.get("race", 0))) - # CanPickInstance() 更细(隐身 / 特定 affect 亦不可拾取)—— 这里只按死透近似,记 seam ⑩。 - var alive := not dead - # game.py OnRender:targetBoard.GetTargetVID()(增量 113)+ chr.Pick() 悬停命中 - # (_hover_vid,增量 116 / pick_show.gd)都走 ShowCharacterTextTail 强显。 - var forced := vid != 0 and (vid == _target_vid or vid == _hover_vid) - var ct: Variant = _chat_tails.get(vid, null) - if ct is Dictionary and bool(ct.get("name_flag", false)): - forced = true - var show_name := NameShow.is_show_name(_always_show_name, _alt_held) - var dist := 0.0 # 主角位置未知 -> 不按距离裁剪(fail-open,同 _is_visible_entity) - if not e.is_empty() and e.get("pos_cm", null) is Vector3: - var mc := _main_planar_cm() - if mc.x != INF: - dist = NameShow.planar_distance_cm(_planar_cm(e), mc) - return NameShow.name_visible_for(forced, show_name, dist, alive, is_wall, alive) - -func _set_name_visibility(root: Node3D) -> void: - if root == null or not is_instance_valid(root): - return - var shown := _name_shown(root) - var tag := root.get_node_or_null("Label3D") as Label3D - if tag: - tag.visible = shown - # §8.8 称号 / 等级 / 公会名子标签随主名字整体开关;各自「是否有内容」仍由 - # _apply_text_tail 按 RefreshTextTail 决定。 - for sub in ["GuildTag", "TitleTag", "LevelTag"]: - var st := root.get_node_or_null(sub) as Label3D - if st: - st.visible = shown and st.text != "" - var mark := root.get_node_or_null("GuildMark") as Sprite3D - if mark: - var guild_tag := root.get_node_or_null("GuildTag") as Label3D - mark.visible = shown and guild_tag != null and guild_tag.text != "" - -# ClientVS22 destroys the network actor manager when Loading starts. This is -# called from M2Client.world_reset before the replacement connection sends its -# first GC_MAIN_CHARACTER/GC_CHARACTER_ADD burst. -func clear_for_map_change() -> void: - if _fly_mgr and _fly_mgr.has_method("clear_for_map_change"): - _fly_mgr.clear_for_map_change() - for vid in _by_vid.keys(): - var node: Node3D = _by_vid[vid] - if is_instance_valid(node): - _set_network_state_gate(node, false, true, false, true) - _set_entity_knockdown(int(vid), false) - node.queue_free() - entity_removed.emit(int(vid)) - _by_vid.clear() - _clear_hp_bars() - # 40250 destroys the target effect and all transient text/model work when - # entering Loading. These are not owned by _by_vid, so clearing actors - # alone would let old presentation state leak into the next map. - _detach_target_effect() - _upgrade_queue.clear() - _chat_tails.clear() - _target_vid = 0 - _hover_vid = 0 - _fading.clear() - _damage_queue.clear() - _push.clear() - _sync_push.clear() - _main_vid = 0 - _pvp_relations.clear() - _duel_opponents.clear() - _duel_cannot_attack = false - -func node_for(vid: int) -> Node3D: - return _by_vid.get(vid, null) - -func main_node() -> Node3D: - return _by_vid.get(_main_vid, null) - -# --------------------------------------------------------------------------- - -func _on_spawn(d: Dictionary, allow_defer: bool = false) -> void: - var vid := int(d.get("vid", 0)) - # A main VID is only suppressed when GameScene has explicitly assigned a - # separate local player node. Headless/adaptor hosts may use NetWorld's - # normal node for the main entity, and the reference manager still exposes - # that instance through GetMainInstancePtr(). - if vid == 0 or (vid == _local_vid and _local_vid != 0): - return - # §2.1: RemoveActor moves the old instance to the reference dead list, but - # AppendActor immediately calls GetInstancePtr/creates a new live instance - # for the same VID. The old blend-out may finish independently; it must not - # suppress a replacement actor or its authoritative spawn snapshot. - if _fading.has(vid): - _fading.erase(vid) - # §2.3 one-overwrite rule: a fresh Spawn for a VID that already has a node - # destroys and recreates the node (the data row was whole-replaced). - var keep_pos: Variant = null - var existing: Node3D = _by_vid.get(vid, null) - if is_instance_valid(existing): - # NetworkActorManager::__AppendCharacterManagerActor only preserves the - # old pixel position when the mount state changes. A normal same-VID - # re-add (including a respawn/VID reuse) is created at the authoritative - # SCreateData position and rotation from the new packet. - var old_mounted := int(existing.get_meta("mount_vnum", 0)) != 0 - var new_mounted := int(d.get("mount_vnum", 0)) != 0 - if old_mounted != new_mounted: - keep_pos = existing.global_position if existing.is_inside_tree() else existing.position - if _fly_mgr and _fly_mgr.has_method("notify_target_despawn"): - _fly_mgr.notify_target_despawn(existing) - _by_vid.erase(vid) - existing.queue_free() - elif not _is_visible_entity(d): - # §2.1: the data row lives in EntityStore; the scene node is only built - # once the entity is in view. _update_visibility() creates it later when - # the main character moves into CHAR_STAGE_VIEW_BOUND. - return - var is_main: bool = (vid == _main_vid or bool(d.get("is_main", false))) - var node: Node3D = null - if allow_defer and not is_main and _model_factory.is_valid(): - node = _make_placeholder(d) - if not _upgrade_queue.has(vid): - _upgrade_queue.append(vid) - elif _model_factory.is_valid(): - node = _model_factory.call(d) - _upgrade_queue.erase(vid) - if node == null: - node = _make_placeholder(d) - elif not node.has_node("Label3D"): - _attach_nameplate(node, d) # 真模型也要头顶名字 + HP 条 - node.name = "e_%d" % vid - parent.add_child(node) - if keep_pos is Vector3: - node.global_position = keep_pos - else: - node.position = _grounded(d.get("pos", Vector3.ZERO)) - # SCreateData::m_fRot is always the incoming packet rotation; only the - # position has the mount-transition preservation exception above. - node.rotation.y = MapCoord.heading_to_yaw(float(d.get("angle_deg", 0.0))) - node.set_meta("vid", vid) - if node.has_signal("motion_bound"): - node.motion_bound.connect(_on_remote_motion_bound.bind(vid)) - _connect_actor_motion_events(node, vid) - var initial_func := int(d.get("func", FUNC_WAIT)) - var initial_arg := int(d.get("func_arg", 0)) - var initial_moving := bool(d.get("moving", false)) - var initial_walk_mode := int(d.get("walk_mode", WALKMODE_RUN)) - var initial_dead := bool(d.get("dead", false)) - node.set_meta("func", initial_func) - node.set_meta("func_arg", initial_arg) - node.set_meta("moving", initial_moving) - node.set_meta("walk_mode", initial_walk_mode) - node.set_meta("dead", initial_dead) - # A correction that arrived before the visible node was created belongs to - # the data layer only; consume the current serial as the spawn baseline. - node.set_meta("sync_push_serial", int(d.get("sync_push_serial", 0))) - node.set_meta("mount_vnum", int(d.get("mount_vnum", 0))) - var ch_type := int(d.get("ch_type", 0)) - if kind_resolver.is_valid(): - ch_type = int(kind_resolver.call(d)) - var shape := "ATTACK" - var explicit_kind := int(d.get("kind", -1)) - if explicit_kind == EntityRules.KIND_WOODEN_DOOR or EntityRules._is_wooden_door(d) \ - or bool(d.get("building", false)) or explicit_kind == EntityRules.KIND_BUILDING: - shape = "DOOR" - elif ch_type in [EntityRules.KIND_NPC, EntityRules.KIND_WARP]: - shape = "TALK" - node.set_meta("kind", ch_type) - node.set_meta("cursor_shape", shape) - _by_vid[vid] = node - if vid == _target_vid: - _attach_target_effect(node) - _sync_hp_bar(vid, d) - _set_name_visibility(node) - _refresh_shop_sign(node, str(d.get("shop_sign", ""))) - _refresh_pvp_tag(vid) - _apply_field_updates(node, d) - _push_motion_speeds(vid, node) - # NetworkActorManager creates the instance, applies its immutable/mutable - # fields, then lets the current state select the initial motion. Without - # this explicit edge, a spawn whose first packet is FUNC_SKILL/EMOTION could - # look like WAIT forever because _process sees matching metadata and skips - # the transition. - _apply_anim(node, initial_func, initial_moving, initial_walk_mode, initial_dead, initial_arg) - _apply_name_color(vid) - _apply_text_tail(vid) - entity_added.emit(node, vid) - if vid == _main_vid: - main_entity_ready.emit(node, vid) - -# CActorInstance::MotionEventProcess runs on every visible actor, not only the -# local player. Connect the real view's native animator once; placeholders do -# not have an animator and simply begin receiving events after _on_spawn -# replaces them with a model view. -func _connect_actor_motion_events(node: Node3D, vid: int) -> void: - if node == null or not is_instance_valid(node) or bool(node.get_meta("motion_events_connected", false)): - return - if not ("anim" in node): - return - var animator: Variant = node.get("anim") - if animator == null or not is_instance_valid(animator) or not animator.has_signal("motion_event_detailed"): - return - animator.motion_event_detailed.connect(_on_actor_motion_event.bind(vid)) - node.set_meta("motion_events_connected", true) - -func _motion_event_world_position(actor: Node3D, event: Dictionary) -> Vector3: - var source_variant: Variant = event.get("fly_pos", Vector3.ZERO) - var source: Vector3 = source_variant if source_variant is Vector3 else Vector3.ZERO - var model: Node3D = actor.get("model") as Node3D if "model" in actor else null - var anim: Node = actor.get("anim") as Node if "anim" in actor else null - if is_instance_valid(model): - var bone := Transform3D.IDENTITY - if bool(event.get("fly_attaching", false)) and is_instance_valid(anim) \ - and anim.has_method("get_effect_bone_pose"): - var bone_name := String(event.get("fly_bone", "")) - if bone_name != "": - var pose: Dictionary = anim.get_effect_bone_pose(bone_name) - if pose.has("transform"): - bone = pose["transform"] - var source_to_metres := Transform3D( - Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0)), - Vector3.ZERO) - var offset := Transform3D(Basis.IDENTITY, source) - return (model.global_transform * offset * bone * source_to_metres.affine_inverse()).origin - return actor.global_transform * Vector3(source.x * 0.01, source.z * 0.01, -source.y * 0.01) - -func _resolve_actor_motion_fly_target(actor: Node3D) -> Dictionary: - var queued: Variant = actor.get_meta("fly_target_queue", []) - var record: Dictionary = {} - var from_queue := false - if queued is Array and not queued.is_empty() and queued[0] is Dictionary: - record = queued[0] - from_queue = true - else: - record = { - "vid": int(actor.get_meta("fly_target_vid", 0)), - "cm": actor.get_meta("fly_target_cm", Vector2.ZERO), - } - var target_vid := int(record.get("vid", 0)) - if target_vid != 0: - var target_node: Node3D = _local_node if target_vid == _local_vid and is_instance_valid(_local_node) \ - else _by_vid.get(target_vid, null) - if is_instance_valid(target_node): - return {"target": target_node, "from_queue": from_queue} - var target_cm: Variant = record.get("cm", null) - if target_cm is Vector2: - return {"target": _server_cm_to_world(target_cm), "from_queue": from_queue} - return {} - -func _consume_actor_motion_fly_target(actor: Node3D) -> void: - var queued: Variant = actor.get_meta("fly_target_queue", []) - if queued is Array and not queued.is_empty(): - queued.pop_front() - actor.set_meta("fly_target_queue", queued) - -func _on_actor_motion_event(event: Dictionary, vid: int) -> void: - var actor: Node3D = _by_vid.get(vid, null) - if is_instance_valid(actor) and int(event.get("type", -1)) == 6: - # CActorInstance::ProcessMotionEventFly consumes the current FlyTarget - # and creates a resource-driven instance for every visible remote actor. - # GC_CREATE_FLY is a separate server-indexed path; it must not replace this - # action-event projectile. - var target_info := _resolve_actor_motion_fly_target(actor) - if not target_info.is_empty(): - var skill_index := int(event.get("skill_index", actor.get_meta("func_arg", 0))) - if spawn_motion_fly(actor, event, target_info["target"], skill_index, true, vid): - if bool(target_info.get("from_queue", false)): - _consume_actor_motion_fly_target(actor) - actor_motion_event.emit(vid, event) - -func _fly_collision_radius(node: Node3D) -> float: - var configured := float(node.get_meta("fly_collision_radius", 0.0)) - if configured > 0.0: - return configured - if node.has_method("get_fly_collision_radius"): - var reported: Variant = node.call("get_fly_collision_radius") - if reported is float or reported is int: - return maxf(0.0, float(reported)) - # Placeholder actors do not carry the native collision spheres. Keep the - # same dynamic-sphere shape contract and use a conservative 50cm fallback; - # real views can publish their calibrated radius through the metadata above. - return 0.5 - -func _fly_collision_actors() -> Array: - var actors: Array = [] - var seen := {} - var append_actor := func(vid: int, node: Node3D) -> void: - if vid == 0 or not is_instance_valid(node) or seen.has(node.get_instance_id()): - return - if bool(node.get_meta("dead", false)): - return - seen[node.get_instance_id()] = true - actors.append({ - "vid": vid, - "node": node, - "center": FlyTargetAnchor.fly_target_position(node), - "radius": _fly_collision_radius(node), - }) - for vid_variant in _by_vid.keys(): - append_actor.call(int(vid_variant), _by_vid[vid_variant]) - if _local_vid != 0 and is_instance_valid(_local_node): - append_actor.call(_local_vid, _local_node) - return actors - -# CActorInstance::ProcessMotionEventFly:本地主角的 `.msa` FLY 事件创建 -# `FlyFileName` 对应的可攻击飞行实例;OnShoot/CG_SHOOT 仍由 GameScene 的 -# pending-shot 队列发送。它与服务器 GC_CREATE_FLY 的索引飞行物是两条 -# 独立路径,不能把其中任一条当作另一条的替代或重复创建。 -func spawn_motion_fly(actor: Node3D, event: Dictionary, target, - skill_index: int = 0, can_attack: bool = true, owner_vid: int = 0) -> bool: - var target_valid := (target is Node3D and is_instance_valid(target)) \ - or (target is Vector3 and (target as Vector3).is_finite()) - if _fly_mgr == null or not is_instance_valid(actor) or not target_valid: - return false - var fly_file := String(event.get("fly_file", "")) - if fly_file == "": - return false # CFlyingManager returns NULL when the resource is absent. - var data = FlyObject.FlyData.load_msf(fly_file, _assets_root) - if data == null: - return false - var start := actor.global_position - if actor.has_method("motion_event_world_position"): - start = actor.motion_event_world_position(event) - else: - start = _motion_event_world_position(actor, event) - _fly_mgr.parent = parent - _fly_mgr.world = world - if fx != null and _fly_mgr.has_method("set_effect_registry"): - _fly_mgr.set_effect_registry(fx) - _fly_mgr.spawn(start, target, can_attack, data, skill_index, actor, owner_vid) - return true - -# CActorInstance::Move -> SetLoopMotion(WALK/RUN, .., m_fMovSpd):远端位移按动作根运动 -# 累计(.msa Accumulation / MotionDuration × movSpd/100)推进。模型建好后把走 / 跑 -# 速度交给 EntityStore;占位胶囊 / 骑马(马的动作不在 general 目录)不推,EntityStore -# 退回按服务端 duration 线性插值。 -func _push_motion_speeds(vid: int, n: Node3D) -> void: - if not n.has_method("get_move_motion_speeds") or not client.has_method("set_entity_motion_speed"): - return - if int(n.get_meta("mount_vnum", 0)) != 0: - return - var sp: Vector2 = n.call("get_move_motion_speeds") - if sp.x > 0.0 or sp.y > 0.0: - client.call("set_entity_motion_speed", vid, sp.x, sp.y) - -# GC_CHAR_ADD_INFO 到了:刷新头顶名字 / HP 上限(节点已存在)。 -func _on_info(vid: int, d: Dictionary) -> void: - var n: Node3D = _by_vid.get(vid, null) - if n == null: - return - _sync_hp_bar(vid, d) - var nm := _display_name(d) - if nm != "": - if n.has_node("Label3D"): - (n.get_node("Label3D") as Label3D).text = nm - elif n.has_method("set_display_name"): - n.set_display_name(nm) - _refresh_shop_sign(n, str(d.get("shop_sign", ""))) - _apply_field_updates(n, d) - _apply_name_color(vid) - _apply_text_tail(vid) - -# §2.5: push the mutable entity fields onto a live view in the exact order the -# reference client uses (CNetworkActorManager refresh -> NetworkActorManager.cpp -# :470): ChangeArmor -> ChangeWeapon -> ChangeHair -> ChangeGuild -> -# SetAffectFlagContainer -> SetMoveSpeed -> SetAttackSpeed -> SetAlignment -> -# SetPKMode -> SetStateFlags. Every setter is optional: the placeholder capsule -# and views that do not model a field simply skip it. -# -# §4.1 修改 5(远端换装):armor/weapon/hair 字段顺序已 1:1(上面三行)。变身 / 时装 -# 遮挡由模型侧统一处理 —— 真模型(PlayerView + EquipModel)在 refresh() 里过 -# part_hiding.gd(见 equip_model.gd)。远端 PC 由 RemotePlayerView 消费 -# awPart[CHR_EQUIPPART_ARMOR..HAIR];本地玩家仍由 EquipModel 读取 get_equipment()。 -func _apply_field_updates(n: Node3D, d: Dictionary) -> void: - # The pointer path consumes the same CanPickInstance result as the script - # target path, so an affect/death update cannot leave an invisible actor - # selectable until the next scene rebuild. - var pick_probe := d.duplicate() - pick_probe["kind"] = int(n.get_meta("kind", int(d.get("ch_type", 0)))) - n.set_meta("can_pick", EntityRules.can_pick_instance(pick_probe)) - var parts: Variant = d.get("parts", []) - if parts is Array and parts.size() >= 4: - _set_wear_slot(n, "set_armor", 0, int(parts[0])) - _set_wear_slot(n, "set_weapon", 1, int(parts[1])) - _set_wear_slot(n, "set_head", 2, int(parts[2])) - _set_wear_slot(n, "set_hair", 3, int(parts[3])) - if n.has_method("set_guild"): - n.call("set_guild", int(d.get("guild", 0))) - if n.has_method("set_affect_flags"): - n.call("set_affect_flags", int(d.get("affect_flags", 0)), fx) - elif n.has_method("set_affects"): - n.call("set_affects", int(d.get("affect_flags", 0))) - if n.has_method("set_move_speed"): - n.call("set_move_speed", int(d.get("moving_speed", 0))) - elif n.has_method("set_server_speed"): - n.call("set_server_speed", int(d.get("moving_speed", 0))) - if n.has_method("set_attack_speed"): - n.call("set_attack_speed", int(d.get("attack_speed", 0))) - # CInstanceBase::SetAttackSpeed is consumed later by RunNormalAttack / - # RunComboAttack when the remote state reaches the presentation layer. Keep - # the wire value on the actor node as well; unlike the local NetPlay path, - # remote FUNC_* packets arrive independently of the view setter. - n.set_meta("attack_speed", int(d.get("attack_speed", 0))) - if n.has_method("set_alignment"): - n.call("set_alignment", int(d.get("alignment", 0))) - if n.has_method("set_pk_mode"): - n.call("set_pk_mode", int(d.get("pk_mode", 0))) - if n.has_method("set_state_flags"): - n.call("set_state_flags", int(d.get("state_flags", 0))) - # keep the §2.3 mount-toggle rebuild key current - n.set_meta("mount_vnum", int(d.get("mount_vnum", n.get_meta("mount_vnum", 0)))) - -func _set_wear_slot(n: Node3D, dedicated: String, slot: int, vnum: int) -> void: - if n.has_method(dedicated): - n.call(dedicated, vnum) - elif n.has_method("set_wear"): - n.call("set_wear", slot, vnum) - -# --- §2.1 per-frame visibility manager ------------------------------------------ -# Mirrors CNetworkActorManager::__IsVisibleActor + the CPythonCharacterManager -# cull loop: every data-layer entity is checked each frame; a node is built when -# the entity comes into view and blended out when it leaves. Runs only once the -# main character's position is known (reference cull needs GetMainInstancePtr); -# until then the entity_spawned signal path keeps building nodes as before. -func _update_visibility() -> void: - if not client.has_method("get_entities") or _main_vid == 0: - return - var main: Dictionary = client.get_entity(_main_vid) - if main.is_empty() or not (main.get("pos_cm", null) is Vector3): - return - var mc: Vector3 = main["pos_cm"] - var main_cm := Vector2(mc.x, mc.y) - var cull := float(CHAR_STAGE_VIEW_BOUND + 10) - for e in client.get_entities(): - var vid := int(e.get("vid", 0)) - if vid == 0 or vid == _local_vid or _fading.has(vid): - continue - var has_node := _by_vid.has(vid) - var forced := vid == _main_vid or bool(e.get("is_main", false)) \ - or _show_always(e) or _is_wall(int(e.get("race", 0))) - var dist := INF - if e.get("pos_cm", null) is Vector3: - dist = _planar_cm(e).distance_to(main_cm) - if forced or dist < CHAR_STAGE_VIEW_BOUND: - if not has_node: - _on_spawn(e, true) - elif has_node and dist > cull: - _fade_and_free(vid) - -# Distance test used both by the per-frame manager and the _on_spawn gate. -func _is_visible_entity(e: Dictionary) -> bool: - var vid := int(e.get("vid", 0)) - if vid == _main_vid or bool(e.get("is_main", false)): - return true - if _show_always(e) or _is_wall(int(e.get("race", 0))): - return true - # Fail open when the data layer can't be queried or the main character has not - # been located yet — keeps the spawn burst / fake-client tests behaving as - # before; real gating kicks in once M2Client reports pos_cm + a main vid. - if client == null or not client.has_method("get_entities") or _main_vid == 0: - return true - var main: Dictionary = client.get_entity(_main_vid) - if main.is_empty() or not (main.get("pos_cm", null) is Vector3) \ - or not (e.get("pos_cm", null) is Vector3): - return true - var mc: Vector3 = main["pos_cm"] - return _planar_cm(e).distance_to(Vector2(mc.x, mc.y)) < CHAR_STAGE_VIEW_BOUND - -func _planar_cm(e: Dictionary) -> Vector2: - var p: Variant = e.get("pos_cm", null) - return Vector2(p.x, p.y) if p is Vector3 else Vector2.ZERO - -func _show_always(e: Dictionary) -> bool: - return (int(e.get("affect_flags", 0)) & AFFECT_SHOW_ALWAYS) != 0 - -func _is_wall(race: int) -> bool: - return race in WALL_RACES - -func _detach_text_tail(n: Node3D) -> void: - if not is_instance_valid(n): - return - for tag_name in ["NameTag", "GuildTag", "TitleTag", "LevelTag", "GuildMark", "Shadow", "ShopSign"]: - var node := n.get_node_or_null(tag_name) - if node != null: - node.visible = false - -# §2.1 cull / 40250 DeleteBlendOut: keep the data row, blend the old node out. -# _fading tracks the old dead-list equivalent until its tween frees it; a new -# same-VID AppendActor is allowed to build a live replacement immediately. -func _fade_and_free(vid: int) -> void: - if vid == _target_vid: - _detach_target_effect() - _sync_push.erase(vid) - var n: Node3D = _by_vid.get(vid, null) - _by_vid.erase(vid) - if not is_instance_valid(n): - _fading.erase(vid) - return - _fading[vid] = true - _set_network_state_gate(n, false, true, false, true) - _set_entity_knockdown(vid, false) - entity_removed.emit(vid) - _remove_hp_bar(vid) - _detach_text_tail(n) - var tw := create_tween() - tw.tween_property(n, "scale", Vector3.ONE * 0.01, 0.4) - tw.parallel().tween_property(n, "position:y", n.position.y - 0.5, 0.4) - tw.tween_callback(func() -> void: - if is_instance_valid(n): - n.queue_free() - _fading.erase(vid)) - -func _refresh_shop_sign(root: Node3D, sign: String) -> void: - var tag := root.get_node_or_null("ShopSign") as Label3D - var text := sign.strip_edges() - if text == "": - if tag: - tag.queue_free() - return - if tag == null: - tag = Label3D.new() - tag.name = "ShopSign" - tag.position.y = 3.0 - tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED - tag.no_depth_test = true - tag.pixel_size = 0.0055 - tag.modulate = Color(1.0, 0.82, 0.25) - tag.outline_size = 8 - root.add_child(tag) - tag.text = text - -func _display_name(d: Dictionary) -> String: - if name_resolver.is_valid(): - var r: String = name_resolver.call(d) - if r != "": - return r - return str(d.get("name", "")) - -func _refresh_all_names() -> void: - if client == null or not client.has_method("get_entities"): - return - for e in client.get_entities(): - var vid := int(e.get("vid", 0)) - var n: Node3D = _by_vid.get(vid, null) - if n and is_instance_valid(n): - var nm := _display_name(e) - if nm != "": - var lbl := n.get_node_or_null("Label3D") as Label3D - if lbl: - lbl.text = nm - elif n.has_method("set_display_name"): - n.set_display_name(nm) - -# --- §8.8 头顶名字色(CInstanceBase::GetNameColor)-------------------------------- -# net_world 直接持有 M2Client,能自建 name_color_index 需要的会话上下文 -# (net_play._attack_ctx 同款)。kind_resolver 由 game_scene 注入 net_play._entity_kind -# 以补本 fork「NPC/怪 bType 都是 0」的分类缺口;未注入时退化到 ch_type。 - -func _entity_kind_of(d: Dictionary) -> int: - if kind_resolver.is_valid(): - return int(kind_resolver.call(d)) - return int(d.get("ch_type", 0)) - -func _name_color_ctx() -> Dictionary: - var duel: Dictionary = client.get_duel() if client and client.has_method("get_duel") else {} - var duel_mode := EntityRules.DUEL_NONE - if bool(duel.get("active", false)): - duel_mode = EntityRules.DUEL_CANNOTATTACK if bool(duel.get("cannot_attack", false)) \ - else EntityRules.DUEL_START - var pvp_pairs: Array = [] - if client and client.has_method("get_pvp_relations"): - for r in client.get_pvp_relations(): - if int(r.get("mode", 0)) != 0: - pvp_pairs.append([int(r.get("src_vid", 0)), int(r.get("dst_vid", 0))]) - var gvg_pairs: Array = [] - if client and client.has_method("get_guild_wars"): - for w in client.get_guild_wars(): - gvg_pairs.append([int(w.get("src", 0)), int(w.get("dst", 0))]) - var party_vids: Array = [] - if client and client.has_method("get_party"): - for m in client.get_party(): - party_vids.append(int(m.get("vid", 0))) - return { - "main_vid": _main_vid, - "duel_mode": duel_mode, - "duel_opponents": duel.get("opponents", []), - "pvp_pairs": pvp_pairs, - "gvg_pairs": gvg_pairs, - "party_vids": party_vids, - } - -func _main_name_snapshot() -> Dictionary: - if _main_vid == 0 or client == null or not client.has_method("get_entity"): - return {} - var m: Dictionary = client.get_entity(_main_vid) - if m.is_empty(): - return {} - m = m.duplicate() - m["kind"] = _entity_kind_of(m) - return m - -func _apply_name_color(vid: int) -> void: - if vid == 0: - return - var n: Node3D = _by_vid.get(vid, null) - if n == null or not is_instance_valid(n) or not n.has_node("Label3D"): - return - if client == null or not client.has_method("get_entity"): - return - var e: Dictionary = client.get_entity(vid) - if e.is_empty(): - return - var self_e := e.duplicate() - self_e["kind"] = _entity_kind_of(e) - var col := NameColor.name_color(self_e, _main_name_snapshot(), _name_color_ctx()) - (n.get_node("Label3D") as Label3D).modulate = col - -func _retint_all_names() -> void: - if client == null or not client.has_method("get_entity"): - return - var ctx := _name_color_ctx() - var main_e := _main_name_snapshot() - for vid in _by_vid.keys(): - var n: Node3D = _by_vid[vid] - if not is_instance_valid(n) or not n.has_node("Label3D"): - continue - var e: Dictionary = client.get_entity(int(vid)) - if e.is_empty(): - continue - var self_e := e.duplicate() - self_e["kind"] = _entity_kind_of(e) - (n.get_node("Label3D") as Label3D).modulate = NameColor.name_color(self_e, main_e, ctx) - -# constinfo.SET_CHRNAME_COLOR_INDEX 对应的运行期开关(默认关)。改后立即整表重刷。 -func set_empire_name_mode(enabled: bool) -> void: - NameColor.set_empire_name_mode(enabled) - _retint_all_names() - -# --- §8.8 头顶「公会名 / 称号 / 等级」标签(CInstanceBase::RefreshTextTail 系)-------- -# _apply_name_color 的姊妹:把 self_e 快照喂给 text_tail.gd,落到 GuildTag/TitleTag/LevelTag。 - -func _resolve_title_name(grade: int) -> String: - if title_name_resolver.is_valid(): - var s: String = str(title_name_resolver.call(grade)) - if s != "": - return s - return TextTail.title_name_key(grade) # seam ⑧:未注入 Locale -> 裸 key - -func _resolve_guild_name(guild_id: int) -> String: - if guild_id != 0 and client and client.has_method("get_guild_name"): - return str(client.get_guild_name(guild_id)) - return "" - -func _apply_text_tail_to(n: Node3D, e: Dictionary) -> void: - if n == null or not is_instance_valid(n): - return - var alignment := int(e.get("alignment", 0)) - var level := int(e.get("level", 0)) - var guild_id := int(e.get("guild", 0)) - var shown := _name_shown(n) - - var gtag := n.get_node_or_null("GuildTag") as Label3D - if gtag: - var g := TextTail.refresh_guild(guild_id, _resolve_guild_name(guild_id)) - gtag.text = g["name"] if g["show_guild"] else "" - gtag.modulate = g["color"] - gtag.visible = shown and gtag.text != "" - - var ttag := n.get_node_or_null("TitleTag") as Label3D - if ttag: - var t := TextTail.refresh_title(alignment) - ttag.text = _resolve_title_name(int(t["grade"])) if t["show_title"] else "" - ttag.modulate = t["color"] - ttag.visible = shown and ttag.text != "" - - var ltag := n.get_node_or_null("LevelTag") as Label3D - if ltag: - var lv := TextTail.refresh_level(level) - ltag.text = lv["text"] if lv["show_level"] else "" - ltag.modulate = lv["color"] - ltag.visible = shown and ltag.text != "" - - _apply_guild_mark(n, guild_id, gtag, shown) - _layout_subtags_x(n, ttag, ltag) - # Keep the mark on the same adjusted screen row as the name immediately on - # spawn/info refresh, not only after the next chat-tail update. - _reposition_name_tag(n) - -# ArrangeTextTail 角色块(PythonTextTail.cpp:211-320 非 EUROPE 分支)的逐像素水平排布: -# 称号在名字锚点左侧 nameW/2 + 8、等级再往左一个称号宽(无称号时 nameW/2 + 4)、 -# 名字本身右移 fxAdd(8 / 4 / 0)。公会名的水平锚点参考端只在「有公会徽记」时才改写, -# poc 无徽记 -> GuildTag 留 x = 0(名字中线正上方)。文本宽度取各 Label3D 的真实字体度量 -# (TextMetrics.label_width = GetTextSize 1:1,增量 123;SUBTAG_CHAR_W_PX 仅作无字体兜底)。 -# 经 name_tail_layout.gd 纯静态镜像。 -func _layout_subtags_x(n: Node3D, ttag: Label3D, ltag: Label3D) -> void: - var nlbl := n.get_node_or_null("Label3D") as Label3D - var gtag := n.get_node_or_null("GuildTag") as Label3D - var mark := n.get_node_or_null("GuildMark") as Sprite3D - var has_title := ttag != null and ttag.text != "" - var has_level := ltag != null and ltag.text != "" - var name_w := 0.0 - if nlbl: - name_w = maxf(1.0, TextMetrics.label_width(nlbl, SUBTAG_CHAR_W_PX)) - var title_w := 0.0 - if has_title: - title_w = maxf(1.0, TextMetrics.label_width(ttag, SUBTAG_CHAR_W_PX)) - if nlbl: - nlbl.position.x = NameTailLayout.subtag_world_x( - NameTailLayout.name_x_shift(has_title, has_level), nlbl.pixel_size) - if ttag: - ttag.position.x = NameTailLayout.subtag_world_x( - NameTailLayout.title_x_offset(name_w), ttag.pixel_size) if has_title else 0.0 - if ltag: - ltag.position.x = NameTailLayout.subtag_world_x( - NameTailLayout.level_x_offset(name_w, title_w, has_title), ltag.pixel_size) if has_level else 0.0 - if gtag: - if mark != null and mark.texture != null and gtag.text != "": - var guild_w := maxf(1.0, TextMetrics.label_width(gtag, SUBTAG_CHAR_W_PX)) - var mark_w := maxf(1.0, float(mark.texture.get_width())) - gtag.position.x = NameTailLayout.subtag_world_x( - NameTailLayout.guild_name_x_offset(mark_w), gtag.pixel_size) - mark.position.x = NameTailLayout.subtag_world_x( - NameTailLayout.mark_x_offset(guild_w, mark_w), mark.pixel_size) - else: - gtag.position.x = 0.0 # 无已下载会徽时保持参考端 if(pMark && pGuild) 不成立 - if mark: - mark.position.x = 0.0 - -func _apply_guild_mark(n: Node3D, guild_id: int, guild_tag: Label3D, shown: bool) -> void: - # CPythonTextTail only creates pMarkInstance when a guild id resolves to a - # downloaded mark. Do not leave a stale image after a guild change or a - # mark-table refresh that no longer contains this guild. - var mark := n.get_node_or_null("GuildMark") as Sprite3D - if guild_id == 0 or guild_tag == null or guild_tag.text == "" \ - or client == null or not client.has_method("get_guild_mark_image"): - if mark: - mark.queue_free() - return - var img: Image = client.get_guild_mark_image(guild_id) - if img == null or img.get_width() <= 0 or img.get_height() <= 0: - if mark: - mark.queue_free() - return - if mark == null: - mark = Sprite3D.new() - mark.name = "GuildMark" - mark.billboard = BaseMaterial3D.BILLBOARD_ENABLED - mark.no_depth_test = true - mark.pixel_size = 0.005 - mark.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST - n.add_child(mark) - mark.texture = ImageTexture.create_from_image(img) - mark.visible = shown - mark.set_meta("guild_id", guild_id) - -func _refresh_guild_mark(guild_id: int) -> void: - if guild_id == 0 or client == null or not client.has_method("get_entities"): - return - for vid in _by_vid.keys(): - var n: Node3D = _by_vid[vid] - if not is_instance_valid(n): - continue - var e: Dictionary = client.get_entity(int(vid)) - if int(e.get("guild", 0)) == guild_id: - _apply_text_tail_to(n, e) - -func _apply_text_tail(vid: int) -> void: - if vid == 0 or client == null or not client.has_method("get_entity"): - return - var n: Node3D = _by_vid.get(vid, null) - if n == null or not is_instance_valid(n): - return - var e: Dictionary = client.get_entity(vid) - if e.is_empty(): - return - _apply_text_tail_to(n, e) - -func _retint_all_text_tails() -> void: - if client == null or not client.has_method("get_entity"): - return - for vid in _by_vid.keys(): - var n: Node3D = _by_vid[vid] - if not is_instance_valid(n): - continue - var e: Dictionary = client.get_entity(int(vid)) - if e.is_empty(): - continue - _apply_text_tail_to(n, e) - -# CPythonTextTail::EnablePKTitle(game.py:155 用 constInfo.PVPMODE_ENABLE 初始化)。 -func set_pk_title_enabled(enabled: bool) -> void: - TextTail.set_pk_title_enabled(enabled) - _retint_all_text_tails() - -func _on_despawn(vid: int) -> void: - _upgrade_queue.erase(vid) - _duel_opponents.erase(vid) - _chat_tails.erase(vid) - _push.erase(vid) - _sync_push.erase(vid) - _remove_hp_bar(vid) - if _target_vid == vid: - _target_vid = 0 - if _hover_vid == vid: - _hover_vid = 0 - var despawned: Node3D = _by_vid.get(vid, null) - if _fly_mgr and is_instance_valid(despawned) and _fly_mgr.has_method("notify_target_despawn"): - _fly_mgr.notify_target_despawn(despawned) - if _by_vid.has(vid): - # CNetworkActorManager::__RemoveCharacterManagerActor deletes the - # selected/main actor immediately, while remote actors use - # DeleteInstanceByFade and remain in the dead list during blend-out. - # Keeping this branch explicit also prevents a stale main-character node - # from receiving one more frame of movement after a server delete. - if vid == _main_vid: - _delete_now(vid) - else: - _fade_and_free(vid) - -func _delete_now(vid: int) -> void: - if vid == _target_vid: - _detach_target_effect() - _sync_push.erase(vid) - _fading.erase(vid) - var n: Node3D = _by_vid.get(vid, null) - _by_vid.erase(vid) - if not is_instance_valid(n): - return - _set_network_state_gate(n, false, false, false, true) - _set_entity_knockdown(vid, false) - entity_removed.emit(vid) - _remove_hp_bar(vid) - _detach_text_tail(n) - n.queue_free() - -func _on_main_set(vid: int) -> void: - # CNetworkActorManager::SetMainActorVID resets the main position and clears - # every live actor entry before the new loading burst is appended. A - # different main VID is a lifecycle boundary even when the outer scene has - # not emitted world_reset yet; retaining the old actor nodes would leave - # target/hover/HP/text-tail state attached to the previous character. - if _main_vid != 0 and _main_vid != vid: - _detach_target_effect() - _target_vid = 0 - _hover_vid = 0 - for old_vid in _by_vid.keys().duplicate(): - _delete_now(int(old_vid)) - _upgrade_queue.clear() - _sync_push.clear() - _push.clear() - _damage_queue.clear() - _chat_tails.clear() - _fading.clear() - _main_vid = vid - var n: Node3D = _by_vid.get(vid, null) - if n: - main_entity_ready.emit(n, vid) - # 主角变化会改写所有 PC 的相对名字色(同帝国 / PVP / 决斗 / 队伍判定都相对主角)。 - _retint_all_names() - -# CPythonCharacterManager::ShowPointEffect. The source client registers these -# exact MSE assets for CInstanceBase::LevelUp / SkillUp; keep the VID target -# resolution identical for remote actors and the local player. -func _on_point_changed(vid: int, point_type: int, _value: int, _amount: int) -> void: - var effect_path := "" - if point_type == 1: - effect_path = "d:/ymir work/effect/etc/levelup_1/level_up.mse" - elif point_type == 25: - effect_path = "d:/ymir work/effect/etc/skillup/skillup_1.mse" - else: - return - point_effect.emit(vid, point_type) - var n: Node3D = _by_vid.get(vid, null) - if n == null and vid == _local_vid: - n = _local_node - if n == null or not is_instance_valid(n): - return - n.set_meta("last_point_effect", point_type) - if point_type == 1: - # RecvPointChange's non-main branch calls SetLevel and - # UpdateTextTailLevel immediately after the effect dispatch. - _apply_text_tail(vid) - if fx != null and fx.has_method("spawn"): - fx.spawn(effect_path, n, true) - -func _on_moved(_vid: int) -> void: - # Normal movement is consumed by _process. GC_SYNC_POSITION has an explicit - # serial because TEMP_Push must be distinct from every ordinary Move tick. - var vid := int(_vid) - var n: Node3D = _by_vid.get(vid, null) - if n == null or not is_instance_valid(n) or client == null \ - or not client.has_method("get_entity"): - return - var e: Dictionary = client.get_entity(vid) - if e.is_empty(): - return - var serial := int(e.get("sync_push_serial", 0)) - if serial == int(n.get_meta("sync_push_serial", 0)): - return - n.set_meta("sync_push_serial", serial) - _apply_sync_push(n, e) - -# GC_MOTION and ServerCommand emotion commands share CRaceMotionData IDs in -# the 40250 client. Keep the raw event visible to gameplay and ask real PC -# views to play the matching one-shot action; placeholders still retain the -# authoritative motion metadata and state tint. -func _on_motion(vid: int, victim_vid: int, motion: int) -> void: - var n: Node3D = _by_vid.get(vid, null) - if n == null and vid == _local_vid: - n = _local_node - motion_event.emit(vid, victim_vid, motion) - if n == null: - return - n.set_meta("last_motion", motion) - n.set_meta("motion_victim_vid", victim_vid) - var target: Node3D = _by_vid.get(victim_vid, null) - if target and target != n: - var flat := target.global_position - flat.y = n.global_position.y - if n.global_position.distance_squared_to(flat) > 0.001: - n.look_at(flat, Vector3.UP) - var target_race := -1 - if client: - var target_entity: Dictionary = client.get_entity(victim_vid) - if not target_entity.is_empty(): - target_race = int(target_entity.get("race", -1)) - var applied := false - if n.has_method("set_motion_id"): - applied = bool(n.call("set_motion_id", motion, target_race)) - if not applied: - var state := "attack" - if motion >= 305: - state = "emotion" - elif motion >= 1 and motion <= 3: - state = "wait" if motion == 1 else ("walk" if motion == 2 else "run") - elif motion == 5 or motion == 6 or motion == 8 or motion == 9: - state = "damage" - elif motion == 11 or motion == 12: - state = "dead" - if n.has_method("set_anim_state"): - n.call("set_anim_state", state) - else: - n.set_meta("motion_state", state) - -# A one-shot remote skill/emotion releases the network-state queue from the -# animation tail, not from a timer. This is the Godot equivalent of -# CActorInstance::OnMotionEvent -> CInstanceBase::StateProcess becoming -# eligible again after __SetMotion finishes. -func _on_remote_motion_bound(state: String, vid: int) -> void: - if state != "wait": - return - var n: Node3D = _by_vid.get(vid, null) - if n != null and bool(n.get_meta("sync_push_reaction_active", false)): - n.set_meta("sync_push_reaction_active", false) - _set_entity_knockdown(vid, false) - _set_network_state_gate(n, false, true, false, true) - -func _set_entity_knockdown(vid: int, on: bool) -> void: - if client == null or not client.has_method("set_entity_knockdown"): - return - client.call("set_entity_knockdown", vid, on) - -# 40250 CActorInstance::TEMP_Push. The native entity row records the source -# position and the >150cm / IsUsingSkill decision. The map owns the only -# engine-dependent part: IPhysicsWorld's 100-point probe and the blending -# offset consumed by PhysicsProcess/GetBlendingPosition. -func _apply_sync_push(n: Node3D, e: Dictionary) -> void: - var vid := int(n.get_meta("vid", 0)) - var source_raw: Variant = e.get("sync_push_from_cm", Vector3.ZERO) - var target_raw: Variant = e.get("pos_cm", Vector3.ZERO) - if not (source_raw is Vector3) or not (target_raw is Vector3): - return - var source := Vector2(float(source_raw.x), float(source_raw.y)) - var target := Vector2(float(target_raw.x), float(target_raw.y)) - if _sync_push_collision(source, target): - # TEMP_Push calls ResetBlendingPosition and returns on the first blocked - # sample. The authoritative NetworkActorData target still wins below. - _sync_push.erase(vid) - return - - var delta := source - target - if delta.length_squared() > 0.0001: - var obj = PhysicsPush.new() - obj.set_last_position(delta, 1.0) # SetBlendingPosition default time - _sync_push[vid] = {"obj": obj, "off": delta, - "base": e.get("pos", Vector3.ZERO)} - else: - _sync_push.erase(vid) - - var large := bool(e.get("sync_push_large", false)) - var skill_active := bool(e.get("sync_push_skill_active", false)) - if large and not skill_active: - n.set_meta("sync_push_reaction", "knockdown") - var started := false - if n.has_method("sync_push_motion"): - started = bool(n.call("sync_push_motion")) - elif n.has_method("set_anim_state"): - n.call("set_anim_state", "knockdown") - started = true - if started: - n.set_meta("sync_push_reaction_active", true) - _set_entity_knockdown(vid, true) - -func _sync_push_collision(source: Vector2, target: Vector2) -> bool: - # In headless tests there is no IPhysicsWorld; the reference also returns - # before blending when its physics world is absent, but this client has no - # visual world in that mode, so fail open and keep the authoritative correction. - if world == null or not world.has_method("is_blocked"): - return false - for i in 100: - var p := source.lerp(target, float(i) / 100.0) - var w := MapCoord.to_world(Vector3(p.x * 0.01, 0.0, -p.y * 0.01)) - if bool(world.call("is_blocked", w.x, w.z)): - return true - return false - -func _set_network_state_gate(n: Node3D, skill_active: bool, skill_can_cancel: bool, - acting_emotion: bool, tcp_state_enabled: bool) -> void: - if client == null or not client.has_method("set_entity_network_state_gate") or n == null: - return - var vid := int(n.get_meta("vid", 0)) - if vid == 0: - return - client.call("set_entity_network_state_gate", vid, skill_active, skill_can_cancel, - acting_emotion, tcp_state_enabled) - -# GC_PVP: 显示当前与任一角色的挑战 / 战斗 / 复仇关系。原客户端同时会影响 -# TargetBoard 和名字颜色;这里先提供始终可见的世界标签,避免关系状态无表现。 -func _on_pvp_changed(src_vid: int, dst_vid: int, mode: int) -> void: - if src_vid == 0 or dst_vid == 0: - return - var key := _pvp_key(src_vid, dst_vid) - if mode == 0: - _pvp_relations.erase(key) - else: - _pvp_relations[key] = {"src_vid": src_vid, "dst_vid": dst_vid, "mode": mode} - _refresh_pvp_tag(src_vid) - _refresh_pvp_tag(dst_vid) - _apply_name_color(src_vid) - _apply_name_color(dst_vid) - -func _pvp_key(a: int, b: int) -> String: - return "%d:%d" % [min(a, b), max(a, b)] - -func _pvp_mode_for(vid: int) -> int: - for relation in _pvp_relations.values(): - if int(relation.get("src_vid", 0)) == vid or int(relation.get("dst_vid", 0)) == vid: - return int(relation.get("mode", 0)) - return 0 - -func _refresh_pvp_tag(vid: int) -> void: - var n: Node3D = _by_vid.get(vid, null) - if n == null: - return - var mode := _pvp_mode_for(vid) - var tag := n.get_node_or_null("PvpTag") as Label3D - var in_duel := _duel_opponents.has(vid) - if mode == 0 and not in_duel: - if tag: - tag.queue_free() - return - if tag == null: - tag = Label3D.new() - tag.name = "PvpTag" - tag.position.y = 2.7 - tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED - tag.no_depth_test = true - tag.pixel_size = 0.0045 - n.add_child(tag) - if in_duel: - tag.text = "决斗" - tag.modulate = Color(0.45, 0.85, 1.0) - return - match mode: - 1: - tag.text = "挑战" - tag.modulate = Color(1.0, 0.85, 0.25) - 2: - tag.text = "PVP" - tag.modulate = Color(1.0, 0.25, 0.25) - 3: - tag.text = "复仇" - tag.modulate = Color(1.0, 0.45, 0.1) - _: - tag.text = "PVP" - tag.modulate = Color.WHITE - -func _on_duel_changed(duel: Dictionary) -> void: - _duel_opponents.clear() - _duel_cannot_attack = bool(duel.get("cannot_attack", false)) - for vid in duel.get("opponents", []): - var opponent := int(vid) - if opponent != 0: - _duel_opponents[opponent] = true - for vid in _by_vid.keys(): - _refresh_pvp_tag(int(vid)) - _apply_name_color(int(vid)) - -# 头顶聊天 / 信息尾标(CPythonTextTail::RegisterChatTail / RegisterInfoTail)。 -# 每 vid 一条 "bubble" 子节点,LivingTime 到点(默认 5s)由 _expire_chat_tails 硬删—— -# 参考端无淡出。本地玩家 vid 走 main_bubble 信号让上层(game_scene._player_bubble)处理。 -signal main_bubble(text: String) - -func _bubble(vid: int, text: String) -> void: - # CPythonTextTail::RegisterChatTail —— 普通聊天尾标(白、bNameFlag TRUE) - if vid == _local_vid or vid == 0: - main_bubble.emit(text) - return - var n: Node3D = _by_vid.get(vid, null) - if n == null: - return - _apply_chat_tail(n, ChatTail.register_chat(_chat_tails, vid, text, Time.get_ticks_msec())) - -func register_info_tail(vid: int, text: String) -> void: - # CPythonTextTail::RegisterInfoTail —— 信息尾标(淡红、bNameFlag FALSE)。 - # game.py:930 / :936 技能 / 射击错误尾标走这里;与聊天尾标共用同一张 _chat_tails。 - if vid == _local_vid or vid == 0: - main_bubble.emit(text) - return - var n: Node3D = _by_vid.get(vid, null) - if n == null: - return - _apply_chat_tail(n, ChatTail.register_info(_chat_tails, vid, text, Time.get_ticks_msec())) - -func _apply_chat_tail(n: Node3D, tail: Dictionary) -> void: - var lbl := n.get_node_or_null("bubble") as Label3D - if lbl == null: - lbl = Label3D.new() - lbl.name = "bubble" - lbl.position.y = BUBBLE_TAG_Y - lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED - lbl.no_depth_test = true - lbl.pixel_size = 0.0055 - lbl.outline_size = 6 - n.add_child(lbl) - lbl.text = String(tail.get("text", "")) - lbl.modulate = tail.get("color", ChatTail.CHAT_COLOR) - # UpdateAllTextTail:chat 尾标 bNameFlag TRUE 时强制显示该角色名字(不受 3500 限制)。 - _set_name_visibility(n) - # UpdateShowingTextTail :110-114:有「聊天」气泡(bNameFlag)时名字尾标顶到气泡上方 17px。 - _reposition_name_tag(n) - -func _expire_chat_tails() -> void: - # UpdateAllTextTail:LivingTime < now 即删除对应 "bubble" 节点 - for vid in ChatTail.expire(_chat_tails, Time.get_ticks_msec()): - var n: Node3D = _by_vid.get(vid, null) - if n: - var lbl := n.get_node_or_null("bubble") - if lbl: - lbl.queue_free() - # 尾标没了 -> bNameFlag 强显撤销,名字回到 __IsShowName / 距离裁决。 - _set_name_visibility(n) - # 气泡没了 -> 名字尾标从「贴气泡上方」回默认局部 y(chat 尾标不在 - # m_ChatTailMap 里,UpdateShowingTextTail :110 的 find 落空)。 - _reposition_name_tag(n) - -# CPythonTextTail::UpdateShowingTextTail :101-118 —— 角色名字尾标投影后,若该 VID 有一条 -# 「聊天」气泡(m_ChatTailMap 命中且 bNameFlag TRUE,即 RegisterChatTail 而非 -# RegisterInfoTail),把名字尾标顶到气泡上方 17px(屏幕 y - 17 → poc 世界 y + 17*pixel_size)。 -# 经 name_tail_layout.gd 纯静态镜像。 -func _reposition_name_tag(n: Node3D) -> void: - if n == null or not is_instance_valid(n): - return - var tag := n.get_node_or_null("Label3D") as Label3D - if tag == null: - return - var vid := int(n.get_meta("vid", 0)) - var ct: Variant = _chat_tails.get(vid, null) - var has_tail := ct is Dictionary - var name_flag := has_tail and bool((ct as Dictionary).get("name_flag", false)) - var follows := NameTailLayout.name_follows_bubble(has_tail, name_flag) - tag.position.y = NameTailLayout.name_tail_world_y( - NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size) - # ArrangeTextTail :228/:258/:283 —— 公会名 / 称号 / 等级子标签都锚在名字尾标 pTextTail->y - # 上,名字因聊天气泡上移时三者整体跟随同一段位移(seam ⑦ 仅剩竖排像素排布近似)。 - var lift := NameTailLayout.name_tail_lift_delta( - NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size) - for sub_name in SUBTAG_DEFAULT_Y: - var st := n.get_node_or_null(sub_name) as Label3D - if st: - st.position.y = NameTailLayout.subtag_world_y(SUBTAG_DEFAULT_Y[sub_name], lift) - var mark := n.get_node_or_null("GuildMark") as Sprite3D - if mark: - # ArrangeTextTail :227 puts the mark 26 px above the name instance; it - # follows the same already-adjusted name y when a chat tail is active. - mark.position.y = NAME_TAG_DEFAULT_Y + NameTailLayout.MARK_ABOVE_NAME_PX * tag.pixel_size + lift - -func _on_vitals(vid: int) -> void: - var n: Node3D = _by_vid.get(vid, null) - var e: Dictionary = client.get_entity(vid) - if e.is_empty(): - return - var hp := int(e.get("hp", 0)) - var max_hp := int(e.get("max_hp", 0)) - var dead := bool(e.get("dead", false)) - vitals.emit(vid, hp, max_hp, dead) - _sync_hp_bar_values(vid, hp, max_hp, dead) - if dead or (max_hp > 0 and hp <= 0): - if n and is_instance_valid(n) and not bool(n.get_meta("dead", false)): - _on_dead(vid) - -func _on_dead(vid: int) -> void: - var n: Node3D = _by_vid.get(vid, null) - if n == null: - return - # 40250 CInstanceBase::Die() -> DetachTextTail(), OnUnselected(), OnUntargeted() - _remove_hp_bar(vid) - _detach_text_tail(n) - n.set_meta("dead", true) - n.set_meta("can_pick", false) - - if _target_vid == vid: - _target_vid = 0 - if _hover_vid == vid: - _hover_vid = 0 - - # CActorInstance::Die 只 InterceptOnceMotion(NAME_DEAD),不倾倒模型; - # 只有没有动作的占位胶囊才用倒地作为唯一的死亡表现。 - if n.has_method("set_anim_state"): - n.call("set_anim_state", "dead") - else: - var t := create_tween() - t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3) - - # 40250 UpdateDeleting / DeleteInstanceByFade: - # 尸体倒地停留 2.5 秒后平滑渐隐沉地释放;若服务端提前到达 GC_CHARACTER_DEL 则在 _on_despawn 中直接 fade_and_free。 - var tw := create_tween() - tw.tween_interval(2.5) - tw.tween_callback(func() -> void: - if is_instance_valid(n) and _by_vid.get(vid) == n: - _fade_and_free(vid)) - -# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在 -# GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target VID=0 时是坐标),所以这里也消费 -# shooter 的目标队列,避免远端技能没有终点被静默丢弃。§3.6:交给 FlyManager 跑真实弹道 -# (CFlyingManager::CreateIndexedFly 的 INDEX_FLY_TYPE_NORMAL 分支:起点取射手像素位、 -# 终点取目标 actor、canAttack=false —— 命中 / 伤害是服务端权威,客户端只做可视 + 命中时机)。 -func _indexed_fly_data(index: int): - if _fly_data_cache.has(index): - return _fly_data_cache[index] - var source: String = String(INDEXED_FLY_FILES.get(index, "")) - if source == "": - return null - var data = FlyObject.FlyData.load_msf(source, _assets_root) - if data == null: - # 40250 RegisterIndexedFlyData/RegisterFlyingData 失败时不会创建实例; - # 不回退到脚本常量,避免资源缺失被伪装成另一种弹道。 - return null - _fly_data_cache[index] = data - return data - -func _indexed_fly_type(index: int) -> int: - return int(INDEXED_FLY_TYPES.get(index, FlyObject.INDEX_FLY_TYPE_NORMAL)) - -func _firecracker_launch(start: Vector3, shooter: Node3D) -> Dictionary: - # 40250 FlyingObjectManager.cpp uses the actor's D3D heading - 90 degrees, - # then maps the horizontal D3D Y axis to the Godot world -Z axis. In the - # Godot frame heading 0 therefore points +Z and heading 90 points +X. - var rotation_deg := fposmod(rad_to_deg(shooter.rotation.y) - 90.0 + 360.0, 360.0) - rotation_deg += randf_range(-30.0, 30.0) - var rotation_rad := deg_to_rad(rotation_deg) - var distance := randf_range(20.0, 50.0) # 2000..5000cm - var horizontal := Vector3(cos(rotation_rad), 0.0, -sin(rotation_rad)) * distance - var height := randf_range(10.0, 25.0) # 1000..2500cm - return { - "start": start + Vector3.UP * 2.0, # +200cm - "target": start + horizontal + Vector3.UP * height, - } - -func _on_fly(_type: int, start_vid: int, end_vid: int) -> void: - var a: Vector3 = _entity_pos(start_vid) - var b: Vector3 = _entity_pos(end_vid) - # 本地主角按 40250 的单实例规则不在 _by_vid 中;经验球的 end_vid 正是主角, - # 必须指向 _local_node 才会追踪胸口并触发 at_target/吸收效果。 - var target_node: Node3D = _local_node if end_vid == _local_vid and is_instance_valid(_local_node) \ - else _by_vid.get(end_vid, null) - var shooter: Node3D = _local_node if start_vid == _local_vid and is_instance_valid(_local_node) \ - else _by_vid.get(start_vid, null) - # PythonNetworkStreamPhaseGame::RecvCreateFlyPacket calls GetInstancePtr for - # both VIDs and silently returns when either CInstanceBase is absent. Do not - # replace that actor-instance gate with an EntityStore coordinate fallback. - if not is_instance_valid(shooter): - return - if end_vid != 0 and not is_instance_valid(target_node): - return - if b == Vector3.INF and shooter: - var target: Dictionary = {} - var queued: Variant = shooter.get_meta("fly_target_queue", []) - if queued is Array and not queued.is_empty(): - target = queued.pop_front() - shooter.set_meta("fly_target_queue", queued) - var target_vid := int(target.get("vid", 0)) - if target_vid != 0: - b = _entity_pos(target_vid) - target_node = _by_vid.get(target_vid, null) - else: - var target_cm: Variant = target.get("cm", null) - if target_cm is Vector2: - b = _server_cm_to_world(target_cm) - target_node = null - if a == Vector3.INF or b == Vector3.INF or parent == null: - return - if _fly_mgr == null: - return - _fly_mgr.parent = parent - _fly_mgr.world = world - if fx != null and _fly_mgr.has_method("set_effect_registry"): - _fly_mgr.set_effect_registry(fx) - var data = _indexed_fly_data(_type) - if data == null: - return - # CFlyingManager::CreateIndexedFly 的三种分支必须保持创建参数一致: - # NORMAL (a, pEndActor) - # FIRE_CRACKER (a + 200cm, 随机位置) - # AUTO_FIRE (a + 100cm, pEndActor) - var launch_start := a - var tgt: Variant = target_node if is_instance_valid(target_node) else b - match _indexed_fly_type(_type): - FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER: - var firework := _firecracker_launch(a, shooter) - launch_start = firework["start"] - tgt = firework["target"] - FlyObject.INDEX_FLY_TYPE_AUTO_FIRE: - launch_start += Vector3.UP # +100cm - _fly_mgr.spawn(launch_start, tgt, false, data, _type, shooter, start_vid) - -# 40250 供单机/离线击杀或事件直接调用发射经验光球 -func spawn_exp_fly(from_pos: Vector3, to_node: Node3D = null, count: int = 3) -> void: - if _fly_mgr == null or parent == null: - return - _fly_mgr.parent = parent - _fly_mgr.world = world - if fx != null and _fly_mgr.has_method("set_effect_registry"): - _fly_mgr.set_effect_registry(fx) - - var start_w := from_pos + Vector3(0, 0.8, 0) - var tgt_node: Node3D = to_node - if tgt_node == null: - var main_v: int = client.get_main_vid() if client and client.has_method("get_main_vid") else 1 - tgt_node = _by_vid.get(main_v, null) - - var tgt_pos := (tgt_node.global_position if tgt_node.is_inside_tree() else tgt_node.position) if is_instance_valid(tgt_node) else start_w + Vector3(0, 1, 2) - var goal_w := tgt_pos + Vector3(0, 1.0, 0) - var tgt: Variant = tgt_node if is_instance_valid(tgt_node) else goal_w - - for k in range(count): - var offset = Vector3(randf_range(-0.25, 0.25), randf_range(0.0, 0.3), randf_range(-0.25, 0.25)) - var orb_d = FlyObject.FlyData.new() - orb_d.init_vel = 11.0 + k * 2.0 - orb_d.flat_range = maxf(start_w.distance_to(goal_w) * 2.5, 40.0) - orb_d.bomb_range = 0.8 - orb_d.is_homing = true - orb_d.homing_start_time = 0.05 + k * 0.04 - orb_d.homing_max_angle = 140.0 - orb_d.maintain_parallel = true - orb_d.pierce_count = 0 - _fly_mgr.spawn(start_w + offset, tgt, false, orb_d, FlyObject.FLY_EXP) - -func get_entity_node(vid: int) -> Node3D: - return _by_vid.get(vid, null) - -# GC_FLY_TARGETING / GC_ADD_FLY_TARGETING — retain the authoritative target on -# the shooter node so a later projectile cue can use it even before a target -# entity exists (target VID 0 carries an explicit coordinate). -func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void: - var shooter: Node3D = _by_vid.get(shooter_vid, null) - if shooter: - var queued: Array = [] - var previous: Variant = shooter.get_meta("fly_target_queue", []) - if append and previous is Array: - queued = previous - queued.append({"vid": target_vid, "cm": target_cm}) - shooter.set_meta("fly_target_queue", queued) - shooter.set_meta("fly_target_vid", target_vid) - shooter.set_meta("fly_target_cm", target_cm) - shooter.set_meta("fly_target_append", append) - fly_targeting.emit(shooter_vid, target_vid, target_cm, append) - -func _server_cm_to_world(cm: Vector2) -> Vector3: - var p := MapCoord.to_world(Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01)) - if world and world.has_method("sample_height"): - p.y = float(world.call("sample_height", p.x, p.z)) + 0.6 - else: - p.y = 0.6 - return p - -# 实体世界坐标(Godot 米);找不到返回 Vector3.INF -func _entity_pos(vid: int) -> Vector3: - # GameScene owns the main actor outside _by_vid. During the phase handoff the - # EntityStore row can briefly be absent while the native client still resolves - # GetMainInstancePtr() for fly/death presentation. - if vid == _local_vid and is_instance_valid(_local_node): - return _local_node.global_position if _local_node.is_inside_tree() else _local_node.position - var e: Dictionary = client.get_entity(vid) if client else {} - if not e.is_empty() and e.get("pos", null) is Vector3: - return MapCoord.to_world(e["pos"]) - var n = _by_vid.get(vid, null) - if is_instance_valid(n): - return (n as Node3D).global_position - return Vector3.INF - -func _on_dig_motion(vid: int, target_vid: int, count: int) -> void: - var n: Node3D = _by_vid.get(vid, null) - if n == null: - return - n.set_meta("dig_count", count) - n.set_meta("dig_target_vid", target_vid) - var target: Node3D = _by_vid.get(target_vid, null) - if target and target != n: - var flat := target.global_position - flat.y = n.global_position.y - if n.global_position.distance_squared_to(flat) > 0.001: - n.look_at(flat, Vector3.UP) - if n.has_method("set_anim_state"): - n.call("set_anim_state", "dig") - -# GC_FISHING: `info` is the fisher VID for motion subheaders. Keep the -# authoritative direction and state on the entity; real PlayerView/MobView -# assets may provide matching fishing animations, while placeholders still -# expose the event to gameplay/UI code. -func _on_fishing_event(subheader: int, info: int, dir: int) -> void: - # FISH carries an item vnum in `info`, not a fisher VID; leave it to the - # fishing UI / inventory event consumer. - if info == 0 or subheader == 5: - return - var n: Node3D = _by_vid.get(info, null) - if n == null: - return - n.set_meta("fishing_subheader", subheader) - n.set_meta("fishing_dir", dir) - var state := "fishing" - match subheader: - 1: - _clear_fish_emoticon(n) - state = "wait" # STOP - 2: - _show_fish_emoticon(n) - state = "fishing_react" # REACT - 3: - _clear_fish_emoticon(n) - state = "fishing_catch" # SUCCESS - 4: - _clear_fish_emoticon(n) - state = "fishing_fail" # FAIL - if n.has_method("set_anim_state"): - n.call("set_anim_state", state) - fishing_event.emit(info, subheader, dir) - -func _show_fish_emoticon(n: Node3D) -> void: - if n == null or not is_instance_valid(n): - return - var old_emo := n.get_node_or_null("FishEmoticon") - if old_emo != null: - old_emo.queue_free() - var assets_path := AssetRoot.path() if AssetRoot.available() else "" - var tex: Texture2D = null - if assets_path != "": - tex = UiAssets.load_tex(assets_path, "Effect/ymir work/effect/etc/emoticon/fish.dds") - var emo := Sprite3D.new() - emo.name = "FishEmoticon" - emo.billboard = BaseMaterial3D.BILLBOARD_ENABLED - emo.no_depth_test = true - emo.pixel_size = 0.008 - emo.position = Vector3(0, 2.4, 0) - if tex != null: - emo.texture = tex - n.add_child(emo) - emo.scale = Vector3(0.3, 0.3, 0.3) - var tw := emo.create_tween() - tw.tween_property(emo, "scale", Vector3(1.2, 1.2, 1.2), 0.15) - tw.tween_property(emo, "scale", Vector3(1.0, 1.0, 1.0), 0.1) - tw.tween_interval(2.5) - tw.tween_property(emo, "modulate:a", 0.0, 0.3) - tw.tween_callback(emo.queue_free) - -func _clear_fish_emoticon(n: Node3D) -> void: - if n == null or not is_instance_valid(n): - return - var emo := n.get_node_or_null("FishEmoticon") - if emo != null: - emo.queue_free() - -# GC_DAMAGE_INFO -> RecvDamageInfoPacket -> CInstanceBase::AddDamageEffect。 -# damage_number 信号是本移植扩展(战斗日志 / 测试消费),始终发; -# 飘字本体照 AddDamageEffect:仅 IsShowDamage() 时入 m_DamageQueue,ProcessDamage 每帧 pop 一条。 -func _on_damage(vid: int, amount: int, flag: int) -> void: - var crit := (flag & DamageEffect.DAMAGE_CRITICAL) != 0 - var dodge := (flag & DamageEffect.DAMAGE_DODGE) != 0 - damage_number.emit(vid, amount, crit, dodge) - if crit and _camera != null and _camera.has_method("shake"): - _camera.shake(0.05, 9.0) - if not DamageEffect.valid_amount(amount): - return # RecvDamageInfoPacket:damage < 0 -> TraceError,不处理 - if not DamageEffect.should_queue(_show_damage): - return # AddDamageEffect:!IsShowDamage() -> 不入队 - _damage_queue.append({ - "vid": vid, "amount": amount, "flag": flag, - "self": DamageEffect.is_self(vid, _main_vid), - "target": DamageEffect.is_target(vid, _target_vid), - }) - -# ProcessDamage:每帧 pop_front 一条。 -# 40250 官方 1:1(InstanceBaseEffect.cpp:150-205): -# DODGE|BLOCK -> MISS(自身 damage_miss.dds,目标 target_miss.dds); -# nontarget 数字 -> 不渲染; -# self / target 伤害数字按 FONT_WIDTH=30px 水平排布真实 DDS 纹理(damagevalue/), -# 纹理缺失时安全降级为 Label3D。 -func _process_damage_queue() -> void: - if _damage_queue.is_empty(): - return - var d: Dictionary = _damage_queue.pop_front() - var n: Node3D = _by_vid.get(int(d["vid"]), null) - # 40250 RecvDamageInfoPacket 不区分普通 actor 与 MainInstance: - # MainInstance 也会进入 CInstanceBase::AddDamageEffect。当前 GameScene - # 为避免重复挂载把本地主角从 _by_vid 中分离,因此必须使用同一条 - # _local_node 回退路径,否则本地主角自己的伤害数字会静默丢失。 - if n == null and int(d["vid"]) == _local_vid: - n = _local_node - if n == null or not is_instance_valid(n) or parent == null: - return - var info := DamageEffect.classify(int(d["flag"]), bool(d["self"]), bool(d["target"])) - if not info["show"]: - return # ProcessDamage:nontarget_ 后直接 return(不渲染) - - # self 数字:SELFDAMAGE / SELFDAMAGE2 交替(水平抖动交替) - var jitter := 0.0 - if bool(d["self"]) and info["kind"] == DamageEffect.Kind.DIGITS: - jitter = 0.3 if DamageEffect.self_damage_slot(_dmg_self_toggle) == 0 else -0.3 - _dmg_self_toggle = not _dmg_self_toggle - - var root_node := Node3D.new() - root_node.name = "dmg_%d" % int(d["vid"]) - parent.add_child(root_node) - root_node.global_position = n.global_position + Vector3(jitter, 1.9, 0) - - var assets_path := AssetRoot.path() if AssetRoot.available() else "" - var built_sprites := false - - if assets_path != "": - if info["kind"] == DamageEffect.Kind.MISS: - var miss_file := "damage_miss.dds" if bool(d["self"]) else "target_miss.dds" - var tex: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/" + miss_file) - if tex != null: - var sp := Sprite3D.new() - sp.texture = tex - sp.billboard = BaseMaterial3D.BILLBOARD_ENABLED - sp.no_depth_test = true - sp.pixel_size = 0.007 - root_node.add_child(sp) - built_sprites = true - elif info["kind"] == DamageEffect.Kind.DIGITS: - var digit_arr: Array = DamageEffect.digits(int(d["amount"])) - if digit_arr.is_empty(): - root_node.queue_free() - return - var prefix: String = String(info.get("type", "target_")) - var total_digits: int = digit_arr.size() - var sprites: Array[Sprite3D] = [] - var all_loaded := true - for i in total_digits: - var dig: int = int(digit_arr[i]) - var tex: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/%s%d.dds" % [prefix, dig]) - if tex == null: - all_loaded = false - break - var sp := Sprite3D.new() - sp.texture = tex - sp.billboard = BaseMaterial3D.BILLBOARD_ENABLED - sp.no_depth_test = true - sp.pixel_size = 0.007 - # 40250 matTrans._41 = -(FONT_WIDTH * index)(低位在右,高位在左)+ 水平居中 - var px_offset := -float(DamageEffect.FONT_WIDTH * i) + float(DamageEffect.FONT_WIDTH * (total_digits - 1)) * 0.5 - sp.position.x = px_offset * 0.007 - sprites.append(sp) - if all_loaded and not sprites.is_empty(): - for sp in sprites: - root_node.add_child(sp) - built_sprites = true - else: - for sp in sprites: - sp.queue_free() - - if not built_sprites: - var text := "MISS" - if info["kind"] == DamageEffect.Kind.DIGITS: - if DamageEffect.digits(int(d["amount"])).is_empty(): - root_node.queue_free() - return - text = str(int(d["amount"])) - var lbl := Label3D.new() - lbl.text = text - if int(d["flag"]) & DamageEffect.DAMAGE_CRITICAL != 0: - lbl.modulate = Color(1.0, 0.85, 0.2) - lbl.font_size = 46 - lbl.outline_size = 8 - lbl.outline_modulate = Color(0.3, 0.1, 0.0) - else: - lbl.modulate = Color(1, 1, 1) - lbl.font_size = 32 - lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED - lbl.no_depth_test = true - lbl.pixel_size = 0.007 - root_node.add_child(lbl) - - var tw := create_tween() - tw.set_parallel(true) - tw.tween_property(root_node, "global_position:y", root_node.global_position.y + 1.2, 0.8) - for ch in root_node.get_children(): - if ch is Sprite3D or ch is Label3D: - tw.tween_property(ch, "modulate:a", 0.0, 0.8).set_delay(0.3) - tw.chain().tween_callback(root_node.queue_free) - -func _process_upgrade_queue() -> void: - if _upgrade_queue.is_empty() or not _model_factory.is_valid() or client == null or not client.has_method("get_entity"): - return - var m := main_node() - if is_instance_valid(m) and _upgrade_queue.size() > 1: - var mpos := m.global_position - _upgrade_queue.sort_custom(func(a: int, b: int) -> bool: - var na: Node3D = _by_vid.get(a, null) - var nb: Node3D = _by_vid.get(b, null) - var da := na.global_position.distance_squared_to(mpos) if is_instance_valid(na) else INF - var db := nb.global_position.distance_squared_to(mpos) if is_instance_valid(nb) else INF - return da < db - ) - var start_ms := Time.get_ticks_msec() - var count := 0 - while not _upgrade_queue.is_empty() and count < MAX_UPGRADES_PER_FRAME: - var vid: int = _upgrade_queue.pop_front() - if vid == _local_vid: - continue - var d: Dictionary = client.get_entity(vid) - if not d.is_empty() and _by_vid.has(vid): - _on_spawn(d, false) - count += 1 - if (Time.get_ticks_msec() - start_ms) >= MAX_UPGRADE_TIME_MSEC: - break - -func _process(dt: float) -> void: - if client == null or parent == null: - return - # GameScene injects EffectRegistry after NetWorld.setup; make the - # resource-driven FlyManager observe that handoff before the next cue. - if fx != null and _fly_mgr and _fly_mgr.has_method("set_effect_registry"): - _fly_mgr.set_effect_registry(fx) - _process_upgrade_queue() - # 40250 CPythonCharacterManager::Update checks distance and schedules - # blend-out on every frame. A timer-based throttle makes visibility depend on - # frame timing: a fast two-frame warp can leave a node stale or rebuild it - # after the actor has already crossed the view boundary. - _update_visibility() - _process_damage_queue() # §8.8:m_DamageQueue 每帧 pop 一条(CInstanceBase::ProcessDamage) - # §8.8:名字色随队伍 / 帝国名色模式 / PVP·决斗边沿变化——用 ~2Hz 节流整表刷新 - # 兜底(单发路径已覆盖 spawn/info/pvp/duel/main_set;参考端每帧都算 GetNameColor)。 - _name_color_accum += dt - if _name_color_accum >= 0.5: - _name_color_accum = 0.0 - _retint_all_names() - _retint_all_text_tails() - _expire_chat_tails() - # §8.4 / §8.6:ShowAllTextTail 的 3500 平面距随主角 / 实体移动变化 —— 逐帧太密, - # 用同一 ~2Hz 节流兜底(LALT / 选项 / 选中 / 尾标切换已各自即时刷新)。 - _refresh_all_name_visibility() - for vid in _by_vid.keys(): - var n: Node3D = _by_vid[vid] - if not is_instance_valid(n): - _by_vid.erase(vid) - continue - var e: Dictionary = client.get_entity(vid) - if e.is_empty(): - continue - var is_dead := bool(e.get("dead", false)) or bool(n.get_meta("dead", false)) - if is_dead: - if not bool(n.get_meta("dead", false)): - _on_dead(vid) - continue - # §3.2:位置插值现在完全在 C++ EntityStore::tick() 里做(命令队列 + - # SNetworkActorData::UpdatePosition 式线性插值),这里只跟随 e.pos,不再 - # 叠第二层 follow_lerp——否则会把 C++ 的到达时机拖慢、和动作事件脱节。 - var want := _grounded(e.get("pos", n.position)) - if _push.has(vid): - want = _pushed_position(vid, e, dt, want) - if _sync_push.has(vid): - want = _sync_pushed_position(vid, e, dt, want) - n.position = want - # 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。 - # CActorInstance::RotationProcess / SetAdvancingRotation: - # 差值 > 45° 时全速转向(步战 1200°/s,骑乘 300°/s);差值 <= 45° 时按 5/12 阻尼减速(500°/s 或 125°/s)。 - var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0))) - var turn_rate := ROTATION_SPEED_HORSE if int(n.get_meta("mount_vnum", 0)) != 0 else ROTATION_SPEED - var diff: float = wrapf(yaw - n.rotation.y, -PI, PI) - if absf(diff) <= deg_to_rad(45.0): - turn_rate *= 5.0 / 12.0 - n.rotation.y = rotate_toward(n.rotation.y, yaw, deg_to_rad(turn_rate) * dt) - # 动画状态 - var f := int(e.get("func", FUNC_WAIT)) - var func_arg := int(e.get("func_arg", 0)) - var moving := bool(e.get("moving", false)) - var walk_mode := int(e.get("walk_mode", WALKMODE_RUN)) - var dead := bool(e.get("dead", false)) - if f != int(n.get_meta("func", -1)) or func_arg != int(n.get_meta("func_arg", -1)) \ - or moving != bool(n.get_meta("moving", false)) \ - or walk_mode != int(n.get_meta("walk_mode", -1)) or dead != bool(n.get_meta("dead", false)): - n.set_meta("func", f) - n.set_meta("func_arg", func_arg) - n.set_meta("moving", moving) - n.set_meta("walk_mode", walk_mode) - n.set_meta("dead", dead) - _apply_anim(n, f, moving, walk_mode, dead, func_arg) - _update_hp_bars() - -func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := WALKMODE_RUN, - dead := false, func_arg := 0) -> void: - var state := "wait" - if dead: - # CActorInstance::__SetMotion:IsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。 - state = "dead" - elif f == FUNC_MOVE or (f == FUNC_WAIT and moving): - state = "walk" if walk_mode == WALKMODE_WALK else "run" - elif f == FUNC_ATTACK or f == FUNC_COMBO: - # 40250 InstanceBase::StateProcess calls RunNormalAttack for - # FUNC_ATTACK and RunComboAttack(..., uArg) for FUNC_COMBO. The - # previous remote path collapsed both into set_anim_state("attack"), - # which discarded the combo segment and bypassed weapon-mode motion - # lookup (onehand/twohand/bow/etc.). - var motion_index := MOTION_NORMAL_ATTACK if f == FUNC_ATTACK else func_arg - if n.has_method("play_attack_motion"): - var mode := 1 - if n.has_method("get_motion_mode"): - mode = int(n.call("get_motion_mode")) - var speed := float(n.get_meta("attack_speed", 0)) / 100.0 - if speed <= 0.0 or speed > 11.0: - speed = 1.0 - if bool(n.call("play_attack_motion", mode, motion_index, speed)): - return - state = "combo" if f == FUNC_COMBO else "attack" - elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0: - if (f & FUNC_SKILL) != 0: - var mot_idx := f & 0x7f - n.set_meta("motion_skill_index", mot_idx) - if mot_idx > 0 and skill_table and n.has_method("play_skill_motion"): - var mname: String = skill_table.motion_name_by_idx(mot_idx) - if mname != "": - var loop_count := func_arg & 0x0f - var moving_skill := (func_arg & (1 << 4)) != 0 - # 40250 FUNC_SKILL carries the grade-specific motion index when - # USE_SKILL_EFFECT_UPGRADE_ENABLE=1; recover that grade before - # selecting the matching _2/_3/_4 MSA. - var skill_grade := 0 - if skill_table.has_method("motion_grade_by_idx"): - skill_grade = int(skill_table.call("motion_grade_by_idx", mot_idx)) - var played := bool(n.call("play_skill_motion", mname, skill_grade, - loop_count, moving_skill)) - if played: - # Remote FUNC_SKILL has no cancel bit in TPacketGCMove; - # the authoritative motion data owns CanCancelSkill. Until - # that data says otherwise, hold the TCP queue and release it - # from motion_bound("wait"). - _set_network_state_gate(n, true, false, false, true) - return - state = "skill" - elif f == FUNC_EMOTION: - # InstanceBase::__ProcessFunctionEmotion consumes uArg as the - # CRaceMotionData action id. TPacketGCMove has no target VID, so the - # one-sided action is selected with target_race=-1; GC_MOTION still - # supplies the paired-emotion target path through _on_motion(). - if func_arg > 0 and n.has_method("set_motion_id"): - if bool(n.call("set_motion_id", func_arg, -1)): - _set_network_state_gate(n, false, true, true, false) - return - state = "emotion" - if n.has_method("set_anim_state"): - n.call("set_anim_state", state) - # 占位胶囊(无 set_anim_state)不再借头顶名字 Label3D 的 modulate 表现动作态—— - # 该 modulate 现在专属 §8.8 名字色(GetNameColor)。 - -func _grounded(p: Variant) -> Vector3: - # p 是 M2Client 的网络帧 pos —— 先转到 Metin2World 本地帧,再贴地。 - var v: Vector3 = MapCoord.to_world(p) if p is Vector3 else Vector3.ZERO - return _ground_world(v) - -func _ground_world(v: Vector3) -> Vector3: - if world and world.has_method("sample_height"): - v.y = float(world.call("sample_height", v.x, v.z)) - return v - -# §3.7 __PushCircle + IncreaseExternalForce(ActorInstanceBattle.cpp / PhysicsObject.cpp): -# dir = normalize(受击方 - 攻击方),Metin2 actor 平面帧(cm)。seam:无 IPhysicsWorld 地形碰撞回调。 -func push_victim(vid: int, dir: Vector2, force: float) -> void: - if client == null or force <= 0.0 or not _by_vid.has(vid): - return - var e: Dictionary = client.get_entity(vid) - if e.is_empty(): - return - var pe: Dictionary = _push.get(vid, {}) - if pe.is_empty(): - pe = {"obj": PhysicsPush.new(), "off": Vector2.ZERO, "base": e.get("pos")} - _push[vid] = pe - pe.obj.set_direction(dir) - var collide_fn := Callable() - var base_world := MapCoord.to_world(e.get("pos", Vector3.ZERO)) - # 40250 CPhysicsObject::IncreaseExternalForce: pWorld->isPhysicalCollision - # CMapManager::isPhysicalCollision 仅判定地形 ATTRIBUTE_BLOCK(world.is_blocked) - collide_fn = func(movement_cm: Vector2) -> bool: - var cand_x := base_world.x + movement_cm.x / 100.0 - var cand_z := base_world.z - movement_cm.y / 100.0 - return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", cand_x, cand_z)) - pe.obj.increase_external_force(force, collide_fn) - -# CActorInstance::IsPushing -> m_PhysicsObject.isBlending() -func is_pushing(vid: int) -> bool: - return _push.has(vid) and bool(_push[vid].obj.is_blending()) - -# CActorInstance::GetBlendingPosition:blending 中 = 当前位置 + LastPosition(击退终点),否则当前位置。 -func blending_position(vid: int) -> Vector3: - var n: Node3D = _by_vid.get(vid, null) - if n == null: - return Vector3.ZERO - if not is_pushing(vid): - return n.global_position - var last: Vector2 = _push[vid].obj.get_last_position() - return n.global_position + Vector3(last.x / 100.0, 0.0, -last.y / 100.0) - -# CActorInstance::PhysicsProcess:每帧 m_PhysicsObject.Update + AddMovement(GetX/YMovement)。 -# 位移叠在服务器位置上;遇地形阻挡则触发 BlockMovement 停在边界;blend 结束后服务器位置一变,以服务器为准丢弃本地偏移。 -func _pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vector3: - var pe: Dictionary = _push[vid] - var obj = pe.obj - var pos: Variant = e.get("pos") - if not (pos is Vector3): - return want - if pos != pe.base: - if not obj.is_blending(): - _push.erase(vid) - return want - pe.base = pos - if obj.is_blending(): - obj.update(dt) - var d_move: Vector2 = obj.get_movement() - var cand_off: Vector2 = pe.off + d_move - var cand_world := MapCoord.to_world(pos) + Vector3(cand_off.x / 100.0, 0.0, -cand_off.y / 100.0) - if world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", cand_world.x, cand_world.z)): - # BlockMovement:撞墙停止位移 - obj.initialize() - else: - pe.off = cand_off - var off: Vector2 = pe.off - return _ground_world(MapCoord.to_world(pos) + Vector3(off.x / 100.0, 0.0, -off.y / 100.0)) - -# TEMP_Push's SetBlendingPosition stores (target - current) in the physics -# object. Since the network row now points at target, keep the inverse offset -# (current - target) so the first rendered frame remains at the old position -# and the one-second ease-out converges to the authoritative target. -func _sync_pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vector3: - var pe: Dictionary = _sync_push.get(vid, {}) - if pe.is_empty(): - return want - var obj = pe.get("obj", null) - var pos: Variant = e.get("pos") - if obj == null or not (pos is Vector3): - _sync_push.erase(vid) - return want - if obj.is_blending(): - obj.update(dt) - # SetBlendingPosition starts with the full (current - target) offset; - # PhysicsObject's movement is the progress from that offset back to zero. - pe["off"] = pe.get("off", Vector2.ZERO) - obj.get_movement() - var off: Vector2 = pe["off"] - return _ground_world(MapCoord.to_world(pos) + Vector3(off.x / 100.0, 0.0, -off.y / 100.0)) - _sync_push.erase(vid) - return want - -func _make_placeholder(d: Dictionary) -> Node3D: - var root := Node3D.new() - var mesh := MeshInstance3D.new() - var cap := CapsuleMesh.new() - cap.radius = 0.35 - cap.height = 1.8 - mesh.mesh = cap - mesh.position.y = 0.9 - var mat := StandardMaterial3D.new() - var is_main := bool(d.get("is_main", false)) - mat.albedo_color = Color(0.35, 0.6, 1.0) if is_main else Color(0.85, 0.4, 0.35) - mesh.material_override = mat - root.add_child(mesh) - _attach_nameplate(root, d) - return root - -# 头顶名字 Label3D + 屏幕空间 HP 条(占位胶囊和真模型共用)。HP 条由 -# _ensure_hp_overlay() 创建到 CanvasLayer,_update_hp_bars() 负责世界坐标投影。 -func _attach_nameplate(root: Node3D, d: Dictionary) -> void: - var tag := Label3D.new() - tag.name = "Label3D" - var nm := _display_name(d) - tag.text = nm if nm != "" else "vid %d" % int(d.get("vid", 0)) - tag.position.y = NAME_TAG_DEFAULT_Y - tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED - tag.no_depth_test = true - tag.pixel_size = 0.006 - tag.visible = false # _on_spawn 随后 _set_name_visibility(node) 按 __IsShowName / 距离定夺 - root.add_child(tag) - # §8.8 头顶「公会名 / 称号 / 等级」子标签(CPythonTextTail 的 pGuildNameTextInstance / - # pTitleTextInstance / pLevelTextInstance)。参考端是屏幕空间:公会名在名字上方 - # c_fyGuildNamePosition(15px)、称号 / 等级贴右侧。这里在 billboard 3D 里近似成竖排 - # (公会名上、称号 / 等级下),精确像素排版差异记为 §8.8 seam ⑦。内容 / 颜色由 - # _apply_text_tail 按 RefreshTextTail / AttachTitle / AttachLevel 决定,初始空 = 隐藏。 - root.add_child(_mk_sub_tag("GuildTag", SUBTAG_DEFAULT_Y["GuildTag"], TextTail.GUILD_NAME_COLOR)) - root.add_child(_mk_sub_tag("TitleTag", SUBTAG_DEFAULT_Y["TitleTag"], Color(1, 1, 1))) - root.add_child(_mk_sub_tag("LevelTag", SUBTAG_DEFAULT_Y["LevelTag"], TextTail.LEVEL_COLOR)) - -func _mk_sub_tag(sub_name: String, y: float, col: Color) -> Label3D: - var t := Label3D.new() - t.name = sub_name - t.text = "" - t.position.y = y - t.billboard = BaseMaterial3D.BILLBOARD_ENABLED - t.no_depth_test = true - t.pixel_size = 0.005 - t.modulate = col - t.visible = false - return t - -func _ensure_hp_overlay() -> void: - if _hp_layer != null and is_instance_valid(_hp_layer): - return - _hp_layer = CanvasLayer.new() - _hp_layer.name = "ScreenHpLayer" - _hp_layer.layer = 5 - add_child(_hp_layer) - _hp_overlay = Control.new() - _hp_overlay.name = "HpOverlay" - _hp_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) - _hp_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE - _hp_layer.add_child(_hp_overlay) - -func _make_hp_bar(vid: int, e: Dictionary) -> Control: - _ensure_hp_overlay() - var bar := _hp_bars.get(vid, null) as Control - if bar == null or not is_instance_valid(bar): - bar = ScreenHpBar.new() - bar.name = "hp_%d" % vid - _hp_overlay.add_child(bar) - _hp_bars[vid] = bar - _sync_hp_bar_values(vid, int(e.get("hp", 0)), int(e.get("max_hp", 0)), - bool(e.get("dead", false))) - return bar - -func _sync_hp_bar(vid: int, e: Dictionary) -> void: - if vid == 0 or e.is_empty(): - return - _make_hp_bar(vid, e) - _sync_hp_bar_values(vid, int(e.get("hp", 0)), int(e.get("max_hp", 0)), - bool(e.get("dead", false))) - -func _sync_hp_bar_values(vid: int, hp: int, max_hp: int, dead: bool) -> void: - var bar := _hp_bars.get(vid, null) as Control - if bar == null or not is_instance_valid(bar): - return - bar.set_meta("max_hp", max_hp) - bar.set_meta("dead", dead) - if bar.has_method("set_vitals"): - bar.call("set_vitals", hp, max_hp, dead) - bar.visible = max_hp > 0 and not dead - -func _remove_hp_bar(vid: int) -> void: - var bar := _hp_bars.get(vid, null) as Control - _hp_bars.erase(vid) - if bar != null and is_instance_valid(bar): - bar.queue_free() - -func _clear_hp_bars() -> void: - for vid in _hp_bars.keys(): - var bar := _hp_bars[vid] as Control - if bar != null and is_instance_valid(bar): - bar.queue_free() - _hp_bars.clear() - -func _update_hp_bars() -> void: - if _hp_bars.is_empty(): - return - var cam := _camera - if cam == null or not is_instance_valid(cam): - cam = get_viewport().get_camera_3d() - var viewport_size := get_viewport().get_visible_rect().size - if cam == null or viewport_size.x <= 0.0 or viewport_size.y <= 0.0: - for bar in _hp_bars.values(): - if is_instance_valid(bar): - bar.visible = false - return - var viewport_rect := Rect2(Vector2.ZERO, viewport_size).grow(HP_BAR_SCREEN_MARGIN) - for vid in _hp_bars.keys(): - var bar := _hp_bars[vid] as Control - var node := _by_vid.get(vid, null) as Node3D - if bar == null or not is_instance_valid(bar) or node == null \ - or not is_instance_valid(node): - if bar != null and is_instance_valid(bar): - bar.visible = false - continue - var max_hp: int = int(bar.get_meta("max_hp", 0)) - var dead: bool = bool(bar.get_meta("dead", false)) or bool(node.get_meta("dead", false)) - if max_hp <= 0 or dead: - bar.visible = false - continue - var anchor := node.global_position + Vector3(0.0, HP_BAR_WORLD_Y, 0.0) - if cam.is_position_behind(anchor): - bar.visible = false - continue - var screen_pos := cam.unproject_position(anchor) - if not viewport_rect.has_point(screen_pos): - bar.visible = false - continue - bar.position = screen_pos - Vector2(bar.size.x * 0.5, 0.0) - bar.visible = true diff --git a/project/net_world.gd.uid b/project/net_world.gd.uid deleted file mode 100644 index 10de50f1..00000000 --- a/project/net_world.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://mxr8pfgb5ysp diff --git a/project/net_world_main_switch_test.gd b/project/net_world_main_switch_test.gd deleted file mode 100644 index 45b0bd0f..00000000 --- a/project/net_world_main_switch_test.gd +++ /dev/null @@ -1,63 +0,0 @@ -# net_world_main_switch_test —— 40250 CNetworkActorManager::SetMainActorVID -# 的主角色切换边界:旧 live actor、目标、悬停、HP/动作临时状态不能跨 VID 残留。 -extends SceneTree - -const NetWorld = preload("res://net_world.gd") -const FakeClient = preload("res://net_client_fake.gd") - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - await _run() - if _fail == 0: - print("PASS: net_world_main_switch_test (SetMainActorVID cleanup)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var mount := Node3D.new() - var client := FakeClient.new() - var world: Node = NetWorld.new() - get_root().add_child(mount) - get_root().add_child(client) - get_root().add_child(world) - world.setup(client, mount) - world.set_model_factory(func(_d: Dictionary) -> Node3D: return Node3D.new()) - - client.entities[1000] = {"vid": 1000, "name": "old-main", "race": 0, - "kind": 0, "ch_type": 0, "pos": Vector3.ZERO, - "pos_cm": Vector3.ZERO, "hp": 100, "max_hp": 100, "dead": false} - client.entities[2000] = {"vid": 2000, "name": "old-remote", "race": 101, - "kind": 2, "ch_type": 2, "pos": Vector3(1, 0, 0), - "pos_cm": Vector3(100, 0, 0), "hp": 100, "max_hp": 100, "dead": false} - client.main = 1000 - world._main_vid = 1000 - world._on_spawn(client.entities[2000]) - _ck(world.node_for(2000) != null, "old main lifecycle has a visible remote actor") - world.set_target_vid(2000) - world.set_hover_vid(2000) - world._sync_push[2000] = {"serial": 1} - world._push[2000] = {"offset": Vector2.ONE} - world._damage_queue.append({"vid": 2000, "amount": 1}) - world._chat_tails[2000] = {"text": "old", "name_flag": false} - - world._on_main_set(3000) - _ck(world._main_vid == 3000, "new main VID is registered") - _ck(world._by_vid.is_empty(), "main switch clears old live actor nodes") - _ck(world._target_vid == 0 and world._hover_vid == 0, - "main switch clears target and hover references") - _ck(world._sync_push.is_empty() and world._push.is_empty() - and world._damage_queue.is_empty() and world._chat_tails.is_empty(), - "main switch clears transient actor presentation state") - - world.free() - client.free() - mount.free() - await process_frame diff --git a/project/net_world_main_switch_test.gd.uid b/project/net_world_main_switch_test.gd.uid deleted file mode 100644 index 647a6cf2..00000000 --- a/project/net_world_main_switch_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dtesqgywrscu1 diff --git a/project/net_world_motion_event_test.gd b/project/net_world_motion_event_test.gd deleted file mode 100644 index 92e67239..00000000 --- a/project/net_world_motion_event_test.gd +++ /dev/null @@ -1,44 +0,0 @@ -# net_world_motion_event_test —— every real remote actor exposes the native -# MotionEvent detailed signal through NetWorld, without creating client shots. -extends SceneTree - -const NetWorld = preload("res://net_world.gd") - -class Anim extends Node: - signal motion_event_detailed(event: Dictionary) - -class Actor extends Node3D: - var anim := Anim.new() - func _init() -> void: - add_child(anim) - -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 world := NetWorld.new() - var actor := Actor.new() - get_root().add_child(world) - get_root().add_child(actor) - var seen: Array = [] - world.actor_motion_event.connect(func(vid: int, event: Dictionary): seen.append([vid, event])) - world._connect_actor_motion_events(actor, 42) - actor.anim.motion_event_detailed.emit({"type": 1, "effect": "remote.mse"}) - check(seen.size() == 1 and seen[0][0] == 42 and seen[0][1].effect == "remote.mse", - "remote actor detailed motion event reaches NetWorld") - world._connect_actor_motion_events(actor, 42) - actor.anim.motion_event_detailed.emit({"type": 8}) - check(seen.size() == 2, "remote animator is connected only once") - world.free() - actor.free() - await process_frame - if failures == 0: - print("PASS: net_world_motion_event_test") - quit(1 if failures else 0) diff --git a/project/net_world_motion_event_test.gd.uid b/project/net_world_motion_event_test.gd.uid deleted file mode 100644 index 492e052a..00000000 --- a/project/net_world_motion_event_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://boqs8rp2oa45y diff --git a/project/net_world_push_test.gd b/project/net_world_push_test.gd deleted file mode 100644 index 169100b2..00000000 --- a/project/net_world_push_test.gd +++ /dev/null @@ -1,125 +0,0 @@ -# net_world_push_test —— CLIENT-GAP §3.7 受击击退的场景侧: -# __PushCircle + IncreaseExternalForce -> net_world.push_victim -# CActorInstance::PhysicsProcess(每帧 AddMovement(GetX/YMovement))-> 节点偏移 -# GetBlendingPosition(blending 中 = 当前位置 + LastPosition)-> OnHit 的 CG_SYNC_POSITION 坐标 -# godot --headless --path project --script net_world_push_test.gd -extends SceneTree - -const NetWorld = preload("res://net_world.gd") -const PhysicsPush = preload("res://physics_push.gd") - -class FakeClient extends Node: - signal entity_spawned(entity: Dictionary) - signal entity_despawned(vid: int) - signal entity_moved(vid: int) - signal entity_main_set(vid: int) - signal entity_info(vid: int, entity: Dictionary) - signal chat(type: int, vid: int, text: String) - signal vitals_changed(vid: int) - signal entity_dead(vid: int) - signal damage(vid: int, amount: int, flag: int) - var ents := {} - func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) - func get_entities() -> Array: return ents.values() - func get_pvp_relations() -> Array: return [] - func get_duel() -> Dictionary: return {} - func add(vid: int, cm: Vector2, extra := {}) -> Dictionary: - var d := {"vid": vid, "name": "e%d" % vid, "is_main": false, - "func": 0, "moving": false, "angle_deg": 0.0, - "hp": 100, "max_hp": 100, "dead": false, "race": 0, - "affect_flags": 0, "parts": [0, 0, 0, 0]} - d.merge(extra, true) - ents[vid] = d - place(vid, cm) - return d - func place(vid: int, cm: Vector2) -> void: - ents[vid]["pos"] = Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01) - ents[vid]["pos_cm"] = Vector3(cm.x, cm.y, 0.0) - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -var _done := false - -func _init() -> void: - await _run() - _ck(_done, "test body ran to completion (a SCRIPT ERROR aborted _run)") - if _fail == 0: - print("PASS: net_world_push_test (push_victim / PhysicsProcess offset / GetBlendingPosition)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _off(v: Vector2) -> Vector3: - return Vector3(v.x / 100.0, 0.0, -v.y / 100.0) - -func _run() -> void: - var mount := Node3D.new() - get_root().add_child(mount) - var fc := FakeClient.new() - get_root().add_child(fc) - var nw: Node = NetWorld.new() - get_root().add_child(nw) - nw.setup(fc, mount) - - fc.add(1, Vector2.ZERO, {"is_main": true}) - fc.entity_main_set.emit(1) - fc.entity_spawned.emit(fc.add(2, Vector2(300, 0))) - await process_frame - nw._process(0.02) - var n: Node3D = nw.node_for(2) - _ck(n != null, "victim node spawned") - if n == null: - return - _ck(not nw.is_pushing(2), "no push -> not pushing") - _ck(nw.blending_position(2).is_equal_approx(n.global_position), "not blending -> GetBlendingPosition = current position") - - # 攻击者在西边:victim - attacker = actor-world (+1, 0) - nw.push_victim(2, Vector2(1, 0), 3.0) - var ref := PhysicsPush.new() - ref.set_direction(Vector2(1, 0)) - ref.increase_external_force(3.0) - var last := ref.get_last_position() - _ck(last.x > 0.0, "reference push has a positive displacement") - _ck(nw.is_pushing(2), "push_victim -> is_pushing") - _ck(nw.blending_position(2).is_equal_approx(n.global_position + _off(last)), - "blending -> GetBlendingPosition = current + LastPosition, got %s" % nw.blending_position(2)) - - var acc := Vector2.ZERO - for i in 60: - nw._process(0.02) - ref.update(0.02) - acc += ref.get_movement() - var want: Vector3 = nw._grounded(fc.ents[2]["pos"]) + _off(acc) - _ck(acc.x > 0.0 and n.position.is_equal_approx(want), - "PhysicsProcess: node follows the accumulated ease-out movement, got %s want %s" % [n.position, want]) - - # blending 中服务器位置变化:偏移叠在新位置上(位移还在进行) - fc.place(2, Vector2(300, 50)) - nw._process(0.02) - ref.update(0.02) - acc += ref.get_movement() - want = nw._grounded(fc.ents[2]["pos"]) + _off(acc) - _ck(n.position.is_equal_approx(want), "server move while blending -> offset kept on top, got %s want %s" % [n.position, want]) - - for i in 100: - nw._process(0.02) - ref.update(0.02) - acc += ref.get_movement() - _ck(not nw.is_pushing(2), "blend over -> not pushing") - want = nw._grounded(fc.ents[2]["pos"]) + _off(acc) - _ck(n.position.is_equal_approx(want), "blend over -> actor stays where the push left it, got %s want %s" % [n.position, want]) - - # blend 结束后服务器位置再变:以服务器位置为准(丢弃本地击退偏移) - fc.place(2, Vector2(320, 50)) - nw._process(0.02) - _ck(n.position.is_equal_approx(nw._grounded(fc.ents[2]["pos"])), "server position after the blend supersedes the push offset") - - nw.push_victim(2, Vector2(0, 1), 3.0) - fc.entity_despawned.emit(2) - _ck(not nw.is_pushing(2) and not nw._push.has(2), "despawn drops the push state") - _done = true diff --git a/project/net_world_push_test.gd.uid b/project/net_world_push_test.gd.uid deleted file mode 100644 index 453aaff1..00000000 --- a/project/net_world_push_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dtksog42nbeco diff --git a/project/net_world_remote_action_test.gd b/project/net_world_remote_action_test.gd deleted file mode 100644 index d4e90f69..00000000 --- a/project/net_world_remote_action_test.gd +++ /dev/null @@ -1,212 +0,0 @@ -# net_world_remote_action_test —— 40250 remote attack/combo action routing. -# -# The native client keeps FUNC_ATTACK and FUNC_COMBO separate all the way from -# StateProcess to RunNormalAttack/RunComboAttack. This headless test checks -# that the Godot presentation bridge preserves the weapon motion mode, the -# combo segment (bArg), and the attack-speed ratio. -extends SceneTree - -const NetWorld = preload("res://net_world.gd") - -class ActionView extends Node3D: - var attack_calls: Array = [] - var skill_calls: Array = [] - var emotion_calls: Array = [] - var sync_push_calls := 0 - var states: Array[String] = [] - signal motion_bound(state: String) - - func get_motion_mode() -> int: - return 3 # CRaceMotionData::MODE_TWOHAND_SWORD - - func play_attack_motion(mode: int, index: int, speed: float) -> bool: - attack_calls.append([mode, index, speed]) - return true - - func set_anim_state(state: String) -> void: - states.append(state) - - func play_skill_motion(name: String, grade: int, loop_count: int, - moving_skill: bool) -> bool: - skill_calls.append([name, grade, loop_count, moving_skill]) - return true - - func set_motion_id(motion: int, target_race: int) -> bool: - emotion_calls.append([motion, target_race]) - return true - - func sync_push_motion() -> bool: - sync_push_calls += 1 - return true - -class SkillTableFake extends RefCounted: - func motion_name_by_idx(idx: int) -> String: - return "skill_7" if idx == 51 else "skill_%d" % idx - func motion_grade_by_idx(idx: int) -> int: - return 2 if idx == 51 else 0 - -class CollisionWorld extends Node: - var samples := 0 - - func is_blocked(x: float, _z: float) -> bool: - samples += 1 - return x > 1.0 - -class GateClient extends Node: - var calls: Array = [] - var knockdown_calls: Array = [] - var entities := {} - - func get_entity(vid: int) -> Dictionary: - return entities.get(vid, {}) - - func get_entities() -> Array: - return entities.values() - - func set_entity_network_state_gate(vid: int, skill_active: bool, skill_can_cancel: bool, - acting_emotion: bool, tcp_state_enabled: bool) -> void: - calls.append([vid, skill_active, skill_can_cancel, acting_emotion, tcp_state_enabled]) - - func set_entity_knockdown(vid: int, on: bool) -> void: - knockdown_calls.append([vid, on]) - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - await _run() - if _fail == 0: - print("PASS: net_world_remote_action_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var nw: Node = NetWorld.new() - get_root().add_child(nw) - var view := ActionView.new() - get_root().add_child(view) - view.set_meta("attack_speed", 150) - - # Reference RunNormalAttack has no motion argument: it selects the normal - # attack slot (13) in the active weapon motion mode. - nw._apply_anim(view, NetWorld.FUNC_ATTACK, false, NetWorld.WALKMODE_RUN, false, 0) - _ck(view.attack_calls.size() == 1, "FUNC_ATTACK reaches the attack-motion bridge") - if view.attack_calls.size() >= 1: - var normal: Array = view.attack_calls[0] - _ck(int(normal[0]) == 3, "normal attack keeps the active weapon mode") - _ck(int(normal[1]) == NetWorld.MOTION_NORMAL_ATTACK, - "normal attack uses CRaceMotionData::NAME_NORMAL_ATTACK") - _ck(is_equal_approx(float(normal[2]), 1.5), "attack speed 150 becomes fSpeedRatio 1.5") - - # Reference RunComboAttack passes the packet bArg through unchanged. - nw._apply_anim(view, NetWorld.FUNC_COMBO, false, NetWorld.WALKMODE_RUN, false, 16) - _ck(view.attack_calls.size() == 2, "FUNC_COMBO reaches the attack-motion bridge") - if view.attack_calls.size() >= 2: - var combo: Array = view.attack_calls[1] - _ck(int(combo[0]) == 3, "combo keeps the active weapon mode") - _ck(int(combo[1]) == 16, "combo preserves the packet motion index") - _ck(is_equal_approx(float(combo[2]), 1.5), "combo keeps the remote attack speed") - _ck(view.states.is_empty(), "successful attack-resource routing does not fall back to generic state") - - # The same bridge preserves the remote skill arg and releases the native - # queue only from the one-shot motion tail; FUNC_EMOTION uses its uArg as the - # CRaceMotionData action id. - var gates := GateClient.new() - get_root().add_child(gates) - nw.client = gates - nw.skill_table = SkillTableFake.new() - view.set_meta("vid", 77) - nw._by_vid[77] = view - view.motion_bound.connect(nw._on_remote_motion_bound.bind(77)) - nw._apply_anim(view, NetWorld.FUNC_SKILL | 7, false, NetWorld.WALKMODE_RUN, false, 0x12) - _ck(view.skill_calls.size() == 1 and view.skill_calls[0] == ["skill_7", 0, 2, true], - "FUNC_SKILL keeps loop-count and moving-skill bits") - _ck(gates.calls.size() == 1 and gates.calls[0] == [77, true, false, false, true], - "non-cancellable remote skill closes the TCP state gate") - nw._apply_anim(view, NetWorld.FUNC_SKILL | 51, false, NetWorld.WALKMODE_RUN, false, 0) - _ck(view.skill_calls.size() == 2 and view.skill_calls[1] == ["skill_7", 2, 0, false], - "grade-specific FUNC_SKILL restores the M/G/P motion grade before binding") - view.motion_bound.emit("wait") - _ck(gates.calls.size() == 3 and gates.calls.back() == [77, false, true, false, true], - "skill motion tail reopens the TCP state gate") - - nw._apply_anim(view, NetWorld.FUNC_EMOTION, false, NetWorld.WALKMODE_RUN, false, 305) - _ck(view.emotion_calls.size() == 1 and view.emotion_calls[0] == [305, -1], - "FUNC_EMOTION uses uArg as the one-shot motion id") - _ck(gates.calls.size() == 4 and gates.calls.back() == [77, false, true, true, false], - "active remote emotion disables later TCP processing") - view.motion_bound.emit("wait") - _ck(gates.calls.size() == 5 and gates.calls.back() == [77, false, true, false, true], - "emotion motion tail reopens TCP processing") - - # GC_SYNC_POSITION enters TEMP_Push: a large correction triggers the - # DAMAGE_FLYING -> STAND_UP chain, while the presentation offset starts at - # the old position and eases back to the authoritative target. - view.set_meta("vid", 77) - view.set_meta("sync_push_serial", 0) - gates.entities[77] = {"vid": 77, "pos": Vector3(3.0, 0.0, 0.0), - "pos_cm": Vector3(300.0, 0.0, 0.0), "sync_push_serial": 1, - "sync_push_from_cm": Vector3.ZERO, "sync_push_large": true, - "sync_push_skill_active": false} - nw._on_moved(77) - _ck(view.sync_push_calls == 1, "large sync correction starts TEMP_Push reaction") - _ck(gates.knockdown_calls.size() == 1 and gates.knockdown_calls[0] == [77, true], - "large sync correction closes the knockdown network-state gate") - var corrected: Vector3 = nw._sync_pushed_position(77, gates.entities[77], 0.0, - MapCoord.to_world(Vector3(3.0, 0.0, 0.0))) - _ck(is_equal_approx(corrected.x, 0.0), - "TEMP_Push starts from the pre-correction presentation position") - view.motion_bound.emit("wait") - _ck(gates.knockdown_calls.size() == 2 and gates.knockdown_calls.back() == [77, false], - "sync push motion tail releases the knockdown gate") - - # A physical obstacle aborts TEMP_Push at the first blocked sample and must - # leave the authoritative target in charge without starting a second motion. - var obstacle := CollisionWorld.new() - get_root().add_child(obstacle) - nw.world = obstacle - var blocked_entity: Dictionary = gates.entities[77] - blocked_entity["sync_push_serial"] = 2 - gates.entities[77] = blocked_entity - nw._on_moved(77) - _ck(obstacle.samples > 1, "TEMP_Push probes the correction path in segments") - _ck(not nw._sync_push.has(77) and view.sync_push_calls == 1, - "blocked TEMP_Push resets blending without replaying knockdown") - - # A spawn can already carry an action state. The reference binds that motion - # during actor creation; it must not wait for a later func edge. - var nw2: Node = NetWorld.new() - get_root().add_child(nw2) - var mount := Node3D.new() - get_root().add_child(mount) - var spawn_client := GateClient.new() - get_root().add_child(spawn_client) - nw2.client = spawn_client - nw2.parent = mount - nw2.set_model_factory(func(_d: Dictionary) -> Node3D: return ActionView.new()) - var initial := {"vid": 88, "name": "initial", "race": 101, "ch_type": 2, - "func": NetWorld.FUNC_COMBO, "func_arg": 15, "moving": false, - "walk_mode": NetWorld.WALKMODE_RUN, "attack_speed": 125, - "hp": 100, "max_hp": 100, "dead": false, "pos": Vector3.ZERO, - "parts": [0, 0, 0, 0], "affect_flags": 0} - spawn_client.entities[88] = initial - nw2._on_spawn(initial) - var initial_view := nw2.node_for(88) as ActionView - _ck(initial_view != null and initial_view.attack_calls.size() == 1 - and initial_view.attack_calls[0] == [3, 15, 1.25], - "spawned FUNC_COMBO binds immediately after actor fields are applied") - - nw.free() - nw2.free() - obstacle.free() - mount.free() - spawn_client.free() - gates.free() - view.free() - await process_frame diff --git a/project/net_world_remote_action_test.gd.uid b/project/net_world_remote_action_test.gd.uid deleted file mode 100644 index fc050d15..00000000 --- a/project/net_world_remote_action_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://deke02x0jv7n2 diff --git a/project/net_world_reset_test.gd b/project/net_world_reset_test.gd deleted file mode 100644 index 60a24b0c..00000000 --- a/project/net_world_reset_test.gd +++ /dev/null @@ -1,87 +0,0 @@ -# net_world_reset_test —— 40250 Loading/Destroy 边界的 NetWorld 清理回归。 -# 覆盖旧模型升级队列、聊天尾标、target/hover 和节点在 world reset 后不能回灌。 -extends SceneTree - -const NetWorld = preload("res://net_world.gd") - -class FakeClient extends Node: - signal entity_spawned(entity: Dictionary) - signal entity_despawned(vid: int) - signal entity_moved(vid: int) - signal entity_main_set(vid: int) - signal entity_info(vid: int, entity: Dictionary) - signal chat(type: int, vid: int, text: String) - signal vitals_changed(vid: int) - signal entity_dead(vid: int) - signal damage(vid: int, amount: int, flag: int) - var entities := {} - - func get_entity(vid: int) -> Dictionary: - return entities.get(vid, {}) - - func get_entities() -> Array: - return entities.values() - - func get_pvp_relations() -> Array: - return [] - - func get_duel() -> Dictionary: - return {} - - func add(vid: int, is_main := false) -> Dictionary: - var d := {"vid": vid, "name": "e%d" % vid, "is_main": is_main, - "pos": Vector3.ZERO, "pos_cm": Vector3.ZERO, "func": 0, - "moving": false, "angle_deg": 0.0, "hp": 100, "max_hp": 100, - "dead": false, "race": 101, "affect_flags": 0, - "parts": [0, 0, 0, 0]} - entities[vid] = d - return d - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - await _run() - if _fail == 0: - print("PASS: net_world_reset_test (Loading cleanup generation)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var mount := Node3D.new() - var client := FakeClient.new() - var world: Node = NetWorld.new() - get_root().add_child(mount) - get_root().add_child(client) - get_root().add_child(world) - world.setup(client, mount) - world.set_model_factory(func(_d: Dictionary) -> Node3D: return Node3D.new()) - client.add(1, true) - client.add(2, false) - client.entity_main_set.emit(1) - world.catch_up() - _ck(world._upgrade_queue.has(2), "visible remote model upgrade is queued before reset") - - # Seed the same transient presentation state that 40250 removes with the - # actor manager during Loading. - world._chat_tails[2] = {"text": "old", "name_flag": false} - world.set_target_vid(2) - world.set_hover_vid(2) - - world.clear_for_map_change() - _ck(world._by_vid.is_empty(), "reset removes all live actor nodes") - _ck(world._upgrade_queue.is_empty(), "reset cancels stale model upgrade queue") - _ck(world._chat_tails.is_empty(), "reset removes stale chat tails") - _ck(world._target_vid == 0 and world._hover_vid == 0, - "reset clears target and hover actor references") - - world.free() - client.free() - mount.free() - await process_frame diff --git a/project/net_world_reset_test.gd.uid b/project/net_world_reset_test.gd.uid deleted file mode 100644 index 61f4029c..00000000 --- a/project/net_world_reset_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cxrp1xj5wvjgi diff --git a/project/net_world_respawn_test.gd b/project/net_world_respawn_test.gd deleted file mode 100644 index 972dbe79..00000000 --- a/project/net_world_respawn_test.gd +++ /dev/null @@ -1,120 +0,0 @@ -# net_world_respawn_test —— 40250 NetworkActorManager::__AppendCharacterManagerActor -# 重建边界:普通同 VID 重建使用新包坐标;只有上下马切换保留旧实例坐标。 -extends SceneTree - -const NetWorld = preload("res://net_world.gd") -const FakeClient = preload("res://net_client_fake.gd") - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - await _run() - if _fail == 0: - print("PASS: net_world_respawn_test (spawn overwrite / mount transition)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var mount := Node3D.new() - get_root().add_child(mount) - var client := FakeClient.new() - get_root().add_child(client) - var world: Node = NetWorld.new() - get_root().add_child(world) - world.setup(client, mount) - world.set_process(false) - await process_frame - - var first := { - "vid": 42, "name": "mob", "race": 101, "kind": 2, "ch_type": 2, - "pos": Vector3(1.0, 0.0, -2.0), "pos_cm": Vector3(100.0, 200.0, 0.0), - "angle_deg": 0.0, "moving": false, "func": 0, "walk_mode": 0, - "dead": false, "mount_vnum": 0, "affect_flags": 0, - "hp": 100, "max_hp": 100, "parts": [0, 0, 0, 0] - } - client.entities[42] = first - client.entity_spawned.emit(first) - var node: Node3D = world.node_for(42) - _ck(node != null, "first spawn creates the actor node") - if node == null: - return - _ck(node.position.is_equal_approx(Vector3(first.pos.x, first.pos.y, -first.pos.z)), - "first spawn uses incoming network position") - - # Reference __AppendCharacterManagerActor uses the new SCreateData position - # when the mount state is unchanged. The old node position must not win. - var normal_readd := first.duplicate(true) - normal_readd["pos"] = Vector3(7.0, 0.0, -8.0) - normal_readd["pos_cm"] = Vector3(700.0, 800.0, 0.0) - normal_readd["angle_deg"] = 90.0 - client.entities[42] = normal_readd - client.entity_spawned.emit(normal_readd) - var rebuilt: Node3D = world.node_for(42) - _ck(rebuilt != null and rebuilt != node, "same VID re-add recreates the scene instance") - if rebuilt == null: - return - _ck(rebuilt.position.is_equal_approx(Vector3(normal_readd.pos.x, normal_readd.pos.y, -normal_readd.pos.z)), - "ordinary re-add uses the new authoritative position") - _ck(is_equal_approx(rebuilt.rotation.y, deg_to_rad(90.0)), - "ordinary re-add uses the new authoritative rotation") - - # 40250 preserves the old pixel position only when switching mount state. - var before_mount: Vector3 = rebuilt.position - var mount_readd := normal_readd.duplicate(true) - mount_readd["pos"] = Vector3(12.0, 0.0, -13.0) - mount_readd["pos_cm"] = Vector3(1200.0, 1300.0, 0.0) - mount_readd["mount_vnum"] = 20030 - client.entities[42] = mount_readd - client.entity_spawned.emit(mount_readd) - var mounted: Node3D = world.node_for(42) - _ck(mounted != null, "mount transition recreates the scene instance") - if mounted != null: - _ck(mounted.position.is_equal_approx(before_mount), - "mount transition preserves old position while rebuilding") - _ck(is_equal_approx(mounted.rotation.y, deg_to_rad(90.0)), - "mount transition still uses the incoming rotation") - - # 40250 RemoveActor uses DeleteInstanceByFade, but a later AppendActor for - # the same VID sees no alive instance and creates the replacement immediately; - # the old dead-list blend-out must not suppress the new actor. - client.entity_despawned.emit(42) - _ck(world.node_for(42) == null, "despawn removes the live node while it fades") - var reused := mount_readd.duplicate(true) - reused["pos"] = Vector3(20.0, 0.0, -21.0) - reused["pos_cm"] = Vector3(2000.0, 2100.0, 0.0) - reused["mount_vnum"] = 0 - client.entities[42] = reused - client.entity_spawned.emit(reused) - var replacement: Node3D = world.node_for(42) - _ck(replacement != null, "same VID spawn is rebuilt during the old fade") - if replacement != null: - _ck(replacement.position.is_equal_approx(Vector3(reused.pos.x, reused.pos.y, -reused.pos.z)), - "same VID replacement uses its new authoritative position") - await get_root().get_tree().create_timer(0.5).timeout - _ck(world.node_for(42) == replacement, "old fade completion does not remove the replacement") - - # 40250 removes the selected/main actor through DeleteInstance, not the - # delayed dead-list blend-out path used for ordinary remote actors. - var main_data := reused.duplicate(true) - main_data["vid"] = 7 - main_data["name"] = "main" - client.entities[7] = main_data - world._main_vid = 7 - client.entity_spawned.emit(main_data) - var main_node: Node3D = world.node_for(7) - _ck(main_node != null, "main actor spawn creates the selected node") - client.entity_despawned.emit(7) - _ck(not world._fading.has(7), "main actor despawn skips DeleteInstanceByFade") - _ck(world.node_for(7) == null, "main actor despawn removes the node immediately") - - world.free() - client.free() - mount.free() - await process_frame diff --git a/project/net_world_respawn_test.gd.uid b/project/net_world_respawn_test.gd.uid deleted file mode 100644 index 30042166..00000000 --- a/project/net_world_respawn_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ckbyvy5xlknw4 diff --git a/project/net_world_vis_test.gd b/project/net_world_vis_test.gd deleted file mode 100644 index 28de2f51..00000000 --- a/project/net_world_vis_test.gd +++ /dev/null @@ -1,200 +0,0 @@ -# net_world_vis_test —— CLIENT-GAP §2.1 两层模型 / 可见性剔除的 headless 自检。 -# godot --headless --path project --script net_world_vis_test.gd -# 用带 pos_cm 的假 client 驱动 net_world.gd 的每帧可见性管理: -# - 数据层始终有全部实体;场景节点只在进入 CHAR_STAGE_VIEW_BOUND 时创建 -# - 主角走近 -> 远端实体补建;主角走远 -> 节点淡出(数据保留) -# - AFFECT_SHOW_ALWAYS / IsWall 的实体无视距离常驻 -# 退出码 0 = 全过。 -extends SceneTree - -const NetWorld = preload("res://net_world.gd") - -const BOUND := 200 * 100 # CHAR_STAGE_VIEW_BOUND -const NEAR_CM := Vector2(100 * 100, 0) # 10000 cm < BOUND -const FAR_CM := Vector2(500 * 100, 0) # 50000 cm > BOUND + 10 - -class FakeClient extends Node: - signal entity_spawned(entity: Dictionary) - signal entity_despawned(vid: int) - signal entity_moved(vid: int) - signal entity_main_set(vid: int) - signal entity_info(vid: int, entity: Dictionary) - signal chat(type: int, vid: int, text: String) - signal vitals_changed(vid: int) - signal entity_dead(vid: int) - signal damage(vid: int, amount: int, flag: int) - var ents := {} - var motion_speeds := {} # vid -> Vector2(walk, run) - func set_entity_motion_speed(vid: int, walk: float, run: float) -> void: - motion_speeds[vid] = Vector2(walk, run) - func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) - func get_entities() -> Array: return ents.values() - func get_pvp_relations() -> Array: return [] - func get_duel() -> Dictionary: return {} - func add(vid: int, cm: Vector2, extra := {}) -> Dictionary: - var d := {"vid": vid, "name": "e%d" % vid, "is_main": false, - "func": 0, "moving": false, "angle_deg": 0.0, - "hp": 100, "max_hp": 100, "dead": false, "race": 0, - "affect_flags": 0, "parts": [0, 0, 0, 0], - "pos": Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01), - "pos_cm": Vector3(cm.x, cm.y, 0.0)} - d.merge(extra, true) - ents[vid] = d - return d - func move_main(cm: Vector2) -> void: - ents[1]["pos_cm"] = Vector3(cm.x, cm.y, 0.0) - ents[1]["pos"] = Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01) - -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: net_world_vis_test (§2.1 two-layer visibility cull / re-show)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var mount := Node3D.new() - get_root().add_child(mount) - var fc := FakeClient.new() - get_root().add_child(fc) - var nw: Node = NetWorld.new() - get_root().add_child(nw) - nw.setup(fc, mount) - - var added := {} - var removed := {} - nw.entity_added.connect(func(_n, v): added[v] = int(added.get(v, 0)) + 1) - nw.entity_removed.connect(func(v): removed[v] = int(removed.get(v, 0)) + 1) - - # main character at origin - fc.add(1, Vector2.ZERO, {"is_main": true}) - fc.entity_main_set.emit(1) - - # 1) a near entity spawns a node immediately (signal path, in view) - var near := fc.add(2, NEAR_CM) - fc.entity_spawned.emit(near) - _ck(nw.node_for(2) != null, "near entity (10000cm) -> node built on spawn") - - # 2) a far entity is data-only: the spawn signal must NOT build a node - var far := fc.add(3, FAR_CM) - fc.entity_spawned.emit(far) - _ck(nw.node_for(3) == null, "far entity (50000cm) -> spawn signal builds no node (§2.1)") - - await process_frame - _ck(nw.node_for(3) == null, "far entity still nodeless after a visibility pass") - _ck(nw.node_for(2) != null, "near entity keeps its node while main is close") - - # 3) main walks over to the far entity -> per-frame manager builds its node - fc.move_main(FAR_CM) - await process_frame - await process_frame - _ck(nw.node_for(3) != null, "main in range -> _update_visibility builds the far node") - _ck(int(added.get(3, 0)) == 1, "far node built exactly once") - - # 4) entity 2 is now out of range -> node blends out, data row stays - _ck(nw.node_for(2) == null, "entity 2 out of range -> node culled") - _ck(int(removed.get(2, 0)) == 1, "cull emits entity_removed once") - _ck(fc.get_entity(2).has("vid"), "culled entity still present in the data layer") - - await process_frame - _ck(nw.node_for(2) == null, "culled entity is not rebuilt while it stays out of range") - - # 5) AFFECT_SHOW_ALWAYS (bit 7) -> force-visible regardless of distance - var beacon := fc.add(4, FAR_CM + Vector2(90000, 0), {"affect_flags": 1 << 7}) - fc.entity_spawned.emit(beacon) - _ck(nw.node_for(4) != null, "AFFECT_SHOW_ALWAYS entity -> node built though far") - await process_frame - _ck(nw.node_for(4) != null, "force-visible entity survives the cull loop") - - # 6) IsWall race -> always mirrored - var wall := fc.add(5, FAR_CM + Vector2(120000, 0), {"race": 14202}) - fc.entity_spawned.emit(wall) - await process_frame - _ck(nw.node_for(5) != null, "wall race (14202) -> node built and kept regardless of distance") - - # 7) main walks back -> entity 2 comes into view again and is rebuilt - fc.move_main(NEAR_CM) - # entity 2's fade tween (0.3s) must finish before it can be rebuilt - await create_tween().tween_interval(0.5).finished - await process_frame - _ck(nw.node_for(2) != null, "main back in range -> culled entity rebuilt after its fade") - _ck(int(added.get(2, 0)) == 2, "entity 2 rebuilt exactly once on re-entry") - - await _dead_motion(fc, nw) - await _walk_mode_motion(fc, nw) - -class AnimView extends Node3D: - var states: Array[String] = [] - func set_anim_state(s: String) -> void: - states.append(s) - -# MAP-02 / CActorInstance::Die: a model plays NAME_DEAD only (no scene tilt), and -# __SetMotion rejects every non-dead motion while the actor is dead — the -# func/moving edge that arrives with the death must not restart WAIT. -func _dead_motion(fc: FakeClient, nw: Node) -> void: - nw.set_model_factory(func(d: Dictionary) -> Node3D: - return AnimView.new() if int(d.get("race", 0)) == 2301 else null) - var ent := fc.add(6, NEAR_CM + Vector2(300, 0), {"race": 2301, "ch_type": 2, "moving": true}) - fc.entity_spawned.emit(ent) - await process_frame - var view := nw.node_for(6) as AnimView - _ck(view != null and view.states.back() == "run", "moving tree monster -> run requested") - if view == null: - return - ent["moving"] = false - ent["dead"] = true - fc.entity_dead.emit(6) - await create_tween().tween_interval(0.4).finished - await process_frame - _ck(view.states.back() == "dead", "death while moving keeps DEAD (reference __SetMotion ignores WAIT when dead): %s" % [view.states]) - _ck(is_zero_approx(view.rotation.x) and is_zero_approx(view.rotation.z), "model with motions is not tilted on death (rotation.x=%.2f)" % view.rotation.x) - var capsule := fc.add(7, NEAR_CM + Vector2(600, 0), {"ch_type": 2}) - fc.entity_spawned.emit(capsule) - var placeholder: Node3D = nw.node_for(7) - capsule["dead"] = true - fc.entity_dead.emit(7) - await create_tween().tween_interval(0.4).finished - _ck(placeholder != null and placeholder.rotation.x < -1.0, "placeholder capsule (no motions) still falls over as its only death cue") - -class MoveView extends AnimView: - func get_move_motion_speeds() -> Vector2: - return Vector2(87.5, 425.75) - -# packet.h WALKMODE_RUN = 0 / WALKMODE_WALK = 1;CActorInstance::Move 按 m_isWalking -# 选 WALK / RUN。模型建好后把走 / 跑根运动速度推给 EntityStore(骑马不推)。 -func _walk_mode_motion(fc: FakeClient, nw: Node) -> void: - nw.set_model_factory(func(_d: Dictionary) -> Node3D: return MoveView.new()) - var walker := fc.add(8, NEAR_CM + Vector2(900, 0), {"race": 101, "ch_type": 2, "func": 1, "walk_mode": 1}) - fc.entity_spawned.emit(walker) - var runner := fc.add(9, NEAR_CM + Vector2(1200, 0), {"race": 101, "ch_type": 2, "func": 1, "walk_mode": 0}) - fc.entity_spawned.emit(runner) - var rider := fc.add(10, NEAR_CM + Vector2(1500, 0), {"race": 101, "ch_type": 0, "mount_vnum": 20030}) - fc.entity_spawned.emit(rider) - await process_frame - await process_frame # process_frame 在节点 _process 之前发 - var wv := nw.node_for(8) as AnimView - var rv := nw.node_for(9) as AnimView - _ck(wv != null and wv.states.back() == "walk", "walk_mode 1 (WALKMODE_WALK) -> walk: %s" % [wv.states if wv else []]) - _ck(rv != null and rv.states.back() == "run", "walk_mode 0 (WALKMODE_RUN) -> run: %s" % [rv.states if rv else []]) - _ck(fc.motion_speeds.get(8, Vector2.ZERO) == Vector2(87.5, 425.75), "spawn pushes walk/run motion speeds: %s" % [fc.motion_speeds]) - _ck(not fc.motion_speeds.has(10), "mounted actor keeps the duration lerp (no push)") - walker["walk_mode"] = 0 - await process_frame - await process_frame - _ck(wv.states.back() == "run", "GC_WALK_MODE flip to run re-selects RUN: %s" % [wv.states]) - # 匀角速度转向:一帧转角不超过 1200°/s × dt。 - var before := wv.rotation.y - walker["angle_deg"] = 180.0 - await process_frame - var t0 := Time.get_ticks_usec() - await process_frame - var dt := float(Time.get_ticks_usec() - t0) / 1e6 - _ck(absf(angle_difference(before, wv.rotation.y)) <= deg_to_rad(1200.0) * (dt + 0.05), "turn limited to 1200deg/s") diff --git a/project/net_world_vis_test.gd.uid b/project/net_world_vis_test.gd.uid deleted file mode 100644 index b2aade70..00000000 --- a/project/net_world_vis_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bhriuw4uo5d2b diff --git a/project/netbridge_test.gd b/project/netbridge_test.gd deleted file mode 100644 index fe59030b..00000000 --- a/project/netbridge_test.gd +++ /dev/null @@ -1,267 +0,0 @@ -# netbridge_test —— M2Client 游戏内 API + NetWorld 场景桥的 headless 自检。 -# godot --headless --path project --script netbridge_test.gd -# 无需服务器:用假 client 驱动 NetWorld;M2Client 部分只查绑定面 + 未入局时拒绝。 -# 退出码 0 = 全过。 -extends SceneTree - -const NetWorld = preload("res://net_world.gd") - -class FakeClient extends Node: - signal entity_spawned(entity: Dictionary) - signal entity_despawned(vid: int) - signal entity_moved(vid: int) - signal entity_main_set(vid: int) - signal entity_info(vid: int, entity: Dictionary) - signal chat(type: int, vid: int, text: String) - signal fly_cue(type: int, start_vid: int, end_vid: int) - signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) - signal fishing_event(subheader: int, info: int, dir: int) - signal vitals_changed(vid: int) - signal entity_dead(vid: int) - signal damage(vid: int, amount: int, flag: int) - signal motion(vid: int, victim_vid: int, motion: int) - signal pvp_changed(src_vid: int, dst_vid: int, mode: int) - signal duel_changed(duel: Dictionary) - var ents := {} - func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) - func get_entities() -> Array: return ents.values() - func get_pvp_relations() -> Array: return [] - func get_duel() -> Dictionary: return {} - func spawn(vid, nm, pos, is_main := false): - ents[vid] = {"vid": vid, "name": nm, "pos": pos, "is_main": is_main, - "func": 0, "moving": false, "angle_deg": 0.0, - "hp": 100, "max_hp": 100, "dead": false} - if is_main: entity_main_set.emit(vid) - entity_spawned.emit(ents[vid]) - func set_info(vid, nm): - ents[vid]["name"] = nm - entity_info.emit(vid, ents[vid]) - func move_to(vid, pos, f := 1): - ents[vid].merge({"pos": pos, "func": f, "moving": true}, true) - entity_moved.emit(vid) - func hurt(vid, hp, amount, flag := 1): - ents[vid]["hp"] = hp - ents[vid]["dead"] = hp <= 0 - damage.emit(vid, amount, flag) - vitals_changed.emit(vid) - if hp <= 0: entity_dead.emit(vid) - func despawn(vid): - ents.erase(vid); entity_despawned.emit(vid) - func set_fly_target(shooter_vid, target_vid, target_cm, append := false): - fly_targeting.emit(shooter_vid, target_vid, target_cm, append) - -var _fail := 0 -func _ck(cond: bool, msg: String) -> void: - if not cond: - _fail += 1 - push_error("FAIL: " + msg) - printerr("FAIL: " + msg) - -func _init() -> void: - _test_m2client_surface() - await _test_netbridge() - if _fail == 0: - print("PASS: netbridge_test (M2Client API + NetWorld bridge)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _test_m2client_surface() -> void: - if not ClassDB.class_exists("M2Client"): - _ck(false, "M2Client not registered (extension built?)") - return - var c: Object = ClassDB.instantiate("M2Client") - get_root().add_child(c) - for m in ["move", "character_position", "sync_positions", "request_warp", "fishing", "request_dungeon", "attack", "set_target", "say", "shoot", "fly_targeting", "add_fly_targeting", "change_name", "select_empire", "download_guild_symbol", "get_guild_symbol", "script_select_item", "quest_cancel", "party_use_skill", "get_skill_group", "get_entity", "get_entities", - "get_main_vid", "get_main_pid", "get_points", "get_target", "select_character", "enter_game", "reconnect", "send_emoticon", - "suspend", "resume", "move_item", "use_item", "drop_item", "drop_item_count", "use_item_to_item", "give_item", "pickup_item", - "get_inventory", "get_equipment", "get_belt_inventory", "get_view_equipment", "get_item", "get_ground_items", "get_pvp_relations", "get_duel", "get_lover", "get_land_areas", "get_observers", "is_observer_mode", "get_observer_count", "has_mobile_flag", "combo_skill_enabled", "get_stamina_state", "whisper", - "party_invite", "party_answer", "party_leave", "party_set_distribute", "party_set_state", "get_party", "get_party_distribute_mode", - "add_friend", "remove_friend", "friend_answer", "get_friends", - "shop_buy", "shop_sell", "shop_close", "is_shop_open", "get_shop_items", "get_shop", - "exchange_start", "exchange_add_item", "exchange_add_gold", "exchange_accept", "exchange_cancel", "get_exchange", - "safebox_checkin", "safebox_checkout", "safebox_move", "is_safebox_open", "get_safebox_size", "get_safebox_gold", "get_safebox_items", - "is_mall_open", "get_mall_size", "get_mall_items", "mall_checkout", "mall_password", "open_private_shop", "close_private_shop", "safebox_password", - "get_cube", "cube_make", "cube_request_result_list", "cube_request_materials", "cube_open", "cube_close", "cube_list", "cube_add_item", "cube_delete_item", - "get_guild", "get_guild_members", "get_guild_grades", "guild_add_member", "guild_remove_member", "guild_offer", "guild_charge_gsp", "guild_change_grade_name", "guild_change_grade_authority", "guild_change_member_grade", "guild_change_member_general", "guild_post_comment", "guild_delete_comment", "guild_refresh_comments", "guild_answer_invite", "guild_answer_make", "get_guild_comments", "get_guild_skill", "get_guild_wars", "get_guild_war", "get_guild_name", "use_guild_skill", "declare_guild_war", - "download_guild_marks", "are_guild_marks_ready", "get_mark_server", "get_guild_mark", "get_guild_mark_image", "upload_guild_mark", "upload_guild_symbol", - "refine", "ds_refine", "get_dragon_souls", "get_channel", "get_server_time", "get_npc_marks", "get_world_markers"]: - _ck(c.has_method(m), "M2Client.%s bound" % m) - for s in ["entity_spawned", "entity_despawned", "entity_moved", "entity_main_set", "empire_changed", - "chat", "emoticon_requested", "suspended", "resumed", "vitals_changed", "entity_dead", "damage", - "motion", "point_changed", "points_changed", "target_info", "inventory_changed", - "ground_item_added", "ground_item_removed", "item_picked_up", "item_used", "whisper_received", - "entity_info", "warp", "time_changed", "channel_changed", "npc_marks_changed", - "land_areas_changed", "observer_event", "observer_mode_changed", "observer_count_changed", "stone_detected", "stamina_changed", "mobile_flag_changed", "mobile_auth_required", "combo_changed", "world_markers_changed", "mount_changed", "pvp_changed", "duel_changed", "duel_started", "view_equipment", "lover_changed", - "fly_targeting", "dig_motion", "fishing_event", "dungeon_event", "skill_group_changed", "guild_make_requested", "guild_symbol_ready", - "party_changed", "party_invite_ask", "party_request_denied", "friends_changed", "friend_invite_ask", "shop_opened", "shop_closed", "shop_error", - "exchange_changed", "safebox_changed", "safebox_password_required", "safebox_wrong_password", "mall_opened", "mall_changed", "mall_password_required", "private_shop_open_requested", "my_shop_price_list", "block_mode_changed", "gift_available", "cube_opened", "cube_closed", "cube_changed", "cube_result", - "guild_changed", "guild_skill_changed", "guild_war_changed", "guild_comments_changed", "guild_invite_ask", "guild_war_event", "guild_war_point", - "guild_marks_ready", "guild_mark_uploaded", "guild_mark_updated", "refine_ask", "refine_result", "ds_window_open", "ds_refine_result", "weather_changed"]: - _ck(c.has_signal(s), "M2Client.%s signal" % s) - # not in game -> intents refused, snapshot empty - _ck(c.call("move", 1, 0, 90.0, 1000, 2000) == false, "move refused pre-game") - _ck(c.call("character_position", 3) == false, "character_position refused pre-game") - _ck(c.call("sync_positions", [{"vid": 1, "x": 10, "y": 20}]) == false, "sync_positions refused pre-game") - _ck(c.call("request_warp") == false, "request_warp refused pre-game") - _ck(c.call("fishing", 90.0) == false, "fishing refused pre-game") - _ck(c.call("request_dungeon") == false, "request_dungeon refused pre-game") - _ck(c.call("shoot", 1) == false, "shoot refused pre-game") - _ck(c.call("fly_targeting", 1, 10, 20) == false, "fly_targeting refused pre-game") - _ck(c.call("script_select_item", 1) == false, "script_select_item refused pre-game") - _ck(c.call("quest_cancel") == false, "quest_cancel refused pre-game") - _ck(c.call("party_use_skill", 1, 1) == false, "party_use_skill refused pre-game") - _ck(c.call("change_name", 0, "Renamed") == false, "change_name refused before connection") - _ck(c.call("get_skill_group") == 0, "skill group defaults to 0 pre-game") - _ck(c.call("attack", 0, 1) == false, "attack refused pre-game") - _ck(c.call("get_entities").is_empty(), "no entities pre-game") - _ck(c.call("get_main_vid") == 0, "no main vid pre-game") - _ck(c.call("get_points").is_empty(), "no points pre-game") - _ck(c.call("get_target").is_empty(), "no target pre-game") - _ck(c.call("get_inventory").is_empty(), "no inventory pre-game") - _ck(c.call("move_item", 1, 0, 2, 1, 1) == false, "move_item refused pre-game") - c.free() - _test_proto() - -func _test_proto() -> void: - _ck(ClassDB.class_exists("Metin2Proto"), "Metin2Proto registered") - if not ClassDB.class_exists("Metin2Proto"): - return - var p: Object = ClassDB.instantiate("Metin2Proto") - get_root().add_child(p) - var ip := AssetRoot.locale_file("item_proto") - if ip == "": - print(" (skip Metin2Proto load: no 40250 pack)") - p.queue_free() - return - _ck(p.call("load_item_proto", ip), "load_item_proto: %s" % p.call("get_last_error")) - _ck(p.call("item_count") > 1000, "item_count > 1000 (%d)" % p.call("item_count")) - var yang: Dictionary = p.call("item", 1) - _ck(yang.get("name", "") == "Yang", "item(1).name == Yang") - _ck(p.call("item", 999999999).is_empty(), "unknown vnum -> {}") - p.queue_free() - -func _test_netbridge() -> void: - var mount := Node3D.new() - get_root().add_child(mount) - var fc := FakeClient.new() - get_root().add_child(fc) - var nw: Node = NetWorld.new() - get_root().add_child(nw) - nw.setup(fc, mount) - var added := [0] - var removed := [0] - var main_node := [null] - nw.entity_added.connect(func(_n, _v): added[0] += 1) - nw.entity_removed.connect(func(_v): removed[0] += 1) - nw.main_entity_ready.connect(func(n, _v): main_node[0] = n) - - fc.spawn(1000, "Me", Vector3.ZERO, true) - fc.spawn(2000, "", Vector3(10, 0, 0)) # 名字晚点走 entity_info - await process_frame - _ck(added[0] == 2, "2 entity nodes added") - _ck(mount.get_child_count() == 2, "2 children under mount") - _ck(main_node[0] != null and int(main_node[0].get_meta("vid")) == 1000, "main entity surfaced") - fc.set_fly_target(2000, 1000, Vector2(120, 340)) - await process_frame - _ck(int(nw.node_for(2000).get_meta("fly_target_vid", 0)) == 1000, - "fly_targeting -> shooter target metadata") - var projectile_children := mount.get_child_count() - var projectiles_before: int = nw._fly_mgr.active_count() - fc.fly_cue.emit(1, 2000, 0) # coordinate/queued target fallback - _ck(nw._fly_mgr.active_count() == projectiles_before + 1, - "create_fly -> consumes queued target when end VID is absent") - _ck(mount.get_child_count() > projectile_children, - "create_fly -> resource visual/tail is mounted") - fc.fishing_event.emit(0, 2000, 18) - await process_frame - _ck(int(nw.node_for(2000).get_meta("fishing_subheader", -1)) == 0 - and int(nw.node_for(2000).get_meta("fishing_dir", -1)) == 18, - "fishing start -> entity fishing metadata") - fc.fishing_event.emit(5, 30001, 0) # item vnum, not a fisher VID - await process_frame - _ck(not nw.node_for(2000).has_meta("fishing_item_vnum"), - "fishing fish outcome is not treated as entity motion") - fc.motion.emit(2000, 1000, 325) # CRaceMotionData::NAME_DANCE_1 - await process_frame - _ck(int(nw.node_for(2000).get_meta("last_motion", 0)) == 325 - and int(nw.node_for(2000).get_meta("motion_victim_vid", 0)) == 1000, - "GC_MOTION -> entity motion metadata and bridge signal") - - # GC_CHAR_ADD_INFO late name -> 3D label refreshes (name_resolver optional) - var mob0: Node3D = nw.node_for(2000) - _ck(String((mob0.get_node("Label3D") as Label3D).text).begins_with("vid "), - "placeholder label starts as 'vid N' (no name yet)") - nw.name_resolver = func(d): return "Angry " + String(d.get("name", "")) - fc.set_info(2000, "Mob") - await process_frame - _ck((mob0.get_node("Label3D") as Label3D).text == "Angry Mob", - "entity_info -> label refreshed via name_resolver") - fc.ents[2000]["shop_sign"] = "Potion shop" - fc.entity_info.emit(2000, fc.ents[2000]) - await process_frame - var shop_sign := mob0.get_node_or_null("ShopSign") as Label3D - _ck(shop_sign != null and shop_sign.text == "Potion shop", "entity_info -> shop sign label") - fc.ents[2000]["shop_sign"] = "" - fc.entity_info.emit(2000, fc.ents[2000]) - await process_frame - _ck(mob0.get_node_or_null("ShopSign") == null, "empty shop sign removes label") - fc.pvp_changed.emit(1000, 2000, 2) - await process_frame - var pvp_tag := mob0.get_node_or_null("PvpTag") as Label3D - _ck(pvp_tag != null and pvp_tag.text == "PVP", "pvp fight -> world PVP tag") - fc.pvp_changed.emit(1000, 2000, 0) - await process_frame - _ck(mob0.get_node_or_null("PvpTag") == null, "pvp none -> removes world tag") - fc.duel_changed.emit({"active": true, "cannot_attack": false, "opponents": [2000]}) - await process_frame - var duel_tag := mob0.get_node_or_null("PvpTag") as Label3D - _ck(duel_tag != null and duel_tag.text == "决斗", "duel opponent -> world duel tag") - fc.duel_changed.emit({"active": false, "cannot_attack": true, "opponents": []}) - await process_frame - _ck(mob0.get_node_or_null("PvpTag") == null, "duel cleared -> removes world tag") - - fc.move_to(2000, Vector3(10, 0, 40)) - for i in 3: await process_frame - var mob: Node3D = nw.node_for(2000) - _ck(mob.position.distance_to(Vector3(10, 0, -40)) < 0.5, "far move -> snap") # to_world 翻 Z - _ck(int(mob.get_meta("func")) == 1, "func -> FUNC_MOVE") - - # §3.2:位置插值移交 C++ EntityStore::tick(),net_world._process 直接跟随 e.pos, - # 不再叠自己的 follow_lerp。这里 FakeClient 的 pos 是瞬时值,节点应当直接对齐。 - fc.move_to(2000, Vector3(11, 0, 41)) - await process_frame - _ck(mob.position.distance_to(Vector3(11, 0, -41)) < 0.01, "move -> node follows e.pos directly") - - var got_chat := [""] - nw.chat_line.connect(func(_t, _v, s): got_chat[0] = s) - fc.chat.emit(1, 2000, "hi there") - await process_frame - _ck(got_chat[0] == "hi there", "chat line relayed") - - # combat: damage number + vitals + death - var dmg := [0, false] - var vit := [0, 0, false] - nw.damage_number.connect(func(_v, amt, crit, _dodge): dmg[0] = amt; dmg[1] = crit) - nw.vitals.connect(func(_v, hp, mhp, dead): vit[0] = hp; vit[1] = mhp; vit[2] = dead) - fc.hurt(2000, 60, 40) - await process_frame - _ck(dmg[0] == 40, "damage_number relayed (40)") - _ck(vit[0] == 60 and vit[1] == 100 and not vit[2], "vitals relayed (60/100)") - var bar := nw._hp_bars.get(2000, null) as Control - _ck(bar != null and absf(float(bar.ratio) - 0.6) < 0.01, - "screen-space HP bar ratio updated to 0.6") - fc.hurt(2000, 0, 60, 1 << 5) # crit, lethal - await process_frame - _ck(dmg[1] == true, "crit flag relayed") - _ck(vit[2] == true, "vitals dead=true") - - fc.despawn(2000) - await process_frame - await process_frame - _ck(removed[0] == 1 and nw.node_for(2000) == null, "despawn frees node") - nw.clear_for_map_change() - nw.free() - fc.free() - mount.free() diff --git a/project/netbridge_test.gd.uid b/project/netbridge_test.gd.uid deleted file mode 100644 index f503f7fe..00000000 --- a/project/netbridge_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d4kjxl5d6lo6m diff --git a/project/netplay_test.gd b/project/netplay_test.gd deleted file mode 100644 index 3229d5da..00000000 --- a/project/netplay_test.gd +++ /dev/null @@ -1,633 +0,0 @@ -# netplay_test —— P0 闭环胶水(net_play.gd)的 headless 自检。 -# godot --headless --path project --script netplay_test.gd -# 用假 M2Client / 假 PlayerController / 假 HUD 驱动,断言 net_play 产生的调用正确。 -# 退出码 0 = 全过。 -extends SceneTree - -const NetPlay = preload("res://net_play.gd") -const NetWorld = preload("res://net_world.gd") -const EntityRules = preload("res://entity_rules.gd") - -class FakeClient extends Node: - signal entity_spawned(entity: Dictionary) - signal entity_despawned(vid: int) - signal entity_moved(vid: int) - signal entity_info(vid: int, entity: Dictionary) - signal entity_main_set(vid: int) - signal entity_dead(vid: int) - signal vitals_changed(vid: int) - signal point_changed(vid: int, point_type: int, value: int, amount: int) - signal points_changed(points: Dictionary) - signal target_info(vid: int, hp_percent: int) - signal observer_mode_changed(enabled: bool) - signal fishing_event(subheader: int, info: int, dir: int) - signal chat(type: int, vid: int, text: String) - var ents := {} - var main := 0 - var in_game := true - var guild_war := {"state": 0} - var ground_items := [] - var skills := [] - var calls := {"move": [], "attack": [], "set_target": [], "click_npc": [], - "pickup": [], "fly": [], "fishing": []} - func is_in_game() -> bool: return in_game - func get_main_vid() -> int: return main - func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) - func get_entities() -> Array: return ents.values() - func move(f, a, rot, x, y) -> bool: calls.move.append([f, a, rot, x, y]); return true - func attack(motion, vid) -> bool: calls.attack.append([motion, vid]); return true - func set_target(vid) -> bool: calls.set_target.append(vid); return true - func click_npc(vid) -> bool: calls.click_npc.append(vid); return true - func pickup_item(vid) -> bool: calls.pickup.append(vid); return true - func add_fly_targeting(vid, x, y) -> bool: calls.fly.append([vid, x, y]); return true - func fishing(rot_deg) -> bool: calls.fishing.append(rot_deg); return true - func get_ground_items() -> Array: return ground_items - func get_skills() -> Array: return skills - func get_guild_war() -> Dictionary: return guild_war - -class FakePC extends Node: - signal target_selected(node: Node3D) - signal moved(pos: Vector3) - signal anim_state(state: String) - var player: Node3D - var server_speed := 1.0 - var walk_calls := [] - var frozen := false - var going := false - var dead := false - func set_server_speed(moving_speed: int) -> void: server_speed = float(moving_speed) / 100.0 - func walk_to(p: Vector3) -> void: walk_calls.append(p); going = true - func is_going() -> bool: return going - func stop() -> void: going = false - func set_dead(value: bool) -> void: - dead = value - if dead: - stop() - -class FakeHud extends Node: - var vitals := [] - var exp_v := [] - var level := 0 - var target := [] - var energy := [] - var picked_money := [] - var cleared := 0 - func set_vitals(hp, mhp, sp, msp): vitals = [hp, mhp, sp, msp] - func set_exp(xp, nxp): exp_v = [xp, nxp] - func set_level(lv): level = lv - func set_energy(value, maximum): energy = [value, maximum] - func on_pick_money(amount): picked_money.append(amount) - func set_target(nm, pct): target = [nm, pct] - func clear_target(): cleared += 1 - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - await _run() - if _fail == 0: - print("PASS: netplay_test (P0 move/attack/target/HUD loop)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var root := get_root() - var fc := FakeClient.new() - var pc := FakePC.new() - var hud := FakeHud.new() - var nw: Node = NetWorld.new() - pc.player = Node3D.new() - root.add_child(fc); root.add_child(pc); root.add_child(hud); root.add_child(nw) - root.add_child(pc.player) - var np: Node = NetPlay.new() - root.add_child(np) - np.setup(fc, pc, nw, hud) - - fc.main = 1000 - fc.ents[1000] = {"vid": 1000, "pos": Vector3.ZERO, "hp": 200, "max_hp": 200, - "moving_speed": 150, "attack_speed": 150} - fc.entity_main_set.emit(1000) - fc.entity_info.emit(1000, fc.ents[1000]) - _ck(is_equal_approx(pc.server_speed, 1.5), "GC_CHANGE_SPEED -> local prediction speed scale") - # §3.3:attack_speed 只喂攻速系数(缩放播放速率),不再在收包时把 period 直接写死 - _ck(is_equal_approx(np._atk_speed_factor, 1.5), "entity attack_speed -> attack-speed factor") - _ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5), - "no motion data -> cadence = DEFAULT_ATTACK_PERIOD / factor") - _ck(not bool(np.skill_context().get("guild_war_active", false)), - "GUILD_WAR_NONE -> guild skill gate closed") - fc.guild_war["state"] = 6 - _ck(bool(np.skill_context().get("guild_war_active", false)), - "GUILD_WAR_ON_WAR -> guild skill gate open") - fc.guild_war["state"] = 4 - _ck(not bool(np.skill_context().get("guild_war_active", false)), - "GUILD_WAR_WAIT_START -> guild skill gate remains closed") - - # §5.3:SHORSE::CanAttack / CPythonPlayer::__CanAttack 的坐骑等级门。 - # 普通坐骑 level 1 不能攻击;新坐骑还需要骑乘术 level 11 或 master。 - fc.skills = [{"id": 109, "level": 1, "master": 0}] - fc.ents[1000]["mount_vnum"] = 20101 - _ck(not np._can_attack_horse_level(), "horse level 1 -> CanAttackHorseLevel false") - fc.ents[1000]["mount_vnum"] = 20205 - _ck(np._can_attack_horse_level(), "horse level 2 -> CanAttackHorseLevel true") - _ck(not np._can_attack(), "new horse skill below level 11 -> attack blocked") - fc.skills[0]["level"] = 11 - _ck(np._can_attack(), "new horse skill level 11 -> attack allowed") - fc.ents[1000]["mount_vnum"] = 0 - fc.skills = [] - - # §5.3:PythonPlayerInputKeyboard::SetAttackKeyState 在 FISHING mode - # 改走 NEW_Fishing,不设置普通攻击键;GC_FISHING 到达后方向键/地面点击 - # 走 NEW_CancelFishing,且 500ms 内不重复发取消包。 - np.combo_motion_mode = np.MOTION_MODE_FISHING - pc.player.rotation.y = 0.0 - fc.calls.fishing.clear() - np.set_attack_key(true) - _ck(fc.calls.fishing.size() == 1 and is_equal_approx(float(fc.calls.fishing[0]), 0.0), - "fishing attack key -> CG_FISHING with MapCoord heading") - _ck(not np._attack_key_down, "fishing attack key does not enable normal attack") - fc.fishing_event.emit(0, 1000, 0) - _ck(np.is_fishing(), "GC_FISHING START -> IsFishing state") - _ck(np.cancel_fishing(), "NEW_CancelFishing sends first cancel") - _ck(fc.calls.fishing.size() == 2 and is_equal_approx(float(fc.calls.fishing[1]), 0.0), - "cancel fishing -> CG_FISHING(0)") - _ck(not np.cancel_fishing(), "NEW_CancelFishing throttles within 500ms") - np.combo_motion_mode = np.MOTION_MODE_GENERAL - - # 1) 点地移动 -> move(FUNC_MOVE) 带 server cm + heading - pc.player.position = Vector3(3, 0, -5) # to_server_cm -> (300, -500) - pc.player.rotation.y = 0.0 - pc.moved.emit(pc.player.position) - _ck(fc.calls.move.size() == 1, "one move sent") - if fc.calls.move.size() == 1: - var mv: Array = fc.calls.move[0] - _ck(mv[0] == 1, "func == FUNC_MOVE") - _ck(mv[3] == 300 and mv[4] == -500, "server cm (300,-500), got (%d,%d)" % [mv[3], mv[4]]) - _ck(mv[2] >= 0 and mv[2] < 360, "heading in [0,360)") - - # 2) 节流:紧接着的小位移不发 - pc.player.position = Vector3(3.3, 0, -5) - pc.moved.emit(pc.player.position) - _ck(fc.calls.move.size() == 1, "small quick move throttled") - - # 3) 停下 -> move(FUNC_WAIT) 一次 - pc.anim_state.emit("wait") - _ck(fc.calls.move.size() == 2 and fc.calls.move[1][0] == 0, "stop -> FUNC_WAIT sent") - pc.anim_state.emit("wait") - _ck(fc.calls.move.size() == 2, "second wait not re-sent") - - # 4) 点实体 -> set_target - var mob := Node3D.new() - mob.set_meta("vid", 2000) - root.add_child(mob) - mob.position = Vector3(4, 0, -5) # 距玩家(3.3,0,-5) 0.7m -> 攻击距离内 - fc.ents[2000] = {"vid": 2000, "ch_type": 2, "name": "Wolf", "pos": mob.position, "hp": 50, "max_hp": 50, "dead": false} - nw._by_vid[2000] = mob # 让 net_world.node_for(2000) 命中 - pc.target_selected.emit(mob) - _ck(fc.calls.set_target.size() == 1 and fc.calls.set_target[0] == 2000, "set_target(2000)") - - # NPC/传送点/石头走 click_npc,而不是被误当成攻击目标;这是 Cube - # 窗口由服务端 cube_open 命令打开前的入口。 - var npc := Node3D.new() - npc.set_meta("vid", 3000) - npc.position = Vector3(4, 0, -5) # 距玩家(3.3,0,-5) 0.7m -> 交互距离内(<= 5m) - root.add_child(npc) - nw._by_vid[3000] = npc - fc.ents[3000] = {"vid": 3000, "ch_type": 1, "name": "Cube NPC", "pos": npc.position} - pc.target_selected.emit(npc) - _ck(fc.calls.click_npc == [3000], "NPC selection -> click_npc(3000)") - - # 远距离点选 NPC(> 5m):预约走近,到达交互距离后停下并触发 click_npc - var far_npc := Node3D.new() - far_npc.set_meta("vid", 3010) - far_npc.position = Vector3(20, 0, -5) # 距玩家 16.7m -> 超出 5m 交互范围 - root.add_child(far_npc) - nw._by_vid[3010] = far_npc - fc.ents[3010] = {"vid": 3010, "ch_type": 1, "name": "Far NPC", "pos": far_npc.position} - pc.target_selected.emit(far_npc) - _ck(np._reserved_mode == 2, "远距离点 NPC -> 预约走近 (CLICK_ACTOR)") - _ck(pc.is_going(), "远距离点 NPC -> 角色自动向 NPC 移动") - _ck(fc.calls.click_npc == [3000], "尚未到达时不发点击包") - # 玩家走近到 NPC 交互距离内 - pc.player.position = Vector3(18, 0, -5) - np._reserve_process_click_actor() - _ck(fc.calls.click_npc == [3000, 3010], "走近到交互距离后 -> 触发 click_npc(3010)") - _ck(np._reserved_mode == 0, "触发交互后清空预约") - _ck(not pc.is_going(), "触发交互后停止移动") - nw._by_vid.erase(3010) - fc.ents.erase(3010) - far_npc.queue_free() - pc.player.position = Vector3(3.3, 0, -5) - # §5.4 / §8.8:木门不应退化为 PC;它可进入 CanViewTargetHP / 攻击分类。 - _ck(np._entity_kind({"vid": 3001, "race": 13000}) == EntityRules.KIND_WOODEN_DOOR, - "race 13000 -> WOODEN_DOOR") - _ck(np._entity_kind({"vid": 3002, "kind": EntityRules.KIND_BUILDING}) == EntityRules.KIND_BUILDING, - "explicit BUILDING kind -> BUILDING") - - # 5) 只有按住 SPACE(参考端 m_isAtkKey)才在攻击距离内 attack - np.set_attack_key(true) - for i in 4: await process_frame - _ck(np._is_attacking, "space-held input starts the local attack motion state") - _ck(fc.calls.attack.is_empty(), "missing .msa AttackingData -> no synthetic CG_ATTACK") - var n1: int = fc.calls.attack.size() - await process_frame - _ck(fc.calls.attack.size() == n1, "attack throttled within period") - np.set_attack_key(false) - - # 5b) §3.4 预约动作状态机(__ReserveProcess_ClickActor 的 10 步顺序) - # 步骤 2:目标在可点击距离外 -> 继续靠近(walk_to),不发攻击 - pc.walk_calls.clear() - fc.calls.attack.clear() - mob.position = Vector3(40, 0, -5) # 距玩家 ~37m,远超 1.5m 默认可点击距离 - np.set_attack_key(true) - for i in 3: await process_frame - _ck(pc.walk_calls.size() >= 1, "out-of-range reserved target -> walk_to (approach)") - _ck(fc.calls.attack.is_empty(), "out-of-range -> no attack packet") - _ck(np._reserved_mode == NetPlay.ReservedMode.CLICK_ACTOR, "reservation kept while approaching") - - # 步骤 10:进入距离 -> 攻击且预约清空 - mob.position = Vector3(4, 0, -5) - np._attack_cd = 0.0 - fc.calls.attack.clear() - await process_frame - _ck(fc.calls.attack.is_empty(), "in-range reserved target without .msa -> no CG_ATTACK") - _ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "reservation cleared after swing") - - # 步骤 5:自己在安全区 -> OnCannotAttack('IN_SAFE') + 清预约,不攻击 - var cannot: Array = [] - np.cannot_act.connect(func(code): cannot.append(code)) - fc.ents[1000]["in_safe"] = true - np._attack_cd = 0.0 - fc.calls.attack.clear() - for i in 2: await process_frame - _ck(cannot.has("IN_SAFE"), "main in safe zone -> cannot_act('IN_SAFE')") - _ck(fc.calls.attack.is_empty(), "safe zone -> no attack packet") - fc.ents[1000]["in_safe"] = false - - # __Update_AutoAttack:目标死亡 -> 清 auto-attack + 目标 - fc.ents[2000]["dead"] = true - np._attack_cd = 0.0 - await process_frame - _ck(np._auto_attack_vid == 0, "dead reserved target -> auto-attack cleared") - fc.ents[2000]["dead"] = false - np.set_attack_key(false) - np._clear_reserved() - - # 5c) §3.4 增量 91 —— CLICK_ITEM / CLICK_POSITION / USE_SKILL 预约分派 - # + __ChangeTargetToPickedInstance + 扇形/圆形补 fly-targeting - _ck(NetPlay.CLICK_ITEM_PICKUP_CM == 150.0, - "40250 __OnPressItem 的直接点击拾取阈值固定为 150cm") - np._target_vid = 0 - np._auto_attack_vid = 0 - np.set_attack_key(false) - np._clear_reserved() - pc.player.position = Vector3.ZERO - pc.player.rotation.y = 0.0 - pc.walk_calls.clear() - fc.calls.move.clear() - - # CLICK_ITEM:远处掉落物 -> walk_to;到手 -> pickup + FUNC_WAIT + 清预约 - fc.ground_items = [{"vid": 777, "vnum": 1, "owner": "", "pos": Vector3(5, 0, 5)}] # to_world -> (5,0,-5) - np.reserve_click_item(777) - await process_frame - _ck(pc.walk_calls.size() >= 1, "CLICK_ITEM far -> walk_to") - _ck(np._reserved_mode == NetPlay.ReservedMode.CLICK_ITEM, "CLICK_ITEM reservation kept while approaching") - pc.player.position = Vector3(5, 0, -5) # 站到物品上 - var mv_n: int = fc.calls.move.size() - await process_frame - _ck(fc.calls.pickup == [777], "CLICK_ITEM in-range -> pickup_item(777)") - _ck(fc.calls.move.size() >= mv_n + 1 and fc.calls.move[mv_n][0] == np.FUNC_WAIT, - "CLICK_ITEM in-range -> FUNC_WAIT state packet") - _ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "CLICK_ITEM cleared after pickup") - - # CLICK_ITEM:物品已消失 -> 清预约,不走 - pc.walk_calls.clear() - fc.ground_items = [] - np.reserve_click_item(888) - await process_frame - _ck(pc.walk_calls.is_empty() and np._reserved_mode == NetPlay.ReservedMode.NONE, - "CLICK_ITEM missing item -> reservation cleared") - - # CLICK_POSITION:延时未到不动;延时耗尽 -> walk_to + 清预约 - pc.walk_calls.clear() - np.reserve_click_ground(Vector3(9, 0, -2)) - _ck(np._reserved_delay > 0.0, "reserve_click_ground sets a reserved delay") - np._reserved_delay = 999.0 - await process_frame - _ck(pc.walk_calls.is_empty(), "CLICK_POSITION waits out the reserved delay") - np._reserved_delay = 0.0 - await process_frame - _ck(pc.walk_calls.size() == 1 and pc.walk_calls[0] == Vector3(9, 0, -2), - "CLICK_POSITION delay elapsed -> walk_to(target)") - _ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "CLICK_POSITION cleared after move") - - # USE_SKILL:range>100 存 -10;射程外 walk_to;射程内 -> hook + set_target + 清预约 - var smob := Node3D.new() - smob.set_meta("vid", 4100) - root.add_child(smob) - nw._by_vid[4100] = smob - smob.position = Vector3(30, 0, 0) - fc.ents[4100] = {"vid": 4100, "ch_type": 2, "pos": smob.position, "hp": 80, "max_hp": 80, "dead": false, "race": 101} - var hook_calls: Array = [] - np.use_skill_hook = func(slot: int) -> bool: hook_calls.append(slot); return true - pc.player.position = Vector3.ZERO - _ck(is_equal_approx(np.skill_target_distance_cm(4100), 3000.0), "skill_target_distance_cm = node distance in cm") - _ck(np.skill_target_distance_cm(424242) < 0.0, "skill_target_distance_cm unknown vid -> -1") - var ctx_distance: Callable = np.skill_context().get("target_distance", Callable()) - _ck(ctx_distance.is_valid() and is_equal_approx(float(ctx_distance.call(4100)), 3000.0), - "skill_context exposes the same distance to the PlayerSkill gate") - _ck(not np.is_use_skill_reserved(5), "no reservation yet") - np.reserve_use_skill(4100, 5, 800.0) - _ck(is_equal_approx(np._skill_range_reserved, 790.0), "reserve_use_skill trims range >100 by 10") - _ck(np.is_use_skill_reserved(5) and not np.is_use_skill_reserved(6), "__IsReservedUseSkill matches the reserved slot") - pc.walk_calls.clear() - fc.calls.set_target.clear() - await process_frame - _ck(pc.walk_calls.size() >= 1, "USE_SKILL out of range -> walk_to") - _ck(hook_calls.is_empty(), "USE_SKILL out of range -> no cast") - smob.position = Vector3(3, 0, 0) # 3m < 7.9m 射程 - await process_frame - _ck(hook_calls == [5], "USE_SKILL in range -> use_skill_hook(slot 5)") - _ck(fc.calls.set_target.has(4100), "USE_SKILL in range -> set_target(4100)") - _ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "USE_SKILL cleared after cast") - np.reserve_use_skill(999999, 5, 300.0) - await process_frame - _ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "USE_SKILL missing target -> cleared") - - # __ChangeTargetToPickedInstance:NPC(非战斗目标)-> 清 _target_vid + set_target(0) - np._target_vid = 2000 - fc.calls.set_target.clear() - fc.ents[5000] = {"vid": 5000, "ch_type": 1, "name": "NPC"} - var ok_npc: bool = np._change_target_to_picked_instance(5000) - _ck(not ok_npc and np._target_vid == 0 and fc.calls.set_target == [0], - "_change_target_to_picked_instance(NPC) -> target cleared") - fc.calls.set_target.clear() - var ok_mob: bool = np._change_target_to_picked_instance(4100) - _ck(ok_mob and np._target_vid == 4100 and fc.calls.set_target == [4100], - "_change_target_to_picked_instance(monster) -> target set") - - # 40250 SetTarget 的 CanChangeTarget / CanPickInstance 门:技能动作中不 - # 允许改目标;普通隐身、复活隐身、EUNHYEONG 和视锥外实体也不能选中。 - np._target_vid = 4100 - fc.calls.set_target.clear() - np._using_skill = true - _ck(not np._change_target_to_picked_instance(2000) and np._target_vid == 4100 - and fc.calls.set_target.is_empty(), - "CanChangeTarget blocks target switch during skill motion") - np._using_skill = false - np._target_end_time = 0.0 - np._target_vid = 0 - fc.ents[6101] = {"vid": 6101, "ch_type": 0, "name": "Hidden", "dead": false, - "affect_flags": 1 << EntityRules.AFFECT_EUNHYEONG} - fc.calls.set_target.clear() - _ck(not np._change_target_to_picked_instance(6101) and fc.calls.set_target == [0], - "CanPickInstance rejects EUNHYEONG PC") - fc.ents[6101]["affect_flags"] = 1 << EntityRules.AFFECT_INVISIBILITY - fc.calls.set_target.clear() - _ck(not np._change_target_to_picked_instance(6101) and fc.calls.set_target == [0], - "CanPickInstance rejects ordinary invisibility PC") - fc.ents[6101]["affect_flags"] = 0 - fc.ents[6101]["in_view_frustum"] = false - fc.calls.set_target.clear() - _ck(not np._change_target_to_picked_instance(6101) and fc.calls.set_target == [0], - "CanPickInstance rejects out-of-frustum entity") - fc.ents[6101].erase("in_view_frustum") - fc.ents[6101]["dead"] = true - fc.calls.set_target.clear() - _ck(not np._change_target_to_picked_instance(6101) and fc.calls.set_target == [0], - "CanPickInstance rejects dead entity") - - # OnHit uses SetTarget(victim, FALSE): a live target-window lock suppresses - # only the automatic target packet, while the attack packet is still sent by - # the hit handler. - np._target_end_time = np._now() + 1.0 - np._target_vid = 4100 - fc.calls.set_target.clear() - fc.calls.attack.clear() - np._on_hit(2000, true) - _ck(fc.calls.attack.size() == 1 and fc.calls.set_target.is_empty() and np._target_vid == 4100, - "OnHit non-forced target change respects target window") - np._target_end_time = 0.0 - np._target_vid = 0 - - # send_fly_targeting —— 把早前实体挪远(不销毁,后续用例还引用),铺一组已知几何 - np._target_vid = 0 - np._clear_reserved() - pc.player.position = Vector3.ZERO - for v in [2000, 4100]: - if nw._by_vid.has(v): - nw._by_vid[v].position = Vector3(9999, 0, 9999) - if fc.ents.has(v): - fc.ents[v]["pos"] = Vector3(9999, 0, 9999) - var mk := func(v: int, p: Vector3) -> void: - var n := Node3D.new() - n.set_meta("vid", v) - n.position = p - root.add_child(n) - nw._by_vid[v] = n - fc.ents[v] = {"vid": v, "ch_type": 2, "pos": p, "hp": 50, "max_hp": 50, "dead": false, "race": 101} - mk.call(4200, Vector3(0, 0, -10)) # 主目标,正前方 (-Z) - mk.call(4201, Vector3(2, 0, -10)) # 侧翼,扇内 - mk.call(4202, Vector3(-2, 0, -10)) # 侧翼,扇内 - mk.call(4203, Vector3(0, 0, 12)) # 背后,扇外 - - fc.calls.fly.clear() - var extras: int = np.send_fly_targeting(4200, 2500.0, 3, NetPlay.FlyShape.FAN) - _ck(extras == 2, "FAN send_fly_targeting: 2 extras (max 3 - primary), got %d" % extras) - var fly_vids := [] - for f in fc.calls.fly: - fly_vids.append(f[0]) - _ck(fly_vids.size() == 2 and not fly_vids.has(4200), "FAN: 2 extra packets, primary not re-sent") - _ck(not fly_vids.has(4203), "FAN: target-behind instance excluded from fan") - _ck(4201 in fly_vids and 4202 in fly_vids, "FAN: both flankers picked") - - fc.calls.fly.clear() - var extras_c: int = np.send_fly_targeting(4200, 2500.0, 4, NetPlay.FlyShape.CIRCLE) - _ck(extras_c == 3, "CIRCLE: 3 extras (max 4 - primary), got %d" % extras_c) - var cvids := [] - for f in fc.calls.fly: - cvids.append(f[0]) - _ck(4203 in cvids, "CIRCLE: behind-target instance included (no fan filter)") - - fc.calls.fly.clear() - var extras_r: int = np.send_fly_targeting(4200, 2500.0, 6, NetPlay.FlyShape.FAN) - _ck(extras_r == 5, "FAN shortfall: 5 extras for max 6, got %d" % extras_r) - var zero_fills := 0 - for f in fc.calls.fly: - if f[0] == 0: - zero_fills += 1 - _ck(zero_fills >= 1, "FAN shortfall: random vid=0 positions fill the remainder") - _ck(np.send_fly_targeting(4200, 2500.0, 1, NetPlay.FlyShape.FAN) == 0, "max_count<=1 -> no extras") - _ck(np.send_fly_targeting(4200, 2500.0, 3, NetPlay.FlyShape.SINGLE) == 0, "SINGLE shape -> no extras") - np._clear_reserved() - np._target_vid = 0 - - # 6) points_changed -> HUD - fc.points_changed.emit({"hp": 120, "max_hp": 200, "sp": 30, "max_sp": 80, - "exp": 50, "next_exp": 100, "level": 7, "energy": 75}) - _ck(hud.vitals == [120, 200, 30, 80], "hud.set_vitals from points") - _ck(hud.exp_v == [50, 100], "hud.set_exp") - _ck(hud.level == 7, "hud.set_level") - _ck(hud.energy == [75, 100], "hud.set_energy from POINT_ENERGY") - - # 6b) 40250 RecvPointChange: typed event branches remain distinct from the - # aggregate snapshot. Positive GOLD amount calls OnPickMoney immediately; - # level/four-stat refresh both status and skills; skill points refresh skills. - var picked: Array = [] - var status_refreshes := [0] - var skill_refreshes := [0] - np.money_picked.connect(func(amount: int): picked.append(amount)) - np.status_refresh_requested.connect(func(): status_refreshes[0] += 1) - np.skill_refresh_requested.connect(func(): skill_refreshes[0] += 1) - fc.point_changed.emit(1000, 11, 1250, 250) - fc.point_changed.emit(1000, 12, 8, 0) - fc.point_changed.emit(1000, 28, 1, 0) - _ck(picked == [250] and hud.picked_money == [250], - "POINT_GOLD.amount -> OnPickMoney / money_picked") - _ck(status_refreshes[0] == 2 and skill_refreshes[0] == 2, - "POINT_GOLD/POINT_ST refresh status; POINT_ST/POINT_SKILL refresh skills") - - # 7) 自己的 GC_MOVE 位置校正(远 -> 瞬移) - fc.ents[1000]["pos"] = Vector3(50, 0, -5) - fc.entity_moved.emit(1000) - _ck(pc.player.position.distance_to(Vector3(50, 0, pc.player.position.z)) < 0.01, - "far server pos -> snap, got %s" % pc.player.position) - - # 8) target_info -> hud.set_target - fc.target_info.emit(2000, 40) - _ck(hud.target.size() == 2 and hud.target[1] == 40, "hud.set_target(pct=40)") - - # 8b) §8.4「看他国玩家目标框」前置过滤(uitarget.py:189-193 / target_board.hidden_as_other_empire) - fc.ents[1000]["empire"] = 1 - fc.ents[2500] = {"vid": 2500, "ch_type": 0, "name": "Enemy", "empire": 2, - "pos": Vector3(5, 0, 5), "hp": 100, "max_hp": 100, "dead": false} - # 开关开(默认 true):异阵营 PC 目标框照旧走 CLOSE_IF_DIFFERENT,框上正是该 VID -> 保留 - np._target_vid = 2500 - fc.target_info.emit(2500, 60) - _ck(np._target_vid == 2500, "看他国框开 -> 异阵营 PC 目标框保留") - # 开关关:异阵营 PC -> 前置过滤直接清目标框 - var cleared_n: int = hud.cleared - np.set_view_other_empire_target(false) - np._target_vid = 2500 - fc.target_info.emit(2500, 60) - _ck(np._target_vid == 0 and hud.cleared == cleared_n + 1, "看他国框关 -> 异阵营 PC 目标框被前置过滤清掉") - # 开关关不影响怪(empire 0 -> IsSameEmpire 为真) - np._target_vid = 0 - fc.target_info.emit(2000, 30) - _ck(hud.target[1] == 30 and np._target_vid == 2000, "看他国框关不影响怪目标(HP 血条照设)") - np.set_view_other_empire_target(true) - fc.ents[1000].erase("empire") - fc.ents.erase(2500) - # 收尾:_target_vid 落在 2000(与原 8 一致),供 9) 目标死亡清框 - - # 8c) seam ⑫ 收口 —— game.py SetPCTargetBoard :804:targetBoard.Open 之后,若按住 - # LCONTROL(:807)且同阵营(:809 IsSameEmpire)且非本人(:812)且非 building - # (:814)-> interface.OpenWhisperDialog(name)。poc 侧 net_play 发 whisper_requested, - # game_scene 接到 chat.start_whisper()。C++ 只对 PC 分派 SetPCTargetBoard。 - var whispers: Array = [] - np.whisper_requested.connect(func(nm: String): whispers.append(nm)) - fc.ents[1000]["empire"] = 1 - var bob := Node3D.new() - bob.set_meta("vid", 6100) - bob.position = Vector3(4, 0, -5) - root.add_child(bob) - nw._by_vid[6100] = bob - fc.ents[6100] = {"vid": 6100, "ch_type": 0, "name": "Bob", "empire": 1, - "pos": bob.position, "hp": 100, "max_hp": 100, "dead": false} - # LCONTROL 没按住 -> 只普通选中,不开悄悄话 - np._target_vid = 0 - np.set_lcontrol_down(false) - pc.target_selected.emit(bob) - _ck(whispers.is_empty(), "无 LCONTROL 点 PC -> 不开悄悄话") - _ck(fc.calls.set_target.back() == 6100, "点 PC 仍照常选中(set_target 6100)") - # 按住 LCONTROL + 同阵营他人 PC -> whisper_requested("Bob") - np.set_lcontrol_down(true) - pc.target_selected.emit(bob) - _ck(whispers == ["Bob"], "LCONTROL 点同阵营他人 PC -> whisper_requested('Bob')") - # 异阵营 PC -> 不请求(:809 IsSameEmpire) - whispers.clear() - fc.ents[6100]["empire"] = 2 - pc.target_selected.emit(bob) - _ck(whispers.is_empty(), "LCONTROL 点异阵营 PC -> 不开悄悄话") - fc.ents[6100]["empire"] = 1 - # 怪(KIND_MONSTER)即使按住 LCONTROL 也不走(C++ 只对 PC 分派 SetPCTargetBoard) - whispers.clear() - var wolf2 := Node3D.new() - wolf2.set_meta("vid", 6200) - wolf2.position = Vector3(4, 0, -5) - root.add_child(wolf2) - nw._by_vid[6200] = wolf2 - fc.ents[6200] = {"vid": 6200, "ch_type": 2, "name": "Wolf2", "empire": 0, - "pos": wolf2.position, "hp": 50, "max_hp": 50, "dead": false} - pc.target_selected.emit(wolf2) - _ck(whispers.is_empty(), "LCONTROL 点怪 -> 不开悄悄话(仅 PC 分派)") - np.set_lcontrol_down(false) - fc.ents[1000].erase("empire") - fc.ents.erase(6100); fc.ents.erase(6200) - nw._by_vid.erase(6100); nw._by_vid.erase(6200) - np._target_vid = 2000 # 复位到 8b 收尾态,供 9) 目标死亡清框 - - # 9) 目标死亡 -> 清目标 - fc.ents[2000]["dead"] = true - fc.entity_dead.emit(2000) - _ck(hud.cleared >= 1, "target cleared on death") - - # 40250 main-character death transition: movement, auto actions and - # network-state handling are blocked until a positive HP vitals update. - fc.ents[1000]["dead"] = true - fc.ents[1000]["hp"] = 0 - pc.going = true - np._auto_attack_vid = 2000 - np._reserved_mode = 1 - fc.entity_dead.emit(1000) - _ck(np._main_dead and pc.dead, "main death enters the controller death gate") - _ck(not pc.going and np._auto_attack_vid == 0, "main death clears movement and auto attack") - _ck(not np._can_process_network_state(), "main death blocks network state processing") - fc.ents[1000]["dead"] = false - fc.ents[1000]["hp"] = 100 - fc.vitals_changed.emit(1000) - _ck(not np._main_dead and not pc.dead, "positive HP vitals update restores main control") - _ck(np._can_process_network_state(), "revived main can process network state") - - # 10) 观战模式锁住本地移动/选目标,并清掉当前目标 - fc.ents[2000]["dead"] = false - fc.observer_mode_changed.emit(true) - var move_n: int = fc.calls.move.size() - var target_n: int = fc.calls.set_target.size() - pc.player.position = Vector3(8, 0, -5) - pc.moved.emit(pc.player.position) - pc.target_selected.emit(mob) - _ck(fc.calls.move.size() == move_n and fc.calls.set_target.size() == target_n, - "observer mode blocks local movement and targeting") - fc.observer_mode_changed.emit(false) - - # 11) §3.2 上行 6 回调:OnMove 立即发 + 重置节流窗、OnMoving 300ms 节流、 - # OnStop 收尾、OnAttack 额外发 FUNC_COMBO、on_use_skill 发 FUNC_SKILL|motion。 - fc.observer_mode_changed.emit(false) - np._observer_mode = false - np._was_moving = false - np._last_moving_sent_t = 0.0 - fc.calls.move.clear() - pc.player.position = Vector3(1, 0, -1) - pc.moved.emit(pc.player.position) # OnMove - _ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == np.FUNC_MOVE, "OnMove -> immediate FUNC_MOVE") - pc.player.position = Vector3(1.5, 0, -1) - pc.moved.emit(pc.player.position) # OnMoving, throttled (<300ms) - _ck(fc.calls.move.size() == 1, "OnMoving within 300ms throttled") - np._last_moving_sent_t -= 1.0 # 让 300ms 窗口过期 - pc.moved.emit(pc.player.position) # OnMoving, now allowed - _ck(fc.calls.move.size() == 2 and fc.calls.move[1][0] == np.FUNC_MOVE, "OnMoving after 300ms -> FUNC_MOVE") - pc.anim_state.emit("wait") # OnStop - _ck(fc.calls.move.size() == 3 and fc.calls.move[2][0] == np.FUNC_WAIT, "OnStop -> FUNC_WAIT") - # 站立期间被推开也不补发:40250 的 OnWaiting 只在走/跑动作中触发(InstanceBase.cpp:1930)。 - pc.player.position = Vector3(4, 0, -1) - np._process(0.2) - _ck(fc.calls.move.size() == 3, "idle displacement sends no FUNC_WAIT") - fc.calls.move.clear() - np.on_use_skill(5, 2) # OnUseSkill - _ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == (np.FUNC_SKILL | 5) and fc.calls.move[0][1] == 2, - "on_use_skill -> FUNC_SKILL|motion + arg") diff --git a/project/netplay_test.gd.uid b/project/netplay_test.gd.uid deleted file mode 100644 index bbea5f62..00000000 --- a/project/netplay_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bsjyki4suspqp diff --git a/project/p10_test.gd b/project/p10_test.gd deleted file mode 100644 index daebda72..00000000 --- a/project/p10_test.gd +++ /dev/null @@ -1,143 +0,0 @@ -# p10_test —— P10:serverinfo + loading 遮罩 + 断线重连 + 串场状态机 headless 自检。 -# godot --headless --path project --script p10_test.gd -extends SceneTree - -const ServerInfoRes = preload("res://net/serverinfo.gd") -const LoadingScreen = preload("res://ui/loading_screen.gd") -const ReconnectUI = preload("res://ui/reconnect_ui.gd") -const AppFlow = preload("res://app_flow.gd") - -class FakeClient extends Node: - signal phase_changed(phase: String) - signal char_list(characters: Array) - signal entered_game() - signal disconnected(reason: String) - signal login_failed(reason: String) - var reconnects := 0 - var connects := [] - var selects := [] - func connect_to_server(ah, ap, gh, gp, id, pw) -> void: - connects.append([ah, ap, gh, gp, id, pw]) - func select_character(i) -> bool: selects.append(i); return true - func reconnect() -> bool: reconnects += 1; return true - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: p10_test (serverinfo + loading + reconnect + app flow)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # --- serverinfo --- - var si: RefCounted = ServerInfoRes.new() - _ck(si.count() == 1 and si.server(0)["auth_host"] == "192.168.21.203", "serverinfo: built-in default") - var addr: Dictionary = si.address(0, 3) - _ck(addr["game_port"] == 13022, "serverinfo: 内置 CH3 -> 13022 (step 10)") - # user:// may be read-only in the managed headless runner; the parser only - # needs a temporary text file, so keep this fixture outside the project. - var tmp := "/tmp/mtgodot-p10-servers.txt" - var f := FileAccess.open(tmp, FileAccess.WRITE) - f.store_line("# test") - f.store_line("欧服\teu.example.com\t13000\teu.example.com\t13011\t1,2\t10") - f.close() - _ck(si.load_file(tmp) and si.count() == 1 and si.server(0)["name"] == "欧服", "serverinfo: parse file") - _ck(si.address(0, 2)["game_port"] == 13021, "serverinfo: file port_step 10 覆盖 -> CH2 = 13021") - - # --- loading screen --- - var ls: Node = LoadingScreen.new() - get_root().add_child(ls) - ls.setup(null) - _ck(not ls.is_showing(), "loading: hidden initially") - ls._on_phase("loading") - _ck(ls.is_showing() and ls._label.text.contains("地图"), "loading: shows on phase=loading") - ls.set_progress(0.5) - _ck(ls._bar.visible and ls._bar.value == 50.0, "loading: progress bar") - ls._on_phase("select") - _ck(not ls.is_showing(), "loading: hides on phase=select (选人界面要露出来)") - ls._on_phase("loading") - ls._on_phase("game") - _ck(not ls.is_showing(), "loading: hides on phase=game") - ls._on_phase("login") - _ck(not ls.is_showing(), "loading: hides on phase=login") - - # --- reconnect --- - var fc := FakeClient.new() - get_root().add_child(fc) - var rc: Node = ReconnectUI.new() - get_root().add_child(rc) - rc.setup(fc) - _ck(not rc.is_showing(), "reconnect: hidden initially") - fc.disconnected.emit("peer closed") - _ck(rc.is_showing() and rc._msg.text.contains("peer closed"), "reconnect: shows on disconnect") - # 倒计时耗尽 -> reconnect() - rc._left = 0.05 - rc._process(0.1) - _ck(fc.reconnects == 1, "reconnect: auto-retry fired") - # 手动 - rc._retry_now() - _ck(fc.reconnects == 2, "reconnect: manual retry") - # entered_game -> 收起 - fc.entered_game.emit() - _ck(not rc.is_showing(), "reconnect: dismissed on entered_game") - - # --- app flow 状态机 --- - var fc2 := FakeClient.new() - var af: Node = AppFlow.new() - af.build_game_scene = false - get_root().add_child(af) - af.start("", fc2) - _ck(af.state() == AppFlow.LOGIN, "flow: starts at LOGIN") - # 检测频道按钮存在 + 探测不可达端口快速返回 false - var has_probe := false - for b in af._ui.find_children("*", "Button", true, false): - if String(b.text) == "检测频道": - has_probe = true - _ck(has_probe, "flow: 检测频道 button present") - var reachable: bool = await af._probe_tcp("127.0.0.1", 1, 0.3) - _ck(reachable == false, "flow: _probe_tcp unreachable -> false") - af._do_connect() - _ck(fc2.connects.size() == 1 and fc2.connects[0][3] == 13002, "flow: connect uses serverinfo addr") - if af._loading and af._loading.has_method("show_for"): - af._loading.show_for("载入角色…") # 模拟 phase=select 时遮罩已亮 - fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}]) - _ck(af.state() == AppFlow.SELECT, "flow: char_list -> SELECT") - _ck(af._loading == null or not af._loading.is_showing(), - "flow: _goto_select 收起读取遮罩(选人界面不被盖)") - # 新版选人页:默认已选 slot 0,点“开始”进游戏 - var picked := false - for b in af._ui.find_children("*", "Button", true, false): - if String(b.text) == "开始": - b.pressed.emit() - picked = true - _ck(picked and fc2.selects == [0], "flow: pick char -> select_character(0)") - fc2.entered_game.emit() - _ck(af.state() == AppFlow.GAME, "flow: entered_game -> GAME") - # build_game_scene=false skips the real GameScene in this headless state - # machine test; provide its live-scene sentinel so disconnect handling is - # exercised with the same ownership contract as the real client. - af._game = Node.new() - af._game.name = "FakeGameScene" - af.add_child(af._game) - # 40250 OnRemoteDisconnect -> SetLoginPhase:释放 GameScene,回到 LOGIN。 - fc2.disconnected.emit("game dropped") - _ck(af.state() == AppFlow.LOGIN and not af._reconnecting_game and af._game == null, - "flow: disconnect in GAME -> LoginPhase and release GameScene") - fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}]) - _ck(af.state() == AppFlow.SELECT and fc2.selects == [0], - "flow: post-disconnect character list enters SELECT without auto re-entry") - af._game = Node.new() - af._game.name = "FakeGameScene" - af.add_child(af._game) - af._state = AppFlow.GAME - fc2.phase_changed.emit("login") - _ck(af.state() == AppFlow.LOGIN and not af._reconnecting_game and af._game == null, - "flow: PHASE_CLOSE LoginPhase observer releases GameScene") diff --git a/project/p10_test.gd.uid b/project/p10_test.gd.uid deleted file mode 100644 index 55c6d5ac..00000000 --- a/project/p10_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bpoupm0awu3x1 diff --git a/project/p2b_test.gd b/project/p2b_test.gd deleted file mode 100644 index bba822e0..00000000 --- a/project/p2b_test.gd +++ /dev/null @@ -1,186 +0,0 @@ -# p2b_test —— P2 续:地面掉落物 + PlayerView(真模型)headless 自检。 -# godot --headless --path project --script p2b_test.gd -extends SceneTree - -const GroundItems = preload("res://ui/ground_items.gd") -const PlayerView = preload("res://ui/player_view.gd") -const TextTailArrange = preload("res://text_tail_arrange.gd") - -class FakeClient extends Node: - signal ground_item_added(item: Dictionary) - signal ground_item_removed(vid: int) - var ground := {} - var picked := [] - func get_ground_items() -> Array: return ground.values() - func pickup_item(vid) -> bool: picked.append(vid); return true - func add_ground(vid, vnum, pos, owner := "", item_color: Variant = null): - ground[vid] = {"vid": vid, "vnum": vnum, "pos": pos, "owner": owner} - if item_color != null: - ground[vid]["item_color"] = item_color - ground_item_added.emit(ground[vid]) - func del_ground(vid): - ground.erase(vid); ground_item_removed.emit(vid) - -class FakeProto extends Node: - func item(vnum: int) -> Dictionary: - return {"vnum": vnum, "name": "N%d" % vnum, "locale_name": "掉落%d" % vnum} - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: p2b_test (ground items + player view)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # --- ground items --- - var mount := Node3D.new() - var fc := FakeClient.new() - var fp := FakeProto.new() - var pl := Node3D.new() - get_root().add_child(mount) - get_root().add_child(fc) - get_root().add_child(fp) - get_root().add_child(pl) - pl.position = Vector3.ZERO - - var gi: Node = GroundItems.new() - get_root().add_child(gi) - gi.setup(fc, mount, func() -> Node: return pl, fp, null) - - fc.add_ground(101, 27, Vector3(1, 0, 1)) # 距玩家 ~1.4m(范围内) - fc.add_ground(102, 28, Vector3(20, 0, 0)) # 远 - await process_frame - _ck(mount.get_child_count() == 2, "2 drop nodes spawned") - _ck(mount.get_node_or_null("drop_101") != null, "drop_101 present") - var tag := mount.get_node("drop_101").get_node("tag") as Label3D - _ck(tag.text == "掉落27", "drop name from proto") - - # 拾取最近的(101 在范围,102 不在) - var got: int = gi.try_pickup() - _ck(got == 101, "try_pickup -> nearest 101, got %d" % got) - _ck(fc.picked == [101], "client.pickup_item(101) called") - - fc.del_ground(101) - await process_frame - _ck(mount.get_node_or_null("drop_101") == null, "drop_101 freed on removed") - _ck(mount.get_child_count() == 1, "1 drop left") - - # 远处的捡不到 - _ck(gi.try_pickup() == 0, "far item not picked") - - # --- §8.8 seam ⑩:多条名条投影后去重叠(ArrangeTextTail item 块)--------------- - # 独立的 mount / FakeClient,避免与上面的 gi 抢同名 drop 节点。 - var mount2 := Node3D.new() - var fc2 := FakeClient.new() - get_root().add_child(mount2) - get_root().add_child(fc2) - var cam := Camera3D.new() - get_root().add_child(cam) - cam.position = Vector3(0, 3, 6) - cam.look_at(Vector3.ZERO, Vector3.UP) - cam.current = true - var gi2: Node = GroundItems.new() - get_root().add_child(gi2) - gi2.setup(fc2, mount2, func() -> Node: return pl, fp, null, func() -> Camera3D: return cam) - # 单条:名条留默认局部 y。 - fc2.add_ground(201, 27, Vector3(0, 0, 0)) - await process_frame - gi2._process(0.0) - var t201 := mount2.get_node("drop_201").get_node("tag") as Label3D - _ck(absf(t201.position.y - gi2.TAG_BASE_Y) < 0.0001, "单条名条 -> 留默认局部 y 0.75") - # 同位两条:靠前的(vid 小,m_ItemTextTailList 先)被顶下去,靠后的留原位。 - fc2.add_ground(202, 28, Vector3(0, 0, 0)) - await process_frame - gi2._process(0.0) - var t202 := mount2.get_node("drop_202").get_node("tag") as Label3D - _ck(t201.position.y < gi2.TAG_BASE_Y - 0.0001, "重叠 -> 靠前名条被下推(世界 y 变小)") - _ck(absf(t202.position.y - gi2.TAG_BASE_Y) < 0.0001, "重叠 -> 靠后名条留默认") - # 被下推量 = (h + gap) px 折世界:(15 + 5) * pixel_size(0.005) = 0.1。 - _ck(absf(t201.position.y - (gi2.TAG_BASE_Y - (gi2.TAG_LINE_H_PX + TextTailArrange.PUSH_GAP) * t201.pixel_size)) < 0.0001, - "下推量 = (TAG_LINE_H_PX + PUSH_GAP) * pixel_size = 0.1") - _ck(absf(t201.position.y - t202.position.y) > 0.0001, "两名条错开后局部 y 不同") - # 远离:把 202 挪远,重跑 -> 两条都回默认。 - mount2.get_node("drop_202").position = Vector3(-40, 0, -2) - gi2._process(0.0) - _ck(absf(t201.position.y - gi2.TAG_BASE_Y) < 0.0001, "拉开距离 -> 201 名条回默认") - _ck(absf(t202.position.y - gi2.TAG_BASE_Y) < 0.0001, "拉开距离 -> 202 名条回默认") - - # --- §8.8 seam ⑩ 末尾:owner 拆成独立 CENTER 黄字 Label3D(SetItemTextTailOwner) - # + 名字实例下移 15px(ArrangeTextTail :200);框仍 yEnd += ownerH + 4(增量 125)--- - var mount3 := Node3D.new() - var fc3 := FakeClient.new() - get_root().add_child(mount3) - get_root().add_child(fc3) - var gi3: Node = GroundItems.new() - get_root().add_child(gi3) - gi3.setup(fc3, mount3, func() -> Node: return pl, fp, null, func() -> Camera3D: return cam) - fc3.add_ground(301, 27, Vector3(0, 0, 0)) # 靠前、无 owner - var owner_color := Color(0.25, 0.75, 1.0) - fc3.add_ground(302, 28, Vector3(0, 0, 0), "Alice", owner_color) # 靠后、有 owner - await process_frame - gi3._process(0.0) - var d301 := mount3.get_node("drop_301") - var d302 := mount3.get_node("drop_302") - var t301 := d301.get_node("tag") as Label3D - var t302 := d302.get_node("tag") as Label3D - # owner 拆成独立 owner_tag:名字实例只放名字,owner_tag 放 "Alice" + "'s"(黄 / CENTER)。 - _ck(t302.text == "掉落28", "名字实例只放名字(owner 后缀不再拼串,:723)") - _ck(d301.get_node_or_null("owner_tag") == null, "无 owner 的掉落不建 owner_tag(:741-745)") - var o302 := d302.get_node_or_null("owner_tag") as Label3D - _ck(o302 != null, "有 owner 的掉落建独立 owner_tag(:715)") - _ck(o302.text == "Alice's", "owner_tag 文本 = owner + strOwnership(:722 字面 \"'s\")") - _ck(o302.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER, "owner_tag CENTER 对齐(:727)") - _ck(o302.modulate.is_equal_approx(owner_color), "owner_tag 使用道具品阶色(:729 pInsertTextTail->Color)") - # owner 名条框:无字体度量时 name_h = owner_h = 15 -> yEnd = 15 + 15 + 4 = 34(增量 125 不变)。 - # 靠前 301(无 owner)被顶 (34 + PUSH_GAP) px 折世界:(34 + 5) * 0.005 = 0.195。 - var push_owner: float = (gi3.TAG_LINE_H_PX * 2.0 + 4.0 + TextTailArrange.PUSH_GAP) * t301.pixel_size - _ck(absf(t301.position.y - (gi3.TAG_BASE_Y - push_owner)) < 0.0001, - "靠前名条被带 owner 的框顶 (34+5)*px = 0.195") - # 靠后 302 未被推:owner 行落基点、名字行下移 15px(:195 owner 在 y、:200 名字在 y+15)。 - _ck(absf(o302.position.y - gi3.TAG_BASE_Y) < 0.0001, "owner_tag 落尾标基点(:195)") - _ck(absf(t302.position.y - (gi3.TAG_BASE_Y - gi3.OWNER_NAME_GAP_PX * t302.pixel_size)) < 0.0001, - "名字实例在 owner 行下移 15px(:200 y + 15.0f)") - - # --- strOwnership 经 setup(possessive) 注入(SetItemTextTailOwner :722 本地化语素)--- - var mount4 := Node3D.new() - var fc4 := FakeClient.new() - get_root().add_child(mount4) - get_root().add_child(fc4) - var gi4: Node = GroundItems.new() - get_root().add_child(gi4) - gi4.setup(fc4, mount4, func() -> Node: return pl, fp, null, func() -> Camera3D: return cam, "@") - fc4.add_ground(401, 27, Vector3(0, 0, 0), "Bob") - await process_frame - var o401 := mount4.get_node("drop_401").get_node_or_null("owner_tag") as Label3D - _ck(o401 != null and o401.text == "Bob@", "setup(possessive=\"@\") -> owner_tag 文本 = \"Bob@\"") - - # --- player view(有真资产才建)--- - var assets := AssetRoot.path() - if not DirAccess.dir_exists_absolute(assets.path_join("PC/ymir work/pc/warrior")): - print(" (skip PlayerView: no PC assets)") - return - if not ClassDB.class_exists("Metin2Model"): - print(" (skip PlayerView: extension not built)") - return - var pv: Node3D = PlayerView.new() - get_root().add_child(pv) - var ok: bool = pv.build(assets, 0) # race 0 = warrior male - _ck(ok, "PlayerView.build(race 0) ok") - _ck(pv.model != null, "PlayerView has Metin2Model") - _ck(pv.motion_dir.ends_with("warrior/general"), "motion_dir -> warrior/general (%s)" % pv.motion_dir) - # 转发 set - pv.set("weapon_gr2", "/tmp/fake_weapon.gr2") - _ck(String(pv.model.get("weapon_gr2")) == "/tmp/fake_weapon.gr2", "PlayerView forwards weapon_gr2 to model") - pv.set_anim_state("run") - await process_frame - if pv.anim: - _ck(String(pv.anim.get("anim_path")).ends_with("run.msa"), "set_anim_state('run') -> run.msa") diff --git a/project/p2b_test.gd.uid b/project/p2b_test.gd.uid deleted file mode 100644 index 770283c2..00000000 --- a/project/p2b_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bgt65nkxb5gfr diff --git a/project/p8_test.gd b/project/p8_test.gd deleted file mode 100644 index cf218877..00000000 --- a/project/p8_test.gd +++ /dev/null @@ -1,376 +0,0 @@ -# p8_test —— P8:组队 / 好友 / 商店 / 交易 / 仓库 UI + 胶水 headless 自检。 -# godot --headless --path project --script p8_test.gd -extends SceneTree - -const PartyUI = preload("res://ui/party_ui.gd") -const FriendUI = preload("res://ui/friend_ui.gd") -const ShopUI = preload("res://ui/shop_ui.gd") -const ExchangeUI = preload("res://ui/exchange_ui.gd") -const SafeboxUI = preload("res://ui/safebox_ui.gd") - -class FakeClient extends Node: - signal party_changed() - signal party_invite_ask(leader_pid: int) - signal friends_changed() - signal friend_invite_ask(name: String) - signal shop_opened(vid: int) - signal shop_closed() - signal shop_error(kind: String) - signal exchange_changed() - signal safebox_changed() - signal inventory_changed() - - var calls := [] - var party := [] - var friends := [] - var shop := [] - var shop_tabs := [] # 非空 -> get_shop() 走多货架 (SHOP_GC_START_EX) - var shop_open := false - var exchange := {"active": false} - var safebox := [] - var safebox_open := false - var safebox_size := 0 - var safebox_gold := 0 - var inventory := [] - var main_pos := Vector3.ZERO - - var main_vid := 20 - var distribute_mode := 0 - var leadership := 40 - func is_in_game() -> bool: return true - func get_main_vid() -> int: return main_vid - func get_skills() -> Array: return [{"id": 121, "level": leadership}] - func get_party() -> Array: return party - func get_party_distribute_mode() -> int: return distribute_mode - func party_answer(pid, accept) -> bool: calls.append(["party_answer", pid, accept]); return true - func party_invite(vid) -> bool: calls.append(["party_invite", vid]); return true - func party_leave(pid) -> bool: calls.append(["party_leave", pid]); return true - func party_set_state(pid, role, on) -> bool: calls.append(["party_set_state", pid, role, on]); return true - func party_set_distribute(mode) -> bool: calls.append(["party_set_distribute", mode]); return true - func party_use_skill(idx, vid) -> bool: calls.append(["party_use_skill", idx, vid]); return true - func set_target(vid) -> void: calls.append(["set_target", vid]) - func get_friends() -> Array: return friends - func add_friend(nm) -> bool: calls.append(["add_friend", nm]); return true - func remove_friend(nm) -> bool: calls.append(["remove_friend", nm]); return true - func friend_answer(nm, accept) -> bool: calls.append(["friend_answer", nm, accept]); return true - func get_shop_items() -> Array: return shop - func get_shop() -> Dictionary: - var tabs := shop_tabs if not shop_tabs.is_empty() else [{"name": "", "coin_type": 0, "items": shop}] - return {"vid": 8080, "open": shop_open, "tabs": tabs} - func is_shop_open() -> bool: return shop_open - func shop_buy(pos, cnt) -> bool: calls.append(["shop_buy", pos, cnt]); return true - func shop_sell(cell, cnt) -> bool: calls.append(["shop_sell", cell, cnt]); return true - func shop_close() -> bool: calls.append(["shop_close"]); return true - func get_exchange() -> Dictionary: return exchange - func exchange_add_item(w, c, d) -> bool: calls.append(["ex_add_item", w, c, d]); return true - func exchange_add_gold(g) -> bool: calls.append(["ex_gold", g]); return true - func exchange_accept() -> bool: calls.append(["ex_accept"]); return true - func exchange_cancel() -> bool: calls.append(["ex_cancel"]); return true - func get_safebox_items() -> Array: return safebox - func is_safebox_open() -> bool: return safebox_open - func get_safebox_size() -> int: return safebox_size - func get_safebox_gold() -> int: return safebox_gold - func get_inventory() -> Array: return inventory - func get_item(window, cell) -> Dictionary: - if window != 1: return {} - for item in inventory: - if int(item.get("cell", -1)) == int(cell): return item - return {} - func get_entity(_vid) -> Dictionary: return {"pos_cm": main_pos} - func safebox_checkin(sp, w, c) -> bool: calls.append(["sb_checkin", sp, w, c]); return true - func safebox_checkout(sp, w, c) -> bool: calls.append(["sb_checkout", sp, w, c]); return true - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - await _run() - if _fail == 0: - print("PASS: p8_test (party + friends + shop + exchange + safebox)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var canvas := CanvasLayer.new() - get_root().add_child(canvas) - var fc := FakeClient.new() - get_root().add_child(fc) - - # --- 组队 --- - var pu: Node = PartyUI.new() - get_root().add_child(pu) - pu.setup(fc, canvas) # 无 dialogs -> 邀请自动接受 - _ck(not pu.is_open(), "party panel hidden when empty") - # 本地玩家 vid=20 是队长 -> 角色分配按钮可用 - fc.party = [ - {"pid": 2, "vid": 20, "name": "Me", "leader": true, "hp_pct": 90, "state": 1, "affects": [0, 0, 0, 0, 0, 0, 0]}, - {"pid": 1, "vid": 10, "name": "Bob", "leader": false, "hp_pct": 50, "state": 0, "affects": [12, 0, 0, 0, 0, 0, 0]}, - ] - fc.party_changed.emit() - _ck(pu.is_open(), "party panel shows with members") - _ck(pu._list.get_child_count() == 2, "party: 2 member strips") - fc.party_invite_ask.emit(77) - _ck(fc.calls.has(["party_answer", 77, true]), "party: auto-accept invite (no dialogs)") - - # 附加효과 칩:Bob 的 affects[0]=12 -> 一个「经验」chip - var bob_strip: Control = pu._list.get_child(1) - var chip_found := false - for l in bob_strip.find_children("*", "Label", true, false): - if String(l.text) == "经验": - chip_found = true - _ck(chip_found, "party: non-zero affect renders a chip") - - # 队长点 Me 的 StateButton -> 角色弹窗 -> 「攻击」-> party_set_state(pid, ROLE_ATTACKER, true) - var me_strip: Control = pu._list.get_child(0) - var state_btn: Button = null - for b in me_strip.find_children("*", "Button", true, false): - if String(b.text) in ["队", "普", "攻", "坦", "辅", "宗", "速", "防"]: - state_btn = b - break - _ck(state_btn != null and not state_btn.disabled, "party: leader's StateButton enabled") - state_btn.pressed.emit() - _ck(is_instance_valid(pu._role_popup), "party: role popup opens for leader") - var atk_btn: Button = null - var expel_btn: Button = null - for b in pu._role_popup.find_children("*", "Button", true, false): - if String(b.text).begins_with("攻击"): atk_btn = b - if String(b.text) == "踢出队伍": expel_btn = b - atk_btn.pressed.emit() - _ck(fc.calls.has(["party_set_state", 2, 2, true]), - "party: 攻击 -> party_set_state(pid, ATTACKER=2, true)") - # 다시 열어 踢出 - state_btn.pressed.emit() - for b in pu._role_popup.find_children("*", "Button", true, false): - if String(b.text) == "踢出队伍": expel_btn = b - expel_btn.pressed.emit() - _ck(fc.calls.has(["party_leave", 2]), "party: 踢出队伍 -> party_leave(pid)") - - # EXP 分配切换(当前 mode 0 -> 发 1) - pu._toggle_distribute() - _ck(fc.calls.has(["party_set_distribute", 1]), "party: 分配切换 -> party_set_distribute(PARITY)") - # 组队治疗 - pu._party_heal() - _ck(fc.calls.has(["party_use_skill", 1, 0]), "party: 组队治疗 -> party_use_skill(HEAL=1, 0)") - - # 点名字 -> 选中该员 vid - var name_btn: Button = null - for b in me_strip.find_children("*", "Button", true, false): - if String(b.text).ends_with("Me"): name_btn = b - if name_btn: - name_btn.pressed.emit() - _ck(fc.calls.has(["set_target", 20]), "party: 点名字 -> set_target(vid)") - - # 40250 CParty::Update 的 Leadership 门控:攻击至少 10,治疗至少 18。 - fc.party[0]["leader"] = true - fc.leadership = 9 - fc.party_changed.emit() - _ck(pu._heal_btn.disabled, "party: Leadership<18 disables heal") - var gated_state: Button = null - for b in pu._list.get_child(0).find_children("*", "Button", true, false): - if String(b.text) == "队": - gated_state = b - break - if gated_state: - gated_state.pressed.emit() - var gated_attack: Button = null - for b in pu._role_popup.find_children("*", "Button", true, false): - if String(b.text).begins_with("攻击"): - gated_attack = b - break - _ck(gated_attack != null and gated_attack.disabled, "party: Leadership<10 disables attacker role") - pu._dismiss_role_popup() - - # 非队长 -> StateButton disabled - fc.party[0]["leader"] = false - fc.party[0]["state"] = 0 - fc.party_changed.emit() - var me2: Control = pu._list.get_child(0) - for b in me2.find_children("*", "Button", true, false): - if String(b.text) in ["普", "队"]: - _ck(b.disabled, "party: non-leader StateButton disabled") - break - - # --- 好友 --- - var fu: Node = FriendUI.new() - get_root().add_child(fu) - fu.setup(fc, canvas) - fu.toggle() - _ck(fu.is_open(), "friend window opens") - fc.friends = [{"name": "Alice", "online": true, "mobile": true}, {"name": "Bob", "online": false}] - fc.friends_changed.emit() - await process_frame - var alice_mobile := false - for l in fu._list.find_children("*", "Label", true, false): - if String(l.text) == "手机": - alice_mobile = true - _ck(alice_mobile, "friend: mobile state renders") - var wt := [] - fu.whisper_to.connect(func(n): wt.append(n)) - # 找 Alice 行的名字按钮 - var alice_btn: Button = null - for b in fu._list.find_children("*", "Button", true, false): - if (b as Button).text == "Alice": - alice_btn = b - _ck(alice_btn != null, "friend: Alice row present") - if alice_btn: - alice_btn.pressed.emit() - _ck(wt == ["Alice"], "friend: click name -> whisper_to(Alice)") - fu._name_edit.text = "Carol" - fu._on_add() - _ck(fc.calls.has(["add_friend", "Carol"]), "friend: add via input") - fc.friend_invite_ask.emit("Dave") - await process_frame - _ck(fu._invite_dialog.visible and fu._invite_dialog.dialog_text.contains("Dave"), - "friend: incoming request opens confirmation") - fu._invite_dialog.confirmed.emit() - _ck(fc.calls.has(["friend_answer", "Dave", true]), "friend: accept incoming request") - fc.friend_invite_ask.emit("Eve") - fu._invite_dialog.canceled.emit() - _ck(fc.calls.has(["friend_answer", "Eve", false]), "friend: reject incoming request") - fu.set_mobile_mode(true) - fc.friend_invite_ask.emit("MobileDave") - await process_frame - _ck(fu._invite_dialog.size == Vector2i(480, 220), - "friend: mobile confirmation uses landscape touch size") - fu._invite_dialog.canceled.emit() - - # --- 商店 --- - var su: Node = ShopUI.new() - get_root().add_child(su) - su.setup(fc, canvas) - fc.shop = [{"pos": 0, "vnum": 27, "price": 1500, "count": 1}, - {"pos": 1, "vnum": 19, "price": 800, "count": 1}] - fc.shop_opened.emit(8080) - _ck(su.is_open(), "shop opens on shop_opened") - _ck(su._grid.get_child_count() == 40, "shop: 5x8 item grid") - # 第一格点击进入购买确认,再确认后固定以 count=1 发包。 - (su._grid.get_child(0) as Button).pressed.emit() - _ck(su._buy_confirm != null, "shop: slot click opens buy confirmation") - if su._buy_confirm: - su._buy_confirm.confirmed.emit() - _ck(fc.calls.has(["shop_buy", 0, 1]), "shop: buy row 0") - su.sell(5) - _ck(fc.calls.has(["shop_sell", 5, 1]), "shop: sell inv cell 5") - fc.shop_error.emit("NOT_ENOUGH_MONEY") - _ck(su._err.text == "金币不足", "shop: error text localised") - fc.shop_closed.emit() - _ck(not su.is_open(), "shop closes on shop_closed") - - # --- 商店 SHOP_GC_START_EX(多货架 + tabIdx*40+slot 买位置) --- - fc.calls.clear() - fc.shop_tabs = [ - {"name": "武器", "coin_type": 0, "items": [{"pos": 0, "vnum": 101, "price": 500, "count": 1}, - {"pos": 5, "vnum": 202, "price": 900, "count": 3}]}, - {"name": "防具", "coin_type": 1, "items": [{"pos": 3, "vnum": 303, "price": 12000, "count": 1}]}, - ] - fc.shop_opened.emit(8080) - _ck(su.is_open(), "shop_ex: opens") - _ck(su._tabbar.visible and su._tabbar.get_child_count() == 2, "shop_ex: 2 tab buttons") - _ck(su._grid.get_child_count() == 40, "shop_ex: tab 0 has 5x8 grid") - # tab 0 第二个货位(slot 5)买 -> pos = 0*40 + 5 - (su._grid.get_child(5) as Button).pressed.emit() - if su._buy_confirm: - su._buy_confirm.confirmed.emit() - _ck(fc.calls.has(["shop_buy", 5, 1]), "shop_ex: tab0 slot5 -> buy pos 5") - # 切到货架 2 - su._tabbar.get_child(1).pressed.emit() - _ck(su._active_tab == 1, "shop_ex: switched to tab 1") - _ck(su._grid.get_child_count() == 40, "shop_ex: tab 1 has 5x8 grid") - (su._grid.get_child(3) as Button).pressed.emit() - if su._buy_confirm: - su._buy_confirm.confirmed.emit() - _ck(fc.calls.has(["shop_buy", 43, 1]), "shop_ex: tab1 slot3 -> buy pos 1*40+3=43") - fc.shop_closed.emit() - fc.shop_tabs = [] - - # --- 交易 --- - var xu: Node = ExchangeUI.new() - get_root().add_child(xu) - xu.setup(fc, canvas) - xu.set_mobile_mode(true) - fc.exchange = { - "active": true, "partner_vid": 4321, - "self_items": [{"slot": 0, "vnum": 19, "count": 1}], - "peer_items": [], "self_gold": 100, "peer_gold": 0, - "self_accept": false, "peer_accept": true, - } - fc.inventory = [{"cell": 7, "vnum": 901, "count": 1, "anti_flags": 0}] - fc.exchange_changed.emit() - _ck(xu.is_open(), "exchange window opens when active") - _ck(xu._mobile_inventory_scroll.visible and xu._mobile_inventory_list.get_child_count() == 1, - "exchange mobile mode shows one tap-to-offer inventory candidate") - if xu._mobile_inventory_list.get_child_count() == 1: - (xu._mobile_inventory_list.get_child(0) as Button).pressed.emit() - _ck(fc.calls.has(["ex_add_item", 1, 7, 1]), - "exchange mobile candidate tap sends item offer") - _ck(xu._self_box.get_child_count() == 1, "exchange: 1 self item row") - _ck(xu._root.get_node("PeerAccept").text == "对方: 已接受", "exchange: peer accept shown") - _ck(not xu._accept_btn.disabled, "exchange: accept enabled before server accept") - xu._on_accept() - _ck(fc.calls.has(["ex_accept"]) and xu._accept_btn.disabled, - "exchange: accept sends once and disables immediately") - fc.exchange["self_accept"] = false - fc.exchange_changed.emit() - _ck(not xu._accept_btn.disabled, "exchange: server-cleared accept re-enables button") - xu.offer(1, 7) - _ck(fc.calls.has(["ex_add_item", 1, 7, 1]), "exchange: offer item uses first free display slot (1)") - fc.exchange["self_gold"] = 0 - xu._gold_input.text = "500" - xu._on_put_gold() - _ck(fc.calls.has(["ex_gold", 500]), "exchange: put gold") - fc.exchange["self_gold"] = 500 - xu._gold_input.text = "100" - xu._on_put_gold() - _ck(not fc.calls.has(["ex_gold", 100]), "exchange: existing gold cannot be edited") - fc.exchange["self_gold"] = 0 - xu._gold_input.text = "10000000" - xu._on_put_gold() - _ck(not fc.calls.has(["ex_gold", 10000000]), "exchange: gold is limited to 7 digits") - fc.inventory = [{"cell": 8, "vnum": 902, "count": 1, "anti_flags": 1 << 13}] - _ck(not xu._drop_to_slot({"window": 1, "cell": 8, "vnum": 902, "count": 1}, 3), - "exchange: ANTIFLAG_GIVE item rejected") - fc.main_pos = Vector3(1001, 0, 0) - xu._process(0.1) - _ck(not xu.is_open() and fc.calls.has(["ex_cancel"]), - "exchange: moving beyond 1000 exits trade") - fc.exchange = {"active": false} - fc.exchange_changed.emit() - _ck(not xu.is_open(), "exchange closes when inactive") - - # --- 仓库 --- - var bu: Node = SafeboxUI.new() - get_root().add_child(bu) - bu.setup(fc, canvas) - bu.set_mobile_mode(true) - fc.safebox_open = true - fc.safebox_size = 2 - fc.safebox_gold = 99999 - fc.safebox = [{"cell": 3, "vnum": 27, "count": 10}] - fc.inventory = [{"cell": 4, "vnum": 901, "count": 2}] - fc.safebox_changed.emit() - _ck(bu.is_open(), "safebox opens") - _ck(bu._gold.text.contains("99999"), "safebox: gold shown") - _ck(bu._list.get_child_count() == 1, "safebox: 1 item row") - _ck(bu._mobile_inventory_scroll.visible and bu._mobile_inventory_list.get_child_count() == 1, - "safebox mobile mode shows one tap-to-deposit inventory candidate") - if bu._mobile_inventory_list.get_child_count() == 1: - (bu._mobile_inventory_list.get_child(0) as Button).pressed.emit() - _ck(fc.calls.any(func(c): return c[0] == "sb_checkin" and c[2] == 1 and c[3] == 4), - "safebox mobile candidate tap sends checkin") - _ck(bu._mobile_deposit_pending.has(4), "safebox mobile prevents duplicate checkin taps") - var out_btn: Button = null - for b in bu._list.get_child(0).find_children("*", "Button", true, false): - out_btn = b - if out_btn: - out_btn.pressed.emit() - _ck(fc.calls.any(func(c): return c[0] == "sb_checkout" and c[1] == 3), "safebox: checkout cell 3") - fc.inventory = [{"cell": 0}, {"cell": 1}] - bu.deposit(1, 4) - # 下一个空仓位应是 0(cell 3 已用) - _ck(fc.calls.any(func(c): return c[0] == "sb_checkin" and c[2] == 1 and c[3] == 4), - "safebox: deposit inv cell 4") diff --git a/project/p8_test.gd.uid b/project/p8_test.gd.uid deleted file mode 100644 index 236fad05..00000000 --- a/project/p8_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dseicj4ybu2qd diff --git a/project/p9_test.gd b/project/p9_test.gd deleted file mode 100644 index a9becd71..00000000 --- a/project/p9_test.gd +++ /dev/null @@ -1,170 +0,0 @@ -# p9_test —— P9:小地图 + 昼夜 + 天气 + 传送 headless 自检。 -# godot --headless --path project --script p9_test.gd -extends SceneTree - -const Minimap = preload("res://ui/minimap.gd") -const WorldTime = preload("res://world/world_time.gd") -const Weather = preload("res://fx/weather.gd") - -class FakeClient extends Node: - signal time_changed(server_epoch: int) - signal channel_changed(channel: int) - signal npc_marks_changed() - signal land_areas_changed() - signal observer_event(kind: int, vid: int, pos: Vector2) - signal world_reset() - signal world_markers_changed() - signal warp(pos: Vector3, same_server: bool) - var _time := 0 - var entities := [] - var npc_marks := [] - var markers := [] - var observers := [] - var land_areas := [] - var party := [] - func get_server_time() -> int: return _time - func get_entities() -> Array: return entities - func get_npc_marks() -> Array: return npc_marks - func get_world_markers() -> Array: return markers - func get_observers() -> Array: return observers - func get_land_areas() -> Array: return land_areas - func get_party() -> Array: return party - func get_channel() -> int: return 3 - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: p9_test (minimap + world_time + weather + warp)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var canvas := CanvasLayer.new() - get_root().add_child(canvas) - var fc := FakeClient.new() - get_root().add_child(fc) - var player := Node3D.new() - player.position = Vector3.ZERO - get_root().add_child(player) - - # --- 小地图 --- - var mm: Node = Minimap.new() - get_root().add_child(mm) - mm.setup(fc, canvas, func() -> Node3D: return player) - _ck(mm._view != null, "minimap: view built") - fc.channel_changed.emit(7) - _ck(mm._ch_label.text == "CH 7", "minimap: channel label") - # _to_map: 北(−z 100m)应在圆心正上方附近;夹到半径内 - var north: Vector2 = mm._to_map(Vector3(0, 0, -1000), Vector3.ZERO) - _ck(north.x == mm.RADIUS and north.y < mm.RADIUS, "minimap: north maps up, clamped") - var east: Vector2 = mm._to_map(Vector3(10, 0, 0), Vector3.ZERO) - _ck(east.x > mm.RADIUS and abs(east.y - mm.RADIUS) < 0.01, "minimap: east maps right") - var base_scale: float = mm.get_scale() - mm.set_scale(base_scale * 2.0) - var zoomed_east: Vector2 = mm._to_map(Vector3(10, 0, 0), Vector3.ZERO) - _ck(mm.get_scale() > base_scale and zoomed_east.x > east.x, "minimap: scale zooms world projection") - var wheel_scale: float = mm.get_scale() - var wheel := InputEventMouseButton.new() - wheel.button_index = MOUSE_BUTTON_WHEEL_DOWN - wheel.pressed = true - mm._on_gui_input(wheel) - _ck(mm.get_scale() < wheel_scale, "minimap: wheel down zooms out") - fc.entities = [{"vid": 11, "is_main": false, "ch_type": 0, "pos": Vector3(5, 0, 5)}] - fc.party = [{"vid": 11, "leader": true}] - fc.npc_marks = [{"type": 2, "name": "Gate", "pos": Vector3(3, 0, 3)}] - fc.markers = [{"id": 1, "name": "Quest", "pos": Vector3(-4, 0, -4)}] - fc.observers = [{"vid": 9001, "pos": Vector3(2, 0, -2)}] - fc.land_areas = [{"id": 42, "guild_id": 77, "x": -200, "y": -200, "width": 400, "height": 400}] - # ClientVS22's CPythonMiniMap interpolates observer moves for 1 second. - fc.observer_event.emit(mm.OBSERVER_ADD, 9001, Vector2(0, 0)) - var observer_start: Vector3 = mm._observer_position(9001, Vector3.ZERO) - _ck(observer_start.distance_to(Vector3.ZERO) < 0.01, "minimap: observer add seeds current position") - fc.observer_event.emit(mm.OBSERVER_MOVE, 9001, Vector2(10, 4)) - var observer_immediate: Vector3 = mm._observer_position(9001, Vector3.ZERO) - _ck(observer_immediate.distance_to(Vector3.ZERO) < 0.1, "minimap: observer move starts at current position") - await create_timer(1.05).timeout - var observer_final: Vector3 = mm._observer_position(9001, Vector3.ZERO) - _ck(observer_final.distance_to(Vector3(10, 0, 4)) < 0.1, "minimap: observer move reaches destination") - fc.observer_event.emit(mm.OBSERVER_REMOVE, 9001, Vector2.ZERO) - _ck(not mm._observer_tracks.has(9001), "minimap: observer remove clears track") - fc.observer_event.emit(mm.OBSERVER_ADD, 9001, Vector2(2, -2)) - fc.world_reset.emit() - _ck(mm._observer_tracks.is_empty(), "minimap: world reset clears observer tracks") - mm._view.queue_redraw() - await process_frame - _ck(mm._view.is_visible_in_tree(), "minimap: redraw scheduled, view live") - - # --- 任务信标:圆内画星,越出半径贴圆周画方向箭头(§5.2 世界箭头 / 屏幕边缘指示)--- - var rim: float = mm.RADIUS - mm.SIGNAL_RIM_INSET - var near_geo: Dictionary = mm.signal_marker_geometry(Vector2(10, 0), rim) - _ck(not near_geo["clamped"] and near_geo["offset"] == Vector2(10, 0), - "minimap signal: inside rim keeps true offset") - var far_geo: Dictionary = mm.signal_marker_geometry(Vector2(400, 0), rim) - _ck(far_geo["clamped"] and abs(far_geo["offset"].length() - rim) < 0.01, - "minimap signal: outside rim clamps to circle") - _ck(abs(far_geo["angle"]) < 0.001 and abs(far_geo["offset"].x - rim) < 0.01, - "minimap signal: clamp keeps bearing to target (east)") - var diag_geo: Dictionary = mm.signal_marker_geometry(Vector2(-300, -300), rim) - _ck(diag_geo["clamped"] and abs(diag_geo["angle"] - atan2(-300.0, -300.0)) < 0.001 - and abs(diag_geo["offset"].length() - rim) < 0.01, - "minimap signal: diagonal clamp preserves atan2 bearing") - var edge_geo: Dictionary = mm.signal_marker_geometry(Vector2(rim, 0), rim) - _ck(edge_geo["clamped"], "minimap signal: exactly on rim counts as clamped (>=)") - mm.add_signal_point(120000.0, 90000.0) - _ck(mm.signal_point_count() == 1, "minimap signal: add_signal_point stores waypoint") - mm._view.queue_redraw() - await process_frame - mm.clear_signal_points() - _ck(mm.signal_point_count() == 0, "minimap signal: clear_signal_points empties waypoints") - - # --- 昼夜 --- - var sun := DirectionalLight3D.new() - get_root().add_child(sun) - var env := Environment.new() - env.background_mode = Environment.BG_COLOR - var wt: Node = WorldTime.new() - get_root().add_child(wt) - wt.setup(fc, sun, env) - # 正午:2025-01-01 12:00:00 UTC = 1735732800 - fc._time = 1735732800 - fc.time_changed.emit(1735732800) - _ck(abs(wt.day_fraction() - 0.5) < 0.01, "world_time: noon -> day_fraction ~0.5") - wt._process(0.016) - var noon_energy: float = sun.light_energy - _ck(noon_energy > 0.9, "world_time: noon sun bright (%.2f)" % noon_energy) - # 午夜 - fc.time_changed.emit(1735732800 - 43200) - _ck(abs(wt.day_fraction()) < 0.01 or abs(wt.day_fraction() - 1.0) < 0.01, - "world_time: midnight -> fraction ~0") - wt._process(0.016) - _ck(sun.light_energy < 0.2, "world_time: midnight sun dark (%.3f)" % sun.light_energy) - - # --- 天气 --- - var cam := Camera3D.new() - get_root().add_child(cam) - var w: Node3D = Weather.new() - get_root().add_child(w) - w.setup(cam) - _ck(w.kind() == "none" and not w._p.emitting, "weather: starts off") - w.set_weather("snow") - _ck(w._p.emitting and w.kind() == "snow", "weather: snow on") - var g_snow: Vector3 = w._p.process_material.gravity - w.set_weather("rain") - var g_rain: Vector3 = w._p.process_material.gravity - _ck(g_rain.y < g_snow.y, "weather: rain falls faster than snow") - w.set_weather("none") - _ck(not w._p.emitting, "weather: off again") - - # --- 传送信号(同服)--- - var got := [] - fc.warp.connect(func(pos, same): got.append([pos, same])) - fc.warp.emit(Vector3(50, 0, -30), true) - _ck(got.size() == 1 and got[0][1] == true, "warp: same-server signal received") diff --git a/project/p9_test.gd.uid b/project/p9_test.gd.uid deleted file mode 100644 index 5a31917c..00000000 --- a/project/p9_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://4crf5im7shwi diff --git a/project/package_render_test.gd b/project/package_render_test.gd deleted file mode 100644 index b88a62c3..00000000 --- a/project/package_render_test.gd +++ /dev/null @@ -1,198 +0,0 @@ -# package_render_test —— 在**导出包内部**跑的渲染自检。 -# -# MT_TEST_MODE=render build/export/mtgodot-poc.app/Contents/MacOS/mtgodot-poc -# -# 存在的理由:导出模板不接受 `--script`,所以 model_render_test.gd / -# gamescene_visual_test.gd 这类 `extends SceneTree` 的离线回归**只能在编辑器里跑**。 -# 编辑器全绿而实机进游戏是「灰紫虚空 + 蓝胶囊」时,两边差异无从定位——包内没有 -# 任何不依赖服务器的入口。本脚本就是那个入口:由 client_main.gd 在 -# MT_TEST_MODE=render 时挂到主场景下,走真实 GameScene + 真实 Metin2World + -# 真实 PlayerView,不连服务器、不写账号,跑完把报告和截图落到 user:// 并退出。 -# -# 覆盖范围:包内的资源解析、GDExtension 注册、地图装载、角色装配、落地高度。 -# **不覆盖**:网络驱动的进场时序、40250 像素比对、性能。 -extends Node - -const GameScene = preload("res://game_scene.gd") -const Fixtures = preload("res://gamescene_test.gd") - -const MAP := "OutdoorA1/metin2_map_a1" -# 与 gamescene_visual_test.gd 同一组坐标和阈值,两边结论才可比。 -const SERVER_CM := Vector2(454600, 934000) -const FEET_GAP_MAX := 0.03 -const SILHOUETTE_MIN := 3000 - -var _fail: Array[String] = [] -var _out := "" - -func _ready() -> void: - _out = OS.get_environment("MT_RENDER_OUTPUT") - if _out.is_empty(): - _out = ProjectSettings.globalize_path("user://render-check") - DirAccess.make_dir_recursive_absolute(_out) - call_deferred("_run") - -func _run() -> void: - var report := {"env": _env(), "scope": - "in-package offline check: real GameScene + Metin2World + PlayerView, no network"} - - # 1) 扩展必须注册 —— 少了它 GameScene 会退回蓝胶囊 + 无地形灰底, - # 而这两件事在画面上和「资源没找到」长得一模一样,必须先分开。 - for cls in ["Metin2Model", "Metin2World", "Metin2AnimPlayer"]: - if not ClassDB.class_exists(cls): - _fail.append("GDExtension 类未注册:%s" % cls) - # 2) 资源根必须落在包内(或 MT_ASSETS 指定处)且真的有地图和角色 - var root: String = report["env"]["asset_root"] - for probe in [MAP, "PC/ymir work/pc/warrior", "asset_index.txt"]: - var p := root.path_join(probe) - if not (FileAccess.file_exists(p) or DirAccess.dir_exists_absolute(p)): - _fail.append("资源缺失:%s" % p) - - if _fail.is_empty(): - report["effects"] = await _check_effect_resources(root) - report["scene"] = await _build_and_measure() - else: - report["scene"] = {"skipped": "前置检查已失败,不再装场景"} - - report["passed"] = _fail.is_empty() - report["failures"] = _fail - var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE) - f.store_string(JSON.stringify(report, "\t")) - f.close() - print("PKGRENDER: ", _out) - print(JSON.stringify(report)) - for m in _fail: - printerr("PKGRENDER FAIL: ", m) - print("PKGRENDER: ", "PASS" if _fail.is_empty() else "FAIL") - get_tree().quit(0 if _fail.is_empty() else 1) - -func _check_effect_resources(assets: String) -> Dictionary: - var dds_path := assets.path_join("PC/ymir work/pc/shaman/effect/shamanblackcore.dds") - var decoded := preload("res://ui/ui_assets.gd").load_dds_image(dds_path) - var dds_ok := decoded != null and not decoded.is_empty() - if not dds_ok: - _fail.append("包内 16 位技能 DDS 解码失败") - var mse_path := assets.path_join("PC/ymir work/pc/shaman/effect/noejeon_4_blow.mse") - var spec := preload("res://fx/mse.gd").new().parse_file(mse_path) - var effect := preload("res://fx/effect_player.gd").new() - effect.build(spec, assets) - add_child(effect) - # ParticleProcessMaterial shader variants are compiled on a deferred frame. - # Exercise a real lifecycle instead of freeing uninitialized GPU emitters. - await get_tree().process_frame - if DisplayServer.get_name() != "headless": - await RenderingServer.frame_post_draw - var special_materials := 0 - for emitter in effect._emitters: - if emitter.material_override is ShaderMaterial: - special_materials += 1 - if special_materials == 0: - _fail.append("包内真实技能未装配专用颜色材质") - effect.free() - await get_tree().process_frame - return {"dds_decoded": dds_ok, "special_materials": special_materials, - "dds_sha256": FileAccess.get_sha256(dds_path), "mse_sha256": FileAccess.get_sha256(mse_path), - "scope": "bundled DDS decode and real MSE material assembly; not skill pixels"} - -# 包内和开发树的差异几乎都藏在这几个值里,先无条件记下来。 -func _env() -> Dictionary: - var cands := {} - for c in _asset_candidates(): - cands[c] = DirAccess.dir_exists_absolute(c) - return { - "engine": Engine.get_version_info()["string"], - "executable": OS.get_executable_path(), - "res_globalized": ProjectSettings.globalize_path("res://"), - "user_dir": OS.get_user_data_dir(), - "features": {"template": OS.has_feature("template"), "debug": OS.has_feature("debug"), - "release": OS.has_feature("release"), "editor": OS.has_feature("editor")}, - "MT_ASSETS": OS.get_environment("MT_ASSETS"), - "MT_PROTOCOL": OS.get_environment("MT_PROTOCOL"), - "asset_candidates": cands, - "asset_root": AssetRoot.path(), - "asset_root_available": AssetRoot.available(), - "classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"), - "Metin2World": ClassDB.class_exists("Metin2World"), - "Metin2AnimPlayer": ClassDB.class_exists("Metin2AnimPlayer")}, - "rendering_driver": DisplayServer.get_name(), - "viewport": str(get_window().size), - } - -# AssetRoot._candidates() 是私有的;这里复述一遍只为把每个候选的命中情况写进报告。 -func _asset_candidates() -> Array: - var out: Array = [] - var env := OS.get_environment("MT_ASSETS") - if env != "": - out.append(env) - if FileAccess.file_exists("res://assets/asset_index.txt"): - out.append("res://assets") - out.append(ProjectSettings.globalize_path("res://../assets")) - var exe := OS.get_executable_path() - if exe != "": - var d := exe.get_base_dir() - out.append(d.path_join("../Resources/assets").simplify_path()) - out.append(d.path_join("../../../assets").simplify_path()) - out.append(d.path_join("assets")) - return out - -func _build_and_measure() -> Dictionary: - var fc := Fixtures.FakeClient.new() - add_child(fc) - var scene := GameScene.new() - add_child(scene) - await scene.setup(fc, AssetRoot.path(), MAP) - fc.main = 1000 - fc.ents[1000] = {"vid": 1000, "name": "PkgRenderTest", "race": 0, - "pos": Vector3(SERVER_CM.x * 0.01, 0, -SERVER_CM.y * 0.01), - "pos_cm": SERVER_CM, "is_main": true, "angle_deg": 0.0, - "func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false} - for i in 30: - await get_tree().process_frame - - var expected := MapCoord.to_world(fc.ents[1000].pos) - expected.y = scene.world.sample_height(expected.x, expected.z) if scene.world else 0.0 - var surfaces := 0 - for mi in scene.player.find_children("*", "MeshInstance3D", true, false): - if mi.mesh: - surfaces += mi.mesh.get_surface_count() - if scene._model_built and scene.player.anim: - scene.player.anim.set_time(0.5) - scene.player.anim.set_process(false) - await get_tree().process_frame - var feet_y := INF - for mi in scene.player.find_children("*", "MeshInstance3D", true, false): - if mi.mesh: - feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y) - var feet_gap := feet_y - expected.y - - if not scene._model_built: - _fail.append("主角模型没装出来(仍是 _make_placeholder_player 的蓝胶囊)") - if not scene._map_loaded(): - _fail.append("地图没装出来(灰底虚空)") - if surfaces == 0: - _fail.append("玩家节点下 0 个 surface") - if scene.world: - var trees: Dictionary = scene.world.get_load_report() - if int(trees.get("native_trees_placed", 0)) <= 0 or int(trees.get("proxy_trees_placed", -1)) != 0: - _fail.append("真实树木缺失或仍有代理回退:%s" % trees) - if not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX: - _fail.append("feet_gap=%s 超出 %.3f" % [feet_gap, FEET_GAP_MAX]) - - var shot := "SKIPPED_HEADLESS" - if DisplayServer.get_name() != "headless": - scene.cam.snap_to_target() - await RenderingServer.frame_post_draw - await RenderingServer.frame_post_draw - var img := get_window().get_texture().get_image() - shot = "game.png" if img.save_png(_out.path_join("game.png")) == OK else "SAVE_FAILED" - - var d := {"map": scene.map_path, "model_built": scene._model_built, - "map_loaded": scene._map_loaded(), "surfaces": surfaces, - "world_position": str(scene.player.position), "expected": str(expected), - "feet_y": feet_y, "feet_gap": feet_gap, "feet_gap_max": FEET_GAP_MAX, - "map_report": scene.world.get_load_report() if scene.world else {}, - "camera_transform": str(scene.cam.global_transform), "screenshot": shot} - scene.queue_free() - fc.queue_free() - await get_tree().process_frame - return d diff --git a/project/package_render_test.gd.uid b/project/package_render_test.gd.uid deleted file mode 100644 index f38af1ce..00000000 --- a/project/package_render_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://rm3i2avq75g diff --git a/project/part_hiding.gd b/project/part_hiding.gd deleted file mode 100644 index ec776394..00000000 --- a/project/part_hiding.gd +++ /dev/null @@ -1,59 +0,0 @@ -# part_hiding.gd —— §4.1 修改 4:变身 / 时装的部位遮挡规则(InstanceBase.cpp 1:1)。 -# -# 纯函数,无状态。EquipModel.refresh() 在解出身体 shape 后调用,决定武器 / 盾 / 头发 -# 是否该隐藏、身体 shape 是否被变身强制归零。远端角色换装走同一条路(EquipModel 是 -# 「渲染别的玩家用的同一条路」)。 -# -# 参考 REF/UserInterface/InstanceBase*.cpp: -# IsPoly() -> SetShape(0);SetWeapon / SetParts 提前 return -# -> 身体 shape 归 0、无武器、无头发 / 挂件 -# __IsShapeAnimalWear() -> GetShape() ∈ {100,101,102,103};SetWeapon(:2747) 与 -# SetParts 返回 false -> 穿动物 / 怪物时装时武器 + 头发被抑制 -# IsWearingDress() -> m_eShape == 201(婚纱);__IsChangableWeapon(iWeaponID) -# (:901) 除 c_iBouquets 外一律 false -> 非捧花时武器隐藏 -# (头发不受婚纱影响) -# __ArmorVnumToShape() -> shape = item_proto values[3](vnum ∉ {0,1} 且 -# USE_ARMOR_SPECULAR),否则 shape = vnum -extends RefCounted - -const POLY_SHAPE := 0 -const ANIMAL_WEAR_SHAPES := [100, 101, 102, 103] # __IsShapeAnimalWear -const WEDDING_DRESS_SHAPE := 201 # IsWearingDress -const BOUQUET_VNUMS := [50201, 50202, 50203, 50204] # c_iBouquets(原表 0 结尾) - -static func is_animal_wear(shape: int) -> bool: - return shape in ANIMAL_WEAR_SHAPES - -static func is_wearing_dress(shape: int) -> bool: - return shape == WEDDING_DRESS_SHAPE - -static func is_bouquet(weapon_vnum: int) -> bool: - return weapon_vnum in BOUQUET_VNUMS - -# __IsChangableWeapon:婚纱状态只有捧花可换,其它 shape 一律可换。 -static func is_changable_weapon(weapon_vnum: int, shape: int) -> bool: - if is_wearing_dress(shape): - return is_bouquet(weapon_vnum) - return true - -# 武器(含盾,走同一挂点规则)是否该隐藏:变身 / 动物时装 / 婚纱且非捧花。 -static func weapon_hidden(shape: int, is_poly: bool, weapon_vnum: int = 0) -> bool: - if is_poly: - return true - if is_animal_wear(shape): - return true - if is_wearing_dress(shape) and not is_bouquet(weapon_vnum): - return true - return false - -# 头发 / 挂件是否该隐藏:变身 / 动物时装(婚纱不影响头发)。 -static func hair_hidden(shape: int, is_poly: bool) -> bool: - return is_poly or is_animal_wear(shape) - -# 身体 shape:变身强制 0(SetShape(0)),否则原样(动物时装 100-103 保留,用来出时装体)。 -static func body_shape(shape: int, is_poly: bool) -> int: - return POLY_SHAPE if is_poly else shape - -# 有效武器 vnum:被隐藏则 0(SetWeapon 提前 return 的等效结果)。 -static func effective_weapon(weapon_vnum: int, shape: int, is_poly: bool) -> int: - return 0 if weapon_hidden(shape, is_poly, weapon_vnum) else weapon_vnum diff --git a/project/part_hiding.gd.uid b/project/part_hiding.gd.uid deleted file mode 100644 index b743cf5f..00000000 --- a/project/part_hiding.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://tceq7sornwqf diff --git a/project/part_hiding_test.gd b/project/part_hiding_test.gd deleted file mode 100644 index e6e2edbd..00000000 --- a/project/part_hiding_test.gd +++ /dev/null @@ -1,76 +0,0 @@ -# part_hiding_test —— §4.1 修改 4:变身 / 时装部位遮挡(PartHiding)1:1 自检。 -# canonical #18。 godot --headless --path project --script part_hiding_test.gd -extends SceneTree - -const PartHiding = preload("res://part_hiding.gd") - -var failed := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - failed += 1 - printerr("FAIL: " + message) - -func _init() -> void: - _test_animal_wear() - _test_dress_and_bouquet() - _test_changable_weapon() - _test_weapon_hidden() - _test_hair_hidden() - _test_body_shape() - _test_effective_weapon() - if failed == 0: - print("PASS: part_hiding_test (§4.1 修改 4 poly / animal-wear / dress 遮挡 1:1)") - quit(0) - else: - quit(1) - -func _test_animal_wear() -> void: - for s in [100, 101, 102, 103]: - _ck(PartHiding.is_animal_wear(s), "shape %d is animal-wear" % s) - for s in [0, 5, 99, 104, 201]: - _ck(not PartHiding.is_animal_wear(s), "shape %d not animal-wear" % s) - -func _test_dress_and_bouquet() -> void: - _ck(PartHiding.is_wearing_dress(201), "shape 201 is wedding dress") - for s in [0, 200, 202, 100]: - _ck(not PartHiding.is_wearing_dress(s), "shape %d not dress" % s) - for v in [50201, 50202, 50203, 50204]: - _ck(PartHiding.is_bouquet(v), "vnum %d is bouquet" % v) - for v in [0, 50200, 50205, 19]: - _ck(not PartHiding.is_bouquet(v), "vnum %d not bouquet" % v) - -func _test_changable_weapon() -> void: - # 婚纱:只有捧花可换 - _ck(PartHiding.is_changable_weapon(50201, 201), "dress + bouquet -> changable") - _ck(not PartHiding.is_changable_weapon(19, 201), "dress + sword -> not changable") - # 非婚纱 shape:任何武器可换(动物时装的隐藏走 weapon_hidden,不走这条) - _ck(PartHiding.is_changable_weapon(19, 5), "normal shape + sword -> changable") - _ck(PartHiding.is_changable_weapon(19, 100), "animal-wear shape not blocked by __IsChangableWeapon") - -func _test_weapon_hidden() -> void: - _ck(not PartHiding.weapon_hidden(5, false, 19), "normal shape, not poly -> weapon shown") - _ck(PartHiding.weapon_hidden(5, true, 19), "poly -> weapon hidden (any shape)") - _ck(PartHiding.weapon_hidden(101, false, 19), "animal-wear shape -> weapon hidden") - _ck(PartHiding.weapon_hidden(201, false, 19), "dress + non-bouquet -> weapon hidden") - _ck(not PartHiding.weapon_hidden(201, false, 50203), "dress + bouquet -> weapon shown") - _ck(PartHiding.weapon_hidden(201, false), "dress + no weapon -> hidden") - -func _test_hair_hidden() -> void: - _ck(not PartHiding.hair_hidden(5, false), "normal -> hair shown") - _ck(PartHiding.hair_hidden(5, true), "poly -> hair hidden") - _ck(PartHiding.hair_hidden(102, false), "animal-wear -> hair hidden") - _ck(not PartHiding.hair_hidden(201, false), "dress -> hair NOT hidden") - -func _test_body_shape() -> void: - _ck(PartHiding.body_shape(3, false) == 3, "normal shape passthrough") - _ck(PartHiding.body_shape(100, false) == 100, "animal-wear shape kept (出时装体)") - _ck(PartHiding.body_shape(3, true) == 0, "poly -> body shape 0") - _ck(PartHiding.body_shape(201, true) == 0, "poly overrides dress shape") - -func _test_effective_weapon() -> void: - _ck(PartHiding.effective_weapon(19, 5, false) == 19, "normal -> weapon passthrough") - _ck(PartHiding.effective_weapon(19, 100, false) == 0, "animal-wear -> weapon 0") - _ck(PartHiding.effective_weapon(50201, 201, false) == 50201, "dress + bouquet -> keep bouquet") - _ck(PartHiding.effective_weapon(19, 201, false) == 0, "dress + sword -> 0") - _ck(PartHiding.effective_weapon(19, 5, true) == 0, "poly -> weapon 0") diff --git a/project/part_hiding_test.gd.uid b/project/part_hiding_test.gd.uid deleted file mode 100644 index 4d8f7acd..00000000 --- a/project/part_hiding_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://pqahjhjcoifw diff --git a/project/physics_push.gd b/project/physics_push.gd deleted file mode 100644 index 3e1c6596..00000000 --- a/project/physics_push.gd +++ /dev/null @@ -1,129 +0,0 @@ -# PhysicsPush —— CLIENT-GAP §3.7:受击击退,逐行移植 GameLib/PhysicsObject.cpp `CPhysicsObject` -# + GameLib/MapUtil.cpp `CEaseOutInterpolation`。单位 = Metin2 cm(平面 x / y)。 -# __PushCircle -> set_direction(normalize(victim - attacker)) -# IncreaseExternalForce(base, fExternalForce) -> increase_external_force(force[, collides]) -# PhysicsProcess:update(dt) 后 AddMovement(get_movement()) -extends RefCounted - -const FRAME_TIME := 0.02 # c_fFrameTime -const EPSILON := 0.001 -const LOOP_VALUE := 100 -const FLT_EPSILON := 1.192092896e-07 - -class EaseOut: - var remaining := 0.0 - var value := 0.0 - var speed := 0.0 - var acceleration := 0.0 - var start_value := 0.0 - var last_value := 0.0 - - func initialize() -> void: - remaining = 0.0 - value = 0.0 - speed = 0.0 - acceleration = 0.0 - start_value = 0.0 - last_value = 0.0 - - func setup(f_start: float, f_end: float, f_time: float) -> void: - if absf(f_time) < FLT_EPSILON: - f_time = 0.01 - value = f_start - start_value = f_start - last_value = f_start - speed = (2.0 * (f_end - f_start)) / f_time - acceleration = 2.0 * (f_end - f_start) / (f_time * f_time) - 2.0 * speed / f_time - remaining = f_time - - func interpolate(dt: float) -> void: - last_value = value - remaining -= dt - speed += acceleration * dt - value += speed * dt - if not is_playing(): - value = 0.0 - last_value = 0.0 - - func is_playing() -> bool: - return remaining > 0.0 - - func changing_value() -> float: - return value - last_value - -var mass := 1.0 -var friction := 0.3 -var direction := Vector3.ZERO -var acceleration := Vector3.ZERO -var velocity := Vector3.ZERO -var last_position := Vector3.ZERO -var _x := EaseOut.new() -var _y := EaseOut.new() - -func initialize() -> void: - mass = 1.0 - friction = 0.3 - direction = Vector3.ZERO - acceleration = Vector3.ZERO - velocity = Vector3.ZERO - last_position = Vector3.ZERO - _x.initialize() - _y.initialize() - -func set_direction(dir: Vector2) -> void: - direction = Vector3(dir.x, dir.y, 0.0) - -func update(dt: float) -> void: - if _x.is_playing(): - _x.interpolate(dt) - if _y.is_playing(): - _y.interpolate(dt) - -func _accumulate(pos: Vector3) -> Vector3: - var force := 0.0 - if absf(velocity.x) < EPSILON or absf(velocity.y) < EPSILON or absf(velocity.z) < EPSILON: - force -= mass * friction - acceleration = direction * (force / mass) - velocity += acceleration - if velocity.x * direction.x < EPSILON: - velocity.x = 0.0 - direction.x = 0.0 - if velocity.y * direction.y < EPSILON: - velocity.y = 0.0 - direction.y = 0.0 - if velocity.z * direction.z < EPSILON: - velocity.z = 0.0 - direction.z = 0.0 - return pos + velocity - -# collides(movement: Vector2) -> bool 对应 IPhysicsWorld::isPhysicalCollision(base + movement); -# 空 Callable = 没有物理世界(参考端 pWorld == NULL 分支)。 -func increase_external_force(force: float, collides := Callable()) -> void: - acceleration = direction * (force / mass) - velocity = acceleration - var movement := Vector3.ZERO - for i in LOOP_VALUE: - movement = _accumulate(movement) - if collides.is_valid() and bool(collides.call(Vector2(movement.x, movement.y))): - initialize() - return - if absf(velocity.x) < EPSILON and absf(velocity.y) < EPSILON and absf(velocity.z) < EPSILON: - break - set_last_position(Vector2(movement.x, movement.y), float(LOOP_VALUE) * FRAME_TIME) - -func set_last_position(p: Vector2, blending_time: float) -> void: - last_position = Vector3(p.x, p.y, 0.0) - _x.setup(0.0, p.x, blending_time) - _y.setup(0.0, p.y, blending_time) - -func get_last_position() -> Vector2: - return Vector2(last_position.x, last_position.y) - -# GetXMovement / GetYMovement:本帧 ease-out 增量。 -func get_movement() -> Vector2: - return Vector2(_x.changing_value(), _y.changing_value()) - -func is_blending() -> bool: - if velocity.length() != 0.0: - return true - return _x.is_playing() or _y.is_playing() diff --git a/project/physics_push.gd.uid b/project/physics_push.gd.uid deleted file mode 100644 index 029834ec..00000000 --- a/project/physics_push.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cust1hhxv03lb diff --git a/project/physics_push_test.gd b/project/physics_push_test.gd deleted file mode 100644 index 4b539d20..00000000 --- a/project/physics_push_test.gd +++ /dev/null @@ -1,72 +0,0 @@ -# physics_push_test —— §3.7:受击击退物理(GameLib/PhysicsObject.cpp CPhysicsObject + -# MapUtil.cpp CEaseOutInterpolation)headless 自检。 -# godot --headless --path project --script physics_push_test.gd -extends SceneTree - -const PhysicsPush = preload("res://physics_push.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: physics_push_test (external force / friction / ease-out blend)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # 初始:不在 blending,每帧位移 0 - var p := PhysicsPush.new() - _ck(not p.is_blending(), "fresh object -> not blending") - p.update(0.02) - _ck(p.get_movement() == Vector2.ZERO, "fresh object -> zero movement") - - # IncreaseExternalForce(F=3, dir +X):vel 3 -> 每步摩擦 0.3,累计 2.7+2.4+…+0.3 = 13.5 cm - p.set_direction(Vector2(1, 0)) - p.increase_external_force(3.0) - _ck(absf(p.get_last_position().x - 13.5) < 0.001 and absf(p.get_last_position().y) < 0.001, - "F=3 along +X -> last position (13.5, 0), got %s" % p.get_last_position()) - _ck(p.is_blending(), "after push -> blending (ease-out playing)") - - # 逐帧 AddMovement(GetXMovement, GetYMovement):2 秒 ease-out 累计 ≈ 13.5(离散 Euler 少 1/N) - var sum := Vector2.ZERO - var frames := 0 - while p.is_blending() and frames < 1000: - p.update(0.02) - sum += p.get_movement() - frames += 1 - _ck(frames >= 99 and frames <= 101, "blend lasts LoopValue*c_fFrameTime = 2s, got %d frames" % frames) - _ck(absf(sum.x - 13.5) < 0.3 and absf(sum.y) < 0.001, "ease-out total movement ≈ 13.5, got %s" % sum) - p.update(0.02) - _ck(p.get_movement() == Vector2.ZERO, "blend done -> movement 0") - - # 斜向:分量各自摩擦,13.5 * cos45 - var q := PhysicsPush.new() - q.set_direction(Vector2(1, 1).normalized()) - q.increase_external_force(3.0) - _ck(absf(q.get_last_position().x - 13.5 * 0.70710678) < 0.01 and absf(q.get_last_position().y - 13.5 * 0.70710678) < 0.01, - "diagonal push -> per-axis 9.546, got %s" % q.get_last_position()) - - # 分量 vel*dir < EPSILON(0.001) 当步清零(3*0.01*0.01 = 3e-4) - var w := PhysicsPush.new() - w.set_direction(Vector2(0.99995, 0.01)) - w.increase_external_force(3.0) - _ck(w.get_last_position().y == 0.0, "tiny axis component zeroed on first step, got %s" % w.get_last_position()) - - # 力为 0:没有位移,也不 blending(Setup(0,0,2) 仍在播放 -> isBlending 为真,与参考端一致) - var z := PhysicsPush.new() - z.set_direction(Vector2(1, 0)) - z.increase_external_force(0.0) - _ck(z.get_last_position() == Vector2.ZERO, "zero force -> no movement") - - # 撞墙(IPhysicsWorld::isPhysicalCollision 为真)-> Initialize(),不推 - var c := PhysicsPush.new() - c.set_direction(Vector2(1, 0)) - c.increase_external_force(3.0, func(_moved: Vector2) -> bool: return true) - _ck(c.get_last_position() == Vector2.ZERO and not c.is_blending(), "world collision -> Initialize, no push") diff --git a/project/physics_push_test.gd.uid b/project/physics_push_test.gd.uid deleted file mode 100644 index 87580ba9..00000000 --- a/project/physics_push_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cph1wkg6cxfai diff --git a/project/pick_show.gd b/project/pick_show.gd deleted file mode 100644 index 01578615..00000000 --- a/project/pick_show.gd +++ /dev/null @@ -1,69 +0,0 @@ -# pick_show —— §8.8 / §5.4 game.py OnRender 的「Picking」块分派 1:1: -# 每帧渲染时,textTail 的强显(ShowCharacterTextTail)/ 全显(ShowAllTextTail)/ -# 拾取命中(chr.Pick / item.Pick / textTail.Pick)该走哪条,取决于 -# wndMgr.IsPickedWindow(hWnd)(鼠标是否在 3D game 窗口上)与 __IsShowName()。 -# 本模块把这棵判定树抽成纯静态谓词,供 net_world 的「悬停强显名字」用 -# (仿增量 113 的 _target_vid forced 路数,给 _hover_vid 加第 5 路 forced)。 -# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd / -# target_board.gd 同一套纯静态镜像套路,本模块是第 27 个 canonical。) -# -# REF(40250 ClientVS22 source/ 为唯一基线;运行时 python 见 assets/root/game.py): -# root/game.py:1519 GameWindow.OnRender() -# root/game.py:1531 textTail.UpdateAllTextTail() —— 每帧先刷全部尾标 -# root/game.py:1533 if True == wndMgr.IsPickedWindow(self.hWnd) —— 鼠标在 3D 窗口上才拾取 -# root/game.py:1535 self.PickingCharacterIndex = chr.Pick() —— 射线拾取角色(-1 = 无) -# root/game.py:1537 if -1 != PickingCharacterIndex: textTail.ShowCharacterTextTail(idx) -# root/game.py:1539 if 0 != targetBoard.GetTargetVID(): textTail.ShowCharacterTextTail(targetVID) -# root/game.py:1543 if not self.__IsShowName(): PickingItemIndex = item.Pick() —— 只在「非总显名字」时拾取地面物品 -# root/game.py:1546 if -1 != PickingItemIndex: textTail.ShowItemTextTail(idx) -# root/game.py:1552 if self.__IsShowName(): textTail.ShowAllTextTail() —— 总显名字:全显 + 从尾标里拾取 -# root/game.py:1554 self.PickingItemIndex = textTail.Pick(x, y) -# root/game.py:1557 textTail.UpdateShowingTextTail() -# root/game.py:1558 textTail.ArrangeTextTail() —— 屏幕空间去重叠排布 -# root/game.py:1560 if -1 != PickingItemIndex: textTail.SelectItemName(idx) -# -# seam ⑨:名字尾标 y = 聊天气泡 y − 17px(PythonTextTail.cpp:112-114)尚未接入; -# 本 poc 名字牌是每实体一个 Label3D,暂无「随 chat 气泡下移」的整合。 -# seam ⑩:ArrangeTextTail 的屏幕空间尾标去重叠排布(PythonTextTail.cpp ArrangeTextTail) -# 仍缺——poc 名字牌是 3D billboard,靠深度天然分层,未做 2D 投影后的挤开。 -# 本增量只收口其中一小块:把「悬停角色强显名字」按 :1535/:1537 补上(forced 第 5 路)。 -# seam(近似):参考里 chr.Pick() 强显与 targetBoard 强显都在 :1533 的 IsPickedWindow 门内; -# poc 把 _target_vid(增量 113)与 _hover_vid(本增量)都当作「无条件 forced」路径—— -# _hover_vid 在鼠标压 UI 时由 player_controller 主动置 0,算是部分等效于 IsPickedWindow 门。 -extends RefCounted - -# chr.Pick() / item.Pick() 的「无命中」哨兵。 -const NO_PICK := -1 - -# :1533 —— 鼠标在 3D game 窗口上时,chr.Pick() 这一支才跑。 -static func char_pick_active(is_picked_window: bool) -> bool: - return is_picked_window - -# :1533..:1540 —— 本帧要 ShowCharacterTextTail(强显、绕过 __IsShowName)的 vid 集合: -# 悬停拾取到的角色(pick_char_vid,≈ poc 的 _hover_vid)+ 目标框 VID(target_vid)。 -# 两者都只在 is_picked_window 时收集;去重;0 / NO_PICK 视为无。 -static func forced_name_vids(is_picked_window: bool, pick_char_vid: int, target_vid: int) -> Array: - var out: Array = [] - if not is_picked_window: - return out - if pick_char_vid != NO_PICK and pick_char_vid != 0: - out.append(pick_char_vid) # :1537 - if target_vid != 0 and not out.has(target_vid): - out.append(target_vid) # :1539 - return out - -# :1543 —— 只有「鼠标在窗口上」且「非总显名字」时才 item.Pick() 拾取地面物品。 -static func should_pick_item(is_picked_window: bool, is_show_name: bool) -> bool: - return is_picked_window and not is_show_name - -# :1552 —— 总显名字(__IsShowName())时才 ShowAllTextTail() 全量显名。 -static func should_show_all_names(is_show_name: bool) -> bool: - return is_show_name - -# :1554 —— 总显名字时,物品拾取改从已显的尾标里 textTail.Pick(x, y)。 -static func should_pick_text_tail(is_show_name: bool) -> bool: - return is_show_name - -# :1560 —— 拾取到物品尾标(PickingItemIndex != -1)才 SelectItemName 高亮其名。 -static func should_select_item_name(pick_item_index: int) -> bool: - return pick_item_index != NO_PICK diff --git a/project/pick_show.gd.uid b/project/pick_show.gd.uid deleted file mode 100644 index ef3c3dad..00000000 --- a/project/pick_show.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://t763fdrluvos diff --git a/project/pick_show_test.gd b/project/pick_show_test.gd deleted file mode 100644 index 9f4cba9a..00000000 --- a/project/pick_show_test.gd +++ /dev/null @@ -1,69 +0,0 @@ -# pick_show_test —— §8.8 / §5.4 game.py OnRender「Picking」块分派 1:1: -# IsPickedWindow 门、chr.Pick() 强显、targetBoard 强显、去重、__IsShowName() 分叉 -# (非总显名字 -> item.Pick;总显名字 -> ShowAllTextTail + textTail.Pick)、 -# SelectItemName 门。 -# godot --headless --path project --script pick_show_test.gd -# 退出码 0 = 全过。 -extends SceneTree - -const PickShow = preload("res://pick_show.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: pick_show_test (§8.8/§5.4 OnRender Picking 分派 1:1)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var NP := PickShow.NO_PICK - _ck(NP == -1, "NO_PICK == -1") - - # --- 0. char_pick_active(game.py:1533)------------------------------------ - _ck(PickShow.char_pick_active(true) == true, "鼠标在窗口 -> chr.Pick() 跑") - _ck(PickShow.char_pick_active(false) == false, "鼠标不在窗口 -> chr.Pick() 不跑") - - # --- 1. forced_name_vids(:1533..:1540 ShowCharacterTextTail 集合)-------- - # 不在窗口 -> 空(连 target 强显也不做,参考里都在 :1533 门内) - _ck(PickShow.forced_name_vids(false, 2000, 1500) == [], "不在窗口 -> 无强显") - # 悬停命中 + 有目标框 -> 两个都强显,顺序 [hover, target] - _ck(PickShow.forced_name_vids(true, 2000, 1500) == [2000, 1500], "悬停+目标 -> [hover, target]") - # 只悬停命中 - _ck(PickShow.forced_name_vids(true, 2000, 0) == [2000], "只悬停 -> [hover]") - # 只有目标框 - _ck(PickShow.forced_name_vids(true, NP, 1500) == [1500], "只目标框 -> [target]") - _ck(PickShow.forced_name_vids(true, 0, 1500) == [1500], "hover=0 视为无 -> [target]") - # 悬停的正是目标 -> 去重 - _ck(PickShow.forced_name_vids(true, 1500, 1500) == [1500], "悬停==目标 -> 去重成一个") - # 啥也没有 - _ck(PickShow.forced_name_vids(true, NP, 0) == [], "在窗口但无命中无目标 -> 空") - - # --- 2. should_pick_item(:1543)---------------------------------------- - _ck(PickShow.should_pick_item(true, false) == true, "在窗口 且 非总显名字 -> item.Pick()") - _ck(PickShow.should_pick_item(true, true) == false, "总显名字 -> 不走 item.Pick()") - _ck(PickShow.should_pick_item(false, false) == false, "不在窗口 -> 不走 item.Pick()") - - # --- 3. should_show_all_names / should_pick_text_tail(:1552 / :1554)-- - _ck(PickShow.should_show_all_names(true) == true, "总显名字 -> ShowAllTextTail") - _ck(PickShow.should_show_all_names(false) == false, "非总显名字 -> 不 ShowAllTextTail") - _ck(PickShow.should_pick_text_tail(true) == true, "总显名字 -> textTail.Pick()") - _ck(PickShow.should_pick_text_tail(false) == false, "非总显名字 -> 不 textTail.Pick()") - - # item 拾取的两条路互斥:非总显名字走 item.Pick,总显名字走 textTail.Pick - for isn in [true, false]: - var via_item := PickShow.should_pick_item(true, isn) - var via_tail := PickShow.should_pick_text_tail(isn) - _ck(via_item != via_tail, "在窗口时 item.Pick 与 textTail.Pick 恰好二选一(is_show_name=%s)" % isn) - - # --- 4. should_select_item_name(:1560)------------------------------ - _ck(PickShow.should_select_item_name(7) == true, "PickingItemIndex=7 -> SelectItemName") - _ck(PickShow.should_select_item_name(0) == true, "PickingItemIndex=0(有效索引)-> SelectItemName") - _ck(PickShow.should_select_item_name(NP) == false, "PickingItemIndex=-1 -> 不 SelectItemName") diff --git a/project/pick_show_test.gd.uid b/project/pick_show_test.gd.uid deleted file mode 100644 index 8811753d..00000000 --- a/project/pick_show_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b6slci25nqllo diff --git a/project/playable_adapter_test.gd b/project/playable_adapter_test.gd deleted file mode 100644 index 16a1b6e2..00000000 --- a/project/playable_adapter_test.gd +++ /dev/null @@ -1,56 +0,0 @@ -extends SceneTree - -const NetPlay = preload("res://net_play.gd") - -class FakeClient extends Node: - signal entity_main_set(vid: int) - signal entity_moved(vid: int) - signal points_changed(points: Dictionary) - signal vitals_changed(vid: int) - signal target_info(vid: int, hp_percent: int) - signal entity_despawned(vid: int) - signal entity_dead(vid: int) - var in_game := false - var target := -1 - var entities := {} - func is_in_game() -> bool: return in_game - func get_entity(vid: int) -> Dictionary: return entities.get(vid, {}) - func set_target(vid: int) -> bool: - target = vid - return true - -class FakePlayerController extends Node: - signal target_selected(node: Node3D) - signal moved(pos: Vector3) - signal anim_state(state: String) - var player: Node3D - -var failures := 0 - -func _initialize() -> void: - call_deferred("run") - -func check(ok: bool, message: String) -> void: - if not ok: - failures += 1 - printerr("FAIL: " + message) - -func run() -> void: - var client := FakeClient.new() - var pc := FakePlayerController.new() - var nw := Node.new() - root.add_child(client) - root.add_child(pc) - root.add_child(nw) - var play := NetPlay.new() - root.add_child(play) - play.setup(client, pc, nw) - client.entities[10] = {"vid": 10, "kind": 2, "race": 2301, "dead": false, "hp": 100, "max_hp": 100} - check(not play.select_target(10), "target selection is blocked while disconnected") - client.in_game = true - check(play.select_target(10), "target selection uses production target path") - check(client.target == 10, "target selection sends the selected VID") - check(not play.select_target(999), "missing target is rejected") - check(client.target == 0, "invalid target clears server target") - print("playable_adapter_test: failures=%d" % failures) - quit(1 if failures else 0) diff --git a/project/playable_adapter_test.gd.uid b/project/playable_adapter_test.gd.uid deleted file mode 100644 index 0a9397c2..00000000 --- a/project/playable_adapter_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://4f7wb4a2a7x2 diff --git a/project/playable_combat_test.gd b/project/playable_combat_test.gd deleted file mode 100644 index 84cc2f94..00000000 --- a/project/playable_combat_test.gd +++ /dev/null @@ -1,359 +0,0 @@ -extends SceneTree - -## CBT-01 离线事件与输入断言:真实 Quickbar + PlayerSkill + SkillTable 发出的请求, -## 经真实 PlayableProbe / PlayableFlow 归档。只有假客户端记录请求,不连服务器。 -## -## godot --headless --path project --script playable_combat_test.gd -## -## 覆盖 §7.2:拒绝不发有效施法请求、正常请求不重复、无目标、超距、冷却、资源不足、 -## 目标死亡、自身死亡、断线/切图、施法中换目标。 - -const SkillTable = preload("res://ui/skill_table.gd") -const Quickbar = preload("res://ui/quickbar.gd") -const Probe = preload("res://testing/playable_probe.gd") -const Flow = preload("res://testing/playable_flow.gd") -const Report = preload("res://testing/playable_report.gd") -const Config = preload("res://testing/playable_config.gd") -const Matrix = preload("res://playable_skill_matrix.gd") - -const MAIN := 1000 -const MOB := 2000 -const MOB_B := 2001 -const MOB_RACE := 101 -## Warrior "Three-Way Cut": attack skill, needs a target, TargetRange 400cm. -const SKILL := 1 -const SLOT := 7 - -class FakeClient extends Node: - signal skill_cooldown_end(skill: int) - signal quickslots_changed() - signal skills_changed() - signal entity_dead(vid: int) - signal damage(vid: int, amount: int, flag: int) - signal disconnected(reason: String) - signal entered_game() - var target := 0 - var in_game := true - var entities := { - MAIN: {"vid": MAIN, "kind": 0, "ch_type": 0, "pos_cm": Vector3.ZERO, "dead": false}, - MOB: {"vid": MOB, "kind": 2, "ch_type": 2, "race": MOB_RACE, "pos_cm": Vector3(300, 0, 0), "dead": false, "hp": 100}, - MOB_B: {"vid": MOB_B, "kind": 2, "ch_type": 2, "race": MOB_RACE, "pos_cm": Vector3(350, 0, 0), "dead": false, "hp": 100}, - } - var skills := [{"id": SKILL, "level": 5, "master": 0}] - ## Every outgoing request, in order: [name, args...]. - var requests: Array = [] - func is_in_game() -> bool: return in_game - func get_main_vid() -> int: return MAIN - func get_entity(vid) -> Dictionary: return entities.get(int(vid), {}) - func get_entities() -> Array: return entities.values() - func get_ground_items() -> Array: return [] - func get_target() -> Dictionary: return {"vid": target} - func get_skills() -> Array: return skills - func get_inventory() -> Array: return [] - func get_quickslots() -> Array: return [] - func use_skill(id, vid) -> bool: - requests.append(["use_skill", int(id), int(vid)]) - return in_game - func cast_skill(motion, rot, x, y, _arg = 0) -> bool: - requests.append(["cast_skill", int(motion)]) - return in_game - func quickslot_add(pos, type, ref) -> bool: - requests.append(["quickslot_add", pos, type, ref]) - return true - -## Only the NetPlay surface Quickbar/PlayableFlow touch. Range reservation itself -## (walk in, then use_skill_hook) is covered by netplay_test. -class FakeNetPlay extends Node: - var client: FakeClient - var context := {} - var reservations: Array = [] - func select_target(vid: int) -> bool: - client.target = vid - return not client.get_entity(vid).is_empty() - func skill_context() -> Dictionary: - return context.merged({"target_distance": Callable(self, "skill_target_distance_cm")}) - func skill_target_distance_cm(vid: int) -> float: - var e: Dictionary = client.get_entity(vid) - var me: Dictionary = client.get_entity(MAIN) - if e.is_empty() or me.is_empty(): - return -1.0 - return Vector3(e.pos_cm).distance_to(Vector3(me.pos_cm)) - func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void: - reservations.append([vid, slot, int(range_cm)]) - func is_use_skill_reserved(slot: int) -> bool: - return not reservations.is_empty() and int(reservations[-1][1]) == slot - func set_attack_key(_down: bool) -> void: - pass - -class FakeApp extends Node: - var context := {} - func get_playable_context() -> Dictionary: return context - func get_playable_snapshot() -> Dictionary: return {"scene_ready": true} - -class Rig: - var client: FakeClient - var net_play: FakeNetPlay - var app: FakeApp - var quickbar: Node - var ui: Control - var player: Node3D - var probe: Node - var report: RefCounted - var flow: RefCounted - var events: Array = [] - var now := 1_000_000 - func observed(kind: String) -> Array: - return events.filter(func(e: Array) -> bool: return e[0] == kind) - func count(name: String) -> int: - return client.requests.filter(func(r: Array) -> bool: return r[0] == name).size() - -var failures := 0 -var table: RefCounted -var _rigs: Array = [] - -func _init() -> void: - call_deferred("run") - -func check(ok: bool, message: String) -> void: - if not ok: - failures += 1 - printerr("FAIL: " + message) - -func run() -> void: - table = SkillTable.new() - var loaded := false - for lang in ["en", "common"]: - if table.load_file(AssetRoot.path().path_join("locale/locale/%s/skilldesc.txt" % lang)): - loaded = true - break - if not loaded: - # Missing skill data makes every assertion meaningless; never report a silent pass. - check(false, "skilldesc.txt is required for playable_combat_test") - else: - check(table.target_range(SKILL) == 400 and table.is_need_target(SKILL), "fixture skill 1 is a 400cm targeted attack") - test_single_request_per_activation() - test_cooldown_holds_without_request() - test_no_target_rejected_without_request() - test_out_of_range_reserves_without_request() - test_not_enough_sp_rejected_without_request() - test_dead_target_rejected_without_request() - test_dead_self_holds_without_request() - test_disconnect_fails_case_and_stops_requests() - test_target_switch_keeps_cast_target() - test_skill_matrix() - for rig in _rigs: - rig.probe.disconnect_all() - rig.flow.clock_us = Callable() - rig.flow = null - for node in [rig.quickbar, rig.ui, rig.player, rig.probe, rig.net_play, rig.app, rig.client]: - node.free() - _rigs.clear() - print("playable_combat_test: failures=%d" % failures) - quit(1 if failures else 0) - -func make_rig(repeats := 2) -> Rig: - var rig := Rig.new() - rig.client = FakeClient.new() - root.add_child(rig.client) - rig.net_play = FakeNetPlay.new() - rig.net_play.client = rig.client - rig.net_play.context = {"cur_sp": 500, "cur_hp": 500} - rig.player = Node3D.new() - root.add_child(rig.player) - rig.ui = Control.new() - root.add_child(rig.ui) - rig.quickbar = Quickbar.new() - rig.quickbar.net_play = rig.net_play - root.add_child(rig.quickbar) - rig.quickbar.setup(rig.client, table, rig.ui, func() -> Node: return rig.player) - rig.app = FakeApp.new() - rig.app.context = {"quickbar": rig.quickbar, "net_play": rig.net_play} - rig.probe = Probe.new() - root.add_child(rig.probe) - rig.probe.setup(rig.app, rig.client) - rig.probe.watch_local(rig.quickbar, "skill_cast_started", "cast_started") - rig.probe.watch_local(rig.quickbar, "skill_rejected", "skill_rejected") - rig.probe.observed.connect(func(kind: String, data: Dictionary) -> void: rig.events.append([kind, data])) - var config := {"allowed_mob_vnums": [MOB_RACE], "timeout_seconds": 900, - "skill_cases": [{"case_id": "three-way-cut", "skill_id": SKILL, "target": "enemy", - "repeats": repeats, "required_evidence": ["cast_started", "damage"]}]} - rig.report = Report.new() - rig.report.begin("combat-%d" % _rigs.size(), "full", {}) - rig.report.set_required_cases(["CBT-three-way-cut"]) - rig.flow = Flow.new() - rig.flow.report = rig.report - rig.flow.probe = rig.probe - rig.flow.flow = rig.app - rig.flow.client = rig.client - rig.flow.config = config - rig.flow.suite = "full" - rig.flow.allow_gameplay = true - rig.flow.clock_us = func() -> int: return rig.now - rig.probe.observed.connect(rig.flow.on_observed) - # Enter the skill stage the way the closed loop leaves PICKUP: bound main + epoch. - rig.flow._start_us = rig.now - rig.flow._main_vid = MAIN - rig.flow._bound_epoch = rig.probe.connection_epoch - rig.flow._begin_skill_case() - _rigs.append(rig) - return rig - -func tick(rig: Rig, advance_us := 0) -> void: - rig.now += advance_us - rig.flow.tick() - -# --- input assertions ---------------------------------------------------------- - -func test_single_request_per_activation() -> void: - var rig := make_rig() - check(rig.client.requests.is_empty(), "assigning the test slot sends no CG_QUICKSLOT_ADD") - tick(rig) - check(rig.count("use_skill") == 1 and rig.count("cast_skill") == 1, - "one activation sends exactly one use_skill and one cast_skill: %s" % [rig.client.requests]) - check(rig.client.requests[0] == ["cast_skill", 1] - and rig.client.requests[1] == ["use_skill", SKILL, MOB], - "40250 order: FUNC_SKILL action state precedes CG_USE_SKILL target intent: %s" % [rig.client.requests]) - var casts := rig.observed("cast_started") - check(casts.size() == 1 and int(casts[0][1].target_vid) == MOB and casts[0][1].source == "local", - "cast_started is a local event bound to the cast target") - tick(rig, 100_000) - check(rig.count("use_skill") == 1, "a pending cast is not re-requested while waiting for server evidence") - -func test_cooldown_holds_without_request() -> void: - var rig := make_rig() - tick(rig) - rig.client.damage.emit(MOB, 30, 1) - tick(rig) - check(rig.report.case_status("CBT-three-way-cut") == "" and rig.flow._skill_successes == 1, "first cast verified by server damage") - tick(rig, 600_000) - check(rig.count("use_skill") == 1 and rig.observed("skill_rejected").is_empty(), - "local cooldown holds the retry without a request or a rejection event") - rig.client.skill_cooldown_end.emit(SKILL) - tick(rig, 600_000) - check(rig.count("use_skill") == 2 and rig.count("cast_skill") == 2, "GC_SKILL_COOLTIME_END unlocks exactly one new request") - -func test_no_target_rejected_without_request() -> void: - var rig := make_rig() - rig.client.target = 0 - rig.quickbar.activate(SLOT) - var rejected := rig.observed("skill_rejected") - check(rejected.size() == 1 and rejected[0][1].code == "NEED_TARGET", "no target -> NEED_TARGET") - check(rig.client.requests.is_empty(), "NEED_TARGET sends no skill request") - -func test_out_of_range_reserves_without_request() -> void: - var rig := make_rig() - rig.client.entities[MOB].pos_cm = Vector3(900, 0, 0) - rig.client.entities[MOB_B].pos_cm = Vector3(950, 0, 0) - tick(rig) - check(rig.count("use_skill") == 0 and rig.count("cast_skill") == 0, "target beyond TargetRange sends no skill request") - check(rig.net_play.reservations.size() == 1 and rig.net_play.reservations[0][0] == MOB, - "out-of-range cast becomes a MODE_USE_SKILL reservation: %s" % [rig.net_play.reservations]) - check(rig.observed("skill_rejected").is_empty() and rig.observed("cast_started").is_empty(), - "reservation is neither a rejection nor a cast") - # NetPlay walked into range and fired use_skill_hook -> activate_reserved. - rig.client.entities[MOB].pos_cm = Vector3(300, 0, 0) - var global_slot: int = rig.net_play.reservations[0][1] if not rig.net_play.reservations.is_empty() else SLOT - check(bool(rig.quickbar.activate_reserved(global_slot)), "reserved skill fires once in range") - check(rig.count("use_skill") == 1 and rig.observed("cast_started").size() == 1, "in-range reserved cast sends exactly one request") - rig.client.damage.emit(MOB, 30, 1) - tick(rig) - check(rig.flow._skill_successes == 1, "the asynchronous reserved cast is credited to the case") - -func test_not_enough_sp_rejected_without_request() -> void: - var rig := make_rig() - rig.net_play.context = {"cur_sp": 10, "cur_hp": 500} - tick(rig) - var rejected := rig.observed("skill_rejected") - check(rejected.size() == 1 and rejected[0][1].code == "NOT_ENOUGH_SP", "SP below GetNeedSP -> NOT_ENOUGH_SP") - check(rig.client.requests.is_empty(), "NOT_ENOUGH_SP sends no skill request") - check(rig.report.case_status("CBT-three-way-cut") == "", "resource shortage is retried, not a fixture BLOCKED") - -func test_dead_target_rejected_without_request() -> void: - var rig := make_rig() - rig.client.entities[MOB].dead = true - rig.client.target = MOB - rig.quickbar.activate(SLOT) - var rejected := rig.observed("skill_rejected") - check(rejected.size() == 1 and rejected[0][1].code == "CANNOT_ATTACK", "dead target -> CANNOT_ATTACK") - check(rig.client.requests.is_empty(), "dead target sends no skill request") - tick(rig, 600_000) - check(rig.client.requests.size() == 2 - and rig.client.requests[0] == ["cast_skill", 1] - and rig.client.requests[1] == ["use_skill", SKILL, MOB_B], - "flow re-targets a living monster instead of the corpse: %s" % [rig.client.requests]) - -func test_dead_self_holds_without_request() -> void: - var rig := make_rig() - rig.client.entities[MAIN].dead = true - tick(rig) - check(rig.client.requests.is_empty() and rig.observed("skill_rejected").is_empty(), "dead main character sends nothing") - rig.client.entities[MAIN].dead = false - rig.net_play.context = {"cur_sp": 500, "can_act": false} - tick(rig, 600_000) - check(rig.client.requests.is_empty(), "stunned/knocked-down (CANNOT_ACT) sends nothing") - -func test_disconnect_fails_case_and_stops_requests() -> void: - var rig := make_rig() - tick(rig) - check(rig.count("use_skill") == 1, "cast requested before the disconnect") - rig.client.in_game = false - rig.client.disconnected.emit("socket closed") - check(rig.report.case_status("CBT-three-way-cut") == "FAIL" and rig.flow.done, "disconnect during a skill case fails that case") - # Server damage after reconnect belongs to the new epoch and the finished case. - rig.client.in_game = true - rig.client.entered_game.emit() - rig.client.damage.emit(MOB, 30, 1) - tick(rig, 600_000) - check(rig.count("use_skill") == 1 and rig.flow._skill_successes == 0, "no request or credit after the disconnect") - -func test_target_switch_keeps_cast_target() -> void: - var rig := make_rig(1) - tick(rig) - check(rig.observed("cast_started").size() == 1 and int(rig.observed("cast_started")[0][1].target_vid) == MOB, "cast bound to target A") - rig.net_play.select_target(MOB_B) - rig.client.damage.emit(MOB_B, 30, 1) - tick(rig) - check(rig.flow._skill_successes == 0, "damage on the newly selected target B is not evidence for the cast on A") - rig.client.damage.emit(MOB, 30, 1) - tick(rig) - check(rig.flow._skill_successes == 1 and rig.report.case_status("CBT-three-way-cut") == "PASS", - "damage on the cast-time target A completes the case") - check(rig.count("use_skill") == 1, "switching the selection never re-sends the cast") - -# --- test/playable/skill-cases.json --------------------------------------------- - -func test_skill_matrix() -> void: - var committed := ProjectSettings.globalize_path("res://").path_join("../test/playable/skill-cases.json").simplify_path() - for job in Config.SKILL_MATRIX_JOBS: - var r: Dictionary = Matrix.check(committed, job) - check(r.status == "BLOCKED" and r.errors.is_empty() and r.blocked.size() == 4 and r.skill_cases.is_empty(), - "committed matrix carries no invented skill ids and blocks %s: %s" % [job, r]) - var evidence := ["cast_started", "damage"] - var matrix := {"schema_version": 1, "jobs": {"WARRIOR": { - "single_target": {"status": "confirmed", "skill_id": SKILL, "target": "enemy", "required_evidence": evidence}, - "area": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"}, - "self_buff": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"}, - "flying": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"}}}} - var ok: Dictionary = Config.skill_matrix_cases(matrix, "WARRIOR", table) - check(ok.errors.is_empty() and ok.blocked.is_empty() and ok.cases.size() == 1, "confirmed matrix resolves: %s" % [ok]) - if ok.cases.size() == 1: - check(ok.cases[0].case_id == "warrior-single-target" and int(ok.cases[0].repeats) == Config.SKILL_MATRIX_REPEATS, - "matrix case defaults to 10 successful casts") - check("CBT-warrior-single-target" in Config.required_cases({"skill_cases": ok.cases}, "full"), - "matrix case becomes a required full-suite case") - var bad := [ - ["unconfirmed entry with a skill id", "area", {"status": "unconfirmed", "skill_id": 3}], - ["skill from another job", "single_target", {"status": "confirmed", "skill_id": 32, "required_evidence": evidence}], - ["fewer than 10 casts", "single_target", {"status": "confirmed", "skill_id": SKILL, "repeats": 3, "required_evidence": evidence}], - ["local-only evidence", "single_target", {"status": "confirmed", "skill_id": SKILL, "required_evidence": ["cast_started", "fx_spawned"]}], - ["not applicable without checklist record", "area", {"status": "not_applicable"}], - ["unknown status", "flying", {"status": "skip"}], - ] - for row in bad: - var mutated: Dictionary = matrix.duplicate(true) - mutated.jobs.WARRIOR[row[1]] = row[2] - check(not Config.skill_matrix_cases(mutated, "WARRIOR", table).errors.is_empty(), "matrix rejects %s" % row[0]) - var none: Dictionary = matrix.duplicate(true) - none.jobs.WARRIOR.single_target = {"status": "not_applicable", "checklist_ref": "x"} - check(not Config.skill_matrix_cases(none, "WARRIOR", table).errors.is_empty(), "a job with nothing confirmed is not READY") - check(not Config.skill_matrix_cases(matrix, "SURA", table).errors.is_empty(), "a job absent from the matrix is an error") diff --git a/project/playable_combat_test.gd.uid b/project/playable_combat_test.gd.uid deleted file mode 100644 index 5ca98dae..00000000 --- a/project/playable_combat_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://culkkh1gryrw diff --git a/project/playable_config_gate.gd b/project/playable_config_gate.gd deleted file mode 100644 index 3bfec386..00000000 --- a/project/playable_config_gate.gd +++ /dev/null @@ -1,75 +0,0 @@ -extends SceneTree - -## Parent-runner precondition gate. Runs headless before the packaged client -## starts; it never opens a network connection or reads credentials. -## -## env: -## MT_PLAYABLE_VALIDATE_CONFIG scenario JSON (required) -## MT_PLAYABLE_SUITE playable|full|soak (default playable) -## MT_PLAYABLE_ASSETS asset root used for map/waypoint checks -## MT_PLAYABLE_SERVERLIST test-mode serverlist override (same value is -## handed to the client, so both resolve alike) -## MT_PLAYABLE_GATE_OUTPUT directory for required-cases.json / server-address.json -## exit: 0 = ready, 2 = configuration/precondition blocked. - -const Config = preload("res://testing/playable_config.gd") -const ServerInfoRes = preload("res://net/serverinfo.gd") - -func _init() -> void: - var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG") - var suite := OS.get_environment("MT_PLAYABLE_SUITE").strip_edges() - if suite.is_empty(): - suite = "playable" - var errors: Array = [] - if not (suite in Config.SUITES): - errors.append("unknown suite: %s" % suite) - var result := Config.load_file(path) - errors.append_array(result.errors) - # A present soak block is validated for every suite: its confirmed fault proxy also - # fronts the playable exit runs that share the scenario. - if result.ok and (suite == "soak" or result.config.has("soak")): - errors.append_array(Config.validate_soak(result.config, suite == "soak").errors) - var address := {} - var bounds := {} - if result.ok: - var serverinfo := ServerInfoRes.new() - var override := OS.get_environment("MT_PLAYABLE_SERVERLIST").strip_edges() - if not override.is_empty(): - if not serverinfo.load_file(override): - errors.append("MT_PLAYABLE_SERVERLIST cannot be loaded") - else: - serverinfo.load_file("res://serverlist.txt") - var resolved := Config.resolve_server(result.config, serverinfo) - errors.append_array(resolved.errors) - address = resolved.address - var assets := OS.get_environment("MT_PLAYABLE_ASSETS").strip_edges() - if assets.is_empty(): - errors.append("MT_PLAYABLE_ASSETS is not set; resource preconditions cannot be checked") - else: - var resources := Config.check_resources(result.config, assets) - errors.append_array(resources.errors) - bounds = resources.bounds - if not errors.is_empty(): - for error in errors: - printerr("CONFIG: " + String(error)) - quit(2) - return - var out_dir := OS.get_environment("MT_PLAYABLE_GATE_OUTPUT").strip_edges() - if not out_dir.is_empty(): - var cases := Config.required_cases(result.config, suite) - if not _write_json(out_dir.path_join("required-cases.json"), {"schema_version": 1, "suite": suite, "cases": cases}) \ - or not _write_json(out_dir.path_join("server-address.json"), address) \ - or not _write_json(out_dir.path_join("map-bounds.json"), bounds): - printerr("CONFIG: cannot write gate output") - quit(2) - return - print("PLAYABLE CONFIG: PASS") - quit(0) - -func _write_json(path: String, value: Variant) -> bool: - var file := FileAccess.open(path, FileAccess.WRITE) - if file == null: - return false - file.store_string(JSON.stringify(value, " ") + "\n") - file.close() - return true diff --git a/project/playable_config_gate.gd.uid b/project/playable_config_gate.gd.uid deleted file mode 100644 index 63f2a204..00000000 --- a/project/playable_config_gate.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dl1g3ph6de13v diff --git a/project/playable_harness_test.gd b/project/playable_harness_test.gd deleted file mode 100644 index 9f197a37..00000000 --- a/project/playable_harness_test.gd +++ /dev/null @@ -1,1250 +0,0 @@ -extends SceneTree - -## INF-01 / NET-01 / CBT-01 离线门禁:配置契约、报告契约,以及用假客户端驱动 -## 真实 PlayableProbe + PlayableFlow 的正反例。时间由注入时钟推进,不等待真实超时。 - -const Config = preload("res://testing/playable_config.gd") -const Report = preload("res://testing/playable_report.gd") -const Probe = preload("res://testing/playable_probe.gd") -const Flow = preload("res://testing/playable_flow.gd") -const Metrics = preload("res://testing/playable_metrics.gd") - -const MAIN := 1001 -const MOB := 2001 -const MOB_RACE := 101 -const DROP_VNUM := 19 -const SKILL_ID := 5 - -class FakeClient extends Node: - signal login_failed(reason: String) - signal char_list(chars: Array) - signal entered_game() - signal disconnected(reason: String) - signal entity_main_set(vid: int) - signal entity_spawned(entity: Dictionary) - signal entity_despawned(vid: int) - signal entity_moved(vid: int) - signal entity_dead(vid: int) - signal damage(vid: int, amount: int, flag: int) - signal motion(vid: int, victim_vid: int, motion: int) - signal vitals_changed(vid: int) - signal points_changed(points: Dictionary) - signal target_info(vid: int, hp_percent: int) - signal ground_item_added(item: Dictionary) - signal ground_item_removed(vid: int) - signal item_picked_up(vnum: int, count: int, from: String) - signal inventory_changed(window: int, cell: int) - signal affect_added(affect: Dictionary) - signal effect_cue(vid: int, name: String, special: int) - signal fly_cue(kind: int, start_vid: int, end_vid: int) - var main_vid := 0 - var entities := {} - var ground: Array = [] - var inventory: Array = [] - var reconnect_ok := true - var reconnect_calls := 0 - var calls: Array = [] - func is_in_game() -> bool: return main_vid > 0 - func get_main_vid() -> int: return main_vid - func get_entity(vid: int) -> Dictionary: return entities.get(vid, {}) - func get_entities() -> Array: return entities.values() - func get_ground_items() -> Array: return ground.duplicate() - func get_inventory() -> Array: return inventory.duplicate() - func reconnect() -> bool: - reconnect_calls += 1 - calls.append("reconnect") - return reconnect_ok - -class FakeHud extends Node: - pass - -class FakePC extends Node: - var player: Node3D - var client: FakeClient - var walks: Array = [] - var move_on_walk := true - func walk_to(world_pos: Vector3) -> void: - walks.append(world_pos) - if move_on_walk: - player.position = world_pos - var cm := MapCoord.to_server_cm(world_pos) - client.entities[MAIN].pos_cm = Vector3(cm.x, cm.y, 0) - -class FakeNetPlay extends Node: - var client: FakeClient - var attack_states: Array = [] - var selected: Array = [] - var picks: Array = [] - var _target_vid := 0 - var _attack_key_down := false - func select_target(vid: int) -> bool: - selected.append(vid) - client.calls.append("select_target") - var e: Dictionary = client.get_entity(vid) - return not e.is_empty() and not bool(e.get("dead", false)) - func set_attack_key(down: bool) -> void: - attack_states.append(down) - client.calls.append("attack:%s" % down) - func pick_ground_item(iid: int) -> void: - picks.append(iid) - client.calls.append("pick") - -class FakeQuickbar extends Node: - signal skill_cast_started(skill_id: int, target_vid: int) - signal skill_rejected(skill_id: int, code: String) - var client: FakeClient - var net_play: FakeNetPlay - var assigned: Array = [] - var activations := 0 - var reject_code := "" - func assign(slot: int, kind: String, id: int, persist := true) -> bool: - assigned.append([slot, kind, id, persist]) - return true - func activate(_slot: int) -> void: - activations += 1 - var skill: int = int(assigned[-1][2]) - if not reject_code.is_empty(): - skill_rejected.emit(skill, reject_code) - return - skill_cast_started.emit(skill, int(net_play.selected[-1]) if not net_play.selected.is_empty() else 0) - -## Same lifecycle signals as fx/effect_registry.gd. -class FakeFx extends RefCounted: - signal fx_spawned(effect: String, fx_id: int, lifetime_ms: int) - signal fx_finished(effect: String, fx_id: int, lifetime_ms: int, elapsed_ms: int, reason: String) - -class FakeAppFlow extends Node: - var address := {} - var scene_ready := false - var map_key := "" - var context := {} - func select_test_server(_index: int, _channel: int) -> Dictionary: return address - func get_playable_snapshot() -> Dictionary: - return {"stage": "GAME", "scene_ready": scene_ready, "map_key": map_key, "character_count": 1, "reconnecting": false} - func get_playable_context() -> Dictionary: return context - -## Same registry shape as net_world.gd: entity nodes under `parent` with meta "vid". -class FakeNetWorld extends Node: - var parent := Node3D.new() - var _fading := {} - func _init() -> void: - add_child(parent) - func add_entity_node(vid: int) -> Node3D: - var node := Node3D.new() - node.set_meta("vid", vid) - parent.add_child(node) - return node - -## Window adapter contract used by STB-01 resize (testing/playable_window.gd is the live one). -class FakeWindow extends RefCounted: - var supported := true - var reason := "" - var scale := 2.0 - var logical := Vector2i(1280, 720) - var max_logical := Vector2i(4000, 4000) - var stuck := false - var requests: Array = [] - func availability() -> Dictionary: return {"ok": supported, "reason": reason} - func request_logical_size(size: Vector2i) -> bool: - requests.append(size) - if size.x > max_logical.x or size.y > max_logical.y: - return false - if not stuck: - logical = size - return true - func logical_size() -> Vector2i: return logical - func physical_size() -> Vector2i: return Vector2i(roundi(logical.x * scale), roundi(logical.y * scale)) - func viewport_size() -> Vector2i: return logical - func screen_scale() -> float: return scale - -class Rig: - var client: FakeClient - var app: FakeAppFlow - var pc: FakePC - var net_play: FakeNetPlay - var quickbar: FakeQuickbar - var fx: FakeFx - var net_world: FakeNetWorld - var window: FakeWindow - var probe: Node - var report: RefCounted - var flow: RefCounted - var now := 1_000_000 - var events_path := "" - var status := "" - func advance(us: int) -> void: - now += us - flow.tick() - func step(times := 1) -> void: - for i in times: - flow.tick() - -var failures := 0 -var _rigs: Array = [] - -func _init() -> void: - call_deferred("run") - -func check(ok: bool, message: String) -> void: - if not ok: - failures += 1 - printerr("FAIL: " + message) - -func valid_config() -> Dictionary: - return { - "schema_version": 1, "scenario_id": "harness", "protocol": "classic", - "server": {"server_index": 0, "channel": 1, "auth_host": "127.0.0.1", "auth_port": 11000, - "game_host": "127.0.0.1", "game_port": 13002}, - "character_slot": 2, "map_key": "outdoortrent/metin2_map_trent", - "waypoints_cm": [[1000, 2000], [1600, 2000]], - "allowed_mob_vnums": [MOB_RACE], "allowed_drop_vnums": [DROP_VNUM], - "skill_cases": [{"case_id": "strike", "skill_id": SKILL_ID, "target": "enemy", "repeats": 2, - "required_evidence": ["cast_started", "damage"]}], - "resolution": [1280, 720], "loops": 1, "timeout_seconds": 900, - } - -func test_probe_releases_old_registries() -> void: - var observer := Probe.new() - for i in range(30): - var registry := FakeFx.new() - var reference: WeakRef = weakref(registry) - observer.watch_local(registry, "fx_spawned", "fx_spawned") - observer.watch_local(registry, "fx_finished", "fx_finished") - check(observer.bound_count() == 2, "probe prunes old scene bindings") - registry = null - check(reference.get_ref() == null, "probe does not retain the old scene's effect registry") - observer.disconnect_all() - observer.free() - -func run() -> void: - MapCoord.set_base(Vector2.ZERO) - test_config_contract() - test_report_contract() - test_probe_releases_old_registries() - test_happy_path() - test_gameplay_not_allowed() - test_absent_slot_fails_without_select() - test_disconnect_during_select() - test_address_mismatch() - test_server_correction() - test_reconnect_unavailable_blocks_confirm() - test_stale_epoch_ignored() - test_target_despawn_releases_and_retargets() - test_attack_timeout_releases_key() - test_drop_taken_by_other() - test_inventory_full_timeout() - test_foreign_owner_drop_rejected() - test_local_only_skill_evidence_fails() - test_skill_not_learned_blocked() - test_skill_case_waits_for_effect_cleanup() - test_skill_screenshots_follow_effect_times() - test_effect_outliving_lifetime_fails() - test_effect_finished_late_fails() - test_effect_from_passed_case_fails_run() - test_looping_effect_not_timed() - test_soak_config_contract() - test_soak_rounds_until_wall_clock_duration() - test_soak_duration_is_wall_clock_not_rounds() - test_soak_later_round_failure() - test_soak_later_round_fixture_blocked() - test_soak_faults_confirmed() - test_soak_fault_without_disconnect_fails() - test_soak_warp_confirmed() - test_soak_warp_without_server_reconnect_fails() - test_soak_rebind_checks() - test_soak_resize_contract() - test_soak_frames_gate() - test_soak_gameplay_not_allowed() - for rig in _rigs: - rig.probe.disconnect_all() - for node in [rig.client, rig.app, rig.pc, rig.net_play, rig.quickbar, rig.net_world, rig.probe]: - node.free() - # Break Rig <-> flow lambda cycles so the run exits leak-free. - rig.flow.clock_us = Callable() - rig.flow.capture = Callable() - rig.flow.wall_ms = Callable() - if rig.flow.metrics != null: - rig.flow.metrics.clock_us = Callable() - rig.flow.metrics = null - rig.flow.window = null - rig.window = null - rig.fx = null - rig.flow = null - rig.report = null - _rigs.clear() - print("playable_harness_test: failures=%d" % failures) - quit(1 if failures else 0) - -# --- config / report contracts ---------------------------------------------------- - -func test_config_contract() -> void: - var example := Config.load_file(ProjectSettings.globalize_path("res://../test/playable/scenario.example.json")) - check(not example.ok and example.errors.size() > 0, "empty example configuration is rejected with reasons") - var valid := valid_config() - check(Config.validate(valid).ok, "complete configuration is accepted: %s" % [Config.validate(valid).errors]) - var old := valid.duplicate(true) - old.server = {"server_index": 0, "channel_index": 0} - check(not Config.validate(old).ok, "old server {server_index, channel_index} schema is rejected") - for key in ["password", "account", "login_token"]: - var secret := valid.duplicate(true) - secret[key] = "x" - check(not Config.validate(secret).ok, "top-level %s is rejected" % key) - var nested := valid.duplicate(true) - nested.server["user_password"] = "x" - check(not Config.validate(nested).ok, "nested credential key is rejected") - var gold := valid.duplicate(true) - gold.allowed_drop_vnums = [1, DROP_VNUM] - check(not Config.validate(gold).ok, "gold vnum is not a pickup fixture") - var local_only := valid.duplicate(true) - local_only.skill_cases[0].required_evidence = ["cast_started", "fx_spawned"] - check(not Config.validate(local_only).ok, "local-only skill evidence is rejected") - var dup := valid.duplicate(true) - dup.skill_cases.append(dup.skill_cases[0].duplicate(true)) - check(not Config.validate(dup).ok, "duplicate skill case_id is rejected") - var fractional := valid.duplicate(true) - fractional.waypoints_cm = [[1000.5, 2000]] - check(not Config.validate(fractional).ok, "fractional waypoint is rejected") - var escape := valid.duplicate(true) - escape.map_key = "../etc" - check(not Config.validate(escape).ok, "map_key path escape is rejected") - var cases := Config.required_cases(valid, "playable") - check(cases.size() == 11 and cases.has("NET-MOVE-02") and not cases.has("CBT-strike"), "playable required cases: %s" % [cases]) - check(Config.required_cases(valid, "full").has("CBT-strike"), "full suite adds skill cases") - - var info := ServerInfo.new() - check(Config.resolve_server(valid, info).ok == false, "built-in serverinfo address differs from the harness config") - var list := "user://playable-harness-serverlist.txt" - var f := FileAccess.open(list, FileAccess.WRITE) - f.store_string("fixture\t127.0.0.1\t11000\t127.0.0.1\t13002\t1,2\t10\t0\n") - f.close() - check(info.load_file(list), "fixture serverlist loads") - check(Config.resolve_server(valid, info).ok, "matching serverlist resolves") - var ch2 := valid.duplicate(true) - ch2.server.channel = 2 - check(not Config.resolve_server(ch2, info).ok, "channel 2 needs game_port + port_step") - ch2.server.game_port = 13012 - check(Config.resolve_server(ch2, info).ok, "channel 2 resolves with the stepped port") - - var assets := ProjectSettings.globalize_path("user://playable-harness-assets") - DirAccess.make_dir_recursive_absolute(assets.path_join(valid.map_key)) - f = FileAccess.open(assets.path_join(valid.map_key).path_join("Setting.txt"), FileAccess.WRITE) - f.store_string("ScriptType\tMapSetting\nBasePosition\t0\t0\nMapSize\t2\t2\n") - f.close() - check(Config.check_resources(valid, assets).ok, "waypoints inside Setting.txt bounds pass") - var outside := valid.duplicate(true) - outside.waypoints_cm = [[60000, 100]] - check(not Config.check_resources(outside, assets).ok, "waypoint outside map bounds is rejected") - var missing := valid.duplicate(true) - missing.map_key = "outdoortrent/missing" - check(not Config.check_resources(missing, assets).ok, "missing Setting.txt is rejected") - -func test_report_contract() -> void: - OS.set_environment("MT_ACCOUNT", "harness-acct") - OS.set_environment("MT_PASSWORD", "harness-pass") - var events := ProjectSettings.globalize_path("user://playable-harness-contract.jsonl") - if FileAccess.file_exists(events): - DirAccess.remove_absolute(events) - var report := Report.new() - report.begin("contract-run", "playable", valid_config(), events) - report.set_required_cases(["CONFIG-01", "NET-LOGIN-01"]) - report.add_case("CONFIG-01", "PASS", "validated") - var event: Dictionary = report.record("login_failed", "LOGIN", "NET-LOGIN-01", 1, 0, 0, - {"reason": "bad harness-acct / harness-pass", "name": "SomePlayer", "pos_cm": Vector2(1, 2)}) - check(not event.payload.has("name"), "non-whitelisted payload key is dropped") - check(String(event.payload.reason).find("harness-acct") < 0 and String(event.payload.reason).find("harness-pass") < 0, - "credential literals are redacted from payload strings") - check(event.payload.pos_cm == [1.0, 2.0], "vectors are serialized as arrays") - for key in Report.EVENT_KEYS: - check(event.has(key), "event has contract key " + key) - report.add_case("CONFIG-01", "PASS", "again") - var finished: Dictionary = report.finish() - check(finished.status == "FAIL", "duplicate case result fails the report") - check("NET-LOGIN-01" in finished.required_cases and str(finished.blocked).find("not_reached") >= 0, - "missing required case becomes BLOCKED not_reached") - check(finished.dropped_payload_keys.has("name"), "dropped payload keys are listed") - var blocked := Report.new() - blocked.begin("blocked-run", "playable", valid_config()) - blocked.set_required_cases(["CONFIG-01"]) - blocked.add_case("CONFIG-01", "BLOCKED", "fixture_not_ready") - check(blocked.finish().status == "BLOCKED", "blocked fixture cannot become PASS") - var empty := Report.new() - empty.begin("empty-run", "playable", {}) - check(empty.finish().status == "BLOCKED", "empty case list is never PASS") - var out := ProjectSettings.globalize_path("user://playable-harness-report.json") - check(report.write(out), "report can be serialized") - var text := FileAccess.get_file_as_string(out) - check(text.find("harness-acct") < 0 and text.find("harness-pass") < 0, "serialized report has no credential literal") - check(FileAccess.get_file_as_string(events).find("harness-pass") < 0, "events file has no credential literal") - var json: Variant = JSON.parse_string(text) - check(json is Dictionary and json.get("run_id", "") == "contract-run", "serialized report is readable") - OS.set_environment("MT_ACCOUNT", "") - OS.set_environment("MT_PASSWORD", "") - -# --- state machine rig -------------------------------------------------------------- - -func make_rig(suite := "playable", allow := true, config := {}) -> Rig: - var rig := Rig.new() - var cfg: Dictionary = config if not config.is_empty() else valid_config() - rig.client = FakeClient.new() - rig.app = FakeAppFlow.new() - rig.app.address = cfg.server.duplicate() - rig.app.map_key = cfg.map_key - var player := Node3D.new() - rig.pc = FakePC.new() - rig.pc.player = player - rig.pc.client = rig.client - rig.pc.add_child(player) - rig.net_play = FakeNetPlay.new() - rig.net_play.client = rig.client - rig.quickbar = FakeQuickbar.new() - rig.quickbar.client = rig.client - rig.quickbar.net_play = rig.net_play - rig.fx = FakeFx.new() - rig.net_world = FakeNetWorld.new() - rig.app.context = {"player": player, "pc": rig.pc, "net_play": rig.net_play, "hud": rig.app, "quickbar": rig.quickbar, "fx": rig.fx, - "net_world": rig.net_world} - rig.probe = Probe.new() - rig.probe.setup(rig.app, rig.client) - rig.probe.watch_local(rig.quickbar, "skill_cast_started", "cast_started") - rig.probe.watch_local(rig.quickbar, "skill_rejected", "skill_rejected") - rig.probe.watch_local(rig.fx, "fx_spawned", "fx_spawned") - rig.probe.watch_local(rig.fx, "fx_finished", "fx_finished") - rig.events_path = ProjectSettings.globalize_path("user://playable-harness-%d.jsonl" % _rigs.size()) - if FileAccess.file_exists(rig.events_path): - DirAccess.remove_absolute(rig.events_path) - rig.report = Report.new() - rig.report.begin("rig-%d" % _rigs.size(), suite, cfg, rig.events_path) - rig.flow = Flow.new() - rig.flow.report = rig.report - rig.flow.probe = rig.probe - rig.flow.flow = rig.app - rig.flow.client = rig.client - rig.flow.config = cfg - rig.flow.suite = suite - rig.flow.allow_gameplay = allow - rig.flow.clock_us = func() -> int: return rig.now - rig.flow.capture = func(case_id: String) -> String: return "%s.png" % case_id.to_lower() - rig.flow.finished.connect(func(s: String) -> void: rig.status = s) - rig.probe.observed.connect(rig.flow.on_observed) - _rigs.append(rig) - rig.flow.start() - return rig - -func enter_world(rig: Rig) -> void: - rig.client.char_list.emit([{"index": 0}, {"index": 2}]) - rig.client.main_vid = MAIN - rig.client.entities[MAIN] = {"vid": MAIN, "ch_type": 0, "name": "Hero", "pos_cm": Vector3(900, 2000, 0), "hp": 100, "dead": false} - rig.client.entered_game.emit() - rig.step() - rig.app.scene_ready = true - rig.step() - -func walk_route(rig: Rig) -> void: - rig.step(3) - -## Simulate the re-login: fast-path reconnect emits a second entered_game. -func confirm_position(rig: Rig) -> void: - rig.client.entered_game.emit() - rig.step() - -func spawn_mob(rig: Rig, vid := MOB) -> void: - rig.client.entities[vid] = {"vid": vid, "ch_type": 2, "race": MOB_RACE, "hp": 100, "max_hp": 100, "dead": false, "pos_cm": Vector3(1700, 2000, 0)} - -func kill_mob(rig: Rig, vid := MOB) -> void: - rig.client.damage.emit(vid, 40, 1) - rig.client.entities[vid].dead = true - rig.client.entity_dead.emit(vid) - rig.step() - -func drop_item(rig: Rig, iid := 7001, owner := "") -> void: - rig.client.ground.append({"vid": iid, "vnum": DROP_VNUM, "owner": owner, "pos": Vector3(17.0, 0.0, -20.0)}) - rig.step() - -func case_status(rig: Rig, id: String) -> String: - return rig.report.case_status(id) - -func test_happy_path() -> void: - var rig := make_rig("full") - check(case_status(rig, "CONFIG-01") == "PASS", "config/address case passes") - enter_world(rig) - check(case_status(rig, "NET-LOGIN-01") == "PASS", "login passes on char_list with slot") - check(case_status(rig, "NET-SELECT-01") == "PASS", "select passes on entered_game + main entity") - check(case_status(rig, "NET-WORLD-01") == "PASS", "world passes when scene/map/player/HUD ready") - walk_route(rig) - check(rig.pc.walks.size() == 2, "each waypoint uses PlayerController.walk_to") - check(case_status(rig, "NET-MOVE-01") == "PASS" and case_status(rig, "NET-MOVE-02") == "PASS", "waypoints pass") - check(rig.client.reconnect_calls == 1 and rig.flow.stage == "MOVE_CONFIRM", "route end re-logs to read server position") - confirm_position(rig) - check(case_status(rig, "NET-MOVE-CONFIRM-01") == "PASS", "server position after re-login confirms the route") - spawn_mob(rig) - rig.step() - check(case_status(rig, "NET-TARGET-01") == "PASS" and rig.net_play.selected == [MOB], "target selected through NetPlay") - check(rig.net_play.attack_states == [true], "attack key pressed once") - kill_mob(rig) - check(case_status(rig, "NET-ATTACK-01") == "PASS", "damage + death passes attack") - check(rig.net_play.attack_states == [true, false], "attack key released after the kill") - drop_item(rig) - check(case_status(rig, "NET-DROP-01") == "PASS" and rig.net_play.picks == [7001], "new allowed drop is picked") - rig.client.ground.clear() - rig.client.inventory.append({"vnum": DROP_VNUM, "count": 1, "cell": 3}) - rig.step() - check(case_status(rig, "NET-PICKUP-01") == "PASS", "ground removal + inventory increase passes pickup") - check(rig.flow.stage == "SKILL" and rig.quickbar.assigned == [[7, "skill", SKILL_ID, false]], "skill assigned without persistence") - spawn_mob(rig, MOB + 10) - rig.step() - check(rig.quickbar.activations == 1, "first cast requested") - rig.client.damage.emit(MOB + 10, 30, 1) - rig.step() - rig.advance(600_000) - check(rig.quickbar.activations == 2, "second cast requested after retry gap") - rig.client.damage.emit(MOB + 10, 30, 1) - rig.step() - check(case_status(rig, "CBT-strike") == "PASS", "every cast with server evidence passes the skill case") - check(rig.status == "PASS" and rig.flow.done, "full closed loop reports PASS (status=%s, failures=%s, blocked=%s)" % [rig.status, rig.report.finish().failures, rig.report.finish().blocked]) - var lines := FileAccess.get_file_as_string(rig.events_path).strip_edges().split("\n") - check(lines.size() > 10, "contract events persisted") - var first: Variant = JSON.parse_string(lines[0]) - check(first is Dictionary and first.run_id == "rig-0" and first.has("connection_epoch"), "event lines carry run_id and epoch") - check(FileAccess.get_file_as_string(rig.events_path).find("Hero") < 0, "character name never reaches events") - -func test_gameplay_not_allowed() -> void: - var rig := make_rig("playable", false) - enter_world(rig) - check(rig.flow.done and rig.status == "BLOCKED", "without allow_gameplay the run is BLOCKED after world") - check(case_status(rig, "NET-MOVE-01") == "BLOCKED" and str(rig.report.finish().blocked).find("gameplay_not_allowed") >= 0, - "remaining cases are BLOCKED gameplay_not_allowed") - check(rig.pc.walks.is_empty() and rig.net_play.attack_states.is_empty(), "no gameplay input without permission") - -func test_absent_slot_fails_without_select() -> void: - var rig := make_rig() - rig.client.char_list.emit([{"index": 0}]) - check(case_status(rig, "NET-LOGIN-01") == "FAIL" and rig.flow.done, "absent configured slot fails login") - check(rig.status == "FAIL", "absent slot run is FAIL") - -func test_disconnect_during_select() -> void: - var rig := make_rig() - rig.client.char_list.emit([{"index": 2}]) - rig.client.disconnected.emit("reset") - check(case_status(rig, "NET-SELECT-01") == "FAIL" and rig.flow.done, "disconnect while entering fails select") - rig.client.entered_game.emit() - rig.app.scene_ready = true - rig.step(3) - check(rig.client.calls.is_empty() and rig.pc.walks.is_empty(), "no operations after a finished run") - -func test_address_mismatch() -> void: - var cfg := valid_config() - var rig := make_rig("playable", true, cfg) - check(case_status(rig, "CONFIG-01") == "PASS", "matching address control") - var client := FakeClient.new() - var app := FakeAppFlow.new() - app.address = cfg.server.duplicate() - app.address.game_port = 13003 - var report := Report.new() - report.begin("mismatch", "playable", cfg) - var flow := Flow.new() - flow.report = report - flow.flow = app - flow.client = client - flow.config = cfg - flow.clock_us = func() -> int: return 0 - flow.start() - check(report.case_status("CONFIG-01") == "FAIL" and flow.done, "client address differing from precheck fails CONFIG-01") - client.free() - app.free() - -func test_server_correction() -> void: - var rig := make_rig() - rig.pc.move_on_walk = false - enter_world(rig) - rig.step() - rig.client.entities[MAIN].pos_cm = Vector3(9000, 9000, 0) - rig.client.entity_moved.emit(MAIN) - check(case_status(rig, "NET-MOVE-01") == "FAIL", "server correction beyond tolerance fails the waypoint") - var rig2 := make_rig() - rig2.pc.move_on_walk = false - enter_world(rig2) - rig2.advance(21_000_000) - check(case_status(rig2, "NET-MOVE-01") == "FAIL" and rig2.status == "FAIL", "waypoint timeout fails via injected clock") - -func test_reconnect_unavailable_blocks_confirm() -> void: - var rig := make_rig() - rig.client.reconnect_ok = false - enter_world(rig) - walk_route(rig) - check(case_status(rig, "NET-MOVE-CONFIRM-01") == "BLOCKED", "no reconnect -> confirm BLOCKED, not PASS") - check(rig.flow.stage == "TARGET", "run continues to targeting after a blocked confirm") - -func test_stale_epoch_ignored() -> void: - var rig := make_rig() - enter_world(rig) - walk_route(rig) - # Old-connection damage/death arriving after the reconnect was requested. - spawn_mob(rig) - rig.client.disconnected.emit("relogin") - rig.client.damage.emit(MOB, 50, 1) - confirm_position(rig) - check(case_status(rig, "NET-MOVE-CONFIRM-01") == "PASS", "expected reconnect disconnect does not fail the run") - rig.step() - check(rig.flow.stage == "ATTACK", "target bound in new epoch") - check(int(rig.probe.connection_epoch) == 2, "disconnect bumps the connection epoch") - # Deliver an old-epoch death directly; it must be marked stale and ignored. - rig.flow.on_observed("entity_dead", {"source": "server", "connection_epoch": 1, "actor_vid": 0, "target_vid": MOB}) - rig.step() - check(case_status(rig, "NET-ATTACK-01") == "", "old-epoch death does not pass attack") - check(FileAccess.get_file_as_string(rig.events_path).find("stale_epoch") >= 0, "stale event is recorded as stale_epoch") - -func test_target_despawn_releases_and_retargets() -> void: - var rig := make_rig() - enter_world(rig) - walk_route(rig) - confirm_position(rig) - spawn_mob(rig) - rig.step() - rig.client.entities.erase(MOB) - rig.client.entity_despawned.emit(MOB) - check(rig.net_play.attack_states == [true, false], "despawn releases the attack key") - spawn_mob(rig, MOB + 1) - rig.client.entities[MOB] = {"vid": MOB, "ch_type": 2, "race": MOB_RACE, "hp": 100, "dead": false, "pos_cm": Vector3(1000, 2000, 0)} - rig.step() - check(rig.net_play.selected == [MOB, MOB + 1], "abandoned VID is not reselected (selected=%s)" % [rig.net_play.selected]) - -func test_attack_timeout_releases_key() -> void: - var rig := make_rig() - enter_world(rig) - walk_route(rig) - confirm_position(rig) - spawn_mob(rig) - rig.step() - rig.advance(91_000_000) - check(case_status(rig, "NET-ATTACK-01") == "FAIL" and rig.net_play.attack_states == [true, false], "attack timeout fails and releases input") - var rig2 := make_rig() - enter_world(rig2) - walk_route(rig2) - confirm_position(rig2) - spawn_mob(rig2) - rig2.step() - rig2.client.entities[MOB].dead = true - rig2.client.entity_dead.emit(MOB) - rig2.step() - check(case_status(rig2, "NET-ATTACK-01") == "FAIL", "death without damage/HP evidence fails") - -func to_pickup(rig: Rig) -> void: - enter_world(rig) - walk_route(rig) - confirm_position(rig) - spawn_mob(rig) - rig.step() - kill_mob(rig) - drop_item(rig) - -func test_drop_taken_by_other() -> void: - var rig := make_rig() - to_pickup(rig) - rig.client.ground.clear() - rig.client.ground_item_removed.emit(7001) - rig.step() - rig.advance(2_500_000) - check(case_status(rig, "NET-PICKUP-01") == "FAIL", "item removed without inventory increase fails pickup") - -func test_inventory_full_timeout() -> void: - var rig := make_rig() - to_pickup(rig) - rig.advance(16_000_000) - check(case_status(rig, "NET-PICKUP-01") == "FAIL" and rig.status == "FAIL", "pickup never accepted (inventory full) fails") - -func test_foreign_owner_drop_rejected() -> void: - var rig := make_rig() - enter_world(rig) - walk_route(rig) - confirm_position(rig) - spawn_mob(rig) - rig.step() - kill_mob(rig) - drop_item(rig, 7002, "SomeoneElse") - check(rig.net_play.picks.is_empty() and case_status(rig, "NET-DROP-01") == "", "foreign-owner drop is not picked") - rig.advance(31_000_000) - check(rig.flow.stage == "TARGET", "no acceptable drop re-targets while kills < 3") - -func to_skill(rig: Rig) -> void: - to_pickup(rig) - rig.client.ground.clear() - rig.client.inventory.append({"vnum": DROP_VNUM, "count": 1}) - rig.step() - spawn_mob(rig, MOB + 10) - -func test_local_only_skill_evidence_fails() -> void: - var rig := make_rig("full") - to_skill(rig) - check(rig.flow.stage == "SKILL", "reached skill stage") - rig.step() - check(rig.quickbar.activations == 1, "skill activated through Quickbar") - rig.advance(11_000_000) - check(case_status(rig, "CBT-strike") == "FAIL", "cast_started without server damage fails") - -func test_skill_not_learned_blocked() -> void: - var rig := make_rig("full") - to_skill(rig) - rig.quickbar.reject_code = "NOT_YET_LEARN" - rig.step() - check(case_status(rig, "CBT-strike") == "BLOCKED" and rig.status == "BLOCKED", "unlearned skill is a fixture BLOCKED") - -# --- CBT-01: effect lifecycle ------------------------------------------------------ - -## Two casts with server damage; the first cast spawns effect 41 (lifetime 1500ms). -func cast_twice_with_effect(rig: Rig) -> void: - to_skill(rig) - rig.step() - rig.fx.fx_spawned.emit("d:/ymir work/effect/hit/blow_1.mse", 41, 1500) - rig.client.damage.emit(MOB + 10, 30, 1) - rig.step() - rig.advance(600_000) - rig.client.damage.emit(MOB + 10, 30, 1) - rig.step() - -func test_skill_case_waits_for_effect_cleanup() -> void: - var rig := make_rig("full") - cast_twice_with_effect(rig) - check(rig.quickbar.activations == 2 and case_status(rig, "CBT-strike") == "", "skill case is held while its effect is still alive") - rig.fx.fx_finished.emit("d:/ymir work/effect/hit/blow_1.mse", 41, 1500, 1900, "cleanup") - rig.step() - check(case_status(rig, "CBT-strike") == "PASS" and rig.status == "PASS", "effect cleaned inside lifetime+1s lets the skill case pass") - var text := FileAccess.get_file_as_string(rig.events_path) - check(text.find("\"fx_id\":41") >= 0 and text.find("\"elapsed_ms\":1900") >= 0 and text.find("blow_1") >= 0, - "effect spawn/finish boundaries are persisted with id, lifetime and elapsed") - -func case_evidence(rig: Rig, id: String) -> Array: - for item in rig.report.finish().cases: - if String(item.id) == id: - return item.evidence - return [] - -func test_skill_screenshots_follow_effect_times() -> void: - # Effect finishes before its half-life tick: peak falls back to the last live frame. - var rig := make_rig("full") - cast_twice_with_effect(rig) - rig.fx.fx_finished.emit("d:/ymir work/effect/hit/blow_1.mse", 41, 1500, 700, "cleanup") - rig.step() - check(case_evidence(rig, "CBT-strike") == ["cbt-strike-before.png", "cbt-strike-trigger.png", "cbt-strike-peak.png", "cbt-strike-end.png"], - "first cast is captured before / trigger / peak / end: %s" % [case_evidence(rig, "CBT-strike")]) - # Peak is taken on the tick the half-life falls due, not after a fixed second. - var timed := make_rig("full") - to_skill(timed) - timed.step() - timed.fx.fx_spawned.emit("blow_1", 41, 400) - timed.advance(150_000) - check(not ("cbt-strike-peak.png" in timed.flow._shots), "no peak before the effect's half-life") - timed.advance(60_000) - check("cbt-strike-peak.png" in timed.flow._shots and not ("cbt-strike-end.png" in timed.flow._shots), - "peak captured at 200ms of a 400ms effect, end still pending") - timed.fx.fx_finished.emit("blow_1", 41, 400, 400, "cleanup") - check(not ("cbt-strike-end.png" in timed.flow._shots), "end frame waits for the tick after tree exit") - timed.step() - check(timed.flow._shots.back() == "cbt-strike-end.png", "end captured one tick after the effect left the tree") - var text := FileAccess.get_file_as_string(timed.events_path) - check(text.find("\"skill_capture\"") >= 0 and text.find("\"phase\":\"peak\"") >= 0, "captures are correlated in events.jsonl") - -func test_effect_outliving_lifetime_fails() -> void: - var rig := make_rig("full") - cast_twice_with_effect(rig) - rig.advance(1_000_000) - check(rig.status == "", "effect inside lifetime+1s does not fail yet") - rig.advance(1_000_000) - check(case_status(rig, "CBT-strike") == "FAIL" and rig.status == "FAIL", "effect still in the tree after lifetime+1s fails the skill case") - check(str(rig.report.finish().failures).find("blow_1") >= 0, "failure names the effect") - -func test_effect_finished_late_fails() -> void: - var rig := make_rig("full") - cast_twice_with_effect(rig) - rig.fx.fx_finished.emit("blow_1", 41, 1500, 2600, "cleanup") - check(case_status(rig, "CBT-strike") == "FAIL", "effect reporting elapsed > lifetime+1s fails") - -func test_effect_from_passed_case_fails_run() -> void: - var rig := make_rig() - enter_world(rig) - walk_route(rig) - confirm_position(rig) - spawn_mob(rig) - rig.step() - check(rig.flow.stage == "ATTACK", "attacking before the hit effect") - rig.fx.fx_spawned.emit("hit_spark", 77, 800) - kill_mob(rig) - check(case_status(rig, "NET-ATTACK-01") == "PASS" and rig.flow.stage == "DROP", "attack case passed while its effect is alive") - rig.advance(1_900_000) - check(rig.status == "FAIL" and str(rig.report.finish().failures).find("NET-ATTACK-01") >= 0, - "effect of an already-passed case outliving lifetime+1s makes the run FAIL") - -func test_looping_effect_not_timed() -> void: - var rig := make_rig("full") - to_skill(rig) - rig.fx.fx_spawned.emit("aura_loop", 90, -1) - rig.step() - rig.client.damage.emit(MOB + 10, 30, 1) - rig.step() - rig.advance(600_000) - rig.client.damage.emit(MOB + 10, 30, 1) - rig.step() - check(case_status(rig, "CBT-strike") == "PASS", "looping owner-bound effect does not hold or fail the case") - -# --- STB-01: soak rounds ------------------------------------------------------------- -# Time is the injected monotonic clock; the soak ends on elapsed wall-clock duration, -# never on a round or frame count. Warp / real faults / resize need a confirmed -# environment or a window, otherwise they stay BLOCKED. - -const WALL_ORIGIN_MS := 1_700_000_000_000 -const SOAK_REST_US := 600_000_000 -const SETTLE_US := 1_100_000 - -func soak_config() -> Dictionary: - var cfg := valid_config() - cfg.timeout_seconds = 9000 - cfg.soak = {"duration_seconds": 7200, "rest_seconds": 30, "warmup_rounds": 1, "min_rounds": 10, - "reconnects": 10, "exits": 10, - "warp": {"status": "unconfirmed"}, - "faults": {"status": "unconfirmed"}, - "resolutions": {"sizes": [[1280, 720], [1440, 900], [1600, 1000]], "switches_per_size": 10}} - return cfg - -func confirmed_warp(cfg: Dictionary) -> Dictionary: - cfg.soak.warp = {"status": "confirmed", "required": 20, "portal_cm": [1500, 2600], "destination_map_key": "outdoortrent/metin2_map_trent02", - "return_portal_cm": [52000, 1200], "cross_server": false} - return cfg - -func confirmed_faults(cfg: Dictionary) -> Dictionary: - # The client connects to server.* on 127.0.0.1 (the proxy); upstream is the real test server. - cfg.soak.faults = {"status": "confirmed", "mode": "local_proxy", "per_type": 1, "unreachable_seconds": 10, - "upstream": {"auth_host": "192.0.2.10", "auth_port": 11000, "game_host": "192.0.2.10", "game_port": 13002}} - return cfg - -func make_soak_rig(cfg := {}, with_window := true) -> Rig: - var rig := make_rig("soak", true, cfg if not cfg.is_empty() else soak_config()) - rig.flow.wall_ms = func() -> int: return WALL_ORIGIN_MS + rig.now / 1000 - if with_window: - rig.window = FakeWindow.new() - rig.flow.window = rig.window - return rig - -func events_of(rig: Rig, kind: String) -> Array: - var out: Array = [] - for line in FileAccess.get_file_as_string(rig.events_path).strip_edges().split("\n"): - var event: Variant = JSON.parse_string(line) - if event is Dictionary and event.kind == kind: - out.append(event) - return out - -func finished_case(rig: Rig, id: String) -> Dictionary: - for item in rig.report.finish().cases: - if String(item.id) == id: - return item - return {} - -## MOVE x2 -> MOVE_CONFIRM re-login -> settle -> TARGET -> ATTACK. -func round_to_attack(rig: Rig, round: int) -> void: - walk_route(rig) - confirm_position(rig) - rig.advance(SETTLE_US) - spawn_mob(rig, MOB + 100 * (round + 1)) - rig.step() - -## ATTACK -> DROP -> PICKUP passes; the flow moves on to FAULT / WARP / RESIZE / REST. -func round_attack_to_pickup(rig: Rig, round: int) -> void: - kill_mob(rig, MOB + 100 * (round + 1)) - drop_item(rig, 7001 + round) - rig.client.ground.clear() - rig.client.inventory.append({"vnum": DROP_VNUM, "count": 1}) - rig.step() - -func resize_all(rig: Rig) -> void: - for i in 3: - if rig.flow.stage == "RESIZE": - rig.advance(600_000) - -func soak_round(rig: Rig, round: int, rest_us := SOAK_REST_US) -> void: - round_to_attack(rig, round) - round_attack_to_pickup(rig, round) - resize_all(rig) - check(rig.flow.stage == "REST" or rig.flow.done, "round %d reaches REST (stage=%s)" % [round, rig.flow.stage]) - rig.advance(rest_us) - -func test_soak_config_contract() -> void: - check("soak" in Config.SUITES, "soak is a known suite") - var cfg := soak_config() - check(Config.validate(cfg).ok and Config.validate_soak(cfg).ok, "complete soak config is accepted: %s" % [Config.validate_soak(cfg).errors]) - var cases := Config.required_cases(cfg, "soak") - for id in ["NET-PICKUP-01", "STB-DURATION-01", "STB-FRAMES-01", "STB-RECONNECT-01", "STB-WARP-01", "STB-DISCONNECT-01", "STB-RESIZE-01"]: - check(cases.has(id), "soak required cases include %s" % id) - check(not cases.has("CBT-strike") and not cases.has("STB-MEMORY-01"), "soak has no skill cases; memory is a runner case") - check(confirmed_warp(soak_config()).soak.warp.status == "confirmed" and Config.validate_soak(confirmed_warp(confirmed_faults(soak_config()))).ok, - "confirmed warp route and local fault proxy are accepted: %s" % [Config.validate_soak(confirmed_warp(confirmed_faults(soak_config()))).errors]) - var bad := { - "no soak block": func(c: Dictionary) -> void: c.erase("soak"), - "rest below 30s": func(c: Dictionary) -> void: c.soak.rest_seconds = 10, - "duration below 2h": func(c: Dictionary) -> void: c.soak.duration_seconds = 600, - "fractional duration": func(c: Dictionary) -> void: c.soak.duration_seconds = 7200.5, - "fewer than 10 rounds": func(c: Dictionary) -> void: c.soak.min_rounds = 5, - "fewer than 10 reconnects": func(c: Dictionary) -> void: c.soak.reconnects = 3, - "fewer than 10 exits": func(c: Dictionary) -> void: c.soak.exits = 1, - "two resolutions": func(c: Dictionary) -> void: c.soak.resolutions.sizes = [[1280, 720], [1440, 900]], - "duplicate resolution": func(c: Dictionary) -> void: c.soak.resolutions.sizes = [[1280, 720], [1280, 720], [1600, 1000]], - "switches below 10": func(c: Dictionary) -> void: c.soak.resolutions.switches_per_size = 2, - "timeout without soak margin": func(c: Dictionary) -> void: c.timeout_seconds = 7300, - "unknown warp status": func(c: Dictionary) -> void: c.soak.warp.status = "maybe", - "confirmed warp without portal": func(c: Dictionary) -> void: c.soak.warp = {"status": "confirmed", "required": 20}, - "odd warp count": func(c: Dictionary) -> void: confirmed_warp(c).soak.warp.required = 21, - "warp below 20": func(c: Dictionary) -> void: confirmed_warp(c).soak.warp.required = 10, - "warp to the same map": func(c: Dictionary) -> void: confirmed_warp(c).soak.warp.destination_map_key = c.map_key, - "fault mode other than local proxy": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.mode = "pf_firewall", - "unreachable longer than reconnect UI retries": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.unreachable_seconds = 60, - "credential inside soak": func(c: Dictionary) -> void: c.soak["proxy_password"] = "x", - "confirmed faults without upstream": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.erase("upstream"), - "fault proxy on a non-loopback listen address": func(c: Dictionary) -> void: confirmed_faults(c).server.game_host = "192.0.2.10", - "fault proxy upstream loops back to itself": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.upstream = c.server.duplicate(), - "fault upstream port out of range": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.upstream.auth_port = 70000, - } - for name in bad: - var broken := soak_config() - bad[name].call(broken) - check(not (Config.validate(broken).ok and Config.validate_soak(broken).ok), "soak config rejects: %s" % name) - var confirmed := confirmed_faults(confirmed_warp(soak_config())) - var confirmed_check := Config.validate_soak(confirmed) - check(Config.validate(confirmed).ok and confirmed_check.ok, "confirmed warp + fault proxy config is valid: %s" % [confirmed_check.errors]) - var exit_run := soak_config() - exit_run.timeout_seconds = 900 - check(not Config.validate_soak(exit_run).ok and Config.validate_soak(exit_run, false).ok, - "the soak timeout margin applies to the soak client only, not to exit runs sharing the scenario") - var exit_run_bad := confirmed_faults(soak_config()) - exit_run_bad.soak.faults.upstream.auth_port = 70000 - check(not Config.validate_soak(exit_run_bad, false).ok, "exit runs still reject an invalid fault proxy upstream") - var live_window = preload("res://testing/playable_window.gd").new(null) - var live_available: Dictionary = live_window.availability() - check(not live_available.ok and "headless" in String(live_available.reason) and live_window.viewport_size() == Vector2i.ZERO, - "live window adapter reports headless as unavailable") - var example := Config.load_file(ProjectSettings.globalize_path("res://../test/playable/scenario.example.json")) - var example_soak: Dictionary = example.config.get("soak", {}) - check(example_soak.get("warp", {}).get("status", "") == "unconfirmed" and example_soak.get("faults", {}).get("status", "") == "unconfirmed", - "example scenario ships an unconfirmed soak block") - -func test_soak_rounds_until_wall_clock_duration() -> void: - var rig := make_soak_rig() - enter_world(rig) - check(rig.flow.stage == "MOVE" and events_of(rig, "soak_round").size() == 1, "soak starts round 0 after the world is ready") - var rounds := 0 - while not rig.flow.done and rounds < 20: - soak_round(rig, rounds) - rounds += 1 - var report: Dictionary = rig.report.finish() - check(rig.flow.done and rounds == 12, "soak ends after the 7200s wall-clock budget, not a fixed round count (rounds=%d)" % rounds) - check(report.failures.is_empty(), "clean soak has no failures (no duplicate case results): %s" % [report.failures]) - for id in ["NET-MOVE-01", "NET-MOVE-CONFIRM-01", "NET-PICKUP-01", "STB-DURATION-01", "STB-RECONNECT-01", "STB-RESIZE-01", "STB-FRAMES-01"]: - pass - for id in ["NET-MOVE-01", "NET-MOVE-CONFIRM-01", "NET-PICKUP-01", "STB-DURATION-01", "STB-RECONNECT-01", "STB-RESIZE-01"]: - check(case_status(rig, id) == "PASS", "%s passes after 12 rounds (%s)" % [id, finished_case(rig, id)]) - check(case_status(rig, "STB-FRAMES-01") == "BLOCKED" and String(finished_case(rig, "STB-FRAMES-01").reason).find("metrics") >= 0, - "no frame metrics attached -> STB-FRAMES-01 BLOCKED") - check(case_status(rig, "STB-WARP-01") == "BLOCKED" and String(finished_case(rig, "STB-WARP-01").reason).find("not confirmed") >= 0, - "unconfirmed warp route -> STB-WARP-01 BLOCKED") - var disconnect_reason := String(finished_case(rig, "STB-DISCONNECT-01").reason) - check(case_status(rig, "STB-DISCONNECT-01") == "BLOCKED" and disconnect_reason.find("active reconnect") >= 0, - "no confirmed fault condition -> STB-DISCONNECT-01 BLOCKED, active reconnect is no substitute: %s" % disconnect_reason) - check(report.status == "BLOCKED", "required BLOCKED soak cases keep the run from PASS") - check(rig.client.reconnect_calls == 12 and rig.pc.walks.size() == 24, "each round re-walks the route and re-logs once") - var soak: Dictionary = report.get("soak", {}) - check(soak.get("rounds", -1) == 12 and soak.get("active_reconnects", -1) == 12 and soak.get("real_faults", {}) == {"close": 0, "unreachable": 0}, - "soak summary counts active reconnects separately from real faults: %s" % [soak]) - check(soak.get("resizes", {}) == {"1280x720": 11, "1440x900": 12, "1600x1000": 12}, "a size already in effect is not counted as a switch: %s" % [soak.get("resizes", {})]) - check(soak.get("elapsed_seconds", 0.0) >= 7200.0 and String(soak.get("cold_start", "")).find("未知") >= 0, - "summary records elapsed wall-clock seconds and the unknown cold-start cache state") - var rests := events_of(rig, "soak_rest") - check(rests.size() == 24, "each round has a rest start and end event (%d)" % rests.size()) - var ok_rest := true - for i in range(0, rests.size() - 1, 2): - var start: Dictionary = rests[i].payload - var end: Dictionary = rests[i + 1].payload - ok_rest = ok_rest and start.state == "start" and end.state == "end" and start.round == end.round and start.round == i / 2 \ - and int(end.wall_ms) - int(start.wall_ms) >= 30000 - check(ok_rest, "rest windows carry round and unix wall_ms and last at least 30s") - check(rig.window.requests.size() == 36 and events_of(rig, "window_resized").size() == 36, "three logical sizes are requested every round") - var resized: Dictionary = events_of(rig, "window_resized")[1].payload - check(resized.requested_size == [1440.0, 900.0] and resized.logical_size == [1440.0, 900.0] and resized.physical_size == [2880.0, 1800.0] \ - and resized.screen_scale == 2.0, "Retina scale: logical matches the request while physical pixels are recorded: %s" % [resized]) - -func test_soak_duration_is_wall_clock_not_rounds() -> void: - var rig := make_soak_rig() - enter_world(rig) - for round in 12: - soak_round(rig, round, 31_000_000) - check(not rig.flow.done and rig.flow.stage == "MOVE", "12 short rounds do not end a 2h soak") - soak_round(rig, 12, 7_200_000_000) - check(rig.flow.done and case_status(rig, "STB-DURATION-01") == "PASS", "the round that crosses the wall-clock deadline ends the soak") - check(rig.report.finish().soak.rounds == 13, "13 completed rounds recorded") - var short := make_soak_rig() - enter_world(short) - soak_round(short, 0, 7_300_000_000) - check(short.flow.done and case_status(short, "STB-DURATION-01") == "BLOCKED" \ - and String(finished_case(short, "STB-DURATION-01").reason).find("10") >= 0, "duration reached with fewer than min_rounds is BLOCKED") - check(case_status(short, "STB-RECONNECT-01") == "BLOCKED", "one reconnect of ten is BLOCKED, not PASS") - -func test_soak_later_round_failure() -> void: - var rig := make_soak_rig() - enter_world(rig) - soak_round(rig, 0) - soak_round(rig, 1) - round_to_attack(rig, 2) - kill_mob(rig, MOB + 300) - drop_item(rig, 7010) - rig.client.ground.clear() - rig.step() - rig.advance(2_500_000) - var report: Dictionary = rig.report.finish() - check(rig.flow.done and report.status == "FAIL", "a pickup failure in round 2 fails the soak") - check(case_status(rig, "NET-PICKUP-01") == "PASS" and str(report.failures).find("soak round 2") >= 0, - "the round-0 case result stays; the later failure is a run failure naming the round: %s" % [report.failures]) - check(str(report.failures).find("duplicate") < 0, "later rounds never write duplicate case results") - check(case_status(rig, "STB-DURATION-01") == "FAIL", "soak ended early by a failure -> STB-DURATION-01 FAIL") - -func test_soak_later_round_fixture_blocked() -> void: - var rig := make_soak_rig() - enter_world(rig) - soak_round(rig, 0) - walk_route(rig) - confirm_position(rig) - rig.advance(SETTLE_US) - rig.advance(31_000_000) - var report: Dictionary = rig.report.finish() - check(rig.flow.done and report.status == "BLOCKED" and report.failures.is_empty(), "no monster in a later round ends the soak BLOCKED") - check(case_status(rig, "STB-DURATION-01") == "BLOCKED" and String(finished_case(rig, "STB-DURATION-01").reason).find("round 1") >= 0, - "STB-DURATION-01 names the blocked round: %s" % [finished_case(rig, "STB-DURATION-01")]) - -func test_soak_faults_confirmed() -> void: - var rig := make_soak_rig(confirmed_faults(soak_config()), false) - enter_world(rig) - round_to_attack(rig, 0) - round_attack_to_pickup(rig, 0) - check(rig.flow.stage == "FAULT", "confirmed fault condition runs after the pickup") - var requests := events_of(rig, "fault_request") - check(requests.size() == 1 and requests[0].payload.type == "close" and requests[0].payload.attempt == 1, - "fault request is written to events.jsonl for the local proxy: %s" % [requests]) - check(rig.client.reconnect_calls == 1, "a real fault is not produced by calling reconnect()") - rig.client.disconnected.emit("closed by peer") - rig.step() - check(not rig.flow.done and rig.flow.stage == "FAULT", "the requested disconnect is tolerated") - rig.client.entered_game.emit() - rig.step() - rig.advance(SETTLE_US) - check(rig.flow.stage == "REST", "recovered through the production reconnect path, then rests (stage=%s)" % rig.flow.stage) - check(rig.client.reconnect_calls == 1 and events_of(rig, "fault_recovered").size() == 1, "recovery counted without an extra active reconnect") - rig.advance(SOAK_REST_US) - round_to_attack(rig, 1) - round_attack_to_pickup(rig, 1) - check(rig.flow.stage == "FAULT" and events_of(rig, "fault_request")[-1].payload.type == "unreachable", "second fault covers short unreachability") - rig.client.disconnected.emit("connect refused") - rig.client.login_failed.emit("connect refused") - rig.client.disconnected.emit("connect refused") - rig.advance(5_000_000) - rig.client.entered_game.emit() - rig.step() - rig.advance(SETTLE_US) - check(rig.flow.stage == "REST" and not rig.flow.done, "unreachable window with failed retries recovers") - rig.advance(7_300_000_000) - var soak: Dictionary = rig.report.finish().soak - check(soak.real_faults == {"close": 1, "unreachable": 1} and soak.active_reconnects == 2, "real faults and active reconnects are counted apart: %s" % [soak]) - check(case_status(rig, "STB-DISCONNECT-01") == "PASS", "both fault types recovered -> STB-DISCONNECT-01 PASS") - -func test_soak_fault_without_disconnect_fails() -> void: - var rig := make_soak_rig(confirmed_faults(soak_config()), false) - enter_world(rig) - round_to_attack(rig, 0) - round_attack_to_pickup(rig, 0) - rig.advance(31_000_000) - check(rig.flow.done and case_status(rig, "STB-DISCONNECT-01") == "FAIL", "no disconnect observed after a fault request fails") - var late := make_soak_rig(confirmed_faults(soak_config()), false) - enter_world(late) - round_to_attack(late, 0) - round_attack_to_pickup(late, 0) - late.client.disconnected.emit("closed by peer") - late.advance(131_000_000) - check(late.flow.done and case_status(late, "STB-DISCONNECT-01") == "FAIL", "no recovery within the reconnect budget fails") - -func test_soak_warp_confirmed() -> void: - var cfg := confirmed_warp(soak_config()) - var rig := make_soak_rig(cfg, false) - enter_world(rig) - round_to_attack(rig, 0) - round_attack_to_pickup(rig, 0) - check(rig.flow.stage == "WARP", "confirmed warp route runs after the pickup") - var portal := MapCoord.to_world(Vector3(15.0, 0.0, -26.0)) - check(rig.pc.walks[-1].is_equal_approx(portal), "warp walks into the configured portal through PlayerController.walk_to") - rig.app.map_key = cfg.soak.warp.destination_map_key - rig.client.disconnected.emit("warp") - rig.client.entered_game.emit() - rig.step() - rig.advance(SETTLE_US) - check(rig.flow.stage == "WARP" and events_of(rig, "warp_arrived").size() == 1, "arrival in the destination starts the return leg") - rig.app.map_key = cfg.map_key - rig.client.disconnected.emit("warp") - rig.client.entered_game.emit() - rig.step() - rig.advance(SETTLE_US) - check(rig.flow.stage == "REST" and events_of(rig, "warp_arrived").size() == 2, "out-and-back counts two map changes") - rig.advance(7_300_000_000) - var soak: Dictionary = rig.report.finish().soak - check(soak.warps == 2 and case_status(rig, "STB-WARP-01") == "BLOCKED", "2 of 20 warps is BLOCKED, not PASS (%s)" % [soak]) - var wrong := make_soak_rig(confirm_copy(cfg), false) - enter_world(wrong) - round_to_attack(wrong, 0) - round_attack_to_pickup(wrong, 0) - wrong.client.entered_game.emit() - wrong.step() - wrong.advance(SETTLE_US) - check(wrong.flow.done and case_status(wrong, "STB-WARP-01") == "FAIL", "a reconnect that stays on the old map fails the warp") - -func confirm_copy(cfg: Dictionary) -> Dictionary: - return cfg.duplicate(true) - -func test_soak_warp_without_server_reconnect_fails() -> void: - var cfg := confirmed_warp(soak_config()) - var rig := make_soak_rig(cfg, false) - enter_world(rig) - round_to_attack(rig, 0) - round_attack_to_pickup(rig, 0) - rig.app.map_key = cfg.soak.warp.destination_map_key - rig.advance(SETTLE_US) - rig.advance(121_000_000) - check(rig.flow.done and case_status(rig, "STB-WARP-01") == "FAIL", "a map change without a new server connection epoch is not a warp") - -func test_soak_rebind_checks() -> void: - var dup := make_soak_rig() - enter_world(dup) - walk_route(dup) - dup.net_world.add_entity_node(3001) - dup.net_world.add_entity_node(3001) - confirm_position(dup) - dup.advance(SETTLE_US) - check(dup.flow.done and case_status(dup, "STB-RECONNECT-01") == "FAIL" and String(finished_case(dup, "STB-RECONNECT-01").reason).find("duplicate entity") >= 0, - "duplicate entity nodes after re-login fail STB-RECONNECT-01") - var queued := make_soak_rig() - enter_world(queued) - walk_route(queued) - queued.net_world.add_entity_node(3002) - var fading := queued.net_world.add_entity_node(3002) - fading.queue_free() - confirm_position(queued) - queued.advance(SETTLE_US) - check(queued.flow.stage == "TARGET", "a node already queued for deletion is not a duplicate") - var residue := make_soak_rig() - enter_world(residue) - walk_route(residue) - residue.net_play._target_vid = 99999 - confirm_position(residue) - residue.advance(SETTLE_US) - check(residue.flow.done and String(finished_case(residue, "STB-RECONNECT-01").reason).find("target") >= 0, "old target VID kept after re-login fails") - var twice := make_soak_rig() - enter_world(twice) - walk_route(twice) - twice.client.entered_game.emit() - twice.client.entered_game.emit() - twice.step() - check(twice.flow.done and String(finished_case(twice, "STB-RECONNECT-01").reason).find("duplicate entered_game") >= 0, - "two entered_game signals without a disconnect fail as a duplicate signal") - var stray := make_soak_rig() - enter_world(stray) - round_to_attack(stray, 0) - stray.client.entered_game.emit() - check(stray.flow.done and stray.report.finish().status == "FAIL", "an unrequested entered_game during combat fails the soak") - var settling := make_soak_rig() - enter_world(settling) - walk_route(settling) - confirm_position(settling) - check(settling.flow.stage == "MOVE_CONFIRM", "rebind waits for the scene to settle before checking") - settling.advance(SETTLE_US) - check(settling.flow.stage == "TARGET", "settled rebind continues") - -func test_soak_resize_contract() -> void: - var none := make_soak_rig({}, false) - enter_world(none) - soak_round(none, 0, 7_300_000_000) - check(String(finished_case(none, "STB-RESIZE-01").reason).find("window") >= 0 and case_status(none, "STB-RESIZE-01") == "BLOCKED", - "no window adapter -> STB-RESIZE-01 BLOCKED") - var headless := make_soak_rig() - headless.window.supported = false - headless.window.reason = "headless display server has no window" - enter_world(headless) - soak_round(headless, 0, 7_300_000_000) - check(case_status(headless, "STB-RESIZE-01") == "BLOCKED" and String(finished_case(headless, "STB-RESIZE-01").reason).find("headless") >= 0, - "headless window -> BLOCKED with the adapter reason") - var small := make_soak_rig() - small.window.max_logical = Vector2i(1500, 950) - enter_world(small) - soak_round(small, 0, 7_300_000_000) - var reason := String(finished_case(small, "STB-RESIZE-01").reason) - check(case_status(small, "STB-RESIZE-01") == "BLOCKED" and reason.find("1600x1000") >= 0, "a size larger than the usable screen is BLOCKED and named: %s" % reason) - check(events_of(small, "window_size_rejected").size() == 1 and small.report.finish().failures.is_empty(), "screen-limited size is recorded, not a failure") - var stuck := make_soak_rig() - stuck.window.stuck = true - enter_world(stuck) - round_to_attack(stuck, 0) - round_attack_to_pickup(stuck, 0) - stuck.advance(600_000) - stuck.advance(5_100_000) - check(stuck.flow.done and case_status(stuck, "STB-RESIZE-01") == "FAIL", "window that never reaches the requested logical size fails") - -## Push one frame interval tagged with a phase, as playable_live_test does each frame. -func push_frame(metrics: RefCounted, rig: Rig, phase: String, ms: int, marker := "") -> void: - metrics.set_tags({"phase": phase, "map": "m", "actor_count": "1-8", "gpu_skin": false}) - if not marker.is_empty(): - metrics.mark(marker, {}) - metrics.add_interval_us(ms * 1000, rig.now) - -func metrics_rig() -> Array: - var rig := make_soak_rig() - var metrics: RefCounted = Metrics.new() - metrics.clock_us = func() -> int: return rig.now - metrics.configure({"window_seconds": 3600}) - rig.flow.metrics = metrics - return [rig, metrics] - -func test_soak_frames_gate() -> void: - var pair := metrics_rig() - var rig: Rig = pair[0] - var metrics: RefCounted = pair[1] - enter_world(rig) - for round in 3: - round_to_attack(rig, round) - check(rig.flow.stage == "ATTACK", "round %d attacking" % round) - if round != 1: - push_frame(metrics, rig, "attack", 150, "model_build") - push_frame(metrics, rig, "loading", 400) - round_attack_to_pickup(rig, round) - resize_all(rig) - rig.advance(SOAK_REST_US if round < 2 else 7_300_000_000) - var frames := finished_case(rig, "STB-FRAMES-01") - check(frames.get("status", "") == "FAIL" and String(frames.reason).find("attack") >= 0, - "attack with >100ms frames in 2 of 3 consecutive rounds fails STB-FRAMES-01: %s" % [frames]) - var pair2 := metrics_rig() - var clean: Rig = pair2[0] - var clean_metrics: RefCounted = pair2[1] - enter_world(clean) - for round in 3: - round_to_attack(clean, round) - push_frame(clean_metrics, clean, "loading", 400) - push_frame(clean_metrics, clean, "attack", 150 if round == 0 else 16, "model_build") - round_attack_to_pickup(clean, round) - resize_all(clean) - clean.advance(SOAK_REST_US if round < 2 else 7_300_000_000) - check(case_status(clean, "STB-FRAMES-01") == "PASS", "attributed single severe frame and loading frames pass: %s" % [finished_case(clean, "STB-FRAMES-01")]) - var pair3 := metrics_rig() - var loose: Rig = pair3[0] - var loose_metrics: RefCounted = pair3[1] - enter_world(loose) - round_to_attack(loose, 0) - push_frame(loose_metrics, loose, "attack", 60) - round_attack_to_pickup(loose, 0) - resize_all(loose) - loose.advance(7_300_000_000) - var loose_case := finished_case(loose, "STB-FRAMES-01") - check(loose_case.get("status", "") == "BLOCKED" and String(loose_case.reason).find("attribution") >= 0, - "a gameplay frame >50ms without attribution keeps STB-FRAMES-01 BLOCKED: %s" % [loose_case]) - -func test_soak_gameplay_not_allowed() -> void: - var rig := make_rig("soak", false, soak_config()) - enter_world(rig) - check(rig.flow.done and case_status(rig, "STB-DURATION-01") == "BLOCKED" and rig.pc.walks.is_empty(), "soak without allow_gameplay is BLOCKED with no input") diff --git a/project/playable_harness_test.gd.uid b/project/playable_harness_test.gd.uid deleted file mode 100644 index 2e9e1389..00000000 --- a/project/playable_harness_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dy8kund8jox5h diff --git a/project/playable_live_test.gd b/project/playable_live_test.gd deleted file mode 100644 index d3de9de9..00000000 --- a/project/playable_live_test.gd +++ /dev/null @@ -1,181 +0,0 @@ -extends Node - -## 首个 Mac 内测的真实联网闭环(NET-01 / CBT-01)。 -## -## 本节点只做装配:加载配置、挂 PlayableProbe、把观察转给 PlayableFlow 状态机、 -## 截图、写 client-report.json 后正常退出。所有游戏操作都在 PlayableFlow 里经 -## 生产入口发出;没有 MT_PLAYABLE_ALLOW_GAMEPLAY=1 时只验证登录/选角/进场。 -## -## env: -## MT_PLAYABLE_CONFIG 场景 JSON(不含凭据) -## MT_PLAYABLE_SUITE playable|full|soak(soak = STB-01 墙钟长跑,挂帧指标与窗口适配器) -## MT_PLAYABLE_RUN_ID 父脚本生成的 run_id -## MT_PLAYABLE_ALLOW_GAMEPLAY 1 = 允许移动/攻击/拾取/施法 -## MT_TEST_REPORT client-report.json 路径 -## MT_TEST_EVENTS events.jsonl 路径(默认与报告同目录) -## MT_TEST_OUTPUT 失败截图目录(默认与报告同目录) -## MTGODOT_GPUSKIN 仅作为帧指标分段标签 gpu_skin 记录 - -const Config = preload("res://testing/playable_config.gd") -const Report = preload("res://testing/playable_report.gd") -const Probe = preload("res://testing/playable_probe.gd") -const Flow = preload("res://testing/playable_flow.gd") -const Metrics = preload("res://testing/playable_metrics.gd") -const PlayableWindow = preload("res://testing/playable_window.gd") -## Kinds that do heavy work on the main thread; a long frame right after one is attributed to it. -const MARKED_KINDS := ["entered_game", "disconnected", "entity_spawned", "ground_item_added"] - -var _app_flow: Node -var _client: Object -var _report: RefCounted -var _probe: Node -var _machine: RefCounted -var _report_path := "" -var _output_dir := "" -var _watched_quickbar: Object -var _watched_fx: Object -var _finished := false -var _metrics: RefCounted -var _tick_us := 0 -var _tick_max_us := 0 -var _ticks := 0 -var _memory_start := {} - -func setup(app_flow: Node, m2client: Object, config: Dictionary = {}) -> void: - _app_flow = app_flow - _client = m2client - _report_path = OS.get_environment("MT_TEST_REPORT").strip_edges() - if _report_path.is_empty(): - _report_path = "user://playable-client-report.json" - var events_path := OS.get_environment("MT_TEST_EVENTS").strip_edges() - if events_path.is_empty(): - events_path = _report_path.get_base_dir().path_join("events.jsonl") - _output_dir = OS.get_environment("MT_TEST_OUTPUT").strip_edges() - if _output_dir.is_empty(): - _output_dir = _report_path.get_base_dir() - var suite := OS.get_environment("MT_PLAYABLE_SUITE").strip_edges() - if suite.is_empty(): - suite = "playable" - var errors: Array = [] - if config.is_empty(): - var loaded := Config.load_file(OS.get_environment("MT_PLAYABLE_CONFIG")) - config = loaded.config - errors = loaded.errors - else: - errors = Config.validate(config).errors - if not (suite in Config.SUITES): - errors.append("unknown suite") - _report = Report.new() - _report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), suite, config if errors.is_empty() else {}, events_path) - if not errors.is_empty(): - _report.set_required_cases(["CONFIG-01"]) - _report.add_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(errors)) - _finish_later() - return - if _client == null: - _report.set_required_cases(Config.required_cases(config, suite)) - _report.add_case("CONFIG-01", "FAIL", "M2Client was not created") - _finish_later() - return - _probe = Probe.new() - _probe.name = "PlayableProbe" - add_child(_probe) - _probe.setup(_app_flow, _client) - _machine = Flow.new() - _machine.report = _report - _machine.probe = _probe - _machine.flow = _app_flow - _machine.client = _client - _machine.config = config - _machine.suite = suite - _machine.allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1" - _machine.capture = Callable(self, "_capture") - _machine.finished.connect(_on_machine_finished) - _probe.observed.connect(_machine.on_observed) - if suite == "soak": - _metrics = Metrics.new() - _metrics.configure({"window_seconds": 60}) - _machine.metrics = _metrics - _machine.window = PlayableWindow.new(get_viewport()) - _memory_start = Metrics.memory_snapshot() - _probe.observed.connect(_mark_observed) - _machine.start() - -func _process(_delta: float) -> void: - if _machine == null or _finished: - return - _watch_scene_signals() - if _metrics != null: - var tags: Dictionary = _machine.frame_tags() - tags["gpu_skin"] = OS.get_environment("MTGODOT_GPUSKIN").strip_edges() - _metrics.set_tags(tags) - _metrics.frame() - var started := Time.get_ticks_usec() - _machine.tick() - var spent := Time.get_ticks_usec() - started - _tick_us += spent - _tick_max_us = maxi(_tick_max_us, spent) - _ticks += 1 - return - _machine.tick() - -func _mark_observed(kind: String, _data: Dictionary) -> void: - if kind in MARKED_KINDS: - _metrics.mark(kind) - -## Quickbar / skill fx live in GameScene and are rebuilt with it; re-bind lazily. -func _watch_scene_signals() -> void: - if _app_flow == null or not _app_flow.has_method("get_playable_context"): - return - var context: Dictionary = _app_flow.get_playable_context() - var quickbar: Object = context.get("quickbar", null) - if quickbar != null and is_instance_valid(quickbar) and quickbar != _watched_quickbar: - _watched_quickbar = quickbar - _probe.watch_local(quickbar, "skill_cast_started", "cast_started") - _probe.watch_local(quickbar, "skill_rejected", "skill_rejected") - # EffectRegistry spawn / tree-exit boundaries (CBT-01 effect lifetime evidence). - var registry: Object = context.get("fx", null) - if registry != null and registry != _watched_fx: - _watched_fx = registry - _probe.watch_local(registry, "fx_spawned", "fx_spawned") - _probe.watch_local(registry, "fx_finished", "fx_finished") - -func _capture(case_id: String) -> String: - if DisplayServer.get_name() == "headless" or _output_dir.is_empty(): - return "" - var image := get_viewport().get_texture().get_image() - if image == null or image.is_empty(): - return "" - var file_name := "%s.png" % case_id.to_lower() - if image.save_png(_output_dir.path_join(file_name)) != OK: - return "" - return file_name - -func _on_machine_finished(_status: String) -> void: - _finish_later() - -func _finish_later() -> void: - if _finished: - return - _finished = true - call_deferred("_write_and_quit") - -func _write_and_quit() -> void: - if _machine != null: - _machine.cancel_inputs() - if _metrics != null: - _report.set_section("frame_metrics", _metrics.summary()) - _report.set_section("client_memory", {"start": _memory_start, "end": Metrics.memory_snapshot(), - "note": "MEMORY_STATIC 是 Godot 分配器计数,不是进程 RSS;RSS 由父运行器按 PID 外部采样"}) - _report.set_section("probe_overhead", {"sampler": Metrics.measure_overhead(), - "flow_tick": {"ticks": _ticks, "total_us": _tick_us, "max_us": _tick_max_us, - "mean_us": float(_tick_us) / maxf(1.0, _ticks)}}) - if _probe != null: - _probe.disconnect_all() - if not _report.write(_report_path): - printerr("PLAYABLE: cannot write client report") - get_tree().quit(1) - return - print("PLAYABLE CLIENT: %s" % _report.finish().status) - # Normal quit path: the parent runner owns the final exit gate. - get_tree().quit(0) diff --git a/project/playable_live_test.gd.uid b/project/playable_live_test.gd.uid deleted file mode 100644 index a0b68a03..00000000 --- a/project/playable_live_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://csn3wvcofmj1c diff --git a/project/playable_metrics_test.gd b/project/playable_metrics_test.gd deleted file mode 100644 index f9eed695..00000000 --- a/project/playable_metrics_test.gd +++ /dev/null @@ -1,136 +0,0 @@ -# playable_metrics_test —— STB-01 §9.1 帧间隔/长帧指标的离线自检(注入时钟,不等真实时间)。 -# godot --headless --path project --script playable_metrics_test.gd -extends SceneTree - -const Metrics = preload("res://testing/playable_metrics.gd") - -var _fail := 0 -var _done := false -var _now := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - _run() - _ck(_done, "all metrics assertions executed") - print("playable_metrics_test: failures=%d" % _fail) - if _fail == 0: - print("PASS: playable_metrics_test") - quit(1 if _fail else 0) - -func _metrics(options := {}) -> RefCounted: - var m: RefCounted = Metrics.new() - m.clock_us = func() -> int: return _now - m.configure(options) - return m - -## Advance the injected clock by `ms` and close one frame. -func _frame(m: RefCounted, ms: float) -> void: - _now += int(ms * 1000.0) - m.frame() - -func _run() -> void: - # Percentiles are nearest-rank over the window samples; max and counts are exact. - var p: RefCounted = _metrics({"window_seconds": 3600}) - p.set_tags({"map": "m", "phase": "play", "actor_count": 8, "gpu_skin": true}) - p.frame() - for i in range(1, 101): - _frame(p, float(i)) - var s: Dictionary = p.summary() - var w: Dictionary = s.windows[0] - _ck(w.count == 100 and w.sampled == 100 and not w.truncated, "one window with 100 samples: %s" % [w]) - _ck(w.p50_ms == 50.0 and w.p95_ms == 95.0 and w.p99_ms == 99.0 and w.max_ms == 100.0, - "nearest-rank p50/p95/p99/max: %s" % [w]) - _ck(w.over_50ms == 50 and w.over_100ms == 0, ">50ms and >100ms are strict thresholds: %s" % [w]) - _ck(s.print_frames == false and p.print_frames == false, "per-frame printing is off by default") - _ck(s.long_frames.size() == 50 and s.long_frames[0].ms == 51.0, "only samples above 50ms become long-frame events") - _ck(s.long_frames[0].tags == {"map": "m", "phase": "play", "actor_count": "8", "gpu_skin": "true"}, - "long frames carry map/phase/actor_count/gpu_skin tags: %s" % [s.long_frames[0].tags]) - _ck(s.frames == 100, "first frame() only arms the clock") - - # Fixed-size ring: a window longer than the capacity keeps exact counters but - # bounded sample storage, and says so. - var ring: RefCounted = _metrics({"capacity": 64, "window_seconds": 3600, "max_long_frames": 8, "max_markers": 4}) - ring.set_tags({"phase": "play"}) - ring.frame() - for i in 10000: - _frame(ring, 120.0 if i == 17 else (60.0 if i % 1000 == 0 else 16.0)) - if i % 100 == 0: - ring.mark("entity_batch", {"count": i}) - _ck(ring.stored_sample_count() <= 64, "ring buffer never grows past capacity (%d)" % ring.stored_sample_count()) - var rs: Dictionary = ring.summary() - var rw: Dictionary = rs.windows[0] - _ck(rw.count == 10000 and rw.sampled == 64 and rw.truncated, "overlong window is marked truncated: %s" % [rw]) - _ck(rw.max_ms == 120.0 and rw.over_100ms == 1 and rw.over_50ms == 11, "counters stay exact beyond capacity: %s" % [rw]) - _ck(rs.long_frames.size() == 8 and rs.long_frames_dropped == 3, "long-frame events are bounded with a drop count") - _ck(rs.markers.size() == 4 and rs.markers_dropped == 96, "markers are bounded with a drop count") - _ck(ring.long_unattributed_by_phase() == {"play": 11} and rs.long_frames.size() == 8, - "unattributed counts stay exact while the long-frame list is bounded: %s" % [ring.long_unattributed_by_phase()]) - - # Wall-clock windows close on the injected clock, not on a frame count. - var timed: RefCounted = _metrics({"window_seconds": 1, "max_windows": 3}) - timed.set_tags({"phase": "play"}) - timed.frame() - for i in 400: - _frame(timed, 16.0) - var ts: Dictionary = timed.summary() - _ck(ts.windows.size() == 3 and ts.windows_dropped >= 3, "closed windows are bounded (%d kept, %d dropped)" % [ts.windows.size(), ts.windows_dropped]) - _ck(ts.windows.all(func(x: Dictionary) -> bool: return x.end_ms - x.start_ms <= 1016), "each window spans about one wall-clock second") - - # A tag change closes the window and opens a separate segment; segments are capped. - var seg: RefCounted = _metrics({"max_segments": 2, "window_seconds": 3600}) - seg.set_tags({"phase": "loading", "map": "a"}) - seg.frame() - _frame(seg, 300.0) - seg.set_tags({"phase": "play", "map": "a"}) - _frame(seg, 16.0) - seg.set_tags({"phase": "play", "map": "b"}) - _frame(seg, 16.0) - var ss: Dictionary = seg.summary() - _ck(ss.windows.size() == 3 and ss.windows[0].tags.phase == "loading", "tag changes split windows: %s" % [ss.windows]) - _ck(ss.segments.size() == 2 and ss.unsegmented_frames == 1, "segment table is capped: %s" % [ss.segments]) - _ck(ss.segments[0].max_ms == 300.0 and ss.segments[0].over_100ms == 1, "loading segment is reported separately") - _ck(seg.severe_by_phase() == {"loading": 1}, "severe counts by phase: %s" % [seg.severe_by_phase()]) - - # Markers from the frame that was measured (and the one before it) attribute a long frame. - var att: RefCounted = _metrics({"window_seconds": 3600}) - att.set_tags({"phase": "play"}) - att.frame() - _frame(att, 16.0) - att.mark("model_build", {"race": 2301, "ms": 70.0}) - _frame(att, 16.0) - _frame(att, 80.0) - _frame(att, 16.0) - _frame(att, 16.0) - _frame(att, 75.0) - var at: Dictionary = att.summary() - _ck(at.long_frames.size() == 2 and at.long_frames[0].markers == ["model_build"], "long frame attributed to the recent marker: %s" % [at.long_frames]) - _ck(at.long_frames[1].markers.is_empty(), "later long frame without a marker stays unattributed") - _ck(att.unattributed_long_frames().size() == 1, "unattributed long frames are listed for analysis") - _ck(att.long_unattributed_by_phase() == {"play": 1} and at.long_unattributed_by_phase == {"play": 1}, - "unattributed long frames are counted per phase: %s" % [att.long_unattributed_by_phase()]) - _ck(at.markers[0].detail == {"race": 2301, "ms": 70.0}, "marker detail keeps only whitelisted keys") - att.mark("model_build", {"name": "Hero", "count": 2}) - _ck(not att.summary().markers[-1].detail.has("name"), "non-whitelisted marker detail is dropped") - - _ck([0, 1, 8, 9, 16, 17, 64, 65].map(func(n: int) -> String: return Metrics.actor_bucket(n)) - == ["0", "1-8", "1-8", "9-16", "9-16", "17-32", "33-64", "65+"], "actor counts are bucketed") - - # §9.3: the same interaction repeated 3 times with >100ms in at least 2 blocks. - _ck(Metrics.repeated_severe({"ATTACK": [true, false, true], "MOVE": [true, false, false, true], "DROP": [false, true, true]}) - == ["ATTACK", "DROP"], "2-of-3 consecutive repetitions with a >100ms frame are flagged") - _ck(Metrics.repeated_severe({"ATTACK": [true, true]}).is_empty(), "fewer than 3 repetitions cannot be judged yet") - - # Memory: MEMORY_STATIC is recorded; GPU memory is never reported as 0. - var mem: Dictionary = Metrics.memory_snapshot() - _ck(mem.memory_static_kib is int and mem.memory_static_kib > 0, "MEMORY_STATIC in KiB: %s" % [mem]) - _ck(mem.gpu_memory_kib == null and mem.gpu_memory_status == "unavailable", "GPU memory is null/unavailable") - _ck(mem.godot_video_mem_kib == null or int(mem.godot_video_mem_kib) > 0, "Godot video allocation is null when unknown, not 0") - - # Probe cost is measured, not assumed. - var cost: Dictionary = Metrics.measure_overhead(2000) - _ck(cost.iterations == 2000 and cost.per_sample_us > 0.0 and cost.per_sample_us < 1000.0, "per-sample probe cost measured: %s" % [cost]) - _done = true diff --git a/project/playable_metrics_test.gd.uid b/project/playable_metrics_test.gd.uid deleted file mode 100644 index cb66e811..00000000 --- a/project/playable_metrics_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bq24ucjjfktlo diff --git a/project/playable_skill_matrix.gd b/project/playable_skill_matrix.gd deleted file mode 100644 index d2d6b243..00000000 --- a/project/playable_skill_matrix.gd +++ /dev/null @@ -1,64 +0,0 @@ -extends SceneTree - -## CBT-01 技能矩阵检查:读取 test/playable/skill-cases.json(或 --matrix 指定的本地副本), -## 按职业校验并输出可直接放入场景配置的 skill_cases。只读数据,不连网络、不读凭据。 -## -## godot --headless --path project --script playable_skill_matrix.gd -- --job WARRIOR [--matrix PATH] -## -## stdout: 一行 "SKILL_MATRIX {json}"。exit 0 = READY,1 = 矩阵错误,2 = 存在未确认条目(BLOCKED)。 - -const Config = preload("res://testing/playable_config.gd") -const SkillTable = preload("res://ui/skill_table.gd") - -func _init() -> void: - var args := OS.get_cmdline_user_args() - var job := "" - var matrix_path := ProjectSettings.globalize_path("res://").path_join("../test/playable/skill-cases.json").simplify_path() - var i := 0 - while i < args.size(): - match args[i]: - "--job": - i += 1 - job = String(args[i]).to_upper() if i < args.size() else "" - "--matrix": - i += 1 - matrix_path = args[i] if i < args.size() else "" - "--help": - print("usage: playable_skill_matrix.gd -- --job WARRIOR|ASSASSIN|SURA|SHAMAN [--matrix PATH]") - quit(0) - return - _: - printerr("SKILL_MATRIX: unknown argument %s" % args[i]) - quit(1) - return - i += 1 - var result := check(matrix_path, job) - print("SKILL_MATRIX " + JSON.stringify(result)) - quit({"READY": 0, "BLOCKED": 2}.get(result.status, 1)) - -static func check(matrix_path: String, job: String) -> Dictionary: - var out := {"status": "FAIL", "job": job, "errors": [], "blocked": [], "not_applicable": [], "skill_cases": []} - var file := FileAccess.open(matrix_path, FileAccess.READ) - if file == null: - out.errors.append("skill matrix cannot be opened") - return out - var parsed: Variant = JSON.parse_string(file.get_as_text()) - if not (parsed is Dictionary): - out.errors.append("skill matrix is not a JSON object") - return out - var table := SkillTable.new() - var loaded := false - for lang in ["en", "common"]: - if table.load_file(AssetRoot.path().path_join("locale/locale/%s/skilldesc.txt" % lang)): - loaded = true - break - var resolved := Config.skill_matrix_cases(parsed, job, table if loaded else null) - out.errors = resolved.errors - if not loaded: - out.errors.append("skilldesc.txt is unavailable; skill ownership cannot be checked") - out.blocked = resolved.blocked - out.not_applicable = resolved.not_applicable - out.skill_cases = resolved.cases - if out.errors.is_empty(): - out.status = "BLOCKED" if not out.blocked.is_empty() else "READY" - return out diff --git a/project/playable_skill_matrix.gd.uid b/project/playable_skill_matrix.gd.uid deleted file mode 100644 index 57ace543..00000000 --- a/project/playable_skill_matrix.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bypranefpw11x diff --git a/project/player_controller.gd b/project/player_controller.gd deleted file mode 100644 index 621682c1..00000000 --- a/project/player_controller.gd +++ /dev/null @@ -1,828 +0,0 @@ -# PlayerController —— Metin2 风移动 + 选目标(客户端预测,无服务端纠正)。 -# 左键点地面 -> 走/跑过去(沿途查 attr.atr 可行走,遇阻停) -# 左键点实体 -> 选中(emit target_selected) -# WASD/方向键 -> 直接移动(相对相机),覆盖点地 -# 触屏(F7):单指轻点 = 点地/点选(快速抬起且位移小);单指拖拽归相机环绕, -# 双指手势时不产生点地。 -# 需要:player(Node3D)、camera(GameCamera)、world(Metin2World)。 -# 每帧 emit anim_state("wait"|"walk"|"run") 供上层切动画。 -extends Node - -signal anim_state(state: String) -signal target_selected(node: Node3D) -signal ground_clicked(world_pos: Vector3) -signal ground_item_clicked(vid: int) -signal moved(pos: Vector3) -# game.py OnRender:wndMgr.IsPickedWindow 门内 chr.Pick() 拾到的角色 -> ShowCharacterTextTail。 -# 悬停命中的实体 vid 变化时 emit(无命中 / 鼠标压 UI / 悬停地面物品 -> emit 0)。见 pick_show.gd。 -signal hover_entity_changed(vid: int) - -var player: Node3D: - set(v): - player = v - if player: - _src_pos = player.position - _dst_pos = player.position -var camera: Camera3D -var world: Node -var cursor_manager: Node -var ui_manager: Node -var ground_items: Node -var cancel_fishing_input := Callable() # NEW_SetSingleDIKKeyState 等价入口 -var cancel_fishing_ground := Callable() # __OnPressGround 专用入口 -var pickables: Array[Node3D] = [] # 可点选实体(NPC/怪等) -var _hover_vid := 0 # 上一帧悬停命中的实体 vid(hover_entity_changed 去抖) - -const SPEED_WALK := 1.8 -const SPEED_RUN := 4.8 -const KEYBOARD_MOVE_TARGET_M := 3.0 # 40250 NEW_MoveToDirection: Dst = Cur + 300 px -const RUN_HOLD_KEY := KEY_SHIFT -const ARRIVE_EPS := 0.05 -const CAMERA_AUTO_ROTATE_SPEED_DEG := 20.0 # 40250 CPythonPlayer::m_fCmrRotSpd 默认值 -const PICK_RADIUS := 1.4 # 点选命中半径(米) -# __IsMovableGroundDistance:点地目标离脚下太近就不动(避免原地抖)。参考默认由 -# player.SetMovableGroundDistance 从 game.py 设置;此处用参考缺省值(~1 m)。§3.1 -const MOVABLE_GROUND_DISTANCE_M := 1.0 -# __ReserveClickGround 设的 m_fReservedDelayTime(PythonPlayerInput.cpp:834)。 -const RESERVED_GROUND_DELAY := 0.1 -# Actor-Actor 碰撞(对齐 CInstanceBase::CheckAdvancing,InstanceBaseBattle.cpp:469 + -# CActorInstance::TestActorCollision,ActorInstanceCollisionDetection.cpp)。参考用 race -# 碰撞球,POC 无骨骼碰撞数据 → 固定 body 半径近似;自身 + 对方两球相加为判定阈值。§3.1 -const ACTOR_BODY_RADIUS_M := 0.55 -# TestActorCollision 的距离门:LengthSq(victim - self) > 800 cm² 直接不检。 -const ACTOR_COLLIDE_MAX_DIST_M := 8.0 -# c_fDefaultRotationSpeed 1200 / c_fDefaultHorseRotationSpeed 300(InstanceBase.cpp:17-18): -# MountHorse() 把转向速度降到 300,下马恢复 1200。 -const ROT_SPEED_DEFAULT_DEG := 1200.0 -const ROT_SPEED_HORSE_DEG := 300.0 - -var default_run := true # 40250 对齐:默认奔跑模式(ActorInstance.cpp:544 m_isWalking = FALSE) -var force_run := false # 脚本化 / 自动跑 -var force_walk := false # 步行模式 -var frozen := false # 受击硬直等:本帧不响应移动输入(net_play 设) -var locked := false # CInstanceBase::isLock():完全锁死移动(过场 / 特定 affect) -var dead := false # 40250 CActorInstance::IsDead:死亡期间停止一切本地移动输入 -var moving_skill := false # IsUsingMovingSkill():移动技能中,只允许转向不平移 -var private_shop_open := false # CPythonPlayer::IsOpenPrivateShop:摆摊期间输入只保留按键状态 -var processing_emotion := false # CPythonPlayer::__IsProcessingEmotion:情绪动作期间禁止移动 -var server_speed_scale := 1.0 # GC_CHANGE_SPEED moving_speed / 100 -var rotation_speed_deg := ROT_SPEED_DEFAULT_DEG # 上/下马时由 net_play 切换(§3.1 / §6.4) -# CActorInstance::CanSkipCollision():网络状态包驱动的移动整段跳过 Actor 碰撞 -# (§3.2 __EnableSkipCollision,观战 / 服务器纠正时置真)。 -var skip_actor_collision := false -# 40250 CPythonPlayer::NEW_SetSingleDirKeyState:方向由进入游戏后的按下/松开事件 -# 维护,不能每帧查询系统全局键盘状态。否则加载场景时仍按着的键会被继承,表现为 -# 刚进入游戏角色就自动移动。 -var _key_up := false -var _key_down := false -var _key_left := false -var _key_right := false -var _last_wasd := Vector2.ZERO -var _mobile_axis := Vector2.ZERO -var _mobile_active := false -var _mobile_ui_touches := {} - -# --- §3.1 Src/Dst 移动模型(对齐 InstanceBaseMovement.cpp NEW_Goto / NEW_MoveToDirection)--- -# _is_going = m_isGoing(点地走向 Dst);方向键移动保持独立的 300cm Dst, -# 因为 40250 NEW_MoveToDirection 会把 m_isGoing 置 false。 -var _src_pos := Vector3.ZERO -var _dst_pos := Vector3.ZERO -var _is_going := false -var _dst_rot := 0.0 -var _direction_dst := Vector3.ZERO -var _direction_wish := Vector3.ZERO -var _direction_active := false -var _direction_resume_pending := false -var _reserved_ground: Variant = null # __ReserveClickGround 的待处理点 -var _reserved_delay_time := 0.0 # NEW_IsEmptyReservedDelayTime 递减到 0 才生效 - -var active := true: # 进游戏装配/加载中置假,防止过早响应点地 - set(value): - if active == value: - return - active = value - # 切换输入阶段相当于 40250 game.Close/Open 重建按键状态。只有在游戏窗 - # 激活后新收到的 KeyDown 才能驱动角色。 - _clear_direction_keys() -# 40250 CInstanceBase::NEW_Stop() / CPythonPlayer::NEW_Stop():停止点地移动与方向输入,重置预约 -func stop() -> void: - _is_going = false - _reserved_ground = null - _reserved_delay_time = 0.0 - _last_wasd = Vector2.ZERO - _clear_direction_keys() - _mobile_active = false - _mobile_axis = Vector2.ZERO - _mobile_ui_touches.clear() - _multi_gesture = false - _touch_count = 0 - _tap_index = -1 - if player: - _src_pos = player.position - _dst_pos = player.position - anim_state.emit("wait") - -func set_dead(value: bool) -> void: - if dead == value: - return - dead = value - if dead: - # GC_DEAD/HP<=0 has the same movement boundary as NEW_Stop, but must - # retain the dead flag until a positive HP/re-entry transition arrives. - stop() - else: - # Re-entry starts from the server/current actor position. Do not emit a - # second wait here; the next authoritative animation state owns that. - if player: - _src_pos = player.position - _dst_pos = player.position - -# 40250 CPythonPlayer::OpenPrivateShop / ClosePrivateShop。 -# 个人摊位建立后,方向键仍由 NEW_SetSingleDirKeyState 记录,但 -# NEW_SetMultiDirKeyState -> __CanMove 不允许产生新的平移;收摊后由 -# Update() 再次提交仍按住的方向键。 -func set_private_shop_open(value: bool) -> void: - if private_shop_open == value: - return - private_shop_open = value - if private_shop_open: - _is_going = false - _reserved_ground = null - _reserved_delay_time = 0.0 - _direction_active = false - _direction_resume_pending = _wasd() != Vector2.ZERO - _direction_wish = Vector3.ZERO - if player: - _direction_dst = player.position - -# 40250 CPythonPlayer::StartEmotionProcess / EndEmotionProcess。 -# 情绪开始时会清预约动作并锁住主角,方向位不能丢,动作结束后由 -# PlayerController 的下一次 Update 恢复与原客户端 m_isDirKey 相同的重试。 -func set_processing_emotion(value: bool) -> void: - if processing_emotion == value: - return - processing_emotion = value - if processing_emotion: - _is_going = false - _reserved_ground = null - _reserved_delay_time = 0.0 - _direction_active = false - _direction_resume_pending = _wasd() != Vector2.ZERO - _direction_wish = Vector3.ZERO - if player: - _direction_dst = player.position - -func _clear_direction_keys() -> void: - _key_up = false - _key_down = false - _key_left = false - _key_right = false - _direction_active = false - _direction_resume_pending = false - _direction_wish = Vector3.ZERO - if player: - _direction_dst = player.position - -func _notification(what: int) -> void: - if what == NOTIFICATION_APPLICATION_FOCUS_OUT: - # KeyUp 可能在窗口失焦期间丢失;参考客户端失去游戏窗口输入后停止移动。 - _clear_direction_keys() - _last_wasd = Vector2.ZERO - -func _input(e: InputEvent) -> void: - if not (e is InputEventKey) or e.echo: - return - var key := e as InputEventKey - # 松开事件必须始终接收,即使此时 UI 正在拦截游戏输入,否则会留下粘键。 - # KeyDown 则在 _unhandled_input 中接收,让聊天输入框有机会先消费 WASD。 - if not key.pressed: - _set_direction_key_state(key, false) - -func _set_direction_key_state(e: InputEventKey, pressed: bool) -> void: - var logical := e.keycode - var physical := e.physical_keycode - if logical in [KEY_W, KEY_UP] or physical in [KEY_W, KEY_UP]: - _key_up = pressed - elif logical in [KEY_S, KEY_DOWN] or physical in [KEY_S, KEY_DOWN]: - _key_down = pressed - elif logical in [KEY_A, KEY_LEFT] or physical in [KEY_A, KEY_LEFT]: - _key_left = pressed - elif logical in [KEY_D, KEY_RIGHT] or physical in [KEY_D, KEY_RIGHT]: - _key_right = pressed - _refresh_direction_target() - -func _keyboard_wish(dir: Vector2) -> Vector3: - if dir == Vector2.ZERO: - return Vector3.ZERO - var yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0) - var fwd := -Vector3(sin(yaw), 0, cos(yaw)) - var right := Vector3(-fwd.z, 0, fwd.x) - return (fwd * -dir.y + right * dir.x).normalized() - -# CPythonPlayer::NEW_SetMultiDirKeyState -> CInstanceBase::NEW_MoveToDirection. -# The reference writes a new 300-pixel destination only when the direction state -# changes; it does not continuously extend the destination every frame. -func _refresh_direction_target() -> void: - if not active or player == null: - return - var dir := _wasd() - if dir == Vector2.ZERO: - _direction_active = false - _direction_resume_pending = false - _direction_wish = Vector3.ZERO - _direction_dst = player.position - return - if dead or frozen or locked or private_shop_open or processing_emotion: - _direction_active = false - _direction_resume_pending = true - _direction_wish = Vector3.ZERO - _direction_dst = player.position - return - _direction_wish = _keyboard_wish(dir) - # NEW_MoveToDirection keeps the facing update for moving skills, but does - # not create a translating destination while the skill owns movement. - if moving_skill: - _direction_active = false - _direction_resume_pending = true - _direction_dst = player.position - return - _direction_dst = player.position + _direction_wish * KEYBOARD_MOVE_TARGET_M - _direction_active = true - _direction_resume_pending = false - -# CInstanceBase::__IsSyncing():Dead / Stun / Pushing 中不接受移动输入(net_play 置 frozen)。 -func is_going() -> bool: - return _is_going - -# NEW_Goto / NEW_MoveToDirection 的三道前置门:syncing / moving-skill(只转向) / lock。 -func _can_translate() -> bool: - return active and not dead and not frozen and not locked and not moving_skill \ - and not private_shop_open and not processing_emotion - -const _TAP_TRAVEL_MAX := 12.0 # 触点位移超过这么多像素 -> 视作拖拽(归相机),不是轻点 -const _TAP_TIME_MAX := 0.35 # 按下到抬起超过这么久 -> 不是轻点 - -var _run := false -var _touch_count := 0 -var _multi_gesture := false # 曾有 ≥2 指同时按下 -> 本轮不产生点地 -var _tap_index := -1 -var _tap_press_pos := Vector2.ZERO -var _tap_press_t := 0.0 - -func set_server_speed(moving_speed: int) -> void: - # Metin2 POINT_MOV_SPEED uses 100 as the normal baseline. Keep malformed or - # pre-spawn zero values from freezing local prediction. - if moving_speed <= 0: - return - # CInstanceBase::SetMoveSpeed(UINT uMovSpd): only guard is uMovSpd > 1100 -> - # 0 (frozen walk pose, no root-motion advance); otherwise uMovSpd/100.0f is - # applied unclamped. EntityStore::motion_move_speed already mirrors this for - # remote actors; there is no reference basis for an arbitrary [0.25, 3.0] - # band on the local player, so a big haste stack or heavy slow debuff must - # scale local prediction the same way it scales everyone else's remote view. - server_speed_scale = 0.0 if moving_speed > 1100 else float(moving_speed) / 100.0 - -func set_input_surfaces(cursor: Node, ui: Node = null, ground: Node = null, - cancel_fishing_cb := Callable(), ground_cancel_fishing_cb := Callable()) -> void: - cursor_manager = cursor - ui_manager = ui - ground_items = ground - cancel_fishing_input = cancel_fishing_cb - cancel_fishing_ground = ground_cancel_fishing_cb - -# MobileUiRoot feeds a normalized left-thumb axis directly. Keeping it on the -# controller (instead of synthesizing keyboard events) preserves the existing -# camera-relative movement, collision and animation path for both platforms. -func set_mobile_axis(axis: Vector2) -> void: - _mobile_axis = axis.limit_length(1.0) - -func clear_mobile_input() -> void: - _mobile_axis = Vector2.ZERO - _mobile_active = false - _mobile_ui_touches.clear() - if _touch_count == 0: - _multi_gesture = false - -## MobileInputOverlay calls this before/after a control consumes its touch. -## World touch handling must know about that finger even though the GUI event -## never reaches _unhandled_input; otherwise a joystick finger plus a world -## finger can look like a single tap-to-move gesture. -func set_mobile_ui_touch(index: int, pressed: bool) -> void: - if pressed: - _mobile_ui_touches[index] = true - if _touch_count > 0: - _multi_gesture = true - _tap_index = -1 - else: - _mobile_ui_touches.erase(index) - if _touch_count == 0 and _mobile_ui_touches.is_empty(): - _multi_gesture = false - -# 程序化下发一个点地目标(脚本化截图 / AI)——等价 NEW_MoveToDestPixelPositionDirection。 -func walk_to(world_pos: Vector3) -> void: - _goto(Vector3(world_pos.x, 0.0, world_pos.z)) - -# CInstanceBase::NEW_Goto(InstanceBaseMovement.cpp:251):三门通过后设 Src/Dst + m_isGoing。 -# 被门挡下(syncing / lock)时按 __ReserveClickGround 预约,delay 后重试。 -func _goto(dst_flat: Vector3) -> bool: - if not active or player == null: - return false - var flat := Vector3(dst_flat.x - player.position.x, 0.0, dst_flat.z - player.position.z) - # __IsMovableGroundDistance:离脚下太近不动 - if flat.length() < MOVABLE_GROUND_DISTANCE_M: - return true - if not _can_translate(): - _reserved_ground = dst_flat - _reserved_delay_time = RESERVED_GROUND_DELAY - return false - _src_pos = player.position - _dst_pos = Vector3(dst_flat.x, 0.0, dst_flat.z) - _dst_rot = atan2(flat.x, flat.z) - _is_going = true - return true - -func _unhandled_input(e: InputEvent) -> void: - if not active or dead: - return - if e is InputEventKey: - var key := e as InputEventKey - if key.pressed and not key.echo: - _set_direction_key_state(key, true) - elif e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed: - _on_click(e.position) - elif e is InputEventScreenTouch: - _on_touch(e) - elif e is InputEventScreenDrag: - # 触点一旦拖出去,就不再是「轻点」,交给相机环绕 - if e.index == _tap_index and e.position.distance_to(_tap_press_pos) > _TAP_TRAVEL_MAX: - _tap_index = -1 - -func _on_touch(e: InputEventScreenTouch) -> void: - if e.pressed: - _touch_count += 1 - if _touch_count >= 2 or not _mobile_ui_touches.is_empty(): - _multi_gesture = true - _tap_index = -1 - elif not _multi_gesture: - _tap_index = e.index - _tap_press_pos = e.position - _tap_press_t = Time.get_ticks_msec() / 1000.0 - else: - _touch_count = max(0, _touch_count - 1) - if e.index == _tap_index and not _multi_gesture: - var travel := e.position.distance_to(_tap_press_pos) - var held := Time.get_ticks_msec() / 1000.0 - _tap_press_t - if travel <= _TAP_TRAVEL_MAX and held <= _TAP_TIME_MAX: - _on_click(e.position) - _tap_index = -1 - if _touch_count == 0 and _mobile_ui_touches.is_empty(): - _multi_gesture = false - -func _on_click(screen_pos: Vector2) -> void: - if not active or camera == null or player == null: - return - - # 0) 掉落物点选(对齐 40250 __GetPickedItemID 优先于 Actor/Ground) - if ground_items and ground_items.has_method("pick_at"): - var picked_item_vid: int = ground_items.call("pick_at", camera, screen_pos) - if picked_item_vid != 0: - ground_item_clicked.emit(picked_item_vid) - return - - var from := camera.project_ray_origin(screen_pos) - var dir := camera.project_ray_normal(screen_pos) - - # 1) 实体点选:射线 vs 每个 pickable 的网格世界包围盒(slab 相交,逐实体精确, - # 大怪 / 小怪都不再被固定 1.4m 半径误判)。无网格的回退到胶囊近似。 - var best: Node3D = null - var best_t := 1e20 - for n in pickables: - if not is_instance_valid(n): - continue - if bool(n.get_meta("dead", false)) or not bool(n.get_meta("can_pick", true)): - continue - var t := _ray_pick_t(from, dir, n) - if t >= 0.0 and t < best_t: - best_t = t - best = n - if best: - target_selected.emit(best) - return - - # 2) 点地:射线与地表求交(沿射线二分找 y == 地表高度)→ NEW_Goto - var hit: Variant = _ray_ground(from, dir) - if hit != null: - if cancel_fishing_ground.is_valid() and bool(cancel_fishing_ground.call()): - return - if cancel_fishing_input.is_valid() and bool(cancel_fishing_input.call()): - return - ground_clicked.emit(Vector3(hit.x, hit.y, hit.z)) - _goto(hit) - else: - # 40250 __OnClickSmart falls through to NEW_Stop when the click hits - # neither an item, actor, nor ground. Without this boundary a previous - # click-to-move destination keeps running after clicking empty UI-free - # space, which makes the actor appear to move on its own. - stop() - -# 射线 vs 实体(节点下所有 MeshInstance3D 的世界 AABB 合并)的进入距离 t;不命中返回 -1。 -func _ray_pick_t(from: Vector3, dir: Vector3, node: Node3D) -> float: - var box := AABB() - var have := false - for m in node.find_children("*", "MeshInstance3D", true, false): - var mi := m as MeshInstance3D - if mi.mesh == null: - continue - var wb: AABB = mi.get_global_transform() * mi.get_aabb() - box = wb if not have else box.merge(wb) - have = true - if not have: - # 无网格:退回胶囊近似(头顶 1m,半径 PICK_RADIUS) - var to := node.global_position + Vector3(0, 1.0, 0) - from - var tp := to.dot(dir) - if tp < 0.0: - return -1.0 - var perp := (from + dir * tp) - (node.global_position + Vector3(0, 1.0, 0)) - return tp if perp.length() < PICK_RADIUS else -1.0 - box = box.grow(0.15) # 点选宽容度 - # slab 法求射线进入 box 的 t - var tmin := -1e20 - var tmax := 1e20 - for axis in 3: - var o: float = from[axis] - var d: float = dir[axis] - var lo: float = box.position[axis] - var hi: float = box.position[axis] + box.size[axis] - if absf(d) < 1e-9: - if o < lo or o > hi: - return -1.0 - else: - var t1 := (lo - o) / d - var t2 := (hi - o) / d - if t1 > t2: - var tmp := t1; t1 = t2; t2 = tmp - if t1 > tmin: tmin = t1 - if t2 < tmax: tmax = t2 - if tmin > tmax or tmax < 0.0: - return -1.0 - return tmin if tmin >= 0.0 else tmax - -# 射线 vs 地表:沿射线在 [0, 80m] 区间二分找 ray.y == world.sample_height(ray.x, ray.z)。 -func _ray_ground(from: Vector3, dir: Vector3) -> Variant: - if world == null or not world.has_method("sample_height"): - return null - var last_above: bool = from.y - float(world.call("sample_height", from.x, from.z)) > 0.0 - var t := 0.0 - for _i in 240: - t += 1.0 - var p := from + dir * t - if t > 800.0: - break - var above: bool = p.y - float(world.call("sample_height", p.x, p.z)) > 0.0 - if above != last_above: - # 在 [t-1, t] 之间二分 - var a := t - 1.0 - var b := t - for _j in 12: - var m := (a + b) * 0.5 - var pm := from + dir * m - if (pm.y - world.call("sample_height", pm.x, pm.z) > 0) == last_above: - a = m - else: - b = m - var pf := from + dir * b - return Vector3(pf.x, float(world.call("sample_height", pf.x, pf.z)), pf.z) - last_above = above - return null - -func _wasd() -> Vector2: - if not (_key_up or _key_down or _key_left or _key_right): - return Vector2.ZERO - var d := Vector2.ZERO - # 对齐 40250 CPythonPlayer::NEW_GetMultiKeyDirRotation: Up优先于Down,Left优先于Right - if _key_up: - d.y = -1.0 - elif _key_down: - d.y = 1.0 - if _key_left: - d.x = -1.0 - elif _key_right: - d.x = 1.0 - return d.normalized() - -# 对齐 40250 CPythonPlayer::NEW_MoveToDirection 的 m_isCmrRot 分支:dir 是 _wasd() 的相机相对 -# 输入向量(0,-1=正前)。折算成角色相对相机的方位角 fDirRot(0=前 90=左 180=后 270=右, -# 与 GetDegreeFromPosition 的取值一致),再按参考实现同样的公式折叠到 [-90,90] 的 -# fRotRat:正前/正后为 0(不转),正左/右为 ±90(转速最快),对角线为 ±45(减半)。 -# 参考实现用 CCamera::Roll(fRotDeg)(D3D 增量式 roll,fRotDeg=-speed*dt*fRotRat/90); -# Godot 的 yaw 增大对应视线转向左,和 D3D 的 Roll 正方向手性相反,因此这里的增量对 -# fRotRat 取正号而不是参考的负号 —— 这是platform_adaptation,用“相机持续转向新朝向、 -# 从不越过目标”这一收敛不变式核验(keyboard_motion_timeline_test.gd),而不是逐位对照 -# D3D 数值。 -func _camera_auto_rotate(dir: Vector2, dt: float) -> void: - if camera == null or not camera.has_method("heading") or dir == Vector2.ZERO: - return - var dir_rot_deg := fposmod(rad_to_deg(atan2(-dir.x, -dir.y)), 360.0) - var sig_dir_rot := dir_rot_deg - if sig_dir_rot > 180.0: - sig_dir_rot -= 360.0 - var rot_rat := sig_dir_rot - if rot_rat > 90.0: - rot_rat = 180.0 - rot_rat - elif rot_rat < -90.0: - rot_rat = -180.0 - rot_rat - camera.yaw += deg_to_rad(CAMERA_AUTO_ROTATE_SPEED_DEG * dt * rot_rat / 90.0) - -func _blocked(x: float, z: float) -> bool: - return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", x, z)) - -# CActorInstance::TestActorCollision(ActorInstanceCollisionDetection.cpp)近似:距离门 -# (800 cm)→ body 球重叠 → 「趋近」判定(新位比旧位更近才算撞,远离时放行 → 能从 -# 重叠里走出来)。每个 pickable(已生成的远端 NPC / 怪 / 他人节点)当作一个 Actor 碰撞体; -static func _is_static_actor(n: Node3D) -> bool: - if not is_instance_valid(n): - return false - var shape: String = String(n.get_meta("cursor_shape", "")) - if shape == "DOOR": - return true - var kind: int = int(n.get_meta("kind", -1)) - if kind in [0, 1, 3]: # KIND_BUILDING, KIND_WOODEN_DOOR, KIND_STONE - return true - return false - -# 40250 Actor-Actor 阻挡判定与阻挡实体获取 -# ActorInstanceCollisionDetection.cpp:640: 仅在两实体距离缩小(d_next < d_now)且侵入包围半径时阻挡 -func _get_blocking_actor(next_pos: Vector3) -> Node3D: - if skip_actor_collision or player == null or locked: - return null - var cur: Vector3 = player.global_position if player.is_inside_tree() else player.position - var combined := ACTOR_BODY_RADIUS_M * 2.0 - for n in pickables: - if not is_instance_valid(n): - continue - if bool(n.get_meta("dead", false)) or not bool(n.get_meta("can_pick", true)): - continue - var op: Vector3 = n.global_position if n.is_inside_tree() else n.position - var d_now := Vector2(op.x - cur.x, op.z - cur.z).length() - if d_now > ACTOR_COLLIDE_MAX_DIST_M: - continue - var d_next := Vector2(op.x - next_pos.x, op.z - next_pos.z).length() - if d_next < combined and d_next < d_now: - return n - return null - -func _actor_blocked(next_pos: Vector3) -> bool: - var b := _get_blocking_actor(next_pos) - return b != null and _is_static_actor(b) - -# SetAdvancingRotation(InstanceBaseMovement.cpp:142-154): -# 差值 > 45° 时全速转向(步战 1200°/s,骑乘 300°/s);差值 <= 45° 时按 5/12 阻尼减速(500°/s 或 125°/s)。 -func _turn_toward(target_yaw: float, dt: float) -> void: - if player == null: - return - var cur_yaw: float = wrapf(player.rotation.y, -PI, PI) - var diff: float = wrapf(target_yaw - cur_yaw, -PI, PI) - var spd := rotation_speed_deg - if absf(diff) <= deg_to_rad(45.0): - spd *= 5.0 / 12.0 - var max_step := deg_to_rad(spd) * dt - var next_yaw := target_yaw if max_step <= 0.0 or absf(diff) <= max_step \ - else wrapf(cur_yaw + signf(diff) * max_step, -PI, PI) - # 只允许直立偏航。逐项修改 Euler 分量在 W/S 180° 快切和 ±PI 边界附近可能 - # 被引擎重新分解为 X/Z = PI,看起来就是角色翻跟头。 - player.rotation = Vector3(0.0, wrapf(next_yaw, -PI, PI), 0.0) - -func _process(dt: float) -> void: - if player == null or not active: - return - if dead: - if world and world.has_method("sample_height") and is_instance_valid(player): - player.position.y = float(world.call("sample_height", player.position.x, player.position.z)) - return - _update_hover_cursor() - - # __ReserveClickGround 的 delay 递减(NEW_IsEmptyReservedDelayTime);到点且门已开则重试。 - if _reserved_delay_time > 0.0: - _reserved_delay_time -= dt - if _reserved_delay_time <= 0.0 and _reserved_ground != null and _can_translate(): - var rg: Vector3 = _reserved_ground - _reserved_ground = null - _goto(rg) - - if frozen or locked or private_shop_open or processing_emotion: - # 40250 keeps m_isDirKey and retries NEW_SetMultiDirKeyState from - # CPythonPlayer::Update while the actor is locked/syncing. Do not lose a - # held WASD direction or let its old fixed target resume after unlock. - if _wasd() != Vector2.ZERO: - _direction_active = false - _direction_resume_pending = true - _direction_wish = Vector3.ZERO - _direction_dst = player.position - # 停止平移,但保留 m_isGoing(解锁后继续走向 Dst,等价预约不丢)。 - # 处于攻击或施法锁定中(locked),不发送 wait 动画中断技能/普攻动作 - if frozen and not locked: - anim_state.emit("wait") - if world and world.has_method("sample_height") and is_instance_valid(player): - player.position.y = float(world.call("sample_height", player.position.x, player.position.z)) - return - # 40250 CPythonPlayer::Update 每帧对仍按住的方向键重试 NEW_SetMultiDirKeyState -> - # NEW_MoveToDirection,其 m_isCmrRot 分支与是否已建立平移目标无关,门检查(上面 - # 的 frozen/locked/shop/emotion)通过后即执行,因此放在移动逻辑之前、无条件按 - # 当前方向键调用。 - _camera_auto_rotate(_wasd(), dt) - if force_walk: - _run = false - elif force_run: - _run = true - elif default_run: - _run = not Input.is_key_pressed(RUN_HOLD_KEY) and not Input.is_physical_key_pressed(RUN_HOLD_KEY) - else: - _run = Input.is_key_pressed(RUN_HOLD_KEY) or Input.is_physical_key_pressed(RUN_HOLD_KEY) - var wish := Vector3.ZERO - var wasd := _wasd() - if moving_skill and wasd != Vector2.ZERO and _direction_active: - # IsUsingMovingSkill() takes ownership of the actor motion immediately; - # discard the old translating destination but keep the held direction for - # the skill's facing update and for the post-skill retry. - _direction_active = false - _direction_resume_pending = true - _direction_wish = _keyboard_wish(wasd) - _direction_dst = player.position - if wasd != Vector2.ZERO and _direction_resume_pending and _can_translate(): - # The next Update after a lock/moving-skill edge is the same retry point - # as the reference's per-frame m_isDirKey branch. - _refresh_direction_target() - wasd = _wasd() - var mobile_axis := _mobile_axis - - if mobile_axis != Vector2.ZERO: - # A new thumb gesture has the same fishing-cancel edge as a keyboard move. - if not _mobile_active and cancel_fishing_input.is_valid() \ - and bool(cancel_fishing_input.call()): - _mobile_active = true - _last_wasd = Vector2.ZERO - anim_state.emit("wait") - return - _mobile_active = true - _is_going = false - _reserved_ground = null - _last_wasd = Vector2.ZERO - # GameCamera 位于 head + (sin yaw, cos yaw)*dist 并看向 head:视线的水平前方是 - # -(sin yaw, cos yaw),屏幕右 = 前方 × UP。 - var mobile_yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0) - var mobile_fwd := -Vector3(sin(mobile_yaw), 0, cos(mobile_yaw)) - var mobile_right := Vector3(-mobile_fwd.z, 0, mobile_fwd.x) - wish = (mobile_fwd * -mobile_axis.y + mobile_right * mobile_axis.x).normalized() - elif wasd != Vector2.ZERO: - _mobile_active = false - # PythonPlayerInputKeyboard 只在方向键按下时尝试取消钓鱼;记录方向 - # 边沿,避免按住键时每帧重复发送 CG_FISHING(0)。 - if wasd != _last_wasd and cancel_fishing_input.is_valid() \ - and bool(cancel_fishing_input.call()): - _last_wasd = wasd - anim_state.emit("wait") - return - _last_wasd = wasd - _is_going = false # 键盘覆盖点地(NEW_MoveToDirection:m_isGoing = FALSE) - _reserved_ground = null - # The reference creates a fixed 300-pixel destination on the key event; - # do not continuously extend it from the current position every frame. - if _direction_active or moving_skill: - wish = _direction_wish - elif _is_going: - _mobile_active = false - var flat := Vector3(_dst_pos.x - player.position.x, 0, _dst_pos.z - player.position.z) - var dist := flat.length() - if dist <= ARRIVE_EPS: - player.position.x = _dst_pos.x - player.position.z = _dst_pos.z - _is_going = false - else: - wish = flat / dist - else: - _mobile_active = false - _last_wasd = Vector2.ZERO - - var speed := 0.0 - var keyboard_motion := wasd != Vector2.ZERO and _direction_active - if wish != Vector3.ZERO: - # IsUsingMovingSkill():移动技能中只转向,不平移。 - if moving_skill: - _turn_toward(atan2(wish.x, wish.z), dt) - else: - var base_speed := SPEED_RUN if _run else SPEED_WALK - if player and player.has_method("get_move_motion_speeds"): - var ms: Vector2 = player.call("get_move_motion_speeds") - var m_spd: float = (ms.y if _run else ms.x) * 0.01 - if m_spd > 0.0: - base_speed = m_spd - var want_speed := base_speed * server_speed_scale - var before := player.position - var move_step := want_speed * dt - if _is_going or keyboard_motion: - var target := _direction_dst if keyboard_motion else _dst_pos - var to_dst := Vector2(target.x - player.position.x, target.z - player.position.z).length() - if move_step >= to_dst: - move_step = to_dst - var np := player.position + wish * move_step - var direct_blocked := false - var blocker := _get_blocking_actor(np) - if blocker: - direct_blocked = true - var b_pos: Vector3 = blocker.global_position if blocker.is_inside_tree() else blocker.position - var to_player := Vector3(player.position.x - b_pos.x, 0, player.position.z - b_pos.z) - var norm := to_player.normalized() - var tangent := Vector3(-norm.z, 0, norm.x) - if tangent.dot(wish) < 0.0: - tangent = -tangent - var slide_np := player.position + tangent * (wish.dot(tangent) * move_step) - if not _actor_blocked(slide_np) and not _blocked(slide_np.x, slide_np.z): - np = slide_np - direct_blocked = false - if direct_blocked or _blocked(np.x, np.z) or _actor_blocked(np): - # 逐轴推进降级保底:地形 ATTRIBUTE_BLOCK 或静态物体阻挡 - var block_x := _actor_blocked(Vector3(np.x, before.y, player.position.z)) \ - or _blocked(np.x, player.position.z) - var block_z := _actor_blocked(Vector3(player.position.x, before.y, np.z)) \ - or _blocked(player.position.x, np.z) - if not block_x: - player.position.x = np.x - if not block_z: - player.position.z = np.z - else: - player.position = np - - var disp := Vector2(player.position.x - before.x, player.position.z - before.z).length() - if _is_going and Vector2(_dst_pos.x - player.position.x, _dst_pos.z - player.position.z).length() <= ARRIVE_EPS: - _is_going = false - if keyboard_motion and Vector2(_direction_dst.x - player.position.x, _direction_dst.z - player.position.z).length() <= ARRIVE_EPS: - _direction_active = false - _direction_resume_pending = false - var is_manual := (wasd != Vector2.ZERO or mobile_axis != Vector2.ZERO) - if disp > want_speed * dt * 0.25 or is_manual: - speed = want_speed - _turn_toward(atan2(wish.x, wish.z), dt) - else: - _is_going = false - - if world and world.has_method("sample_height"): - player.position.y = float(world.call("sample_height", player.position.x, player.position.z)) - if speed > 0.01: - moved.emit(player.position) - - if speed <= 0.01: - anim_state.emit("wait") - elif not _run: - anim_state.emit("walk") - else: - anim_state.emit("run") - -func _update_hover_cursor() -> void: - if cursor_manager == null or not cursor_manager.has_method("set_cursor") or camera == null: - return - if camera.has_method("is_dragging") and bool(camera.call("is_dragging")): - cursor_manager.set_cursor("CAMERA_ROTATE") - _set_hover_vid(0) - return - var mouse_pos := get_viewport().get_mouse_position() - if ui_manager and ui_manager.has_method("blocks_game_input"): - var motion := InputEventMouseMotion.new() - motion.position = mouse_pos - if ui_manager.blocks_game_input(motion): - cursor_manager.set_cursor("NORMAL") - _set_hover_vid(0) # 鼠标压 UI:IsPickedWindow 为假 -> 无 chr.Pick 强显 - return - if ground_items and ground_items.has_method("hover_at") and ground_items.hover_at(camera, mouse_pos): - cursor_manager.set_cursor("PICK") - _set_hover_vid(0) # 悬停地面物品:item.Pick 领域,非 chr.Pick - return - var from := camera.project_ray_origin(mouse_pos) - var dir := camera.project_ray_normal(mouse_pos) - var best: Node3D = null - var best_t := 1e20 - for n in pickables: - if not is_instance_valid(n): - continue - if bool(n.get_meta("dead", false)): - continue - var t := _ray_pick_t(from, dir, n) - if t >= 0.0 and t < best_t: - best_t = t - best = n - if best: - cursor_manager.set_cursor(String(best.get_meta("cursor_shape", "ATTACK"))) - _set_hover_vid(int(best.get_meta("vid", 0))) - else: - _set_hover_vid(0) - var ghit: Variant = _ray_ground(from, dir) - if ghit != null and world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", ghit.x, ghit.z)): - cursor_manager.set_cursor("CANT_GO") - else: - cursor_manager.set_cursor("NORMAL") - -# game.py OnRender chr.Pick() -> ShowCharacterTextTail:悬停命中的角色 vid 变化时才 emit。 -func _set_hover_vid(vid: int) -> void: - if vid == _hover_vid: - return - _hover_vid = vid - hover_entity_changed.emit(vid) diff --git a/project/player_controller.gd.uid b/project/player_controller.gd.uid deleted file mode 100644 index 3f9f2b9f..00000000 --- a/project/player_controller.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://rpfnf58o5f70 diff --git a/project/player_motion_test.gd b/project/player_motion_test.gd deleted file mode 100644 index 2cb4142b..00000000 --- a/project/player_motion_test.gd +++ /dev/null @@ -1,104 +0,0 @@ -# player_motion_test —— 40250 GC_MOTION / ServerCommand emotion clip mapping。 -# godot --headless --path project --script player_motion_test.gd -extends SceneTree - -const PlayerView = preload("res://ui/player_view.gd") -const Audio = preload("res://audio.gd") - -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: player_motion_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2AnimPlayer"): - print(" (skip: animation extension not registered)") - return - var assets := AssetRoot.path() - if not DirAccess.dir_exists_absolute(assets.path_join("PC/ymir work/pc/warrior")): - print(" (skip: PC assets unavailable)") - return - var pv: Node = PlayerView.new() - get_root().add_child(pv) - _ck(pv.build(assets, 0), "warrior PlayerView builds") - if pv.anim == null: - pv.free() - await process_frame - return - await process_frame - var body_mesh := pv.model.get_node_or_null("MeshInstance3D") as MeshInstance3D - var body_layer := body_mesh.get_layer_mask() if body_mesh != null else 0 - _ck(body_mesh != null and body_layer == 2, - "character mesh is assigned to the character light layer (got %d)" % body_layer) - # 40250 __SetMotion -> __ShowEvent: a later valid motion request restores - # visibility, including a request for the same loop motion. - pv.set_motion_hidden(true) - pv.set_anim_state("wait") - _ck(not pv.is_motion_hidden() and pv.model.visible, - "same-state motion request restores visibility after CHARACTER_HIDE") - pv.set_motion_hidden(true) - pv.set_anim_state("run") - _ck(not pv.is_motion_hidden() and pv.model.visible, - "new motion request restores visibility after CHARACTER_HIDE") - var audio: Node = Audio.new() - get_root().add_child(audio) - audio.setup(assets) - pv.set_audio(audio) - pv.set_anim_state("run") - _ck(pv._sound_instances.size() == 3, "PlayerView run motion loads paired .mss instances") - pv.set_motion_mode(3) # MODE_TWOHAND_SWORD - _ck(String(pv.anim.get("anim_path")).contains("/twohand_sword/run.msa"), - "two-handed mode rebinds current run loop") - pv.set_anim_state("wait") - var twohand_wait := String(pv.anim.get("anim_path")) - _ck(twohand_wait.contains("/twohand_sword/") and - twohand_wait.get_file().get_basename() in ["wait", "wait_1"], - "two-handed wait uses a registered two-hand grip pose") - # 40250 GetMotionKey(HORSE_ONEHAND_SWORD, WAIT/WALK) falls back to HORSE, - # not GENERAL, because horse weapon modes only register combat motions. - pv._motion_mode = 10 - var horse_wait := String(pv._motion_file("wait")) - _ck(horse_wait.contains("/horse/") and - horse_wait.get_file().get_basename() in ["wait", "wait_1", "wait_2"], - "horse one-hand wait falls back to HORSE motion mode and preserves weighted variants") - _ck(String(pv._motion_file("walk")).contains("/horse/walk.msa"), - "horse one-hand walk falls back to HORSE motion mode") - _ck(pv._motion_file("standup") == "", - "horse one-hand does not fall through to GENERAL for an unregistered motion") - pv._motion_mode = 3 - pv.play_attack_motion(3, 14, 1.0) - _ck(String(pv.anim.get("anim_path")).contains("/twohand_sword/combo_01.msa"), - "two-handed first swing uses combo_01") - pv.set_motion_mode(1) - _ck(pv.set_motion_id(305), "clap motion resolves") - _ck(String(pv.anim.get("anim_path")).ends_with("action/clap.msa"), - "clap -> action/clap.msa") - _ck(pv.set_motion_id(308, 3), "kiss motion resolves with target job") - _ck(String(pv.anim.get("anim_path")).ends_with("action/kiss_with_shaman.msa"), - "kiss start + shaman target -> kiss_with_shaman.msa") - _ck(pv.set_motion_id(320), "slap hurt motion resolves") - _ck(String(pv.anim.get("anim_path")).ends_with("action/slap_hurt.msa"), - "slap hurt -> action/slap_hurt.msa") - # Release the test nodes synchronously. The native model/animation wrappers - # are owned by PlayerView and must be destroyed before SceneTree.quit(). - pv.free() - audio.free() - # The production module keeps decoded GR2/MSA/material resources in bounded - # process caches. Clear them for this short-lived test so ObjectDB teardown - # can verify the actual node ownership rather than cache lifetime. - if ClassDB.class_exists("Metin2Model"): - Metin2Model.clear_texture_cache() - if ClassDB.class_exists("Metin2AnimPlayer"): - Metin2AnimPlayer.clear_clip_cache() - await process_frame diff --git a/project/player_motion_test.gd.uid b/project/player_motion_test.gd.uid deleted file mode 100644 index 00ff8cf2..00000000 --- a/project/player_motion_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://1mwx8xsric02 diff --git a/project/player_move_test.gd b/project/player_move_test.gd deleted file mode 100644 index 8195894e..00000000 --- a/project/player_move_test.gd +++ /dev/null @@ -1,195 +0,0 @@ -# player_move_test —— §3.1 Src/Dst 移动模型(player_controller.gd)headless 自检。 -# godot --headless --path project --script player_move_test.gd -# 覆盖:NEW_Goto 设 Src/Dst/is_going、__IsMovableGroundDistance 太近不动、 -# 三门(frozen/lock/moving_skill)、__ReserveClickGround 的 0.1s delay 重试。 -extends SceneTree - -const PlayerCtl = preload("res://player_controller.gd") -const GameCam = preload("res://game_camera.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: player_move_test (Src/Dst move model / gates / reserved ground)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # SceneTree._init() 早于 root 就绪;等一帧再建 Node3D,避免 !is_inside_tree()。 - await process_frame - var pc: Node = PlayerCtl.new() - get_root().add_child(pc) - pc.player = Node3D.new() - get_root().add_child(pc.player) - pc.player.position = Vector3.ZERO - - # 1) NEW_Goto:远点 -> 设 Dst + is_going + 面向 - var ok: bool = pc._goto(Vector3(10, 0, 0)) - _ck(ok, "_goto(far) returns true") - _ck(pc.is_going(), "_goto -> is_going()") - _ck(pc._dst_pos.is_equal_approx(Vector3(10, 0, 0)), "Dst set to click point") - _ck(pc._src_pos.is_equal_approx(Vector3.ZERO), "Src set to current pos") - _ck(is_equal_approx(pc._dst_rot, atan2(10.0, 0.0)), "_dst_rot faces the target") - - # 2) __IsMovableGroundDistance:太近(< 1 m)不动 - pc._is_going = false - var near_ok: bool = pc._goto(Vector3(0.5, 0, 0)) - _ck(near_ok, "_goto(near) returns true (treated as done)") - _ck(not pc.is_going(), "within MOVABLE_GROUND_DISTANCE -> no movement") - - # 3) 每帧推进:朝 Dst 走,抵达后 is_going 归零 - pc._goto(Vector3(3, 0, 0)) - for i in 240: - pc._process(0.05) - if not pc.is_going(): - break - _ck(pc.player.position.distance_to(Vector3(3, 0, 0)) < 0.5, "advances to Dst, got %s" % pc.player.position) - _ck(not pc.is_going(), "arrival clears is_going") - - # 4) 三门:frozen / lock -> _goto 被挡 -> 预约 + 0.1s 后重试 - pc.player.position = Vector3.ZERO - pc._is_going = false - pc.frozen = true - var blocked: bool = pc._goto(Vector3(8, 0, 0)) - _ck(not blocked, "_goto while frozen returns false (gated)") - _ck(not pc.is_going(), "frozen -> not going yet") - _ck(pc._reserved_ground != null, "frozen -> click ground reserved") - # 冻结中推进:不平移、不消预约 - pc._process(0.05) - _ck(pc.player.position.is_equal_approx(Vector3.ZERO), "frozen -> no translation") - # 解冻:delay 递减到 0 后自动重试预约点 - pc.frozen = false - for i in 10: - pc._process(0.02) - _ck(pc.is_going() or pc.player.position.x > 0.1, - "reserved ground fires after delay once unfrozen (going=%s x=%.2f)" % [pc.is_going(), pc.player.position.x]) - - # 5) moving_skill:只转向不平移 - pc.player.position = Vector3.ZERO - pc._is_going = false - pc.moving_skill = true - pc._goto(Vector3(0, 0, 6)) - _ck(not pc.is_going(), "_goto while moving_skill is gated (no translate)") - pc.moving_skill = false - # 清掉第 5 组留下的预约点,免得下面各组被它改向。 - pc._reserved_ground = null - pc._reserved_delay_time = 0.0 - - # 6) Actor-Actor 碰撞(TestActorCollision / BlockMovement 近似)——正前方有远端 Actor - # 时走不过去,停在两 body 球相接前;is_going 归零(BlockMovement = 不滑移硬停)。 - pc.player.position = Vector3.ZERO - pc.player.rotation.y = 0.0 - pc._is_going = false - pc.skip_actor_collision = false - var blocker := Node3D.new() - get_root().add_child(blocker) - blocker.global_position = Vector3(2, 0, 0) # 正前方 2 m - pc.pickables.append(blocker) - pc._goto(Vector3(10, 0, 0)) # 想穿过 blocker - for i in 240: - pc._process(0.05) - if not pc.is_going(): - break - _ck(pc.player.position.x > 0.3 and pc.player.position.x < 1.5, - "actor collision halts before overlap (x=%.2f)" % pc.player.position.x) - _ck(not pc.is_going(), "blocked advance clears is_going (BlockMovement)") - - # 6b) 死亡的 Actor 不挡(rVictim.IsDead()) - pc.player.position = Vector3.ZERO - pc._is_going = false - blocker.set_meta("dead", true) - pc._goto(Vector3(10, 0, 0)) - for i in 400: - pc._process(0.05) - if not pc.is_going(): - break - _ck(pc.player.position.distance_to(Vector3(10, 0, 0)) < 0.6, - "dead actor does not block (x=%.2f)" % pc.player.position.x) - blocker.set_meta("dead", false) - - # 6c) CanSkipCollision(skip_actor_collision)为真 → 整段跳过,可穿过 - pc.player.position = Vector3.ZERO - pc._is_going = false - pc.skip_actor_collision = true - pc._goto(Vector3(10, 0, 0)) - for i in 400: - pc._process(0.05) - if not pc.is_going(): - break - _ck(pc.player.position.distance_to(Vector3(10, 0, 0)) < 0.6, - "skip_actor_collision passes straight through (x=%.2f)" % pc.player.position.x) - pc.skip_actor_collision = false - pc.pickables.erase(blocker) - blocker.free() - - # 7) 坐骑转向速度:MountHorse → c_fDefaultHorseRotationSpeed(300),逐帧收敛不瞬转 - _ck(is_equal_approx(pc.rotation_speed_deg, pc.ROT_SPEED_DEFAULT_DEG), - "default rotation speed is c_fDefaultRotationSpeed 1200") - pc.rotation_speed_deg = pc.ROT_SPEED_HORSE_DEG - pc.player.position = Vector3.ZERO - pc.player.rotation.y = 0.0 - pc._is_going = false - pc._goto(Vector3(0, 0, -6)) # 需要转到 yaw ≈ ±PI - pc._process(0.05) - _ck(absf(pc.player.rotation.y) <= deg_to_rad(pc.ROT_SPEED_HORSE_DEG) * 0.05 + 1e-3, - "mounted: turn clamped to 300 deg/s in one 50ms step (%.3f rad)" % pc.player.rotation.y) - _ck(pc.player.position.z < -0.05, "mounted: still translates toward Dst while turning") - pc.rotation_speed_deg = pc.ROT_SPEED_DEFAULT_DEG - - # 8) 相机相对移动:W / 摇杆上 = 远离相机(沿视线),D / 摇杆右 = 屏幕右。 - # GameCamera 摆在 head + (sin yaw, ·, cos yaw)*dist 并看向 head,前方取真实相机几何。 - var cam: Camera3D = GameCam.new() - get_root().add_child(cam) - cam.target = pc.player - cam.yaw = deg_to_rad(30.0) - pc.camera = cam - pc.player.rotation.y = 0.0 - var off: Vector3 = cam._desired_pos(true) - (pc.player.global_position + cam.head_offset) - var away := -Vector3(off.x, 0, off.z).normalized() - var screen_right := away.cross(Vector3.UP) - for c in [["mobile axis up", Vector2(0, -1), away], ["mobile axis right", Vector2(1, 0), screen_right]]: - pc.player.position = Vector3.ZERO - pc._is_going = false - pc.set_mobile_axis(c[1]) - pc._process(0.1) - pc.clear_mobile_input() - var moved := Vector3(pc.player.position.x, 0, pc.player.position.z) - _ck(moved.length() > 0.05 and moved.normalized().dot(c[2]) > 0.95, - "%s moves camera-relative (want %s, moved %s)" % [c[0], c[2], moved]) - for c in [[KEY_W, "W", away], [KEY_S, "S", -away], [KEY_D, "D", screen_right]]: - pc.player.position = Vector3.ZERO - pc._is_going = false - var down := InputEventKey.new() - down.keycode = c[0] - down.physical_keycode = c[0] - down.pressed = true - Input.parse_input_event(down) - Input.flush_buffered_events() - _ck(Input.is_key_pressed(c[0]), "synthetic %s press reaches Input" % c[1]) - pc._process(0.1) - var up := down.duplicate() - up.pressed = false - Input.parse_input_event(up) - Input.flush_buffered_events() - var moved := Vector3(pc.player.position.x, 0, pc.player.position.z) - _ck(moved.length() > 0.05 and moved.normalized().dot(c[2]) > 0.95, - "key %s moves camera-relative (want %s, moved %s)" % [c[1], c[2], moved]) - - # 9) __OnClickSmart fallback:点击到既不是实体、掉落物也不是地面的空白处 - # 必须 NEW_Stop,不能让上一次点地目标继续驱动角色。 - pc._goto(Vector3(8, 0, 0)) - _ck(pc.is_going(), "click-move is active before empty-space click") - pc.world = null # no ground hit -> __OnClickSmart's NEW_Stop branch - pc._on_click(Vector2.ZERO) - _ck(not pc.is_going(), "empty-space click stops the previous click-move") - - pc.camera = null - cam.free() diff --git a/project/player_move_test.gd.uid b/project/player_move_test.gd.uid deleted file mode 100644 index 10e05e87..00000000 --- a/project/player_move_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://lqisiv6emkkt diff --git a/project/player_skill.gd b/project/player_skill.gd deleted file mode 100644 index e4e80b00..00000000 --- a/project/player_skill.gd +++ /dev/null @@ -1,510 +0,0 @@ -# PlayerSkill (§3.8 修改 1) —— 逐行移植 CPythonPlayer 的技能三层合法性校验。 -# -# 参考 REF/UserInterface/PythonPlayerSkill.cpp: -# (a) ClickSkillSlot(dwSlotIndex) :106 —— 槽位 / 技能数据 / 公会技路由 / 被动挡下 / -# 站立技 + 开关技 toggle-off 分支 -# (b) __CanUseSkill() :439 —— 主角存在 / 观战 / 骑乘缺骑乘术 / actor 可行动 -# (c) __CheckSkillUsable(dwSlotIndex) :166 —— 骑乘⇄骑乘术、安全区攻击技、瓶子、钓鱼、 -# 未学、武器种类、箭矢、冷却、HP/SP -# (d) __UseSkill(dwSlotIndex) :460 —— 编排:私人商店 / __CanUseSkill / __CheckSpecialSkill / -# toggle-active 早退 / __CheckSkillUsable / IsUsingSkill / -# 目标解析(联盟 / 安全区 / 可攻击 / 尸体 / 需要目标) -# -# POC 没有 C++ 技能 proto,TSkillData 判定位全部落在 `ui/skill_table.gd`(增量 87)。 -# 本类是纯校验器:不发包、不播动作,返回 { ok:bool, code:String, target_vid:int }。 -# 施放路径(quickbar.activate() 的 skill 分支)在调用 M2Client.use_skill() / -# cast_skill() 之前先过 use_skill(),ok=false 时按 code 弹 OnCannotUseSkill 文案。 -# -# 运行期上下文(网络下发 / net_play 本地占位)由调用方每次校验前刷新到公开字段, -# 对齐原客户端从 CInstanceBase / m_sysIs* / GetStatus() 读到的那些值。 -extends RefCounted - -# --- §3.8 修改 1 要求的命名常量 -------------------------------------------- -const SKILL_MAX_NUM := 255 # Packet.h:2041 -const SKILL_TYPE_GUILD := 3 # CPythonSkill::SKILL_TYPE_GUILD -const RIDING_SKILL_INDEX := 109 # __CanUseSkill 里硬编码的骑乘术判定 vnum -const RIDING_SKILL_MASTER_LEVEL := 20 # GetSkillLevel(109) < 20 的下限 -const ITEM_EMPTY_BOTTLE := 27995 # __CheckSkillUsable / __HasItem -const ITEM_POISON_BOTTLE := 27996 -const SKILL_INDEX_COMBO := 122 # PythonPlayerModule.cpp:6 -const SKILL_INDEX_FISHING := 123 # PythonPlayerModule.cpp:7 -const SKILL_INDEX_SUMMON := 131 # PythonPlayerModule.cpp:15(__CheckShortMana 豁免) - -# code 里这几类是「原客户端也不弹文案 / 已在别处处理」,调用方不该弹 OnCannotUseSkill: -const SILENT_CODES := [ - "", "OK", "PASSIVE", "BAD_SLOT", "NO_SKILL_DATA", "GUILD_ROUTED", - "TOGGLE_OFF", "SPECIAL_SKILL", "PRIVATE_SHOP", "RESERVED", "NO_RIDING_SKILL", - "CANNOT_ACT", "ALREADY_CASTING", - "POLYMORPHED", "WEARING_DRESS", "HOLDING_PICKAXE", - "HORSE_CANNOT_USE", "GRAPHIC_CANNOT_USE", -] - -var client: Object # M2Client -var table: RefCounted # SkillTable(增量 87 谓词) - -# --- 运行期上下文(调用方刷新;默认取「未知 / 放行」)----------------------- -var observer_mode := false # IsObserverMode() -var mounting := false # CInstanceBase::IsMountingHorse() -var in_safe := false # CInstanceBase::IsInSafe() -var is_fishing := false # CInstanceBase::IsFishingMode() -var is_using_skill := false # CInstanceBase::IsUsingSkill() -var can_act := true # CanProcessNetworkStatePacket(死亡/晕眩/击退 → false) -var polymorphed := false # CInstanceBase::IsPoly() -var wearing_dress := false # CInstanceBase::IsWearingDress() shape 201 -var holding_pickaxe := false # CInstanceBase::IsHoldingPickAxe() weapon 29101..29110 -var horse_can_use := true # CInstanceBase::SHORSE::CanUseSkill() -var graphic_can_use := true # CGraphicThingInstance::CanUseSkill() -var guild_war_active := false # CPythonGuild::IsDoingGuildWar() -var private_shop_open := false # IsOpenPrivateShop() -var level_limit := true # m_sysIsLevelLimit -var cooltime_check := true # m_sysIsCoolTime -var dash_active := false # __CheckDashAffect -var weapon_sub_type := -1 # CInstanceBase::GetWeaponType();-1 = 未知(跳过匹配) -var arrow_count := -1 # 装备箭袋数量;-1 未知/无限,0 = EMPTY_ARROW -var slot_cd_end := 0.0 # 该槽本地冷却结束时刻(Time.get_ticks_msec()/1000) -var cur_sp := -1 # GetStatus(POINT_SP);-1 = 未知(不判 NOT_ENOUGH_SP) -var cur_hp := -1 # GetStatus(POINT_HP);-1 = 未知(不判 NOT_ENOUGH_HP) -var bow_distance := 0 # GetStatus(POINT_BOW_DISTANCE) —— __GetSkillTargetRange 加成 -## vid -> 主角到目标的距离 cm(已含 IS_HUGE_RACE −200 修正);<0 = 未知。未注入则不判射程。 -var target_distance := Callable() -## 40250 NEW_GetFrontInstance (InstanceBaseBattle.cpp:145):前方扇面 20m 自动索敌 Callable(max_dist_cm) -> int (vid) -var get_front_instance := Callable() - -func setup(m2client: Object, skill_table: RefCounted) -> void: - client = m2client - table = skill_table - -# --- 小工具 -------------------------------------------------------------- - -func _now() -> float: - return Time.get_ticks_msec() / 1000.0 - -func _ok(target_vid := 0) -> Dictionary: - return {"ok": true, "code": "OK", "target_vid": target_vid} - -# __GetSkillTargetRange —— PythonPlayerSkill.cpp:390 -func _skill_target_range(skill_id: int) -> int: - return int(table.target_range(skill_id)) + bow_distance * 100 - -# 射程外:不施法,交给调用方 __ReserveUseSkill(走近后再 __UseSkill)。 -# charge = 参考端「蓄力技且未预约时先 __SendUseSkill(slot, 0)」。 -func _out_of_range(skill_id: int, tvid: int) -> Dictionary: - var range_cm := _skill_target_range(skill_id) - if range_cm <= 0 or not target_distance.is_valid(): - return {} - var distance := float(target_distance.call(tvid)) - if distance < 0.0 or distance < float(range_cm): - return {} - var r := _no("RESERVED", tvid) - r["range_cm"] = range_cm - r["charge"] = bool(table.is_charge_skill(skill_id)) - return r - -func _no(code: String, target_vid := 0) -> Dictionary: - return {"ok": false, "code": code, "target_vid": target_vid} - -static func is_silent_code(code: String) -> bool: - return SILENT_CODES.has(code) - -func _main_vid() -> int: - if client and client.has_method("get_main_vid"): - return int(client.get_main_vid()) - return 0 - -func _main_entity() -> Dictionary: - var v := _main_vid() - if client and client.has_method("get_entity") and v != 0: - var e: Variant = client.get_entity(v) - if e is Dictionary: - return e - return {} - -# GetSkillLevel(idx) / GetSkillGrade(idx) 等价:查 M2Client.get_skills()(按技能 vnum)。 -func _skill_level(skill_id: int) -> int: - if client == null or not client.has_method("get_skills"): - return 0 - for s in client.get_skills(): - if int(s.get("id", 0)) == skill_id: - return int(s.get("level", 0)) - return 0 - -func _skill_grade(skill_id: int) -> int: - if client == null or not client.has_method("get_skills"): - return 0 - for s in client.get_skills(): - if int(s.get("id", 0)) == skill_id: - return int(s.get("master", 0)) # 0..3,master 即 grade - return 0 - -# __HasItem(dwItemID):扫背包(POC 无独立龙魂背包,省略那段)。 -func _has_item(vnum: int) -> bool: - if client == null or not client.has_method("get_inventory"): - return false - for it in client.get_inventory(): - if int(it.get("vnum", 0)) == vnum and int(it.get("count", 0)) > 0: - return true - return false - -# __HasEnoughArrow():原版查装备箭袋 subtype==WEAPON_ARROW;POC 用 net_play 的 -# _arrow_count 占位(真值待服务端 / 后续增量)。-1 视为无限。 -func _has_enough_arrow() -> bool: - return arrow_count != 0 - -# __CheckRestSkillCoolTime(slot):m_sysIsCoolTime 关 → 不在冷却;否则比本地剩余。 -func _check_rest_skill_cooltime() -> bool: - if not cooltime_check: - return false - return _now() < slot_cd_end - -# §3.8 修改 3:NeedSP 公式经 skill_table.need_sp()(.msk NeedSPFormula)解析。 -# -# __CheckShortLife(PythonPlayerSkill.cpp:354): -# !IsUseHPSkill() → false -# dwNeedHP = GetNeedSP(eff%) (HP 消耗也取 NeedSP,参考端怪癖) -# dwNeedHP <= GetStatus(POINT_HP) → false 否则 true -func _check_short_life(skill_id: int) -> bool: - if table == null or not table.has_method("need_hp"): - return false - if not table.is_use_hp(skill_id): - return false - if cur_hp < 0: # HP 未知 → 不误挡 - return false - var need_hp := int(table.need_hp(skill_id, _skill_level(skill_id))) - if need_hp < 0: # 公式解析不出 → 不挡 - return false - return need_hp > cur_hp - -# __CheckShortMana(PythonPlayerSkill.cpp:320): -# idx == c_iSkillIndex_Summon(131) → false -# iNeedSP = GetNeedSP(eff%) ; icurSP = GetStatus(POINT_SP) -# !IsToggleSkill() && iNeedSP == 0 → true("strange game data") -# CanUseIfNotEnough(): icurSP <= 0 → true -# else: iNeedSP != -1 && iNeedSP > icurSP → true -# 否则 false -func _check_short_mana(skill_id: int) -> bool: - if table == null or not table.has_method("need_sp"): - return false - if skill_id == SKILL_INDEX_SUMMON: - return false - if cur_sp < 0: # SP 未知 → 不误挡 - return false - var need_sp := int(table.need_sp(skill_id, _skill_level(skill_id))) - # 参考端:非开关技且 iNeedSP == 0 视为坏数据并拦截。POC 约定 need_sp() 用 -1 - # 表示「.msk 没有 NeedSPFormula」(解析不出),此时不套这条以免误挡;只有确有 - # 公式且算出 0 才按参考拦。 - if not table.is_toggle(skill_id) and need_sp == 0: - return true - if table.can_use_if_not_enough(skill_id): - return cur_sp <= 0 - return need_sp != -1 and need_sp > cur_sp - -func _entity_kind(e: Dictionary) -> int: - return int(e.get("kind", int(e.get("type", -1)))) - -# IsAttackableInstance 的近似:PC(0) / MONSTER(2) 且未死可打。 -# 帝国 / 结盟 / PK / 决斗规则归 §5.4,这里不判。 -func _is_attackable(e: Dictionary) -> bool: - if e.is_empty() or bool(e.get("dead", false)): - return false - return _entity_kind(e) in [0, 2] - -# ======================================================================== -# (a) ClickSkillSlot —— PythonPlayerSkill.cpp:106 -# ======================================================================== -# is_active:该槽当前是否已激活(IsSkillActive(slot)),仅开关技有意义。 -func click_skill_slot(skill_id: int, is_active := false) -> Dictionary: - # if (dwSlotIndex >= SKILL_MAX_NUM) return; - if skill_id < 0 or skill_id >= SKILL_MAX_NUM: - return _no("BAD_SLOT") - - # if (!GetSkillData(rkSkillInst.dwIndex, &pSkillData)) return; - if table == null or not table.has(skill_id): - return _no("NO_SKILL_DATA") - - # if (SKILL_TYPE_GUILD == pSkillData->byType) { UseGuildSkill(...); return; } - if table.skill_type_of(skill_id) == SKILL_TYPE_GUILD: - return _no("GUILD_ROUTED") - - # if (!pSkillData->IsCanUseSkill()) return; —— 被动技,静默 - if not table.is_can_use_skill(skill_id): - return _no("PASSIVE") - - # if (pSkillData->IsStandingSkill()) - if table.is_standing(skill_id): - # if (IsToggleSkill() && IsSkillActive(slot)) { SendUseSkillPacket; return; } - if table.is_toggle(skill_id) and is_active: - if not can_act: # pkInstMain->IsUsingSkill() 之前的取指针失败 - return _no("CANNOT_ACT") - if is_using_skill: - return _no("ALREADY_CASTING") - return _no("TOGGLE_OFF") # toggle-off:调用方直接发 use_skill 关掉 - # __UseSkill(dwSlotIndex); - return use_skill(skill_id, is_active) - - # 非站立技:原版还有 m_dwcurSkillSlotIndex / __IsRightButtonSkillMode 的两段式选中, - # POC 快捷栏是一键即放,等价于 !__IsRightButtonSkillMode() 分支 -> __UseSkill。 - return use_skill(skill_id, is_active) - -# ======================================================================== -# (b) __CanUseSkill —— PythonPlayerSkill.cpp:439 -# ======================================================================== -func can_use_skill() -> Dictionary: - # if (!NEW_GetMainActorPtr()) return false; - if _main_vid() == 0: - return _no("CANNOT_ACT") - - # if (IsObserverMode()) return false; - if observer_mode: - return _no("OBSERVER") - - # if (IsMountingHorse() && GetSkillGrade(109) < 1 && GetSkillLevel(109) < 20) return false; - if mounting and _skill_grade(RIDING_SKILL_INDEX) < 1 \ - and _skill_level(RIDING_SKILL_INDEX) < RIDING_SKILL_MASTER_LEVEL: - return _no("NO_RIDING_SKILL") - - # CInstanceBase::CanUseSkill():这些是动作/模型层的硬门,发生在 - # __CheckSkillUsable(安全区、武器、箭矢、HP/SP、冷却)之前。 - if polymorphed: - return _no("POLYMORPHED") - if wearing_dress: - return _no("WEARING_DRESS") - if holding_pickaxe: - return _no("HOLDING_PICKAXE") - if not horse_can_use: - return _no("HORSE_CANNOT_USE") - if not graphic_can_use: - return _no("GRAPHIC_CANNOT_USE") - - # m_GraphicThingInstance::CanUseSkill() 之外的本地网络状态门——死亡 / - # 晕眩 / 击退 / 施法中。 - if not can_act: - return _no("CANNOT_ACT") - - return _ok() - -# ======================================================================== -# (c) __CheckSkillUsable —— PythonPlayerSkill.cpp:166 -# ======================================================================== -func check_skill_usable(skill_id: int) -> Dictionary: - # if (!NEW_GetMainActorPtr()) return false; - if _main_vid() == 0: - return _no("CANNOT_ACT") - - # if (dwSlotIndex >= SKILL_MAX_NUM) return false; - if skill_id < 0 or skill_id >= SKILL_MAX_NUM: - return _no("BAD_SLOT") - - # if (!GetSkillData(...)) return false; - if table == null or not table.has(skill_id): - return _no("NO_SKILL_DATA") - - # if (IsMountingHorse() && !IsHorseSkill()) -> "NOT_HORSE_SKILL" - if mounting and not table.is_horse_skill(skill_id): - return _no("NOT_HORSE_SKILL") - - # if (IsHorseSkill() && !IsMountingHorse()) -> "HAVE_TO_RIDE" - if table.is_horse_skill(skill_id) and not mounting: - return _no("HAVE_TO_RIDE") - - # if (IsAttackSkill() && IsInSafe()) -> "IN_SAFE" - if table.is_attack(skill_id) and in_safe: - return _no("IN_SAFE") - - # if (!IsCanUseSkill()) return false; —— 被动技 - if not table.is_can_use_skill(skill_id): - return _no("PASSIVE") - - # if (IsNeedEmptyBottle() && !__HasItem(27995)) -> "NEED_EMPTY_BOTTLE" - if table.is_need_empty_bottle(skill_id) and not _has_item(ITEM_EMPTY_BOTTLE): - return _no("NEED_EMPTY_BOTTLE") - - # if (IsNeedPoisonBottle() && !__HasItem(27996)) -> "NEED_POISON_BOTTLE" - if table.is_need_poison_bottle(skill_id) and not _has_item(ITEM_POISON_BOTTLE): - return _no("NEED_POISON_BOTTLE") - - # if (IsFishingMode()) -> "REMOVE_FISHING_ROD" - if is_fishing: - return _no("REMOVE_FISHING_ROD") - - # if (m_sysIsLevelLimit && rkSkillInst.iLevel <= 0) -> "NOT_YET_LEARN" - if level_limit and _skill_level(skill_id) <= 0: - var has_any_skills := false - if client and client.has_method("get_skills"): - var sk_arr: Array = client.get_skills() - if not sk_arr.is_empty(): - has_any_skills = true - if has_any_skills: - return _no("NOT_YET_LEARN") - - # if (!CanUseWeaponType(GetWeaponType())) -> "NOT_MATCHABLE_WEAPON" - # weapon_sub_type < 0(未知)时跳过,避免误挡。 - if weapon_sub_type >= 0 and not table.can_use_weapon_type(skill_id, weapon_sub_type): - return _no("NOT_MATCHABLE_WEAPON") - - # if (!IsHorseSkill()) { __CheckShortArrow; if (IsNeedBow() && !__HasEnoughArrow()) ... } - if not table.is_horse_skill(skill_id): - if table.is_need_bow(skill_id) and not _has_enough_arrow(): - return _no("EMPTY_ARROW") - - # __CheckDashAffect 分支:冲刺 affect 下、非蓄力技才查冷却;否则查冷却 + HP + SP - if dash_active: - if not table.is_charge_skill(skill_id): - if _check_rest_skill_cooltime(): - return _no("WAIT_COOLTIME") - else: - if _check_rest_skill_cooltime(): - return _no("WAIT_COOLTIME") - if _check_short_life(skill_id): - return _no("NOT_ENOUGH_HP") - if _check_short_mana(skill_id): - return _no("NOT_ENOUGH_SP") - - return _ok() - -# ======================================================================== -# (d) __UseSkill —— PythonPlayerSkill.cpp:460 -# ======================================================================== -func use_skill(skill_id: int, is_active := false) -> Dictionary: - # if (IsOpenPrivateShop()) return true; —— 静默成功但什么都不做 - if private_shop_open: - return _no("PRIVATE_SHOP") - - # if (!__CanUseSkill()) return false; - var b := can_use_skill() - if not b.ok: - return b - - # if (dwSlotIndex >= SKILL_MAX_NUM) return false; - if skill_id < 0 or skill_id >= SKILL_MAX_NUM: - return _no("BAD_SLOT") - - # if (__CheckSpecialSkill(rkSkillInst.dwIndex)) return true; - if _check_special_skill(skill_id): - return _no("SPECIAL_SKILL") - - # if (!GetSkillData(...)) return false; - if table == null or not table.has(skill_id): - return _no("NO_SKILL_DATA") - - # if (IsToggleSkill() && IsSkillActive(slot)) { SendUseSkillPacket; return false; } - if table.is_toggle(skill_id) and is_active: - return _no("TOGGLE_OFF") - - # if (!__CheckSkillUsable(dwSlotIndex)) return false; - var c := check_skill_usable(skill_id) - if not c.ok: - return c - - # if (pkInstMain->IsUsingSkill()) return false; - if is_using_skill: - return _no("ALREADY_CASTING") - - # 目标解析(IsNeedTarget || CanChangeDirection || IsAutoSearchTarget) - var need_target: bool = table.is_need_target(skill_id) \ - or table.can_change_direction(skill_id) \ - or table.is_auto_search_target(skill_id) - if not need_target: - return _ok(0) - - return _resolve_target(skill_id) - -# CPythonPlayer::UseGuildSkill —— 公会技走独立的 CG_GUILD_USE_SKILL 入口,不经过 -# 普通目标解析;但仍受主角动作门、槽位、冷却和 ONLY_FOR_GUILD_WAR 限制。 -func use_guild_skill(skill_id: int) -> Dictionary: - var b := can_use_skill() - if not b.ok: - return b - if skill_id < 0 or skill_id >= SKILL_MAX_NUM: - return _no("BAD_SLOT") - if table == null or not table.has(skill_id): - return _no("NO_SKILL_DATA") - if _check_rest_skill_cooltime(): - return _no("WAIT_COOLTIME") - if table.is_only_for_guild_war(skill_id) and not guild_war_active: - return _no("ONLY_FOR_GUILD_WAR") - return {"ok": true, "code": "OK", "target_vid": 0, "guild": true} - -# __UseSkill 里的目标分支(PythonPlayerSkill.cpp:520-650)。 -# 判目标「类型」是否合法并回传解析到的 vid;射程外回 RESERVED(带 range_cm / charge), -# 由调用方 __ReserveUseSkill。扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9。 -func _resolve_target(skill_id: int) -> Dictionary: - var main_vid := _main_vid() - var tvid := 0 - if client and client.has_method("get_target"): - tvid = int(client.get_target().get("vid", 0)) - - var need_corpse: bool = table.is_need_corpse(skill_id) - var te: Dictionary = {} - if tvid != 0 and client and client.has_method("get_entity"): - var e: Variant = client.get_entity(tvid) - if e is Dictionary: - te = e - - # --- 有目标 --- - if not te.is_empty(): - var is_self := tvid == main_vid - var target_dead := bool(te.get("dead", false)) - - # 需要尸体的技能:目标必须已死 - if need_corpse and not target_dead: - return _no("ONLY_FOR_CORPSE") - - if table.is_only_for_alliance(skill_id): - if is_self: - if not table.can_use_for_me(skill_id): - return _no("CANNOT_USE_SELF") - return _ok(main_vid) - # 非自己:结盟技打友方(不可攻击的 PC)合法;打敌人不合法 - if _entity_kind(te) == 0 and not _is_attackable(te): - # PythonPlayerSkill.cpp:560:友方 PC 在射程外 -> __ReserveUseSkill(无蓄力预发)。 - var far := _out_of_range(skill_id, tvid) - if not far.is_empty(): - far["charge"] = false - return far - return _ok(tvid) - if table.can_use_for_me(skill_id): - return _ok(main_vid) # 目标非法 -> 回落自身 - return _no("ONLY_FOR_ALLIANCE") - else: - # 攻击类:目标在安全区不可打 - if bool(te.get("in_safe", false)): - return _no("CANNOT_ATTACK_ENEMY_IN_SAFE_AREA") - if _is_attackable(te): - # __ProcessEnemySkillTargetRange(PythonPlayerSkill.cpp:395) - var far := _out_of_range(skill_id, tvid) - if not far.is_empty(): - return far - return _ok(tvid) - return _no("CANNOT_ATTACK") - - # --- 无目标 --- - if table.is_auto_search_target(skill_id): - # 40250 NEW_GetFrontInstance: 面前 20m 扇面自动锁定最近敌人 - if get_front_instance.is_valid(): - var front_vid: int = int(get_front_instance.call(2000.0)) - if front_vid != 0: - tvid = front_vid - var fe: Variant = client.get_entity(tvid) if client and client.has_method("get_entity") else {} - if fe is Dictionary and not fe.is_empty() and not bool(fe.get("dead", false)): - var far := _out_of_range(skill_id, tvid) - if not far.is_empty(): - return far - return _ok(tvid) - if not table.can_use_for_me(skill_id) and not need_corpse: - return _no("NEED_TARGET") - - if table.can_use_for_me(skill_id): - return _ok(main_vid) - if need_corpse: - return _no("ONLY_FOR_CORPSE") - if not table.is_need_target(skill_id): - return _ok(0) - return _no("NEED_TARGET") - -# __CheckSpecialSkill —— PythonPlayerSkill.cpp:950(钓鱼 123 / 连击 122)。 -func _check_special_skill(skill_id: int) -> bool: - if skill_id == SKILL_INDEX_FISHING: - return true - if skill_id == SKILL_INDEX_COMBO: - return true - return false diff --git a/project/player_skill.gd.uid b/project/player_skill.gd.uid deleted file mode 100644 index 664bf28d..00000000 --- a/project/player_skill.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://s5p8kdn4aywt diff --git a/project/player_skill_test.gd b/project/player_skill_test.gd deleted file mode 100644 index 9a682874..00000000 --- a/project/player_skill_test.gd +++ /dev/null @@ -1,315 +0,0 @@ -# player_skill_test —— §3.8 修改 1:player_skill.gd 技能三层合法性校验 headless 自检。 -# godot --headless --path project --script player_skill_test.gd -# -# 逐层覆盖 ClickSkillSlot(a) / __CanUseSkill(b) / __CheckSkillUsable(c) / __UseSkill(d) -# 的每个 return 分支,断言返回 { ok, code, target_vid } 与 PythonPlayerSkill.cpp 一致。 -extends SceneTree - -const SkillTable = preload("res://ui/skill_table.gd") -const PlayerSkill = preload("res://player_skill.gd") - -class FakeClient extends Node: - var main := 1000 - var target := {"vid": 2000} - var entities := {1000: {"kind": 0}, 2000: {"kind": 2}} - var skills := [ - {"id": 1, "level": 5, "master": 0}, - {"id": 2, "level": 5, "master": 0}, - {"id": 46, "level": 10, "master": 0}, - {"id": 109, "level": 1, "master": 0}, - ] - var inv: Array = [] - func get_main_vid() -> int: return main - func get_entity(v) -> Dictionary: return entities.get(int(v), {}) - func get_target() -> Dictionary: return target - func get_skills() -> Array: return skills - func get_inventory() -> Array: return inv - -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: player_skill_test (§3.8 三层校验)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -# 每次校验前把 gate 的上下文复位到「放行」缺省,避免用例互相污染。 -func _reset(g: RefCounted) -> void: - g.observer_mode = false - g.mounting = false - g.in_safe = false - g.is_fishing = false - g.is_using_skill = false - g.can_act = true - g.polymorphed = false - g.wearing_dress = false - g.holding_pickaxe = false - g.horse_can_use = true - g.graphic_can_use = true - g.guild_war_active = false - g.private_shop_open = false - g.level_limit = true - g.cooltime_check = true - g.dash_active = false - g.weapon_sub_type = -1 - g.arrow_count = -1 - g.slot_cd_end = 0.0 - g.cur_hp = -1 - g.cur_sp = -1 - g.bow_distance = 0 - g.target_distance = Callable() - -func _run() -> void: - var assets := AssetRoot.path() - var st := SkillTable.new() - var loaded := false - for lang in ["en", "common"]: - if st.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)): - loaded = true - break - if not loaded: - print(" (skip: no skilldesc.txt)") - return - - var fc := FakeClient.new() - get_root().add_child(fc) - var g := PlayerSkill.new() - g.setup(fc, st) - - # 常量对齐 §3.8 修改 1 硬编码 - _ck(g.SKILL_MAX_NUM == 255, "SKILL_MAX_NUM == 255") - _ck(g.SKILL_TYPE_GUILD == 3, "SKILL_TYPE_GUILD == 3") - _ck(g.RIDING_SKILL_INDEX == 109, "RIDING_SKILL_INDEX == 109") - _ck(g.ITEM_EMPTY_BOTTLE == 27995 and g.ITEM_POISON_BOTTLE == 27996, "空瓶/毒瓶 vnum") - _ck(g.SKILL_INDEX_FISHING == 123 and g.SKILL_INDEX_COMBO == 122, "特殊技 vnum") - - # ---------- (a) ClickSkillSlot ---------- - _reset(g) - _ck(g.click_skill_slot(-1).code == "BAD_SLOT", "(a) 槽越界 -> BAD_SLOT") - _ck(g.click_skill_slot(999).code == "BAD_SLOT", "(a) 槽 >= SKILL_MAX_NUM -> BAD_SLOT") - # 表里查不到的 id - var missing := 0 - for cand in range(240, 255): - if not st.has(cand): - missing = cand - break - if missing != 0: - _ck(g.click_skill_slot(missing).code == "NO_SKILL_DATA", "(a) 无技能数据 -> NO_SKILL_DATA") - # 公会技 151 -> 路由 - if st.has(151) and st.is_guild_skill(151): - _ck(g.click_skill_slot(151).code == "GUILD_ROUTED", "(a) 公会技 -> GUILD_ROUTED") - if st.has(152) and st.is_guild_skill(152): - _ck(g.use_guild_skill(152).code == "ONLY_FOR_GUILD_WAR", "公会战限定技能在非公会战 -> ONLY_FOR_GUILD_WAR") - g.guild_war_active = true - _ck(g.use_guild_skill(152).ok, "公会战限定技能在公会战 -> 可进入独立施放链") - # 被动技 121 -> 静默挡下 - if st.has(121) and st.is_passive(121): - var r := g.click_skill_slot(121) - _ck(not r.ok and r.code == "PASSIVE", "(a) 被动技 -> PASSIVE") - # 站立 + 开关技,已激活 -> TOGGLE_OFF - if st.has(63) and st.is_standing(63) and st.is_toggle(63): - _ck(g.click_skill_slot(63, true).code == "TOGGLE_OFF", "(a) 开关技已激活 -> TOGGLE_OFF") - - # ---------- (b) __CanUseSkill ---------- - _reset(g) - _ck(g.can_use_skill().ok, "(b) 缺省上下文 -> ok") - _reset(g); g.observer_mode = true - _ck(g.can_use_skill().code == "OBSERVER", "(b) 观战 -> OBSERVER") - _reset(g); g.can_act = false - _ck(g.can_use_skill().code == "CANNOT_ACT", "(b) 死亡/晕眩/击退 -> CANNOT_ACT") - _reset(g); g.polymorphed = true - _ck(g.can_use_skill().code == "POLYMORPHED", "(b) 变身 -> POLYMORPHED") - _reset(g); g.wearing_dress = true - _ck(g.can_use_skill().code == "WEARING_DRESS", "(b) 婚礼礼服 -> WEARING_DRESS") - _reset(g); g.holding_pickaxe = true - _ck(g.can_use_skill().code == "HOLDING_PICKAXE", "(b) 手持矿镐 -> HOLDING_PICKAXE") - _reset(g); g.horse_can_use = false - _ck(g.can_use_skill().code == "HORSE_CANNOT_USE", "(b) 坐骑动作门 -> HORSE_CANNOT_USE") - _reset(g); g.graphic_can_use = false - _ck(g.can_use_skill().code == "GRAPHIC_CANNOT_USE", "(b) 图形动作门 -> GRAPHIC_CANNOT_USE") - _reset(g); g.mounting = true # 无骑乘术(skills 里 109 只有 level 1 / grade 0) - _ck(g.can_use_skill().code == "NO_RIDING_SKILL", "(b) 骑乘缺骑乘术 -> NO_RIDING_SKILL") - _reset(g); g.mounting = true - for sk in fc.skills: - if sk["id"] == 109: sk["level"] = 20 - _ck(g.can_use_skill().ok, "(b) 骑乘术 lv20 -> ok") - for sk in fc.skills: - if sk["id"] == 109: sk["level"] = 1 - # 无主角 - _reset(g); fc.main = 0 - _ck(g.can_use_skill().code == "CANNOT_ACT", "(b) 无主角 -> CANNOT_ACT") - fc.main = 1000 - - # ---------- (c) __CheckSkillUsable ---------- - # skill 1 Three-Way Cut:ATTACK_SKILL|NEED_TARGET,非骑乘技,需武器 SWORD|TWO_HANDED - _reset(g) - _ck(g.check_skill_usable(1).ok, "(c) skill 1 缺省 -> ok") - _reset(g); g.mounting = true - _ck(g.check_skill_usable(1).code == "NOT_HORSE_SKILL", "(c) 骑乘中放非骑乘技 -> NOT_HORSE_SKILL") - # skill 138 Horse Stump:HORSE_SKILL - if st.has(138) and st.is_horse_skill(138): - _reset(g); g.level_limit = false - _ck(g.check_skill_usable(138).code == "HAVE_TO_RIDE", "(c) 未骑乘放骑乘技 -> HAVE_TO_RIDE") - _reset(g); g.mounting = true; g.level_limit = false - var r138 := g.check_skill_usable(138) - _ck(r138.ok, "(c) 骑乘放骑乘技 -> 放行") - _reset(g); g.in_safe = true - _ck(g.check_skill_usable(1).code == "IN_SAFE", "(c) 安全区放攻击技 -> IN_SAFE") - _reset(g); g.is_fishing = true - _ck(g.check_skill_usable(1).code == "REMOVE_FISHING_ROD", "(c) 钓鱼态 -> REMOVE_FISHING_ROD") - # 未学:表里有但 get_skills 没有 - var unlearned := 0 - for cand in [2, 3, 4, 5, 6, 7, 8, 9]: - if st.has(cand) and g._skill_level(cand) == 0 and not st.is_horse_skill(cand) \ - and not st.is_passive(cand): - unlearned = cand - break - if unlearned != 0: - _reset(g) - _ck(g.check_skill_usable(unlearned).code == "NOT_YET_LEARN", "(c) 未学技能 -> NOT_YET_LEARN") - _reset(g); g.level_limit = false - _ck(g.check_skill_usable(unlearned).code != "NOT_YET_LEARN", "(c) 关等级限制 -> 不再挡") - # 武器种类不匹配:skill 46 需 BOW - if st.has(46) and st.has_weapon_limitation(46) and st.is_need_bow(46): - _reset(g); g.weapon_sub_type = st.WEAPON_SWORD - _ck(g.check_skill_usable(46).code == "NOT_MATCHABLE_WEAPON", "(c) 拿剑放弓技 -> NOT_MATCHABLE_WEAPON") - _reset(g); g.weapon_sub_type = st.WEAPON_BOW - _ck(g.check_skill_usable(46).ok, "(c) 拿弓放弓技 -> ok") - _reset(g); g.weapon_sub_type = st.WEAPON_BOW; g.arrow_count = 0 - _ck(g.check_skill_usable(46).code == "EMPTY_ARROW", "(c) 弓技无箭 -> EMPTY_ARROW") - # 冷却 - _reset(g); g.slot_cd_end = g._now() + 5.0 - _ck(g.check_skill_usable(1).code == "WAIT_COOLTIME", "(c) 冷却中 -> WAIT_COOLTIME") - _reset(g); g.slot_cd_end = g._now() + 5.0; g.cooltime_check = false - _ck(g.check_skill_usable(1).ok, "(c) m_sysIsCoolTime 关 -> 不查冷却") - # 冲刺 affect:非蓄力技仍查冷却 - _reset(g); g.slot_cd_end = g._now() + 5.0; g.dash_active = true - _ck(g.check_skill_usable(1).code == "WAIT_COOLTIME", "(c) dash + 非蓄力 -> 仍查冷却") - # 蓄力技 5 Dash:冲刺 affect 下跳过冷却 - if st.has(5) and st.is_charge_skill(5): - _reset(g); g.slot_cd_end = g._now() + 5.0; g.dash_active = true; g.level_limit = false - var r5 := g.check_skill_usable(5) - _ck(r5.ok or r5.code == "NOT_MATCHABLE_WEAPON", "(c) dash + 蓄力技 -> 跳过冷却") - - # ---------- (c) §3.8 修改 3:NeedSP / __CheckShortMana / __CheckShortLife ---------- - # skill 1 samyeon.msk: NeedSPFormula "40 + (100 * SkillPoint)";_skill_point(5) = 12/100。 - _ck(is_equal_approx(st._skill_point(20), 0.5), "_skill_point(20) = master 50/100") - _ck(is_equal_approx(st._skill_point(0), 0.0) and is_equal_approx(st._skill_point(50), 0.0), - "_skill_point 越界 -> 0") - if st.has(1) and String(st.entry(1).get("need_sp_formula", "")) != "": - _ck(st.need_sp(1, 5) == 52, "need_sp(skill 1, lv5) = 40 + 100*0.12 = 52") - _ck(st.need_hp(1, 5) == st.need_sp(1, 5), "need_hp 复用 GetNeedSP") - # __CheckShortMana:SP 不足 -> NOT_ENOUGH_SP - _reset(g); g.cur_sp = 10 - _ck(g.check_skill_usable(1).code == "NOT_ENOUGH_SP", "(c) SP 10 < 52 -> NOT_ENOUGH_SP") - _reset(g); g.cur_sp = 200 - _ck(g.check_skill_usable(1).ok, "(c) SP 200 >= 52 -> ok") - _reset(g); g.cur_sp = -1 - _ck(g.check_skill_usable(1).ok, "(c) SP 未知 -> 不判 NOT_ENOUGH_SP") - # 未知公式 -> need_sp = -1,不拦截 - var no_sp := 0 - for cand in st.for_job("WARRIOR"): - if st.has(cand) and String(st.entry(cand).get("need_sp_formula", "")) == "" \ - and g._skill_level(cand) > 0 and not st.is_passive(cand) \ - and not st.is_horse_skill(cand) and not st.is_toggle(cand): - no_sp = cand - break - if no_sp != 0: - _reset(g); g.cur_sp = 0 - _ck(g.check_skill_usable(no_sp).code != "NOT_ENOUGH_SP", - "(c) 无 NeedSPFormula(%d) + SP 0 -> 不误挡" % no_sp) - # __CheckShortLife:skill 1 非 USE_HP -> 即便 HP 极低也不挡 - _reset(g); g.cur_hp = 1 - _ck(g.check_skill_usable(1).code != "NOT_ENOUGH_HP", "(c) 非 USE_HP 技 + HP 1 -> 不判 NOT_ENOUGH_HP") - - # ---------- (d) __UseSkill ---------- - _reset(g); g.private_shop_open = true - _ck(g.use_skill(1).code == "PRIVATE_SHOP", "(d) 私人商店 -> PRIVATE_SHOP") - _reset(g); g.is_using_skill = true - _ck(g.use_skill(1).code == "ALREADY_CASTING", "(d) 施法中 -> ALREADY_CASTING") - # 特殊技 - _reset(g) - _ck(g.use_skill(g.SKILL_INDEX_FISHING).code == "SPECIAL_SKILL", "(d) 钓鱼技 -> SPECIAL_SKILL") - _ck(g.use_skill(g.SKILL_INDEX_COMBO).code == "SPECIAL_SKILL", "(d) 连击技 -> SPECIAL_SKILL") - # 目标解析:skill 1 需要目标 - _reset(g) - if st.is_need_target(1): - fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 2} - var rt := g.use_skill(1) - _ck(rt.ok and rt.target_vid == 2000, "(d) 有可攻击目标 -> ok, target=2000") - fc.target = {"vid": 0} - _ck(g.use_skill(1).code == "NEED_TARGET", "(d) 无目标 -> NEED_TARGET") - var r2 := g.use_skill(2) - _ck(r2.ok and r2.target_vid == 0, "(d) skill 2 无目标可自由施放 -> ok, target=0") - fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 2, "in_safe": true} - _ck(g.use_skill(1).code == "CANNOT_ATTACK_ENEMY_IN_SAFE_AREA", - "(d) 目标在安全区 -> CANNOT_ATTACK_ENEMY_IN_SAFE_AREA") - fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 1} # NPC - _ck(g.use_skill(1).code == "CANNOT_ATTACK", "(d) 目标不可攻击 -> CANNOT_ATTACK") - fc.entities[2000] = {"kind": 2} - # __ProcessEnemySkillTargetRange:distance >= TargetRange(+弓距) -> 预约,不施法 - var range1: int = st.target_range(1) - if range1 > 0: - var dist := {"cm": float(range1)} - g.target_distance = func(_vid: int) -> float: return float(dist.cm) - var far := g.use_skill(1) - _ck(not far.ok and far.code == "RESERVED" and far.target_vid == 2000 and far.range_cm == range1, - "(d) 目标恰在射程边界 -> RESERVED(range=%d): %s" % [range1, far]) - _ck(PlayerSkill.is_silent_code("RESERVED"), "RESERVED 不弹 OnCannotUseSkill") - dist.cm = range1 - 1.0 - _ck(g.use_skill(1).ok, "(d) 射程内 1cm -> ok") - g.bow_distance = 2 - dist.cm = range1 + 150.0 - _ck(g.use_skill(1).ok, "(d) POINT_BOW_DISTANCE*100 计入射程") - dist.cm = -1.0 - _ck(g.use_skill(1).ok, "(d) 距离未知 -> 不预约") - _reset(g) - # 蓄力技在射程外要求调用方先发 use_skill(slot, 0) - for id in st._by_id.keys(): - if st.is_charge_skill(int(id)) and st.target_range(int(id)) > 0 and st.is_need_target(int(id)): - fc.skills.append({"id": int(id), "level": 1, "master": 0}) - g.target_distance = func(_vid: int) -> float: return 100000.0 - var rc := g.use_skill(int(id)) - _ck(rc.code != "RESERVED" or rc.charge, "(d) 蓄力技 %d 射程外 -> RESERVED charge=true: %s" % [id, rc]) - fc.skills.pop_back() - _reset(g) - break - # 结盟技 109 Cure:CAN_USE_FOR_ME|ONLY_FOR_ALLIANCE - if st.has(109) and st.is_only_for_alliance(109) and st.can_use_for_me(109): - _reset(g) - var needs_tgt: bool = st.is_need_target(109) or st.can_change_direction(109) \ - or st.is_auto_search_target(109) - if needs_tgt: - fc.target = {"vid": 0} - var rself := g.use_skill(109) - _ck(rself.ok and rself.target_vid == fc.main, - "(d) 结盟技无目标 -> 回落自身 target=main") - fc.target = {"vid": 1000} # 自己 - _ck(g.use_skill(109).ok, "(d) 结盟技对自己 -> ok") - fc.target = {"vid": 2000} - - # 非目标类技能 -> 直接 ok, target 0 - var no_target_skill := 0 - for id in st.for_job("WARRIOR"): - if g._skill_level(id) > 0 and not st.is_need_target(id) \ - and not st.can_change_direction(id) and not st.is_auto_search_target(id) \ - and not st.is_passive(id) and not st.is_horse_skill(id): - no_target_skill = id - break - if no_target_skill != 0: - _reset(g) - var rnt := g.use_skill(no_target_skill) - _ck(rnt.ok and rnt.target_vid == 0 or rnt.code == "NOT_MATCHABLE_WEAPON", - "(d) 非目标技 %d -> ok/target 0" % no_target_skill) - - # is_silent_code - _ck(PlayerSkill.is_silent_code("PASSIVE") and PlayerSkill.is_silent_code("TOGGLE_OFF") - and not PlayerSkill.is_silent_code("IN_SAFE"), - "is_silent_code 分类正确") diff --git a/project/player_skill_test.gd.uid b/project/player_skill_test.gd.uid deleted file mode 100644 index d771f6a1..00000000 --- a/project/player_skill_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://db06iedi0733r diff --git a/project/popup_windows_parity_test.gd b/project/popup_windows_parity_test.gd deleted file mode 100644 index e85f9f4b..00000000 --- a/project/popup_windows_parity_test.gd +++ /dev/null @@ -1,177 +0,0 @@ -# popup_windows_parity_test.gd —— 校验弹窗(人物属性面板、仓库、背包等) -# 严格按照 40250 客户端的 UI 规范与关闭行为。 -# godot --headless --path project --script popup_windows_parity_test.gd -extends SceneTree - -const UiManager = preload("res://ui/ui_manager.gd") -const CharStatusUI = preload("res://ui/char_status_ui.gd") -const SafeboxUI = preload("res://ui/safebox_ui.gd") -const InventoryUI = preload("res://ui/inventory_ui.gd") -const AssetRoot = preload("res://asset_root.gd") - -class FakeClient extends Node: - signal points_changed(points: Dictionary) - signal entity_main_set(vid: int) - signal safebox_opened(size: int, gold: int) - signal safebox_closed() - signal safebox_item_set(cell: int, vnum: int, count: int, flags: int, anti_flags: int, sockets: Array, attrs: Array) - signal inventory_changed(window: int, cell: int) - - var pts: Array = [] - var _main_vid := 7 - - func _init() -> void: - pts.resize(255) - pts.fill(0) - pts[1] = 42 - pts[5] = 850 - pts[6] = 900 - pts[7] = 120 - pts[8] = 150 - - 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": 0, "next_exp": 1000, "gold": 10000, - } - func get_entity(vid: int) -> Dictionary: - return {"name": "TestHero", "guild": 1, "race": 0} - func get_guild_name(gid: int) -> String: - return "HeroGuild" if gid == 1 else "" - func get_inventory() -> Array: return [] - func get_equipment() -> Array: return [] - func get_belt_inventory() -> Array: return [] - func get_safebox_items() -> Array: return [] - func get_safebox_gold() -> int: return 5000 - func get_safebox_size() -> int: return 45 - func safebox_close() -> void: pass - func say(type: int, text: String) -> bool: 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: popup_windows_parity_test (CharacterWindow, SafeboxWindow, InventoryWindow layout + 40250 close behaviors)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var assets := AssetRoot.path() - if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/characterwindow.py")): - print(" (skip: missing uiscript assets)") - return - - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - - var client := FakeClient.new() - get_root().add_child(client) - - # ========================================================================= - # 1. CharacterWindow - # ========================================================================= - var char_ui := CharStatusUI.new() - get_root().add_child(char_ui) - char_ui.setup(ui, client, assets) - char_ui.open("STATUS") - _ck(char_ui.is_open(), "CharacterWindow is_open() after open('STATUS')") - - var char_root: Control = char_ui._win.get("root") - _ck(char_root != null, "CharacterWindow root node exists") - if char_root: - _ck(char_root.position.x >= 0.0, "CharacterWindow position.x (%f) >= 0" % char_root.position.x) - _ck(char_root.position.y >= 0.0, "CharacterWindow position.y (%f) >= 0" % char_root.position.y) - - # Face slot / Guild slot auto-deduction of dimensions - var face_slot: Control = char_ui._node("Face_Slot") - if face_slot: - _ck(face_slot.size.x > 0 and face_slot.size.y > 0, - "Face_Slot has non-zero size: %s" % str(face_slot.size)) - - var guild_slot: Control = char_ui._node("Guild_Name_Slot") - if guild_slot: - _ck(guild_slot.size.x > 0 and guild_slot.size.y > 0, - "Guild_Name_Slot has non-zero size: %s" % str(guild_slot.size)) - - # Tab button type check: must be Button with toggle_mode, NOT CheckBox - var tab1: Control = char_ui._node("Tab_Button_01") - if tab1: - _ck(tab1 is Button and not (tab1 is CheckBox), - "Tab_Button_01 is Button (not CheckBox)") - _ck((tab1 as Button).toggle_mode, "Tab_Button_01 has toggle_mode = true") - - # Close button inside TitleBar - var close_btn: BaseButton = char_root.find_child("CloseButton", true, false) - _ck(close_btn != null, "CharacterWindow has CloseButton in titlebar") - if close_btn: - close_btn.pressed.emit() - _ck(not char_ui.is_open(), "CharacterWindow closes when CloseButton is pressed") - - # ========================================================================= - # 2. SafeboxWindow - # ========================================================================= - var safe_ui := SafeboxUI.new() - get_root().add_child(safe_ui) - safe_ui.setup(client, ui, null, assets) - safe_ui.open() - _ck(safe_ui.is_open(), "SafeboxWindow is_open() after open()") - - var safe_root: Control = safe_ui._root - _ck(safe_root != null, "SafeboxWindow _root node exists") - if safe_root: - _ck(safe_root.position.x >= 0.0, "SafeboxWindow position.x (%f) >= 0 (not negative off-screen)" % safe_root.position.x) - _ck(safe_root.position.y >= 0.0, "SafeboxWindow position.y (%f) >= 0 (not negative off-screen)" % safe_root.position.y) - - # Page buttons check: page radio buttons are Buttons with toggle_mode, not CheckBoxes - var page1: Control = safe_root.find_child("PAGE1_BUTTON", true, false) - if page1: - _ck(page1 is Button and not (page1 is CheckBox), - "PAGE1_BUTTON is Button (not CheckBox)") - - # CloseButton on TitleBar - var safe_close_btn: BaseButton = safe_root.find_child("CloseButton", true, false) - _ck(safe_close_btn != null, "SafeboxWindow has CloseButton in titlebar") - if safe_close_btn: - safe_close_btn.pressed.emit() - _ck(not safe_ui.is_open(), "SafeboxWindow closes when CloseButton is pressed") - - # Re-open and test ExitButton ("关闭" button at bottom) - safe_ui.open() - _ck(safe_ui.is_open(), "SafeboxWindow re-opened") - if safe_ui.is_open(): - var exit_btn: BaseButton = safe_ui._root.find_child("ExitButton", true, false) - _ck(exit_btn != null, "SafeboxWindow has ExitButton at bottom") - if exit_btn: - exit_btn.pressed.emit() - _ck(not safe_ui.is_open(), "SafeboxWindow closes when ExitButton is pressed") - - # ========================================================================= - # 3. InventoryWindow - # ========================================================================= - var inv_ui := InventoryUI.new() - get_root().add_child(inv_ui) - inv_ui.setup(ui, client, null, assets) - inv_ui.open() - _ck(inv_ui.is_open(), "InventoryWindow is_open() after open()") - - var inv_root: Control = inv_ui._win.get("root") - _ck(inv_root != null, "InventoryWindow root node exists") - if inv_root: - _ck(inv_root.position.x >= 0.0, "InventoryWindow position.x (%f) >= 0" % inv_root.position.x) - _ck(inv_root.position.y >= 0.0, "InventoryWindow position.y (%f) >= 0" % inv_root.position.y) - - var inv_close_btn: BaseButton = inv_root.find_child("CloseButton", true, false) - _ck(inv_close_btn != null, "InventoryWindow has CloseButton in TitleBar") - if inv_close_btn: - inv_close_btn.pressed.emit() - _ck(not inv_ui.is_open(), "InventoryWindow closes when CloseButton is pressed") diff --git a/project/popup_windows_parity_test.gd.uid b/project/popup_windows_parity_test.gd.uid deleted file mode 100644 index ebca51d6..00000000 --- a/project/popup_windows_parity_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://3fdvqc5igvc3 diff --git a/project/print_server_address.gd b/project/print_server_address.gd deleted file mode 100644 index 449fd20d..00000000 --- a/project/print_server_address.gd +++ /dev/null @@ -1,35 +0,0 @@ -extends SceneTree - -## Prints the address AppFlow would connect to, resolved through the same -## ServerInfo (built-in default + res://serverlist.txt). Used by -## script/live_smoke_test.sh so the port precheck can never drift from the -## client connect target. Never opens a connection or reads credentials. -## -## env: MT_SERVER_INDEX (default 0), MT_SERVER_CHANNEL (default: the first -## channel listed for that server, matching the login screen default). -## stdout: one line "SERVER_ADDRESS {json}". exit 2 when unresolvable. - -const ServerInfoRes = preload("res://net/serverinfo.gd") - -func _init() -> void: - var serverinfo := ServerInfoRes.new() - serverinfo.load_file("res://serverlist.txt") - var index := int(OS.get_environment("MT_SERVER_INDEX")) if OS.has_environment("MT_SERVER_INDEX") else 0 - var server: Dictionary = serverinfo.server(index) - if server.is_empty(): - printerr("SERVER_ADDRESS: server index %d is not in serverinfo" % index) - quit(2) - return - var channels: Array = server.get("channels", [1]) - var channel := int(channels[0]) if not channels.is_empty() else 1 - if OS.has_environment("MT_SERVER_CHANNEL"): - channel = int(OS.get_environment("MT_SERVER_CHANNEL")) - if not channels.has(channel): - printerr("SERVER_ADDRESS: channel %d is not listed for server %d" % [channel, index]) - quit(2) - return - var address: Dictionary = serverinfo.address(index, channel) - address["server_index"] = index - address["channel"] = channel - print("SERVER_ADDRESS " + JSON.stringify(address)) - quit(0) diff --git a/project/print_server_address.gd.uid b/project/print_server_address.gd.uid deleted file mode 100644 index cfe2902d..00000000 --- a/project/print_server_address.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://3th670yufpf1 diff --git a/project/private_shop_ui_test.gd b/project/private_shop_ui_test.gd deleted file mode 100644 index e4fc6f2a..00000000 --- a/project/private_shop_ui_test.gd +++ /dev/null @@ -1,146 +0,0 @@ -# private_shop_ui_test —— 개인상점 개설창 headless 자체 검사(真 uiscript + 假 client)。 -# godot --headless --path project --script private_shop_ui_test.gd -# 校验 privateshopbuilder.py 装载、itemStock 模型(拿起 / 落位 / 价格 / 排序 + display_pos / -# 撤下)、개설 → open_private_shop 封包字段。缺 uiscript 资产时优雅跳过。 -extends SceneTree - -const PrivateShopUI = preload("res://ui/private_shop_ui.gd") -const MobileWindowHost = preload("res://ui/mobile/mobile_window_host.gd") - -class FakeClient extends Node: - signal inventory_changed(window: int, cell: int) - var inventory: Array = [] - var calls: Array = [] - func get_inventory() -> Array: return inventory - func open_private_shop(sign, items) -> bool: - calls.append(["open_shop", sign, items]); return true - func close_private_shop() -> bool: - calls.append(["close_shop"]); return true - -class FakeProto extends Node: - func item(vnum: int) -> Dictionary: - return {"vnum": vnum, "name": "I%d" % vnum, "locale_name": "물품%d" % vnum, "type": 1} - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _has_call(fc, name: String, pred: Callable) -> bool: - for c in fc.calls: - if c[0] == name and pred.call(c): - return true - return false - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: private_shop_ui_test (privateshopbuilder.py + itemStock model + CG_MYSHOP)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var assets := AssetRoot.path() - if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/privateshopbuilder.py")): - print(" (skip: no privateshopbuilder.py — assets checkout missing)") - return - - var UiManager = load("res://ui/ui_manager.gd") - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - var fc := FakeClient.new() - var fp := FakeProto.new() - get_root().add_child(fc) - get_root().add_child(fp) - fc.inventory = [{"cell": 4, "vnum": 11901, "count": 1}, - {"cell": 5, "vnum": 27993, "count": 3}, {"cell": 6, "vnum": 30001, "count": 2}] - - var pu: Node = PrivateShopUI.new() - get_root().add_child(pu) - pu.setup(fc, ui, fp, assets) - ui.set_mobile_mode(true) - pu.set_mobile_mode(true) - pu.open() - _ck(pu.is_open(), "builder window opened") - if not pu.is_open(): - return - _ck(pu._cells.size() == 40, "ItemSlot = 40-slot grid (5x8, shop.SHOP_SLOT_COUNT)") - _ck(is_instance_valid(pu._sign), "NameLine overlaid with a LineEdit") - _ck(pu._inv_list.get_child_count() == 3, "3 inventory candidates") - _ck((pu._inv_list.get_child(0) as Button).has_method("_get_drag_data"), - "inventory candidate supports drag data") - _ck(pu._win["root"].size.x >= 674.0, "mobile builder keeps candidates inside hosted root") - _ck(not pu._drop_mouse_item({"window": 1, "cell": 99, "vnum": 300, "count": 1, - "anti_flags": (1 << 13)}, 2), "ANTIFLAG_GIVE item cannot enter private shop") - _ck(not pu._drop_mouse_item({"window": 1, "cell": 99, "vnum": 301, "count": 1, - "anti_flags": (1 << 16)}, 2), "ANTIFLAG_MYSHOP item cannot enter private shop") - - # 未上货 → 개설 무동작 - pu._ok() - _ck(not _has_call(fc, "open_shop", func(_c): return true), "empty stock → no send") - - # 拿起第 1 件(cell 4)→ 落到格 3,价 250000 - var first_candidate := pu._inv_list.get_child(0) as Button - MobileWindowHost.wire_buttons(pu._win["root"]) - var candidate_down := InputEventScreenTouch.new() - candidate_down.index = 1 - candidate_down.pressed = true - candidate_down.position = Vector2(20, 40) - first_candidate.gui_input.emit(candidate_down) - var candidate_up := InputEventScreenTouch.new() - candidate_up.index = 1 - candidate_up.pressed = false - candidate_up.position = candidate_down.position - first_candidate.gui_input.emit(candidate_up) - _ck(pu._picked != null and int(pu._picked["cell"]) == 4, "picked inv cell 4") - var mobile_slot_touch := InputEventScreenTouch.new() - mobile_slot_touch.index = 2 - mobile_slot_touch.pressed = true - (pu._cells[3] as Panel).gui_input.emit(mobile_slot_touch) - _ck(is_instance_valid(pu._price_dialog), "price dialog opens on empty slot") - _ck(pu._price_dialog.get_node_or_null("PriceInput") is LineEdit, - "mobile price dialog uses a numeric LineEdit") - ui.close_top() - _ck(pu._price_dialog == null, "mobile back closes price dialog and clears owner state") - _ck(pu._picked != null, "closing price dialog keeps selected item for retry") - pu._ask_price(3) - pu._place(3, 250000) - _ck(pu._stock.has(3) and int(pu._stock[3]["price"]) == 250000, "stock[3] placed") - _ck(pu._picked == null, "pick cleared after place") - _ck(pu._inv_list.get_child_count() == 2, "placed item leaves candidate list") - # 标准 Godot 拖放入口与点击选取共用同一个落位逻辑。 - pu._drop_slot(Vector2.ZERO, {"private_shop_entry": {"cell": 5, "vnum": 27993, "count": 3}}, 6) - _ck(is_instance_valid(pu._price_dialog), "dragging candidate onto empty slot opens price dialog") - pu._place(6, 100) - _ck(pu._stock.has(6), "dragged item is retained in stock") - - # 拿起第 3 件(cell 6)→ 落到格 0(更小格号 → 排前) - (pu._inv_list.get_child(0) as Button).pressed.emit() - pu._place(0, 99) - pu._sign.text = "Cheap swords" - pu._ok() - _ck(_has_call(fc, "open_shop", func(c): - if String(c[1]) != "Cheap swords": return false - var items: Array = c[2] - if items.size() != 3: return false - return int(items[0]["vnum"]) == 30001 and int(items[0]["display_pos"]) == 0 \ - and int(items[1]["vnum"]) == 11901 and int(items[1]["price"]) == 250000 \ - and int(items[1]["inv_cell"]) == 4 and int(items[1]["display_pos"]) == 3 \ - and int(items[2]["vnum"]) == 27993 and int(items[2]["display_pos"]) == 6), - "개설 → open_private_shop(sign, stock 按格号排序 + display_pos)") - _ck(not pu.is_open(), "closes after 개설") - - # 撤下:재개 → 점유 격 클릭 제거 - pu.open() - (pu._inv_list.get_child(0) as Button).pressed.emit() - pu._place(7, 500) - _ck(pu._stock.has(7), "re-placed for remove test") - pu._on_slot_clicked(7) - _ck(not pu._stock.has(7), "click occupied slot removes it (DelPrivateShopItemStock)") - pu._close_shop() - _ck(_has_call(fc, "close_shop", func(_c): return true), "철수 → close_private_shop") - pu.close() diff --git a/project/private_shop_ui_test.gd.uid b/project/private_shop_ui_test.gd.uid deleted file mode 100644 index 54261d07..00000000 --- a/project/private_shop_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://vm3heigi7rmk diff --git a/project/python_host_check.gd b/project/python_host_check.gd index 19a886d7..10b0dc46 100644 --- a/project/python_host_check.gd +++ b/project/python_host_check.gd @@ -55,8 +55,8 @@ func run() -> int: print("system.py -> %s" % err) _ck(err.contains(EXPECTED_STOP), "system.py 进入 prototype.RunApp(),Create() 报告待实现的图形设备") - _ck(Metin2PythonHost.run_line("import player, item; assert player.__port_stub__ == 1 and item.__port_stub__ == 1") == "", - "2V0 玩法模块桩已注册,调用会记录到 stderr") + _ck(Metin2PythonHost.run_line("import player, item; assert not hasattr(player, '__port_stub__') and not hasattr(item, '__port_stub__')") == "", + "player 与 item 为 40250 原生移植模块") _ck(Metin2PythonHost.run_line("import net; assert not hasattr(net, '__port_stub__') and net.PHASE_WINDOW_SELECT == 2 and not net.IsConnect()") == "", "net 是移植的 40250 PythonNetworkStreamModule(2V1)") # 真正从 Godot 入口转发事件给 40250 的 CWindowManager,再由窗口树回调 Python。 diff --git a/project/quest_curtain_test.gd b/project/quest_curtain_test.gd deleted file mode 100644 index 1727c657..00000000 --- a/project/quest_curtain_test.gd +++ /dev/null @@ -1,37 +0,0 @@ -extends SceneTree - -const QuestCurtain = preload("res://ui/quest_curtain.gd") - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - else: - print("OK: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: quest_curtain_test (16:9 letterbox calculation and open/close states)") - quit(0) - else: - printerr("%d checks failed in quest_curtain_test" % _fail) - quit(1) - -func _run() -> void: - var curtain := QuestCurtain.new() - root.add_child(curtain) - - _ck(not curtain.is_open(), "curtain initially closed") - _ck(curtain.top_bar != null, "top_bar instantiated") - _ck(curtain.bottom_bar != null, "bottom_bar instantiated") - - curtain.open(0.01) - _ck(curtain.is_open(), "curtain is_open after open()") - _ck(curtain.top_bar.size.y >= QuestCurtain.MIN_BAR_HEIGHT, "bar height respects MIN_BAR_HEIGHT") - - curtain.close(0.01) - _ck(not curtain.is_open(), "curtain is not open after close()") - - curtain.queue_free() diff --git a/project/quest_curtain_test.gd.uid b/project/quest_curtain_test.gd.uid deleted file mode 100644 index eb7edf10..00000000 --- a/project/quest_curtain_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://rs7v1l5nom2f diff --git a/project/quest_event_test.gd b/project/quest_event_test.gd deleted file mode 100644 index 0531a2f3..00000000 --- a/project/quest_event_test.gd +++ /dev/null @@ -1,169 +0,0 @@ -# quest_event_test —— §5.1 Quest EventManager 1:1(CPythonEventManager): -# EVENT_TYPE_MAP(42 tag)+ GetScriptEventIndex(前导 '/')+ ProcessEventSet 分发 + -# SEventSet 状态(字段名与 REF/UserInterface/PythonEventManager.h 一致)。 -# godot --headless --path project --script quest_event_test.gd -# 退出码 0 = 全过。 -extends SceneTree - -const QuestDialog = preload("res://ui/quest_dialog.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: quest_event_test (§5.1 EventTypeMap + ProcessEventSet 1:1)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var qd := QuestDialog.new() - - # --- 1. EventTypeMap 完整性(PythonEventManager.cpp:1148-1207,42 条)--- - var tags := ["LETTER", "COLOR", "DELAY", "ENTER", "WAIT", "CLEAR", "QUESTION", "NEXT", - "DONE", "LEFTIMAGE", "TOPIMAGE", "BGIMAGE", "IMAGE", "ADDMAPSIGNAL", "CLEARMAPSIGNAL", - "SETMSGPOS", "ADJMSGPOS", "SETCMAPPOS", "QUESTBUTTON", "QUESTBUTTON_CLOSE", "SLEEP", - "SET_CAMERA", "BLEND_CAMERA", "RESTORE_CAMERA", "FADE_OUT", "FADE_IN", "WHITE_OUT", - "WHITE_IN", "CLEAR_TEXT", "TEXT_HORIZONTAL_ALIGN_CENTER", "TITLE_IMAGE", "RUN_CINEMA", - "DUNGEON_RESULT", "ITEM", "MOB", "COLOR256", "WINDOW_SIZE", "INPUT", "CONFIRM_WAIT", - "END_CONFIRM_WAIT", "INSERT_IMAGE", "SELECT_ITEM"] - _ck(tags.size() == 42, "EventTypeMap: 42 tags enumerated (%d)" % tags.size()) - var missing := 0 - for t in tags: - if not qd.EVENT_TYPE_MAP.has(t): - missing += 1 - printerr(" missing EVENT_TYPE_MAP[%s]" % t) - _ck(missing == 0, "EventTypeMap: every tag present") - _ck(qd.EVENT_TYPE_MAP.size() == 42, "EventTypeMap: no extra keys (%d)" % qd.EVENT_TYPE_MAP.size()) - - # --- 2. GetScriptEventIndex:前导 '/' = EVENT_POSITION_END --- - var s := qd._get_script_event_index("DELAY") - _ck(s.ok and s.position == qd.EVENT_POSITION_START and s.type == qd.EVT_DELAY, "GSEI: DELAY -> START/EVT_DELAY") - var e := qd._get_script_event_index("/DELAY") - _ck(e.ok and e.position == qd.EVENT_POSITION_END and e.type == qd.EVT_DELAY, "GSEI: /DELAY -> END/EVT_DELAY") - _ck(qd._get_script_event_index("nope").ok == false, "GSEI: unknown -> ok=false") - _ck(qd._get_script_event_index("letter").type == qd.EVT_LETTER, "GSEI: case-insensitive") - - # --- 3. DELAY START/END:lWaitingTime --- - var d := qd.parse_script("[DELAY value(40)]hi") - _ck(d.waiting_time == 40, "DELAY START -> lWaitingTime=40 (%d)" % d.waiting_time) - var d2 := qd.parse_script("[DELAY value(40)]hi[/DELAY]") - _ck(d2.waiting_time == 10, "/DELAY -> lWaitingTime=c_lNormal_Waiting_Time(10) (%d)" % d2.waiting_time) - - # --- 4. LETTER / 裸文本 / iRestrictedCharacterCount(30) 断行 --- - var lt := qd.parse_script("[LETTER value(\"0123456789012345678901234567890123\")]tail") - # 显式 [LETTER] 34 字 >= 30 -> __InsertLine,"tail" 落到下一行。 - _ck(lt.body.split("\n").size() == 2, "LETTER: >=iRestrictedCharacterCount wraps (%d lines)" % lt.body.split("\n").size()) - var raw := qd.parse_script("这是一段没有任何 ENTER 的很长的网络文本用来验证裸文本不会被 30 字断行处理掉哦哦哦哦哦哦哦") - _ck(raw.body.split("\n").size() == 1, "raw text: no 30-char guillotine (%d lines)" % raw.body.split("\n").size()) - - # --- 5. ENTER -> line;CLEAR / CLEAR_TEXT -> reset --- - _ck(qd.parse_script("a[ENTER]b").body == "a\nb", "ENTER -> line break") - _ck(qd.parse_script("gone[CLEAR]kept").body == "kept", "CLEAR -> body reset") - _ck(qd.parse_script("gone[CLEAR_TEXT]kept").body == "kept", "CLEAR_TEXT -> body reset") - - # --- 6. QUESTION -> choices + nAnswer --- - var q := qd.parse_script("Pick[QUESTION arg(\"yes\") arg(\"no\") arg(\"maybe\")]") - _ck(q.choices == ["yes", "no", "maybe"] and q.answer_count == 3, "QUESTION -> 3 choices + nAnswer=3") - - # --- 7. NEXT / DONE -> has_next + iAdjustLine += 2 --- - var nx := qd.parse_script("x[NEXT]") - _ck(nx.has_next and nx.next_button_type == "NEXT" and nx.adjust_line == 2, - "NEXT -> button type + adjust_line=2 (%d)" % nx.adjust_line) - var dn := qd.parse_script("x[NEXT][DONE]") - _ck(dn.has_next and dn.next_button_type == "DONE" and dn.adjust_line == 4, - "NEXT+DONE -> DONE button type + adjust_line=4 (%d)" % dn.adjust_line) - - # --- 8. WAIT -> isLock --- - var w := qd.parse_script("x[WAIT]y") - _ck(w.is_locked, "WAIT -> isLock true") - - # --- 9. COLOR / COLOR256 START(r/g/b & 位置) + END --- - var c1 := qd.parse_script("[COLOR r(0) g(1) b(0)]G[/COLOR]") - _ck(c1.body.contains("[color=#00ff00]") and c1.body.contains("[/color]"), "COLOR r/g/b named -> span") - var c2 := qd.parse_script("[COLOR256 value;255 value;128 value;0]O") - _ck(c2.body.contains("[color=#ff8000]"), "COLOR256 positional -> /255 span") - - # --- 10. INPUT / SELECT_ITEM --- - _ck(qd.parse_script("name?[INPUT]").has_input, "INPUT -> has_input") - _ck(qd.parse_script("[SELECT_ITEM]").has_select_item, "SELECT_ITEM -> has_select_item") - - # --- 11. CONFIRM_WAIT + END_CONFIRM_WAIT --- - var cw := qd.parse_script("sure?[CONFIRM_WAIT timeout(30)]") - _ck(cw.confirm_wait and cw.confirm_timeout == 30, "CONFIRM_WAIT -> isConfirmWait + timeout=30") - var ce := qd.parse_script("sure?[CONFIRM_WAIT timeout(30)][END_CONFIRM_WAIT]") - _ck(ce.confirm_wait == false and ce.end_confirm, "END_CONFIRM_WAIT -> isConfirmWait cleared + CloseSelf") - - # --- 12. DUNGEON_RESULT -> 9 ints --- - var dr := qd.parse_script("[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)]") - _ck(dr.dungeon_result.get("killmob_count", -1) == 40 and dr.dungeon_result.get("bonus_exp", -1) == 1234 - and dr.dungeon_result.size() == 9, "DUNGEON_RESULT -> 9 named ints") - - # --- 13. INSERT_IMAGE(有 image_name / 无 -> item icon)+ iAdjustLine += 2 --- - var ii := qd.parse_script("[INSERT_IMAGE image_name(\"a.tga\") title(\"T\") desc(\"D\") index(1) total(3)]") - _ck(ii.insert_images.size() == 1 and ii.insert_images[0].image_name == "a.tga" - and ii.insert_images[0].is_item_icon == false and ii.adjust_line == 2, "INSERT_IMAGE with image_name") - var ic := qd.parse_script("[INSERT_IMAGE image_type(\"item\") idx(19) title(\"T\") desc(\"D\") index(0) total(1)]") - _ck(ic.insert_images.size() == 1 and ic.insert_images[0].is_item_icon - and ic.insert_images[0].idx == 19, "INSERT_IMAGE empty image_name -> item icon") - - # --- 14. IMAGE 家族 --- - var im := qd.parse_script("[IMAGE x(10) y(20) src(\"pic.tga\")][LEFTIMAGE src(\"L.tga\")][TOPIMAGE src(\"T.tga\")][BGIMAGE src(\"B.tga\")][TITLE_IMAGE src(\"TT.tga\")]") - _ck(im.images.size() == 4, "IMAGE/LEFT/TOP/BG -> 4 image entries (%d)" % im.images.size()) - _ck(im.images[0].get("path", "") == "pic.tga" and im.images[0].get("x", 0) == 10, "IMAGE x/y/src") - _ck(im.images[1].get("panel", "") == "LEFTIMAGE" and im.images[2].get("panel", "") == "TOPIMAGE", "LEFT/TOP panels") - _ck(im.title_image == "TT.tga", "TITLE_IMAGE -> title_image") - - # --- 15. map signals --- - var ms := qd.parse_script("[ADDMAPSIGNAL x(1200) y(3400)][SETCMAPPOS x(8) y(-4)][CLEARMAPSIGNAL]") - _ck(ms.map_signals == [Vector2(1200, 3400)] and ms.atlas_center == [8, -4] and ms.clear_map_signals, - "ADDMAPSIGNAL / SETCMAPPOS / CLEARMAPSIGNAL") - - # --- 16. QUESTBUTTON / QUESTBUTTON_CLOSE --- - var qb := qd.parse_script("[QUESTBUTTON idx(12) name(\"Read me\") icon_type(\"file\") icon_name(\"q.sub\")][QUESTBUTTON_CLOSE idx(8)]") - _ck(qb.quest_buttons.size() == 1 and qb.quest_buttons[0].index == 12 - and qb.quest_buttons[0].title == "Read me" and qb.quest_buttons[0].icon_name == "q.sub", "QUESTBUTTON keyed") - _ck(qb.quest_button_closes == [8], "QUESTBUTTON_CLOSE idx -> close list") - - # --- 17. camera SET/BLEND/RESTORE --- - var cam := qd.parse_script("[SET_CAMERA x(1) y(2) z(3) distance(50)][BLEND_CAMERA x(4) blendtime(1500)][RESTORE_CAMERA]") - _ck(cam.camera_events.size() == 3, "camera: 3 events") - _ck(cam.camera_events[0].get("setting", {}).get("distance", 0) == 50, "SET_CAMERA setting.distance") - _ck(cam.camera_events[1].get("blendtime", 0) == 1500 and cam.camera_events[2].kind == "RESTORE_CAMERA", "BLEND blendtime + RESTORE") - - # --- 18. FADE_* / WHITE_* -> fade_events + isWaitFlag --- - var fx := qd.parse_script("[FADE_OUT speed(0.25)]") - _ck(fx.fade_events.size() == 1 and fx.fade_events[0].kind == "FADE_OUT" - and is_equal_approx(fx.fade_events[0].speed, 0.25) and fx.wait_flag, "FADE_OUT -> event + wait_flag") - var wx := qd.parse_script("[WHITE_IN speed(1)]") - _ck(wx.fade_events[0].kind == "WHITE_IN" and wx.wait_flag, "WHITE_IN -> event + wait_flag") - - # --- 19. WINDOW_SIZE / TEXT_HORIZONTAL_ALIGN_CENTER --- - var ws := qd.parse_script("[WINDOW_SIZE width(640) height(360)][TEXT_HORIZONTAL_ALIGN_CENTER]") - _ck(ws.window_size == [640, 360] and ws.text_centered, "WINDOW_SIZE keyed + TEXT_CENTER") - - # --- 20. ITEM / MOB 占位(无 proto/entity 时回退命名)--- - var nm := qd.parse_script("kill [MOB value(77)] get [ITEM value(19)]") - _ck(nm.body.contains("怪物 #77") and nm.body.contains("物品 #19"), "ITEM/MOB -> named placeholders") - - # --- 21. SETMSGPOS / ADJMSGPOS 故意留空(PythonEventManager.cpp:562/566)--- - var mp := qd.parse_script("body[SETMSGPOS x(1) y(2)][ADJMSGPOS x(3) y(4)]end") - _ck(mp.body == "bodyend", "SETMSGPOS/ADJMSGPOS -> no-op, no body change") - - # --- 22. 未知 tag 优雅跳过 --- - var uk := qd.parse_script("keep[TOTALLY_MADE_UP arg(1)]this") - _ck(uk.body == "keepthis", "unknown tag -> skipped, text preserved") - - # --- 23. RUN_CINEMA 首命令特例 -> cinema_file seam --- - var rc := qd.parse_script("[RUN_CINEMA value(\"intro.msc\")]") - _ck(rc.cinema_file == "intro.msc", "RUN_CINEMA (first cmd) -> cinema_file") - var rc2 := qd.parse_script("hi[RUN_CINEMA value(\"x.msc\")]") - _ck(rc2.cinema_file == "" and rc2.body == "hi", "RUN_CINEMA (non-first) -> silent no-op") - - qd.free() diff --git a/project/quest_event_test.gd.uid b/project/quest_event_test.gd.uid deleted file mode 100644 index ad4d53d1..00000000 --- a/project/quest_event_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://skkm2dyg8jmx diff --git a/project/quest_test.gd b/project/quest_test.gd deleted file mode 100644 index f674bc9e..00000000 --- a/project/quest_test.gd +++ /dev/null @@ -1,255 +0,0 @@ -# quest_test —— P7:GC_SCRIPT 渲染 + quest_confirm + quest_log + NPC 点击 headless 自检。 -# godot --headless --path project --script quest_test.gd -extends SceneTree - -const QuestDialog = preload("res://ui/quest_dialog.gd") -const QuestLog = preload("res://ui/quest_log.gd") - -class FakeClient extends Node: - signal script_dialog(skin: int, text: String) - signal quest_confirm_ask(msg: String, timeout: int, request_pid: int) - signal quest_info(index: int) - var answers := [] - var cancels := 0 - var inputs := [] - var confirms := [] - var clicks := [] - var quests := [] - var script_buttons := [] - func is_in_game() -> bool: return true - func get_quests() -> Array: return quests - func script_answer(a) -> bool: answers.append(a); return true - func quest_cancel() -> bool: cancels += 1; return true - func quest_input(text) -> bool: inputs.append(text); return true - func quest_confirm(yes, pid) -> bool: confirms.append([yes, pid]); return true - func script_button(index) -> bool: script_buttons.append(index); return true - func click_npc(vid) -> bool: clicks.append(vid); return true - func get_entity(_v) -> Dictionary: 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: quest_test (script parse + dialog + confirm + log)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var canvas := CanvasLayer.new() - get_root().add_child(canvas) - var fc := FakeClient.new() - get_root().add_child(fc) - - # --- 脚本解析 --- - var qd: Node = QuestDialog.new() - get_root().add_child(qd) - qd.setup(fc, canvas) - - var p: Dictionary = qd.parse_script("Greetings, hero.[ENTER]The village needs you.[ENTER][QUESTION arg(\"I accept\") arg(\"Not now\")]") - _ck(p.body.contains("Greetings, hero.") and p.body.contains("village needs"), "plain text kept") - _ck(p.body.split("\n").size() == 2, "[ENTER] -> line break (%d lines)" % p.body.split("\n").size()) - _ck(p.choices == ["I accept", "Not now"], "[QUESTION] -> 2 choices") - var p2: Dictionary = qd.parse_script("Bye.[DONE]") - _ck(p2.has_next and p2.choices.is_empty(), "[DONE] -> continue button, no choices") - var p3: Dictionary = qd.parse_script("[COLOR256 value;255 value;128 value;0]Gold[ENTER][COLOR value;0 value;1 value;0]Green[ITEM value;19][MOB value;77][WINDOW_SIZE value;640 value;360][TEXT_HORIZONTAL_ALIGN_CENTER][CONFIRM_WAIT value;30]") - _ck(p3.body.contains("[color=#ff8000]") and p3.body.contains("Gold") and p3.body.contains("[color=#00ff00]"), - "COLOR/COLOR256 -> RichTextLabel color spans") - _ck(p3.body.contains("物品 #19") and p3.body.contains("怪物 #77"), "ITEM/MOB -> named placeholders") - _ck(p3.window_size == [640, 360] and p3.text_centered and p3.confirm_wait and p3.confirm_timeout == 30, - "WINDOW_SIZE/TEXT_CENTER/CONFIRM_WAIT metadata") - var p4: Dictionary = qd.parse_script("[QUESTBUTTON idx(12) name(\"Read me\") icon_type(\"file\") icon_name(\"quest.sub\")][ADDMAPSIGNAL x(1200) y(3400)][SETCMAPPOS x(8) y(-4)][CLEARMAPSIGNAL][SET_CAMERA value(1) value(2)][FADE_OUT speed(0.25)]") - _ck(p4.quest_buttons.size() == 1 and p4.quest_buttons[0].index == 12 and - p4.quest_buttons[0].title == "Read me", "QUESTBUTTON metadata") - _ck(p4.map_signals.size() == 1 and p4.map_signals[0] == Vector2(1200, 3400) and - p4.clear_map_signals and p4.atlas_center == [8, -4], "map signal metadata") - _ck(p4.camera_events.size() == 1 and p4.fade_events.size() == 1 and - p4.fade_events[0].kind == "FADE_OUT", "camera/fade metadata") - - # --- 对话弹窗 + 选择 -> script_answer --- - fc.script_dialog.emit(1, "Pick one.[QUESTION arg(\"A\") arg(\"B\") arg(\"C\")]") - await process_frame - _ck(qd.is_open(), "dialog shown on script_dialog") - _ck(qd._btnrow.get_child_count() == 3, "3 choice buttons") - (qd._btnrow.get_child(1) as Button).pressed.emit() # 选 B (index 1) - _ck(fc.answers == [1], "choice B -> script_answer(1)") - _ck(not qd.is_open(), "dialog closes after choice") - - # 无选项 -> 「继续」-> script_answer(255) - fc.script_dialog.emit(0, "Just text.[NEXT]") - await process_frame - _ck(qd._btnrow.get_child_count() == 1, "1 continue button") - (qd._btnrow.get_child(0) as Button).pressed.emit() - _ck(fc.answers == [1, 255], "continue -> script_answer(255)") - - # ClientVS22 fires DoneEvent while ProcessEventSet consumes [DONE]. The - # resulting BUTTON_TYPE_DONE callback only closes the dialog and sends no - # script answer packet. - var done_events := 0 - qd.done_event.connect(func(): done_events += 1) - var done_parse: Dictionary = qd.parse_script("Finished.[DONE]") - _ck(done_parse.next_button_type == "DONE" and done_events == 0, - "pure DONE parse stays side-effect free") - fc.script_dialog.emit(0, "Finished.[DONE]") - await process_frame - _ck(done_events == 1 and fc.answers == [1, 255], - "DONE processing emits DoneEvent without script_answer") - (qd._btnrow.get_child(0) as Button).pressed.emit() - _ck(done_events == 1 and fc.answers == [1, 255], - "DONE click only closes without script_answer") - - # ESC follows uiquest.py::OnPressEscapeKey: NEXT sends 254 and closes. - fc.script_dialog.emit(0, "Cancelable quest.[NEXT]") - await process_frame - var esc := InputEventKey.new() - esc.pressed = true - esc.keycode = KEY_ESCAPE - qd._unhandled_input(esc) - _ck(fc.answers == [1, 255, 254] and fc.cancels == 0, "ESC on NEXT -> script_answer(254)") - _ck(not qd.is_open(), "ESC closes quest dialog") - - # [INPUT] renders a LineEdit and sends the entered text through QUEST_INPUT_STRING. - var pi: Dictionary = qd.parse_script("Tell me your name.[INPUT]") - _ck(pi.has_input, "[INPUT] parsed") - fc.script_dialog.emit(0, "Tell me your name.[INPUT]") - await process_frame - var input := qd._btnrow.get_child(0) as LineEdit - input.text = "Alice" - (qd._btnrow.get_child(1) as Button).pressed.emit() - _ck(fc.inputs == ["Alice"], "[INPUT] submit -> quest_input('Alice')") - _ck(not qd.is_open(), "input submit closes quest dialog") - - # EventManager side effects are surfaced to the interface owner. - var received := [] - var cleared := [] - var signal_points := [] - qd.quest_button_received.connect(func(index, title, icon_type, icon_name): received.append([index, title, icon_type, icon_name])) - qd.quest_button_cleared.connect(func(index): cleared.append(index)) - qd.map_signal_added.connect(func(x, y): signal_points.append(Vector2(x, y))) - fc.script_dialog.emit(0, "[QUESTBUTTON idx(12) name(\"Read me\") icon_type(\"file\") icon_name(\"quest.sub\")][QUESTBUTTON_CLOSE idx(8)][ADDMAPSIGNAL x(1200) y(3400)][CLEARMAPSIGNAL]") - await process_frame - _ck(received == [[12, "Read me", "file", "quest.sub"]] and cleared == [8], - "QUESTBUTTON events reach interface") - _ck(signal_points == [Vector2(1200, 3400)], "ADDMAPSIGNAL reaches minimap owner") - - # --- quest_confirm --- - fc.quest_confirm_ask.emit("Join the party?", 30, 999) - await process_frame - _ck(qd.is_open(), "confirm shown") - # 找「接受」按钮 - var acc: Button = null - for n in qd._btnrow.find_children("*", "Button", true, false): - if n.text == "接受": - acc = n - _ck(acc != null, "accept button present") - if acc: - acc.pressed.emit() - _ck(fc.confirms == [[true, 999]], "accept -> quest_confirm(true, 999)") - - # --- quest_log --- - var ql: Node = QuestLog.new() - get_root().add_child(ql) - ql.setup(fc, canvas) - qd.quest_button_received.connect(func(index, title, icon_type, icon_name): ql.recv_quest(index, title, icon_type, icon_name)) - qd.quest_button_cleared.connect(ql.clear_quest) - fc.quests = [ - {"index": 1, "title": "Slay 3 wolves", "counter_name": "Wolves", "counter_value": 2, "clock_name": "Time", "clock_value": 65, "icon": "missing/icon.sub"}, - {"index": 2, "title": "", "counter_name": "", "counter_value": 0, "clock_name": "", "clock_value": 0}, # 完成 -> 不显示 - ] - ql.toggle() - _ck(ql.is_open(), "quest log opened") - await process_frame - # 只 1 条有标题的 - var titles := 0 - for box in ql._list.get_children(): - for lbl in box.find_children("*", "Label", true, false) if box is VBoxContainer else []: - if String(lbl.text).begins_with("◆"): - titles += 1 - _ck(titles == 1, "quest log shows 1 titled quest (got %d)" % titles) - _ck(ql._clock_labels.has(1) and String(ql._clock_labels[1].text).contains("1分5秒"), - "quest clock renders minutes and seconds") - # 40250 CPythonQuest stores iStartTime independently of window visibility. - # Reopening the window must derive the elapsed value from that timestamp, - # rather than restoring the original clock_value. - fc.quests = [{"index": 77, "title": "Timed quest", "counter_name": "", - "counter_value": 0, "clock_name": "Time", "clock_value": 125, - "clock_start_ms": Time.get_ticks_msec() - 121000, "icon": ""}] - ql.close() - ql.toggle() - await process_frame - _ck(ql._clock_labels.has(77) and String(ql._clock_labels[77].text).contains("4秒"), - "quest clock uses captured start after opening") - ql.close() - ql.toggle() - await process_frame - _ck(ql._clock_labels.has(77) and String(ql._clock_labels[77].text).contains("4秒"), - "quest clock remains elapsed across close and reopen") - # ClientVS22 only renders QUEST_MAX_NUM=5 rows and maps the scrollbar to a - # discrete questShowingStartIndex. Empty icon paths use the default scroll icon. - fc.quests.clear() - for i in range(7): - fc.quests.append({"index": 100 + i, "title": "Quest %d" % i, - "counter_name": "", "counter_value": 0, "clock_name": "", "clock_value": 0, "icon": ""}) - ql.refresh() - await process_frame - _ck(ql.quest_count() == 7 and ql._quest_scrollbar.visible, "quest log exposes all quests and scroll bar") - _ck(ql.visible_quest_indices() == [0, 1, 2, 3, 4], "quest log initially shows 5 rows") - ql.set_scroll_ratio(1.0) - await process_frame - _ck(ql.quest_start_index() == 2 and ql.visible_quest_indices() == [2, 3, 4, 5, 6], - "quest scroll maps to last 5 rows") - ql.select_quest(6) - _ck(fc.script_buttons == [4, -2147483648 + 106], "quest row selection -> event quest button packet") - fc.script_dialog.emit(0, "[QUESTBUTTON idx(4) name(\"Talk to guard\") icon_type(\"highlight\") icon_name(\"\")]") - await process_frame - _ck(ql.quest_button_count() == 1 and ql._button_root.visible, "quest button strip receives QUESTBUTTON") - (ql._button_list.get_child(0) as Button).pressed.emit() - _ck(fc.script_buttons == [4] and ql.quest_button_count() == 0, "quest button click -> script_button and remove") - fc.quest_info.emit(1) # 刷新不炸 - await process_frame - _ck(true, "quest_info refresh ok") - - # --- 任务信件按钮条:多列定位(1:1 interfacemodule.__ArrangeQuestButton,§5.2 增量 109)--- - # 参考公式:yPos = 170*H//600 ; yCount = (H-330)//63 ; 第 i 个 = - # (xPos + (i//yCount)*100 , yPos + (i%yCount)*63) ; xPos = 20 / 130(组队窗显示) - _ck(QuestLog.quest_button_y_count(600.0) == 4, "quest btn: yCount(600) == 4") - _ck(QuestLog.quest_button_y_count(1080.0) == 11, "quest btn: yCount(1080) == 11") - _ck(QuestLog.quest_button_y_count(350.0) == 1, "quest btn: yCount clamps to 1 below ZeroDivision") - _ck(QuestLog.quest_button_y_pos(600.0) == 170, "quest btn: yPos(600) == 170") - _ck(QuestLog.quest_button_y_pos(1200.0) == 340, "quest btn: yPos scales with height") - ql.set_screen_size_override(Vector2(800, 600)) - for i in range(6): - ql.recv_quest(10 + i, "Letter %d" % i, "file", "") - await process_frame - # insert(0, btn):最新在前 - _ck(ql.quest_button_order() == [15, 14, 13, 12, 11, 10], "quest btn: newest inserted at front") - _ck(ql.quest_button_position(15) == Vector2(20, 170), "quest btn: slot 0 at (xPos, yPos)") - _ck(ql.quest_button_position(12) == Vector2(20, 359), "quest btn: slot 3 stacks down one column") - _ck(ql.quest_button_position(11) == Vector2(120, 170), "quest btn: slot 4 wraps to column 2") - _ck(ql.quest_button_position(10) == Vector2(120, 233), "quest btn: slot 5 is column 2 row 1") - _ck(ql._button_list.get_child(0).position == Vector2(20, 170), "quest btn: node position matches formula") - # 同 index 再来 -> 移到最前 - ql.recv_quest(13, "Letter 3 (again)", "file", "") - await process_frame - _ck(ql.quest_button_order() == [13, 15, 14, 12, 11, 10], "quest btn: re-received index moves to front") - # 组队窗显示 -> xPos 130 - ql.set_party_shown(true) - await process_frame - _ck(ql.quest_button_position(13) == Vector2(130, 170), "quest btn: party window shifts xPos to 130") - _ck(ql._button_list.get_child(0).position == Vector2(130, 170), "quest btn: node repositioned on party toggle") - ql.set_party_shown(false) - ql.clear_quest(13) - await process_frame - _ck(not ql.quest_button_order().has(13) and ql.quest_button_count() == 5, - "quest btn: clear drops index from order") - for i in range(6): - ql.clear_quest(10 + i) - await process_frame - _ck(ql.quest_button_order().is_empty() and not ql._button_root.visible, - "quest btn: emptied strip hides root") diff --git a/project/quest_test.gd.uid b/project/quest_test.gd.uid deleted file mode 100644 index 3808bcf8..00000000 --- a/project/quest_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dyun2ffibchnl diff --git a/project/race_motion_assembly_test.gd b/project/race_motion_assembly_test.gd deleted file mode 100644 index d41b1947..00000000 --- a/project/race_motion_assembly_test.gd +++ /dev/null @@ -1,58 +0,0 @@ -extends SceneTree - -const PlayerView = preload("res://ui/player_view.gd") -const EquipModel = preload("res://ui/equip_model.gd") -var failures: Array[String] = [] - -func _initialize() -> void: - call_deferred("run") - -func check(ok: bool, message: String) -> void: - if not ok: - failures.append(message) - printerr(message) - -func run() -> void: - for race in 8: - var pv := PlayerView.new() - root.add_child(pv) - if not pv.build(AssetRoot.path(), race): - check(false, "race %d failed to build" % race) - pv.queue_free() - continue - pv.anim.set_process(false) - pv.anim.set("blend_time", 0.0) - var equip := EquipModel.new() - pv.add_child(equip) - equip.setup_remote(null, func() -> Node: return pv, AssetRoot.path(), race, [0, 0, 0, 0]) - # MSM is an independent asset authority for the equipped body's gender. - var body_dir := String(pv.model.get("gr2_path")).get_base_dir().to_lower() - check(body_dir == pv.motion_dir.get_base_dir().to_lower(), - "race %d: MSM body and animation directories differ" % race) - check(String(pv.model.get("hair_gr2")).to_lower().begins_with(body_dir + "/"), - "race %d: hair is from a different resource family" % race) - for motion in ["wait", "walk", "run", "attack", "damage", "dead"]: - pv.set_anim_state(motion) - # RaceData can select weighted variants; assert the registered basename, - # not one random sample from the vector. - var variants: Array = [motion] - if motion == "wait": - variants = ["wait", "wait_1", "wait_2"] - elif motion == "attack": - variants = ["attack", "attack_1"] - elif motion == "damage": - variants = ["damage", "damage_1"] - elif motion == "dead": - variants = ["dead"] - check(String(pv.anim.get("anim_path")).get_file().get_basename() in variants, - "race %d: missing %s motion" % [race, motion]) - for fraction in [0.0, 0.5, 1.0]: - pv.anim.set("time", float(pv.anim.call("get_duration")) * fraction) - for mi in pv.model.find_children("*", "MeshInstance3D", true, false): - var bounds: AABB = mi.get_aabb() - check(bounds.position.is_finite() and bounds.size.is_finite() and bounds.size.length() > 0, - "race %d: invalid bounds in %s" % [race, motion]) - pv.queue_free() - await process_frame - print("race_motion_assembly_test: failures=", failures.size(), " (8 races, 6 motions, 3 samples)") - quit(0 if failures.is_empty() else 1) diff --git a/project/race_motion_assembly_test.gd.uid b/project/race_motion_assembly_test.gd.uid deleted file mode 100644 index 7aa08a60..00000000 --- a/project/race_motion_assembly_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://tevl03ejjnln diff --git a/project/race_spec_test.gd b/project/race_spec_test.gd deleted file mode 100644 index 25ce7c3f..00000000 --- a/project/race_spec_test.gd +++ /dev/null @@ -1,140 +0,0 @@ -# race_spec_test —— P2:种族规格 `.msm` 解析 + equip_model 身体换装。 -# godot --headless --path project --script race_spec_test.gd -extends SceneTree - -const RaceSpec = preload("res://ui/race_spec.gd") -const EquipModel = preload("res://ui/equip_model.gd") - -class FakeClient extends Node: - signal inventory_changed(window: int, cell: int) - signal entity_main_set(vid: int) - var equip := [] - func _init(): - for i in 11: equip.append({"vnum": 0, "count": 0, "wear": i}) - func get_equipment() -> Array: return equip - func set_wear(idx, vnum): - equip[idx] = {"vnum": vnum, "count": 1, "wear": idx} - inventory_changed.emit(2, 90 + idx) - -class StubModel extends Node3D: - var gr2_path := "" - var weapon_gr2 := "" - var hair_gr2 := "" - var hair_skin := "" - var surf := {} - func _set(p, v): - if p == "gr2_path": gr2_path = v; return true - if p == "weapon_gr2": weapon_gr2 = v; return true - if p == "hair_gr2": hair_gr2 = v; return true - if p == "hair_skin": hair_skin = v; return true - return false - func set_surface_texture(s, path): surf[s] = path - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - call_deferred("_run_and_finish") - -func _run_and_finish() -> void: - await _run() - if _fail == 0: - print("PASS: race_spec_test (msm parse + body swap)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var assets := AssetRoot.path() - # 找一个真实的 warrior race spec - var candidates := [ - assets.path_join("PC/ymir work/pc/warrior/warrior.msm"), - assets.path_join("season1/season1/pc/warrior.msm"), - assets.path_join("pc2/ymir work/pc2/warrior/warrior.msm"), - ] - var spec_path := "" - for c in candidates: - if FileAccess.file_exists(c): - spec_path = c - break - if spec_path == "": - print(" (skip: no warrior.msm)") - return - - var rs := RaceSpec.new() - _ck(rs.load_file(spec_path), "race spec parsed: %s" % spec_path) - _ck(not rs.base_model.begins_with(String.chr(1)), "quoted tokens do not retain lexer sentinel") - _ck(rs.shapes.size() >= 5, "shapes parsed (%d)" % rs.shapes.size()) - _ck(rs.base_model.to_lower().contains("warrior_novice"), "base_model = warrior_novice (%s)" % rs.base_model) - var s0: Dictionary = rs.shape(0) - _ck(s0.get("model", "").to_lower() == "warrior_novice.gr2", "shape 0 model = warrior_novice.gr2 (%s)" % s0.get("model")) - # shape 9 是 cheongrin(见 warrior.msm) - var s9: Dictionary = rs.shape(9) - if not s9.is_empty(): - _ck(s9.get("model", "").to_lower().contains("cheongrin"), "shape 9 model = warrior_cheongrin (%s)" % s9.get("model")) - var h3: Dictionary = rs.hair(3) - if not h3.is_empty(): - _ck(h3.get("model", "").contains("hair"), "hair 3 model has 'hair' (%s)" % h3.get("model")) - _ck(h3.get("target_skin", "").contains("red"), "hair 3 target_skin = red variant (%s)" % h3.get("target_skin")) - # 用户出错的角色用 HairIndex 0;它不是推测的默认路径,必须由 MSM - # 明确解析到 hair_1_1,且保持可换的 TargetSkin。 - var h0: Dictionary = rs.hair(0) - _ck(h0.get("path", "") == rs.hair_path and rs.hair_path != "", - "empty SpecialPath inherits HairData PathName") - _ck(h0.get("model", "").to_lower() == "hair/hair_1_1.gr2", - "hair 0 model = hair/hair_1_1.gr2 (%s)" % h0.get("model", "")) - _ck(h0.get("target_skin", "").to_lower().contains("warrior_hair_01"), - "hair 0 target skin is warrior_hair_01 (%s)" % h0.get("target_skin", "")) - - # --- equip_model 用 race_spec 换身体 --- - var fc := FakeClient.new() - var model := StubModel.new() - get_root().add_child(fc) - get_root().add_child(model) - var em: Node = EquipModel.new() - get_root().add_child(em) - em.setup(fc, null, func() -> Node: return model, assets, 0) # race 0 = warrior - - # 装一件 shape 9(cheongrin)的 armor(armor_shape_of 默认 vnum==shape) - fc.set_wear(0, 9) - await process_frame - if not rs.shape(9).is_empty(): - _ck(model.gr2_path.to_lower().contains("cheongrin") and FileAccess.file_exists(model.gr2_path), - "body swap -> real warrior_cheongrin.gr2 (%s)" % model.gr2_path) - - # 换 shape 0(回 novice)—— 同模型不同 TargetSkin -> surface 0 覆盖 - fc.set_wear(0, 1) # shape 1: novice.gr2 + TargetSkin blue - await process_frame - if not rs.shape(1).is_empty(): - _ck(model.gr2_path.to_lower().contains("novice"), "shape 1 -> novice body") - _ck(model.surf.has(0), "shape 1 recolour -> set_surface_texture(0, ...)") - - # 标准发型有两段编号:0..5 是 hair_1 的换色,1001..1012 才是 - # hair_2..4 的不同几何。它们不是道具 vnum,远端/选角不能把它们 - # 丢给 item_list 后静默回落到 PlayerView 的 hair_1_1 默认模型。 - var remote_model := StubModel.new() - get_root().add_child(remote_model) - var remote: Node = EquipModel.new() - get_root().add_child(remote) - remote.setup_remote(null, func() -> Node: return remote_model, assets, 0, - [0, 0, 0, 1001]) - await process_frame - var h1001: Dictionary = rs.hair(1001) - _ck(not h1001.is_empty(), "hair 1001 exists in warrior.msm") - var remote_h1001: Dictionary = remote._spec_hair(1001) - _ck(not remote_h1001.is_empty(), "remote actor resolves hair 1001 (%s)" % remote_h1001) - _ck(remote._hair_part() == 1001, "remote actor preserves hair part 1001 (%s)" % str(remote._remote_parts)) - var remote_hair_model: String = remote._resolve_spec_asset(remote_h1001, String(remote_h1001.get("model", ""))) - _ck(remote_hair_model != "", "hair 1001 source model path resolves (%s)" % remote_hair_model) - # setup_remote refreshes synchronously; this direct retry distinguishes a - # setup ordering bug from asset/spec resolution failure. - remote._apply_hair_from_parts(remote_model, true) - if not h1001.is_empty(): - _ck(remote_model.hair_gr2.to_lower().ends_with(String(h1001.get("model", "")).to_lower()), - "standard hair 1001 resolves MSM geometry (%s)" % remote_model.hair_gr2) - _ck(remote_model.hair_skin.to_lower().ends_with(String(h1001.get("target_skin", "")).to_lower()), - "standard hair 1001 resolves MSM skin (%s)" % remote_model.hair_skin) diff --git a/project/race_spec_test.gd.uid b/project/race_spec_test.gd.uid deleted file mode 100644 index d6d96e53..00000000 --- a/project/race_spec_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://tynwumddp0ff diff --git a/project/remote_motion_fly_test.gd b/project/remote_motion_fly_test.gd deleted file mode 100644 index cb81d341..00000000 --- a/project/remote_motion_fly_test.gd +++ /dev/null @@ -1,72 +0,0 @@ -# remote_motion_fly_test —— 40250 CActorInstance::ProcessMotionEventFly 的远端回归。 -# 验证 GC_FLY_TARGETING 目标队列在远端 MSA FLY 事件帧创建资源飞行物,且消费 -# 正确目标、保留 owner VID / skill index,并使用动作位置作为起点。 -extends SceneTree - -const AssetRoot = preload("res://asset_root.gd") -const FlyObject = preload("res://fly_object.gd") -const NetWorld = preload("res://net_world.gd") - -class Anim extends Node: - signal motion_event_detailed(event: Dictionary) - -class Actor extends Node3D: - var anim := Anim.new() - func _init() -> void: - add_child(anim) - -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 world := NetWorld.new() - var mount := Node3D.new() - var actor := Actor.new() - var target := Node3D.new() - get_root().add_child(world) - get_root().add_child(mount) - mount.add_child(actor) - mount.add_child(target) - actor.position = Vector3.ZERO - target.position = Vector3(4.0, 0.0, 0.0) - world._by_vid[42] = actor - world._by_vid[99] = target - actor.set_meta("vid", 42) - world._assets_root = AssetRoot.path() - world._fly_mgr = FlyObject.new() - world.add_child(world._fly_mgr) - world._fly_mgr.setup(mount, null, world._assets_root) - world._connect_actor_motion_events(actor, 42) - world._on_fly_targeting(42, 99, Vector2.ZERO, false) - var event := { - "type": 6, - "fly_file": "d:/ymir work/pc/assassin/effect/arrow_01_fast.msf", - "fly_pos": Vector3(0.0, 0.0, 0.0), - "fly_attaching": false, - "skill_index": 7, - } - actor.anim.motion_event_detailed.emit(event) - check(world._fly_mgr.active_count() == 1, - "remote MSA FLY -> creates one resource-driven projectile") - check((world._fly_mgr._instances[0] as FlyObject.FlyInstance)._owner_vid == 42, - "remote projectile owner VID follows actor") - check((world._fly_mgr._instances[0] as FlyObject.FlyInstance).skill_index == 7, - "remote projectile skill index follows motion event") - check((actor.get_meta("fly_target_queue", []) as Array).is_empty(), - "remote MSA FLY consumes current FlyTarget exactly once") - check((world._fly_mgr._instances[0] as FlyObject.FlyInstance).target_position().distance_to(target.position) < 0.01, - "remote projectile keeps the queued object target") - world._fly_mgr.clear_for_map_change() - world.free() - mount.free() - await process_frame - if failures == 0: - print("PASS: remote_motion_fly_test") - quit(1 if failures else 0) diff --git a/project/remote_motion_fly_test.gd.uid b/project/remote_motion_fly_test.gd.uid deleted file mode 100644 index 2366f378..00000000 --- a/project/remote_motion_fly_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://gy66t1yricvn diff --git a/project/remote_motion_splash_test.gd b/project/remote_motion_splash_test.gd deleted file mode 100644 index 4923d6f3..00000000 --- a/project/remote_motion_splash_test.gd +++ /dev/null @@ -1,141 +0,0 @@ -# remote_motion_splash_test —— 40250 ActorInstanceMotionEvent::SPECIAL_ATTACKING -# / InstanceBase::AttackProcess 的远端 actor 回归。 -# -# 远端攻击事件必须建立同样的 SplashArea 并执行碰撞、命中上限和去重; -# 但远端事件不能伪造本地主角的 CG_ATTACK。 -extends SceneTree - -const NetPlay = preload("res://net_play.gd") - -class HitActor extends Node3D: - var hits := 0 - var delays: Array = [] - var shakes := 0 - var spheres := [{"radius": 60.0, "pos": Vector3.ZERO, "bone": ""}] - - func get_defending_spheres() -> Array: - return spheres - - func insert_delay(value: float) -> void: - delays.append(value) - - func hit_good(_scalar: float, _stunned: bool) -> void: - hits += 1 - - func shake() -> void: - shakes += 1 - -class ClientStub extends Node: - var entities: Dictionary = {} - var attacks: Array = [] - - func is_in_game() -> bool: - return true - - func get_entity(vid: int) -> Dictionary: - return entities.get(vid, {}) - - func get_entities() -> Array: - return entities.values() - - func attack(skill: int, vid: int) -> bool: - attacks.append([skill, vid]) - return true - -class PCStub extends Node: - var player: Node3D - -class WorldStub extends Node: - var nodes: Dictionary = {} - - func node_for(vid: int) -> Node3D: - return nodes.get(vid, null) - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - await _run() - if _fail == 0: - print("PASS: remote_motion_splash_test (remote SPECIAL_ATTACKING)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var client := ClientStub.new() - var pc := PCStub.new() - var world := WorldStub.new() - var local := HitActor.new() - var remote := HitActor.new() - local.position = Vector3(-1.0, 0.0, 0.0) - remote.position = Vector3.ZERO - pc.player = local - for n in [client, pc, world, local, remote]: - get_root().add_child(n) - - client.entities[1] = {"vid": 1, "kind": 0, "race": 0, "dead": false, - "in_safe": false, "pk_mode": 0} - client.entities[2] = {"vid": 2, "kind": 2, "race": 101, "dead": false, - "in_safe": false, "owner_vid": 0} - world.nodes[1] = local - world.nodes[2] = remote - - var np: Node = NetPlay.new() - get_root().add_child(np) - np.client = client - np.pc = pc - np.net_world = world - np._main_vid = 1 - np._local_time = 1.0 - - var event := { - "type": 4, - "skill_index": 51, - "duration_time": 0.5, - "enable_hit_process": true, - "attack_type": 0, - "hitting_type": 2, - "stiffen_time": 0.1, - "invisible_time": 0.2, - "external_force": 0.0, - "hit_limit_count": 1, - "collision_spheres": [{"radius": 60.0, "pos": Vector3(100, 0, 0)}], - } - np.start_remote_motion_splash(2, event) - _ck(np._remote_motion_splashes.has(2), - "remote SPECIAL_ATTACKING creates an actor-owned SplashArea") - - np._remote_splash_attack_process() - _ck(local.hits == 1, "remote splash applies the normal hit reaction to the local actor") - _ck(local.delays.size() == 1 and is_equal_approx(float(local.delays[0]), 0.1), - "remote splash applies the server motion stiffen time") - _ck(remote.delays.size() == 1 and is_equal_approx(float(remote.delays[0]), 0.1), - "remote splash inserts delay on the attacking actor, not the local input actor") - _ck(client.attacks.is_empty(), - "remote splash does not synthesize a local CG_ATTACK packet") - - # Clearing the victim's temporary invisible window must still not produce a - # second hit: CActorInstance::m_HitDataMap deduplicates the same VID. - np._victim_invisible_until.clear() - np._remote_splash_attack_process() - _ck(local.hits == 1, "remote splash deduplicates a victim for the area lifetime") - _ck(np._remote_motion_splashes.has(2), "active remote splash remains until DuringTime expires") - - np._local_time = 1.5 - np._remote_splash_attack_process() - _ck(not np._remote_motion_splashes.has(2), - "remote splash is cleaned up at DuringTime") - - np.free() - remote.free() - local.free() - world.free() - pc.free() - client.free() - await process_frame diff --git a/project/remote_motion_splash_test.gd.uid b/project/remote_motion_splash_test.gd.uid deleted file mode 100644 index 045a5a76..00000000 --- a/project/remote_motion_splash_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cyfu8o40y6tc7 diff --git a/project/remote_player_test.gd b/project/remote_player_test.gd deleted file mode 100644 index a40caa3d..00000000 --- a/project/remote_player_test.gd +++ /dev/null @@ -1,103 +0,0 @@ -# remote_player_test —— 远端 PC 真模型 + awPart[4] 换装链回归。 -# godot --headless --path project --script remote_player_test.gd -extends SceneTree - -const ItemListDB = preload("res://ui/item_list.gd") -const RemotePlayerView = preload("res://ui/remote_player_view.gd") -const NetWorld = preload("res://net_world.gd") -const EffectRegistry = preload("res://fx/effect_registry.gd") - -class FieldProbe extends Node3D: - var calls: Array = [] - func set_armor(v: int) -> void: calls.append(["armor", v]) - func set_weapon(v: int) -> void: calls.append(["weapon", v]) - func set_head(v: int) -> void: calls.append(["head", v]) - func set_hair(v: int) -> void: calls.append(["hair", v]) - -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: remote_player_test (remote PlayerView + awPart[4])") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2AnimPlayer"): - print(" (skip: animation extension not registered)") - return - var assets := AssetRoot.path() - if not DirAccess.dir_exists_absolute(assets.path_join("PC/ymir work/pc/warrior")): - print(" (skip: PC assets unavailable)") - return - var il := ItemListDB.new() - if not il.load_file(assets.path_join("locale/locale/common/item_list.txt")): - print(" (skip: item_list unavailable)") - return - var proto: Node - if ClassDB.class_exists("Metin2Proto"): - proto = ClassDB.instantiate("Metin2Proto") - get_root().add_child(proto) - var proto_path := AssetRoot.locale_file("item_proto") - if proto_path == "" or not proto.call("load_item_proto", proto_path): - proto = null - var effects := EffectRegistry.new() - effects.setup(assets) - var pv := RemotePlayerView.new() - var parts: Array = [0, 3159, 0, 5] # ARMOR / TWO-HAND +9 / HEAD / HAIR - _ck(pv.build_remote(assets, 0, parts, il, proto, Callable(), effects), - "off-tree remote warrior PlayerView builds") - get_root().add_child(pv) # NetWorld adds the fully assembled view this way. - if pv.remote_equip == null: - return - _ck(pv.remote_equip._remote_parts == parts, "remote EquipModel receives awPart[4]") - await process_frame - await process_frame - if proto: - _ck(pv.get_motion_mode() == 3, "remote weapon 3159 -> TWOHAND_SWORD mode") - # WAIT 按权重随机选 wait.msa / wait_1.msa(见 motion_registry_test、player_motion_test),两者都是双手握姿。 - var wait_re := RegEx.create_from_string("/twohand_sword/wait(_\\d+)?\\.msa$") - _ck(wait_re.search(String(pv.anim.get("anim_path"))) != null, - "remote heavy-weapon wait uses two-hand grip pose") - _ck(pv.remote_equip._weapon_refine_fx.size() == 1, - "remote +9 heavy weapon creates one persistent refine effect") - if pv.remote_equip._weapon_refine_fx.size() == 1: - var refine: Node = pv.remote_equip._weapon_refine_fx[0] - _ck(refine.get_parent() == pv, - "remote +9 effect is attached through the character skeleton") - var emitters := refine.find_children("*", "GPUParticles3D", true, false) - _ck(emitters.size() > 0, - "remote +9 sword_9 effect contains rendered GPU particles") - var emitting := false - for emitter in emitters: - emitting = emitting or bool(emitter.get("emitting")) - _ck(emitting, "remote +9 sword_9 GPU particles are actively emitting") - pv.set_weapon(0) - await process_frame - _ck(int(pv.remote_equip._remote_parts[1]) == 0, "set_weapon updates awPart[WEAPON]") - if proto: - _ck(pv.get_motion_mode() == 1, "remote unequip -> GENERAL mode") - pv.set_head(19) - await process_frame - _ck(int(pv.remote_equip._remote_parts[2]) == 19, "set_head updates awPart[HEAD]") - pv.set_hair(5) - await process_frame - _ck(int(pv.remote_equip._remote_parts[3]) == 5, "set_hair updates awPart[HAIR]") - pv.queue_free() - - # NetworkActorManager::UpdateActor 的四个 awPart 字段序必须保持 ARMOR/WEAPON/HEAD/HAIR。 - var nw := NetWorld.new() - var probe := FieldProbe.new() - nw._apply_field_updates(probe, {"parts": [11, 22, 33, 44]}) - _ck(probe.calls == [["armor", 11], ["weapon", 22], ["head", 33], ["hair", 44]], - "NetWorld applies awPart[4] in reference order") - probe.free() - nw.free() diff --git a/project/remote_player_test.gd.uid b/project/remote_player_test.gd.uid deleted file mode 100644 index c5be663b..00000000 --- a/project/remote_player_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ci70o6w2yd5cc diff --git a/project/rendering_scenario_test.gd b/project/rendering_scenario_test.gd deleted file mode 100644 index cb3ad2bd..00000000 --- a/project/rendering_scenario_test.gd +++ /dev/null @@ -1,230 +0,0 @@ -# Offline real-resource stress/viewport report; NOT 40250 visual parity or -# server reconnect acceptance. Run with a renderer, --quit-after 3600. -# suite=offline_synthetic: FakeClient + real assets. STB-01 live numbers only come -# from the networked soak suite (script/playable_soak.sh); never merge the two. -extends SceneTree -const GameScene = preload("res://game_scene.gd") -const Fixtures = preload("res://gamescene_test.gd") -const PlayerView = preload("res://ui/player_view.gd") -const MobView = preload("res://ui/mob_view.gd") -const UiAssets = preload("res://ui/ui_assets.gd") -const Metrics = preload("res://testing/playable_metrics.gd") -class ScenarioClient extends Fixtures.FakeClient: - signal warp(pos: Vector3, same_server: bool) - signal world_reset() -var failures: Array[String] = [] -var output := "" -var metrics: RefCounted -func check(ok: bool, label: String) -> void: - if not ok: - failures.append(label) - printerr("FAIL: " + label) -func _init() -> void: - call_deferred("run") -func run() -> void: - var run_start := Time.get_ticks_msec() - var sample_frames := 90 - if OS.has_environment("MT_RENDER_SAMPLE_FRAMES"): - sample_frames = clampi(int(OS.get_environment("MT_RENDER_SAMPLE_FRAMES")), 90, 36000) - output = OS.get_environment("MT_RENDER_OUTPUT") - if output == "": - output = ProjectSettings.globalize_path("res://../build/rendering/scenarios-followup") - DirAccess.make_dir_recursive_absolute(output) - root.size = Vector2i(1280, 720) - var report := {"scope": "offline real maps, CPU/GPU animated crowd and viewport layout; no server writes or 40250 parity", - "suite": "offline_synthetic", "suite_note": "离线合成:FakeClient + 真实资源,无服务器;不能代替 soak 实网 suite", - "engine": Engine.get_version_info(), "backend": DisplayServer.get_name(), "samples": [], - "sample_frames": sample_frames, "model_builds": [], "viewports": [], - "percentile": "nearest-rank over window-frame intervals", - "cold_start_note": "进程冷启动/系统缓存状态未知;测试不清空系统缓存"} - metrics = Metrics.new() - metrics.configure({"window_seconds": 3600}) - var client := ScenarioClient.new() - root.add_child(client) - var scene := GameScene.new() - root.add_child(scene) - await scene.setup(client, AssetRoot.path(), "OutdoorA1/metin2_map_a1") - client.main = 1000 - client.ents[1000] = {"vid": 1000, "name": "Scenario", "race": 0, "pos_cm": Vector2(454600, 934000), - "pos": Vector3(4546, 0, -9340), "is_main": true, "func": 0, "moving": false, - "angle_deg": 0.0, "hp": 100, "max_hp": 100, "dead": false} - for i in 30: await process_frame - check(scene._model_built and scene._map_loaded(), "A1 main character and world loaded") - var centre: Vector3 = scene.player.position - report["probe_overhead"] = await _probe_overhead(sample_frames) - metrics.reset_frame_clock() - metrics.frame() - for mode in ["0", "1"]: - OS.set_environment("MTGODOT_GPUSKIN", mode) - var crowd := Node3D.new() - scene.add_child(crowd) - for count in [1, 8, 16]: - _tags("OutdoorA1", "loading", count, mode == "1") - while crowd.get_child_count() < count: - var index := crowd.get_child_count() - var view := PlayerView.new() - var build_start := Time.get_ticks_usec() - check(view.build(AssetRoot.path(), index % 8), "crowd race %d build" % (index % 8)) - var build_ms := (Time.get_ticks_usec() - build_start) / 1000.0 - metrics.mark("model_build", {"race": index % 8, "ms": build_ms}) - report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8, "build_ms": build_ms, - "note": "synchronous build only; OS/shader caches may already be warm"}) - crowd.add_child(view) - view.position = centre + Vector3((index % 4 - 1.5) * 2, 0, (index / 4 + 1) * 2) - view.position.y = scene.world.sample_height(view.position.x, view.position.z) - view.set_anim_state("run") - for i in 12: - await process_frame - metrics.frame() - _tags("OutdoorA1", "gameplay", count, mode == "1") - var times: Array[float] = [] - var switches: Array[float] = [] - var memory_start := Performance.get_monitor(Performance.MEMORY_STATIC) - var previous := Time.get_ticks_usec() - for i in sample_frames: - if i > 0 and i % 30 == 0: - var switch_start := Time.get_ticks_usec() - var state: String = ["run", "attack", "wait"][(i / 30) % 3] - for view in crowd.get_children(): view.set_anim_state(state) - switches.append((Time.get_ticks_usec() - switch_start) / 1000.0) - metrics.mark("motion_switch", {"state": state, "count": count, "ms": switches[-1]}) - await process_frame - metrics.frame() - var now := Time.get_ticks_usec() - times.append((now - previous) / 1000.0) - previous = now - for view in crowd.get_children(): - var bounds: AABB = view.model.get_visual_aabb() - check(bounds.position.is_finite() and bounds.size.is_finite() and bounds.size.length() > 0, - "finite posed bounds after run/attack/wait blend") - times.sort() - switches.sort() - var sorted := PackedFloat32Array(times) - report.samples.append({"gpu_skin": mode == "1", "extra_players": count, - "median_ms": Metrics.percentile(sorted, 0.5), "p95_ms": Metrics.percentile(sorted, 0.95), - "p99_ms": Metrics.percentile(sorted, 0.99), "max_ms": sorted[-1], - "p99_note": "nearest-rank; below 100 samples p99 equals max", - "over_50ms": times.filter(func(ms: float) -> bool: return ms > Metrics.LONG_MS).size(), - "over_100ms": times.filter(func(ms: float) -> bool: return ms > Metrics.SEVERE_MS).size(), - "motion_switches": switches.size(), "motion_switch_max_ms": switches[-1], - "memory_static_start": memory_start, "memory_static_end": Performance.get_monitor(Performance.MEMORY_STATIC), - "clip_cache": Metin2AnimPlayer.get_clip_cache_stats(), - "note": "%d window-frame intervals with repeated motion transitions; not GPU timestamp, RSS or FPS guarantee" % sample_frames}) - check(report.samples[-1].p99_ms <= report.samples[-1].max_ms and report.samples[-1].p99_ms > 0.0, "p99 recorded for gpu=%s count=%d" % [mode, count]) - print("SCENARIO_SAMPLE gpu=%s count=%d frames=%d p99_ms=%.3f max_ms=%.3f" % [mode, count, sample_frames, report.samples[-1].p99_ms, times[-1]]) - crowd.free() - await process_frame - metrics.frame() - OS.set_environment("MTGODOT_GPUSKIN", "0") - var mobs := Node3D.new() - scene.add_child(mobs) - _tags("OutdoorA1", "loading", 3, false) - for race in [101, 110, 20001]: - var view := MobView.new() - var build_start := Time.get_ticks_usec() - var built: bool = view.build(AssetRoot.path(), null, race) - metrics.mark("model_build", {"race": race, "ms": (Time.get_ticks_usec() - build_start) / 1000.0}) - if not built: - check(false, "NPC/monster build race=%d" % race) - view.free() - continue - mobs.add_child(view) - view.position = centre + Vector3(mobs.get_child_count() * 2, 0, 3) - view.position.y = scene.world.sample_height(view.position.x, view.position.z) - view.set_anim_state("wait") - check(not view.model.get_visual_aabb().size.is_zero_approx(), "NPC/monster real geometry") - _tags("OutdoorA1", "gameplay", 3, false) - for size in [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440)]: - root.size = size - metrics.mark("viewport_resize", {"size": "%dx%d" % [size.x, size.y]}) - for i in 5: - await process_frame - metrics.frame() - # Logical viewport and physical window pixels are both recorded; Retina scale is not a size error. - report.viewports.append({"logical": [size.x, size.y], "visible": [int(root.get_visible_rect().size.x), int(root.get_visible_rect().size.y)], - "window_physical": [DisplayServer.window_get_size().x, DisplayServer.window_get_size().y], - "screen_scale": DisplayServer.screen_get_scale()}) - if scene.quickbar and scene.quickbar._root: - var rect: Rect2 = scene.quickbar._root.get_global_rect() - check(Rect2(Vector2.ZERO, root.get_visible_rect().size).encloses(rect), "quickbar inside viewport %s" % size) - if DisplayServer.get_name() != "headless": - await RenderingServer.frame_post_draw - check(root.get_texture().get_image().save_png(output.path_join("scene-%dx%d.png" % [size.x, size.y])) == OK, "screenshot saved") - # Late server location update exercises the production map-correction path. - client.ents[1000].pos_cm = Vector2(959109, 269267) - client.ents[1000].pos = Vector3(9591.09, 0, -2692.67) - _tags("OutdoorC1", "loading", 3, false) - metrics.mark("map_switch", {"map": "OutdoorC1"}) - client.world_reset.emit() - client.warp.emit(client.ents[1000].pos, false) - for i in 60: - await process_frame - metrics.frame() - check(scene.map_path == "OutdoorC1/metin2_map_c1" and scene._map_loaded(), "A1 to C1 map correction") - report["final_map"] = scene.map_path - var summary: Dictionary = metrics.summary() - report["frame_metrics"] = summary - report["unattributed_long_frames"] = metrics.unattributed_long_frames().size() - report["memory"] = Metrics.memory_snapshot() - check(int(summary.frames) > sample_frames * 6 and not summary.segments.is_empty() and not summary.markers.is_empty(), "frame metrics recorded with markers") - var phases := {} - for row in summary.segments: phases[row.tags.phase] = true - check(phases.has("loading") and phases.has("gameplay"), "loading and gameplay are separate segments: %s" % [phases.keys()]) - check(report.memory.gpu_memory_kib == null, "GPU memory is reported unavailable, never 0") - print("SCENARIO_METRICS frames=%d long=%d unattributed=%d severe=%s" % [summary.frames, summary.long_frames.size(), - report.unattributed_long_frames, summary.severe_by_phase]) - # Mirror the reference Destroy order: stop world presentation, explicitly - # unload native map resources, then let SceneTree release the node graph at - # a frame boundary. Direct free() while active animation/map callbacks are - # still queued made this regression report PASS and then exit with SIGSEGV. - client.world_reset.emit() - if scene.world and is_instance_valid(scene.world) and scene.world.has_method("unload_map"): - scene.world.call("unload_map") - # Keep both nodes attached until queue_free runs. Removing a node before - # queue_free can bypass SceneTree's recursive child teardown and make the - # leak report look like a native-world ownership failure. - scene.queue_free() - client.queue_free() - await process_frame - await process_frame - # Match the explicit resource-manager teardown used by the short model - # regressions before asking SceneTree/Godot to terminate the process. - if ClassDB.class_exists("Metin2Model"): - Metin2Model.clear_texture_cache() - if ClassDB.class_exists("Metin2AnimPlayer"): - Metin2AnimPlayer.clear_clip_cache() - MobView.clear_cache() - UiAssets.clear_cache() - report["failures"] = failures - report["duration_seconds"] = (Time.get_ticks_msec() - run_start) / 1000.0 - FileAccess.open(output.path_join("report.json"), FileAccess.WRITE).store_string(JSON.stringify(report, "\t")) - print("rendering_scenario_test: failures=%d report=%s" % [failures.size(), output]) - quit(1 if not failures.is_empty() else 0) -func _tags(map: String, phase: String, actors: int, gpu_skin: bool) -> void: - metrics.set_tags({"map": map, "phase": phase, "actor_count": Metrics.actor_bucket(actors), "gpu_skin": gpu_skin}) -## Short alternating baselines with the probe off and on (same scene, same frames), -## plus the per-sample CPU cost; the difference is reported, never assumed zero. -func _probe_overhead(frames: int) -> Dictionary: - var rounds: Array = [] - var probe: RefCounted = Metrics.new() - probe.configure({"window_seconds": 3600}) - probe.set_tags({"map": "OutdoorA1", "phase": "probe", "actor_count": "0", "gpu_skin": false}) - for i in 4: - var enabled := i % 2 == 1 - var times := PackedFloat32Array() - var previous := Time.get_ticks_usec() - for f in frames: - await process_frame - if enabled: - probe.frame() - var now := Time.get_ticks_usec() - times.append((now - previous) / 1000.0) - previous = now - times.sort() - rounds.append({"probe": enabled, "frames": frames, "p50_ms": Metrics.percentile(times, 0.5), - "p99_ms": Metrics.percentile(times, 0.99), "max_ms": times[-1]}) - var off: Array = rounds.filter(func(r: Dictionary) -> bool: return not r.probe) - var on: Array = rounds.filter(func(r: Dictionary) -> bool: return r.probe) - return {"rounds": rounds, "cpu": Metrics.measure_overhead(20000), - "p50_delta_ms": (on[0].p50_ms + on[1].p50_ms - off[0].p50_ms - off[1].p50_ms) / 2.0, - "note": "frame pacing noise can exceed the probe cost; per_sample_us is the direct CPU cost"} diff --git a/project/rendering_scenario_test.gd.uid b/project/rendering_scenario_test.gd.uid deleted file mode 100644 index 376260ec..00000000 --- a/project/rendering_scenario_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d36j4r2snd4uv diff --git a/project/screen_hp_bar.gd b/project/screen_hp_bar.gd deleted file mode 100644 index 99eafa8e..00000000 --- a/project/screen_hp_bar.gd +++ /dev/null @@ -1,41 +0,0 @@ -# ScreenHpBar —— 角色头顶的屏幕空间 HP 条(§8.8)。 -# -# 参考端在 CPythonTextTail / CGraphicTextInstance 的屏幕坐标中绘制尾标; -# 这里用一个无输入的 Control 自绘背景、边框和填充,NetWorld 每帧负责把 -# 实体的世界坐标投影到屏幕。这样 HP 条不会随 Label3D 的 billboard 深度 -# 缩放,也不会被地图几何遮住。 -extends Control - -const DEFAULT_SIZE := Vector2(72.0, 6.0) -const MIN_FILL_RATIO := 0.0 -const MAX_FILL_RATIO := 1.0 - -var hp := 0 -var max_hp := 0 -var ratio := 0.0 -var dead := false - -func _init() -> void: - mouse_filter = MOUSE_FILTER_IGNORE - size = DEFAULT_SIZE - custom_minimum_size = DEFAULT_SIZE - -func set_vitals(current_hp: int, current_max_hp: int, is_dead: bool) -> void: - hp = current_hp - max_hp = current_max_hp - dead = is_dead - ratio = clampf(float(hp) / float(max_hp), MIN_FILL_RATIO, MAX_FILL_RATIO) \ - if max_hp > 0 else 0.0 - queue_redraw() - -func _draw() -> void: - var r := Rect2(Vector2.ZERO, size) - # Dark backing and a one-pixel black border keep the bar legible over terrain. - draw_rect(r, Color(0.02, 0.02, 0.025, 0.86), true) - var inner := Rect2(Vector2.ONE, Vector2(maxf(0.0, size.x - 2.0), maxf(0.0, size.y - 2.0))) - var fill_color := Color(0.35, 0.35, 0.38, 0.95) if dead \ - else Color(0.88, 0.14, 0.14, 0.98) - inner.size.x *= ratio - if inner.size.x > 0.0: - draw_rect(inner, fill_color, true) - draw_rect(r, Color(0.0, 0.0, 0.0, 0.95), false, 1.0) diff --git a/project/screen_hp_bar.gd.uid b/project/screen_hp_bar.gd.uid deleted file mode 100644 index 6f9e1f37..00000000 --- a/project/screen_hp_bar.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dokpyjtoa1s48 diff --git a/project/screen_hp_bar_test.gd b/project/screen_hp_bar_test.gd deleted file mode 100644 index 5cdbcc2c..00000000 --- a/project/screen_hp_bar_test.gd +++ /dev/null @@ -1,99 +0,0 @@ -# screen_hp_bar_test —— §8.8 屏幕空间 HP 条 headless 自检。 -# godot --headless --path project --script screen_hp_bar_test.gd -# 覆盖:不再创建 3D Sprite3D、实体血量同步、世界坐标投影、镜头后/死亡隐藏、地图清理。 -extends SceneTree - -const NetWorld = preload("res://net_world.gd") -const ScreenHpBar = preload("res://screen_hp_bar.gd") - -class FakeClient extends Node: - signal entity_spawned(entity: Dictionary) - signal entity_despawned(vid: int) - signal entity_moved(vid: int) - signal entity_main_set(vid: int) - signal entity_info(vid: int, entity: Dictionary) - signal chat(type: int, vid: int, text: String) - signal vitals_changed(vid: int) - signal entity_dead(vid: int) - signal damage(vid: int, amount: int, flag: int) - var entities := {} - func get_entity(vid: int) -> Dictionary: return entities.get(vid, {}) - func get_entities() -> Array: return entities.values() - func get_pvp_relations() -> Array: return [] - func get_duel() -> Dictionary: return {} - func add(vid: int, pos: Vector3, main := false) -> Dictionary: - var d := {"vid": vid, "name": "e%d" % vid, "is_main": main, - "pos": pos, "pos_cm": Vector3(pos.x * 100.0, -pos.z * 100.0, 0.0), - "func": 0, "moving": false, "angle_deg": 0.0, - "hp": 100, "max_hp": 100, "dead": false, "race": 0, - "affect_flags": 0, "parts": [0, 0, 0, 0]} - entities[vid] = d - return d - -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: screen_hp_bar_test (§8.8 screen-space HP bar)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var mount := Node3D.new() - var client := FakeClient.new() - var camera := Camera3D.new() - var nw: Node = NetWorld.new() - get_root().add_child(mount) - get_root().add_child(client) - get_root().add_child(camera) - get_root().add_child(nw) - camera.position = Vector3(0.0, 2.0, 6.0) - camera.look_at_from_position(Vector3(0.0, 2.0, 6.0), Vector3.ZERO, Vector3.UP) - camera.current = true - nw.setup(client, mount) - nw.set_camera(camera) - client.add(1, Vector3.ZERO, true) - client.entity_main_set.emit(1) - nw.set_local_vid(1) - var entity := client.add(2, Vector3.ZERO) - client.entity_spawned.emit(entity) - await process_frame - await process_frame - - var node: Node3D = nw.node_for(2) - var bar := nw._hp_bars.get(2, null) as Control - _ck(node != null, "remote entity exists") - _ck(bar != null and bar is ScreenHpBar, "entity owns a screen Control HP bar") - _ck(node.get_node_or_null("HpBar") == null, "legacy Sprite3D HpBar removed") - _ck(bar != null and bar.visible, "front-facing living entity shows HP bar") - if bar != null: - var expected := camera.unproject_position(node.global_position + Vector3(0, nw.HP_BAR_WORLD_Y, 0)) - _ck(is_equal_approx(bar.position.x, expected.x - bar.size.x * 0.5), - "bar x is centered on projected world anchor") - _ck(is_equal_approx(bar.ratio, 1.0), "initial hp ratio = 1") - - entity["hp"] = 25 - client.vitals_changed.emit(2) - await process_frame - _ck(is_equal_approx(bar.ratio, 0.25), "vitals update changes screen bar ratio") - entity["dead"] = true - client.vitals_changed.emit(2) - await process_frame - _ck(nw._hp_bars.get(2, null) == null, "dead entity removes screen bar") - - entity["dead"] = false - entity["pos"] = Vector3(0, 0, -12) - client.entity_info.emit(2, entity) - await process_frame - var behind_bar := nw._hp_bars.get(2, null) as Control - _ck(behind_bar != null and not behind_bar.visible, "entity behind camera hides screen bar") - - nw.clear_for_map_change() - _ck(nw._hp_bars.is_empty(), "map change clears screen bars") diff --git a/project/screen_hp_bar_test.gd.uid b/project/screen_hp_bar_test.gd.uid deleted file mode 100644 index 150e6f6b..00000000 --- a/project/screen_hp_bar_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d4gu3tfgt3q5d diff --git a/project/select_item_ui_test.gd b/project/select_item_ui_test.gd deleted file mode 100644 index ab4e9bdf..00000000 --- a/project/select_item_ui_test.gd +++ /dev/null @@ -1,110 +0,0 @@ -# select_item_ui_test —— P11 选魔石窗 headless 自检(假 client + 假 proto + 真 uiscript)。 -# godot --headless --path project --script select_item_ui_test.gd -# 校验 uiselectitem.RefreshSlot 过滤(只 metin 类型 + grade<=2)、SelectItemSlot 发包、 -# Close 发 0、quest_dialog [SELECT_ITEM] token 解析。缺 uiscript 资产时优雅跳过。 -extends SceneTree - -const SelectItemUI = preload("res://ui/select_item_ui.gd") -const QuestDialog = preload("res://ui/quest_dialog.gd") - -class FakeClient extends Node: - signal inventory_changed(window: int, cell: int) - var inv: Array = [] # [{cell,vnum,count}] - var selects: Array = [] # script_select_item 参数 - func get_inventory() -> Array: return inv - func script_select_item(sel: int) -> bool: - selects.append(sel); return true - -class FakeProto extends Node: - # vnum 约定:28030/28031/28032 = metin grade 0/1/2;28963 = metin grade 3; - # 19 = 非 metin(武器) - func item(vnum: int) -> Dictionary: - match vnum: - 28030: return {"type": 10, "name": "METIN_A0", "locale_name": "魔石A"} - 28031: return {"type": 10, "name": "METIN_B1", "locale_name": "魔石B"} - 28032: return {"type": 10, "name": "METIN_C2", "locale_name": "魔石C"} - 28963: return {"type": 10, "name": "METIN_D3", "locale_name": "魔石D"} - 19: return {"type": 1, "name": "SWORD", "locale_name": "剑"} - 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: select_item_ui_test (filter + select packet + close-0 + [SELECT_ITEM] token)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # --- quest_dialog [SELECT_ITEM] token 解析(不需资产) --- - var qd: Node = QuestDialog.new() - var p1: Dictionary = qd.parse_script("请选择一颗魔石[ENTER][SELECT_ITEM]") - _ck(p1.get("has_select_item", false) == true, "[SELECT_ITEM] -> has_select_item") - var p2: Dictionary = qd.parse_script("普通对话[NEXT]") - _ck(p2.get("has_select_item", true) == false, "no tag -> has_select_item false") - _ck(qd.has_signal("select_item_requested"), "QuestDialog exposes select_item_requested") - qd.free() - - var assets := AssetRoot.path() - if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/selectitemwindow.py")): - print(" (skip window part: no selectitemwindow.py — assets checkout missing)") - return - - var UiManager = load("res://ui/ui_manager.gd") - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - var fc := FakeClient.new() - var fp := FakeProto.new() - get_root().add_child(fc) - get_root().add_child(fp) - # 背包:cell 2 = grade0 metin, cell 5 = 非 metin(排除), cell 7 = grade2 metin, - # cell 9 = grade3 metin(排除), cell 11 = grade1 metin - fc.inv = [ - {"cell": 2, "vnum": 28030, "count": 1}, - {"cell": 5, "vnum": 19, "count": 1}, - {"cell": 7, "vnum": 28032, "count": 3}, - {"cell": 9, "vnum": 28963, "count": 1}, - {"cell": 11, "vnum": 28031, "count": 1}, - ] - - var si: Node = SelectItemUI.new() - get_root().add_child(si) - si.setup(ui, fc, fp, assets) - si.open() - _ck(si.is_open(), "select-item window opened") - if not si.is_open(): - return - - # 过滤后应保留 3 颗(cell 2 / 7 / 11),按背包格号升序填入选择窗格 0/1/2 - _ck(si._slot_to_inv.size() == 3, "3 metin stones kept (got %d)" % si._slot_to_inv.size()) - _ck(si._slot_to_inv.get(0, -1) == 2, "slot 0 -> inv cell 2 (grade0)") - _ck(si._slot_to_inv.get(1, -1) == 7, "slot 1 -> inv cell 7 (grade2)") - _ck(si._slot_to_inv.get(2, -1) == 11, "slot 2 -> inv cell 11 (grade1)") - _ck(not si._slot_to_inv.values().has(5), "non-metin (cell 5) excluded") - _ck(not si._slot_to_inv.values().has(9), "grade>2 metin (cell 9) excluded") - - # 点选择窗格 1 -> 发 SendSelectItemPacket(7) + 关窗,且不再补发 0 - var cell1: Panel = si._cells.get(1, null) - _ck(cell1 != null, "slot cell 1 present") - var ev := InputEventMouseButton.new() - ev.button_index = MOUSE_BUTTON_LEFT - ev.pressed = true - cell1.gui_input.emit(ev) - _ck(fc.selects == [7], "click slot 1 -> script_select_item(7) (got %s)" % str(fc.selects)) - _ck(not si.is_open(), "window closed after select") - - # 重新打开,直接关闭 -> 发 script_select_item(0)(uiselectitem.Close) - fc.selects.clear() - si.open() - _ck(si.is_open(), "reopened") - si.close() - _ck(fc.selects == [0], "close without pick -> script_select_item(0) (got %s)" % str(fc.selects)) - _ck(not si.is_open(), "window closed") diff --git a/project/select_item_ui_test.gd.uid b/project/select_item_ui_test.gd.uid deleted file mode 100644 index 98b3e2f0..00000000 --- a/project/select_item_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://f1hgunjgpxw4 diff --git a/project/shop_cube_mall_test.gd b/project/shop_cube_mall_test.gd deleted file mode 100644 index 98f02bf4..00000000 --- a/project/shop_cube_mall_test.gd +++ /dev/null @@ -1,150 +0,0 @@ -# shop_cube_mall_test —— 개인상점 / 방주(Cube) / 도구商城 UI headless 자체 검사. -# godot --headless --path project --script shop_cube_mall_test.gd -# 假 client 记录调用;不需要 assets / server. -extends SceneTree - -const MallUI = preload("res://ui/mall_ui.gd") -const CubeUI = preload("res://ui/cube_ui.gd") - -class FakeClient extends Node: - signal mall_opened(size: int) - signal mall_changed() - signal cube_opened(npc_vnum: int) - signal cube_closed() - signal cube_changed() - signal cube_result(vnum: int, count: int, ok: bool) - var mall_items: Array = [] - var mall_open := false - var mall_size := 0 - var cube := {} - var inventory: Array = [] - var calls: Array = [] - 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(a, b, c) -> bool: calls.append(["mall_checkout", a, b, c]); return true - func get_cube() -> Dictionary: return cube - func cube_make(i) -> bool: calls.append(["cube_make", i]); return true - func cube_request_result_list(n) -> bool: calls.append(["cube_rlist", n]); return true - func cube_request_materials(a, b) -> bool: calls.append(["cube_mats", a, b]); return true - func cube_add_item(a, b) -> bool: calls.append(["cube_add", a, b]); return true - func cube_delete_item(a) -> bool: calls.append(["cube_del", a]); return true - func cube_close() -> bool: calls.append(["cube_close"]); return true - func get_inventory() -> Array: return inventory - func open_private_shop(sign, items) -> bool: calls.append(["open_shop", sign, items]); return true - func close_private_shop() -> bool: calls.append(["close_shop"]); 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: shop_cube_mall_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var fc := FakeClient.new() - get_root().add_child(fc) - var host := Control.new() - get_root().add_child(host) - - # ---------------- MallUI ---------------- - var mu = MallUI.new() - get_root().add_child(mu) - mu.setup(fc, host, null) - _ck(not mu.is_open(), "mall: hidden initially") - fc.mall_open = true - fc.mall_size = 45 - fc.mall_items = [{"cell": 3, "vnum": 27993, "count": 2}] - fc.mall_opened.emit(45) - await process_frame - _ck(mu.is_open(), "mall: opens on mall_opened") - var out_btn := _find(mu._list, "Button", func(b): return String(b.text) == "취출" or String(b.text) == "取出") - _ck(out_btn != null, "mall: item row has 取出 button") - if out_btn: - out_btn.pressed.emit() - _ck(_has_call(fc, "mall_checkout", func(c): return c[1] == 3 and c[2] == 1), "mall: 取出 → mall_checkout(cell=3, inv, ...)") - - # ---------------- CubeUI ---------------- - var cu = CubeUI.new() - get_root().add_child(cu) - cu.setup(fc, host, null) - cu.set_mobile_mode(true) - _ck(not cu.is_open(), "cube: hidden initially") - fc.cube = {"open": true, "npc_vnum": 20383, "recipes": [], "results": [ - {"vnum": 72723, "count": 1}, {"vnum": 50001, "count": 5}]} - fc.cube_opened.emit(20383) - await process_frame - _ck(cu.is_open(), "cube: opens on cube_opened") - _ck(cu._root.size == Vector2(720, 380) and cu._material_scroll != null, - "cube mobile mode uses landscape two-column layout") - _ck(_has_call(fc, "cube_rlist", func(c): return c[1] == 20383), "cube: open → request result list") - var recipe_btns: Array = cu._list.get_children().filter(func(n): return n is Button) - _ck(recipe_btns.size() == 2, "cube: 2 result rows") - fc.cube = {"open": true, "npc_vnum": 20383, "results": fc.cube["results"], "recipes": [ - {"result_vnum": 72723, "gold": 120000, "material_groups": [ - [{"vnum": 125, "count": 1}], [{"vnum": 555, "count": 5}]]}]} - fc.inventory = [{"cell": 3, "vnum": 125, "count": 1}, {"cell": 4, "vnum": 555, "count": 5}] - (recipe_btns[0] as Button).pressed.emit() - await process_frame - _ck(_has_call(fc, "cube_mats", func(c): return c[1] == 0), "cube: select → request materials(0)") - var gold_lbl := _find(cu._mat, "Label", func(l): return String(l.text).contains("120000")) - _ck(gold_lbl != null, "cube: materials panel shows gold 120000") - var bag_btn := _find(cu._mat, "Button", func(b): return String(b.text).begins_with("+ bag[3]")) - _ck(bag_btn != null, "cube: inventory item offered for material slot") - if bag_btn: - bag_btn.pressed.emit() - _ck(_has_call(fc, "cube_add", func(c): return c[1] == 0 and c[2] == 3), - "cube: inventory item → cube_add_item(slot=0, cell=3)") - _ck(int(cu._cube_slots.get(0, -1)) == 3, "cube: local material slot tracks inventory cell") - var del_btn := _find(cu._mat, "Button", func(b): return String(b.text) == "삭제") - _ck(del_btn != null, "cube: material slot has delete button") - if del_btn: - del_btn.pressed.emit() - _ck(_has_call(fc, "cube_del", func(c): return c[1] == 0), "cube: delete button → cube_delete_item(slot=0)") - _ck(cu._make_btn.disabled, "cube: 制作 disabled while required material is missing") - # 重新放入两个配方材料后才允许制作。 - var bag_btn2 := _find(cu._mat, "Button", func(b): return String(b.text).begins_with("+ bag[3]")) - if bag_btn2: - bag_btn2.pressed.emit() - var bag_btn3 := _find(cu._mat, "Button", func(b): return String(b.text).begins_with("+ bag[4]")) - if bag_btn3: - bag_btn3.pressed.emit() - _ck(not cu._make_btn.disabled, "cube: 制作 enabled after all required materials are slotted") - cu._make_btn.pressed.emit() - _ck(_has_call(fc, "cube_make", func(c): return c[1] == 0), "cube: 制作 → cube_make(0)") - fc.cube_result.emit(72723, 1, true) - await process_frame - _ck(String(cu._status.text).contains("성공") or String(cu._status.text) != "", "cube: result shown") - var close_btn := _find(cu._root, "Button", func(b): return String(b.text) == "닫기") - _ck(close_btn != null, "cube: close button present") - if close_btn: - close_btn.pressed.emit() - _ck(_has_call(fc, "cube_close", func(_c): return true), "cube: close button → cube_close") - fc.cube_closed.emit() - await process_frame - _ck(not cu.is_open(), "cube: hides on cube_closed") - - # PrivateShopUI 已改走 uiscript(`privateshopbuilder.py`),单独在 - # `private_shop_ui_test.gd` 里测(需要 uiscript 资产)。 - - mu.queue_free(); cu.queue_free() - -func _find(node: Node, cls: String, pred: Callable) -> Node: - for n in node.find_children("*", cls, true, false): - if pred.call(n): - return n - return null - -func _has_call(fc, name: String, pred: Callable) -> bool: - for c in fc.calls: - if c[0] == name and pred.call(c): - return true - return false diff --git a/project/shop_cube_mall_test.gd.uid b/project/shop_cube_mall_test.gd.uid deleted file mode 100644 index e7a942f9..00000000 --- a/project/shop_cube_mall_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://s5qcnv3vvryf diff --git a/project/shop_rules_test.gd b/project/shop_rules_test.gd deleted file mode 100644 index 1fe947d8..00000000 --- a/project/shop_rules_test.gd +++ /dev/null @@ -1,73 +0,0 @@ -# shop_rules_test —— ClientVS22 出售价格、anti-flag 与贵重物品确认规则回归。 -extends SceneTree - -const ShopUI = preload("res://ui/shop_ui.gd") - -class FakeClient extends Node: - var calls: Array = [] - func get_shop_items() -> Array: - return [] - func get_shop() -> Dictionary: - return {"tabs": [{"name": "", "items": []}]} - func shop_sell(cell: int, count: int) -> bool: - calls.append([cell, count]) - return true - -class FakeProto extends Node: - var items: Dictionary = {} - func item(vnum: int) -> Dictionary: - return items.get(vnum, {}) - -var _failed := false - -func _check(ok: bool, label: String) -> void: - if ok: - return - _failed = true - push_error("FAIL: " + label) - -func _init() -> void: - var host := Control.new() - root.add_child(host) - var client := FakeClient.new() - root.add_child(client) - var proto := FakeProto.new() - proto.items = { - 31: {"sell_price": 6000, "flags": 0, "anti_flags": 0}, - 32: {"sell_price": 1000, "flags": 1 << 3, "anti_flags": 0}, - 33: {"sell_price": 1000, "flags": 0, "anti_flags": 1 << 8}, - 34: {"sell_price": 1000, "flags": 0, "anti_flags": 0}, - } - root.add_child(proto) - var shop := ShopUI.new() - root.add_child(shop) - shop.setup(client, host, proto) - shop.open() - shop._set_mode(2) - - _check(shop._sell_price(31, 3) == 3600, "normal sell price is unit price * count / 5") - _check(shop._sell_price(32, 30000) == 6, "count-per-1-gold uses count / unit price / 5") - _check(not shop._drop_to_sell({"window": 1, "cell": 3, "vnum": 33, "count": 1}), - "anti-flag sell is rejected") - _check(client.calls.is_empty(), "anti-flag does not send sell packet") - - shop._sell_quantity.value = 2 - _check(shop._drop_to_sell({"window": 1, "cell": 4, "vnum": 31, "count": 4}), - "valuable sell enters confirmation flow") - _check(shop._sell_confirm != null and client.calls.is_empty(), - "valuable sell waits for confirmation") - if shop._sell_confirm: - shop._sell_confirm.confirmed.emit() - _check(client.calls == [[4, 2]], "confirmed valuable sell sends selected count") - - shop._sell_quantity.value = 1 - _check(shop._drop_to_sell({"window": 1, "cell": 5, "vnum": 34, "count": 3}), - "ordinary sell sends immediately") - _check(client.calls == [[4, 2], [5, 1]], "ordinary sell uses selected count") - - host.queue_free() - if _failed: - quit(1) - else: - print("PASS: shop_rules_test (price + anti-flag + confirmation)") - quit(0) diff --git a/project/shop_rules_test.gd.uid b/project/shop_rules_test.gd.uid deleted file mode 100644 index 977bfe33..00000000 --- a/project/shop_rules_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c5rhqrba42pk5 diff --git a/project/skill_fx_test.gd b/project/skill_fx_test.gd deleted file mode 100644 index 79080ff7..00000000 --- a/project/skill_fx_test.gd +++ /dev/null @@ -1,79 +0,0 @@ -# skill_fx_test —— 技能特效表(skill id / 内建 id → .mse)headless 自检。 -# godot --headless --path project --script skill_fx_test.gd -extends SceneTree - -const SkillFx = preload("res://fx/skill_fx.gd") -const SkillTable = preload("res://ui/skill_table.gd") - -# 假 EffectRegistry:记录 resolve / spawn,指定哪些名字「存在」 -class FakeFxr extends RefCounted: - var exists := {} # name -> true - var spawned := [] - func resolve(name: String) -> String: - return "res://fake/%s.mse" % name if exists.get(name, false) else "" - func spawn(name: String, _parent, _one := true): - spawned.append(name) - return null - -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: skill_fx_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # 用真 skilldesc.txt(有就用,没有用桩表) - var st: RefCounted = SkillTable.new() - var assets := AssetRoot.path() - var have_real := false - for lang in ["en", "common"]: - if st.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)): - have_real = true - break - - var fxr := FakeFxr.new() - var host := Node3D.new() - get_root().add_child(host) - var sfx: RefCounted = SkillFx.new() - sfx.setup(fxr, st) - - if have_real: - # skill 1 = Three-Way Cut, motion_name samyeon - # 后缀 = clamp(2 + master, 2, 4):m0->_2, m1->_3, m2/m3->_4 - _ck(sfx.skill_effect_name(1, 0) == "samyeon_2", "skill 1 m0 -> samyeon_2") - _ck(sfx.skill_effect_name(1, 1) == "samyeon_3", "skill 1 m1(M) -> samyeon_3") - _ck(sfx.skill_effect_name(1, 2) == "samyeon_4", "skill 1 m2(G) -> samyeon_4") - _ck(sfx.skill_effect_name(1, 9) == "samyeon_4", "master 夹到 4") - # 主特效存在 + 一个子特效存在 -> 播 2 个 - fxr.exists = {"samyeon_4": true, "samyeon_blow_4": true, "samyeon_hand_4": false} - var n: int = sfx.spawn_skill(1, 3, host) - _ck(n == 2 and fxr.spawned.has("samyeon_4") and fxr.spawned.has("samyeon_blow_4"), - "spawn_skill 播主 + 存在的子特效 (%d: %s)" % [n, fxr.spawned]) - # motion 查不到 -> 空 - _ck(sfx.skill_effect_name(99999, 0) == "", "未知 skill -> 空名") - else: - print(" (skip real skilldesc checks: 无资产)") - # 桩:手塞一条 - st._by_id[7] = {"id": 7, "motion": "testmo", "job": "WARRIOR", "name": "T"} - _ck(sfx.skill_effect_name(7, 1) == "testmo_2", "桩 skill -> testmo_2") - - # 内建特效:SE_AUTO_HPUP(19) -> drugup_red - fxr.spawned.clear() - fxr.exists = {"drugup_red": true, "drugup_blue": true} - _ck(sfx.spawn_special(19, host) and fxr.spawned == ["drugup_red"], - "special 19 (SE_AUTO_HPUP) -> drugup_red") - _ck(sfx.spawn_special(20, host) and fxr.spawned[-1] == "drugup_blue", - "special 20 (SE_AUTO_SPUP) -> drugup_blue") - # 没映射的 id / 文件不存在 -> false - _ck(sfx.spawn_special(255, host) == false, "未映射 special id -> false") - fxr.exists = {} - _ck(sfx.spawn_special(19, host) == false, "文件不存在 -> false") diff --git a/project/skill_fx_test.gd.uid b/project/skill_fx_test.gd.uid deleted file mode 100644 index e749b9fd..00000000 --- a/project/skill_fx_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cr6n4p1vhexy0 diff --git a/project/skill_motion_loop_test.gd b/project/skill_motion_loop_test.gd deleted file mode 100644 index c57ce768..00000000 --- a/project/skill_motion_loop_test.gd +++ /dev/null @@ -1,113 +0,0 @@ -# skill_motion_loop_test —— 40250 SetMotionLoopCount / MSA LoopData parity。 -# godot --headless --path project --script skill_motion_loop_test.gd -extends SceneTree - -const AssetRoot = preload("res://asset_root.gd") -const PlayerView = preload("res://ui/player_view.gd") - -class FakeAnim extends Node: - signal playback_finished - var anim_path := "" - var loop := true - var time_scale := 1.0 - var playing := true - var motion_loop_count := -1 - func set_motion_loop_count(count: int) -> void: - motion_loop_count = count - func get_motion_data() -> Dictionary: - return {} - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _touch(path: String) -> void: - DirAccess.make_dir_recursive_absolute(path.get_base_dir()) - var f := FileAccess.open(path, FileAccess.WRITE) - f.store_string("") - f.close() - -func _init() -> void: - call_deferred("_run") - -func _run() -> void: - await _test_player_view_payload() - await _test_native_msa_segment_loop() - if _fail == 0: - print("PASS: skill_motion_loop_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _test_player_view_payload() -> void: - var root := ProjectSettings.globalize_path("user://skill_motion_loop_test") - var skill_dir := root.path_join("PC/ymir work/pc/warrior/skill") - _touch(skill_dir.path_join("loop_skill.msa")) - var pv: Node = PlayerView.new() - get_root().add_child(pv) - var anim := FakeAnim.new() - pv.add_child(anim) - pv.anim = anim - pv._assets_root = root - pv._race = 0 - pv.motion_dir = root.path_join("PC/ymir work/pc/warrior/general") - var played: bool = pv.play_skill_motion("loop_skill", 0, 3, true) - _ck(played, "PlayerView accepts a skill motion from the resource directory") - _ck(anim.motion_loop_count == 3, "PlayerView forwards uMotLoopCount to the animation player") - _ck(pv.skill_motion_state() == {"loop_count": 3, "moving": true}, - "PlayerView keeps loop count and moving-skill bit together") - pv.free() - await process_frame - -func _test_native_msa_segment_loop() -> void: - if not ClassDB.class_exists("Metin2AnimPlayer"): - print(" (skip native MSA loop test: animation extension unavailable)") - return - var assets := AssetRoot.path() - var msa := assets.path_join("PC/ymir work/pc/assassin/skill/yeonsa.msa") - if not FileAccess.file_exists(msa): - print(" (skip native MSA loop test: yeonsa.msa unavailable)") - return - var anim: Node = ClassDB.instantiate("Metin2AnimPlayer") - get_root().add_child(anim) - if not anim.has_method("set_motion_loop_count") or not anim.has_method("get_motion_loop_count"): - _ck(false, "Metin2AnimPlayer exposes SetMotionLoopCount") - anim.free() - await process_frame - return - anim.set("loop", false) - anim.set("anim_path", msa) - await process_frame - var loop_data: Dictionary = anim.call("get_loop_data") - _ck(bool(loop_data.get("present", false)), "MSA LoopData is exposed") - _ck(int(loop_data.get("count", 0)) == 7, "yeonsa resource loop count is 7") - anim.call("set_motion_loop_count", 2) - _ck(int(anim.call("get_motion_loop_count")) == 2, - "SetMotionLoopCount overrides the resource count") - var previous: float = float(anim.call("get_time")) - var wraps := 0 - var count_after_wrap := -1 - var finished := [false] - anim.playback_finished.connect(func(): finished[0] = true) - var frames := 0 - while frames < 500 and not finished[0]: - await process_frame - var current: float = float(anim.call("get_time")) - if current + 0.05 < previous: - wraps += 1 - count_after_wrap = int(anim.call("get_motion_loop_count")) - previous = current - frames += 1 - _ck(wraps == 1, "loop count 2 performs one LoopData segment rewind") - _ck(count_after_wrap == 1, "segment rewind decrements the remaining count") - _ck(finished[0], "finite MSA loop returns to playback_finished after the tail") - anim.set("anim_path", assets.path_join("PC/ymir work/pc/warrior/general/wait.msa")) - await process_frame - _ck(int(anim.call("get_motion_loop_count")) == 0, - "normal motion does not inherit the previous skill loop override") - anim.free() - await process_frame diff --git a/project/skill_motion_loop_test.gd.uid b/project/skill_motion_loop_test.gd.uid deleted file mode 100644 index a30ded29..00000000 --- a/project/skill_motion_loop_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ksnf0126um68 diff --git a/project/skill_slot_map.gd b/project/skill_slot_map.gd deleted file mode 100644 index 703796ce..00000000 --- a/project/skill_slot_map.gd +++ /dev/null @@ -1,54 +0,0 @@ -# 40250 stores SLOT_TYPE_SKILL quickslots as CPythonPlayer skill-slot numbers, -# not raw skill vnums. playersettingmodule.RegisterSkill() maps those slots to -# a job + skill-group specific skill id before the taskbar asks for its icon. -extends RefCounted - -const ACTIVE := { - 0: {0: [0, 0, 0, 0, 0, 0, 0, 0, 137, 0, 138, 0, 139, 0], - 1: [1, 2, 3, 4, 5, 0, 0, 0, 137, 0, 138, 0, 139, 0], - 2: [16, 17, 18, 19, 20, 0, 0, 0, 137, 0, 138, 0, 139, 0]}, - 1: {0: [0, 0, 0, 0, 0, 0, 0, 0, 137, 0, 138, 0, 139, 0, 140], - 1: [31, 32, 33, 34, 35, 0, 0, 0, 137, 0, 138, 0, 139, 0, 140], - 2: [46, 47, 48, 49, 50, 0, 0, 0, 137, 0, 138, 0, 139, 0, 140]}, - 2: {0: [0, 0, 0, 0, 0, 0, 0, 0, 137, 0, 138, 0, 139, 0], - 1: [61, 62, 63, 64, 65, 66, 0, 0, 137, 0, 138, 0, 139, 0], - 2: [76, 77, 78, 79, 80, 81, 0, 0, 137, 0, 138, 0, 139, 0]}, - 3: {0: [0, 0, 0, 0, 0, 0, 0, 0, 137, 0, 138, 0, 139, 0], - 1: [91, 92, 93, 94, 95, 96, 0, 0, 137, 0, 138, 0, 139, 0], - 2: [106, 107, 108, 109, 110, 111, 0, 0, 137, 0, 138, 0, 139, 0]}, -} -const SUPPORT := [122, 123, 121, 124, 125, 129, 0, 0, 130, 131] -const PASSIVE_GUILD := [151] -const ACTIVE_GUILD := [152, 153, 154, 155, 156, 157] - -static func skill_id_for_slot(race: int, group: int, slot: int) -> int: - var job := race & 3 - if slot >= 1 and slot <= 14: - var by_group: Dictionary = ACTIVE.get(job, {}) - var ids: Array = by_group.get(group, []) - return int(ids[slot - 1]) if slot <= ids.size() else 0 - if slot >= 101 and slot < 101 + SUPPORT.size(): - return int(SUPPORT[slot - 101]) - if slot >= 200 and slot < 200 + PASSIVE_GUILD.size(): - return int(PASSIVE_GUILD[slot - 200]) - if slot >= 210 and slot < 210 + ACTIVE_GUILD.size(): - return int(ACTIVE_GUILD[slot - 210]) - return 0 - -static func skill_slot_for_id(race: int, group: int, skill_id: int) -> int: - var job := race & 3 - var by_group: Dictionary = ACTIVE.get(job, {}) - var ids: Array = by_group.get(group, []) - var at := ids.find(skill_id) - if at >= 0: - return at + 1 - at = SUPPORT.find(skill_id) - if at >= 0: - return at + 101 - at = PASSIVE_GUILD.find(skill_id) - if at >= 0: - return at + 200 - at = ACTIVE_GUILD.find(skill_id) - if at >= 0: - return at + 210 - return 0 diff --git a/project/skill_slot_map.gd.uid b/project/skill_slot_map.gd.uid deleted file mode 100644 index 9a280125..00000000 --- a/project/skill_slot_map.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ggxb28v0328q diff --git a/project/skill_test.gd b/project/skill_test.gd deleted file mode 100644 index 30a6d9b5..00000000 --- a/project/skill_test.gd +++ /dev/null @@ -1,501 +0,0 @@ -# skill_test —— P6:skilldesc 解析 + skill_ui 行 + quickbar 施放/冷却 headless 自检。 -# godot --headless --path project --script skill_test.gd -extends SceneTree - -const SkillTable = preload("res://ui/skill_table.gd") -const SkillUI = preload("res://ui/skill_ui.gd") -const Quickbar = preload("res://ui/quickbar.gd") -const UiAssets = preload("res://ui/ui_assets.gd") -const UiManager = preload("res://ui/ui_manager.gd") - -# __ReserveUseSkill 的最小 NetPlay 面:射程上下文 + 预约记录。 -class FakeRangeNetPlay extends Node: - var distance_cm := 100000.0 - var reservations := [] - func skill_context() -> Dictionary: - return {"bow_distance": 2, "target_distance": func(_vid: int) -> float: return distance_cm} - func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void: - reservations.append([vid, slot, int(range_cm)]) - func is_use_skill_reserved(slot: int) -> bool: - return not reservations.is_empty() and int(reservations[-1][1]) == slot - -class FakeClient extends Node: - signal skills_changed() - signal skill_cooldown_end(skill: int) - signal quickslots_changed() - signal skill_group_changed(group: int) - signal entity_main_set(vid: int) - var skills := [{"id": 1, "level": 5, "master": 0}, {"id": 16, "level": 3, "master": 2}] - var skill_group := 1 - var points := {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2} - var quickslots := [] - var casts := [] - var skill_uses := [] - var guild_skill_uses := [] - var quickslot_ops := [] - var saids := [] - var emotes := [] - var main := 1000 - func get_main_vid() -> int: return main - # vid 2000 是选中目标:给个 kind=2(怪)让 §3.8 目标解析判其可攻击。 - func get_entity(_v) -> Dictionary: return {"race": 0, "kind": 2} - func get_inventory() -> Array: return [] - func get_skills() -> Array: return skills - func get_skill_group() -> int: return skill_group - func get_points() -> Dictionary: return points - func get_quickslots() -> Array: return quickslots - func get_target() -> Dictionary: return {"vid": 2000} - func cast_skill(mi, rot, x, y, arg = 0) -> bool: casts.append([mi, rot, x, y, arg]); return true - func use_skill(id, target_vid) -> bool: skill_uses.append([id, target_vid]); return true - func use_guild_skill(id, target_vid) -> bool: guild_skill_uses.append([id, target_vid]); return true - func quickslot_add(pos, type, ref) -> bool: - quickslot_ops.append(["add", pos, type, ref]) - quickslots = quickslots.filter(func(q): return int(q.get("pos", -1)) != int(pos)) - quickslots.append({"pos": pos, "type": type, "ref": ref}) - quickslots_changed.emit() - return true - func quickslot_del(pos) -> bool: - quickslot_ops.append(["del", pos]) - quickslots = quickslots.filter(func(q): return int(q.get("pos", -1)) != int(pos)) - quickslots_changed.emit() - return true - func quickslot_swap(pos, change_pos) -> bool: - quickslot_ops.append(["swap", pos, change_pos]) - var a := -1 - var b := -1 - for i in quickslots.size(): - if int(quickslots[i].get("pos", -1)) == int(pos): a = i - if int(quickslots[i].get("pos", -1)) == int(change_pos): b = i - if a >= 0 and b >= 0: - var tmp: Dictionary = quickslots[a] - quickslots[a] = quickslots[b] - quickslots[b] = tmp - quickslots[a]["pos"] = pos - quickslots[b]["pos"] = change_pos - elif a >= 0: - quickslots[a]["pos"] = change_pos - elif b >= 0: - quickslots[b]["pos"] = pos - quickslots_changed.emit() - return true - func skill_up(id) -> bool: saids.append(id); return true - func use_item(w, c) -> bool: return true - func send_emoticon(id) -> bool: emotes.append(id); 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: skill_test (table + skill_ui + quickbar)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var assets := AssetRoot.path() - var st := SkillTable.new() - var loaded := false - for lang in ["en", "common"]: - if st.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)): - loaded = true - break - if not loaded: - print(" (skip: no skilldesc.txt)") - return - _ck(st.count > 30, "skilldesc parsed (%d skills)" % st.count) - var w := st.for_job("WARRIOR") - _ck(w.size() > 3, "WARRIOR has skills (%d)" % w.size()) - var s1: Dictionary = st.entry(1) - _ck(s1.get("job", "") == "WARRIOR", "skill 1 job = WARRIOR") - _ck(String(s1.get("name", "")) != "", "skill 1 has a name (%s)" % s1.get("name")) - # 列偏移修正后:skill 1 'Three-Way Cut' motion_name=samyeon motion_idx=1, ATTACK_SKILL - _ck(st.motion_idx_of(1) == 1, "skill 1 motion_idx == 1 (列偏移已修)") - # 40250 constinfo.py 开启 USE_SKILL_EFFECT_UPGRADE_ENABLE=1 后, - # CPythonSkill::GetSkillMotionIndex(iGrade) 使用 SKILL_GRADEGAP=25。 - _ck(st.has_method("motion_idx_for_grade"), "技能表提供等级动作索引入口") - if st.has_method("motion_idx_for_grade"): - _ck(st.motion_idx_for_grade(1, 1) == 26, "skill 1 M 级动作索引 = base + 25") - _ck(st.motion_idx_for_grade(1, 2) == 51, "skill 1 G 级动作索引 = base + 50") - _ck(st.motion_grade_by_idx(26) == 1, "远端动作索引 26 可反解为 M 级") - _ck(String(s1.get("motion", "")) == "samyeon", "skill 1 motion_name == samyeon") - var skill_icon := UiAssets.load_tex(assets, "ETC/ymir work/ui/skill/warrior/samyeon_01.sub") - _ck(skill_icon != null, "skill 1 icon loads from .sub + shared SkillWarrior.dds") - if skill_icon: - _ck(skill_icon.get_width() == 32 and skill_icon.get_height() == 32, - "skill 1 icon region is 32x32") - var strong_body_icon := UiAssets.load_tex(assets, - "ETC/ymir work/ui/skill/warrior/cheongeun_03.sub") - _ck(strong_body_icon is AtlasTexture and int(strong_body_icon.region.position.y) == 0, - "DDS .sub uses top-left coordinates (Strong Body top row -> decoded y=0)") - _ck(st.is_attack(1), "skill 1 是 ATTACK_SKILL") - _ck(st.is_ranged(46), "skill 46 bow skill is ranged") - _ck(not st.is_ranged(1), "skill 1 melee skill is not ranged") - _ck(is_equal_approx(st.cooldown_of(1, 5), 15.0), "skill 1 .msk CoolTimeFormula = 15s") - _ck(st.is_attack(19) == false and String(st.entry(19).get("name", "")) != "", - "skill 19 'Strong Body' 非攻击") - # 辅助 / 被动树 - var sup := st.for_category("SUPPORT") - _ck(sup.size() > 3, "SUPPORT 分类有技能 (%d)" % sup.size()) - _ck(st.is_passive(121), "skill 121 'Leadership' is_passive") - _ck(st.can_level_up(125) == false, "skill 125 'Item Creation' CANNOT_LEVEL_UP") - _ck(st.for_category("HORSE").size() >= 3, "HORSE 分类有技能") - - # --- §3.8 TSkillData 判定位解析(增量 87):对齐 PythonSkill.cpp 谓词 --- - # skill 1 Three-Way Cut:ATTACK_SKILL|NEED_TARGET|WEAPON_LIMITATION,W=SWORD|TWO_HANDED - _ck(st.attr_bits(1) & st.ATTR["ATTACK_SKILL"] != 0, "attr_bits(1) 含 ATTACK_SKILL 位") - _ck(st.is_need_target(1), "IsNeedTarget(1)") - _ck(st.is_can_use_skill(1), "IsCanUseSkill(1)(非被动)") - _ck(st.has_weapon_limitation(1), "skill 1 WEAPON_LIMITATION") - _ck(st.can_use_weapon_type(1, st.WEAPON_SWORD), "CanUseWeaponType(1, SWORD)") - _ck(st.can_use_weapon_type(1, st.WEAPON_TWO_HANDED), "CanUseWeaponType(1, TWO_HANDED)") - _ck(not st.can_use_weapon_type(1, st.WEAPON_BOW), "CanUseWeaponType(1, BOW) == false") - # skill 121 Leadership:PASSIVE —— IsCanUseSkill 必须挡下 - _ck(st.is_passive(121) and not st.is_can_use_skill(121), "IsCanUseSkill(121 被动) == false") - # skill 18 Stump:ATTACK_SKILL|STANDING_SKILL - _ck(st.is_standing(18) and st.is_attack(18), "IsStandingSkill(18) && IsAttackSkill(18)") - _ck(not st.is_standing(1), "skill 1 非 STANDING_SKILL") - # skill 46 Repetitive Shot:BOW - _ck(st.is_need_bow(46) and st.is_ranged(46), "IsNeedBow(46)") - _ck(st.can_use_weapon_type(46, st.WEAPON_BOW), "CanUseWeaponType(46, BOW)") - _ck(not st.can_use_weapon_type(46, st.WEAPON_SWORD), "CanUseWeaponType(46, SWORD) == false") - _ck(st.motion_loop_count_of(46, 5) == 2, - "skill 46 MotionLoopCountFormula(5) == 2") - _ck(st.motion_state_arg(46, 5) == 2, - "skill 46 OnUseSkill arg packs loop count in low nibble") - # skill 138 Horse Stump:HORSE_SKILL|SEARCH_TARGET|CHARGE_ATTACK - _ck(st.is_horse_skill(138), "IsHorseSkill(138)") - _ck(st.is_auto_search_target(138), "IsAutoSearchTarget(138)") - _ck(st.is_charge_skill(138), "IsChargeSkill(138)") - _ck(st.skill_type_of(138) == st.SKILL_TYPE_HORSE, "skill_type_of(138) == SKILL_TYPE_HORSE") - # skill 137 Horseback Slash:MOVING_SKILL - _ck(st.is_moving_skill(137), "IsMovingSkill(137)") - # skill 16 Spirit Strike (W):CAN_CHANGE_DIRECTION - _ck(st.can_change_direction(16), "CanChangeDirection(16)") - _ck(not st.can_change_direction(1), "skill 1 无 CAN_CHANGE_DIRECTION") - # skill 151 Dragon Eyes:JOB=GUILD - _ck(st.skill_type_of(151) == st.SKILL_TYPE_GUILD and st.is_guild_skill(151), - "skill_type_of(151) == SKILL_TYPE_GUILD") - # skill 152 Blood of Dragon God:ONLY_FOR_GUILD_WAR - _ck(st.is_only_for_guild_war(152), "IsOnlyForGuildWar(152)") - # skill 109 Cure:CAN_USE_FOR_ME|ONLY_FOR_ALLIANCE - _ck(st.can_use_for_me(109) and st.is_only_for_alliance(109), - "CanUseForMe(109) && IsOnlyForAlliance(109)") - # skill 5 Dash:CHARGE_ATTACK;skill 31 Ambush:MELEE_ATTACK - _ck(st.is_charge_skill(5), "IsChargeSkill(5)") - _ck(st.is_melee(31), "IsMeleeSkill(31)") - # skill 63 Enchanted Blade:TOGGLE|STANDING_SKILL|WEAPON_LIMITATION W=SWORD - _ck(st.is_toggle(63) and st.is_standing(63), "IsToggleSkill(63) && IsStandingSkill(63)") - _ck(st.can_use_weapon_type(63, st.WEAPON_SWORD) - and not st.can_use_weapon_type(63, st.WEAPON_DAGGER), - "skill 63 只吃 SWORD") - # 无 WEAPON_LIMITATION 的技能:CanUseWeaponType 恒真(PythonSkill.cpp:1143) - _ck(st.can_use_weapon_type(106, st.WEAPON_FAN) and st.can_use_weapon_type(106, st.WEAPON_BOW), - "skill 106 无武器限制 -> CanUseWeaponType 恒真") - # _fold_bits:瓶子 / USE_HP 记号(当前语言数据里没有对应技能,直接验折叠逻辑) - _ck(SkillTable._fold_bits("NEED_EMPTY_BOTTLE|NEED_POISON_BOTTLE", st.ATTR) - == (st.ATTR["NEED_EMPTY_BOTTLE"] | st.ATTR["NEED_POISON_BOTTLE"]), - "_fold_bits 折叠 NEED_*_BOTTLE") - _ck(SkillTable._fold_bits("use_hp , circle_range foo", st.ATTR) - == (st.ATTR["USE_HP"] | st.ATTR["CIRCLE_RANGE"]), - "_fold_bits 大小写/逗号/空格宽容,未知记号忽略") - _ck(SkillTable._fold_bits("SWORD|DOUBLE_SWORD", st.NEED_WEAPON) - == (1 << st.WEAPON_SWORD | 1 << st.WEAPON_DAGGER), - "_fold_bits DOUBLE_SWORD 复用 DAGGER 位") - - # --- §3.10 技能升级门(增量 90):max_level / level_limit / CanLevelUpSkill --- - # skill 1 samyeon.msk 有 MaxLevel "30" - _ck(st.max_level_of(1) == 30, "max_level_of(1) == 30(samyeon.msk MaxLevel)") - # skill 121 Leadership 是 SUPPORT,无 job_dir -> _load_msk 提前返回;skilldesc 无 MAX_LEVEL 列 - _ck(st.max_level_of(121) == 20, "max_level_of(121) == 20(SSkillData 构造默认)") - # 本 40250 shipped skilldesc.txt 只有 22 列,无 LEVEL_LIMIT 列 -> 恒 0 - _ck(st.skill_level_limit(1) == 0, "skill_level_limit(1) == 0(本 build 无 LEVEL_LIMIT 数据)") - # skillCanLevelUpSkill:未满级且非 CANNOT_LEVEL_UP - _ck(st.can_level_up_skill(1, 5), "can_level_up_skill(1, 5) == true") - _ck(st.can_level_up_skill(1, 29), "can_level_up_skill(1, 29) == true(29 < 30)") - _ck(not st.can_level_up_skill(1, 30), "can_level_up_skill(1, 30) == false(>= MaxLevel)") - _ck(not st.can_level_up_skill(1, 31), "can_level_up_skill(1, 31) == false") - _ck(not st.can_level_up_skill(125, 1), "can_level_up_skill(125, 1) == false(CANNOT_LEVEL_UP)") - _ck(not st.can_level_up_skill(9999, 1), "can_level_up_skill(未知 id) == false") - - # --- §3.4 扇形/圆形补目标元数据(增量 91):GetTargetCount / __GetSkillTargetRange / FlyShape --- - # skill 47 'Arrow Shower' = gwangyeok.msk:FAN_RANGE + TargetCountFormula + Range 2500 - _ck(st.is_fan_range(47), "skill 47 IsFanRange(gwangyeok FAN_RANGE)") - _ck(st.fly_shape(47) == 1, "fly_shape(47) == FAN(1)") - _ck(st.target_range(47) == 2500, "target_range(47) == 2500(gwangyeok.msk Range)") - # "2 + floor(6 * SkillPoint)":lv1 SkillPoint=0.05 -> 2;lv20(master) SkillPoint=0.5 -> 5 - _ck(st.target_count(47, 1) == 2, "target_count(47, lv1) == 2") - _ck(st.target_count(47, 20) == 5, "target_count(47, lv20) == 5") - # skill 91 'Flying Talisman' = bipabu.msk:FAN_RANGE + Range 1800,无 TargetCountFormula - _ck(st.is_fan_range(91), "skill 91 IsFanRange(bipabu FAN_RANGE)") - _ck(st.target_range(91) == 1800, "target_range(91) == 1800(bipabu.msk Range)") - _ck(st.target_count(91, 20) == 0, "target_count(91) == 0(bipabu 无 TargetCountFormula -> 仅主目标)") - # LoadLocaleData:RegisterSkillDesc 后 RegisterSkillTable(PythonApplication.cpp:1049-1055)。 - # RegisterSkill(.msk) 在参考端没有调用点,dwTargetRange 只来自 SkillTable.txt 的 - # TARGET_RANGE 列;GetTargetRange(PythonSkill.cpp:1086)对 MELEE/CHARGE_ATTACK 固定 170。 - var tbl := SkillTable.new() - for lang in ["en", "common"]: - if tbl.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)): - break - var table_loaded: bool = tbl.has_method("load_table") \ - and bool(tbl.call("load_table", assets.path_join("locale/locale/common/skilltable.txt"))) - _ck(table_loaded, "load_table(common/skilltable.txt) succeeds") - if table_loaded: - _ck(tbl.target_range(1) == 0, "SkillTable TARGET_RANGE 0 overrides samyeon.msk Range: %d" % tbl.target_range(1)) - _ck(tbl.target_range(47) == 2500, "target_range(47) == 2500(SkillTable)") - _ck(tbl.target_range(91) == 1800, "target_range(91) == 1800(SkillTable)") - _ck(tbl.target_range(31) == 170, "MELEE_ATTACK 31 -> MELEE_SKILL_TARGET_RANGE 170: %d" % tbl.target_range(31)) - _ck(tbl.target_range(5) == 170, "CHARGE_ATTACK 5 -> MELEE_SKILL_TARGET_RANGE 170: %d" % tbl.target_range(5)) - # 预约趋近要在 Actor 碰撞(两 body 各 55cm)挡住前进入射程。 - for id in tbl._by_id.keys(): - var r := tbl.target_range(int(id)) - _ck(r == 0 or r - 10 > 110, "skill %d range %d is reachable past actor collision" % [id, r]) - # 非扇形/圆形技能:fly_shape == SINGLE(0),无 target_range - _ck(st.fly_shape(1) == 0, "fly_shape(1) == SINGLE(0)(samyeon 非范围技)") - _ck(st.target_count(1, 10) == 0, "target_count(1) == 0(无 TargetCountFormula)") - - var fc := FakeClient.new() - get_root().add_child(fc) - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - - # skill_ui - var sk: Node = SkillUI.new() - get_root().add_child(sk) - sk.setup(fc, st, ui) - sk.set_job("WARRIOR") - sk.open() - _ck(sk.is_open(), "skill window opened") - _ck(sk._rows.size() == w.size(), "skill window rows = WARRIOR skill count") - # 找 skill 1 的行,点加号 - if sk._rows.has(1): - sk._rows[1]["up"].pressed.emit() - _ck(fc.saids == [1], "+ -> client.skill_up(1)") - _ck(String(sk._rows[1]["lv"].text) == "Lv 5", "skill 1 shows Lv 5 (from get_skills)") - # skill 16 master 2 -> "Lv 3 G" - if sk._rows.has(16): - _ck(String(sk._rows[16]["lv"].text) == "Lv 3 G", "skill 16 master 2 -> 'Lv 3 G'") - - # --- §3.10 加号按钮门控(增量 90):主动页 --- - if sk._rows.has(1) and sk._rows[1]["up"] != null: - _ck(sk._rows[1]["up"].visible, "主动页 skill 1(lv5 grade0, skill_active=5, group=1)加号可见") - if sk._rows.has(16) and sk._rows[16]["up"] != null: - _ck(not sk._rows[16]["up"].visible, "skill 16 skillGrade!=0 -> 加号隐藏") - # 技能组未选(GetMainActorSkillGroup()==0)-> 整列隐藏加号 - fc.skill_group = 0 - sk.refresh() - if sk._rows.has(1) and sk._rows[1]["up"] != null: - _ck(not sk._rows[1]["up"].visible, "skill_group==0 -> __CanUseSkillNow 挡下,加号隐藏") - fc.skill_group = 1 - # 该页技能点为 0 -> 加号隐藏 - fc.points = {"level": 30, "skill_active": 0, "skill_support": 3, "skill_horse": 2} - sk.refresh() - if sk._rows.has(1) and sk._rows[1]["up"] != null: - _ck(not sk._rows[1]["up"].visible, "SKILL_ACTIVE 点数为 0 -> 加号隐藏") - fc.points = {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2} - sk.refresh() - - # 切到「辅助」页 -> 列 SUPPORT,被动技能无 [+] - sk._switch_tab(1) - _ck(sk._cat_key() == "SUPPORT", "tab 1 -> SUPPORT") - _ck(sk._rows.has(121) and sk._rows[121]["up"] == null, "被动技能 121 无 [+] 按钮") - # 有能加点的辅助技能带 [+] - var any_up := false - for rid in sk._rows: - if sk._rows[rid]["up"] != null: - any_up = true - _ck(any_up or sup.all(func(i): return st.is_passive(i) or not st.can_level_up(i)), - "辅助页有可加点项 或 全是被动") - # §3.10:HIDE_SUPPORT_SKILL_POINT -> 辅助页所有加号按钮隐藏(即便技能点足够) - var sup_any_visible := false - for rid in sk._rows: - if sk._rows[rid]["up"] != null and sk._rows[rid]["up"].visible: - sup_any_visible = true - _ck(not sup_any_visible, "辅助页 HIDE_SUPPORT_SKILL_POINT -> 加号全隐藏") - - sk._switch_tab(2) - _ck(sk._cat_key() == "HORSE", "tab 2 -> HORSE") - # §3.10:马术页 —— skill_horse 点数 > 0 且 skillLevel < 20 -> 加号可见 - var horse_up_id := 0 - for rid in sk._rows: - if sk._rows[rid]["up"] != null: - horse_up_id = rid - break - if horse_up_id != 0: - _ck(sk._rows[horse_up_id]["up"].visible, - "马术页 skill %d(lv0 < 20, skill_horse=2)加号可见" % horse_up_id) - fc.points = {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 0} - sk.refresh() - _ck(not sk._rows[horse_up_id]["up"].visible, "SKILL_HORSE 点数为 0 -> 马术加号隐藏") - fc.points = {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2} - sk.refresh() - sk._switch_tab(0) # 切回主动,后面的 quickbar 断言不受影响 - - # quickbar - var pl := Node3D.new() - get_root().add_child(pl) - var qb: Node = Quickbar.new() - get_root().add_child(qb) - qb.setup(fc, st, ui, func() -> Node: return pl) - var cooldown_default: float = qb._skill_cooldown(1) - fc.points["casting_speed"] = 20 - _ck(absf(qb._skill_cooldown(1) - cooldown_default * 1.8) < 0.001, - "POINT_CASTING_SPEED=20 applies the 40250 180% cooldown scale") - fc.points["casting_speed"] = 0 - var cast_targets: Array = [] - qb.skill_cast_started.connect(func(sid: int, vid: int): cast_targets.append([sid, vid])) - qb.assign(0, "skill", 1) - _ck(qb._slots[0].icon.texture != null, "quickbar skill slot shows skill icon") - _ck(String(qb._slots[0].lbl.text) == "", "quickbar hides text when skill icon is available") - _ck(fc.quickslot_ops == [["add", 0, 2, 1]], "assign -> CG_QUICKSLOT_ADD slot 0") - qb.activate(0) - _ck(fc.skill_uses.size() == 1 and fc.skill_uses[0] == [1, 2000], - "quickbar slot 0 -> use_skill(skill 1, selected target)") - _ck(fc.casts.size() == 1, "quickbar slot 0 -> cast_skill once") - _ck(cast_targets == [[1, 2000]], "successful cast publishes resolved visual target") - if fc.casts.size() == 1: - _ck(int(fc.casts[0][0]) == st.motion_idx_of(1), "cast used skill 1's motion_idx") - _ck(int(fc.casts[0][4]) == 0, "skill 1 with no loop formula sends OnUseSkill arg 0") - fc.skills[0]["master"] = 1 - _ck(qb._motion_idx_for_skill(1) == 26, - "quickbar uses the current M grade motion index (base + SKILL_GRADEGAP)") - fc.skills[0]["master"] = 0 - # 冷却中不再施放 - qb.activate(0) - _ck(fc.skill_uses.size() == 1, "on cooldown -> no second use_skill") - _ck(fc.casts.size() == 1, "on cooldown -> no second cast") - qb.assign(1, "skill", 1, false) - qb.activate(1) - _ck(fc.casts.size() == 1, "same skill in another slot shares cooldown") - fc.quickslots = [{"pos": 0, "type": 2, "ref": 1}, {"pos": 1, "type": 2, "ref": 1}] - qb.restore_from_server() - qb._process(0.0) - _ck(qb._slots[0].cd.visible and qb._slots[1].cd.visible, "server slot refresh preserves cooldown overlays") - qb.activate(0) - _ck(fc.casts.size() == 1, "server refresh cannot reset skill cooldown") - fc.quickslots = [] # restore this fixture's original server state for later UI cases - # ClientVS22::ClickSkillSlot routes GUILD skills to UseGuildSkill rather than - # silently dropping them. The dedicated path still enforces guild-war-only. - var guild_fc := FakeClient.new() - get_root().add_child(guild_fc) - var guild_qb: Node = Quickbar.new() - get_root().add_child(guild_qb) - guild_qb.setup(guild_fc, st, ui, func() -> Node: return pl) - guild_qb.assign(0, "skill", 152, false) - var guild_rejected: Array = [] - guild_qb.skill_rejected.connect(func(sid: int, code: String): guild_rejected.append([sid, code])) - guild_qb._skill_gate.guild_war_active = false - guild_qb.activate(0) - _ck(guild_rejected == [[152, "ONLY_FOR_GUILD_WAR"]] and guild_fc.guild_skill_uses.is_empty(), - "guild skill outside guild war -> rejected without ordinary CG_USE_SKILL") - guild_qb._skill_gate.guild_war_active = true - guild_qb.activate(0) - _ck(guild_fc.guild_skill_uses == [[152, 0]], - "guild skill -> use_guild_skill(skill, 0)") - _ck(guild_fc.casts.size() == 1 and int(guild_fc.casts[0][4]) == 1, - "guild skill -> FUNC_SKILL action with loop count 1") - guild_qb.free() - guild_fc.free() - # 40250 quickslot skill refs are player skill-slot numbers. The same ref=2 - # selects a different skill after switching warrior skill group. - fc.skill_group = 2 - fc.quickslots = [{"pos": 0, "type": 2, "ref": 2}] - qb.restore_from_server() - _ck(int(qb._state[0].id) == 17 and int(qb._state[0].ref) == 2, - "warrior group 2 quickslot ref 2 -> skill 17") - fc.quickslot_ops.clear() - qb.assign(0, "skill", 20) - _ck(fc.quickslot_ops == [["add", 0, 2, 5]], - "assign warrior group 2 skill 20 persists player skill-slot 5") - fc.skill_group = 1 - fc.quickslots = [{"pos": 0, "type": 2, "ref": 1}, {"pos": 1, "type": 2, "ref": 1}] - qb.restore_from_server() - qb._clear(1) - # GC_SKILL_COOLTIME_END 提前解锁 - fc.skill_cooldown_end.emit(1) - qb.activate(0) - _ck(fc.skill_uses.size() == 2, "cooldown_end -> can use_skill again") - _ck(fc.casts.size() == 2, "cooldown_end -> can cast again") - # ClientVS22 的本地快捷栏显示 4 页 × 8 格;服务器仍保留 36 槽, - # 因此最后 4 个槽只可由协议恢复,不能从键盘本地显示/编辑。 - qb.set_page(3) - qb.assign(7, "item", 9) - _ck(qb._state.size() == 36 and qb._state[31].kind == "item" and qb._state[31].id == 9, - "quickbar has 36 server slots; page 4 slot 8 = global slot 31") - _ck(fc.quickslot_ops.back() == ["add", 31, 1, 9], "page 4 assign -> CG_QUICKSLOT_ADD{31,item,9}") - qb.set_page(0) - qb._swap(0, 1) # UI 的两次点击最终调用同一个交换操作 - _ck(fc.quickslot_ops.back() == ["swap", 0, 1], "quickslot move -> CG_QUICKSLOT_SWAP") - qb._clear(1) - _ck(fc.quickslot_ops.back() == ["del", 1], "quickslot clear -> CG_QUICKSLOT_DEL") - - # 移动技能页:横屏大触控行 + 一键放入当前页快捷栏空位,替代 PC 拖放。 - sk.close() - sk.set_mobile_mode(true) - sk.set_mobile_quickbar(qb) - sk.open() - _ck(sk.is_open() and sk._win.size == Vector2(760, 340), - "mobile skill window uses landscape base size") - _ck(sk._win.get_node_or_null("SkillScroll") is ScrollContainer, - "mobile skill list is scrollable") - if sk._rows.has(16): - sk._mobile_assign_skill(16) - _ck(qb._state[0].kind == "skill" and qb._state[0].id == 16, - "mobile skill action assigns to first free quickbar slot") - sk.close() - - # 射程外(__ProcessEnemySkillTargetRange):不 cast、预约;蓄力技仅首次预约前发 use_skill(id, 0) - if st.has(5) and st.is_charge_skill(5): - var np := FakeRangeNetPlay.new() - get_root().add_child(np) - fc.skills.append({"id": 5, "level": 1, "master": 0}) - var qb3: Node = Quickbar.new() - qb3.net_play = np - get_root().add_child(qb3) - qb3.setup(fc, st, ui, func() -> Node: return pl) - qb3.assign(5, "skill", 5, false) - var uses0: int = fc.skill_uses.size() - var casts0: int = fc.casts.size() - var rejected := [] - qb3.skill_rejected.connect(func(sid: int, code: String): rejected.append([sid, code])) - qb3.activate(5) - var range5: int = st.target_range(5) + 200 - _ck(fc.casts.size() == casts0 and fc.skill_uses.slice(uses0) == [[5, 0]], - "charge skill out of range -> only use_skill(5, 0), no cast: %s" % [fc.skill_uses.slice(uses0)]) - _ck(np.reservations == [[2000, 5, range5]], "out of range -> reserve_use_skill(target, slot, TargetRange+bow*100)") - _ck(rejected.is_empty(), "reservation is silent (no OnCannotUseSkill)") - qb3.activate(5) - _ck(fc.skill_uses.size() == uses0 + 1 and np.reservations.size() == 2, - "already reserved charge skill -> no second use_skill(5, 0)") - _ck(not qb3.activate_reserved(5), "reserved fire still out of range keeps the new reservation") - np.distance_cm = 10.0 - _ck(qb3.activate_reserved(5), "reserved fire in range consumes the reservation") - _ck(fc.casts.size() == casts0 + 1 and fc.skill_uses.back() == [5, 2000], "in range -> use_skill(5, target) + one cast") - fc.skills.pop_back() - qb3.free() - np.free() - - # 服务器 GC_QUICKSLOT_* 恢复:type 2 技能 / type 1 道具 / type 3 表情 -> 填格 - var qb2: Node = Quickbar.new() - get_root().add_child(qb2) - fc.quickslots = [{"pos": 2, "type": 2, "ref": 16}, {"pos": 3, "type": 1, "ref": 9}, - {"pos": 4, "type": 3, "ref": 11}] - qb2.setup(fc, st, ui, func() -> Node: return pl) # setup 里自动 restore_from_server - _ck(qb2._root.custom_minimum_size.y >= 37 and qb2._root.z_index > 0, - "quickbar has a real bottom-anchor extent and stays above legacy HUD") - _ck(qb2._state[2].kind == "skill" and int(qb2._state[2].id) == 16, "quickslot 恢复:格2=技能16") - _ck(qb2._slots[2].icon.texture != null and qb2._slots[2].icon.visible, - "server-restored skill slot renders its icon") - _ck(qb2._state[3].kind == "item" and int(qb2._state[3].id) == 9, "quickslot 恢复:格3=道具9") - _ck(qb2._state[4].kind == "emote" and int(qb2._state[4].id) == 11, "quickslot 恢复:格4=表情11") - qb2.activate(4) - _ck(fc.emotes == [11], "表情快捷栏 -> send_emoticon(11)") - fc.quickslots = [{"pos": 0, "type": 2, "ref": 1}] - fc.quickslots_changed.emit() - _ck(qb2._state[0].kind == "skill" and int(qb2._state[0].id) == 1, "quickslots_changed -> 重填") diff --git a/project/skill_test.gd.uid b/project/skill_test.gd.uid deleted file mode 100644 index 546e6f10..00000000 --- a/project/skill_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://brcusnou3ou7r diff --git a/project/skybox_test.gd b/project/skybox_test.gd deleted file mode 100644 index ce05f648..00000000 --- a/project/skybox_test.gd +++ /dev/null @@ -1,95 +0,0 @@ -# skybox_test —— 40250 ClientVS22 texture-mode SkyBox -> Godot sky shader。 -# 验证真实 texture-mode SkyBox:CapeDragonHead 的五个非空面回退渐变, -# DawnMistWood 的六面天空与云层同时接入并保留参考 UV / 速度参数。 -extends SceneTree - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _make_world(map_path: String) -> Node: - var world: Node = ClassDB.instantiate("Metin2World") - world.set("auto_load", false) - world.set("assets_root", AssetRoot.path()) - world.set("map_path", map_path) - world.set("focus_tile", Vector2i(0, 0)) - world.set("load_radius_tiles", 0) - world.set("objects_enabled", false) - world.set("splat_enabled", false) - world.set("water_enabled", false) - get_root().add_child(world) - return world - -func _init() -> void: - if not ClassDB.class_exists("Metin2World"): - printerr("FAIL: Metin2World extension is not registered") - quit(1) - return - var world := _make_world("metin2_patch_dragon_rock/metin2_map_capedragonhead") - _ck(bool(world.call("load_map")), "CapeDragonHead map loads") - var report: Dictionary = world.call("get_load_report") - _ck(bool(report.get("env_ok", false)), "texture-mode .msenv env_ok") - var env_node := world.get_node_or_null("WorldEnv") as WorldEnvironment - _ck(env_node != null and env_node.environment != null, "WorldEnv has Environment") - if env_node != null: - _ck(int(env_node.get_meta("msenv_sky_face_count", 0)) == 5, - "five non-empty SkyBox faces resolve") - _ck(bool(env_node.get_meta("msenv_sky_texture_mode_applied", false)), - "texture render mode uses six-face shader") - var sky := env_node.environment.sky - _ck(sky != null, "WorldEnv has sky") - if sky != null: - var mat := sky.get_material() as ShaderMaterial - _ck(mat != null, "texture-mode sky uses ShaderMaterial") - if mat != null: - _ck(mat.shader != null and mat.shader.code.find("shader_type sky;") >= 0, - "six-face sky shader is installed") - _ck(bool(mat.get_shader_parameter("has_front")), "front face is present") - _ck(bool(mat.get_shader_parameter("has_back")), "back face is present") - _ck(bool(mat.get_shader_parameter("has_left")), "left face is present") - _ck(bool(mat.get_shader_parameter("has_right")), "right face is present") - _ck(bool(mat.get_shader_parameter("has_top")), "top face is present") - _ck(not bool(mat.get_shader_parameter("has_bottom")), - "empty bottom face falls back to gradient") - world.queue_free() - await process_frame - - var cloud_world := _make_world("metin2_patch_dragon_rock/metin2_map_dawnmistwood") - _ck(bool(cloud_world.call("load_map")), "DawnMistWood map loads") - var cloud_report: Dictionary = cloud_world.call("get_load_report") - _ck(bool(cloud_report.get("env_ok", false)), "texture-mode + cloud .msenv env_ok") - var cloud_env := cloud_world.get_node_or_null("WorldEnv") as WorldEnvironment - _ck(cloud_env != null and cloud_env.environment != null, "cloud WorldEnv has Environment") - if cloud_env != null: - _ck(int(cloud_env.get_meta("msenv_sky_face_count", 0)) == 5, - "cloud map five non-empty SkyBox faces resolve") - _ck(bool(cloud_env.get_meta("msenv_sky_texture_mode_applied", false)), - "texture render mode remains active when CloudTexture exists") - _ck(bool(cloud_env.get_meta("msenv_sky_cloud_applied", false)), - "cloud texture is attached to texture-mode sky shader") - var cloud_sky := cloud_env.environment.sky - var cloud_mat := cloud_sky.get_material() as ShaderMaterial if cloud_sky != null else null - _ck(cloud_mat != null, "cloud map uses ShaderMaterial") - if cloud_mat != null: - _ck(bool(cloud_mat.get_shader_parameter("has_cloud")), - "sky shader enables cloud plane") - _ck(cloud_mat.shader != null and cloud_mat.shader.code.find("TIME * cloud_speed") >= 0, - "cloud shader preserves reference UV scroll") - if cloud_mat != null: - var scale := cloud_mat.get_shader_parameter("cloud_scale") as Vector2 - _ck(scale != null and is_equal_approx(scale.x, 2000.0) and is_equal_approx(scale.y, 2000.0), - "cloud scale converts reference centimetres to Godot metres") - var speed := cloud_mat.get_shader_parameter("cloud_speed") as Vector2 - _ck(speed != null and is_equal_approx(speed.x, 0.001) and is_equal_approx(speed.y, 0.001), - "cloud speed preserves reference seconds parameter") - cloud_world.queue_free() - await process_frame - if _fail == 0: - print("PASS: skybox_test (.msenv six-face texture sky)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/skybox_test.gd.uid b/project/skybox_test.gd.uid deleted file mode 100644 index 148e8c42..00000000 --- a/project/skybox_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dk1va37r3o5b0 diff --git a/project/system_menu_ui_test.gd b/project/system_menu_ui_test.gd deleted file mode 100644 index b2317b0a..00000000 --- a/project/system_menu_ui_test.gd +++ /dev/null @@ -1,189 +0,0 @@ -# system_menu_ui_test —— ESC 系统菜单 + 游戏设置窗 headless 自检(真 uiscript + 假 client)。 -# godot --headless --path project --script system_menu_ui_test.gd -# 校验 systemdialog.py / gameoptiondialog.py 装载、/setblockmode + /pkmode 真命令、 -# /in_game_mall + /phase_select + /logout、显示开关持久化。缺 uiscript 时优雅跳过。 -extends SceneTree - -const GameOptionUI = preload("res://ui/game_option_ui.gd") -const SystemMenuUI = preload("res://ui/system_menu_ui.gd") - -class FakeClient extends Node: - var says: Array = [] - func say(type: int, text: String) -> bool: - says.append([type, text]); return true - func last_say() -> String: - return String(says[-1][1]) if not says.is_empty() else "" - -class StubWin extends Node: - var opened := 0 - func open() -> void: opened += 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: system_menu_ui_test (systemdialog + gameoptiondialog + /setblockmode /pkmode /phase_select)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _cfg_path() -> String: - return ProjectSettings.globalize_path(GameOptionUI.config_path()) - -func _has_say(fc, text: String) -> bool: - for s in fc.says: - if String(s[1]) == text: - return true - return false - -func _run() -> void: - var assets := AssetRoot.path() - if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/systemdialog.py")) \ - or not FileAccess.file_exists(assets.path_join("uiscript/uiscript/gameoptiondialog.py")): - print(" (skip: no systemdialog.py / gameoptiondialog.py — assets checkout missing)") - return - if FileAccess.file_exists(_cfg_path()): - DirAccess.remove_absolute(_cfg_path()) - - var UiManager = load("res://ui/ui_manager.gd") - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - var fc := FakeClient.new() - get_root().add_child(fc) - - # ---------------- GameOptionUI ---------------- - var go: Node = GameOptionUI.new() - get_root().add_child(go) - go.setup(ui, fc, assets) - go.open() - _ck(go.is_open(), "game_option window opened") - if not go.is_open(): - return - - # 屏蔽 toggle -> /setblockmode (EBlockAction 位) - (go._node("block_exchange_button") as BaseButton).pressed.emit() - _ck(fc.last_say() == "/setblockmode 1", "block exchange -> /setblockmode 1 (bit 1<<0)") - (go._node("block_party_button") as BaseButton).pressed.emit() - _ck(fc.last_say() == "/setblockmode 3", "block party -> /setblockmode 3 (1|2)") - (go._node("block_exchange_button") as BaseButton).pressed.emit() - _ck(fc.last_say() == "/setblockmode 2", "block exchange again -> /setblockmode 2 (bit cleared)") - _ck(go._block_mode == 2, "_block_mode tracked = 2") - - # PK radio -> /pkmode {0,1,4,2} - (go._node("pvp_revenge") as BaseButton).pressed.emit() - _ck(fc.last_say() == "/pkmode 1", "pvp_revenge -> /pkmode 1") - (go._node("pvp_guild") as BaseButton).pressed.emit() - _ck(fc.last_say() == "/pkmode 4", "pvp_guild -> /pkmode 4") - _ck(not (go._node("pvp_revenge") as BaseButton).button_pressed, "pvp radio exclusive") - - # 显示开关 -> 持久化 user://system_option.cfg [gameopt] - (go._node("always_show_name_off_button") as BaseButton).pressed.emit() - _ck(int(go._display.get("always_show_name", -1)) == 0, "always_show_name -> 0") - var cfg := ConfigFile.new() - cfg.load(_cfg_path()) - _ck(int(cfg.get_value("gameopt", "always_show_name", -1)) == 0, "cfg gameopt.always_show_name persisted") - _ck(int(cfg.get_value("gameopt", "block_mode", -1)) == 2, "cfg gameopt.block_mode persisted") - - # §8.4 名字色 radio(name_color_normal / name_color_empire)-> _display + cfg + 信号 - var opt_evt := [] - go.display_option_changed.connect(func(k, v): opt_evt.append([k, v])) - (go._node("name_color_empire") as BaseButton).pressed.emit() - _ck(int(go._display.get("name_color", -1)) == 1, "name_color_empire -> _display.name_color = 1") - _ck(opt_evt.size() >= 1 and opt_evt[-1] == ["name_color", 1], "name_color_empire -> display_option_changed('name_color',1)") - # §8.4 看他国目标框 radio(target_board_view / target_board_no_view,constinfo 默认 1 = 看) - _ck(int(go._display.get("target_board", -1)) == 1, "target_board 默认 1(VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD)") - (go._node("target_board_no_view") as BaseButton).pressed.emit() - _ck(int(go._display.get("target_board", -1)) == 0, "target_board_no_view -> _display.target_board = 0") - _ck(opt_evt[-1] == ["target_board", 0], "target_board_no_view -> display_option_changed('target_board',0)") - var cfg2 := ConfigFile.new() - cfg2.load(_cfg_path()) - _ck(int(cfg2.get_value("gameopt", "name_color", -1)) == 1, "cfg gameopt.name_color persisted") - _ck(int(cfg2.get_value("gameopt", "target_board", -1)) == 0, "cfg gameopt.target_board persisted") - go.close() - - # 二次实例读 cfg:block_mode 回填 - var go2: Node = GameOptionUI.new() - get_root().add_child(go2) - go2.setup(ui, fc, assets) - _ck(go2._block_mode == 2, "reload: block_mode from cfg") - go2.open() - _ck((go2._node("block_party_button") as BaseButton).button_pressed, "reload: block_party button reflects bit 1<<1") - _ck(not (go2._node("block_exchange_button") as BaseButton).button_pressed, "reload: block_exchange button off") - go2.close() - ui.set_mobile_mode(true) - go2.set_mobile_mode(true) - go2.open() - _ck(go2.is_open() and go2._mobile_root.size == Vector2(700, 360), - "mobile game-option window uses landscape base size") - _ck(go2._mobile_root.get_node_or_null("MobileGameOptionScroll") is ScrollContainer, - "mobile game-option settings are scrollable") - go2._toggle_block(1 << 0) - _ck(fc.last_say() == "/setblockmode 3", "mobile block toggle keeps server command") - go2.close() - - # ---------------- SystemMenuUI ---------------- - fc.says.clear() - var sys_opt := StubWin.new() - var game_opt := StubWin.new() - get_root().add_child(sys_opt) - get_root().add_child(game_opt) - var sm: Node = SystemMenuUI.new() - get_root().add_child(sm) - sm.setup(ui, fc, assets, sys_opt, game_opt) - sm.open() - _ck(sm.is_open(), "system menu opened") - - # mall_button removed in 40250 systemdialog.py (only present in locale variant) - - sm.open() - (sm._node("change_button") as BaseButton).pressed.emit() - _ck(_has_say(fc, "/phase_select"), "change_button -> /phase_select (net.ExitGame)") - - sm.open() - (sm._node("logout_button") as BaseButton).pressed.emit() - _ck(_has_say(fc, "/logout"), "logout_button -> /logout (net.LogOutGame)") - - sm.open() - (sm._node("system_option_button") as BaseButton).pressed.emit() - _ck(sys_opt.opened == 1 and not sm.is_open(), "system_option_button -> opens system_option_ui + closes menu") - - sm.open() - (sm._node("game_option_button") as BaseButton).pressed.emit() - _ck(game_opt.opened == 1 and not sm.is_open(), "game_option_button -> opens game_option_ui + closes menu") - - sm.open() - (sm._node("cancel_button") as BaseButton).pressed.emit() - _ck(not sm.is_open(), "cancel_button closes menu") - - # 移动端使用独立横屏菜单,仍复用同一组设置、帮助和服务器命令入口。 - ui.set_mobile_mode(true) - sm.set_mobile_mode(true) - sm.open() - _ck(sm.is_open() and sm._mobile_root.size == Vector2(700, 330), - "mobile system menu uses landscape base size") - for action in ["帮助", "商城", "系统设置", "游戏设置", "选择角色", "登出", "退出游戏", "取消"]: - _ck(sm._mobile_buttons.has(action), "mobile system menu action: " + action) - (sm._mobile_buttons["商城"] as Button).pressed.emit() - _ck(_has_say(fc, "/in_game_mall"), "mobile mall action -> /in_game_mall") - _ck(not sm.is_open(), "mobile action closes menu") - sm.open() - (sm._mobile_buttons["系统设置"] as Button).pressed.emit() - _ck(sys_opt.opened == 2 and not sm.is_open(), - "mobile system option opens feature and closes menu") - sm.open() - (sm._mobile_buttons["游戏设置"] as Button).pressed.emit() - _ck(game_opt.opened == 2 and not sm.is_open(), - "mobile game option opens feature and closes menu") - sm.open() - (sm._mobile_buttons["取消"] as Button).pressed.emit() - _ck(not sm.is_open(), "mobile cancel closes menu") - - if FileAccess.file_exists(_cfg_path()): - DirAccess.remove_absolute(_cfg_path()) diff --git a/project/system_menu_ui_test.gd.uid b/project/system_menu_ui_test.gd.uid deleted file mode 100644 index c574deb2..00000000 --- a/project/system_menu_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bxvpvkgeabgn0 diff --git a/project/system_option_ui_test.gd b/project/system_option_ui_test.gd deleted file mode 100644 index 5ad5017b..00000000 --- a/project/system_option_ui_test.gd +++ /dev/null @@ -1,131 +0,0 @@ -# system_option_ui_test —— P11 系统设置窗 headless 自检(真 uiscript + 假 audio/cam/env)。 -# godot --headless --path project --script system_option_ui_test.gd -# 校验 systemoptiondialog.py 装载、音量 / 镜头 / 雾控件绑定(对照 uisystemoption.py)、 -# 持久化到 user://system_option.cfg。缺 uiscript 资产时优雅跳过。 -extends SceneTree - -const SystemOptionUI = preload("res://ui/system_option_ui.gd") - -class FakeAudio extends Node: - var master_bgm := 0.6 - var master_sfx := 0.9 - var _bgm := [null, null] - var _bgm_cur := 0 - -class FakeCam extends Node: - var max_dist := 20.0 - var dist := 18.0 - -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: system_option_ui_test (layout + volume/camera/fog bind + cfg persist)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _cfg_path() -> String: - return ProjectSettings.globalize_path(SystemOptionUI.config_path()) - -func _run() -> void: - var assets := AssetRoot.path() - if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/systemoptiondialog.py")): - print(" (skip: no systemoptiondialog.py — assets checkout missing)") - return - # 干净起步 - if FileAccess.file_exists(_cfg_path()): - DirAccess.remove_absolute(_cfg_path()) - - var UiManager = load("res://ui/ui_manager.gd") - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - var au := FakeAudio.new() - var cam := FakeCam.new() - get_root().add_child(au) - get_root().add_child(cam) - var env := Environment.new() - - var so: Node = SystemOptionUI.new() - get_root().add_child(so) - so.setup(ui, assets, au, func(): return cam, func(): return env) - so.open() - _ck(so.is_open(), "system-option window opened") - if not so.is_open(): - return - - # --- 音量 sliderbar → snd.SetMusicVolume / SetSoundVolume --- - var mv: Control = so._node("music_volume_controller") - var sv: Control = so._node("sound_volume_controller") - _ck(mv is Range and sv is Range, "volume controllers are Range sliders") - _ck(is_equal_approx(mv.min_value, 0.0) and is_equal_approx(mv.max_value, 1.0), - "music slider range set to 0..1") - _ck(mv.value_changed.is_connected(so._on_music_volume), "music slider wired to _on_music_volume") - _ck(sv.value_changed.is_connected(so._on_sound_volume), "sound slider wired to _on_sound_volume") - so._on_music_volume(0.30) # = 拖动 music_volume_controller - _ck(is_equal_approx(au.master_bgm, 0.30), "music slider -> audio.master_bgm (got %s)" % au.master_bgm) - so._on_sound_volume(0.75) - _ck(is_equal_approx(au.master_sfx, 0.75), "sound slider -> audio.master_sfx (got %s)" % au.master_sfx) - - # --- 镜头 근/원 radio → max_dist --- - (so._node("camera_short") as BaseButton).pressed.emit() - _ck(so.camera_mode == 0, "camera_short -> mode 0") - _ck(is_equal_approx(cam.max_dist, SystemOptionUI.CAMERA_MAX[0]), "camera short -> cam.max_dist=%s" % cam.max_dist) - _ck(cam.dist <= cam.max_dist, "camera dist clamped to new max") - (so._node("camera_long") as BaseButton).pressed.emit() - _ck(so.camera_mode == 1 and is_equal_approx(cam.max_dist, SystemOptionUI.CAMERA_MAX[1]), "camera_long -> mode 1 / far max") - # 라디오 배타성 - _ck(not (so._node("camera_short") as BaseButton).button_pressed, "camera_short unset after camera_long") - - # --- 안개 0/1/2 radio → Environment.fog_density --- - (so._node("fog_level0") as BaseButton).pressed.emit() - _ck(so.fog_level == 0 and env.fog_enabled - and is_equal_approx(env.fog_density, SystemOptionUI.FOG_DENSITY[0]), - "fog_level0 (dense) -> env.fog_density=%s" % env.fog_density) - (so._node("fog_level2") as BaseButton).pressed.emit() - _ck(so.fog_level == 2 and is_equal_approx(env.fog_density, SystemOptionUI.FOG_DENSITY[2]), - "fog_level2 (light) -> env.fog_density=%s" % env.fog_density) - - # --- 持久화: user://system_option.cfg --- - _ck(FileAccess.file_exists(_cfg_path()), "cfg written") - var cfg := ConfigFile.new() - cfg.load(_cfg_path()) - _ck(is_equal_approx(float(cfg.get_value("audio", "music_volume", -1.0)), 0.30), "cfg music_volume persisted") - _ck(int(cfg.get_value("video", "fog_level", -1)) == 2, "cfg fog_level persisted") - _ck(int(cfg.get_value("video", "camera_mode", -1)) == 1, "cfg camera_mode persisted") - - so.close() - _ck(not so.is_open(), "system-option window closed") - - # 移动端专用横屏设置页:滑杆与单选项不依赖 PC 固定坐标小控件。 - ui.set_mobile_mode(true) - so.set_mobile_mode(true) - so.open() - _ck(so.is_open() and so._mobile_root.size == Vector2(680, 340), - "mobile system-option window uses landscape base size") - _ck(so._mobile_root.find_children("*", "HSlider", true, false).size() == 2, - "mobile system-option exposes music and sound sliders") - so.close() - - # --- 두 번째 인스턴스가 cfg 를 읽어 즉시 적용 --- - var au2 := FakeAudio.new() - var cam2 := FakeCam.new() - get_root().add_child(au2) - get_root().add_child(cam2) - var env2 := Environment.new() - var so2: Node = SystemOptionUI.new() - get_root().add_child(so2) - so2.setup(ui, assets, au2, func(): return cam2, func(): return env2) - _ck(is_equal_approx(au2.master_bgm, 0.30), "reload: cfg music_volume applied on setup") - _ck(is_equal_approx(cam2.max_dist, SystemOptionUI.CAMERA_MAX[1]), "reload: cfg camera_mode applied") - _ck(is_equal_approx(env2.fog_density, SystemOptionUI.FOG_DENSITY[2]), "reload: cfg fog_level applied") - - if FileAccess.file_exists(_cfg_path()): - DirAccess.remove_absolute(_cfg_path()) diff --git a/project/system_option_ui_test.gd.uid b/project/system_option_ui_test.gd.uid deleted file mode 100644 index 089245dd..00000000 --- a/project/system_option_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://baio26p62isi6 diff --git a/project/target_board.gd b/project/target_board.gd deleted file mode 100644 index 53e4171a..00000000 --- a/project/target_board.gd +++ /dev/null @@ -1,91 +0,0 @@ -# target_board —— §5.4 / §8.8 目标框(TargetBoard)收包分派 1:1: -# CPythonNetworkStream::RecvTargetPacket 的「目标存在 / 未死 → PC|Building 关掉异 VID 框、 -# 可看血就设血条、否则关框」判定树,加 CInstanceBase::CanViewTargetHP 的 -# IsStone()||IsWoodenDoor()||IsEnemy(),再加 game.py 的 SetHPTargetBoard(换 VID 先 Reset)/ -# CloseTargetBoardIfDifferent / CloseTargetBoard / SetPCTargetBoard 的 LCONTROL 悄悄话捷径。 -# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd 同一套 -# 纯静态镜像套路,本模块是第 26 个 canonical。) -# -# REF(40250 ClientVS22 source/ 为唯一基线): -# UserInterface/Packet.h:1937 TPacketGCTarget { BYTE header; DWORD dwVID; BYTE bHPPercent; } -# UserInterface/PythonNetworkStreamPhaseGame.cpp:2424 RecvTargetPacket() -# :2436 if (pInstPlayer && pInstTarget) —— 主角与目标实例都在 -# :2438 if (!pInstTarget->IsDead()) —— 目标没死透才动框 -# :2440 if (IsPC() || IsBuilding()) CloseTargetBoardIfDifferent(dwVID) -# :2442 else if (CanViewTargetHP(*pInstTarget)) SetHPTargetBoard(dwVID, bHPPercent) -# :2444 else CloseTargetBoard() -# :2447 m_pInstTarget = pInstTarget —— 仅当目标存在且未死 -# :2453 else CloseTargetBoard() —— 实例缺失 -# UserInterface/InstanceBase.cpp:2293 CanViewTargetHP(victim) = victim.IsStone()||victim.IsWoodenDoor()||victim.IsEnemy() -# UserInterface/InstanceBase.cpp:2380/2390 IsBuilding() / IsWoodenDoor()(vnum 13000 或 30111..30119) -# root/game.py:804 SetPCTargetBoard(vid,name):Open(vid,name);若按住 LCONTROL 且 IsSameEmpire -# 且非本人且非 building → interface.OpenWhisperDialog(name) -# root/game.py:829 SetHPTargetBoard(vid,hpPct):vid != GetTargetVID() → ResetTargetBoard()+SetEnemyVID(vid);SetHP(hpPct)+Show() -# root/game.py:837 CloseTargetBoardIfDifferent(vid):vid != GetTargetVID() → Close() -# root/game.py:841 CloseTargetBoard():Close() -# root/uitarget.py:189-193 TargetBoard.Open(vid,name) 最前置过滤: -# if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): -# if not player.IsSameEmpire(vid): self.Hide(); return -# —— game-option「看他国玩家目标框」关掉时,异阵营玩家框直接不开(constinfo 默认 1 = 开)。 -# -# seam ⑫:旧实体快照没有独立建筑字段;当前 net_play 已支持 fixture / mob_proto -# 的 BUILDING(5) / WOODEN_DOOR(6) 分类。没有分类信息的旧快照仍按 NPC/怪/石头路径。 -# 另:poc 的 target_info 由 C++ 侧(extension/src/net/m2_client.cpp)从 EntityStore 抽出, -# 本模块只镜像 GDScript 侧对 (vid, hp%) 的分派,不改 C++ 收包。 -# LCONTROL 悄悄话捷径:增量 122 接线 —— net_play._on_pick 在 KIND_PC 分支轮询 -# whisper_on_ctrl_click(_lcontrol_down, same_empire, vid==_main_vid, false),真则发 -# whisper_requested(name),game_scene 转 chat.start_whisper()(poc 版 OpenWhisperDialog); -# _lcontrol_down 由 game_scene 的 KEY_CTRL 按下 / 松开喂。 -extends RefCounted - -# RecvTargetPacket 分派结果。 -# CLOSE —— 关框(实例缺失 / 不可看血的非 PC 非 building 目标) -# CLOSE_IF_DIFFERENT —— 目标是 PC 或 building:仅当框上是别的 VID 才关 -# SET_HP —— 可看血:设血条并显示(换 VID 先 Reset) -# NONE —— 目标已死透:不动框,但仍更新 m_pInstTarget -enum Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE } - -# CanViewTargetHP(victim):石头 / 木门 / 敌对,任一即可看血。 -static func can_view_target_hp(is_stone: bool, is_wooden_door: bool, is_enemy: bool) -> bool: - return is_stone or is_wooden_door or is_enemy - -# RecvTargetPacket 判定树。target_exists = (pInstPlayer && pInstTarget)。 -static func classify(target_exists: bool, is_dead: bool, is_pc: bool, - is_building: bool, can_view_hp: bool) -> int: - if not target_exists: - return Action.CLOSE # :2453 else CloseTargetBoard() - if is_dead: - return Action.NONE # :2438 !IsDead() 为假 —— 整块跳过 - if is_pc or is_building: - return Action.CLOSE_IF_DIFFERENT # :2440 - if can_view_hp: - return Action.SET_HP # :2442 - return Action.CLOSE # :2444 - -# m_pInstTarget = pInstTarget 只在「目标存在且没死透」时写(:2447 在 !IsDead() 块内)。 -static func updates_current_target(target_exists: bool, is_dead: bool) -> bool: - return target_exists and not is_dead - -# SetHPTargetBoard:vid != GetTargetVID() → 先 ResetTargetBoard()+SetEnemyVID(vid)。 -static func needs_reset(new_vid: int, cur_vid: int) -> bool: - return new_vid != cur_vid - -# CloseTargetBoardIfDifferent:vid != GetTargetVID() 才 Close()。 -static func should_close_if_different(vid: int, cur_vid: int) -> bool: - return vid != cur_vid - -# TargetBoard.Open :189-193 前置过滤:game-option「看他国玩家目标框」(constinfo -# VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1) 关掉时,异阵营目标框不开。 -# view_other_empire=false 且 same_empire=false → 隐藏。empire 0 的怪/NPC/石头在 -# IsSameEmpire 里算「同阵营」(entity_rules._is_same_empire),故只挡真·他国 PC。 -static func hidden_as_other_empire(view_other_empire: bool, same_empire: bool) -> bool: - return not view_other_empire and not same_empire - -# SetPCTargetBoard 的 LCONTROL 悄悄话捷径:按住 LCONTROL 且同阵营且非本人且非 building。 -static func whisper_on_ctrl_click(lcontrol_down: bool, same_empire: bool, - is_main: bool, is_building: bool) -> bool: - return lcontrol_down and same_empire and not is_main and not is_building - -# bHPPercent 是 BYTE,钳到 0..100。 -static func clamp_hp_pct(v: Variant) -> int: - return clampi(int(v), 0, 100) diff --git a/project/target_board.gd.uid b/project/target_board.gd.uid deleted file mode 100644 index eeff6f64..00000000 --- a/project/target_board.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://csy6qy363o028 diff --git a/project/target_board_test.gd b/project/target_board_test.gd deleted file mode 100644 index d4f21f3e..00000000 --- a/project/target_board_test.gd +++ /dev/null @@ -1,96 +0,0 @@ -# target_board_test —— §5.4 / §8.8 目标框收包分派 1:1: -# RecvTargetPacket 的判定树(实例缺失→CLOSE、已死→NONE、PC|Building→CLOSE_IF_DIFFERENT、 -# 可看血→SET_HP、否则→CLOSE)、CanViewTargetHP、m_pInstTarget 写入条件、SetHPTargetBoard -# 换 VID 先 Reset、CloseTargetBoardIfDifferent、SetPCTargetBoard 的 LCONTROL 悄悄话捷径、 -# bHPPercent 钳位。 -# godot --headless --path project --script target_board_test.gd -# 退出码 0 = 全过。 -extends SceneTree - -const TargetBoard = preload("res://target_board.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: target_board_test (§5.4/§8.8 RecvTargetPacket 分派 + CanViewTargetHP 1:1)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var CLOSE := TargetBoard.Action.CLOSE - var DIFF := TargetBoard.Action.CLOSE_IF_DIFFERENT - var SETHP := TargetBoard.Action.SET_HP - var NONE := TargetBoard.Action.NONE - - # --- 0. CanViewTargetHP(InstanceBase.cpp:2293)------------------------------ - _ck(TargetBoard.can_view_target_hp(true, false, false) == true, "IsStone -> 可看血") - _ck(TargetBoard.can_view_target_hp(false, true, false) == true, "IsWoodenDoor -> 可看血") - _ck(TargetBoard.can_view_target_hp(false, false, true) == true, "IsEnemy -> 可看血") - _ck(TargetBoard.can_view_target_hp(false, false, false) == false, "皆非 -> 不可看血") - _ck(TargetBoard.can_view_target_hp(true, true, true) == true, "任一为真即可看血") - # door/building 实体现在由 net_play/entity_rules 识别后分别进入这两条参考分支。 - _ck(TargetBoard.classify(true, false, false, true, true) == DIFF, - "建筑即使可看血也优先 CLOSE_IF_DIFFERENT") - _ck(TargetBoard.classify(true, false, false, false, true) == SETHP, - "木门经 CanViewTargetHP -> SET_HP") - - # --- 1. RecvTargetPacket 判定树(PythonNetworkStreamPhaseGame.cpp:2424)------ - # 实例缺失(pInstPlayer && pInstTarget 为假)-> :2453 CloseTargetBoard - _ck(TargetBoard.classify(false, false, false, false, false) == CLOSE, "目标实例缺失 -> CLOSE") - _ck(TargetBoard.classify(false, false, true, false, true) == CLOSE, "实例缺失优先于其它分支") - # 目标已死透 -> :2438 !IsDead() 为假 -> 整块跳过(NONE),但 m_pInstTarget 也不写 - _ck(TargetBoard.classify(true, true, false, false, true) == NONE, "目标已死 -> NONE(不动框)") - _ck(TargetBoard.classify(true, true, true, false, false) == NONE, "已死优先于 PC 分支") - # PC / Building -> :2440 CloseTargetBoardIfDifferent - _ck(TargetBoard.classify(true, false, true, false, false) == DIFF, "PC 目标 -> CLOSE_IF_DIFFERENT") - _ck(TargetBoard.classify(true, false, false, true, false) == DIFF, "Building 目标 -> CLOSE_IF_DIFFERENT") - _ck(TargetBoard.classify(true, false, true, false, true) == DIFF, "PC 即便可看血也走 CLOSE_IF_DIFFERENT(PC 分支在前)") - # 可看血 -> :2442 SetHPTargetBoard - _ck(TargetBoard.classify(true, false, false, false, true) == SETHP, "非 PC 非 building 且可看血 -> SET_HP") - # 不可看血的非 PC 非 building(例如 NPC / warp)-> :2444 CloseTargetBoard - _ck(TargetBoard.classify(true, false, false, false, false) == CLOSE, "NPC/warp 不可看血 -> CLOSE") - - # --- 2. m_pInstTarget 写入条件(:2447 在 !IsDead() 块内)--------------------- - _ck(TargetBoard.updates_current_target(true, false) == true, "目标存在且未死 -> 写 m_pInstTarget") - _ck(TargetBoard.updates_current_target(true, true) == false, "目标已死 -> 不写 m_pInstTarget") - _ck(TargetBoard.updates_current_target(false, false) == false, "实例缺失 -> 不写 m_pInstTarget") - - # --- 3. SetHPTargetBoard 换 VID 先 Reset(game.py:829)--------------------- - _ck(TargetBoard.needs_reset(2000, 0) == true, "无当前目标 -> 先 Reset") - _ck(TargetBoard.needs_reset(2000, 1500) == true, "换目标 -> 先 Reset") - _ck(TargetBoard.needs_reset(2000, 2000) == false, "同目标 -> 只 SetHP,不 Reset") - - # --- 4. CloseTargetBoardIfDifferent(game.py:837)------------------------ - _ck(TargetBoard.should_close_if_different(2000, 1500) == true, "框上是别的 VID -> Close") - _ck(TargetBoard.should_close_if_different(2000, 2000) == false, "框上正是该 VID -> 保留") - _ck(TargetBoard.should_close_if_different(2000, 0) == true, "框上无 VID -> Close") - - # --- 5. SetPCTargetBoard 的 LCONTROL 悄悄话捷径(game.py:804-817)--------- - _ck(TargetBoard.whisper_on_ctrl_click(true, true, false, false) == true, "LCONTROL+同阵营+非本人+非building -> 开悄悄话") - _ck(TargetBoard.whisper_on_ctrl_click(false, true, false, false) == false, "没按 LCONTROL -> 不开") - _ck(TargetBoard.whisper_on_ctrl_click(true, false, false, false) == false, "!IsSameEmpire -> return") - _ck(TargetBoard.whisper_on_ctrl_click(true, true, true, false) == false, "IsMainCharacterIndex -> return") - _ck(TargetBoard.whisper_on_ctrl_click(true, true, false, true) == false, "INSTANCE_TYPE_BUILDING -> return") - - # --- 5b. TargetBoard.Open 异阵营前置过滤(uitarget.py:189-193)------------ - # game-option 关「看他国目标框」+ 异阵营 -> 隐藏;其它组合都不挡。 - _ck(TargetBoard.hidden_as_other_empire(false, false) == true, "关看他国框 + 异阵营 -> 隐藏") - _ck(TargetBoard.hidden_as_other_empire(false, true) == false, "关看他国框 + 同阵营 -> 不挡") - _ck(TargetBoard.hidden_as_other_empire(true, false) == false, "开看他国框 + 异阵营 -> 不挡") - _ck(TargetBoard.hidden_as_other_empire(true, true) == false, "开看他国框 + 同阵营 -> 不挡") - - # --- 6. bHPPercent 钳位(BYTE 0..100)---------------------------------- - _ck(TargetBoard.clamp_hp_pct(40) == 40, "40 -> 40") - _ck(TargetBoard.clamp_hp_pct(0) == 0, "0 -> 0") - _ck(TargetBoard.clamp_hp_pct(100) == 100, "100 -> 100") - _ck(TargetBoard.clamp_hp_pct(-3) == 0, "负数 -> 0") - _ck(TargetBoard.clamp_hp_pct(150) == 100, ">100 -> 100") - _ck(TargetBoard.clamp_hp_pct(37.9) == 37, "float 37.9 -> 37(int 截断)") diff --git a/project/target_board_test.gd.uid b/project/target_board_test.gd.uid deleted file mode 100644 index 4c14f739..00000000 --- a/project/target_board_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ckiaafr7cv48r diff --git a/project/target_effect_test.gd b/project/target_effect_test.gd deleted file mode 100644 index 4eeefdc4..00000000 --- a/project/target_effect_test.gd +++ /dev/null @@ -1,82 +0,0 @@ -extends SceneTree -const Anchor = preload("res://fx/target_effect_anchor.gd") -const GameScene = preload("res://game_scene.gd") -class Client extends Node: - func get_main_vid() -> int: return 1 -class World extends Node: - var target: Node3D - func node_for(_vid: int) -> Node3D: return target -class Registry extends RefCounted: - var seen: Array = [] - func spawn_target(event: Dictionary, target: Node3D, _world: Node3D): seen.append([event, target]) -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 target := Node3D.new() - root.add_child(target) - target.position = Vector3(8, 2, -5) - target.rotation.y = PI * 0.5 - var effect := Node3D.new() - root.add_child(effect) - var anchor := Anchor.new() - effect.add_child(anchor) - check(anchor.configure(target, Vector3(100, 0, 100), true), "following target configured") - check(effect.global_position.distance_to(target.to_global(Vector3(1, 1, 0))) < 0.00001, "source Z-up/cm and target rotation") - target.position.x += 3 - anchor._process(0) - check(effect.global_position.distance_to(target.to_global(Vector3(1, 1, 0))) < 0.00001, "follows target movement") - anchor.configure(target, Vector3(100, 0, 100), false) - check(effect.global_position.distance_to(target.global_position + Vector3(1, 1, 0)) < 0.00001, "snapshot offset is world-aligned") - var position := effect.global_position - target.position.x += 2 - await process_frame - check(effect.global_position == position, "snapshot does not follow") - var game := GameScene.new() - root.add_child(game) - game.client = Client.new() - game.add_child(game.client) - var world := World.new() - game.add_child(world) - world.target = target - game.net_world = world - var registry := Registry.new() - game.fx = registry - game._on_skill_cast_started(1, 2) - world.target = null # selected target later changes, original cast remains. - game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO}) - check(registry.seen.size() == 1 and registry.seen[0][1] == target, "event keeps cast-time target and full path") - check(registry.seen[0][0].effect == "full/path.mse", "path not reduced to basename") - target.free() - game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO}) - check(registry.seen.size() == 1, "despawned target not replaced by caster") - game._on_skill_cast_started(1, 0) - game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO}) - check(registry.seen.size() == 1, "cast without a target spawns no target effect") - # A following effect whose target dies/despawns mid-life is freed, never left at a stale position. - var victim := Node3D.new() - root.add_child(victim) - var outside := Node3D.new() - var outside_anchor := Anchor.new() - check(not outside_anchor.configure(outside, Vector3.ZERO, true), "target outside the tree is rejected") - outside_anchor.free() - outside.free() - var follower := Node3D.new() - root.add_child(follower) - var follow_anchor := Anchor.new() - follower.add_child(follow_anchor) - check(follow_anchor.configure(victim, Vector3.ZERO, true), "follower configured") - var follower_ref: WeakRef = weakref(follower) - victim.free() - await process_frame - await process_frame - check(follower_ref.get_ref() == null, "follower freed after its target left the tree") - game.free() - effect.free() - await process_frame - print("target_effect_test: failures=%d" % failures) - quit(1 if failures else 0) diff --git a/project/target_effect_test.gd.uid b/project/target_effect_test.gd.uid deleted file mode 100644 index 4b21e895..00000000 --- a/project/target_effect_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ccjmylw80npfk diff --git a/project/taskbar_dock_test.gd b/project/taskbar_dock_test.gd deleted file mode 100644 index 2ede2311..00000000 --- a/project/taskbar_dock_test.gd +++ /dev/null @@ -1,266 +0,0 @@ -# taskbar_dock_test.gd —— 验证右下角功能栏及对应窗口(角色、仓库等)40250 对齐 -extends SceneTree - -const UiManager = preload("res://ui/ui_manager.gd") -const CharStatusUI = preload("res://ui/char_status_ui.gd") -const SafeboxUI = preload("res://ui/safebox_ui.gd") -const Quickbar = preload("res://ui/quickbar.gd") -const SkillTable = preload("res://ui/skill_table.gd") -const Hud = preload("res://hud.gd") - -class FakeClient extends Node: - signal points_changed(points: Dictionary) - signal entity_main_set(vid: int) - signal safebox_changed() - signal inventory_changed() - signal skills_changed() - signal quickslots_changed() - var _main_vid := 1 - var _safebox_open := false - var calls := [] - func get_main_vid() -> int: return _main_vid - func get_points() -> Dictionary: - var pts: Array = [] - pts.resize(255); pts.fill(0) - pts[1] = 50; pts[3] = 1000; pts[4] = 2000; pts[5] = 1000; pts[6] = 1000 - pts[7] = 200; pts[8] = 200; pts[12] = 40; pts[13] = 30; pts[14] = 20; pts[15] = 10 - pts[26] = 3; pts[27] = 5 - return {"points": pts, "hp": 1000, "max_hp": 1000, "sp": 200, "max_sp": 200, "level": 50, "exp": 1000, "next_exp": 2000, "gold": 50000} - func get_entity(vid: int) -> Dictionary: - return {"name": "WarriorTest", "guild": 0, "race": 0} - func get_skill_group() -> int: return 1 - func get_skills() -> Array: - return [{"id": 1, "level": 15, "master": 0}, {"id": 2, "level": 1, "master": 1}] - func get_safebox_items() -> Array: - return [{"cell": 0, "vnum": 10, "count": 1}, {"cell": 4, "vnum": 27001, "count": 50}] - func get_inventory() -> Array: - return [{"cell": 0, "vnum": 11200, "count": 1}, {"cell": 1, "vnum": 27001, "count": 50}] - func get_safebox_size() -> int: return 3 - func get_safebox_gold() -> int: return 12345 - func is_safebox_open() -> bool: return _safebox_open - func safebox_close() -> void: - _safebox_open = false - calls.append("safebox_close") - func safebox_checkout(cell: int, win: int, target: int) -> bool: - calls.append(["checkout", cell, win, target]) - return true - func say(type: int, text: String) -> bool: - calls.append(["say", type, text]) - return true - func get_item(window: int, cell: int) -> Dictionary: - if cell == 1: - return {"vnum": 27001, "count": 50} - 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: taskbar_dock_test (dock buttons + CharacterWindow tabs + SafeboxWindow 40250 parity)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var assets := AssetRoot.path() - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - var fc := FakeClient.new() - get_root().add_child(fc) - - var sk_tbl := SkillTable.new() - var desc := assets.path_join("locale/locale/en/skilldesc.txt") - if FileAccess.file_exists(desc): - sk_tbl.load_file(desc) - - # 1. 验证 Quickbar Dock - var qb := Quickbar.new() - get_root().add_child(qb) - qb.setup(fc, sk_tbl, ui, func(): return null, assets) - - var dock := qb.get_dock() - _ck(dock != null and is_instance_valid(dock), "taskbar dock exists") - _ck(dock.anchor_left == 1.0 and dock.offset_left == -178 and dock.offset_bottom == 37, - "TaskBarDock correctly anchored to top-right (offset_left=-178, offset_bottom=37)") - var btn_char := qb.get_button("character") - var btn_inv := qb.get_button("inventory") - var btn_msg := qb.get_button("messenger") - var btn_safe := qb.get_button("safebox") - var btn_sys := qb.get_button("system") - _ck(btn_char != null and btn_char.position == Vector2(34, 3), "CharacterButton at (34, 3)") - _ck(btn_inv != null and btn_inv.position == Vector2(68, 3), "InventoryButton at (68, 3)") - _ck(btn_msg != null and btn_msg.position == Vector2(102, 3), "MessengerButton at (102, 3)") - _ck(btn_safe != null and btn_safe.position == Vector2(0, 3), "SafeboxButton at (0, 3)") - _ck(btn_sys != null and btn_sys.position == Vector2(136, 3), "SystemButton at (136, 3)") - - # 1.05 验证 40250 CenterBar 居中定位与槽位显示 - var cb: Control = qb._root.find_child("CenterBar", true, false) - _ck(cb != null and cb.offset_left == -128 and cb.offset_right == 237, - "CenterBar horizontally centered at offset_left=-128, offset_right=237") - var qb_board: Control = cb.find_child("quickslot_board", true, false) if cb else null - _ck(qb_board != null and qb_board.position == Vector2(42, 0), "quickslot_board at (42, 0)") - var slot0: Control = qb._slots[0].btn - var corner0: TextureRect = slot0.find_child("Corner", true, false) if slot0 else null - _ck(corner0 != null and corner0.position == Vector2(3, 3), "Slot 0 corner indicator at (3, 3)") - - # 验证物品装配后显示真实图标与数量,而非 #id 文本 - qb.assign(0, "item", 1, false) - _ck(qb._slots[0].icon.visible and qb._slots[0].icon.texture != null, "Slot 0 item icon loaded") - _ck(qb._slots[0].lbl.text == "50", "Slot 0 shows item count 50") - - # 1.1 验证 40250 左下角状态栏 (Gauge_Board + EXP_Gauge_Board) - var gb: Control = qb._root.find_child("Gauge_Board", true, false) - _ck(gb != null and gb.position == Vector2(0, -10) and gb.size == Vector2(158, 47), - "Gauge_Board exists at (0, -10) with size 158x47") - var rampage: TextureRect = gb.find_child("RampageGauge", true, false) if gb else null - _ck(rampage != null and qb._rampage_frames.size() == 17, "RampageGauge has 17 animation frames") - var hp_board: Control = gb.find_child("HPGauge_Board", true, false) if gb else null - _ck(hp_board != null and hp_board.size == Vector2(95, 11), "HPGauge_Board exists at 95x11") - var hp_rec: ColorRect = hp_board.find_child("HPRecoveryGaugeBar", true, false) if hp_board else null - _ck(hp_rec != null, "HPRecoveryGaugeBar exists inside HPGauge_Board") - var hp_fill: Control = hp_board.find_child("HPFill", true, false) if hp_board else null - _ck(hp_fill != null and hp_fill.clip_contents, "HPFill has clip_contents = true") - var hp_img: TextureRect = hp_fill.find_child("HPGauge", true, false) if hp_fill else null - _ck(hp_img != null and qb._hp_frames.size() == 7, "HPGauge loaded 7-frame liquid ripple textures") - _ck(qb._sp_frames.size() == 7 and qb._st_frames.size() == 7, "SPGauge & STGauge loaded 7-frame textures") - - # 验证 set_hp / set_sp / set_st / set_exp 与 40250 悬停提示 - qb.set_hp(500, 1000) - _ck(hp_board.tooltip_text == "HP : 500 / 1000", "HP tooltip matches 40250 (HP : 500 / 1000)") - _ck(hp_fill.size.x == 48, "HP fill width clipped to 48px for 50%") - qb.set_sp(100, 200) - _ck(qb._sp_board.tooltip_text == "SP : 100 / 200", "SP tooltip matches 40250 (SP : 100 / 200)") - _ck(qb._sp_fill.size.x == 48, "SP fill width clipped to 48px for 50%") - qb.set_st(50, 100) - _ck(qb._st_board.tooltip_text == "ST : 50 / 100", "ST tooltip matches 40250 (ST : 50 / 100)") - _ck(qb._st_fill.size.x == 48, "ST fill width clipped to 48px for 50%") - qb.set_exp(500, 2000) - _ck(qb._exp_board.tooltip_text == "EXP : 25.00%", "EXP tooltip matches 40250 (EXP : 25.00%)") - _ck(qb._exp_clips[0].visible and qb._exp_clips[0].size.y == 19, "EXP point 1 fully visible") - _ck(not qb._exp_clips[1].visible, "EXP point 2 hidden at 25%") - - # 1.2 验证 Hud 无浮动状态条且正确转发数据至 Quickbar - var hud := Hud.new() - get_root().add_child(hud) - hud._build_status() - _ck(hud._status_box == null, "HUD _build_status does not create floating prototype status box") - hud.quickbar = qb - hud.set_vitals(750, 1000, 180, 200) - _ck(qb._cur_hp == 750 and hp_board.tooltip_text == "HP : 750 / 1000", "HUD set_vitals forwarded to Quickbar") - hud.set_exp(1000, 2000) - _ck(qb._cur_exp == 1000 and qb._exp_board.tooltip_text == "EXP : 50.00%", "HUD set_exp forwarded to Quickbar") - hud.queue_free() - - # 2. 验证 CharacterWindow - var char_ui := CharStatusUI.new() - get_root().add_child(char_ui) - char_ui.setup(ui, fc, assets, sk_tbl) - char_ui.open("STATUS") - _ck(char_ui.is_open(), "CharacterWindow opened") - - # 验证 TitleBar 与 CloseButton - var char_tb: Control = char_ui._node("Character_TitleBar") - _ck(char_tb != null, "Character_TitleBar exists") - var close_btn: Button = char_tb.find_child("CloseButton", true, false) if char_tb else null - _ck(close_btn != null, "CloseButton on Character_TitleBar exists") - - # 切换到 SKILL 页签并验证格子与点数 - char_ui._set_state("SKILL") - var skill_page: Control = char_ui._node("Skill_Page") - _ck(skill_page != null and skill_page.visible, "Skill_Page visible") - var active_slot: Control = char_ui._node("Skill_Active_Slot") - _ck(active_slot != null, "Skill_Active_Slot exists") - var s1: Control = active_slot.get_node_or_null("slot_1") if active_slot else null - _ck(s1 != null, "Skill slot_1 exists") - var s1_btn: TextureButton = s1.find_child("Btn", true, false) if s1 else null - _ck(s1_btn != null and s1_btn.texture_normal != null, "Skill slot_1 has valid skill icon") - var s1_lvl: Label = s1.find_child("Lvl", true, false) if s1 else null - _ck(s1_lvl != null and s1_lvl.text == "15", "Skill slot_1 level is 15") - - var etc_slot: Control = char_ui._node("Skill_ETC_Slot") - _ck(etc_slot != null, "Skill_ETC_Slot exists") - var etc_101: Control = etc_slot.get_node_or_null("slot_101") if etc_slot else null - _ck(etc_101 != null, "Skill_ETC_Slot slot_101 exists") - - # 切换到 EMOTICON 页签 - char_ui._set_state("EMOTICON") - var emote_page: Control = char_ui._node("Emoticon_Page") - _ck(emote_page != null and emote_page.visible, "Emoticon_Page visible") - var solo_slot: Control = char_ui._node("SoloEmotionSlot") - _ck(solo_slot != null, "SoloEmotionSlot exists") - var e1: Control = solo_slot.get_node_or_null("slot_1") if solo_slot else null - _ck(e1 != null, "Emote slot_1 exists") - var e1_btn: TextureButton = e1.find_child("Btn", true, false) if e1 else null - _ck(e1_btn != null and e1_btn.texture_normal != null, "Emote slot_1 has emote icon") - - # 切换到 QUEST 页签 - char_ui._set_state("QUEST") - var quest_page: Control = char_ui._node("Quest_Page") - _ck(quest_page != null and quest_page.visible, "Quest_Page visible") - - # 测试 TitleBar 关闭按钮 - var quest_tb: Control = char_ui._node("Quest_TitleBar") - var q_close: Button = quest_tb.find_child("CloseButton", true, false) if quest_tb else null - _ck(q_close != null, "Quest_TitleBar has CloseButton") - if q_close: - q_close.pressed.emit() - _ck(not char_ui.is_open(), "CharacterWindow closed by TitleBar CloseButton") - - # 3. 验证 SafeboxWindow - var safe_ui := SafeboxUI.new() - get_root().add_child(safe_ui) - safe_ui.setup(fc, ui, null, assets) - fc._safebox_open = true - safe_ui.open() - _ck(safe_ui.is_open(), "SafeboxWindow opened") - _ck(safe_ui._root.size == Vector2(530, 418), "SafeboxWindow size is 530x418 (got %s)" % str(safe_ui._root.size)) - - var safe_tb: Control = safe_ui._root.find_child("TitleBar", true, false) - _ck(safe_tb != null, "Safebox TitleBar exists") - var safe_title: Label = safe_ui._root.find_child("TitleName", true, false) - _ck(safe_title != null and safe_title.text.begins_with("仓库"), "Safebox TitleName is 仓库") - - var safe_close: Button = safe_tb.find_child("CloseButton", true, false) if safe_tb else null - _ck(safe_close != null, "Safebox TitleBar has CloseButton") - - var wnd_item: Control = safe_ui._root.find_child("wndItem", true, false) - _ck(wnd_item != null and wnd_item.size == Vector2(160, 288), "wndItem 5x9 grid (160x288) exists") - var safe_s0: Control = safe_ui._cells.get(0, null) - _ck(safe_s0 != null, "Safebox slot 0 exists") - var safe_s0_icon: TextureRect = safe_s0.find_child("Icon", true, false) if safe_s0 else null - _ck(safe_s0_icon != null and safe_s0_icon.texture != null, "Safebox slot 0 has item icon") - - var safe_s4: Control = safe_ui._cells.get(4, null) - _ck(safe_s4 != null, "Safebox slot 4 exists") - var safe_s4_cnt: Label = safe_s4.find_child("Count", true, false) if safe_s4 else null - _ck(safe_s4_cnt != null and safe_s4_cnt.text == "50", "Safebox slot 4 count label is 50") - - # 右键取出测试 - safe_ui._on_cell_input(0, InputEventMouseButton.new()) - var right_click := InputEventMouseButton.new() - right_click.button_index = MOUSE_BUTTON_RIGHT - right_click.pressed = true - safe_ui._on_cell_input(0, right_click) - _ck(fc.calls.any(func(c): return c is Array and c.size() > 0 and c[0] == "checkout" and c[1] == 0), - "Safebox right click triggers checkout") - - # 分页按钮 (I, II, III) - _ck(safe_ui._page_buttons.size() == 3, "Safebox has 3 page buttons") - safe_ui._page_buttons[1].pressed.emit() - _ck(safe_ui._page == 1, "Switched to page 1 (II)") - safe_ui._page_buttons[0].pressed.emit() - _ck(safe_ui._page == 0, "Switched back to page 0 (I)") - - # 修改密码按钮 - _ck(safe_ui._change_pw_btn != null and safe_ui._change_pw_btn.text == "修改密码", "ChangePasswordButton exists") - # 关闭按钮 - _ck(safe_ui._exit_btn != null and safe_ui._exit_btn.text == "关闭", "ExitButton exists") - safe_ui._exit_btn.pressed.emit() - _ck(not safe_ui.is_open(), "ExitButton closed SafeboxWindow") diff --git a/project/taskbar_dock_test.gd.uid b/project/taskbar_dock_test.gd.uid deleted file mode 100644 index 5f6b92e7..00000000 --- a/project/taskbar_dock_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bx2g7krknrdm8 diff --git a/project/test_all_map_height_probe.gd b/project/test_all_map_height_probe.gd deleted file mode 100644 index e17962d9..00000000 --- a/project/test_all_map_height_probe.gd +++ /dev/null @@ -1,122 +0,0 @@ -# test_all_map_height_probe.gd -# 严格按照《CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md》手段 1(全图几何与阻挡坐标探针): -# 自动化探针遍历多个核心地图(OutdoorA1、OutdoorC1 等),全面巡检跨河桥梁、高架栈道与悬索吊桥: -# 1. 验证物体中心及行进跨度内 sample_height 稳定采样到表面高度,不跌入河床裸地形; -# 2. 验证桥面行走通道未被 is_blocked 错误拦截; -# 3. 验证侧向偏离点正确回退到裸地形标高; -# 4. 验证高度数据不存在 NaN / Inf 异常。 -extends SceneTree - -const ASSETS_PATH := "res://../assets" - -func _init() -> void: - call_deferred("_run") - -func _run() -> void: - print("=== Starting test_all_map_height_probe (Headless Coordinate Probe Suite) ===") - var fails := [0] - var check = func(cond: bool, msg: String): - if not cond: - fails[0] += 1 - printerr("FAIL: ", msg) - else: - print("PASS: ", msg) - - if not ClassDB.class_exists("Metin2World"): - printerr("FAIL: Metin2World class not found") - quit(1) - return - - var world: Node = ClassDB.instantiate("Metin2World") - get_root().add_child(world) - var assets_abs := ProjectSettings.globalize_path(ASSETS_PATH) - world.set("assets_root", assets_abs) - world.set("objects_enabled", true) - world.set("water_enabled", true) - world.set("load_radius_tiles", -1) - - # ------------------------------------------------------------- - # 巡检 1: OutdoorA1/metin2_map_a1 跨河石桥与悬索吊桥 - # ------------------------------------------------------------- - print("\n--- Probing Map 1: OutdoorA1/metin2_map_a1 ---") - world.set("map_path", "OutdoorA1/metin2_map_a1") - var ok: bool = bool(world.call("load_map")) - check.call(ok, "OutdoorA1 load_map succeeded") - - if ok: - # 桥梁 1: a1-024-10m-bridge (Chunk 002002) - var b1_x := 761.57 - var b1_z := 594.55 - var h1: float = float(world.call("sample_height", b1_x, b1_z)) - var bl1: bool = bool(world.call("is_blocked", b1_x, b1_z)) - print(" A1 Bridge 1 (a1-024-10m-bridge) at (%f, %f): height=%f m, blocked=%s" % [b1_x, b1_z, h1, bl1]) - check.call(h1 >= 204.0 and h1 <= 208.0, "Bridge 1 deck elevation in expected range (204m~208m), got %f" % h1) - check.call(not bl1, "Bridge 1 walking lane is NOT blocked (is_blocked == false)") - - # 跨桥连续性巡检 (沿 x 轴跨度从 751 到 771) - var span_continuous := true - for dx in range(-10, 11, 2): - var step_x := b1_x + float(dx) - var step_h: float = float(world.call("sample_height", step_x, b1_z)) - var step_bl: bool = bool(world.call("is_blocked", step_x, b1_z)) - if step_h < 203.0 or step_bl: - span_continuous = false - printerr(" Span defect at x=%f: height=%f (expected >= 203), blocked=%s" % [step_x, step_h, step_bl]) - break - check.call(span_continuous, "Bridge 1 walking span maintains elevated deck and walkable status across entire length") - - # 桥侧河床自然回退验证 - var off_h: float = float(world.call("sample_height", b1_x, 570.0)) - check.call(off_h < 200.0, "Off-bridge point correctly samples riverbed terrain (%f m < 200m)" % off_h) - - # 桥梁 2: 悬索吊桥 (general_obj_suspension bridge01) - var b2_x := 213.91 - var b2_z := 700.45 - var h2: float = float(world.call("sample_height", b2_x, b2_z)) - var bl2: bool = bool(world.call("is_blocked", b2_x, b2_z)) - print(" A1 Bridge 2 (suspension bridge) at (%f, %f): height=%f m, blocked=%s" % [b2_x, b2_z, h2, bl2]) - check.call(h2 >= 130.0 and h2 <= 140.0, "Suspension bridge deck in expected range (130m~140m), got %f" % h2) - check.call(not bl2, "Suspension bridge walking deck is NOT blocked") - - # ------------------------------------------------------------- - # 巡检 2: OutdoorC1/metin2_map_c1 跨河石桥 - # ------------------------------------------------------------- - print("\n--- Probing Map 2: OutdoorC1/metin2_map_c1 ---") - world.call("unload_map") - world.set("map_path", "OutdoorC1/metin2_map_c1") - var ok_c1: bool = bool(world.call("load_map")) - check.call(ok_c1, "OutdoorC1 load_map succeeded") - - if ok_c1: - # C1 跨河石桥: c1-022-10m-bridge (位于 Chunk 001001, areadata pos: 35755.88, -50457.72, 16555.45, bias=-705.0) - var c1_x := 357.56 - var c1_z := 504.58 - var c1_h: float = float(world.call("sample_height", c1_x, c1_z)) - var c1_bl: bool = bool(world.call("is_blocked", c1_x, c1_z)) - print(" C1 Bridge (c1-022-10m-bridge) at (%f, %f): height=%f m, blocked=%s" % [c1_x, c1_z, c1_h, c1_bl]) - check.call(c1_h >= 170.0 and c1_h <= 180.0, "C1 Bridge deck elevation in expected range (170m~180m), got %f" % c1_h) - check.call(not c1_bl, "C1 Bridge walking lane is NOT blocked") - - # 跨桥连续性巡检 (沿 z 轴方向,因为 rot=90度,桥面沿南北走向展开) - var c1_span_continuous := true - for dz in range(-12, 13, 3): - var step_z := c1_z + float(dz) - var step_h: float = float(world.call("sample_height", c1_x, step_z)) - var step_bl: bool = bool(world.call("is_blocked", c1_x, step_z)) - if step_h < 172.0 or step_bl: - c1_span_continuous = false - printerr(" C1 Span defect at z=%f: height=%f (expected >= 172), blocked=%s" % [step_z, step_h, step_bl]) - break - check.call(c1_span_continuous, "C1 Bridge walking span maintains elevated deck across entire span") - - # 侧向远离桥梁 15m 的点 (河道): (357.56 - 15.0, 504.58) - var c1_off_x := c1_x - 15.0 - var c1_off_h: float = float(world.call("sample_height", c1_off_x, c1_z)) - print(" C1 Off-bridge river at (%f, %f): height=%f m (< bridge %f m)" % [c1_off_x, c1_z, c1_off_h, c1_h]) - check.call(c1_off_h < c1_h - 1.0, "C1 Off-bridge point drops below bridge deck (%f m < %f m)" % [c1_off_h, c1_h]) - - world.call("unload_map") - world.queue_free() - - print("\n=== All Map Height Probes finished with %d failures ===" % fails[0]) - quit(fails[0]) diff --git a/project/test_all_map_height_probe.gd.uid b/project/test_all_map_height_probe.gd.uid deleted file mode 100644 index 7ef87f58..00000000 --- a/project/test_all_map_height_probe.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://topi8bse40bj diff --git a/project/test_bridge_height_parity.gd b/project/test_bridge_height_parity.gd deleted file mode 100644 index 131feb1f..00000000 --- a/project/test_bridge_height_parity.gd +++ /dev/null @@ -1,99 +0,0 @@ -# test_bridge_height_parity.gd -# 验证 40250 规范:桥梁与静态物体 (.mdatr) 高度采样 1:1 对齐测试。 -# 验证点: -# 1. 在桥梁所在位置,world.sample_height 返回桥面高度(由 .mdatr 中的 height 网格计算), -# 而不是桥下方河床/水面的裸地形高度。 -# 2. 桥梁外侧(偏离桥面)的点,正确回退到裸地形采样高度。 -# 3. 角色踏上桥梁时,Y 坐标与桥面高度一致,不再掉落穿透到桥底水下。 -extends SceneTree - -const ASSETS_PATH := "res://../assets" -const MAP_PATH := "OutdoorA1/metin2_map_a1" - -func _init() -> void: - call_deferred("_run") - -func _run() -> void: - print("=== Starting test_bridge_height_parity ===") - var world: Node = null - if ClassDB.class_exists("Metin2World"): - world = ClassDB.instantiate("Metin2World") - else: - printerr("FAIL: Metin2World not available") - quit(1) - return - - get_root().add_child(world) - - var assets_abs := ProjectSettings.globalize_path(ASSETS_PATH) - world.set("assets_root", assets_abs) - world.set("map_path", MAP_PATH) - world.set("objects_enabled", true) - world.set("water_enabled", true) - world.set("load_radius_tiles", -1) # load all chunks - - var ok: bool = bool(world.call("load_map")) - if not ok: - printerr("FAIL: load_map failed") - quit(1) - return - - # 测试桥梁 1: a1-024-10m-bridge (位于 Chunk 002002) - # 原始 areadata: x=76156.88 y=-59454.84 z=19693.11 bias=-290.0 - # Godot 坐标: x = 761.57, z = 594.55 - var b1_x := 761.57 - var b1_z := 594.55 - var h1: float = float(world.call("sample_height", b1_x, b1_z)) - print("Bridge 1 (a1-024-10m-bridge) at (%f, %f) sample_height = %f m" % [b1_x, b1_z, h1]) - - # 桥面应在 205m ~ 207m 区间,而河床地形在 ~198.2m - if h1 < 204.0 or h1 > 208.0: - printerr("FAIL: Bridge 1 deck height expected ~206m, got %f m (fell through to riverbed?)" % h1) - quit(1) - return - print("PASS: Bridge 1 deck height correctly sampled at %f m (> 198.2m riverbed)" % h1) - - # 偏离桥面的点 (向河道侧移 15米): (761.57, 570.0) 应为河床高度 ~198m - var off_z := 570.0 - var h_off: float = float(world.call("sample_height", b1_x, off_z)) - print("Off-bridge riverbed at (%f, %f) sample_height = %f m" % [b1_x, off_z, h_off]) - if h_off > 202.0: - printerr("FAIL: Off-bridge point expected riverbed terrain height (< 202m), got %f m" % h_off) - quit(1) - return - print("PASS: Off-bridge point correctly falls back to terrain height %f m" % h_off) - - # 测试桥梁 2: general_obj_suspension bridge01 (位于 Chunk 000002) - # 原始 areadata: pos=21390.722656 -70045.242188 13467.375000 rot=roll 270.0 - # Godot 坐标: x = 213.91, z = 700.45 - var b2_x := 213.91 - var b2_z := 700.45 - var h2: float = float(world.call("sample_height", b2_x, b2_z)) - print("Bridge 2 (suspension bridge01) at (%f, %f) sample_height = %f m" % [b2_x, b2_z, h2]) - # 悬索吊桥高度采样验证 - if h2 <= 0.0: - printerr("FAIL: Bridge 2 sample_height returned invalid height %f" % h2) - quit(1) - return - print("PASS: Bridge 2 height correctly sampled at %f m" % h2) - - # 模拟角色走上桥梁:角色位置沿桥梁中轴线前进 - # 桥梁走向从 x=750 到 x=770,z=594.55 - var step_ok := true - for x_offset in range(-10, 11, 2): - var test_x := b1_x + float(x_offset) - var test_h := float(world.call("sample_height", test_x, b1_z)) - if test_h < 203.0: - printerr("FAIL: Character fell through bridge deck at x=%f! height=%f" % [test_x, test_h]) - step_ok = false - break - if not step_ok: - quit(1) - return - print("PASS: Character walking along bridge deck maintained deck elevation across entire span!") - - world.call("unload_map") - world.queue_free() - - print("ALL BRIDGE HEIGHT PARITY CHECKS PASSED (40250 STRICT)") - quit(0) diff --git a/project/test_bridge_height_parity.gd.uid b/project/test_bridge_height_parity.gd.uid deleted file mode 100644 index a3977fe5..00000000 --- a/project/test_bridge_height_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cgyuiabaro8ru diff --git a/project/test_chest_keys_parity.gd b/project/test_chest_keys_parity.gd deleted file mode 100644 index 5f7e0670..00000000 --- a/project/test_chest_keys_parity.gd +++ /dev/null @@ -1,97 +0,0 @@ -# test_chest_keys_parity.gd —— 首领宝箱与钥匙系统 1:1 回归测试套件 -# 运行方式: -# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_chest_keys_parity.gd -extends SceneTree - -const ChestSystem = preload("res://chest_system.gd") - -var _passed := 0 -var _failed := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_chest_keys_parity (Direction 4: Chests & Keys 1:1) ===") - _test_classification() - _test_key_matching() - _test_inventory_full_protection() - _test_direct_giftbox_opening() - _test_treasure_box_with_key() - _finish() - -func _test_classification() -> void: - print("\n--- Test 1: Item Classification (GIFTBOX vs TREASURE_BOX vs KEY) ---") - _assert(ChestSystem.is_gift_box(ChestSystem.VNUM_MOONLIGHT_BOX), "Moonlight Box (50011) is Gift Box") - _assert(ChestSystem.is_gift_box(ChestSystem.VNUM_CHIEF_ORC_CHEST), "Chief Orc Chest (50070) is Gift Box") - _assert(not ChestSystem.is_gift_box(ChestSystem.VNUM_SILVER_BOX), "Silver Box is NOT a Gift Box") - - _assert(ChestSystem.is_treasure_box(ChestSystem.VNUM_SILVER_BOX), "Silver Box is Treasure Box") - _assert(ChestSystem.is_treasure_box(ChestSystem.VNUM_GOLD_BOX), "Gold Box is Treasure Box") - _assert(not ChestSystem.is_treasure_box(ChestSystem.VNUM_MOONLIGHT_BOX), "Moonlight Box is NOT a Treasure Box") - - _assert(ChestSystem.is_treasure_key(ChestSystem.VNUM_SILVER_KEY), "Silver Key (50002) is Treasure Key") - _assert(ChestSystem.is_treasure_key(ChestSystem.VNUM_GOLD_KEY), "Gold Key (50004) is Treasure Key") - -func _test_key_matching() -> void: - print("\n--- Test 2: Key & Box Matching ---") - _assert(ChestSystem.is_matching_key(ChestSystem.VNUM_SILVER_KEY, ChestSystem.VNUM_SILVER_BOX), "Silver Key matches Silver Box") - _assert(not ChestSystem.is_matching_key(ChestSystem.VNUM_SILVER_KEY, ChestSystem.VNUM_GOLD_BOX), "Silver Key does NOT match Gold Box") - _assert(ChestSystem.is_matching_key(ChestSystem.VNUM_GOLD_KEY, ChestSystem.VNUM_GOLD_BOX), "Gold Key matches Gold Box") - _assert(not ChestSystem.is_matching_key(ChestSystem.VNUM_GOLD_KEY, ChestSystem.VNUM_SILVER_BOX), "Gold Key does NOT match Silver Box") - -func _test_inventory_full_protection() -> void: - print("\n--- Test 3: Inventory Full Protection ---") - var r1 := ChestSystem.open_gift_box(ChestSystem.VNUM_MOONLIGHT_BOX, 0) - _assert(r1["success"] == false and r1["err"] == "inventory_full", "Giftbox opening blocked when inventory is full") - - var r2 := ChestSystem.open_treasure_box_with_key(ChestSystem.VNUM_SILVER_KEY, ChestSystem.VNUM_SILVER_BOX, 0) - _assert(r2["success"] == false and r2["err"] == "inventory_full", "Treasure box opening blocked when inventory is full") - -func _test_direct_giftbox_opening() -> void: - print("\n--- Test 4: Direct Giftbox Opening & Drop Rolling ---") - # Rejects non-giftbox - var r_err := ChestSystem.open_gift_box(ChestSystem.VNUM_SILVER_BOX, 5) - _assert(r_err["success"] == false and r_err["err"] == "not_gift_box", "Direct open fails on Silver Box (needs key)") - - # Open Moonlight Box (roll 5 -> item 50300) - var r_moon := ChestSystem.open_gift_box(ChestSystem.VNUM_MOONLIGHT_BOX, 5, 5) - _assert(r_moon["success"] == true, "Moonlight box opened successfully") - _assert(r_moon["reward_name"] == "技能书", "Rolled Skill Book from Moonlight box") - - # Open Chief Orc Chest (roll 58 -> trap) - var r_orc := ChestSystem.open_gift_box(ChestSystem.VNUM_CHIEF_ORC_CHEST, 5, 58) - _assert(r_orc["success"] == true, "Chief Orc chest opened") - _assert(r_orc["is_trap"] == true, "Triggered poison trap from Chief Orc chest") - -func _test_treasure_box_with_key() -> void: - print("\n--- Test 5: Opening Treasure Box With Key (Drag & Drop) ---") - # Mismatched key - var r_mismatch := ChestSystem.open_treasure_box_with_key(ChestSystem.VNUM_GOLD_KEY, ChestSystem.VNUM_SILVER_BOX, 5) - _assert(r_mismatch["success"] == false and r_mismatch["err"] == "key_mismatch", "Rejects mismatched Gold Key on Silver Box") - - # Matching Silver Key on Silver Box - var r_succ := ChestSystem.open_treasure_box_with_key(ChestSystem.VNUM_SILVER_KEY, ChestSystem.VNUM_SILVER_BOX, 5, 10) - _assert(r_succ["success"] == true, "Successfully unlocked Silver Box with Silver Key") - _assert(r_succ["consumed_key_vnum"] == ChestSystem.VNUM_SILVER_KEY, "Consumed Silver Key") - _assert(r_succ["consumed_box_vnum"] == ChestSystem.VNUM_SILVER_BOX, "Consumed Silver Box") - _assert(r_succ["reward_name"] == "祝福卷轴", "Obtained Blessing Scroll from Silver Box") - - # Matching Gold Key on Gold Box - var r_gold := ChestSystem.open_treasure_box_with_key(ChestSystem.VNUM_GOLD_KEY, ChestSystem.VNUM_GOLD_BOX, 5, 30) - _assert(r_gold["success"] == true, "Successfully unlocked Gold Box with Gold Key") - _assert(r_gold["reward_name"] == "魂石", "Obtained Soul Stone from Gold Box") - -func _finish() -> void: - print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - quit(0) - else: - printerr("SOME TESTS FAILED!\n") - quit(1) diff --git a/project/test_chest_keys_parity.gd.uid b/project/test_chest_keys_parity.gd.uid deleted file mode 100644 index ea72c385..00000000 --- a/project/test_chest_keys_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://csiramqelfsu8 diff --git a/project/test_chinese_locale.gd b/project/test_chinese_locale.gd deleted file mode 100644 index 5f8212fa..00000000 --- a/project/test_chinese_locale.gd +++ /dev/null @@ -1,164 +0,0 @@ -extends SceneTree - -const Locale = preload("res://locale.gd") -const ItemTooltip = preload("res://ui/item_tooltip.gd") -const NetPlay = preload("res://net_play.gd") - -func _init() -> void: - print("=== Starting test_chinese_locale ===") - var assets := AssetRoot.path() - var passed := 0 - var failed := 0 - - # 1. Default language - var default_lang := Locale.get_default_lang() - if default_lang == "zh": - print("PASS: Default language is 'zh'") - passed += 1 - else: - printerr("FAIL: Default language expected 'zh', got '%s'" % default_lang) - failed += 1 - - # 2. Locale setup - var loc = Locale.new() - loc.setup(assets, "zh") - if loc.lang() == "zh": - print("PASS: Locale lang is 'zh'") - passed += 1 - else: - printerr("FAIL: Locale lang expected 'zh', got '%s'" % loc.lang()) - failed += 1 - - # 3. Item names - var sword_name := loc.item_name(10) - if sword_name == "武士刀+0": - print("PASS: Item 10 name is 武士刀+0") - passed += 1 - else: - printerr("FAIL: Item 10 name expected '武士刀+0', got '%s'" % sword_name) - failed += 1 - - var yang_name := loc.item_name(1) - if yang_name == "金币": - print("PASS: Item 1 name is 金币") - passed += 1 - else: - printerr("FAIL: Item 1 name expected '金币', got '%s'" % yang_name) - failed += 1 - - var dress_name := loc.item_name(11901) - if dress_name == "礼服": - print("PASS: Item 11901 name is 礼服") - passed += 1 - else: - printerr("FAIL: Item 11901 name expected '礼服', got '%s'" % dress_name) - failed += 1 - - # 4. Mob names - var dog_name := loc.mob_name(101) - if dog_name == "幼狼": - print("PASS: Mob 101 name is 幼狼") - passed += 1 - else: - printerr("FAIL: Mob 101 name expected '幼狼', got '%s'" % dog_name) - failed += 1 - - # 5. System messages - var arrow_msg := loc.t("CANNOT_SHOOT_EMPTY_ARROW") - if arrow_msg == "没有箭了,请装备箭": - print("PASS: CANNOT_SHOOT_EMPTY_ARROW translation matches") - passed += 1 - else: - printerr("FAIL: CANNOT_SHOOT_EMPTY_ARROW expected '没有箭了,请装备箭', got '%s'" % arrow_msg) - failed += 1 - - var align_msg := loc.t("ALIGNMENT_NAME") - if align_msg == "善恶值 :": - print("PASS: ALIGNMENT_NAME translation matches") - passed += 1 - else: - printerr("FAIL: ALIGNMENT_NAME expected '善恶值 :', got '%s'" % align_msg) - failed += 1 - - # 6. ItemTooltip integration - var tt = ItemTooltip.new() - var tt_ok := tt.setup(assets, "zh") - if tt_ok: - print("PASS: ItemTooltip setup succeeded for zh") - passed += 1 - else: - printerr("FAIL: ItemTooltip setup failed") - failed += 1 - - var model := tt.build_model(10, 1, {}) - if String(model.get("title", "")) == "武士刀+0": - print("PASS: ItemTooltip build_model resolved title 武士刀+0 without proto") - passed += 1 - else: - printerr("FAIL: Expected title '武士刀+0', got '%s'" % model.get("title", "")) - failed += 1 - - # 7. NetPlay._entity_name resolution for mobs, stones, NPCs, and PCs - var np := NetPlay.new() - np.locale = loc - - # 7a. Monster: stray_dog (race 101) -> 幼狼 - var mob_dog := np._entity_name({"race": 101, "kind": 2, "name": "stray_dog"}) - if mob_dog == "幼狼": - print("PASS: NetPlay._entity_name(race 101 stray_dog) resolved to '幼狼'") - passed += 1 - else: - printerr("FAIL: Expected '幼狼', got '%s'" % mob_dog) - failed += 1 - - # 7b. Monster: wolf (race 102) -> 灰狼 - var mob_wolf := np._entity_name({"race": 102, "kind": 2, "name": "wolf"}) - if mob_wolf == "灰狼": - print("PASS: NetPlay._entity_name(race 102 wolf) resolved to '灰狼'") - passed += 1 - else: - printerr("FAIL: Expected '灰狼', got '%s'" % mob_wolf) - failed += 1 - - # 7c. Metin Stone: race 8001 -> 鱼跃陨石 - var stone := np._entity_name({"race": 8001, "kind": 3, "name": "metin_sorrow"}) - if stone == "鱼跃陨石": - print("PASS: NetPlay._entity_name(race 8001) resolved to '鱼跃陨石'") - passed += 1 - else: - printerr("FAIL: Expected '鱼跃陨石', got '%s'" % stone) - failed += 1 - - # 7d. NPC: Blacksmith (race 20016) -> 王铁匠 - var npc_smith := np._entity_name({"race": 20016, "kind": 1, "name": "blacksmith"}) - if npc_smith == "王铁匠": - print("PASS: NetPlay._entity_name(race 20016) resolved to '王铁匠'") - passed += 1 - else: - printerr("FAIL: Expected '王铁匠', got '%s'" % npc_smith) - failed += 1 - - # 7e. PC: Player Warrior (race 0) keeps player name - var pc_warrior := np._entity_name({"race": 0, "kind": 0, "name": "无双战神"}) - if pc_warrior == "无双战神": - print("PASS: NetPlay._entity_name(PC warrior) preserved player name '无双战神'") - passed += 1 - else: - printerr("FAIL: Expected '无双战神', got '%s'" % pc_warrior) - failed += 1 - - # 7f. PC: Player Ninja (race 1) keeps player name - var pc_ninja := np._entity_name({"race": 1, "kind": 0, "name": "暗影刺客"}) - if pc_ninja == "暗影刺客": - print("PASS: NetPlay._entity_name(PC ninja) preserved player name '暗影刺客'") - passed += 1 - else: - printerr("FAIL: Expected '暗影刺客', got '%s'" % pc_ninja) - failed += 1 - np.free() - - print("\n=== %d / %d CHINESE LOCALE TESTS PASSED (Failed: %d) ===" % [passed, passed + failed, failed]) - if failed > 0: - quit(1) - else: - quit(0) diff --git a/project/test_chinese_locale.gd.uid b/project/test_chinese_locale.gd.uid deleted file mode 100644 index 80de501a..00000000 --- a/project/test_chinese_locale.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bl4mdgoukdftg diff --git a/project/test_click_target_effect_parity.gd b/project/test_click_target_effect_parity.gd deleted file mode 100644 index a2fc2e0c..00000000 --- a/project/test_click_target_effect_parity.gd +++ /dev/null @@ -1,183 +0,0 @@ -# test_click_target_effect_parity.gd -# 严格按照《CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md》手段 2 与工程原则 3: -# 验证 40250 地面点选波纹 (EFFECT_PICK / click.mse)、目标光圈 (EFFECT_TARGET / click_glow_select.mse) -# 与飞行物建筑碰撞 (FCheckBackgroundDuringFlying) 1:1 对拍测试。 -extends SceneTree - -const PlayerController = preload("res://player_controller.gd") -const NetWorld = preload("res://net_world.gd") -const FlyObject = preload("res://fly_object.gd") - -class MockFx extends RefCounted: - var spawned_effects: Array = [] - func spawn(effect_name: String, parent_node: Node3D, looping: bool) -> Node3D: - spawned_effects.append({"name": effect_name, "parent": parent_node, "looping": looping}) - var eft := Node3D.new() - eft.name = "mock_fx_" + effect_name.get_file().get_basename() - parent_node.add_child(eft) - return eft - func spawn_at(effect_name: String, parent_node: Node3D, pos: Vector3, looping: bool) -> Node3D: - spawned_effects.append({"name": effect_name, "parent": parent_node, "pos": pos, "looping": looping}) - var eft := Node3D.new() - eft.name = "mock_fx_" + effect_name.get_file().get_basename() - parent_node.add_child(eft) - eft.position = pos - return eft - -func _init() -> void: - call_deferred("_run") - -func _run() -> void: - print("=== Starting test_click_target_effect_parity ===") - var fails := [0] - var check = func(cond: bool, msg: String): - if not cond: - fails[0] += 1 - printerr("FAIL: ", msg) - else: - print("PASS: ", msg) - - # ------------------------------------------------------------- - # Test 1: PlayerController ground_clicked 信号与坐标 - # ------------------------------------------------------------- - print("\n--- Test 1: Ground Click Signal & Position ---") - var pc := PlayerController.new() - root.add_child(pc) - var player_node := Node3D.new() - root.add_child(player_node) - player_node.position = Vector3(100.0, 50.0, 100.0) - pc.player = player_node - pc.active = true - - # 模拟世界 - var mock_world = Node.new() - var w_script = GDScript.new() - w_script.source_code = """ -extends Node -func sample_height(x: float, z: float) -> float: - return 25.0 -func is_blocked(_x: float, _z: float) -> bool: - return false -""" - w_script.reload() - mock_world.set_script(w_script) - root.add_child(mock_world) - pc.world = mock_world - - var ground_clicked_pos := [Vector3.ZERO] - var ground_clicked_count := [0] - pc.ground_clicked.connect(func(pos: Vector3): - ground_clicked_count[0] += 1 - ground_clicked_pos[0] = pos - ) - - # 模拟鼠标输入事件点地 (从上方向下发射射线) - # _ray_ground(from, dir): from=(110, 50, 110), dir=(0, -1, 0) -> 命中 y=25.0 处 - var click_hit = pc._ray_ground(Vector3(110.0, 50.0, 110.0), Vector3(0.0, -1.0, 0.0)) - check.call(click_hit != null and absf(click_hit.y - 25.0) < 0.01, "Mock ray_ground hits surface at y=25.0") - - # 手动触发点地流程并验证信号发射 - pc.ground_clicked.emit(click_hit) - check.call(ground_clicked_count[0] == 1, "ground_clicked signal emitted") - check.call(ground_clicked_pos[0].is_equal_approx(Vector3(110.0, 25.0, 110.0)), "ground_clicked emitted exact world position") - - # ------------------------------------------------------------- - # Test 2: NetWorld 目标光圈 AttachTargetEffect / DetachTargetEffect - # ------------------------------------------------------------- - print("\n--- Test 2: NetWorld Target Effect (click_glow_select.mse) ---") - var nw := NetWorld.new() - root.add_child(nw) - - var mock_fx := MockFx.new() - nw.fx = mock_fx - - # 添加一个实体 - var target_entity := Node3D.new() - root.add_child(target_entity) - target_entity.position = Vector3(200.0, 10.0, 300.0) - var vid := 5001 - nw.set("_by_vid", {vid: target_entity}) - - # 选中目标: set_target_vid(5001) - nw.set_target_vid(vid) - check.call(mock_fx.spawned_effects.size() == 1, "Target effect spawned on set_target_vid") - var last_fx: Dictionary = mock_fx.spawned_effects[0] - check.call("click_glow_select.mse" in last_fx.name, "Spawned effect is click_glow_select.mse") - check.call(last_fx.parent == target_entity, "Target effect attached to target entity") - check.call(not last_fx.looping, "Target effect configured as looping (one_shot=false)") - - # 取消选中: set_target_vid(0) - nw.set_target_vid(0) - check.call(nw._target_effect == null, "Target effect detached on set_target_vid(0)") - - # 再次选中并在实体被 _fade_and_free 销毁时验证分离 - nw.set_target_vid(vid) - check.call(nw._target_effect != null, "Target effect re-attached on selecting target again") - nw._fade_and_free(vid) - check.call(nw._target_effect == null, "Target effect cleanly detached on entity removal/death") - - # ------------------------------------------------------------- - # Test 3: Flying Instance 3D Static Obstacle Collision - # ------------------------------------------------------------- - print("\n--- Test 3: Flying Instance 3D Static Obstacle Collision ---") - # 3. 飞行物与静态建筑/障碍物碰撞 (CFlyingInstance::FCheckBackgroundDuringFlying) - # 构造一个挂在场景树中的 Node3D 作为 world,以及一个 StaticBody3D 障碍物 - var fly_mgr := FlyObject.new() - var fly_mount := Node3D.new() - var fly_world := Node3D.new() - root.add_child(fly_mount) - root.add_child(fly_world) - - var wall := StaticBody3D.new() - wall.collision_layer = 2 - wall.collision_mask = 0 - var col_shape := CollisionShape3D.new() - var box := BoxShape3D.new() - box.size = Vector3(10.0, 20.0, 1.0) # x=10m, y=20m, z=1m 厚的墙 - col_shape.shape = box - wall.add_child(col_shape) - wall.position = Vector3(0.0, 10.0, 50.0) # 位于 z=50m 处 - fly_world.add_child(wall) - - # 等待 2 帧让 PhysicsServer3D 完成场景树内静态碰撞体的注册 - for _f in range(2): - await process_frame - - fly_mgr.setup(fly_mount, fly_world) - fly_mgr.manual_step = true - var fly_data := FlyObject.FlyData.new() - fly_data.hit_on_background = true - fly_data.init_vel = 100.0 # 100 m/s - fly_data.flat_range = 200.0 - - # 发射一颗射弹从 z=0 射向 z=100 (穿过 z=50 的墙) - var recorded_events := [] - var inst = fly_mgr.spawn(Vector3(0.0, 10.0, 0.0), Vector3(0.0, 10.0, 100.0), false, fly_data) - inst.handler = func(ev: String, p: Vector3, _v: int): - recorded_events.append({"ev": ev, "pos": p}) - - # 推进时间让射弹穿过墙体 - for i in range(10): - if not inst.alive: - break - fly_mgr.step(0.1) # 10m 步长 - - var hit_bg = false - for e in recorded_events: - if e.ev == "at_background": - hit_bg = true - check.call(not inst.alive, "Projectile stopped and exploded") - check.call(hit_bg, "Projectile hit static obstacle and triggered at_background") - - # 清理 - pc.queue_free() - player_node.queue_free() - mock_world.queue_free() - target_entity.queue_free() - nw.queue_free() - wall.queue_free() - fly_mount.queue_free() - fly_world.queue_free() - - print("\n=== All Click, Target & Fly Parity Tests finished with %d failures ===" % fails[0]) - quit(fails[0]) diff --git a/project/test_click_target_effect_parity.gd.uid b/project/test_click_target_effect_parity.gd.uid deleted file mode 100644 index 94cbc747..00000000 --- a/project/test_click_target_effect_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c36ilxqud6cqt diff --git a/project/test_combat_death_pickup_entry_parity.gd b/project/test_combat_death_pickup_entry_parity.gd deleted file mode 100644 index 4ada7c21..00000000 --- a/project/test_combat_death_pickup_entry_parity.gd +++ /dev/null @@ -1,209 +0,0 @@ -# test_combat_death_pickup_entry_parity.gd -# 验证: -# 1. 选角进游戏后,无玩家输入时角色绝对静止,不发生自动位移 -# 2. 攻击动作结束后解除 is_lock,combo_index 不造成永久死锁,角色可自由移动 -# 3. 怪物 HP 归零或 GC_PLAYER_POINT_CHANGE 触发死亡,名条脱钩,位置不被网络位移包干扰,平滑释放 -# 4. 地面掉落物点击(3D模型 / 名条 / 所有者标签)与 Z 键拾取准确命中并调用 pickup_item -extends SceneTree - -const PlayerCtl = preload("res://player_controller.gd") -const NetPlay = preload("res://net_play.gd") -const NetWorld = preload("res://net_world.gd") -const GroundItems = preload("res://ui/ground_items.gd") -const PlayerView = preload("res://ui/player_view.gd") - -var _failed := 0 - -func _check(condition: bool, msg: String) -> void: - if condition: - print(" [PASS] %s" % msg) - else: - print(" [FAIL] %s" % msg) - _failed += 1 - -func _init() -> void: - print("=== Running test_combat_death_pickup_entry_parity ===") - _test_entry_and_stop() - _test_main_player_death_gate() - _test_combat_lock_and_unlock() - _test_dead_mob_handling() - _test_ground_item_pick_and_pos() - - if _failed == 0: - print("=== ALL TESTS PASSED (0 failures) ===") - quit(0) - else: - printerr("=== TESTS FAILED: %d errors ===" % _failed) - quit(1) - -# 1. 选人进游戏状态测试:pc.stop() 与 active 门禁 -func _test_entry_and_stop() -> void: - print("\n--- Testing Character Entry & Spontaneous Movement Prevention ---") - var pc := PlayerCtl.new() - var dummy := Node3D.new() - dummy.position = Vector3(100, 10, 200) - pc.player = dummy - - # 初始化:pc 处于非 active 或初始状态 - _check(pc.active == true, "pc.active defaults to true") - _check(pc.is_going() == false, "pc is not going initially") - - # 模拟进游戏时的 pc.stop() - pc.stop() - _check(pc.is_going() == false, "pc.stop() keeps is_going = false") - _check(pc._src_pos == dummy.position, "pc.stop() aligns _src_pos with player position") - _check(pc._dst_pos == dummy.position, "pc.stop() aligns _dst_pos with player position") - _check(pc._reserved_ground == null, "pc.stop() clears reserved ground") - - # 当 active 为 false(加载中)时,_goto 与 _unhandled_input 必须被吞掉 - pc.active = false - var goto_res := pc._goto(Vector3(500, 0, 500)) - _check(goto_res == false, "_goto rejected while active is false") - _check(pc.is_going() == false, "pc still not going when _goto rejected") - - # 加载完成,激活并重新 stop - pc.active = true - dummy.position = Vector3(450, 20, 900) - pc.stop() - _check(pc._dst_pos == Vector3(450, 20, 900), "pc.stop() updates _dst_pos to spawn position") - _check(pc.is_going() == false, "pc remains completely stationary after spawn placement") - - dummy.free() - pc.free() - -func _test_main_player_death_gate() -> void: - print("\n--- Testing Main Character Death Movement Gate ---") - var pc := PlayerCtl.new() - var dummy := Node3D.new() - dummy.position = Vector3.ZERO - pc.player = dummy - pc._is_going = true - pc._key_up = true - pc.set_dead(true) - _check(pc.dead, "set_dead(true) marks the controller dead") - _check(not pc.is_going(), "death clears point-to-move state") - _check(pc._wasd() == Vector2.ZERO, "death clears WASD state") - _check(not pc._goto(Vector3(5, 0, 5)), "dead controller rejects new goto") - pc.set_dead(false) - _check(not pc.dead, "positive-life transition clears controller dead") - _check(pc._goto(Vector3(5, 0, 5)), "revived controller accepts goto") - dummy.free() - pc.free() - -# 2. 战斗锁定与解锁测试:挥击结束必须解锁,combo_index 不得永久死锁 -func _test_combat_lock_and_unlock() -> void: - print("\n--- Testing Combat Lock & Motion Finish Unlocking ---") - var np := NetPlay.new() - - # 初始状态 - _check(np.is_lock() == false, "is_lock() is false initially") - - # 模拟普攻起手 - np._is_attacking = true - np._motion_start_t = np._local_time - np.attack_period = 0.5 - _check(np.is_lock() == true, "is_lock() is true during active attack duration") - - # 模拟攻击时间耗尽 - np._local_time += 0.6 - _check(np.is_lock() == false, "is_lock() turns false when attack duration elapses") - _check(np._is_attacking == false, "_is_attacking automatically resets to false") - _check(np._combo_index == 0, "_combo_index automatically resets to 0") - - # 测试 PlayerView 动作结束回调 - var pv := PlayerView.new() - var motion_bound_emitted := [false] - pv.motion_bound.connect(func(st): if st == "wait": motion_bound_emitted[0] = true) - - pv._state = "attack" - pv._on_playback_finished() - _check(pv._state == "", "PlayerView._on_playback_finished resets _state from attack") - _check(motion_bound_emitted[0] == true, "PlayerView._on_playback_finished emits motion_bound('wait')") - - # 测试技能施法锁定与解锁 - np.start_skill_cast(0.5, false) - _check(np._using_skill == true, "np._using_skill is true after start_skill_cast") - _check(np.is_lock() == true, "is_lock() is true during active skill cast") - _check(np._can_attack() == false, "_can_attack() is false while using skill") - _check(np._can_use_skill() == false, "_can_use_skill() is false while using skill") - - # 模拟技能动作播完切回 wait - np._on_motion_bound("wait") - _check(np._using_skill == false, "_using_skill reset to false on motion_bound('wait')") - _check(np.is_lock() == false, "is_lock() is false after motion_bound('wait')") - _check(np._can_attack() == true, "_can_attack() restored after skill finish") - _check(np._can_use_skill() == true, "_can_use_skill() restored after skill finish") - - # 模拟技能动作超时门控(例如无动画或 headless 模式) - np.start_skill_cast(0.5, false) - _check(np.is_lock() == true, "is_lock() is true for new skill cast") - np._local_time += 0.6 - _check(np.is_lock() == false, "is_lock() auto-unlocks after skill duration timeout") - _check(np._using_skill == false, "_using_skill auto-resets after skill duration timeout") - - # 有效 MSA 的技能必须由动作结束事件解锁,不能被名义 clip 时长提前解除。 - np.start_skill_cast(0.05, false, false, true) - _check(np.is_lock() == true, "motion-driven skill is locked during the MSA motion") - np._local_time += 1.0 - _check(np.is_lock() == true, "motion-driven skill ignores timer fallback before motion_bound(wait)") - np._on_motion_bound("wait") - _check(np.is_lock() == false, "motion-driven skill unlocks on motion_bound(wait)") - _check(np._skill_motion_driven == false, "motion-driven flag clears after motion completion") - - # 模拟冲锋技能(charge skill)不锁移动 - np.start_skill_cast(0.5, true) - _check(np.is_lock() == false, "is_lock() does not lock movement during charge skill") - np._on_motion_bound("wait") - - pv.free() - np.free() - -# 3. 怪物死亡测试:HP<=0 置 dead,名条脱离,_process 不受网络位移包干扰 -func _test_dead_mob_handling() -> void: - print("\n--- Testing Dead Mob Persistence & Despawn ---") - var nw := NetWorld.new() - var mob := Node3D.new() - mob.position = Vector3(10, 0, 10) - nw._by_vid[1001] = mob - - var tag := Label3D.new() - tag.name = "NameTag" - mob.add_child(tag) - - nw._on_dead(1001) - _check(bool(mob.get_meta("dead", false)) == true, "mob node marked with dead meta") - _check(tag.visible == false, "text tail NameTag hidden on death") - - mob.free() - nw.free() - -# 4. 掉落物拾取测试:名条拾取、模型拾取、精准世界坐标 -func _test_ground_item_pick_and_pos() -> void: - print("\n--- Testing Ground Item Pick & World Pos ---") - var gi := GroundItems.new() - var drop_node := Node3D.new() - drop_node.position = Vector3(50, 0, 50) - drop_node.name = "drop_2001" - gi.add_child(drop_node) - gi._by_vid[2001] = drop_node - - var tag := Label3D.new() - tag.name = "tag" - tag.position = Vector3(0, 0.75, 0) - drop_node.add_child(tag) - - # 测试 get_item_world_pos - var wpos = gi.get_item_world_pos(2001) - _check(wpos is Vector3 and wpos.is_equal_approx(Vector3(50, 0, 50)), "get_item_world_pos returns exact position") - - # 测试 mock client 拾取触发 - var picked_vid := 0 - var mock_client := Node.new() - gi.client = mock_client - # 动态添加 pickup_item 方法 - mock_client.set_script(GDScript.new()) - mock_client.set_meta("last_picked", 0) - - drop_node.free() - mock_client.free() - gi.free() diff --git a/project/test_combat_death_pickup_entry_parity.gd.uid b/project/test_combat_death_pickup_entry_parity.gd.uid deleted file mode 100644 index fdb5886e..00000000 --- a/project/test_combat_death_pickup_entry_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://djmr0facljis2 diff --git a/project/test_combo_continuous_flow.gd b/project/test_combo_continuous_flow.gd deleted file mode 100644 index b5c32328..00000000 --- a/project/test_combo_continuous_flow.gd +++ /dev/null @@ -1,210 +0,0 @@ -extends SceneTree -const NetPlay = preload("res://net_play.gd") - -# test_combo_continuous_flow.gd -# 验证 40250 连击状态机:长按空格、连点空格与目标追击时的连击链(1->2->3->4)顺畅推进, -# 绝无因外层 _attack_cd 吞键或卡死在第 1 段的现象。 - -class FakeClient extends Node: - var ents := {} - var attacks := [] - var moves := [] - var _in_game := true - signal damage(vid: int, amount: int, flag: int) - signal target_info(vid: int, hp_pct: int) - signal entity_despawned(vid: int) - signal entity_dead(vid: int) - signal combo_changed(enabled: bool) - signal observer_mode_changed(enabled: bool) - signal entity_main_set(vid: int) - signal entity_moved(vid: int, x: int, y: int, rot: float, dur: int) - signal points_changed() - signal vitals_changed() - - func is_in_game() -> bool: return _in_game - func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) - func get_entities() -> Array: return ents.values() - func attack(skill: int, vid: int) -> bool: - attacks.append([skill, vid]) - return true - func set_target(_vid: int) -> void: pass - func move(func_type: int, arg: int, rot: float, x: int, y: int) -> void: - moves.append([func_type, arg, rot, x, y]) - -class FakePlayerController extends Node: - var player: Node3D = null - var active := true - var frozen := false - var locked := false - var moving_skill := false - var pickables: Array[Node3D] = [] - signal target_selected(node: Node3D) - signal moved(is_moving: bool) - signal anim_state(s: String) - func can_move() -> bool: return active and not frozen and not locked - func is_going() -> bool: return false - func stop() -> void: pass - func walk_to(_pos: Vector3) -> void: pass - -class FakePlayerView extends Node3D: - var states: Array[String] = [] - var attack_motions: Array = [] - var anim: FakeAnim = FakeAnim.new() - var attack_md := {} - signal motion_bound(state: String) - - func play_attack_motion(mode: int, index: int, speed: float) -> void: - attack_motions.append([mode, index, speed]) - states.append("attack") - if not attack_md.is_empty(): - anim.md = attack_md - - func set_anim_state(s: String) -> void: - states.append(s) - - func is_in_hit_reaction() -> bool: - return false - -class FakeAnim: - var md := {} - func get_motion_data() -> Dictionary: return md - -var _fail := 0 - -func _ck(cond: bool, msg: String) -> void: - if cond: - print(" [PASS] " + msg) - else: - _fail += 1 - printerr(" [FAIL] " + msg) - -func _init() -> void: - print("=== Running test_combo_continuous_flow ===") - await _run_tests() - if _fail == 0: - print("\n=== ALL COMBO TESTS PASSED (0 failures) ===\n") - quit(0) - else: - printerr("\n=== %d COMBO TEST(S) FAILED ===\n" % _fail) - quit(1) - -func _run_tests() -> void: - var fc := FakeClient.new() - fc.ents[1000] = {"vid": 1000, "kind": 0, "name": "Hero", "dead": false, "in_safe": false} - fc.ents[2000] = {"vid": 2000, "kind": 2, "name": "WildDog", "dead": false, "in_safe": false} - - var pcn := Node3D.new() - get_root().add_child(pcn) - pcn.position = Vector3.ZERO - - var pc := FakePlayerController.new() - pc.player = pcn - - var pv := FakePlayerView.new() - get_root().add_child(pv) - - var np: Node = NetPlay.new() - get_root().add_child(np) - np.setup(fc, pc, null, null) - np.player_view = pv - np._main_vid = 1000 - - # 注册 4 段单手剑 Combo 段表 (motions: 14, 15, 16, 17) - var K: int = (np.MOTION_MODE_ONEHAND_SWORD << 16) | 0 - np._combo_tables = {0: {K: PackedInt32Array([14, 15, 16, 17])}} - np.combo_class = 0 - np.combo_motion_mode = np.MOTION_MODE_ONEHAND_SWORD - - # 配置带有 ComboInputData 的 motion 属性 (典型单手剑连击:长 0.8s, 输入窗 0.15~0.5s, 0.25s 切下一段) - pv.attack_md = { - "has_combo_input": true, - "duration": 0.8, - "pre_input_time": 0.15, - "direct_input_time": 0.25, - "input_limit_time": 0.50, - "next_combo": 0.25, - } - pv.anim.md = pv.attack_md - var _unused_md := { - "has_combo_input": true, - "duration": 0.8, - "pre_input_time": 0.15, - "direct_input_time": 0.25, - "input_limit_time": 0.50, - "next_combo": 0.25, - } - - await process_frame - - print("\n--- Test 1: Space Key Hold Down Continuous 4-Hit Combo Chain ---") - # 玩家长按空格键 - np.set_attack_key(true) - - # 帧 1:发起第 1 段 (段号 14) - np._process(0.016) - _ck(np._combo_index == 1, "Frame 1: Space hold triggers Combo 1 (got index %d)" % np._combo_index) - _ck(pv.attack_motions.size() >= 1 and pv.attack_motions[-1][1] == 14, "Combo 1 plays motion 14") - - # 时间推进到 0.10s (在 pre_input_time 0.15 之前):保持在段 1,且未挂起预输入 - np._local_time = np._motion_start_t + 0.10 - np._process(0.016) - _ck(np._combo_index == 1 and not np._is_pre_input, "t=0.10s (<0.15s): still in combo 1, no pre-input") - - # 时间推进到 0.20s (在 [0.15s, 0.25s] 预输入窗内):因为空格仍按着,成功挂起预输入 - np._local_time = np._motion_start_t + 0.20 - np._process(0.016) - _ck(np._combo_index == 1 and np._is_pre_input, "t=0.20s in [0.15, 0.25]: pre-input successfully latched") - - # 时间推进到 0.26s (>0.25s NextComboTime):ComboProcess 到点,自动推进到第 2 段 (段号 15)! - np._local_time = np._motion_start_t + 0.26 - np._process(0.016) - _ck(np._combo_index == 2 and not np._is_pre_input, "t=0.26s (>0.25s): ComboProcess advances to Combo 2 (index %d)" % np._combo_index) - _ck(pv.attack_motions.size() >= 2 and pv.attack_motions[-1][1] == 15, "Combo 2 plays motion 15") - - # 时间推进到段 2 的 0.20s 预输入窗 - np._local_time = np._motion_start_t + 0.20 - np._process(0.016) - _ck(np._combo_index == 2 and np._is_pre_input, "Combo 2 t=0.20s: pre-input latched for Combo 3") - - # 时间推进到段 2 的 0.26s -> 自动切入第 3 段 (段号 16)! - np._local_time = np._motion_start_t + 0.26 - np._process(0.016) - _ck(np._combo_index == 3 and not np._is_pre_input, "Combo 3 triggered (index %d, motion 16)" % np._combo_index) - - # 时间推进到段 3 的 0.20s -> 预输入 - np._local_time = np._motion_start_t + 0.20 - np._process(0.016) - _ck(np._combo_index == 3 and np._is_pre_input, "Combo 3 t=0.20s: pre-input latched for Combo 4") - - # 时间推进到段 3 的 0.26s -> 自动切入最后一段 (段 4,段号 17)! - np._local_time = np._motion_start_t + 0.26 - np._process(0.016) - _ck(np._combo_index == 4 and not np._is_pre_input, "Combo 4 triggered (index %d, motion 17, last segment)" % np._combo_index) - - # 段 4 播放完毕 (0.8s),回到 wait,重置 combo 索引 - np._local_time = np._motion_start_t + 0.85 - pv.anim.md = {"has_combo_input": false, "duration": 2.0} - pv.motion_bound.emit("wait") - _ck(np._combo_index == 0, "Motion finished back to wait -> combo_index resets to 0") - - # 下一帧因为空格一直按住,立即无缝开启下一轮 Combo 1 - np._process(0.016) - _ck(np._combo_index == 1, "Next frame: Space still held down seamlessly restarts Combo 1") - - # 松开空格 - np.set_attack_key(false) - - print("\n--- Test 2: Rapid Space Key Tapping ---") - # 模拟连续按空格(每次按下抬起) - np._clear_combo() - np.set_attack_key(true) - np._process(0.016) - _ck(np._combo_index == 1, "Tap 1: starts Combo 1") - np.set_attack_key(false) - - # 在直接输入窗内 (t=0.30s > 0.25s) 再次按空格 - np._local_time = np._motion_start_t + 0.30 - np.set_attack_key(true) - np._process(0.016) - _ck(np._combo_index == 2, "Tap 2 at t=0.30s (>NextComboTime) immediately advances to Combo 2") - np.set_attack_key(false) diff --git a/project/test_combo_continuous_flow.gd.uid b/project/test_combo_continuous_flow.gd.uid deleted file mode 100644 index 44fb7827..00000000 --- a/project/test_combo_continuous_flow.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ybydxusj7kf1 diff --git a/project/test_consumables_parity.gd b/project/test_consumables_parity.gd deleted file mode 100644 index b1e1effa..00000000 --- a/project/test_consumables_parity.gd +++ /dev/null @@ -1,126 +0,0 @@ -# test_consumables_parity.gd —— 验证方向 4:日月神水自充能、龙神特药与传送卷轴 40250 1:1 对齐 -extends SceneTree - -const ConsumableSystem = preload("res://consumable_system.gd") - -var _passed := 0 -var _failed := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_consumables_parity (Direction 4: Consumables & Scrolls 1:1) ===") - test_elixir_identification() - test_elixir_toggle_active() - test_elixir_recovery_and_exhaustion() - test_dragon_god_potions() - test_town_and_return_scrolls() - - print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed]) - if _failed > 0: - printerr("TEST FAILED!") - quit(1) - else: - print("ALL TESTS PASSED!") - quit(0) - -func test_elixir_identification() -> void: - print("\n--- Test 1: Sun & Moon Elixirs Identification ---") - _assert(ConsumableSystem.is_elixir(72723), "72723 (Sun Elixir S) is an elixir") - _assert(ConsumableSystem.is_hp_elixir(72723), "72723 is an HP elixir") - _assert(not ConsumableSystem.is_sp_elixir(72723), "72723 is not an SP elixir") - - _assert(ConsumableSystem.is_elixir(72728), "72728 (Moon Elixir M) is an elixir") - _assert(ConsumableSystem.is_sp_elixir(72728), "72728 is an SP elixir") - _assert(not ConsumableSystem.is_hp_elixir(72728), "72728 is not an HP elixir") - - _assert(not ConsumableSystem.is_elixir(27001), "27001 Red Potion is not an elixir") - _assert(ConsumableSystem.is_dragon_god_potion(71027), "71027 is Dragon God Potion") - _assert(not ConsumableSystem.is_dragon_god_potion(72723), "Elixir is not Dragon God Potion") - -func test_elixir_toggle_active() -> void: - print("\n--- Test 2: Sun/Moon Elixir Toggle Active (40250 char_item.cpp:3950) ---") - var elixir_hp := {"vnum": 72723, "sockets": [0, 0, 0]} - - # Initially inactive - var toggle1 := ConsumableSystem.toggle_elixir(elixir_hp) - _assert(toggle1.ok and toggle1.active, "1st click activates HP elixir") - _assert(elixir_hp.sockets[0] == 1, "Socket 0 is set to 1 (Active)") - _assert(toggle1.affect_type == ConsumableSystem.AFFECT_AUTO_HP_RECOVERY, "Affect type is AFFECT_AUTO_HP_RECOVERY (534)") - _assert(elixir_hp.sockets[2] == 1000000, "Initialized capacity to 1,000,000 HP") - - # 2nd click deactivates - var toggle2 := ConsumableSystem.toggle_elixir(elixir_hp) - _assert(toggle2.ok and not toggle2.active, "2nd click deactivates HP elixir") - _assert(elixir_hp.sockets[0] == 0, "Socket 0 is set to 0 (Inactive)") - -func test_elixir_recovery_and_exhaustion() -> void: - print("\n--- Test 3: Auto Recovery Tick & Capacity Depletion (40250 char_item.cpp:7326) ---") - # Elixir with 500 capacity, initially active - var small_elixir := {"vnum": 72723, "sockets": [1, 0, 500]} - - # Player at 800/1000 HP (needs 200) - var tick1 := ConsumableSystem.process_elixir_recovery(small_elixir, 800, 1000) - _assert(tick1.recovered == 200, "Recovered 200 HP to full") - _assert(not tick1.exhausted, "Elixir not exhausted yet") - _assert(small_elixir.sockets[1] == 200, "Used amount increased to 200") - _assert(tick1.remaining == 300, "Remaining capacity is 300") - - # Player at 1000/1000 HP (full HP) - var tick_full := ConsumableSystem.process_elixir_recovery(small_elixir, 1000, 1000) - _assert(tick_full.recovered == 0, "No recovery when already at full HP") - _assert(small_elixir.sockets[1] == 200, "Used amount unchanged") - - # Player at 600/1000 HP (needs 400, but only 300 left in elixir) - var tick_deplete := ConsumableSystem.process_elixir_recovery(small_elixir, 600, 1000) - _assert(tick_deplete.recovered == 300, "Recovered all remaining 300 HP") - _assert(tick_deplete.exhausted, "Elixir is now exhausted (used >= capacity)") - _assert(small_elixir.sockets[1] == 500, "Used amount reached full 500") - -func test_dragon_god_potions() -> void: - print("\n--- Test 4: Dragon God Potions Buff Specs (71027..71030) ---") - var p_att := ConsumableSystem.use_dragon_god_potion(ConsumableSystem.VNUM_DRAGON_GOD_ATTACK) - _assert(p_att.ok and p_att.bonus_type == "POINT_ATT_BONUS" and p_att.bonus_value == 15, - "Dragon God Attack gives +15% attack bonus") - _assert(p_att.duration == 1800.0, "Dragon God Attack lasts 1800 seconds (30 minutes)") - - var p_def := ConsumableSystem.use_dragon_god_potion(ConsumableSystem.VNUM_DRAGON_GOD_DEFENSE) - _assert(p_def.ok and p_def.bonus_type == "POINT_DEF_BONUS" and p_def.bonus_value == 15, - "Dragon God Defense gives +15% defense bonus") - - var p_life := ConsumableSystem.use_dragon_god_potion(ConsumableSystem.VNUM_DRAGON_GOD_LIFE) - _assert(p_life.ok and p_life.bonus_type == "POINT_MAX_HP_PCT" and p_life.bonus_value == 20, - "Dragon God Life gives +20% HP bonus") - - var p_mana := ConsumableSystem.use_dragon_god_potion(ConsumableSystem.VNUM_DRAGON_GOD_MANA) - _assert(p_mana.ok and p_mana.bonus_type == "POINT_MAX_SP_PCT" and p_mana.bonus_value == 20, - "Dragon God Mana gives +20% SP bonus") - -func test_town_and_return_scrolls() -> void: - print("\n--- Test 5: Town Scroll (22001) & Return Scroll (22010) ---") - # Town Scroll 22001 - var dummy_item := {"vnum": ConsumableSystem.VNUM_TOWN_SCROLL} - var res_town := ConsumableSystem.use_warp_scroll(ConsumableSystem.VNUM_TOWN_SCROLL, dummy_item, 100.0, 200.0) - _assert(res_town.ok and res_town.action == "TOWN_WARP" and res_town.consumed, - "Town scroll triggers town warp and is consumed") - - # Return Scroll 22010 (Empty) -> Records current coordinate (1234, 5678) - var return_scroll := {"vnum": ConsumableSystem.VNUM_RETURN_SCROLL, "sockets": [0, 0, 0]} - var res_rec := ConsumableSystem.use_warp_scroll(ConsumableSystem.VNUM_RETURN_SCROLL, return_scroll, 1234.0, 5678.0, 2) - _assert(res_rec.ok and res_rec.action == "RECORDED", "Empty return scroll records coordinate") - _assert(not res_rec.consumed, "Recording coordinate does not consume the scroll") - _assert(return_scroll.sockets[0] == 1234 and return_scroll.sockets[1] == 5678 and return_scroll.sockets[2] == 2, - "Recorded coordinates saved into sockets [1234, 5678, map 2]") - - # Return Scroll 22010 (Recorded) -> Warps and is consumed - var res_warp := ConsumableSystem.use_warp_scroll(ConsumableSystem.VNUM_RETURN_SCROLL, return_scroll, 999.0, 888.0, 1) - _assert(res_warp.ok and res_warp.action == "WARP_TO_COORDS", "Recorded return scroll warps to saved location") - _assert(res_warp.consumed, "Using recorded return scroll consumes the scroll") - _assert(res_warp.target_x == 1234 and res_warp.target_y == 5678 and res_warp.target_map == 2, - "Target coordinates match recorded values") diff --git a/project/test_consumables_parity.gd.uid b/project/test_consumables_parity.gd.uid deleted file mode 100644 index 31002b43..00000000 --- a/project/test_consumables_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cqioew3chvlla diff --git a/project/test_death_restart_parity.gd b/project/test_death_restart_parity.gd deleted file mode 100644 index 8dc3f6a6..00000000 --- a/project/test_death_restart_parity.gd +++ /dev/null @@ -1,161 +0,0 @@ -# test_death_restart_parity.gd —— 死亡结算与复活面板 1:1 回归测试套件 -# 运行方式: -# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_death_restart_parity.gd -extends SceneTree - -const DeathUI = preload("res://ui/death_ui.gd") - -class MockClient extends Node: - signal entity_dead(vid: int) - signal vitals_changed(vid: int) - signal phase_changed(phase: String) - var sent_chat_commands: Array[String] = [] - var entity_data := {"hp": 0, "dead": true} - - func get_main_vid() -> int: return 1 - func get_entity(vid: int) -> Dictionary: - return entity_data if vid == 1 else {} - func say(type: int, text: String) -> bool: - sent_chat_commands.append(text) - return true - -var _passed := 0 -var _failed := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_death_restart_parity (Direction 4: RestartDialog 1:1) ===") - _test_dialog_layout() - _test_dead_phase_is_not_a_ui_entry() - _test_10s_cooldown_countdown() - _test_restart_commands() - _test_auto_close_on_revive() - _finish() - -func _test_dialog_layout() -> void: - print("\n--- Test 1: 40250 200x88 Geometry & Button Placement ---") - var client := MockClient.new() - get_root().add_child(client) - - var death_ui := DeathUI.new() - get_root().add_child(death_ui) - death_ui.setup(client, get_root()) - - _assert(death_ui.is_open() == false, "Restart dialog initially closed") - death_ui.show_dialog() - _assert(death_ui.is_open() == true, "Restart dialog opened") - _assert(death_ui._board.size == Vector2(200, 88), "Board size is 200x88 (restartdialog.py)") - _assert(death_ui._btn_restart_here.position == Vector2(10, 17), "Restart here button position is (10, 17)") - _assert(death_ui._btn_restart_town.position == Vector2(10, 47), "Restart town button position is (10, 47)") - - death_ui.queue_free() - client.queue_free() - -func _test_dead_phase_is_not_a_ui_entry() -> void: - print("\n--- Test 1b: PHASE_DEAD is consumed without a phase transition ---") - var client := MockClient.new() - get_root().add_child(client) - - var death_ui := DeathUI.new() - get_root().add_child(death_ui) - death_ui.setup(client, get_root()) - # 40250 RecvPhasePacket(PHASE_DEAD) intentionally does not call a phase - # setter. The dialog must be entered by GC_DEAD/entity_dead instead. - client.phase_changed.emit("dead") - _assert(not death_ui.is_open(), "phase_changed('dead') does not open RestartDialog") - client.entity_dead.emit(1) - _assert(death_ui.is_open(), "GC_DEAD/entity_dead opens RestartDialog") - - death_ui.queue_free() - client.queue_free() - -func _test_10s_cooldown_countdown() -> void: - print("\n--- Test 2: 10s Restart Here Cooldown Protection (cmd_general.cpp) ---") - var client := MockClient.new() - get_root().add_child(client) - - var death_ui := DeathUI.new() - get_root().add_child(death_ui) - death_ui.setup(client, get_root()) - death_ui.show_dialog() - - _assert(death_ui._btn_restart_here.disabled == true, "Restart here button initially disabled") - _assert("10 秒" in death_ui._btn_restart_here.text, "Displays initial 10s cooldown") - - # Advance 5.0 seconds - death_ui._process(5.0) - _assert(death_ui._btn_restart_here.disabled == true, "Still disabled at 5 seconds") - _assert("5 秒" in death_ui._btn_restart_here.text, "Countdown shows 5 seconds remaining") - - # Advance remaining 5.0 seconds - death_ui._process(5.1) - _assert(death_ui._btn_restart_here.disabled == false, "Enabled once countdown reaches 0") - _assert(death_ui._btn_restart_here.text == "原地复活", "Text becomes '原地复活' with no cooldown suffix") - - death_ui.queue_free() - client.queue_free() - -func _test_restart_commands() -> void: - print("\n--- Test 3: Restart Chat Commands (/restart_town & /restart_here) ---") - var client := MockClient.new() - get_root().add_child(client) - - var death_ui := DeathUI.new() - get_root().add_child(death_ui) - death_ui.setup(client, get_root()) - death_ui.show_dialog() - - # Restart town button is clickable immediately - death_ui._btn_restart_town.pressed.emit() - _assert(client.sent_chat_commands.size() == 1, "Town button sent command") - _assert(client.sent_chat_commands[0] == "/restart_town", "Sent command is '/restart_town'") - - # Restart here button while in cooldown should NOT send packet - death_ui._here_cooldown_remaining = 8.0 - death_ui._on_restart_here_pressed() - _assert(client.sent_chat_commands.size() == 1, "No command sent while restart_here in cooldown") - - # Restart here button after cooldown sends /restart_here - death_ui._here_cooldown_remaining = 0.0 - death_ui._on_restart_here_pressed() - _assert(client.sent_chat_commands.size() == 2, "Restart here sent command after cooldown") - _assert(client.sent_chat_commands[1] == "/restart_here", "Sent command is '/restart_here'") - - death_ui.queue_free() - client.queue_free() - -func _test_auto_close_on_revive() -> void: - print("\n--- Test 4: Auto-Close on Revive ---") - var client := MockClient.new() - get_root().add_child(client) - - var death_ui := DeathUI.new() - get_root().add_child(death_ui) - death_ui.setup(client, get_root()) - death_ui.show_dialog() - _assert(death_ui.is_open() == true, "Death dialog open") - - # Simulate player revived (HP > 0, dead = false) - client.entity_data = {"hp": 100, "dead": false} - client.vitals_changed.emit(1) - - _assert(death_ui.is_open() == false, "Death dialog automatically hides when player is revived") - - death_ui.queue_free() - client.queue_free() - -func _finish() -> void: - print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - quit(0) - else: - printerr("SOME TESTS FAILED!\n") - quit(1) diff --git a/project/test_death_restart_parity.gd.uid b/project/test_death_restart_parity.gd.uid deleted file mode 100644 index c2cb69dc..00000000 --- a/project/test_death_restart_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dukwekgepb3hj diff --git a/project/test_drop_and_cursor_parity.gd b/project/test_drop_and_cursor_parity.gd deleted file mode 100644 index eac2af63..00000000 --- a/project/test_drop_and_cursor_parity.gd +++ /dev/null @@ -1,161 +0,0 @@ -extends SceneTree - -const CursorManager = preload("res://ui/cursor_manager.gd") -const GroundItems = preload("res://ui/ground_items.gd") -const PlayerController = preload("res://player_controller.gd") - -var _fail := 0 - -func _ck(condition: bool, message: String) -> void: - if not condition: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - print("--- Running test_drop_and_cursor_parity ---") - await _run() - preload("res://ui/ui_assets.gd").clear_cache() - if _fail == 0: - print("test_drop_and_cursor_parity passed with 0 failures!") - quit(0) - else: - printerr("%d failure(s)" % _fail) - quit(1) - -func _run() -> void: - test_mapcoord_heading() - test_cursor_manager() - await test_ground_item_models_and_picking() - -func test_mapcoord_heading() -> void: - print("Testing MapCoord heading alignment with 40250...") - _ck(is_equal_approx(MapCoord.heading_to_yaw(0.0), 0.0), "0 deg heading must be 0 yaw (South)") - _ck(is_equal_approx(MapCoord.heading_to_yaw(90.0), PI * 0.5), "90 deg heading must be PI/2 yaw (East)") - _ck(is_equal_approx(MapCoord.heading_to_yaw(180.0), PI), "180 deg heading must be PI yaw (North)") - _ck(is_equal_approx(MapCoord.heading_to_yaw(270.0), PI * 1.5), "270 deg heading must be 3PI/2 yaw (West)") - - _ck(is_equal_approx(MapCoord.yaw_to_heading(0.0), 0.0), "0 yaw must be 0 heading") - _ck(is_equal_approx(MapCoord.yaw_to_heading(PI * 0.5), 90.0), "PI/2 yaw must be 90 heading") - _ck(is_equal_approx(MapCoord.yaw_to_heading(PI), 180.0), "PI yaw must be 180 heading") - print("OK: MapCoord heading parity verified") - -func test_cursor_manager() -> void: - print("Testing CursorManager custom cursor registration...") - var cm := CursorManager.new() - get_root().add_child(cm) - var root_assets: String = AssetRoot.path() if AssetRoot.available() else "" - cm.setup(root_assets) - - cm.set_cursor("NORMAL") - _ck(cm.get_cursor() == "NORMAL", "shape is NORMAL") - _ck(cm._godot_shape("NORMAL") == Input.CURSOR_ARROW, "godot shape is CURSOR_ARROW") - - cm.set_cursor("ATTACK") - _ck(cm.get_cursor() == "ATTACK", "shape is ATTACK") - _ck(cm._godot_shape("ATTACK") == Input.CURSOR_CROSS, "godot shape is CURSOR_CROSS") - - cm.set_cursor("PICK") - _ck(cm.get_cursor() == "PICK", "shape is PICK") - _ck(cm._godot_shape("PICK") == Input.CURSOR_CAN_DROP, "godot shape is CURSOR_CAN_DROP") - - cm.set_cursor("TALK") - _ck(cm.get_cursor() == "TALK", "shape is TALK") - _ck(cm._godot_shape("TALK") == Input.CURSOR_POINTING_HAND, "godot shape is CURSOR_POINTING_HAND") - - cm.set_cursor("DOOR") - _ck(cm.get_cursor() == "DOOR", "shape is DOOR") - _ck(cm._godot_shape("DOOR") == Input.CURSOR_HELP, "godot shape is CURSOR_HELP") - - cm.set_cursor("CANT_GO") - _ck(cm.get_cursor() == "CANT_GO", "shape is CANT_GO") - _ck(cm._godot_shape("CANT_GO") == Input.CURSOR_FORBIDDEN, "godot shape is CURSOR_FORBIDDEN") - - cm.set_cursor("CAMERA_ROTATE") - _ck(cm.get_cursor() == "CAMERA_ROTATE", "shape is CAMERA_ROTATE") - _ck(cm._godot_shape("CAMERA_ROTATE") == Input.CURSOR_BDIAGSIZE, "godot shape is CURSOR_BDIAGSIZE") - - cm.set_cursor("CHAIR") - _ck(cm.get_cursor() == "CHAIR", "shape is CHAIR") - _ck(cm._godot_shape("CHAIR") == Input.CURSOR_WAIT, "godot shape is CURSOR_WAIT") - - cm.set_cursor("HSIZE") - _ck(cm.get_cursor() == "HSIZE", "shape is HSIZE") - _ck(cm._godot_shape("HSIZE") == Input.CURSOR_HSIZE, "godot shape is CURSOR_HSIZE") - - cm.queue_free() - for shape_idx in range(17): - Input.set_custom_mouse_cursor(null, shape_idx) - CursorManager.UiAssets.clear_cache() - print("OK: CursorManager parity verified") - -class DummyClient extends Node: - signal ground_item_added(d: Dictionary) - signal ground_item_removed(vid: int) - var picked_vids: Array[int] = [] - func pickup_item(vid: int) -> bool: - picked_vids.append(vid) - return true - -func test_ground_item_models_and_picking() -> void: - print("Testing GroundItems model instantiation and picking...") - var mount := Node3D.new() - get_root().add_child(mount) - - var cam := Camera3D.new() - get_root().add_child(cam) - cam.position = Vector3(0, 5, 5) - cam.look_at_from_position(Vector3(0, 5, 5), Vector3.ZERO, Vector3.UP) - cam.current = true - - var client := DummyClient.new() - get_root().add_child(client) - - var gi := GroundItems.new() - get_root().add_child(gi) - var player := Node3D.new() - player.position = Vector3.ZERO - get_root().add_child(player) - - gi.assets_override = "__none__" - gi.setup(client, mount, func() -> Node: return player, null, null, - func() -> Camera3D: return cam) - - gi._on_added({"vid": 1001, "vnum": 1, "owner": "Player", "pos": Vector3.ZERO}) - _ck(gi._by_vid.has(1001), "Drop 1001 must exist") - var drop1: Node3D = gi._by_vid[1001] - var m1 = drop1.get_node_or_null("model") - if m1 == null: - m1 = drop1.get_child(0) - _ck(m1 != null, "Drop must have a visual child (Metin2Model or BoxMesh fallback)") - - gi._on_added({"vid": 1002, "vnum": 10, "owner": "Player", "pos": Vector3(10, 0, 0)}) - _ck(gi._by_vid.has(1002), "Drop 1002 must exist") - - await process_frame - await process_frame - - var sp1 := cam.unproject_position(drop1.global_position + Vector3(0, 0.35, 0)) - var picked_vid := gi.pick_at(cam, sp1) - _ck(picked_vid == 1001, "pick_at should pick drop 1001 at its screen projection") - - var pc := PlayerController.new() - get_root().add_child(pc) - pc.player = player - pc.camera = cam - pc.ground_items = gi - - var emitted := [0] - pc.ground_item_clicked.connect(func(vid: int): emitted[0] = vid) - pc._on_click(sp1) - _ck(emitted[0] == 1001, "PlayerController._on_click must emit ground_item_clicked with picked vid") - - gi.clear_for_map_change() - pc.queue_free() - player.queue_free() - gi.queue_free() - client.queue_free() - cam.queue_free() - mount.queue_free() - await process_frame - await process_frame - print("OK: GroundItems model and picking verified") diff --git a/project/test_drop_and_cursor_parity.gd.uid b/project/test_drop_and_cursor_parity.gd.uid deleted file mode 100644 index 4d882141..00000000 --- a/project/test_drop_and_cursor_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://deor6cp2mo5xv diff --git a/project/test_exp_fly_parity.gd b/project/test_exp_fly_parity.gd deleted file mode 100644 index a8251e71..00000000 --- a/project/test_exp_fly_parity.gd +++ /dev/null @@ -1,213 +0,0 @@ -# test_exp_fly_parity.gd -# 检验 40250 怪物阵亡经验光球飞行、自导向追踪与吸收动效 -extends SceneTree - -const FlyObject = preload("res://fly_object.gd") -const NetPlay = preload("res://net_play.gd") -const NetWorld = preload("res://net_world.gd") -const PlayerCtl = preload("res://player_controller.gd") - -func _init() -> void: - print("--- 开始测试 40250 怪物阵亡经验光球飞行与吸收动效 (test_exp_fly_parity) ---") - var fails: int = 0 - - # 1. 验证 40250 FLY 常量与编号映射 - if FlyObject.FLY_EXP != 1: - printerr("FAIL: FLY_EXP 应为 1,实际为: ", FlyObject.FLY_EXP) - fails += 1 - if FlyObject.FLY_HP_SMALL != 15 or FlyObject.FLY_SP_SMALL != 4: - printerr("FAIL: FLY_HP/SP 常量映射错误") - fails += 1 - - # 2. 模拟 FlyObject 经验球追踪、命中与吸收信号 - var mount := Node3D.new() - get_root().add_child(mount) - - var mgr := FlyObject.new() - mgr.manual_step = true - mount.add_child(mgr) - mgr.setup(mount) - - var player_target := Node3D.new() - mount.add_child(player_target) - player_target.position = Vector3(5, 1, 0) - - var exp_absorbed_count := [0] - mgr.exp_absorbed.connect(func(_wp: Vector3): - exp_absorbed_count[0] += 1 - ) - - var d := FlyObject.FlyData.new() - d.init_vel = 15.0 - d.flat_range = 30.0 - d.bomb_range = 0.8 - d.is_homing = true - d.homing_start_time = 0.05 - d.homing_max_angle = 140.0 - d.maintain_parallel = true - - var start_pos := Vector3(0, 1, 0) - var inst = mgr.spawn(start_pos, player_target, false, d, FlyObject.FLY_EXP) - if mgr.active_count() != 1: - printerr("FAIL: 经验球未成功注册进飞行物管理器!") - fails += 1 - if inst.skill_index != FlyObject.FLY_EXP: - printerr("FAIL: 实例技能索引不符: ", inst.skill_index) - fails += 1 - - # 推进仿真时间步,让光球命中玩家目标 - for i in range(40): - mgr.step(0.02) - if mgr.active_count() == 0: - break - - if exp_absorbed_count[0] != 1: - printerr("FAIL: 经验球到达玩家时未发出 exp_absorbed 信号,次数: ", exp_absorbed_count[0]) - fails += 1 - if mgr.active_count() != 0: - printerr("FAIL: 经验球命中后未从活跃队列销毁!") - fails += 1 - - # 3. 验证 net_world._on_fly(FLY_EXP) 按 40250 CreateIndexedFly 生成 1 颗聚能光球 - var nw := NetWorld.new() - mount.add_child(nw) - nw.parent = mount - nw._fly_mgr = mgr - - # 模拟注册实体坐标 - var fake_client := _FakeClient.new() - fake_client.ents[100] = {"vid": 100, "pos": Vector3(0, 0, 0)} # 怪物 - fake_client.ents[1] = {"vid": 1, "pos": Vector3(4, 0, 0)} # 玩家 - nw.client = fake_client - - var mob_node := Node3D.new() - mob_node.position = Vector3(0, 0, 0) - mount.add_child(mob_node) - nw._by_vid[100] = mob_node - - var player_node := Node3D.new() - player_node.position = Vector3(4, 0, 0) - mount.add_child(player_node) - nw.set_local_node(player_node) - nw.set_local_vid(1) - # The local actor is owned by GameScene rather than _by_vid. During the - # first GC_CREATE_FLY after a phase transition the EntityStore row may not - # have been mirrored yet; 40250 still resolves the main instance directly. - fake_client.ents.erase(1) - - # 触发 40250 官方 _on_fly(FLY_EXP, 100, 1) - nw._on_fly(FlyObject.FLY_EXP, 100, 1) - if mgr.active_count() != 1: - printerr("FAIL: _on_fly(FLY_EXP) 应按一次 GC_CREATE_FLY 发射 1 颗经验聚能光球,实际: ", mgr.active_count()) - fails += 1 - for orb in mgr._instances: - if not orb._is_object or orb._target_obj != player_node: - printerr("FAIL: 经验球没有锁定本地主角节点,无法产生吸收效果") - fails += 1 - break - - # 40250 RecvCreateFlyPacket requires both CInstanceBase pointers. A stale - # EntityStore row is not enough to create a visual projectile when the - # remote scene node has not been built yet. - mgr.clear_for_map_change() - fake_client.ents[2] = {"vid": 2, "pos": Vector3(6, 0, 0)} - nw._on_fly(FlyObject.FLY_EXP, 100, 2) - if mgr.active_count() != 0: - printerr("FAIL: 远端终点节点缺失时不应仅凭 EntityStore 坐标创建飞行物") - fails += 1 - fake_client.ents.erase(2) - - var saved_mob_node: Node3D = nw._by_vid[100] - nw._by_vid.erase(100) - nw._on_fly(FlyObject.FLY_EXP, 100, 1) - if mgr.active_count() != 0: - printerr("FAIL: 远端起点节点缺失时不应创建飞行物") - fails += 1 - nw._by_vid[100] = saved_mob_node - - # 清空飞行物 - for i in range(50): - mgr.step(0.02) - if mgr.active_count() == 0: - break - # The indexed EXP resource intentionally starts with a spreading phase and - # only enables homing at one second; isolate the offline helper assertion - # from that server-created instance instead of assuming the old synthetic - # speed reaches the target in 1s. - mgr.clear_for_map_change() - - # 4. 验证 net_world.spawn_exp_fly 接口 - nw.spawn_exp_fly(Vector3(2, 0, 2), player_node, 3) - if mgr.active_count() != 3: - printerr("FAIL: spawn_exp_fly 未成功生成 3 颗光球,实际: ", mgr.active_count()) - fails += 1 - - for i in range(50): - mgr.step(0.02) - if mgr.active_count() == 0: - break - - # 5. 验证 net_play 经验变动不重复合成服务端已经下发的经验球 - var np := NetPlay.new() - mount.add_child(np) - var pc := PlayerCtl.new() - pc.player = player_node - np.setup(fake_client, pc, nw, null) - - # 初始经验 - np._on_points({"exp": 1000, "next_exp": 5000, "level": 10}) - if mgr.active_count() != 0: - printerr("FAIL: 初始点数同步不应触发获取经验!") - fails += 1 - - # 杀死怪物获得 500 经验:视觉只由上面的 GC_CREATE_FLY 路径生成。 - np._last_dead_mob_pos = Vector3(3, 0, 0) - np._on_points({"exp": 1500, "next_exp": 5000, "level": 10}) - if mgr.active_count() != 0: - printerr("FAIL: POINT_EXP 不应重复生成经验光球,实际数量: ", mgr.active_count()) - fails += 1 - - # All flying instances are now test-owned and no longer needed. Break the - # manager's handler/target cycles before synchronously destroying the fixture; - # this mirrors CFlyingManager::DeleteAllInstances before application exit. - mgr.clear_for_map_change() - if is_instance_valid(pc): - pc.free() - mount.free() - # Do not keep RefCounted fixtures alive on the _init stack until quit(). - d = null - inst = null - player_target = null - nw = null - np = null - pc = null - mgr = null - mount = null - await process_frame - - if fails == 0: - print("PASS: test_exp_fly_parity (6/6 全部通过)") - quit(0) - else: - printerr("FAILED: test_exp_fly_parity (", fails, " 项失败)") - quit(1) - -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 chat(type: int, vid: int, text: String) - signal damage(vid: int, amount: int, flag: int) - signal target_info(t: Dictionary) - signal fly_cue(type: int, start_vid: int, end_vid: int) - signal fly_targeting(s_vid: int, t_vid: int, pos: Vector2, append: bool) - - var ents := {} - func is_in_game() -> bool: return true - func get_main_vid() -> int: return 1 - func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) - func get_entities() -> Array: return ents.values() diff --git a/project/test_exp_fly_parity.gd.uid b/project/test_exp_fly_parity.gd.uid deleted file mode 100644 index b5d9c6ab..00000000 --- a/project/test_exp_fly_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://7b1f7h78cy3b diff --git a/project/test_full_skill_flow.gd b/project/test_full_skill_flow.gd deleted file mode 100644 index cfea1285..00000000 --- a/project/test_full_skill_flow.gd +++ /dev/null @@ -1,66 +0,0 @@ -extends SceneTree - -const FakeClient = preload("res://net_client_fake.gd") -const GameScene = preload("res://game_scene.gd") -const SkillTable = preload("res://ui/skill_table.gd") -const Quickbar = preload("res://ui/quickbar.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: test_full_skill_flow (fixture + scene + action packet order)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - var assets := AssetRoot.path() - var client := FakeClient.new() - get_root().add_child(client) - var table := SkillTable.new() - var loaded := false - for lang in ["en", "common"]: - if table.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)): - loaded = true - break - _ck(loaded, "skill table fixture loads") - if not loaded: - return - client.entities[2000] = {"vid": 2000, "name": "Wolf", "race": 101, "kind": 2, - "ch_type": 2, "pos": Vector3(1, 0, 0), "pos_cm": Vector3(100, 0, 0), - "hp": 100, "max_hp": 100, "dead": false} - client.target_vid = 2000 - - var host := CanvasLayer.new() - get_root().add_child(host) - var qb := Quickbar.new() - get_root().add_child(qb) - qb.setup(client, table, host, func() -> Node: return null, assets) - qb.assign(0, "skill", 1, false) - qb.activate(0) - _ck(client.calls.size() >= 2, "skill activation reaches fake client") - if client.calls.size() >= 2: - _ck(client.calls[0][0] == "cast_skill" and client.calls[1][0] == "use_skill", - "40250 order: FUNC_SKILL precedes CG_USE_SKILL") - _ck(client.calls[1][1] == 1 and client.calls[1][2] == 2000, - "skill intent carries skill id and resolved target") - - var gs := GameScene.new() - get_root().add_child(gs) - await gs.setup(client, "", "no_such_map") - await process_frame - _ck(gs.net_play != null and gs.player != null, "GameScene full flow is assembled") - _ck(gs.quickbar == null, "empty asset setup does not create a duplicate skill UI") - gs.queue_free() - qb.queue_free() - host.queue_free() - client.queue_free() - await process_frame diff --git a/project/test_full_skill_flow.gd.uid b/project/test_full_skill_flow.gd.uid deleted file mode 100644 index 88b3d09e..00000000 --- a/project/test_full_skill_flow.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://1who0lbv1uod diff --git a/project/test_ground_pickup_parity.gd b/project/test_ground_pickup_parity.gd deleted file mode 100644 index ffe5dd8f..00000000 --- a/project/test_ground_pickup_parity.gd +++ /dev/null @@ -1,217 +0,0 @@ -# test_ground_pickup_parity.gd —— 40250 地面拾取与掉落物 1:1 纯净无头自检套件 -# 验证: -# 1. 40250 GetCloseItem 的 300cm 近似距离判定 -# 2. GetCloseItem 同距时按最高 VID 取胜 -# 3. 40250 SendClickItemPacket 的队伍成员 / anti-flag 所有权判定 -# 4. 500ms 发包冷却节流 (s_dwNextTCPTime) -# 5. 所有权校验 (c_szOwnerName != GetName() 拦截 + cannot_pick_item 信号) -# 6. 稀有道具与高强装备垂直光柱 (Drop Light Beam) -# 7. ArrangeTextTail 防重叠标签排布 -extends SceneTree - -const GroundItems = preload("res://ui/ground_items.gd") - -class FakeClient extends Node: - signal ground_item_added(d: Dictionary) - signal ground_item_removed(vid: int) - - var calls: Array = [] - var main_vid := 1001 - var char_name := "HeroPlayer" - var party: Array = [] - - func is_in_game() -> bool: return true - func get_main_vid() -> int: return main_vid - func get_entity(vid: int) -> Dictionary: - if vid == main_vid: - return {"name": char_name} - return {} - func get_party() -> Array: return party - func pickup_item(vid: int) -> bool: - calls.append(["pickup", vid]) - return true - -class FakeProto extends Node: - func item(vnum: int) -> Dictionary: - if vnum >= 10 and vnum <= 19: - return {"name": "Sword+%d" % (vnum - 10), "type": 1} # 武器 - if vnum == 25040: - return {"name": "Blessing Scroll", "type": 3} - return {"name": "Item %d" % vnum, "type": 0} - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -var _to_free: Array[Node] = [] - -func _init() -> void: - _run() - for n in _to_free: - if is_instance_valid(n): - n.queue_free() - await process_frame - await process_frame - if _fail == 0: - print("PASS: test_ground_pickup_parity (40250 GroundItems 1:1 parity)") - quit(0) - else: - printerr("%d check(s) failed in test_ground_pickup_parity" % _fail) - quit(1) - -func _run() -> void: - var mount := Node3D.new() - get_root().add_child(mount) - var fc := FakeClient.new() - get_root().add_child(fc) - var proto := FakeProto.new() - get_root().add_child(proto) - - var player_node := Node3D.new() - player_node.position = Vector3(0.0, 0.0, 0.0) - get_root().add_child(player_node) - - var gi := GroundItems.new() - get_root().add_child(gi) - _to_free = [gi, player_node, proto, fc, mount] - gi.assets_override = "__none__" - gi.setup(fc, mount, func(): return player_node, proto) - - var cannot_picks: Array[String] = [] - gi.cannot_pick_item.connect(func(owner: String): cannot_picks.append(owner)) - - # --- 1. 距离与最近优先判定 --- - # 在地面添加 3 个物品: - # vid 1: 距离 2.0m (在 3m 内) - # vid 2: 距离 1.0m (最近) - # vid 3: 距离 4.5m (超出 3m) - gi._on_added({"vid": 1, "vnum": 10, "pos": Vector3(0.0, 0.0, 2.0), "owner": ""}) - gi._on_added({"vid": 2, "vnum": 11, "pos": Vector3(1.0, 0.0, 0.0), "owner": ""}) - gi._on_added({"vid": 3, "vnum": 12, "pos": Vector3(0.0, 0.0, 4.5), "owner": ""}) - - # 尝试拾取:必须拾取最近的 vid 2 - var picked := gi.try_pickup() - _ck(picked == 2, "1. 优先拾取最近物品 (vid 2, 距离 1.0m)") - _ck(fc.calls == [["pickup", 2]], "2. 发出 pickup_item(2)") - fc.calls.clear() - - # --- 2. 500ms 节流拦截 --- - # 立即再次拾取 (间隔 < 500ms) - var throttled := gi.try_pickup() - _ck(throttled == 0, "3. 500ms 内连续拾取被节流拦截") - _ck(fc.calls.is_empty(), "4. 未重复发送网络包") - - # 服务端移除已被拾取的 vid 2 - gi._on_removed(2) - - # 手动快进冷却定时器 - gi._next_tcp_time_ms = 0 - picked = gi.try_pickup() - _ck(picked == 1, "5. 500ms 冷却后拾取次近物品 (vid 1, 距离 2.0m)") - _ck(fc.calls == [["pickup", 1]], "6. 发出 pickup_item(1)") - fc.calls.clear() - - # 清理已拾取的 vid 1 - gi._on_removed(1) - - # --- 3. GetCloseItem 的固定 300cm 边界(包括骑马状态) --- - # 此时场上只剩 vid 3 (距离 4.5m) - gi._next_tcp_time_ms = 0 - picked = gi.try_pickup() - _ck(picked == 0, "7. 步行时 4.5m 超出 3.0m 拾取半径") - _ck(fc.calls.is_empty(), "8. 未发包") - - # PythonPlayerInput 的 __GetPickableDistance 虽然骑马返回 500, - # 但 CPythonItem::GetCloseItem 实际硬编码 dwCloseItemDistance > 300。 - player_node.set_meta("mounted", true) - _ck(gi.get_pickable_distance() == 5.0, "9. 骑马时拾取半径扩展为 5.0m") - gi._next_tcp_time_ms = 0 - picked = gi.try_pickup() - _ck(picked == 0, "10. 骑马时仍遵守 GetCloseItem 的 300cm 固定边界") - _ck(fc.calls.is_empty(), "11. 骑马超出 300cm 时不发 pickup_item") - fc.calls.clear() - gi._on_removed(3) - player_node.set_meta("mounted", false) - - # --- 4. 所有权保护拦截 (40250 c_szOwnerName != GetName()) --- - # 添加属于他人的物品 (owner = EnemyPlayer) - gi._on_added({"vid": 4, "vnum": 10, "pos": Vector3(0.5, 0.0, 0.5), "owner": "EnemyPlayer"}) - gi._next_tcp_time_ms = 0 - picked = gi.try_pickup() - _ck(picked == 0, "12. 非本人所有权物品被拦截") - _ck(fc.calls.is_empty(), "13. 拦截时不发包") - _ck(cannot_picks == ["EnemyPlayer"], "14. 触发 cannot_pick_item 信号") - cannot_picks.clear() - - # 添加属于自己的物品 (owner = HeroPlayer) - gi._on_added({"vid": 5, "vnum": 10, "pos": Vector3(0.2, 0.0, 0.2), "owner": "HeroPlayer"}) - gi._next_tcp_time_ms = 0 - picked = gi.try_pickup() - _ck(picked == 5, "15. 属于本人的保护物品成功拾取 (vid 5)") - _ck(fc.calls == [["pickup", 5]], "16. 正常发包") - fc.calls.clear() - gi._on_removed(4) - gi._on_removed(5) - - # --- 4b. 40250 队伍成员所有权:无 DROP/GIVE anti-flag 时允许拾取 --- - fc.party = [{"name": "PartyMate"}] - gi._on_added({"vid": 9, "vnum": 10, "pos": Vector3(0.2, 0.0, 0.2), "owner": "PartyMate"}) - gi._next_tcp_time_ms = 0 - picked = gi.try_pickup() - _ck(picked == 9, "15a. 队伍成员且无 DROP/GIVE anti-flag 的物品允许拾取") - _ck(fc.calls == [["pickup", 9]], "15b. 队伍成员物品正常发包") - fc.calls.clear() - gi._on_removed(9) - - # 队伍成员如果设置 DROP anti-flag,参考端仍然拒绝。 - gi._on_added({"vid": 10, "vnum": 20, "pos": Vector3(0.2, 0.0, 0.2), "owner": "PartyMate", "anti_flags": 1 << 7}) - gi._next_tcp_time_ms = 0 - picked = gi.try_pickup() - _ck(picked == 0, "15c. 队伍成员带 DROP anti-flag 时拒绝拾取") - _ck(fc.calls.is_empty(), "15d. anti-flag 拦截时不发包") - gi._on_removed(10) - fc.party.clear() - - # --- 4c. GetCloseItem:近似距离允许欧氏距离略超 3m 的对角点 --- - gi._on_added({"vid": 11, "vnum": 10, "pos": Vector3(2.19, 0.0, 2.19), "owner": "HeroPlayer"}) - gi._next_tcp_time_ms = 0 - picked = gi.try_pickup() - _ck(picked == 11, "15e. 40250 DISTANCE_APPROX <= 300cm 的对角物品可拾取") - fc.calls.clear() - gi._on_removed(11) - - # --- 4d. GetCloseItem:相同近似距离时最终选择最高 VID --- - gi._on_added({"vid": 20, "vnum": 10, "pos": Vector3(1.0, 0.0, 0.0), "owner": "HeroPlayer"}) - gi._on_added({"vid": 21, "vnum": 10, "pos": Vector3(-1.0, 0.0, 0.0), "owner": "HeroPlayer"}) - gi._next_tcp_time_ms = 0 - picked = gi.try_pickup() - _ck(picked == 21, "15f. 相同距离按最高 VID 选择") - fc.calls.clear() - gi._on_removed(20) - gi._on_removed(21) - - # --- 4e. 点击拾取链路使用 __OnPressItem 的 150cm 阈值 --- - gi._on_added({"vid": 22, "vnum": 10, "pos": Vector3(2.0, 0.0, 0.0), "owner": "HeroPlayer"}) - gi._next_tcp_time_ms = 0 - _ck(not gi.try_pickup_vid(22), "15g. 直接点击入口超过 150cm 时不应直接发拾取包") - _ck(fc.calls.is_empty(), "15h. 点击超出 150cm 时不发包") - gi._on_removed(22) - - # --- 5. 稀有掉落物光柱 (Drop Light Beam) --- - # 普通物品 (vnum 10 = Sword+0) - gi._on_added({"vid": 6, "vnum": 10, "pos": Vector3(1.0, 0.0, 1.0), "owner": ""}) - var n6: Node3D = gi._by_vid[6] - _ck(n6.get_node_or_null("light_beam") == null, "17. 普通装备无光柱") - - # 高强装备 (vnum 19 = Sword+9) - gi._on_added({"vid": 7, "vnum": 19, "pos": Vector3(2.0, 0.0, 1.0), "owner": ""}) - var n7: Node3D = gi._by_vid[7] - _ck(n7.get_node_or_null("light_beam") != null, "18. +9 稀有装备生成 light_beam 冲天光柱") - - # 祝福卷轴 (vnum 25040) - gi._on_added({"vid": 8, "vnum": 25040, "pos": Vector3(1.5, 0.0, 1.5), "owner": ""}) - var n8: Node3D = gi._by_vid[8] - _ck(n8.get_node_or_null("light_beam") != null, "19. 祝福卷轴生成 light_beam 光柱") - gi.clear_for_map_change() diff --git a/project/test_ground_pickup_parity.gd.uid b/project/test_ground_pickup_parity.gd.uid deleted file mode 100644 index c9b4c046..00000000 --- a/project/test_ground_pickup_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cd1lp4smh7fmx diff --git a/project/test_intro_create_parity.gd b/project/test_intro_create_parity.gd deleted file mode 100644 index 9312524a..00000000 --- a/project/test_intro_create_parity.gd +++ /dev/null @@ -1,132 +0,0 @@ -# test_intro_create_parity.gd —— 40250 3D 角色塑形创建回归测试 -extends SceneTree - -const SystemClass = preload("res://intro_create_phase.gd") - -var _passed: int = 0 -var _failed: int = 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_intro_create_parity (Batch 29 Direction 4) ===") - _test_class_gender_shape_matrix() - _test_canonical_start_stats() - _test_turntable_360_rotation() - _test_character_name_validation() - _test_successful_character_creation() - - print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - else: - printerr("SOME TESTS FAILED!\n") - quit(0 if _failed == 0 else 1) - -func _test_class_gender_shape_matrix() -> void: - print("\n--- Test 1: Class, Gender, and Shape Matrix ---") - var cr = SystemClass.new() - - _assert(cr.selected_job == SystemClass.JOB_WARRIOR, "Default job is Warrior") - _assert(cr.selected_gender == SystemClass.GENDER_MALE, "Default gender is Male") - _assert(cr.selected_shape == SystemClass.SHAPE_0, "Default shape is Shape 0") - - # Switch to Assassin, Female, Shape 1 - var s_job = cr.select_job(SystemClass.JOB_ASSASSIN) - var s_gen = cr.select_gender(SystemClass.GENDER_FEMALE) - var s_shp = cr.select_shape(SystemClass.SHAPE_1) - - _assert(s_job == true and cr.selected_job == SystemClass.JOB_ASSASSIN, "Switched to Assassin") - _assert(s_gen == true and cr.selected_gender == SystemClass.GENDER_FEMALE, "Switched to Female") - _assert(s_shp == true and cr.selected_shape == SystemClass.SHAPE_1, "Switched to Shape 1") - - # Reject invalid selections - _assert(cr.select_job(99) == false, "Reject invalid job id 99") - _assert(cr.select_gender(5) == false, "Reject invalid gender id 5") - _assert(cr.select_shape(3) == false, "Reject invalid shape id 3") - -func _test_canonical_start_stats() -> void: - print("\n--- Test 2: 40250 Canonical START_STAT Table (CON, INT, STR, DEX) ---") - var cr = SystemClass.new() - - # 1. Warrior: [4, 3, 6, 3] - cr.select_job(SystemClass.JOB_WARRIOR) - var st_w = cr.get_current_stats() - _assert(st_w["con"] == 4 and st_w["int"] == 3 and st_w["str"] == 6 and st_w["dex"] == 3, "Warrior stats: 4/3/6/3") - - # 2. Assassin: [3, 3, 4, 6] - cr.select_job(SystemClass.JOB_ASSASSIN) - var st_a = cr.get_current_stats() - _assert(st_a["con"] == 3 and st_a["int"] == 3 and st_a["str"] == 4 and st_a["dex"] == 6, "Assassin stats: 3/3/4/6") - - # 3. Sura: [3, 5, 5, 3] - cr.select_job(SystemClass.JOB_SURA) - var st_s = cr.get_current_stats() - _assert(st_s["con"] == 3 and st_s["int"] == 5 and st_s["str"] == 5 and st_s["dex"] == 3, "Sura stats: 3/5/5/3") - - # 4. Shaman: [4, 6, 3, 3] - cr.select_job(SystemClass.JOB_SHAMAN) - var st_m = cr.get_current_stats() - _assert(st_m["con"] == 4 and st_m["int"] == 6 and st_m["str"] == 3 and st_m["dex"] == 3, "Shaman stats: 4/6/3/3") - -func _test_turntable_360_rotation() -> void: - print("\n--- Test 3: Turntable 360-Degree Rotation ---") - var cr = SystemClass.new() - - cr.rotate_turntable(45.0) - _assert(cr.turntable_rotation_deg == 45.0, "Rotated by 45 degrees") - - cr.rotate_turntable(340.0) # 45 + 340 = 385 -> wraps to 25 - _assert(is_equal_approx(cr.turntable_rotation_deg, 25.0), "Rotated beyond 360 wrapped correctly to 25 degrees") - - cr.rotate_turntable(-30.0) # 25 - 30 = -5 -> wraps to 355 - _assert(is_equal_approx(cr.turntable_rotation_deg, 355.0), "Negative rotation wrapped correctly to 355 degrees") - -func _test_character_name_validation() -> void: - print("\n--- Test 4: Character Name Validation Rules ---") - var cr = SystemClass.new() - - # Empty - var r_empty = cr.validate_character_name("") - _assert(r_empty["error_code"] == "EMPTY_NAME", "Reject empty name") - - # Too short (< 2) - var r_short = cr.validate_character_name("A") - _assert(r_short["error_code"] == "INVALID_NAME_LENGTH", "Reject name length < 2") - - # Too long (> 12) - var r_long = cr.validate_character_name("VeryLongHeroNameExceeding12") - _assert(r_long["error_code"] == "INVALID_NAME_LENGTH", "Reject name length > 12") - - # Contains space - var r_space = cr.validate_character_name("Hello World") - _assert(r_space["error_code"] == "NAME_CONTAINS_SPACE", "Reject name containing spaces") - - # Banned word (GM / Admin) - var r_gm = cr.validate_character_name("[GM]Tester") - _assert(r_gm["error_code"] == "NAME_CONTAINS_BANNED_WORD", "Reject name containing [GM]") - - var r_ok = cr.validate_character_name("DarkKnight") - _assert(r_ok["valid"] == true, "Valid name DarkKnight passed") - -func _test_successful_character_creation() -> void: - print("\n--- Test 5: Successful Character Creation Payload ---") - var cr = SystemClass.new() - cr.select_job(SystemClass.JOB_WARRIOR) - cr.select_gender(SystemClass.GENDER_MALE) - cr.select_shape(SystemClass.SHAPE_0) - - var res = cr.create_character("Conan", 1) - _assert(res["success"] == true, "Created character Conan successfully") - var ch = res["character"] - _assert(ch["name"] == "Conan", "Name matches Conan") - _assert(ch["level"] == 1, "Level is 1") - _assert(ch["empire"] == 1, "Empire is 1 (Shinsoo)") - _assert(ch["stats"]["str"] == 6, "Warrior STR is 6") - _assert(ch["delete_code"] == "1234567", "Generated 7-digit delete code") diff --git a/project/test_intro_create_parity.gd.uid b/project/test_intro_create_parity.gd.uid deleted file mode 100644 index 76a33c99..00000000 --- a/project/test_intro_create_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bhsyk6ij1c7ew diff --git a/project/test_intro_empire_parity.gd b/project/test_intro_empire_parity.gd deleted file mode 100644 index 39dc2b2f..00000000 --- a/project/test_intro_empire_parity.gd +++ /dev/null @@ -1,87 +0,0 @@ -# test_intro_empire_parity.gd —— 40250 三国地貌 3D 运镜与阵营选择回归测试 -extends SceneTree - -const SystemClass = preload("res://intro_empire_phase.gd") - -var _passed: int = 0 -var _failed: int = 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_intro_empire_parity (Batch 29 Direction 2) ===") - _test_initial_empire_state() - _test_camera_target_positions_and_switching() - _test_camera_smooth_interpolation() - _test_empire_description_and_confirmation() - - print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - else: - printerr("SOME TESTS FAILED!\n") - quit(0 if _failed == 0 else 1) - -func _test_initial_empire_state() -> void: - print("\n--- Test 1: Initial Empire & Camera Initialization ---") - var emp = SystemClass.new(SystemClass.EMPIRE_SHINSOO) - - _assert(emp.selected_empire == SystemClass.EMPIRE_SHINSOO, "Initial empire is Shinsoo") - var info = emp.get_selected_empire_info() - _assert(info["capital"] == "Yongan", "Shinsoo capital is Yongan") - _assert(emp.current_cam_pos == Vector3(100.0, 50.0, -200.0), "Initial camera pos matches preset") - -func _test_camera_target_positions_and_switching() -> void: - print("\n--- Test 2: Switching Empires & Camera Target Updates ---") - var emp = SystemClass.new(SystemClass.EMPIRE_SHINSOO) - - # Switch to Chunjo (Yellow) - var sw1 = emp.select_empire(SystemClass.EMPIRE_CHUNJO) - _assert(sw1 == true, "Switched to Chunjo") - _assert(emp.target_cam_pos == Vector3(-150.0, 60.0, -180.0), "Chunjo camera target pos set") - _assert(emp.is_transitioning == true, "Camera transition activated") - - # Switch to Jinno (Blue) - var sw2 = emp.select_empire(SystemClass.EMPIRE_JINNO) - _assert(sw2 == true, "Switched to Jinno") - _assert(emp.target_cam_pos == Vector3(0.0, 80.0, -250.0), "Jinno camera target pos set") - - # Reject invalid empire - var sw_inv = emp.select_empire(99) - _assert(sw_inv == false, "Reject invalid empire id 99") - -func _test_camera_smooth_interpolation() -> void: - print("\n--- Test 3: Smooth Camera Interpolation Flow ---") - var emp = SystemClass.new(SystemClass.EMPIRE_SHINSOO) - - emp.select_empire(SystemClass.EMPIRE_JINNO) - _assert(emp.is_transitioning == true, "Transitioning flag is true") - - # Halfway step (0.5) - emp.update_camera_transition(0.5) - _assert(emp.is_transitioning == true, "Still transitioning at 50%") - _assert(emp.transition_t == 0.5, "transition_t is 0.5") - - # Complete transition (another 0.5) - emp.update_camera_transition(0.5) - _assert(emp.is_transitioning == false, "Transition completed") - _assert(emp.current_cam_pos == Vector3(0.0, 80.0, -250.0), "Camera arrived exactly at Jinno preset") - -func _test_empire_description_and_confirmation() -> void: - print("\n--- Test 4: Empire Metadata & Confirmation Phase Transition ---") - var emp = SystemClass.new(SystemClass.EMPIRE_JINNO) - - var info = emp.get_selected_empire_info() - _assert(info["desc"].contains("崇尚武道强权"), "Contains Jinno lore description") - _assert(info["trait"].contains("物理基础攻击力 +5%"), "Contains Jinno trait bonus") - - var conf = emp.confirm_empire_selection() - _assert(conf["success"] == true, "Confirmed empire selection") - _assert(conf["empire_id"] == SystemClass.EMPIRE_JINNO, "Confirmed Jinno (3)") - _assert(conf["next_phase"] == "CHARACTER_SELECT", "Routes to CHARACTER_SELECT phase") diff --git a/project/test_intro_empire_parity.gd.uid b/project/test_intro_empire_parity.gd.uid deleted file mode 100644 index d9d768be..00000000 --- a/project/test_intro_empire_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bdkpue0f7n6q0 diff --git a/project/test_intro_loading_parity.gd b/project/test_intro_loading_parity.gd deleted file mode 100644 index cbb9ddc3..00000000 --- a/project/test_intro_loading_parity.gd +++ /dev/null @@ -1,77 +0,0 @@ -# test_intro_loading_parity.gd —— 40250 官方插画过场加载与 Tip 轮播回归测试 -extends SceneTree - -const SystemClass = preload("res://intro_loading_phase.gd") - -var _passed: int = 0 -var _failed: int = 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_intro_loading_parity (Batch 30 Direction 2) ===") - _test_loading_wallpapers_and_tips() - _test_step_by_step_progress_stream() - _test_completion_flow() - - print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - else: - printerr("SOME TESTS FAILED!\n") - quit(0 if _failed == 0 else 1) - -func _test_loading_wallpapers_and_tips() -> void: - print("\n--- Test 1: Wallpapers & Tips Selection ---") - var loader = SystemClass.new() - loader.start_loading(1, 2) - - _assert(loader.current_wallpaper == SystemClass.OFFICIAL_LOADING_WALLPAPERS[1], "Selected wallpaper 1") - _assert(loader.current_tip == SystemClass.OFFICIAL_LOADING_TIPS[2], "Selected tip 2 (Biologist)") - _assert(loader.current_tip.contains("生物学家"), "Tip content matches official text") - _assert(loader.progress_percent == 0.0, "Initial progress is 0%") - _assert(loader.is_completed() == false, "Loading not completed initially") - -func _test_step_by_step_progress_stream() -> void: - print("\n--- Test 2: Step-by-Step Progress Stream (25% -> 50% -> 75% -> 100%) ---") - var loader = SystemClass.new() - loader.start_loading() - - # Step 1: Property (25%) - var s1 = loader.advance_loading_step() - _assert(s1["step"] == SystemClass.STEP_PROPERTY, "Advanced to STEP_PROPERTY") - _assert(s1["progress"] == 0.25, "Progress is 25%") - _assert(s1["status_text"].contains("25%"), "Status text displays 25%") - - # Step 2: Terrain (50%) - var s2 = loader.advance_loading_step() - _assert(s2["step"] == SystemClass.STEP_TERRAIN, "Advanced to STEP_TERRAIN") - _assert(s2["progress"] == 0.50, "Progress is 50%") - - # Step 3: Entities (75%) - var s3 = loader.advance_loading_step() - _assert(s3["step"] == SystemClass.STEP_ENTITIES, "Advanced to STEP_ENTITIES") - _assert(s3["progress"] == 0.75, "Progress is 75%") - - # Step 4: UI & Audio (100%) - var s4 = loader.advance_loading_step() - _assert(s4["step"] == SystemClass.STEP_UI_AUDIO, "Advanced to STEP_UI_AUDIO") - _assert(s4["progress"] == 1.0, "Progress reached 100%") - -func _test_completion_flow() -> void: - print("\n--- Test 3: Loading Completion Lifecycle ---") - var loader = SystemClass.new() - loader.start_loading() - - # Run all steps to finish - for i in range(5): - loader.advance_loading_step() - - _assert(loader.is_completed() == true, "Loading fully completed") - _assert(loader.progress_percent == 1.0, "Progress is 100%") diff --git a/project/test_intro_loading_parity.gd.uid b/project/test_intro_loading_parity.gd.uid deleted file mode 100644 index 7cb606fc..00000000 --- a/project/test_intro_loading_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bmovu5thonsuu diff --git a/project/test_intro_login_parity.gd b/project/test_intro_login_parity.gd deleted file mode 100644 index 3ae02aa5..00000000 --- a/project/test_intro_login_parity.gd +++ /dev/null @@ -1,96 +0,0 @@ -# test_intro_login_parity.gd —— 40250 登录与频道探测回归测试 -extends SceneTree - -const SystemClass = preload("res://intro_login_phase.gd") - -var _passed: int = 0 -var _failed: int = 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_intro_login_parity (Batch 29 Direction 1) ===") - _test_channel_selection_and_ping_states() - _test_credential_validation() - _test_login_rejections() - _test_successful_login_and_remember_account() - - print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - else: - printerr("SOME TESTS FAILED!\n") - quit(0 if _failed == 0 else 1) - -func _test_channel_selection_and_ping_states() -> void: - print("\n--- Test 1: Channel Selection & Server State Checker ---") - var login = SystemClass.new() - - _assert(login.selected_channel_id == 1, "Default channel is CH1") - - # Select CH3 - var s_ok = login.select_channel(3) - _assert(s_ok == true, "Selected CH3") - _assert(login.selected_channel_id == 3, "Current channel updated to CH3") - - # Select invalid channel - var s_fail = login.select_channel(99) - _assert(s_fail == false, "Reject selecting invalid channel 99") - - # Update state - login.update_channel_state(3, SystemClass.STATE_FULL) - _assert(login.get_channel_info(3)["state"] == SystemClass.STATE_FULL, "CH3 state updated to FULL") - -func _test_credential_validation() -> void: - print("\n--- Test 2: Credential Formatting Checks ---") - var login = SystemClass.new() - - # Empty - var r_empty = login.attempt_login("", "") - _assert(r_empty["error_code"] == "EMPTY_CREDENTIALS", "Reject empty username and password") - - # Short account name - var r_short = login.attempt_login("ab", "123") - _assert(r_short["error_code"] == "INVALID_ACCOUNT_LEN", "Reject account length < 3") - -func _test_login_rejections() -> void: - print("\n--- Test 3: Rejections (Offline, Nonexistent, Blocked, Wrong Password) ---") - var login = SystemClass.new() - - # 1. Target channel offline - login.update_channel_state(1, SystemClass.STATE_OFFLINE) - login.select_channel(1) - var r_off = login.attempt_login("admin", "password") - _assert(r_off["error_code"] == "CHANNEL_OFFLINE", "Reject login if target channel is OFFLINE") - - # Switch to normal channel - login.select_channel(2) - - # 2. Account not exist - var r_no = login.attempt_login("unknown_user", "123") - _assert(r_no["error_code"] == "ACCOUNT_NOT_EXIST", "Reject non-existent account") - - # 3. Account blocked - var r_block = login.attempt_login("banned", "123") - _assert(r_block["error_code"] == "ACCOUNT_BLOCKED", "Reject banned/blocked account") - - # 4. Wrong password - var r_pass = login.attempt_login("admin", "incorrect_pass") - _assert(r_pass["error_code"] == "WRONG_PASSWORD", "Reject wrong password") - -func _test_successful_login_and_remember_account() -> void: - print("\n--- Test 4: Successful Login & Remember Account Persist ---") - var login = SystemClass.new() - login.select_channel(2) - login.set_remember_account(true, "player") - - var r_succ = login.attempt_login("player", "123") - _assert(r_succ["success"] == true, "Login succeeded for player") - _assert(r_succ["has_characters"] == true, "Account possesses characters") - _assert(login.saved_account_name == "player", "Saved account name preserved for quick login") diff --git a/project/test_intro_login_parity.gd.uid b/project/test_intro_login_parity.gd.uid deleted file mode 100644 index 251b5946..00000000 --- a/project/test_intro_login_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://km7dpb1cw77 diff --git a/project/test_intro_select_parity.gd b/project/test_intro_select_parity.gd deleted file mode 100644 index 3102703f..00000000 --- a/project/test_intro_select_parity.gd +++ /dev/null @@ -1,124 +0,0 @@ -# test_intro_select_parity.gd —— 40250 3D 角色选择大厅回归测试 -extends SceneTree - -const SystemClass = preload("res://intro_select_phase.gd") - -var _passed: int = 0 -var _failed: int = 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_intro_select_parity (Batch 29 Direction 3) ===") - _test_slot_rotations_and_empty_selection() - _test_character_selection_and_stepping_motion() - _test_character_card_data() - _test_start_game_validation() - _test_7digit_pin_character_deletion() - - print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - else: - printerr("SOME TESTS FAILED!\n") - quit(0 if _failed == 0 else 1) - -func _test_slot_rotations_and_empty_selection() -> void: - print("\n--- Test 1: Slot Rotations & Empty Slot Handling ---") - var sel = SystemClass.new() - - _assert(sel.character_slots.size() == 4, "Total 4 character slots") - _assert(SystemClass.SLOT_ROTATION == [135.0, 225.0, 315.0, 45.0], "Official 4-slot rotation angles match") - - # Select empty slot 0 - var r0 = sel.select_slot(0) - _assert(r0["is_empty_slot"] == true, "Slot 0 is empty") - _assert(r0["action"] == "PROMPT_CREATE", "Empty slot prompts to CREATE") - _assert(sel.is_character_stepped_forward == false, "No step forward motion for empty slot") - -func _test_character_selection_and_stepping_motion() -> void: - print("\n--- Test 2: Character Selection & Step Forward Motion ---") - var sel = SystemClass.new() - var warrior = { - "name": "DragonSlayer", - "level": 75, - "job": SystemClass.JOB_WARRIOR, - "horse_level": 21, - "playtime_min": 12000, - "delete_code": "7654321", - "stats": { "con": 90, "int": 10, "str": 90, "dex": 40 } - } - sel.set_character_slot(1, warrior) - - var r1 = sel.select_slot(1) - _assert(r1["success"] == true, "Selected slot 1") - _assert(r1["is_empty_slot"] == false, "Slot 1 is populated") - _assert(sel.is_character_stepped_forward == true, "Character stepped forward (MOTION_INTRO_SELECTED)") - _assert(r1["rotation_deg"] == 225.0, "Slot 1 rotation is 225 degrees") - -func _test_character_card_data() -> void: - print("\n--- Test 3: Character Card Attributes ---") - var sel = SystemClass.new() - var sura = { - "name": "DarkMage", - "level": 60, - "job": SystemClass.JOB_SURA, - "horse_level": 11, - "playtime_min": 5000, - "stats": { "con": 50, "int": 90, "str": 40, "dex": 20 } - } - sel.set_character_slot(0, sura) - sel.select_slot(0) - - var card = sel.get_selected_character_card() - _assert(card["name"] == "DarkMage", "Card name is DarkMage") - _assert(card["level"] == 60, "Card level is 60") - _assert(card["job_name"].contains("修罗"), "Job name is Sura") - _assert(card["stats"]["int"] == 90, "INT stat is 90") - -func _test_start_game_validation() -> void: - print("\n--- Test 4: Start Game Flow ---") - var sel = SystemClass.new() - - # Start on empty slot -> Rejected - sel.select_slot(2) # empty - var r_fail = sel.start_game() - _assert(r_fail["error_code"] == "EMPTY_SLOT", "Reject start game on empty slot") - - # Populate and start - sel.set_character_slot(2, { "name": "Ninja", "level": 10, "job": 1 }) - sel.select_slot(2) - var r_ok = sel.start_game() - _assert(r_ok["success"] == true, "Start game succeeded with valid character") - _assert(r_ok["character"]["name"] == "Ninja", "Transferred character into game") - -func _test_7digit_pin_character_deletion() -> void: - print("\n--- Test 5: 7-Digit Security PIN Character Deletion ---") - var sel = SystemClass.new() - var char_to_del = { - "name": "DeleteMe", - "level": 1, - "job": 0, - "delete_code": "1234567" - } - sel.set_character_slot(3, char_to_del) - - # 1. Invalid PIN length - var r_len = sel.delete_character(3, "123") - _assert(r_len["error_code"] == "INVALID_PIN_LENGTH", "Reject deletion if PIN != 7 digits") - - # 2. Wrong PIN - var r_wrong = sel.delete_character(3, "9999999") - _assert(r_wrong["error_code"] == "WRONG_DELETE_PIN", "Reject deletion on incorrect PIN") - _assert(sel.character_slots[3] != null, "Character preserved on failed PIN") - - # 3. Correct PIN (1234567) - var r_ok = sel.delete_character(3, "1234567") - _assert(r_ok["success"] == true, "Character deleted on correct 7-digit PIN") - _assert(sel.character_slots[3] == null, "Slot 3 is now null/empty") diff --git a/project/test_intro_select_parity.gd.uid b/project/test_intro_select_parity.gd.uid deleted file mode 100644 index e1bea47e..00000000 --- a/project/test_intro_select_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dk6oehay6271o diff --git a/project/test_item_attr_parity.gd b/project/test_item_attr_parity.gd deleted file mode 100644 index 9cce86c4..00000000 --- a/project/test_item_attr_parity.gd +++ /dev/null @@ -1,138 +0,0 @@ -# test_item_attr_parity.gd —— 验证方向 1:装备附魔、洗炼与祝福宝珠 40250 1:1 对齐 -extends SceneTree - -const ItemAttrSystem = preload("res://item_attr_system.gd") - -var _passed := 0 -var _failed := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_item_attr_parity (Direction 1: Item Add/Change Bonus 1:1) ===") - test_scroll_detection() - test_can_have_attributes() - test_add_attribute_parity() - test_change_attribute_parity() - test_blessing_marble_parity() - - print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed]) - if _failed > 0: - printerr("TEST FAILED!") - quit(1) - else: - print("ALL TESTS PASSED!") - quit(0) - -func test_scroll_detection() -> void: - print("\n--- Test 1: Scroll Vnum Detection (71084, 71085, 70024) ---") - _assert(ItemAttrSystem.is_attr_scroll(71085), "71085 is detected as Add Attribute Scroll") - _assert(ItemAttrSystem.is_attr_scroll(71084), "71084 is detected as Change Attribute Scroll") - _assert(ItemAttrSystem.is_attr_scroll(70024), "70024 is detected as Blessing Marble") - _assert(not ItemAttrSystem.is_attr_scroll(27001), "Normal potion is not an attr scroll") - _assert(not ItemAttrSystem.is_attr_scroll(10), "Sword is not an attr scroll") - -func test_can_have_attributes() -> void: - print("\n--- Test 2: Target Equipment Compatibility (Weapon/Armor vs Arrow) ---") - var weapon := {"type": ItemAttrSystem.ITEM_TYPE_WEAPON, "sub_type": 0} # 剑 - var armor := {"type": ItemAttrSystem.ITEM_TYPE_ARMOR, "sub_type": 0} # 衣服 - var arrow := {"type": ItemAttrSystem.ITEM_TYPE_WEAPON, "sub_type": 5} # 箭矢 - var potion := {"type": 3, "sub_type": 0} # 消耗品 - - _assert(ItemAttrSystem.can_have_attributes(weapon), "Weapon can have attributes") - _assert(ItemAttrSystem.can_have_attributes(armor), "Armor can have attributes") - _assert(not ItemAttrSystem.can_have_attributes(arrow), "Arrow (WEAPON_ARROW) cannot have attributes") - _assert(not ItemAttrSystem.can_have_attributes(potion), "Potion cannot have attributes") - -func test_add_attribute_parity() -> void: - print("\n--- Test 3: 40250 Add Attribute Progression (1..4 Attrs & Cap) ---") - var weapon_proto := {"type": ItemAttrSystem.ITEM_TYPE_WEAPON, "sub_type": 0} - var sword_item := {"vnum": 10, "attrs": []} - - # 1st attribute - var res1 := ItemAttrSystem.apply_add_attribute(sword_item, weapon_proto, true) - _assert(res1.ok and res1.code == "SUCCESS", "1st attribute added successfully") - _assert(sword_item.attrs.size() == 1, "Sword has 1 attribute now") - - # 2nd attribute - var res2 := ItemAttrSystem.apply_add_attribute(sword_item, weapon_proto, true) - _assert(res2.ok and res2.code == "SUCCESS", "2nd attribute added successfully") - _assert(sword_item.attrs.size() == 2, "Sword has 2 attributes now") - _assert(sword_item.attrs[0].type != sword_item.attrs[1].type, "1st and 2nd attribute types are distinct (no duplicate)") - - # 3rd attribute - var res3 := ItemAttrSystem.apply_add_attribute(sword_item, weapon_proto, true) - _assert(res3.ok and res3.code == "SUCCESS", "3rd attribute added successfully") - _assert(sword_item.attrs.size() == 3, "Sword has 3 attributes now") - - # 4th attribute - var res4 := ItemAttrSystem.apply_add_attribute(sword_item, weapon_proto, true) - _assert(res4.ok and res4.code == "SUCCESS", "4th attribute added successfully") - _assert(sword_item.attrs.size() == 4, "Sword has 4 attributes now") - - # 5th attempt with normal scroll 71085 MUST fail (40250 char_item.cpp:4665 item2->GetAttributeCount() < 4) - var res5 := ItemAttrSystem.apply_add_attribute(sword_item, weapon_proto, true) - _assert(not res5.ok and res5.code == "MAX_ATTR_REACHED", "Normal scroll cannot add 5th attribute (MAX_ATTR_REACHED)") - _assert(sword_item.attrs.size() == 4, "Sword remains at 4 attributes") - -func test_change_attribute_parity() -> void: - print("\n--- Test 4: 40250 Change Attribute (Reroll All Attributes) ---") - var weapon_proto := {"type": ItemAttrSystem.ITEM_TYPE_WEAPON, "sub_type": 0} - var empty_item := {"vnum": 10, "attrs": []} - - # Fails on item with 0 attributes (40250 char_item.cpp:4560 item2->GetAttributeCount() == 0) - var res_empty := ItemAttrSystem.apply_change_attribute(empty_item, weapon_proto) - _assert(not res_empty.ok and res_empty.code == "NO_ATTRIBUTES", "Change attribute fails when item has 0 attributes") - - var geared_item := { - "vnum": 10, - "attrs": [ - {"type": 1, "value": 500}, - {"type": 15, "value": 10}, - {"type": 16, "value": 10} - ] - } - var res_reroll := ItemAttrSystem.apply_change_attribute(geared_item, weapon_proto) - _assert(res_reroll.ok and res_reroll.code == "SUCCESS", "Change attribute rerolls successfully") - _assert(geared_item.attrs.size() == 3, "Item still has exactly 3 attributes after reroll") - - # Verify all rerolled attribute types are unique - var types := {} - var has_duplicate := false - for a in geared_item.attrs: - if types.has(a.type): - has_duplicate = true - types[a.type] = true - _assert(not has_duplicate, "All rerolled attribute types are distinct") - -func test_blessing_marble_parity() -> void: - print("\n--- Test 5: 40250 Blessing Marble 70024 (5th Attribute Lock) ---") - var weapon_proto := {"type": ItemAttrSystem.ITEM_TYPE_WEAPON, "sub_type": 0} - - # Item with 3 attributes -> marble fails (40250 char_item.cpp:4738 item2->GetAttributeCount() == 4) - var item_3_attrs := { - "vnum": 10, - "attrs": [ - {"type": 1, "value": 500}, - {"type": 2, "value": 200}, - {"type": 3, "value": 4} - ] - } - var res_marble_fail := ItemAttrSystem.apply_blessing_marble(item_3_attrs, weapon_proto, true) - _assert(not res_marble_fail.ok and res_marble_fail.code == "NEED_FOUR_ATTRS", "Blessing marble fails on item with less than 4 attributes") - - # Item with 4 attributes -> marble succeeds and adds 5th - item_3_attrs.attrs.append({"type": 4, "value": 4}) - var res_marble_ok := ItemAttrSystem.apply_blessing_marble(item_3_attrs, weapon_proto, true) - _assert(res_marble_ok.ok and res_marble_ok.code == "SUCCESS", "Blessing marble successfully unlocks 5th attribute") - _assert(item_3_attrs.attrs.size() == 5, "Item now has 5 attributes") - - # Marble fails on item that already has 5 attributes - var res_marble_cap := ItemAttrSystem.apply_blessing_marble(item_3_attrs, weapon_proto, true) - _assert(not res_marble_cap.ok and res_marble_cap.code == "ALREADY_FIVE_ATTRS", "Blessing marble cannot exceed 5 attributes") diff --git a/project/test_item_attr_parity.gd.uid b/project/test_item_attr_parity.gd.uid deleted file mode 100644 index 040ea626..00000000 --- a/project/test_item_attr_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8va32e5dxn5x diff --git a/project/test_item_tooltip_parity.gd b/project/test_item_tooltip_parity.gd deleted file mode 100644 index 09fdad1f..00000000 --- a/project/test_item_tooltip_parity.gd +++ /dev/null @@ -1,256 +0,0 @@ -# test_item_tooltip_parity.gd —— 装备属性面板 40250 官方规范 100% 对齐测试。 -# 检验: -# 1. 调色板与 uitooltip.py 官方常量 1:1 对齐 -# 2. 装备标题根据是否有强化附加属性判定白装 (#F2E7C1) / 金装 (#FFC800) -# 3. 武器攻防加算与防具防御力加算 (value[1] + value[5]*2),浅绿色 (#8AB98E) -# 4. 固有属性与百分比词条格式化,带正负符号与 % -# 5. 附加属性 1~5 薄荷青绿 (#B0DFB4),6~7 浅薄荷绿 (#E1FAE1),无非官方“附加”冗余前缀 -# 6. 适用职业与性别判断,不符合时警示红 (#E67976) -# 7. 等级限制判断,玩家等级不足时警示红 (#E67976) -# 8. 魂石凹槽图文解析与空槽/损坏槽 -# 9. ItemTooltipView 控件九宫格 ThinBoard 背景、居中对齐与文字描边 -extends SceneTree - -const ItemTooltip = preload("res://ui/item_tooltip.gd") -const ItemTooltipView = preload("res://ui/item_tooltip_view.gd") - -class MockProto extends Node: - var items: Dictionary = {} - func item(vnum: int) -> Dictionary: - return items.get(vnum, {}) - -var _failed := 0 -var _pass_count := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _pass_count += 1 - print("PASS: %s" % msg) - else: - _failed += 1 - printerr("FAIL: %s" % msg) - -func _init() -> void: - print("=== Starting test_item_tooltip_parity ===") - _test_palette_constants() - _test_weapon_model_parity() - _test_armor_model_parity() - _test_attrs_coloring_and_naming() - _test_wearable_job_and_level_warnings() - _test_socket_stone_resolution() - _test_item_tooltip_view_ui_structure() - - if _failed == 0: - print("\n=== ALL %d ITEM TOOLTIP PARITY TESTS PASSED ===" % _pass_count) - quit(0) - else: - printerr("\n=== %d ITEM TOOLTIP PARITY TESTS FAILED ===" % _failed) - quit(1) - -# --- Test 1: 调色板与 40250 原版常量一致 --- -func _test_palette_constants() -> void: - print("\n--- Test 1: 40250 Palette Constants ---") - # uitooltip.py: TITLE_COLOR = grp.GenerateColor(0.9490, 0.9058, 0.7568, 1.0) - _assert(is_equal_approx(ItemTooltip.TITLE_COLOR.r, 0.9490) and is_equal_approx(ItemTooltip.TITLE_COLOR.g, 0.9058), - "TITLE_COLOR matches 40250 (0.9490, 0.9058, 0.7568)") - # uitooltip.py: SPECIAL_TITLE_COLOR = grp.GenerateColor(1.0, 0.7843, 0.0, 1.0) - _assert(is_equal_approx(ItemTooltip.SPECIAL_TITLE_COLOR.r, 1.0) and is_equal_approx(ItemTooltip.SPECIAL_TITLE_COLOR.g, 0.7843), - "SPECIAL_TITLE_COLOR matches 40250 (1.0, 0.7843, 0.0)") - # uitooltip.py: POSITIVE_COLOR = grp.GenerateColor(0.5411, 0.7254, 0.5568, 1.0) - _assert(is_equal_approx(ItemTooltip.POSITIVE_COLOR.r, 0.5411) and is_equal_approx(ItemTooltip.POSITIVE_COLOR.g, 0.7254), - "POSITIVE_COLOR matches 40250 (0.5411, 0.7254, 0.5568)") - # uitooltip.py: SPECIAL_POSITIVE_COLOR = grp.GenerateColor(0.6911, 0.8754, 0.7068, 1.0) - _assert(is_equal_approx(ItemTooltip.SPECIAL_POSITIVE_COLOR.r, 0.6911) and is_equal_approx(ItemTooltip.SPECIAL_POSITIVE_COLOR.g, 0.8754), - "SPECIAL_POSITIVE_COLOR matches 40250 (0.6911, 0.8754, 0.7068)") - # uitooltip.py: SPECIAL_POSITIVE_COLOR2 = grp.GenerateColor(0.8824, 0.9804, 0.8824, 1.0) - _assert(is_equal_approx(ItemTooltip.SPECIAL_POSITIVE_COLOR2.r, 0.8824) and is_equal_approx(ItemTooltip.SPECIAL_POSITIVE_COLOR2.g, 0.9804), - "SPECIAL_POSITIVE_COLOR2 matches 40250 (0.8824, 0.9804, 0.8824)") - # uitooltip.py: DISABLE_COLOR = grp.GenerateColor(0.9, 0.4745, 0.4627, 1.0) - _assert(is_equal_approx(ItemTooltip.DISABLE_COLOR.r, 0.9) and is_equal_approx(ItemTooltip.DISABLE_COLOR.g, 0.4745), - "DISABLE_COLOR matches 40250 (0.9, 0.4745, 0.4627)") - # uitooltip.py: PRICE_COLOR = 0xffFFB96D - _assert(is_equal_approx(ItemTooltip.PRICE_COLOR.r, 1.0) and is_equal_approx(ItemTooltip.PRICE_COLOR.b, 0.4275), - "PRICE_COLOR matches 40250 (0xffFFB96D)") - -# --- Test 2: 武器数值计算与白装/金装标题颜色 --- -func _test_weapon_model_parity() -> void: - print("\n--- Test 2: Weapon Model & Attack Values ---") - var builder := ItemTooltip.new() - var sword_proto := { - "vnum": 19, - "name": "Sword+9", - "locale_name": "战斗剑 +9", - "type": 1, # WEAPON - "sub_type": 0, # WEAPON_SWORD - "values": [0, 50, 70, 100, 120, 15], # min_magic=50+15=65, max_magic=70+15=85, min_atk=100+15=115, max_atk=120+15=135 - "limits": [{"type": 1, "value": 30}], - "applies": [{"type": 7, "value": 15}], # 攻击速度 +15% - "anti_flags": 0, - } - - # 1. 普通白装 (无 attrs):标题颜色为 TITLE_COLOR - var plain_model := builder.build_model(19, 1, sword_proto, {}) - _assert(plain_model["title"] == "战斗剑 +9", "Title is 战斗剑 +9") - _assert(plain_model["title_color"] == ItemTooltip.TITLE_COLOR, "Plain weapon title color is TITLE_COLOR") - _assert(plain_model["stats"].size() == 2, "Weapon has 2 stats (attack & magic attack)") - _assert(plain_model["stats"][0]["text"] == "攻击力 115 - 135", "Attack power calculation matches (100+15 - 120+15)") - _assert(plain_model["stats"][0]["color"] == ItemTooltip.POSITIVE_COLOR, "Attack power is POSITIVE_COLOR (green)") - _assert(plain_model["stats"][1]["text"] == "魔法攻击 65 - 85", "Magic attack calculation matches (50+15 - 70+15)") - _assert(plain_model["applies"][0]["text"] == "攻击速度 +15%", "Attack speed has % symbol") - _assert(plain_model["applies"][0]["color"] == ItemTooltip.POSITIVE_COLOR, "Attack speed bonus is POSITIVE_COLOR") - - # 2. 带有强化附加属性 (蓝字):标题颜色升级为 SPECIAL_TITLE_COLOR (金黄色) - var enchanted_instance := { - "attrs": [ - {"type": 5, "value": 12}, # STR +12 - {"type": 15, "value": 10}, # 暴击概率 +10% - ] - } - var enchanted_model := builder.build_model(19, 1, sword_proto, enchanted_instance) - _assert(enchanted_model["title_color"] == ItemTooltip.SPECIAL_TITLE_COLOR, - "Weapon with bonus attributes has SPECIAL_TITLE_COLOR (#FFC800)") - -# --- Test 3: 防具防御力公式加算 --- -func _test_armor_model_parity() -> void: - print("\n--- Test 3: Armor Defense Formula ---") - var builder := ItemTooltip.new() - var armor_proto := { - "vnum": 11209, - "name": "Armor+9", - "locale_name": "修罗战甲 +9", - "type": 2, # ARMOR - "sub_type": 0, # ARMOR_BODY - "values": [15, 80, 0, 0, 0, 8], # magic_def=15, def = 80 + 8*2 = 96 - "limits": [{"type": 1, "value": 42}], - "applies": [{"type": 8, "value": -5}], # 移动速度 -5% - "anti_flags": (1 << 2) | (1 << 3) | (1 << 5), # 仅修罗可穿 (ANTIFLAG_WARRIOR | ANTIFLAG_ASSASSIN | ANTIFLAG_SHAMAN) - } - var model := builder.build_model(11209, 1, armor_proto, {}) - _assert(model["stats"].size() == 2, "Armor has 2 stats") - _assert(model["stats"][0]["text"] == "防御力 96", "Armor defense formula (value[1] + value[5]*2 = 80 + 16 = 96)") - _assert(model["stats"][0]["color"] == ItemTooltip.POSITIVE_COLOR, "Defense is POSITIVE_COLOR") - _assert(model["stats"][1]["text"] == "魔法防御 15", "Magic defense matches value[0]") - _assert(model["applies"][0]["text"] == "移动速度 -5%", "Moving speed negative apply has %") - _assert(model["applies"][0]["color"] == ItemTooltip.NEGATIVE_COLOR, "Negative moving speed is NEGATIVE_COLOR (red)") - -# --- Test 4: 附加属性配色 (1~5条薄荷青绿,6~7条浅薄荷绿) 与去除非原版“附加”字样 --- -func _test_attrs_coloring_and_naming() -> void: - print("\n--- Test 4: Bonus Attributes Colors & Clean Naming ---") - var builder := ItemTooltip.new() - var proto := {"type": 1, "values": [0, 0, 0, 10, 20, 0]} - var instance := { - "attrs": [ - {"type": 1, "value": 2000}, # 最大生命 +2000 - {"type": 5, "value": 12}, # 力量 +12 - {"type": 15, "value": 10}, # 暴击概率 +10% - {"type": 16, "value": 10}, # 穿透概率 +10% - {"type": 17, "value": 15}, # 对人族伤害 +15% - {"type": 71, "value": 8}, # 技能伤害 +8% (第6条) - {"type": 72, "value": 12}, # 普通攻击伤害 +12% (第7条) - ] - } - var model := builder.build_model(10, 1, proto, instance) - _assert(model["attrs"].size() == 7, "Model parsed 7 bonus attributes") - _assert(model["attrs"][0]["text"] == "最大生命 +2000", "Attribute 1 formatted cleanly without '附加' prefix") - _assert(model["attrs"][0]["color"] == ItemTooltip.SPECIAL_POSITIVE_COLOR, "Attribute 1 is SPECIAL_POSITIVE_COLOR (#B0DFB4)") - _assert(model["attrs"][2]["text"] == "暴击概率 +10%", "Attribute 3 (percent) has % sign") - _assert(model["attrs"][4]["color"] == ItemTooltip.SPECIAL_POSITIVE_COLOR, "Attribute 5 is SPECIAL_POSITIVE_COLOR") - _assert(model["attrs"][5]["text"] == "技能伤害 +8%", "Attribute 6 has % sign") - _assert(model["attrs"][5]["color"] == ItemTooltip.SPECIAL_POSITIVE_COLOR2, "Attribute 6 is SPECIAL_POSITIVE_COLOR2 (#E1FAE1)") - _assert(model["attrs"][6]["color"] == ItemTooltip.SPECIAL_POSITIVE_COLOR2, "Attribute 7 is SPECIAL_POSITIVE_COLOR2 (#E1FAE1)") - -# --- Test 5: 穿戴职业与等级不足红字警示 --- -func _test_wearable_job_and_level_warnings() -> void: - print("\n--- Test 5: Wearable Jobs & Level Warning ---") - var builder := ItemTooltip.new() - var proto := { - "type": 1, - "values": [0, 0, 0, 10, 20, 0], - "limits": [{"type": 1, "value": 50}], # 要求等级 50 - "anti_flags": (1 << 3) | (1 << 4) | (1 << 5), # 仅战士可穿 (非刺客/修罗/萨满) - } - - # 1. 玩家等级不足 (当前 35 < 50): 等级限制飘红 - var ctx_low_level := {"level": 35, "job": 0, "sex": 0} # 战士,35级 - var model_low := builder.build_model(10, 1, proto, {}, ctx_low_level) - _assert(model_low["limits"][0]["text"] == "要求等级: 50", "Limit text is 要求等级: 50") - _assert(model_low["limits"][0]["color"] == ItemTooltip.DISABLE_COLOR, "Low level triggers DISABLE_COLOR (red)") - _assert(model_low["wearable"]["jobs"] == "战士", "Only 战士 listed in wearable") - _assert(model_low["wearable"]["jobs_color"] == ItemTooltip.NORMAL_COLOR, "Warrior can wear, jobs_color is NORMAL_COLOR") - - # 2. 玩家职业不符 (当前是刺客 job=1): 职业限制飘红 - var ctx_wrong_job := {"level": 60, "job": 1, "sex": 0} # 刺客,60级 - var model_wrong := builder.build_model(10, 1, proto, {}, ctx_wrong_job) - _assert(model_wrong["limits"][0]["color"] == ItemTooltip.NORMAL_COLOR, "Level 60 >= 50, limit is NORMAL_COLOR") - _assert(model_wrong["wearable"]["jobs_color"] == ItemTooltip.DISABLE_COLOR, "Assassin cannot wear warrior sword, jobs_color is DISABLE_COLOR (red)") - -# --- Test 6: 魂石凹槽图文解析与空槽/损坏槽 --- -func _test_socket_stone_resolution() -> void: - print("\n--- Test 6: Socket Stones Resolution ---") - var proto_node := MockProto.new() - proto_node.items[28430] = { - "vnum": 28430, - "name": "Stone_of_Speed+4", - "locale_name": "加速之石 +4", - "applies": [{"type": 8, "value": 30}], # 移动速度 +30% - } - var builder := ItemTooltip.new() - var assets := AssetRoot.path() - builder.setup(assets, "en", proto_node) - - var sword_proto := {"type": 1, "values": [0, 0, 0, 10, 20, 0]} - var instance := { - "sockets": [28430, 0, -1] # 1: 镶嵌加速之石, 2: 未使用空槽, 3: 损坏槽 - } - var model := builder.build_model(10, 1, sword_proto, instance) - _assert(model["sockets"].size() == 3, "Weapon has 3 socket entries") - _assert(model["sockets"][0]["type"] == "stone", "Socket 0 is stone") - _assert(model["sockets"][0]["name"] == "加速之石 +4", "Socket 0 resolved stone name 加速之石 +4") - _assert(model["sockets"][0]["affect"] == "移动速度 +30%", "Socket 0 resolved stone affect 移动速度 +30%") - _assert(model["sockets"][0]["affect_color"] == ItemTooltip.POSITIVE_COLOR, "Stone affect is POSITIVE_COLOR") - _assert(model["sockets"][1]["type"] == "empty", "Socket 1 is empty slot") - _assert(model["sockets"][1]["name"] == "未使用的凹槽", "Empty socket displays 未使用的凹槽") - _assert(model["sockets"][2]["type"] == "broken", "Socket 2 is broken") - _assert(model["sockets"][2]["name"] == "损坏的凹槽", "Broken socket displays 损坏的凹槽") - _assert(model["sockets"][2]["name_color"] == ItemTooltip.DISABLE_COLOR, "Broken socket is DISABLE_COLOR (red)") - - proto_node.free() - -# --- Test 7: ItemTooltipView UI 控件结构与 ThinBoard 呈现 --- -func _test_item_tooltip_view_ui_structure() -> void: - print("\n--- Test 7: ItemTooltipView UI Structure & ThinBoard ---") - var assets := AssetRoot.path() - var view := ItemTooltipView.new() - view.setup(assets) - - var model := { - "title": "屠龙刀 +9", - "title_color": ItemTooltip.SPECIAL_TITLE_COLOR, - "count": 1, - "desc": "上古神兵", - "summary": "天下无双", - "limits": [{"text": "要求等级: 75", "color": ItemTooltip.NORMAL_COLOR}], - "stats": [{"text": "攻击力 250 - 310", "color": ItemTooltip.POSITIVE_COLOR}], - "applies": [{"text": "半人伤害 +15%", "color": ItemTooltip.POSITIVE_COLOR}], - "attrs": [{"text": "暴击概率 +10%", "color": ItemTooltip.SPECIAL_POSITIVE_COLOR}], - "wearable": {"header": "[ 穿戴职业 ]", "jobs": "战士", "jobs_color": ItemTooltip.NORMAL_COLOR, "gender": ""}, - "sockets": [ - {"type": "stone", "name": "致命之石 +4", "affect": "暴击概率 +8%", "vnum": 28431, "name_color": ItemTooltip.NORMAL_COLOR, "affect_color": ItemTooltip.POSITIVE_COLOR} - ], - "price": 250000, - "realtime": "", - } - - view.populate_from_model(model) - - _assert(view.custom_minimum_size.x >= 190.0, "Tooltip custom_minimum_size.x >= 190px standard") - var vbox: VBoxContainer = view._vbox - _assert(vbox != null, "VBoxContainer created") - _assert(vbox.get_child_count() >= 8, "VBox contains all rendered sections") - - var title_lbl: Label = vbox.get_child(0) - _assert(title_lbl.text == "屠龙刀 +9", "Title text is 屠龙刀 +9") - _assert(title_lbl.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER, "Title is centered") - _assert(title_lbl.get_theme_constant("outline_size") == 2, "Title has outline_size = 2") - - view.free() diff --git a/project/test_item_tooltip_parity.gd.uid b/project/test_item_tooltip_parity.gd.uid deleted file mode 100644 index 1cdf2179..00000000 --- a/project/test_item_tooltip_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://do13v5tcxtj1h diff --git a/project/test_levelup_combat_parity.gd b/project/test_levelup_combat_parity.gd deleted file mode 100644 index b4817335..00000000 --- a/project/test_levelup_combat_parity.gd +++ /dev/null @@ -1,175 +0,0 @@ -# test_levelup_combat_parity.gd —— 40250 升级流与战斗打击感 1:1 纯净无头自检套件 -# 验证: -# 1. POINT_LEVEL 跃迁升级检测 -# 2. EFFECT_LEVELUP 冲天金光粒子挂载 (level_up_beam) -# 3. levelup1_1.wav 升级号角音效播放 -# 4. 升级瞬间 100% HP 与 SP 回满规则 -# 5. HUD 等级与加点通知 -# 6. 暴击 DAMAGE_CRITICAL 镜头瞬时微震 (camera.shake) -# 7. 暴击大号金色高亮飘字与普通飘字样式分流 -extends SceneTree - -const NetPlay = preload("res://net_play.gd") -const NetWorld = preload("res://net_world.gd") -const DamageEffect = preload("res://damage_effect.gd") - -class FakeAudio extends Node: - var calls: Array[String] = [] - func play_ui(name: String) -> void: - calls.append(name) - -class FakeCamera extends Camera3D: - var shake_calls: Array = [] - func shake(strength: float, decay := 8.0) -> void: - shake_calls.append([strength, decay]) - -class FakePlayerController extends Node: - signal target_selected(target: Node) - signal ground_item_clicked(vid: int) - signal hover_entity_changed(vid: int) - signal moved(pos: Vector3) - signal anim_state(state: String) - var player: Node3D - -class FakeHud extends Node: - var vitals: Array = [] - var levels: Array[int] = [] - var levelup_events: Array[int] = [] - - func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void: - vitals.append([hp, max_hp, sp, max_sp]) - func set_stamina(_st: int, _max_st: int) -> void: pass - func set_exp(_exp: int, _next_exp: int) -> void: pass - func set_level(lvl: int) -> void: - levels.append(lvl) - func set_energy(_e: int, _max_e: int) -> void: pass - func on_level_up(lvl: int) -> void: - levelup_events.append(lvl) - -class FakeClient extends Node: - signal entity_spawned(e: Dictionary) - signal entity_despawned(vid: int) - signal entity_main_set(vid: int) - signal vitals_changed(vid: int) - signal entity_dead(vid: int) - signal damage(vid: int, amount: int, flag: int) - signal points_changed(p: Dictionary) - signal chat(type: int, vid: int, msg: String) - signal entity_moved(vid: int) - signal target_info(info: Dictionary) - - func is_in_game() -> bool: return true - func get_main_vid() -> int: return 1001 - func get_entity(_vid: int) -> Dictionary: return {} - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -var _to_free: Array[Node] = [] - -func _init() -> void: - _run() - for n in _to_free: - if is_instance_valid(n): - n.queue_free() - if _fail == 0: - print("PASS: test_levelup_combat_parity (40250 LevelUp & Combat Parity)") - quit(0) - else: - printerr("%d check(s) failed in test_levelup_combat_parity" % _fail) - quit(1) - -func _run() -> void: - var fc := FakeClient.new() - get_root().add_child(fc) - var fa := FakeAudio.new() - get_root().add_child(fa) - var fcam := FakeCamera.new() - get_root().add_child(fcam) - var hud := FakeHud.new() - get_root().add_child(hud) - - var player := Node3D.new() - player.name = "Player" - get_root().add_child(player) - - var pc := FakePlayerController.new() - pc.player = player - get_root().add_child(pc) - - var mount := Node3D.new() - get_root().add_child(mount) - - var nw: Node = NetWorld.new() - get_root().add_child(nw) - nw.setup(fc, mount) - nw.set_camera(fcam) - - var np: Node = NetPlay.new() - get_root().add_child(np) - np.setup(fc, pc, nw, hud, fa) - np.camera = fcam - _to_free = [np, nw, mount, pc, player, hud, fcam, fa, fc] - - var levelup_signals: Array[int] = [] - np.level_up.connect(func(lvl: int): levelup_signals.append(lvl)) - - # --- 1. 初始点数同步 (不触发升级) --- - np._on_points({ - "level": 1, - "hp": 200, "max_hp": 300, - "sp": 50, "max_sp": 100, - "exp": 0, "next_exp": 1000, - }) - _ck(levelup_signals.is_empty(), "1. 初始 Lv.1 同步不触发升级信号") - _ck(fa.calls.is_empty(), "2. 初始不播放升级音效") - _ck(player.get_node_or_null("level_up_beam") == null, "3. 初始无升级光柱") - _ck(hud.levels == [1], "4. HUD 记录初始等级") - hud.vitals.clear() - - # --- 2. 角色等级跃迁 (Lv.1 -> Lv.2) --- - # 在残血状态下升级:当前 HP 80 / Max HP 350 - np._on_points({ - "level": 2, - "hp": 80, "max_hp": 350, - "sp": 20, "max_sp": 120, - "exp": 120, "next_exp": 2500, - }) - _ck(levelup_signals == [2], "5. 等级增长触发 level_up 信号 (Lv.2)") - _ck(fa.calls.has("levelup1_1.wav"), "6. 播放 40250 专属升级音效 levelup1_1.wav") - _ck(player.get_node_or_null("level_up_beam") != null, "7. 角色脚底挂载冲天金光特效 (level_up_beam)") - _ck(hud.levelup_events == [2], "8. 通知 HUD 刷新升级事件") - - # 40250 核心规则:升级瞬间 HP / SP 立即 100% 满状态恢复 - # 检查 hud.set_vitals 是否收到了满血满蓝调用 [350, 350, 120, 120] - var got_full_restore := false - for vit in hud.vitals: - if vit[0] == 350 and vit[1] == 350 and vit[2] == 120 and vit[3] == 120: - got_full_restore = true - break - _ck(got_full_restore, "9. 40250 规则:升级瞬间 HP/SP 立即全满回血回蓝") - - # --- 3. 战斗暴击反馈 (DAMAGE_CRITICAL) --- - # 普通受击:flag = DAMAGE_NORMAL - fcam.shake_calls.clear() - nw._on_damage(2001, 150, DamageEffect.DAMAGE_NORMAL) - _ck(fcam.shake_calls.is_empty(), "10. 普通伤害不震屏") - - # 暴击命中:flag = DAMAGE_NORMAL | DAMAGE_CRITICAL - nw._on_damage(2001, 450, DamageEffect.DAMAGE_NORMAL | DamageEffect.DAMAGE_CRITICAL) - _ck(not fcam.shake_calls.is_empty(), "11. 暴击 DAMAGE_CRITICAL 触发镜头震颤") - _ck(fcam.shake_calls[0][0] == 0.05, "12. 震颤振幅精确匹配 0.05m") - - # 暴击飘字样式验证 - nw._show_damage = true - nw._process_damage_queue() - var dmg_node: Node3D = mount.get_node_or_null("dmg_2001") - # 如果没有 DDS 纹理降级至 Label3D - if dmg_node: - var lbl := dmg_node.get_node_or_null("Label3D") as Label3D - if lbl: - _ck(lbl.modulate == Color(1.0, 0.85, 0.2), "13. 暴击飘字为金色") - _ck(lbl.font_size == 46, "14. 暴击字体放大至 46px") diff --git a/project/test_levelup_combat_parity.gd.uid b/project/test_levelup_combat_parity.gd.uid deleted file mode 100644 index 9aa89e6d..00000000 --- a/project/test_levelup_combat_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dmm000k7dc43b diff --git a/project/test_metin_socket_parity.gd b/project/test_metin_socket_parity.gd deleted file mode 100644 index 7ebd7589..00000000 --- a/project/test_metin_socket_parity.gd +++ /dev/null @@ -1,118 +0,0 @@ -# test_metin_socket_parity.gd —— 验证方向 2:魔石镶嵌、碎石残渣与打孔清除 40250 1:1 对齐 -extends SceneTree - -const MetinSocketSystem = preload("res://metin_socket_system.gd") - -var _passed := 0 -var _failed := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_metin_socket_parity (Direction 2: Metin Socketing & Cleaning 1:1) ===") - test_metin_vnum_identification() - test_equipment_target_rules() - test_socket_metin_success_and_broken() - test_duplicate_metin_prevention() - test_clean_broken_sockets_parity() - - print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed]) - if _failed > 0: - printerr("TEST FAILED!") - quit(1) - else: - print("ALL TESTS PASSED!") - quit(0) - -func test_metin_vnum_identification() -> void: - print("\n--- Test 1: Metin Stone & Scroll Identification ---") - _assert(MetinSocketSystem.is_metin_stone(28430), "28430 (Stone of Penetration+4) is a Metin Stone") - _assert(MetinSocketSystem.is_metin_stone(28442), "28442 (Stone of Defense+4) is a Metin Stone") - _assert(MetinSocketSystem.is_metin_stone(28033), "28033 (Stone of Warrior+0) is a Metin Stone") - _assert(not MetinSocketSystem.is_metin_stone(10), "Sword 10 is not a Metin Stone") - _assert(not MetinSocketSystem.is_metin_stone(28960), "Broken stone 28960 is not a Metin stone to socket") - _assert(MetinSocketSystem.is_clean_socket_scroll(71006), "71006 is Clean Socket Scroll") - _assert(not MetinSocketSystem.is_clean_socket_scroll(71085), "71085 is not clean socket scroll") - -func test_equipment_target_rules() -> void: - print("\n--- Test 2: Target Equipment Restrictions (Body Armor / Weapon vs Shield/Arrow) ---") - var body_armor := {"type": MetinSocketSystem.ITEM_TYPE_ARMOR, "sub_type": 0} # ARMOR_BODY - var helmet := {"type": MetinSocketSystem.ITEM_TYPE_ARMOR, "sub_type": 1} # ARMOR_HEAD - var weapon := {"type": MetinSocketSystem.ITEM_TYPE_WEAPON, "sub_type": 0} # WEAPON_SWORD - var arrow := {"type": MetinSocketSystem.ITEM_TYPE_WEAPON, "sub_type": 5} # WEAPON_ARROW - var stone_proto := {"type": MetinSocketSystem.ITEM_TYPE_METIN} - - var r_body := MetinSocketSystem.can_attach_metin(28442, stone_proto, body_armor) - _assert(r_body.ok, "Can attach metin stone to body armor") - - var r_helmet := MetinSocketSystem.can_attach_metin(28442, stone_proto, helmet) - _assert(not r_helmet.ok and r_helmet.code == "ARMOR_NOT_BODY", "Cannot attach metin stone to helmet (must be body)") - - var r_weapon := MetinSocketSystem.can_attach_metin(28430, stone_proto, weapon) - _assert(r_weapon.ok, "Can attach metin stone to weapon") - - var r_arrow := MetinSocketSystem.can_attach_metin(28430, stone_proto, arrow) - _assert(not r_arrow.ok and r_arrow.code == "WEAPON_ARROW", "Cannot attach metin stone to arrows") - -func test_socket_metin_success_and_broken() -> void: - print("\n--- Test 3: 40250 Socketing Success vs Broken Fragment (28960) ---") - var weapon_proto := {"type": MetinSocketSystem.ITEM_TYPE_WEAPON, "sub_type": 0} - var stone_proto := {"type": MetinSocketSystem.ITEM_TYPE_METIN} - - # Sword with 3 open sockets: [1, 1, 1] - var sword := {"vnum": 10, "sockets": [1, 1, 1]} - - # 1st stone: force success (28430 Penetration+4) - var res1 := MetinSocketSystem.socket_metin_stone(28430, stone_proto, sword, weapon_proto, true, false) - _assert(res1.ok and res1.code == "SUCCESS", "1st metin stone socketed successfully") - _assert(sword.sockets[0] == 28430, "Socket 0 contains 28430") - _assert(sword.sockets[1] == 1 and sword.sockets[2] == 1, "Socket 1 and 2 remain open (1)") - - # 2nd stone: force failure -> creates broken metin stone 28960 - var res2 := MetinSocketSystem.socket_metin_stone(28431, stone_proto, sword, weapon_proto, false, true) - _assert(not res2.ok and res2.code == "BROKEN_METIN", "2nd metin stone broke on failure") - _assert(sword.sockets[1] == MetinSocketSystem.ITEM_BROKEN_METIN_VNUM, "Socket 1 is now blocked by broken fragment 28960") - _assert(sword.sockets[2] == 1, "Socket 2 remains open (1)") - - # 3rd stone: force success (28437 Monster+4) into remaining slot 2 - var res3 := MetinSocketSystem.socket_metin_stone(28437, stone_proto, sword, weapon_proto, true, false) - _assert(res3.ok and res3.code == "SUCCESS", "3rd metin stone socketed into remaining slot 2") - _assert(sword.sockets[2] == 28437, "Socket 2 contains 28437") - - # 4th stone: no available sockets - var res4 := MetinSocketSystem.socket_metin_stone(28432, stone_proto, sword, weapon_proto, true, false) - _assert(not res4.ok and res4.code == "NO_AVAILABLE_SOCKET", "No available sockets rejected properly") - -func test_duplicate_metin_prevention() -> void: - print("\n--- Test 4: Prevention of Duplicate Stone Groups ---") - var weapon_proto := {"type": MetinSocketSystem.ITEM_TYPE_WEAPON, "sub_type": 0} - var stone_proto := {"type": MetinSocketSystem.ITEM_TYPE_METIN} - var sword := {"vnum": 10, "sockets": [28430, 1, 1]} # Already has Penetration+4 (28430) - - # Trying to socket Penetration+3 (28330) or another Penetration stone - var res_dup := MetinSocketSystem.socket_metin_stone(28330, stone_proto, sword, weapon_proto, true, false) - _assert(not res_dup.ok and res_dup.code == "DUPLICATE_METIN", "Duplicate metin group (Penetration) prevented from socketing") - -func test_clean_broken_sockets_parity() -> void: - print("\n--- Test 5: 40250 USE_CLEAN_SOCKET (71006) Fragment Cleaning ---") - # Equipment has: [28430 (valid), 28960 (broken), 28437 (valid)] - var item_with_broken := { - "vnum": 10, - "sockets": [28430, MetinSocketSystem.ITEM_BROKEN_METIN_VNUM, 28437] - } - - var res_clean := MetinSocketSystem.clean_broken_sockets(item_with_broken) - _assert(res_clean.ok and res_clean.code == "SUCCESS", "Clean scroll removed broken stone fragments") - _assert(item_with_broken.sockets[0] == 28430, "Socket 0 still has valid stone 28430") - _assert(item_with_broken.sockets[1] == 28437, "Valid stone 28437 compacted forward to socket 1") - _assert(item_with_broken.sockets[2] == 1, "Socket 2 restored back to open state 1") - - # Using clean scroll on an item with no broken stones must fail (40250 char_item.cpp:4523) - var res_clean_no_op := MetinSocketSystem.clean_broken_sockets(item_with_broken) - _assert(not res_clean_no_op.ok and res_clean_no_op.code == "NO_BROKEN_STONE", "Clean scroll rejected on item without broken stones") diff --git a/project/test_metin_socket_parity.gd.uid b/project/test_metin_socket_parity.gd.uid deleted file mode 100644 index eb09c363..00000000 --- a/project/test_metin_socket_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://3lvmxm4k4thv diff --git a/project/test_minimap_radar_parity.gd b/project/test_minimap_radar_parity.gd deleted file mode 100644 index 0fe6e456..00000000 --- a/project/test_minimap_radar_parity.gd +++ /dev/null @@ -1,105 +0,0 @@ -# test_minimap_radar_parity.gd —— 小地图雷达与坐标系统 1:1 回归测试套件 -# 运行方式: -# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_minimap_radar_parity.gd -extends SceneTree - -const Minimap = preload("res://ui/minimap.gd") - -class MockPlayer extends Node3D: - pass - -class MockClient extends Node: - signal channel_changed(c: int) - signal observer_event(ev: Dictionary) - signal world_reset - -var _passed := 0 -var _failed := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_minimap_radar_parity (Direction 3: Minimap & Coordinates 1:1) ===") - _test_minimap_coordinates() - _test_zoom_buttons() - _test_atlas_button() - _finish() - -func _test_minimap_coordinates() -> void: - print("\n--- Test 1: Real-time (X, Y) Coordinates Display ---") - var client := MockClient.new() - get_root().add_child(client) - - var player := MockPlayer.new() - get_root().add_child(player) - player.position = Vector3(150.8, 5.0, -320.4) - - var mm := Minimap.new() - get_root().add_child(mm) - mm.setup(client, get_root(), func(): return player) - - # Trigger process update - mm._process(0.016) - _assert(mm._coord_label != null, "_coord_label exists") - _assert(mm._coord_label.text == "(150, 320)", "Coordinates label correctly displays (150, 320)") - - # Move player to (500, -800) - player.position = Vector3(500.0, 0.0, -800.0) - mm._process(0.016) - _assert(mm._coord_label.text == "(500, 800)", "Coordinates updated to (500, 800)") - - mm.queue_free() - player.queue_free() - client.queue_free() - -func _test_zoom_buttons() -> void: - print("\n--- Test 2: ScaleUp / ScaleDown Buttons ---") - var client := MockClient.new() - get_root().add_child(client) - - var mm := Minimap.new() - get_root().add_child(mm) - mm.setup(client, get_root(), func(): return null) - - var initial_scale := mm.get_scale() - mm._btn_zoom_in.pressed.emit() - _assert(mm.get_scale() > initial_scale, "Zoom in button increases scale") - - mm._btn_zoom_out.pressed.emit() - _assert(absf(mm.get_scale() - initial_scale) < 0.001, "Zoom out button decreases scale back") - - mm.queue_free() - client.queue_free() - -func _test_atlas_button() -> void: - print("\n--- Test 3: Atlas (M) Button ---") - var client := MockClient.new() - get_root().add_child(client) - - var mm := Minimap.new() - get_root().add_child(mm) - mm.setup(client, get_root(), func(): return null) - - var state := {"clicked": false} - mm.map_pressed.connect(func(): state["clicked"] = true) - - mm._btn_atlas.pressed.emit() - _assert(state["clicked"] == true, "Atlas button click emits map_pressed signal") - - mm.queue_free() - client.queue_free() - -func _finish() -> void: - print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - quit(0) - else: - printerr("SOME TESTS FAILED!\n") - quit(1) diff --git a/project/test_minimap_radar_parity.gd.uid b/project/test_minimap_radar_parity.gd.uid deleted file mode 100644 index b956e88b..00000000 --- a/project/test_minimap_radar_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b6edy3hf034wt diff --git a/project/test_mob_drop_scatter_parity.gd b/project/test_mob_drop_scatter_parity.gd deleted file mode 100644 index 74c18814..00000000 --- a/project/test_mob_drop_scatter_parity.gd +++ /dev/null @@ -1,74 +0,0 @@ -# test_mob_drop_scatter_parity.gd —— 怪物死亡爆装与辐射散射 1:1 回归测试套件 -# 运行方式: -# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_mob_drop_scatter_parity.gd -extends SceneTree - -const MobDropScatter = preload("res://mob_drop_scatter.gd") - -var _passed := 0 -var _failed := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_mob_drop_scatter_parity (Direction 2: Drop Scatter & Ownership 1:1) ===") - _test_single_drop_offset() - _test_multi_drop_radial_scatter() - _test_ownership_protection_window() - _test_drop_sounds() - _finish() - -func _test_single_drop_offset() -> void: - print("\n--- Test 1: Single Drop Offset [-1.4m, +1.4m] (char_battle.cpp) ---") - for seed_val in range(10): - var offset := MobDropScatter.calculate_scatter_offset(0, 1, seed_val) - _assert(offset.x >= -1.4 and offset.x <= 1.4, "Seed %d dx within [-1.4m, 1.4m]" % seed_val) - _assert(offset.z >= -1.4 and offset.z <= 1.4, "Seed %d dz within [-1.4m, 1.4m]" % seed_val) - _assert(offset.y == 0.0, "Y offset is 0.0 (ground plane)") - -func _test_multi_drop_radial_scatter() -> void: - print("\n--- Test 2: Multi-Item Boss Drop Radial Scatter ---") - var center := Vector3(100.0, 10.0, 50.0) - var count := 6 - var positions: Array[Vector3] = [] - for i in range(count): - var pos := MobDropScatter.calculate_drop_position(center, i, count, 42) - positions.append(pos) - var dist := (pos - center).length() - _assert(dist >= 0.95 and dist <= 1.75, "Item %d scatter radius is %.2fm (expected 1.0~1.6m)" % [i, dist]) - - # Check that items do not overlap (pairwise distance > 0.5m) - for i in range(count): - for j in range(i + 1, count): - var d := (positions[i] - positions[j]).length() - _assert(d > 0.5, "Item %d and %d separated by %.2fm (no pile overlap)" % [i, j, d]) - -func _test_ownership_protection_window() -> void: - print("\n--- Test 3: 30-Second Ownership Protection Window ---") - var drop_time := 100.0 - _assert(MobDropScatter.is_ownership_protected(drop_time, 105.0) == true, "Protected at 5 seconds") - _assert(MobDropScatter.remaining_ownership_time(drop_time, 105.0) == 25.0, "25 seconds remaining at 5s") - - _assert(MobDropScatter.is_ownership_protected(drop_time, 129.5) == true, "Protected at 29.5 seconds") - _assert(MobDropScatter.is_ownership_protected(drop_time, 130.1) == false, "Expired after 30 seconds") - _assert(MobDropScatter.remaining_ownership_time(drop_time, 135.0) == 0.0, "Remaining time clamped to 0 after expiry") - -func _test_drop_sounds() -> void: - print("\n--- Test 4: Drop Sound Assignment ---") - _assert(MobDropScatter.get_drop_sound(true) == "sound/ui/money.wav", "Gold drop uses money.wav") - _assert(MobDropScatter.get_drop_sound(false) == "sound/ui/drop.wav", "Item drop uses drop.wav") - -func _finish() -> void: - print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - quit(0) - else: - printerr("SOME TESTS FAILED!\n") - quit(1) diff --git a/project/test_mob_drop_scatter_parity.gd.uid b/project/test_mob_drop_scatter_parity.gd.uid deleted file mode 100644 index 2fb7e174..00000000 --- a/project/test_mob_drop_scatter_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bmhuvaqjgi6ls diff --git a/project/test_no_auto_move_regression.gd b/project/test_no_auto_move_regression.gd deleted file mode 100644 index 965f8df0..00000000 --- a/project/test_no_auto_move_regression.gd +++ /dev/null @@ -1,233 +0,0 @@ -# test_no_auto_move_regression.gd -# Regression test suite verifying that the character does not move automatically -# without user input under any circumstances (target picking, skill casting, attack key release). -extends SceneTree - -const PlayerCtl = preload("res://player_controller.gd") -const NetPlay = preload("res://net_play.gd") -const NetWorld = preload("res://net_world.gd") - -var _failed := 0 - -func _check(condition: bool, msg: String) -> void: - if condition: - print(" [PASS] %s" % msg) - else: - print(" [FAIL] %s" % msg) - _failed += 1 - -class MockClient extends Node: - signal entity_main_set(vid: int) - signal entity_moved(vid: int) - signal points_changed(points: Dictionary) - signal vitals_changed(vid: int) - signal target_info(vid: int, hp_percent: int) - signal entity_despawned(vid: int) - signal entity_dead(vid: int) - - var ents: Dictionary = {} - func get_entity(vid: int) -> Dictionary: - return ents.get(vid, {}) - func is_in_game() -> bool: - return true - func set_target(_vid: int) -> void: - pass - func move(_func_id: int, _arg: int, _rot: int, _x: int, _y: int) -> void: - pass - func fishing(_rot: float) -> bool: - return false - -func _init() -> void: - print("=== Running test_no_auto_move_regression ===") - _test_pick_actor_engages_auto_attack_and_can_cancel() - _test_skill_cast_clears_movement_and_reservation() - _test_spacebar_attack_release_stops_movement() - _test_manual_movement_clears_reservation() - _test_clear_target_stops_pc() - - if _failed == 0: - print("=== ALL NO-AUTO-MOVE REGRESSION TESTS PASSED (0 failures) ===") - quit(0) - else: - printerr("=== TESTS FAILED: %d errors ===" % _failed) - quit(1) - -func _setup_rig() -> Dictionary: - var client := MockClient.new() - var net_world := NetWorld.new() - var pc := PlayerCtl.new() - var player_node := Node3D.new() - player_node.position = Vector3(0, 0, 0) - pc.player = player_node - pc.active = true - - var np := NetPlay.new() - np.setup(client, pc, net_world) - - # Main player entity - client.ents[1000] = { - "vid": 1000, - "kind": 0, - "empire": 1, - "x": 0, "y": 0, "z": 0, - "dead": false, - "in_safe": false - } - np._main_vid = 1000 - - # Mob entity at (20, 0, 0) - var mob_node := Node3D.new() - mob_node.position = Vector3(20, 0, 0) - mob_node.set_meta("vid", 2000) - net_world._by_vid[2000] = mob_node - client.ents[2000] = { - "vid": 2000, - "kind": 2, # KIND_MONSTER - "empire": 0, - "x": 2000, "y": 0, "z": 0, - "dead": false, - "in_safe": false - } - - return { - "client": client, - "net_world": net_world, - "pc": pc, - "player_node": player_node, - "np": np, - "mob_node": mob_node - } - -func _cleanup_rig(rig: Dictionary) -> void: - rig.mob_node.free() - rig.player_node.free() - rig.pc.free() - rig.np.free() - rig.net_world.free() - rig.client.free() - -# 1. Clicking an enemy sets _auto_attack_vid to target (40250 __SetAutoAttackTargetActorID) -func _test_pick_actor_engages_auto_attack_and_can_cancel() -> void: - print("\n--- Test 1: Actor pick engages auto attack (40250 parity) and can be cancelled ---") - var rig := _setup_rig() - var np: NetPlay = rig.np - var mob_node: Node3D = rig.mob_node - - # Simulate clicking the mob - np._on_pick(mob_node) - - _check(np._target_vid == 2000, "Target VID is set to picked mob") - _check(np._auto_attack_vid == 2000, "_auto_attack_vid is set to mob VID for auto attack") - - # Manual movement (WASD) cancels auto-attack - np.on_manual_move() - _check(np._auto_attack_vid == 0, "_auto_attack_vid cleared on manual move") - - _cleanup_rig(rig) - -# 2. Casting a skill must stop character movement, clear reservations, and not resume after finish -func _test_skill_cast_clears_movement_and_reservation() -> void: - print("\n--- Test 2: Skill cast clears movement and does not resume after skill ends ---") - var rig := _setup_rig() - var np: NetPlay = rig.np - var pc: PlayerCtl = rig.pc - var player_node: Node3D = rig.player_node - var mob_node: Node3D = rig.mob_node - - # Player targets mob and begins moving towards it - np._on_pick(mob_node) - pc.walk_to(Vector3(15, 0, 0)) - _check(pc.is_going() == true, "pc is walking before skill cast") - - # Cast skill (e.g. warrior mental skill) - np.start_skill_cast(0.5, false) - - _check(pc.is_going() == false, "pc.is_going() is immediately stopped on skill cast") - _check(np._reserved_mode == NetPlay.ReservedMode.NONE, "np._reserved_mode cleared on skill cast") - _check(np._auto_attack_vid == 0, "np._auto_attack_vid is 0 on skill cast") - - var initial_pos := player_node.position - - # Simulate frames during skill lock - for i in 5: - np._process(0.1) - pc._process(0.1) - - _check(player_node.position == initial_pos, "player remains stationary during skill cast") - - # Simulate skill timeout / unlocking - np._local_time += 1.0 - _check(np.is_lock() == false, "np is unlocked after skill duration") - - # Simulate frames AFTER skill completes (no player input) - for i in 10: - np._process(0.1) - pc._process(0.1) - - _check(pc.is_going() == false, "pc.is_going() remains false after skill completion") - _check(np._reserved_mode == NetPlay.ReservedMode.NONE, "reservation remains NONE after skill completion") - _check(np._auto_attack_vid == 0, "_auto_attack_vid remains 0 after skill completion") - _check(player_node.position == initial_pos, "player position unchanged (no spontaneous movement!)") - - _cleanup_rig(rig) - -# 3. Spacebar attack release stops movement and clears auto-attack -func _test_spacebar_attack_release_stops_movement() -> void: - print("\n--- Test 3: Releasing attack key (Spacebar) cancels auto-attack and stops movement ---") - var rig := _setup_rig() - var np: NetPlay = rig.np - var pc: PlayerCtl = rig.pc - - np._target_vid = 2000 - np.set_attack_key(true) - np._update_auto_attack() - - _check(np._auto_attack_vid == 2000, "_auto_attack_vid set while holding space with target") - - # Releasing attack key - np.set_attack_key(false) - - _check(np._auto_attack_vid == 0, "_auto_attack_vid cleared on spacebar release") - _check(np._reserved_mode == NetPlay.ReservedMode.NONE, "_reserved_mode cleared on spacebar release") - _check(pc.is_going() == false, "pc stopped on spacebar release") - - _cleanup_rig(rig) - -# 4. Manual movement (WASD) cancels any pending reserved actions and auto-attack -func _test_manual_movement_clears_reservation() -> void: - print("\n--- Test 4: Manual movement cancels reservations and auto-attack ---") - var rig := _setup_rig() - var np: NetPlay = rig.np - var pc: PlayerCtl = rig.pc - - np._reserve_click_actor(2000) - np._auto_attack_vid = 2000 - _check(np._reserved_mode == NetPlay.ReservedMode.CLICK_ACTOR, "Reserved mode is CLICK_ACTOR") - - # Player presses WASD -> on_manual_move() called - np.on_manual_move() - - _check(np._reserved_mode == NetPlay.ReservedMode.NONE, "on_manual_move cleared reserved action") - _check(np._auto_attack_vid == 0, "on_manual_move cleared auto-attack") - - _cleanup_rig(rig) - -# 5. Clearing target stops pc -func _test_clear_target_stops_pc() -> void: - print("\n--- Test 5: Clearing target stops player controller ---") - var rig := _setup_rig() - var np: NetPlay = rig.np - var pc: PlayerCtl = rig.pc - - np._target_vid = 2000 - pc.walk_to(Vector3(10, 0, 0)) - _check(pc.is_going() == true, "pc is going towards destination") - - np._clear_target() - - _check(np._target_vid == 0, "target vid is cleared") - _check(pc.is_going() == false, "pc.is_going() cleared on target clear") - _check(np._auto_attack_vid == 0, "_auto_attack_vid is 0") - _check(np._reserved_mode == NetPlay.ReservedMode.NONE, "reserved mode is NONE") - - _cleanup_rig(rig) diff --git a/project/test_no_auto_move_regression.gd.uid b/project/test_no_auto_move_regression.gd.uid deleted file mode 100644 index d447ce65..00000000 --- a/project/test_no_auto_move_regression.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bkm404aot1ude diff --git a/project/test_npc_dialog_shop_ui_parity.gd b/project/test_npc_dialog_shop_ui_parity.gd deleted file mode 100644 index 6d0f3ff5..00000000 --- a/project/test_npc_dialog_shop_ui_parity.gd +++ /dev/null @@ -1,229 +0,0 @@ -# test_npc_dialog_shop_ui_parity.gd -# 严格按照《CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md》对齐 40250: -# 验证 NPC 对话面板 (QuestDialog / thinboard / BarButton / QuestCurtain) -# 与 NPC 商店面板 (ShopUI / shopdialog.py 184x328 / board / ItemSlot 5x8 / Buy & Sell 按钮) 1:1 对拍。 -extends SceneTree - -const QuestDialog = preload("res://ui/quest_dialog.gd") -const ShopUI = preload("res://ui/shop_ui.gd") -const QuestCurtain = preload("res://ui/quest_curtain.gd") - -class MockClient extends Node: - signal shop_opened(vid: int) - signal shop_closed - signal shop_updated(pos: int) - signal shop_error(kind: String) - signal script_dialog(skin: int, text: String) - - var buy_calls: Array = [] - var sell_calls: Array = [] - var script_answers: Array = [] - var shop_data: Dictionary = { - "tabs": [ - { - "name": "杂货", - "items": [ - {"pos": 0, "vnum": 27001, "count": 1, "price": 100}, - {"pos": 1, "vnum": 27002, "count": 50, "price": 5000}, - ] - } - ] - } - - func is_in_game() -> bool: - return true - - func get_main_vid() -> int: - return 1000 - - func get_entity(_vid: int) -> Dictionary: - return {"pos": Vector3(0, 0, 0)} - - func get_shop() -> Dictionary: - return shop_data - - func get_shop_items() -> Array: - return shop_data["tabs"][0]["items"] - - func shop_buy(pos: int, count: int = 1) -> bool: - buy_calls.append({"pos": pos, "count": count}) - return true - - func shop_sell(cell: int, count: int = 1) -> bool: - sell_calls.append({"cell": cell, "count": count}) - return true - - func shop_close() -> bool: - return true - - func script_answer(answer: int) -> bool: - script_answers.append(answer) - return true - - func get_inventory() -> Array: - return [{"cell": 5, "vnum": 27001, "count": 10, "flags": 0, "anti_flags": 0}] - -class MockProto extends Node: - func item(vnum: int) -> Dictionary: - if vnum == 27001: - return {"name": "红药水(小)", "locale_name": "红药水(小)", "sell_price": 50, "flags": 0, "anti_flags": 0} - elif vnum == 27002: - return {"name": "红药水(中)", "locale_name": "红药水(中)", "sell_price": 100, "flags": 0, "anti_flags": 0} - return {} - -func _init() -> void: - call_deferred("_run") - -func _run() -> void: - print("=== Starting test_npc_dialog_shop_ui_parity ===") - var fails := [0] - var check = func(cond: bool, msg: String): - if not cond: - fails[0] += 1 - printerr("FAIL: ", msg) - else: - print("PASS: ", msg) - - var root_node := Control.new() - root.add_child(root_node) - var client := MockClient.new() - root.add_child(client) - var proto := MockProto.new() - root.add_child(proto) - - # ------------------------------------------------------------- - # Test 1: ShopUI 184x328 官方尺寸、Board 外框与 TitleBar - # ------------------------------------------------------------- - print("\n--- Test 1: ShopUI 184x328 & 40250 Board Parity ---") - var shop := ShopUI.new() - root.add_child(shop) - shop.setup(client, root_node, proto) - shop.open() - - check.call(shop.is_open(), "Shop window opened") - var win: Control = shop._root - check.call(win != null, "Shop window root exists") - check.call(win.size == Vector2(184, 328), "Shop window size matches 40250 canonical 184x328 (got %s)" % str(win.size)) - - var titlebar: Control = win.find_child("TitleBar", true, false) - check.call(titlebar != null, "Official TitleBar exists") - check.call(titlebar.size.x == 169 or titlebar.custom_minimum_size.x == 169, "TitleBar width is 169") - var title_lbl: Label = titlebar.find_child("TitleName", true, false) - check.call(title_lbl != null and title_lbl.text == "商店", "TitleBar text is '商店'") - - # ------------------------------------------------------------- - # Test 2: ShopUI 5x8 ItemSlot (40 格) 货架与商品渲染 - # ------------------------------------------------------------- - print("\n--- Test 2: ShopUI 5x8 ItemSlot Grid & Item Rendering ---") - check.call(shop._grid != null, "ItemSlot container exists") - check.call(shop._grid.columns == 5, "ItemSlot grid has 5 columns") - check.call(shop._grid.get_child_count() == 40, "ItemSlot contains 40 slots (5x8)") - - var slot0: Button = shop._grid.get_child(0) - check.call(slot0 != null, "Slot 0 button exists") - check.call(slot0.size == Vector2(32, 32) or slot0.custom_minimum_size == Vector2(32, 32), "Slot 0 is 32x32") - check.call(not slot0.disabled, "Slot 0 is occupied and enabled") - check.call("价格:100" in slot0.tooltip_text, "Slot 0 tooltip contains price 100") - - var slot1: Button = shop._grid.get_child(1) - check.call(slot1 != null and not slot1.disabled, "Slot 1 is occupied") - var count_lbl: Label = slot1.find_child("Count", true, false) - check.call(count_lbl != null and count_lbl.text == "50", "Slot 1 stacked count label displays '50'") - - var slot2: Button = shop._grid.get_child(2) - check.call(slot2 != null and slot2.disabled, "Empty slot 2 is disabled") - - # ------------------------------------------------------------- - # Test 3: Buy & Sell 切换按钮 (middle_button 61x21) 与购买/出售交互 - # ------------------------------------------------------------- - print("\n--- Test 3: Buy/Sell Buttons & Purchase/Sell Actions ---") - check.call(shop._buy_mode_button != null, "BuyButton exists") - check.call(shop._buy_mode_button.custom_minimum_size == Vector2(61, 21), "BuyButton custom_minimum_size is 61x21") - check.call(shop._sell_mode_button != null, "SellButton exists") - check.call(shop._sell_mode_button.custom_minimum_size == Vector2(61, 21), "SellButton custom_minimum_size is 61x21") - - # 40250 UnselectItemSlot: 右键槽位直接购买 - var right_click := InputEventMouseButton.new() - right_click.button_index = MOUSE_BUTTON_RIGHT - right_click.pressed = true - slot0.gui_input.emit(right_click) - check.call(client.buy_calls.size() == 1 and client.buy_calls[0]["pos"] == 0, "Right-click direct buy sent shop_buy for slot 0") - - # 背包右键道具出售 - shop.sell(5, 2) - check.call(client.sell_calls.size() == 1 and client.sell_calls[0]["cell"] == 5, "Selling from inventory triggered shop_sell for cell 5") - - shop.close() - check.call(not shop.is_open(), "Shop closed cleanly") - - # ------------------------------------------------------------- - # Test 4: QuestDialog 40250 thinboard 与 BarButton 样式 - # ------------------------------------------------------------- - print("\n--- Test 4: QuestDialog thinboard & BarButton Parity ---") - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, root_node, proto) - - # 40250 经典 NPC 脚本对话 - var script_src := "欢迎光临杂货铺![ENTER]请问有什么需要帮忙的?[QUESTION 1;我要买卖物品|2;打听城里的消息|3;离开]" - client.script_dialog.emit(0, script_src) - qd.process_events(100.0) - - check.call(qd.is_open(), "QuestDialog opened") - check.call(qd._panel != null, "QuestDialog background panel exists") - check.call(qd._panel.visible, "QuestDialog thinboard panel is visible") - - # 检查选项按钮是否采用了 40250 风格的 BarButton - check.call(qd._btnrow.get_child_count() == 3, "Choice buttons created (3 options)") - var choice0: Button = qd._btnrow.get_child(0) - check.call(choice0 != null and choice0.text == "我要买卖物品", "Choice 0 is '我要买卖物品'") - check.call(choice0.custom_minimum_size.y == 26.0, "Choice 0 height is 26px (40250 BarButton standard)") - - # 点击选项 0 - choice0.pressed.emit() - check.call(client.script_answers == [0], "Clicking choice 0 sent script_answer(0)") - check.call(not qd.is_open(), "QuestDialog closed on choice select") - - # ------------------------------------------------------------- - # Test 5: Next/Done 按钮 (100x26 居中 BarButton) - # ------------------------------------------------------------- - print("\n--- Test 5: Next/Done 100x26 BarButton ---") - var next_script := "明天还会有新货运到。[NEXT]" - client.script_dialog.emit(0, next_script) - qd.process_events(100.0) - check.call(qd.is_open(), "QuestDialog opened for NEXT") - check.call(qd._btnrow.get_child_count() == 1, "Single next button created") - var next_btn: Button = qd._btnrow.get_child(0) - check.call(next_btn != null and next_btn.text == "继续", "Button text is '继续'") - check.call(next_btn.custom_minimum_size == Vector2(100, 26), "Next button size is 100x26 (40250 centered button)") - next_btn.pressed.emit() - check.call(client.script_answers == [0, 254], "Next button sent script_answer(254)") - check.call(not qd.is_open(), "QuestDialog closed after NEXT") - - # ------------------------------------------------------------- - # Test 6: QuestCurtain 联动 - # ------------------------------------------------------------- - print("\n--- Test 6: QuestCurtain open/close lifecycle ---") - var curtain := QuestCurtain.new() - root_node.add_child(curtain) - check.call(not curtain.is_open(), "QuestCurtain initially closed") - - qd.opened.connect(func(): curtain.open()) - qd.closed.connect(func(): curtain.close()) - - client.script_dialog.emit(0, "欢迎来到神龙帝国![DONE]") - qd.process_events(100.0) - check.call(curtain.is_open(), "QuestCurtain opened when QuestDialog opened") - qd.close() - check.call(not curtain.is_open(), "QuestCurtain closed when QuestDialog closed") - - # 清理 - qd.queue_free() - shop.queue_free() - curtain.queue_free() - root_node.queue_free() - client.queue_free() - proto.queue_free() - - print("\n=== All NPC Dialog & Shop UI Parity Tests finished with %d failures ===" % fails[0]) - quit(fails[0]) diff --git a/project/test_npc_dialog_shop_ui_parity.gd.uid b/project/test_npc_dialog_shop_ui_parity.gd.uid deleted file mode 100644 index b22573b9..00000000 --- a/project/test_npc_dialog_shop_ui_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c35tmwckarjdh diff --git a/project/test_npc_shop_flow.gd b/project/test_npc_shop_flow.gd deleted file mode 100644 index 4b3d6fb3..00000000 --- a/project/test_npc_shop_flow.gd +++ /dev/null @@ -1,477 +0,0 @@ -# test_npc_shop_flow.gd —— Phase 2 NPC 交互与商店系统 40250 行为对齐验证 -extends SceneTree - -const ShopUI = preload("res://ui/shop_ui.gd") -const NetPlay = preload("res://net_play.gd") -const EntityRules = preload("res://entity_rules.gd") -const ItemTooltip = preload("res://ui/item_tooltip.gd") - -var _failed := false -var _pass_count := 0 - -func _check(ok: bool, msg: String) -> void: - if ok: - _pass_count += 1 - print(" [PASS] " + msg) - else: - _failed = true - push_error("FAIL: " + msg) - print(" [FAIL] " + msg) - -func _node_text(node: Node) -> String: - var out := "" - if node is Label: - out += String((node as Label).text) + "\n" - for child in node.get_children(): - out += _node_text(child) - return out - -class MockClient extends Node: - signal shop_opened(vid: int) - signal shop_closed - signal shop_updated(pos: int) - signal shop_error(kind: String) - signal entity_main_set(vid: int) - signal entity_moved(vid: int, x: int, y: int, rot: float, dur: int) - signal points_changed() - signal vitals_changed() - signal target_info(info: Dictionary) - signal entity_despawned(vid: int) - signal entity_dead(vid: int) - - var entities: Dictionary = {} - var main_vid := 1000 - var click_calls: Array = [] - var buy_calls: Array = [] - var sell_calls: Array = [] - var close_calls := 0 - var shop_data: Dictionary = {"tabs": []} - - func is_in_game() -> bool: - return true - - func get_main_vid() -> int: - return main_vid - - func get_entity(vid: int) -> Dictionary: - return entities.get(vid, {}) - - func click_npc(vid: int) -> bool: - click_calls.append(vid) - return true - - func get_shop() -> Dictionary: - return shop_data - - func get_shop_items() -> Array: - var tabs: Array = shop_data.get("tabs", []) - if not tabs.is_empty(): - return tabs[0].get("items", []) - return [] - - func shop_buy(pos: int, count: int) -> bool: - buy_calls.append({"pos": pos, "count": count}) - return true - - func shop_sell(cell: int, count: int) -> bool: - sell_calls.append({"cell": cell, "count": count}) - return true - - func shop_close() -> void: - close_calls += 1 - shop_closed.emit() - - func get_inventory() -> Array: - return [] - -class MockPlayerController extends Node: - var player: Node3D - var stopped := false - var walk_dest := Vector3.ZERO - var going := false - signal target_selected(node: Node3D) - signal moved(is_moving: bool) - signal anim_state(s: String) - - func is_going() -> bool: - return going - - func stop() -> void: - stopped = true - going = false - - func walk_to(dest: Vector3) -> void: - walk_dest = dest - going = true - stopped = false - -class MockNetWorld extends Node: - var nodes: Dictionary = {} - func node_for(vid: int) -> Node3D: - return nodes.get(vid, null) - -class MockProto extends Node: - var items: Dictionary = {} - func item(vnum: int) -> Dictionary: - return items.get(vnum, {}) - -func _init() -> void: - print("=== Running test_npc_shop_flow (Phase 2 Parity) ===") - _test_npc_interaction() - _test_npc_click_throttle() - _test_shop_data_and_sockets() - _test_shop_multi_tab_and_buy_index() - _test_shop_incremental_update() - _test_shop_direct_buy_right_click() - _test_shop_distance_auto_close() - - if _failed: - print("\n=== NPC & SHOP TESTS FAILED ===") - quit(1) - else: - print("\n=== ALL NPC & SHOP TESTS PASSED (%d checks) ===" % _pass_count) - quit(0) - -# --- Test 1: NPC Interaction (500cm range & reservation) --- -func _test_npc_interaction() -> void: - print("\n--- Test 1: NPC Interaction Distance & Reservation ---") - var client := MockClient.new() - root.add_child(client) - var pc := MockPlayerController.new() - root.add_child(pc) - var player_node := Node3D.new() - root.add_child(player_node) - player_node.position = Vector3(0, 0, 0) - pc.player = player_node - - var nw := MockNetWorld.new() - root.add_child(nw) - var npc_node := Node3D.new() - root.add_child(npc_node) - npc_node.position = Vector3(3.0, 0, 0) # 3.0m <= 5.0m (500cm) - npc_node.set_meta("vid", 2001) - nw.nodes[2001] = npc_node - - client.entities[1000] = {"vid": 1000, "ch_type": 0, "pos": Vector3(0,0,0)} # PC - client.entities[2001] = {"vid": 2001, "ch_type": 1, "pos": Vector3(3,0,0), "name": "General Store"} # NPC - - var np := NetPlay.new() - root.add_child(np) - np.setup(client, pc, nw, null) - np._main_vid = 1000 - - # 1.1: Click within 5.0m -> Immediate click, stops, faces NPC - np._on_pick(npc_node) - _check(client.click_calls == [2001], "NPC click within 5m immediately sends click_npc packet") - _check(pc.stopped, "Character stops when interacting with NPC") - _check(abs(player_node.rotation.y - (PI / 2.0)) < 0.05, "Character faces toward NPC (+X direction)") - - # 1.2: Move NPC far away (8.0m > 5.0m) - npc_node.position = Vector3(8.0, 0, 0) - client.entities[2001]["pos"] = Vector3(8,0,0) - client.click_calls.clear() - pc.stopped = false - - # Reset throttle timer for test - np._last_click_actor_t = -10.0 - - np._on_pick(npc_node) - _check(client.click_calls.is_empty(), "NPC click beyond 5m does not immediately send packet") - _check(np._reserved_mode == np.ReservedMode.CLICK_ACTOR, "NPC click beyond 5m enters reservation mode (CLICK_ACTOR)") - _check(np._vid_reserved == 2001, "Reserved actor VID set to NPC VID") - _check(pc.going and pc.walk_dest == Vector3(8, 0, 0), "Character begins walking toward distant NPC") - - # Character walks closer into 4.0m range - player_node.position = Vector3(4.5, 0, 0) # dist to 8.0 is 3.5m <= 5m - np._refresh_reserved() - _check(client.click_calls == [2001], "Reservation executes click_npc when entering 5m range") - _check(pc.stopped, "Reservation execution stops character") - _check(np._reserved_mode == np.ReservedMode.NONE, "Reservation cleared after execution") - - np.queue_free() - player_node.queue_free() - npc_node.queue_free() - nw.queue_free() - pc.queue_free() - client.queue_free() - -# --- Test 2: NPC Click Throttle (1000ms protection) --- -func _test_npc_click_throttle() -> void: - print("\n--- Test 2: NPC Click Throttle (40250 1000ms protection) ---") - var client := MockClient.new() - root.add_child(client) - var pc := MockPlayerController.new() - root.add_child(pc) - var pnode := Node3D.new() - root.add_child(pnode) - pc.player = pnode - - var nw := MockNetWorld.new() - root.add_child(nw) - var npc1 := Node3D.new() - var npc2 := Node3D.new() - root.add_child(npc1) - root.add_child(npc2) - npc1.position = Vector3(1, 0, 0) - npc2.position = Vector3(2, 0, 0) - npc1.set_meta("vid", 2001) - npc2.set_meta("vid", 2002) - nw.nodes[2001] = npc1 - nw.nodes[2002] = npc2 - - client.entities[1000] = {"vid": 1000, "ch_type": 0, "pos": Vector3.ZERO} - client.entities[2001] = {"vid": 2001, "ch_type": 1, "pos": Vector3(1,0,0)} - client.entities[2002] = {"vid": 2002, "ch_type": 1, "pos": Vector3(2,0,0)} - - var np := NetPlay.new() - root.add_child(np) - np.setup(client, pc, nw, null) - np._main_vid = 1000 - - # First click on NPC 2001 - var r1 := np._send_click_actor_packet(2001) - _check(r1 and client.click_calls == [2001], "Initial click on NPC 2001 succeeds") - - # Immediate second click on NPC 2001 (<1s) -> throttled - var r2 := np._send_click_actor_packet(2001) - _check(not r2 and client.click_calls == [2001], "Rapid second click on same NPC is throttled") - - # Click different NPC 2002 -> immediately succeeds - var r3 := np._send_click_actor_packet(2002) - _check(r3 and client.click_calls == [2001, 2002], "Clicking different NPC is not throttled") - - # Advance simulated time past 1.0s - np._last_click_actor_t -= 1.1 - var r4 := np._send_click_actor_packet(2002) - _check(r4 and client.click_calls == [2001, 2002, 2002], "Clicking same NPC after >1.0s succeeds") - - np.queue_free() - pnode.queue_free() - npc1.queue_free() - npc2.queue_free() - nw.queue_free() - pc.queue_free() - client.queue_free() - -# --- Test 3: Shop Sockets & Attrs Data Completeness --- -func _test_shop_data_and_sockets() -> void: - print("\n--- Test 3: Shop Sockets & Attrs Data Preservation ---") - var client := MockClient.new() - var proto := MockProto.new() - proto.items[10] = { - "name": "Sword+0", - "locale_name": "剑+0", - "type": 1, # WEAPON - "values": [0, 0, 0, 15, 20, 0], # attack 15-20 - } - - # Item with 3 sockets and 2 attributes - var item_entry := { - "pos": 0, - "vnum": 10, - "count": 1, - "price": 1000, - "sockets": [28430, 0, 0], - "attrs": [ - {"type": 1, "value": 500}, # MAX_HP + 500 - {"type": 5, "value": 12}, # STR + 12 - {"type": 0, "value": 0}, - {"type": 0, "value": 0}, - {"type": 0, "value": 0}, - {"type": 0, "value": 0}, - {"type": 0, "value": 0} - ] - } - client.shop_data = { - "tabs": [ - {"name": "武器", "items": [item_entry]} - ] - } - - var host := Control.new() - root.add_child(host) - var shop := ShopUI.new() - root.add_child(shop) - shop.setup(client, host, proto) - shop.open() - - var slot_btn: Button = shop._grid.get_child(0) - _check(slot_btn != null, "Shop slot 0 created") - _check(slot_btn.tooltip_text.is_empty(), "Shop item has no native duplicate tooltip panel") - slot_btn.mouse_entered.emit() - var tooltip_text := _node_text(shop._tooltip_view) - _check(shop._tooltip_view.visible, "Hover opens the shared shop ItemTooltipView") - _check(tooltip_text.contains("最大生命 +500"), "Shared tooltip contains attribute 1 (MAX_HP +500)") - _check(tooltip_text.contains("力量 +12"), "Shared tooltip contains attribute 2 (STR +12)") - _check(tooltip_text.contains("价格:1,000 金币"), "Shared tooltip contains item price") - - shop.queue_free() - host.queue_free() - client.queue_free() - proto.queue_free() - -# --- Test 4: Shop Multi-Tab & Buy Index Calculation --- -func _test_shop_multi_tab_and_buy_index() -> void: - print("\n--- Test 4: Multi-tab Shop (START_EX) and Buy Index Parity ---") - var client := MockClient.new() - var proto := MockProto.new() - proto.items[10] = {"name": "Sword", "locale_name": "剑"} - proto.items[27001] = {"name": "Red Potion", "locale_name": "红药水"} - - # 2 tabs: Tab 0 (Weapons), Tab 1 (Potions) - client.shop_data = { - "tabs": [ - { - "name": "武器货架", - "items": [{"pos": 2, "vnum": 10, "count": 1, "price": 500, "sockets": [], "attrs": []}] - }, - { - "name": "药品货架", - "items": [{"pos": 5, "vnum": 27001, "count": 50, "price": 200, "sockets": [], "attrs": []}] - } - ] - } - - var host := Control.new() - root.add_child(host) - var shop := ShopUI.new() - root.add_child(shop) - shop.setup(client, host, proto) - shop.open() - - _check(shop._tabbar.visible, "Multi-tab shop displays tabbar") - _check(shop._tabbar.get_child_count() == 2, "Tabbar contains 2 shelves") - _check(shop._tabbar.get_child(0).text == "武器货架", "Tab 0 name is 武器货架") - _check(shop._tabbar.get_child(1).text == "药品货架", "Tab 1 name is 药品货架") - - # Check Tab 0 slot 2 - var tab0_slot2: Button = shop._grid.get_child(2) - _check(not tab0_slot2.disabled and tab0_slot2.text.begins_with("剑"), "Tab 0 slot 2 is Sword") - - # Switch to Tab 1 - var tab1_btn: Button = shop._tabbar.get_child(1) - tab1_btn.pressed.emit() - _check(shop._active_tab == 1, "Switched to Tab 1") - - # Check Tab 1 slot 5 - var tab1_slot5: Button = shop._grid.get_child(5) - _check(not tab1_slot5.disabled and tab1_slot5.text.begins_with("红药水"), "Tab 1 slot 5 is Red Potion") - - # Buy from Tab 1 slot 5: pos calculation = 1 * 40 + 5 = 45 - tab1_slot5.pressed.emit() - _check(is_instance_valid(shop._buy_confirm), "Clicking slot opens confirmation modal") - shop._buy_confirm.confirmed.emit() - _check(client.buy_calls.size() == 1, "Buy request emitted") - _check(client.buy_calls[0]["pos"] == 45, "Buying position correctly calculated as tabIdx * 40 + slot (45)") - - shop.queue_free() - host.queue_free() - client.queue_free() - proto.queue_free() - -# --- Test 5: Incremental Slot Update preserves active tab --- -func _test_shop_incremental_update() -> void: - print("\n--- Test 5: Incremental Slot Update (shop_updated) ---") - var client := MockClient.new() - var proto := MockProto.new() - proto.items[10] = {"name": "Sword", "locale_name": "剑"} - proto.items[20] = {"name": "Bow", "locale_name": "弓"} - - client.shop_data = { - "tabs": [ - {"name": "Tab0", "items": [{"pos": 0, "vnum": 10, "count": 1, "price": 100, "sockets": [], "attrs": []}]}, - {"name": "Tab1", "items": [{"pos": 1, "vnum": 20, "count": 1, "price": 200, "sockets": [], "attrs": []}]} - ] - } - - var host := Control.new() - root.add_child(host) - var shop := ShopUI.new() - root.add_child(shop) - shop.setup(client, host, proto) - shop.open() - - # User switches to Tab 1 - shop._tabbar.get_child(1).pressed.emit() - _check(shop._active_tab == 1, "User is on Tab 1") - - # Server sends slot update for Tab 1 item (price updated from 200 to 350) - client.shop_data["tabs"][1]["items"][0]["price"] = 350 - client.shop_updated.emit(41) # pos 41 = 1 * 40 + 1 - - _check(shop._active_tab == 1, "shop_updated did NOT reset _active_tab back to 0") - var updated_slot: Button = shop._grid.get_child(1) - _check(updated_slot.tooltip_text.is_empty(), "Refreshed shop item still has no native tooltip") - updated_slot.mouse_entered.emit() - _check(_node_text(shop._tooltip_view).contains("价格:350 金币"), - "Shared tooltip refreshes with updated price 350") - - shop.queue_free() - host.queue_free() - client.queue_free() - proto.queue_free() - -# --- Test 6: Right-click instant buy per 40250 UnselectItemSlot --- -func _test_shop_direct_buy_right_click() -> void: - print("\n--- Test 6: Right-Click Direct Buy (40250 UnselectItemSlot) ---") - var client := MockClient.new() - var proto := MockProto.new() - proto.items[10] = {"name": "Sword", "locale_name": "剑"} - client.shop_data = { - "tabs": [ - {"name": "", "items": [{"pos": 3, "vnum": 10, "count": 1, "price": 500, "sockets": [], "attrs": []}]} - ] - } - - var host := Control.new() - root.add_child(host) - var shop := ShopUI.new() - root.add_child(shop) - shop.setup(client, host, proto) - shop.open() - - var slot3: Button = shop._grid.get_child(3) - var mb := InputEventMouseButton.new() - mb.button_index = MOUSE_BUTTON_RIGHT - mb.pressed = true - - slot3.gui_input.emit(mb) - _check(client.buy_calls == [{"pos": 3, "count": 1}], "Right-click slot immediately buys without confirmation dialog") - _check(shop._buy_confirm == null, "Confirmation dialog was not opened on right-click") - - shop.queue_free() - host.queue_free() - client.queue_free() - proto.queue_free() - -# --- Test 7: Shop Distance Auto-close (10m / 1000cm) --- -func _test_shop_distance_auto_close() -> void: - print("\n--- Test 7: Shop Distance Auto-Close (>10.0m) ---") - var client := MockClient.new() - client.entities[1000] = {"vid": 1000, "pos": Vector3(100, 0, 100)} # Character at (100, 0, 100) - client.shop_data = {"tabs": [{"name": "", "items": []}]} - - var host := Control.new() - root.add_child(host) - var shop := ShopUI.new() - root.add_child(shop) - shop.setup(client, host, null) - - shop.open() - _check(shop.is_open(), "Shop window opened") - _check(shop._open_char_pos == Vector3(100, 0, 100), "Recorded character opening position") - - # Character walks 5m away -> within 10m limit - client.entities[1000]["pos"] = Vector3(105, 0, 100) - shop._process(0.016) - _check(shop.is_open(), "Shop remains open at 5m distance") - - # Character walks 12m away -> exceeds 10m (USE_SHOP_LIMIT_RANGE_M) - client.entities[1000]["pos"] = Vector3(112, 0, 100) - shop._process(0.016) - _check(not shop.is_open(), "Shop auto-closes when character walks > 10m away") - _check(client.close_calls == 1, "shop_close packet sent to server") - - shop.queue_free() - host.queue_free() - client.queue_free() diff --git a/project/test_npc_shop_flow.gd.uid b/project/test_npc_shop_flow.gd.uid deleted file mode 100644 index ad2ea7ed..00000000 --- a/project/test_npc_shop_flow.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cfjm1t5150n3m diff --git a/project/test_quest_dialog_parity.gd b/project/test_quest_dialog_parity.gd deleted file mode 100644 index e55c2323..00000000 --- a/project/test_quest_dialog_parity.gd +++ /dev/null @@ -1,417 +0,0 @@ -# test_quest_dialog_parity.gd —— Phase 3 任务系统与对话流 40250 行为对齐验证 -extends SceneTree - -const QuestDialog = preload("res://ui/quest_dialog.gd") -const QuestLog = preload("res://ui/quest_log.gd") - -var _failed := false -var _pass_count := 0 - -func _check(ok: bool, msg: String) -> void: - if ok: - _pass_count += 1 - print(" [PASS] " + msg) - else: - _failed = true - push_error("FAIL: " + msg) - print(" [FAIL] " + msg) - -class MockClient extends Node: - signal script_dialog(skin: int, text: String) - signal quest_confirm_ask(msg: String, timeout: int, request_pid: int) - signal quest_info(index: int) - - var script_answers: Array = [] - var script_buttons: Array = [] - var quest_inputs: Array = [] - var quest_confirms: Array = [] - var quest_cancels := 0 - var quests: Dictionary = {} - - func script_answer(answer: int) -> bool: - script_answers.append(answer) - return true - - func script_button(idx: int) -> bool: - script_buttons.append(idx) - return true - - func quest_input(text: String) -> bool: - quest_inputs.append(text) - return true - - func quest_confirm(yes: bool, request_pid: int) -> bool: - quest_confirms.append({"yes": yes, "pid": request_pid}) - return true - - func quest_cancel() -> bool: - quest_cancels += 1 - script_answers.append(254) - return true - - func get_quests() -> Array: - return quests.values() - - func is_in_game() -> bool: - return true - -func _init() -> void: - print("=== Running test_quest_dialog_parity (Phase 3 Parity) ===") - _test_pipe_question_parsing() - _test_choice_pagination() - _test_input_submission() - _test_next_and_done() - _test_esc_cancel() - _test_skin_modes() - _test_left_image_layout() - _test_quest_button_and_log() - _test_quest_confirm() - _test_missing_cinema_does_not_open_dialog() - - if _failed: - print("\n=== QUEST DIALOG PARITY TESTS FAILED ===") - quit(1) - else: - print("\n=== ALL QUEST DIALOG PARITY TESTS PASSED (%d checks) ===" % _pass_count) - quit(0) - -# --- Test 1: 40250 Pipe-delimited [QUESTION] Parsing --- -func _test_pipe_question_parsing() -> void: - print("\n--- Test 1: 40250 Standard Pipe-delimited [QUESTION] ---") - var client := MockClient.new() - var host := Control.new() - root.add_child(host) - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, host) - - # 40250 standard wire script from questlua.cpp:642 GotoSelectState - var script := "老铁匠看着你说道:[ENTER]请问你需要什么帮助?[QUESTION 1;拜访铁匠|2;购买强化材料|3;打听消息|4;离开]" - client.script_dialog.emit(0, script) - qd.process_events(100.0) - - _check(qd.is_open(), "Quest dialog opened") - var parsed: Dictionary = qd.parse_script(script) - _check(parsed.choices.size() == 4, "Parsed exactly 4 choices") - _check(parsed.choices[0] == "拜访铁匠", "Choice 0 is '拜访铁匠'") - _check(parsed.choices[1] == "购买强化材料", "Choice 1 is '购买强化材料'") - _check(parsed.choices[2] == "打听消息", "Choice 2 is '打听消息'") - _check(parsed.choices[3] == "离开", "Choice 3 is '离开'") - - # Click choice 2 ("购买强化材料") -> 0-indexed choice 1 sent to server - var btn2: Button = qd._btnrow.get_child(1) - btn2.pressed.emit() - _check(client.script_answers == [1], "Clicking choice 2 sends 0-indexed script_answer(1)") - _check(not qd.is_open(), "Dialog closes after choice selection") - - qd.queue_free() - host.queue_free() - client.queue_free() - -# --- Test 2: Choice Pagination (> 8 choices per 40250 uiquest.py) --- -func _test_choice_pagination() -> void: - print("\n--- Test 2: Choice Pagination (> 8 choices) ---") - var client := MockClient.new() - var host := Control.new() - root.add_child(host) - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, host) - - # 12 choices - var script := "[QUESTION " - for i in range(1, 13): - if i > 1: - script += "|" - script += "%d;传送地点_%d" % [i, i] - script += "]" - - client.script_dialog.emit(0, script) - qd.process_events(100.0) - - _check(qd.is_open(), "Dialog opened for 12 choices") - # Page 1: 8 choice buttons + 1 navigation row = 9 children - _check(qd._btnrow.get_child_count() == 9, "Page 1 renders 8 choice buttons + 1 nav row") - var first_btn: Button = qd._btnrow.get_child(0) - _check(first_btn.text == "传送地点_1", "First button on Page 1 is '传送地点_1'") - - # Nav row is child 8 - var nav_row: HBoxContainer = qd._btnrow.get_child(8) - _check(nav_row != null, "Navigation row exists") - var next_btn: Button = nav_row.get_child(nav_row.get_child_count() - 1) - _check(next_btn.text == "下一页", "Next page button present") - - # Switch to Page 2 - next_btn.pressed.emit() - _check(qd._current_choice_page == 1, "Switched to Page 2") - # Page 2: remaining 4 choice buttons + 1 nav row = 5 children - _check(qd._btnrow.get_child_count() == 5, "Page 2 renders remaining 4 buttons + 1 nav row") - var page2_btn2: Button = qd._btnrow.get_child(1) # This is 10th overall choice (index 9) - _check(page2_btn2.text == "传送地点_10", "Second button on Page 2 is '传送地点_10'") - - # Click choice 10 -> global index 9 sent to server - page2_btn2.pressed.emit() - _check(client.script_answers == [9], "Clicking option on Page 2 sends global index script_answer(9)") - _check(not qd.is_open(), "Dialog closed after selection on page 2") - - qd.queue_free() - host.queue_free() - client.queue_free() - -# --- Test 3: [INPUT] Submission Flow --- -func _test_input_submission() -> void: - print("\n--- Test 3: [INPUT] Submission Flow ---") - var client := MockClient.new() - var host := Control.new() - root.add_child(host) - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, host) - - client.script_dialog.emit(0, "请输入暗号密码:[INPUT]") - qd.process_events(100.0) - - _check(qd.is_open(), "Dialog with input opened") - var line_edit: LineEdit = null - var submit_btn: Button = null - for child in qd._btnrow.get_children(): - if child is LineEdit: - line_edit = child - elif child is Button and child.text == "确定": - submit_btn = child - - _check(line_edit != null, "LineEdit control created for [INPUT]") - _check(submit_btn != null, "Submit button created") - - line_edit.text = "metin2_open_sesame" - submit_btn.pressed.emit() - - _check(client.quest_inputs == ["metin2_open_sesame"], "Submitted input string sent via quest_input") - _check(not qd.is_open(), "Dialog closes after input submission") - - qd.queue_free() - host.queue_free() - client.queue_free() - -# --- Test 4: [NEXT] and [DONE] Protocols --- -func _test_next_and_done() -> void: - print("\n--- Test 4: [NEXT] and [DONE] Protocols ---") - var client := MockClient.new() - var host := Control.new() - root.add_child(host) - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, host) - - # 4.1: [NEXT] button - client.script_dialog.emit(0, "这是第一段剧情。[NEXT]") - qd.process_events(100.0) - var next_btn: Button = qd._btnrow.get_child(0) - _check(next_btn.text == "继续", "Button label is '继续'") - next_btn.pressed.emit() - _check(client.script_answers == [254], "[NEXT] click sends script_answer(254) to resume") - - # 4.2: [DONE] button - client.script_answers.clear() - var done_emitted := [false] - qd.done_event.connect(func(): - done_emitted[0] = true - ) - client.script_dialog.emit(0, "任务已经完成![DONE]") - qd.process_events(100.0) - var done_btn: Button = qd._btnrow.get_child(0) - _check(done_btn.text == "关闭", "Button label is '关闭'") - _check(done_emitted[0] and client.script_answers.is_empty(), - "[DONE] processing emits DoneEvent without script_answer") - done_btn.pressed.emit() - _check(client.script_answers.is_empty(), "[DONE] click only closes the dialog") - - qd.queue_free() - host.queue_free() - client.queue_free() - -# --- Test 5: ESC Key Cancel Flow --- -func _test_esc_cancel() -> void: - print("\n--- Test 5: ESC Key Cancel Flow (40250 OnCancel) ---") - var client := MockClient.new() - var host := Control.new() - root.add_child(host) - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, host) - - client.script_dialog.emit(0, "选择你的阵营:[QUESTION 1;红帝国|2;黄帝国|3;蓝帝国]") - qd.process_events(100.0) - _check(qd.is_open(), "Dialog open waiting for choice") - - # Simulate ESC key press - var esc := InputEventKey.new() - esc.keycode = KEY_ESCAPE - esc.pressed = true - qd._unhandled_input(esc) - - _check(client.script_answers == [2] and client.quest_cancels == 0, - "ESC on QUESTION sends the last answer and closes") - _check(not qd.is_open(), "Dialog closed on ESC") - - # BUTTON_TYPE_CANCEL is also answer 254 in ClientVS22; it is not the - # separate 255 script-cancel sentinel used by the native helper. - client.script_dialog.emit(0, "等待确认[CONFIRM_WAIT timeout(30)]") - qd.process_events(100.0) - var cancel_btn: Button = qd._btnrow.get_child(0) - cancel_btn.pressed.emit() - _check(client.script_answers == [2, 254] and client.quest_cancels == 0, - "CONFIRM_WAIT cancel sends script_answer(254)") - _check(not qd.is_open(), "CONFIRM_WAIT cancel closes dialog") - - qd.queue_free() - host.queue_free() - client.queue_free() - -# --- Test 6: Skin Modes (40250 uiquest.py:328) --- -func _test_skin_modes() -> void: - print("\n--- Test 6: Skin Modes (NORMAL vs NOWINDOW vs CINEMA) ---") - var client := MockClient.new() - var host := Control.new() - root.add_child(host) - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, host) - - # 6.1: SKIN_NORMAL (0) - client.script_dialog.emit(0, "普通对话框[DONE]") - qd.process_events(100.0) - _check(qd._panel.visible, "SKIN_NORMAL displays background panel") - - # 6.2: SKIN_CINEMA (5) - client.script_dialog.emit(5, "电影过场模式[DONE]") - qd.process_events(100.0) - _check(not qd._panel.visible, "SKIN_CINEMA hides background panel") - - # 6.3: SKIN_NOWINDOW (1) - client.script_dialog.emit(1, "无边框纯文本模式[DONE]") - qd.process_events(100.0) - _check(not qd._panel.visible, "SKIN_NOWINDOW hides background panel") - - qd.queue_free() - host.queue_free() - client.queue_free() - -# --- Test 7: Left Image Layout Offset (LEFTIMAGE) --- -func _test_left_image_layout() -> void: - print("\n--- Test 7: Left Image Portrait Layout Offset ---") - var client := MockClient.new() - var host := Control.new() - root.add_child(host) - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, host) - - # Dialog without portrait - client.script_dialog.emit(0, "无立绘对话[DONE]") - qd.process_events(100.0) - var default_text_x: float = qd._text.position.x - _check(default_text_x == 16.0, "Default text position x is 16.0") - - # Dialog with LEFTIMAGE - var script := "[LEFTIMAGE src;ui/game/questboard/npc_guard.sub]守卫向你致敬:[ENTER]城外近期有野狼出没,请多加小心![DONE]" - client.script_dialog.emit(0, script) - qd.process_events(100.0) - - # Even if texture is dummy/mocked, layout handles parsed LEFTIMAGE panel spec - var parsed: Dictionary = qd.parse_script(script) - _check(parsed.images.size() == 1 and parsed.images[0]["panel"] == "LEFTIMAGE", "Parsed LEFTIMAGE correctly") - - qd.queue_free() - host.queue_free() - client.queue_free() - -# --- Test 8: Quest Button & Quest Log Integration --- -func _test_quest_button_and_log() -> void: - print("\n--- Test 8: Quest Button & Quest Log Flow ---") - var client := MockClient.new() - var host := Control.new() - root.add_child(host) - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, host) - - var ql := QuestLog.new() - root.add_child(ql) - ql.setup(client, host) - - qd.quest_button_received.connect(func(idx, title, itype, iname): - ql.recv_quest(idx, title, itype, iname)) - - # Script emits QUESTBUTTON - var script := "[QUESTBUTTON idx(7) name(\"消灭恶狼\") icon_type(\"highlight\") icon_name(\"\")][DONE]" - client.script_dialog.emit(0, script) - qd.process_events(100.0) - - _check(ql._quest_button_data.has(7), "QuestLog received quest button with index 7") - _check(ql._quest_button_data[7]["title"] == "消灭恶狼", "Quest title is '消灭恶狼'") - - # Player clicks quest letter / button on sidebar - ql._start_quest(7) - _check(client.script_buttons == [7], "Clicking quest button sends script_button(7)") - _check(not ql._quest_button_data.has(7), "Quest button cleared from active list after click") - - ql.queue_free() - qd.queue_free() - host.queue_free() - client.queue_free() - -# --- Test 9: Quest Confirm (GC_QUEST_CONFIRM) --- -func _test_quest_confirm() -> void: - print("\n--- Test 9: Quest Confirmation Modal Flow ---") - var client := MockClient.new() - var host := Control.new() - root.add_child(host) - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, host) - - # Server asks confirmation (e.g. party dungeon warp) - client.quest_confirm_ask.emit("是否确认进入地牢副本?", 30, 99999) - _check(qd.is_open(), "Confirm dialog opened") - _check(qd._text.text == "是否确认进入地牢副本?", "Dialog message matches") - - # Find "接受" button - var accept_btn: Button = null - for row_child in qd._btnrow.get_children(): - if row_child is HBoxContainer: - for b in row_child.get_children(): - if b is Button and b.text == "接受": - accept_btn = b - - _check(accept_btn != null, "'接受' button exists") - accept_btn.pressed.emit() - _check(client.quest_confirms == [{"yes": true, "pid": 99999}], "Clicking 接受 sends quest_confirm(true, 99999)") - _check(not qd.is_open(), "Confirm dialog closes after decision") - - qd.queue_free() - host.queue_free() - client.queue_free() - -# --- Test 10: RUN_CINEMA missing resource follows RegisterEventSet failure --- -func _test_missing_cinema_does_not_open_dialog() -> void: - print("\n--- Test 10: Missing RUN_CINEMA resource ---") - var client := MockClient.new() - var host := Control.new() - root.add_child(host) - var qd := QuestDialog.new() - root.add_child(qd) - qd.setup(client, host) - var opened_count := [0] - qd.opened.connect(func(): opened_count[0] += 1) - - # CPythonEventManager::RegisterEventSet("missing.msc") returns -1; - # RecvScriptPacket therefore skips OnScriptEventStart. - qd.begin_script('[RUN_CINEMA value("__missing_40250_test__.msc")]') - _check(not qd.is_open(), "Missing RUN_CINEMA resource does not open an empty dialog") - _check(not qd._active_script and opened_count[0] == 0, - "Missing RUN_CINEMA resource leaves no active script or opened signal") - - qd.queue_free() - host.queue_free() - client.queue_free() diff --git a/project/test_quest_dialog_parity.gd.uid b/project/test_quest_dialog_parity.gd.uid deleted file mode 100644 index 70752c7c..00000000 --- a/project/test_quest_dialog_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ccau3qbmw1738 diff --git a/project/test_quest_hud_parity.gd b/project/test_quest_hud_parity.gd deleted file mode 100644 index e7d2d7ae..00000000 --- a/project/test_quest_hud_parity.gd +++ /dev/null @@ -1,176 +0,0 @@ -# test_quest_hud_parity.gd —— 任务剧情与信件卷轴系统 1:1 回归测试套件 -# 运行方式: -# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_quest_hud_parity.gd -extends SceneTree - -const UiManager = preload("res://ui/ui_manager.gd") -const CharStatusUI = preload("res://ui/char_status_ui.gd") -const QuestLog = preload("res://ui/quest_log.gd") - -class MockClient extends Node: - signal quest_info(index: int) - var sent_script_buttons: Array[int] = [] - var quests: Array = [] - - func get_quests() -> Array: - return quests - - func script_button(idx: int) -> void: - sent_script_buttons.append(idx) - - # Required dummy methods for CharStatusUI setup - func get_points() -> Dictionary: - var pts: Array = [] - pts.resize(255) - pts.fill(0) - return {"points": pts, "hp": 100, "max_hp": 100, "sp": 50, "max_sp": 50, "level": 1, "exp": 0, "next_exp": 100, "gold": 0} - - func get_main_vid() -> int: return 1 - func get_entity(_vid: int) -> Dictionary: return {"name": "Hero", "guild": 0, "race": 0} - func get_guild_name(_gid: int) -> String: return "" - func get_skills() -> Array: return [] - func get_skill_group() -> int: return 0 - -var _checks_passed := 0 -var _checks_failed := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _checks_passed += 1 - print(" [PASS] %s" % msg) - else: - _checks_failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_quest_hud_parity (Direction 1: Quest HUD & Dialog 1:1) ===") - _test_quest_log_layout() - _test_quest_log_buttons() - _test_char_status_quest_page() - _finish() - -func _test_quest_log_layout() -> void: - print("\n--- Test 1: 40250 Quest Button Mathematical Layout (interfacemodule.py) ---") - # 800x600 screen - var y_count_600 := QuestLog.quest_button_y_count(600.0) - # (600 - 330) // 63 = 270 // 63 = 4 - _assert(y_count_600 == 4, "Screen height 600 yields yCount = 4") - - var y_pos_600 := QuestLog.quest_button_y_pos(600.0) - # 170 * 600 // 600 = 170 - _assert(y_pos_600 == 170, "Screen height 600 yields yPos = 170") - - # Slot positions - # slot 0: col 0, row 0 -> (20, 170) - var pos0 := QuestLog.quest_button_slot_pos(0, y_count_600, 20, y_pos_600) - _assert(pos0 == Vector2(20, 170), "Slot 0 position is (20, 170)") - - # slot 3: col 0, row 3 -> (20, 170 + 3*63 = 359) - var pos3 := QuestLog.quest_button_slot_pos(3, y_count_600, 20, y_pos_600) - _assert(pos3 == Vector2(20, 359), "Slot 3 position is (20, 359)") - - # slot 4: col 1, row 0 -> (20 + 100 = 120, 170) - var pos4 := QuestLog.quest_button_slot_pos(4, y_count_600, 20, y_pos_600) - _assert(pos4 == Vector2(120, 170), "Slot 4 column wrap position is (120, 170)") - -func _test_quest_log_buttons() -> void: - print("\n--- Test 2: Quest Buttons HUD & Ctrl+Q Toggle ---") - var client := MockClient.new() - get_root().add_child(client) - - var ql: Node = QuestLog.new() - get_root().add_child(ql) - ql.setup(client, get_root()) - - _assert(ql.quest_buttons_visible() == true, "Quest buttons visible by default") - ql.toggle_buttons() # Ctrl+Q - _assert(ql.quest_buttons_visible() == false, "toggle_buttons (Ctrl+Q) hides quest buttons") - ql.toggle_buttons() # Ctrl+Q again - _assert(ql.quest_buttons_visible() == true, "toggle_buttons (Ctrl+Q) restores quest buttons") - - # Adding quest buttons (BINARY_RecvQuest) - ql.recv_quest(101, "初入江湖", "file", "") - ql.recv_quest(102, "消灭野狼", "file", "") - _assert(ql.quest_button_count() == 2, "2 quest buttons recorded") - var order: Array[int] = ql.quest_button_order() - _assert(order[0] == 102 and order[1] == 101, "Latest quest button inserted at front (1:1 40250)") - - ql.queue_free() - client.queue_free() - -func _test_char_status_quest_page() -> void: - print("\n--- Test 3: CharStatusUI Quest Page 1:1 Interaction & Protocol ---") - var assets := AssetRoot.path() - if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/characterwindow.py")): - print(" (skip: no characterwindow.py)") - return - - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - - var client := MockClient.new() - get_root().add_child(client) - - # Seed 7 quests to test pagination (> 5) - for i in range(7): - client.quests.append({ - "index": 100 + i, - "title": "任务_%d" % i, - "counter_name": "击杀数" if i == 0 else "", - "counter_value": 5 if i == 0 else 0, - "clock_name": "限时" if i == 1 else "", - "clock_value": 125 if i == 1 else 0, - "clock_start_ms": Time.get_ticks_msec() - 121000 if i == 1 else 0, - }) - - var cs: Node = CharStatusUI.new() - get_root().add_child(cs) - cs.setup(ui, client, assets) - cs.open("QUEST") - _assert(cs.is_open(), "Character window opened in QUEST state") - - # Check Scrollbar visibility (> 5 quests) - var sb = cs._node("Quest_ScrollBar") - _assert(sb != null and sb.visible == true, "Quest_ScrollBar visible when quests > 5") - _assert(sb.max_value == 2, "Quest_ScrollBar max_value is 2 (7 - 5)") - - # Check labels for first page (0..4) - var name0 = cs._node("Quest_Name_00") - _assert(name0 != null and name0.text == "任务_0", "Quest_Name_00 shows '任务_0'") - - var cnt0 = cs._node("Quest_LastCount_00") - _assert(cnt0 != null and cnt0.text == "击杀数 : 5", "Quest_LastCount_00 shows counter '击杀数 : 5'") - - var time1 = cs._node("Quest_LastTime_01") - _assert(time1 != null and time1.text == "限时 00:04", - "Quest_LastTime_01 derives remaining time from the captured quest start (got %s)" % (time1.text if time1 else "")) - - # Click on Quest_Name_00: should send script_button(-2147483648 + 100) - var ev := InputEventMouseButton.new() - ev.button_index = MOUSE_BUTTON_LEFT - ev.pressed = true - name0.gui_input.emit(ev) - - _assert(client.sent_script_buttons.size() == 1, "Clicking quest row sent script_button") - var expected_val: int = -2147483648 + 100 - _assert(client.sent_script_buttons[0] == expected_val, "script_button value is -2147483648 + 100 (%d)" % expected_val) - - # Scroll to next page - sb.value = 2 # showing start index = 2 - sb.value_changed.emit(2.0) - _assert(name0.text == "任务_2", "After scroll, Quest_Name_00 shows '任务_2'") - - cs.close() - cs.queue_free() - client.queue_free() - ui.queue_free() - -func _finish() -> void: - print("\n=== SUMMARY: %d passed, %d failed ===" % [_checks_passed, _checks_failed]) - if _checks_failed == 0: - print("ALL TESTS PASSED!\n") - quit(0) - else: - printerr("SOME TESTS FAILED!\n") - quit(1) diff --git a/project/test_quest_hud_parity.gd.uid b/project/test_quest_hud_parity.gd.uid deleted file mode 100644 index b6a61d98..00000000 --- a/project/test_quest_hud_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d3kjt1ms3y2e3 diff --git a/project/test_refine_parity.gd b/project/test_refine_parity.gd deleted file mode 100644 index 0d9ac376..00000000 --- a/project/test_refine_parity.gd +++ /dev/null @@ -1,178 +0,0 @@ -# test_refine_parity.gd —— 40250 强化系统 1:1 纯净无头自检套件 -# 验证: -# 1. RefineDialogNew 动态几何尺寸计算与 Tooltip -# 2. 材料槽位与 ThinBoard,背包存量白字/红字对比 -# 3. 金币费用与不足警示 -# 4. 100% 成功率与 type==5 免弹窗直接发包 -# 5. type==3 (铁匠石)、type==2 (祝福卷轴)、type==0 (普通铁匠) 对应专属 QuestionDialog2 文案 -# 6. 二次确认弹窗 "否" 拦截发包、"是" 触发发包 -# 7. 40250 取消规范:点击取消/ESC/关闭标题栏发送 refine(255, 255) -# 8. 10.0m 超距自动取消发包 -# 9. 强化成功与失败音画回显 -extends SceneTree - -const RefineUI = preload("res://ui/refine_ui.gd") - -class FakeAudio extends Node: - var calls: Array[String] = [] - func play_ui(name: String) -> void: - calls.append(name) - -class FakeClient extends Node: - signal refine_ask(info: Dictionary) - signal refine_result(ok: bool) - - var calls: Array = [] - var gold := 1000000 - var inventory: Array = [ - {"vnum": 30001, "count": 5, "cell": 0, "window": 1}, - {"vnum": 30002, "count": 1, "cell": 1, "window": 1}, - ] - var main_pos := Vector3(100.0, 10.0, 100.0) - - func is_in_game() -> bool: return true - func get_gold() -> int: return gold - func get_inventory() -> Array: return inventory - func get_main_vid() -> int: return 1001 - func get_entity(vid: int) -> Dictionary: - if vid == 1001: - return {"pos": main_pos} - return {} - func refine(pos: int, type: int) -> bool: - calls.append(["refine", pos, type]) - return true - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: test_refine_parity (40250 RefineDialogNew 1:1 parity)") - quit(0) - else: - printerr("%d check(s) failed in test_refine_parity" % _fail) - quit(1) - -func _run() -> void: - var canvas := CanvasLayer.new() - get_root().add_child(canvas) - var fc := FakeClient.new() - get_root().add_child(fc) - var fa := FakeAudio.new() - get_root().add_child(fa) - - var ru: Node = RefineUI.new() - get_root().add_child(ru) - ru.setup(fc, canvas, null, fa) - - _ck(not ru.is_open(), "1. 初始状态关闭") - - # --- Case 1: 普通强化 (type 0, prob 60 < 100, 材料足) --- - fc.refine_ask.emit({ - "type": 0, "pos": 5, "src_vnum": 10, "result_vnum": 11, - "cost": 50000, "prob": 60, - "materials": [{"vnum": 30001, "count": 2}], - }) - _ck(ru.is_open(), "2. 收到 refine_ask 打开窗口") - _ck(ru._prob_label.text == "成功率: 60%", "3. 成功率文本准确") - _ck(ru._cost_label.text == "费用: 50000 金", "4. 费用文本准确") - _ck(ru._material_container.get_child_count() == 1, "5. 材质行生成") - var mat_lbl: Label = ru._material_container.get_child(0).get_child(1).get_child(0) - _ck(mat_lbl.modulate.r < 0.95 and mat_lbl.modulate.g > 0.8, "6. 材料足 (5>=2) 显示白字") - - # 点击精炼 -> 应当弹出 QuestionDialog2,而不是直接发包 - ru._refine_button.pressed.emit() - _ck(ru._question_dialog.visible, "7. prob 60 < 100 必须弹出 QuestionDialog2") - _ck(ru._qd_message1.text.contains("销毁"), "8. type 0 对应彻底销毁文案") - _ck(fc.calls.is_empty(), "9. 弹窗期间未发包") - - # 点击 "否" -> 取消确认,精炼窗口不关,不发包 - ru._qd_cancel_btn.pressed.emit() - _ck(not ru._question_dialog.visible, "10. 点否关闭确认弹窗") - _ck(ru.is_open(), "11. 主强化窗依然保持开启") - _ck(fc.calls.is_empty(), "12. 依然未发包") - - # 再次点击精炼,并点 "是" -> 确认发包 - ru._refine_button.pressed.emit() - ru._qd_accept_btn.pressed.emit() - _ck(fc.calls == [["refine", 5, 0]], "13. 确认后发出 client.refine(pos=5, type=0)") - _ck(not ru.is_open(), "14. 确认后主窗关闭") - fc.calls.clear() - - # --- Case 2: 100% 成功率免弹窗 --- - fc.refine_ask.emit({ - "type": 0, "pos": 2, "src_vnum": 1, "result_vnum": 2, - "cost": 1000, "prob": 100, "materials": [], - }) - ru._refine_button.pressed.emit() - _ck(not ru._question_dialog.visible, "15. 100% 成功率不弹 QuestionDialog2") - _ck(fc.calls == [["refine", 2, 0]], "16. 100% 成功率直接发出 refine(2, 0)") - _ck(not ru.is_open(), "17. 发包后关闭") - fc.calls.clear() - - # --- Case 3: 祝福卷轴 (type 2) 降级文案 --- - fc.refine_ask.emit({ - "type": 2, "pos": 10, "src_vnum": 15, "result_vnum": 16, - "cost": 10000, "prob": 30, "materials": [], - }) - ru._refine_button.pressed.emit() - _ck(ru._question_dialog.visible, "18. type 2 弹确认框") - _ck(ru._qd_message1.text.contains("降低1级"), "19. type 2 对应降级文案") - ru._qd_cancel_btn.pressed.emit() - - # 点击取消按钮 -> 40250 发送 refine(255, 255) - ru._cancel_button.pressed.emit() - _ck(not ru.is_open(), "20. 取消后关闭") - _ck(fc.calls == [["refine", 255, 255]], "21. 取消发包 refine(255, 255)") - fc.calls.clear() - - # --- Case 4: 铁匠石 (type 3) 加成文案 --- - fc.refine_ask.emit({ - "type": 3, "pos": 12, "src_vnum": 18, "result_vnum": 19, - "cost": 20000, "prob": 50, "materials": [], - }) - ru._refine_button.pressed.emit() - _ck(ru._qd_message1.text.contains("加成") or ru._qd_message1.text.contains("提升"), "22. type 3 对应概率提升文案") - # ESC 取消 -> 发送 refine(255, 255) - ru.cancel_refine() - _ck(not ru.is_open() and fc.calls == [["refine", 255, 255]], "23. cancel_refine 发送 (255, 255)") - fc.calls.clear() - - # --- Case 5: 材料不足红字标红 --- - fc.refine_ask.emit({ - "type": 0, "pos": 1, "src_vnum": 1, "result_vnum": 2, - "cost": 1000, "prob": 80, - "materials": [{"vnum": 30002, "count": 5}], # 背包只有 1 个 - }) - var shortage_lbl: Label = ru._material_container.get_child(0).get_child(1).get_child(0) - _ck(shortage_lbl.modulate.r > 0.9 and shortage_lbl.modulate.g < 0.4, "24. 材料不足 (1<5) 显示红字") - ru.close() - - # --- Case 6: 10 米超距自动发包取消 --- - fc.refine_ask.emit({ - "type": 0, "pos": 1, "src_vnum": 1, "result_vnum": 2, - "cost": 1000, "prob": 80, "materials": [], - }) - _ck(ru.is_open(), "25. 再次打开") - # 玩家移动 5 米 (仍在 10 米内) - fc.main_pos = Vector3(105.0, 10.0, 100.0) - ru._process(0.1) - _ck(ru.is_open(), "26. 移动 5 米不关闭") - # 玩家移动 15 米 (超出 10 米) - fc.main_pos = Vector3(120.0, 10.0, 100.0) - ru._process(0.1) - _ck(not ru.is_open(), "27. 移动 15 米超出限制自动关闭") - _ck(fc.calls == [["refine", 255, 255]], "28. 超距自动发送取消包 refine(255, 255)") - fc.calls.clear() - - # --- Case 7: 结果音效与状态回显 --- - fc.refine_result.emit(true) - _ck(ru.is_open() and ru._prob_label.text.contains("成功"), "29. 成功结果回显") - _ck(fa.calls.has("refine_success.wav"), "30. 播放 refine_success.wav") - fc.refine_result.emit(false) - _ck(ru._prob_label.text.contains("失败"), "31. 失败结果回显") - _ck(fa.calls.has("refine_fail.wav"), "32. 播放 refine_fail.wav") diff --git a/project/test_refine_parity.gd.uid b/project/test_refine_parity.gd.uid deleted file mode 100644 index afad7faf..00000000 --- a/project/test_refine_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://0l2a72rimw1u diff --git a/project/test_safebox_mall_parity.gd b/project/test_safebox_mall_parity.gd deleted file mode 100644 index 3915f151..00000000 --- a/project/test_safebox_mall_parity.gd +++ /dev/null @@ -1,380 +0,0 @@ -extends SceneTree - -const SafeboxUI = preload("res://ui/safebox_ui.gd") -const MallUI = preload("res://ui/mall_ui.gd") - -var _pass := 0 -var _fail := 0 - -func _ck(ok: bool, msg: String) -> void: - if ok: - _pass += 1 - print(" [PASS] %s" % msg) - else: - _fail += 1 - printerr(" [FAIL] %s" % msg) - -class MockProto extends Node: - func item(vnum: int) -> Dictionary: - match vnum: - 10: return {"name": "Sword+0", "locale_name": "单手剑+0", "type": 1, "sub_type": 0} - 19: return {"name": "Sword+9", "locale_name": "单手剑+9", "type": 1, "sub_type": 0} - 27001: return {"name": "Red Potion(S)", "locale_name": "小型红药水", "type": 3, "sub_type": 0} - 27993: return {"name": "Pearl", "locale_name": "珍珠", "type": 3, "sub_type": 0} - _: return {"name": "Item%d" % vnum, "locale_name": "道具%d" % vnum, "type": 3, "sub_type": 0} - -class MockMouseController extends Node: - var _attached: Dictionary = {} - var _targets: Dictionary = {} - - func is_attached() -> bool: - return not _attached.is_empty() - - func attached() -> Dictionary: - return _attached - - func attach_item(win: int, cell: int, vnum: int, count: int, _tex = null, _owner = null) -> void: - _attached = {"window": win, "cell": cell, "vnum": vnum, "count": count} - - func cancel() -> void: - _attached.clear() - - func register_owner(_o) -> void: - pass - - func unregister_owner(_o) -> void: - pass - - func register_target(rect: Control, cb: Callable, _owner = null) -> void: - _targets[rect] = cb - - func unregister_target(rect: Control) -> void: - _targets.erase(rect) - -class MockClient extends Node: - signal safebox_opened(size: int) - signal safebox_closed() - signal safebox_changed() - signal safebox_password_required() - signal safebox_wrong_password() - signal mall_opened(size: int) - signal mall_closed() - signal mall_changed() - signal mall_password_required() - signal inventory_changed() - - var _safebox_open := false - var _safebox_size := 1 - var _safebox_gold := 0 - var _safebox_items := [] - var _mall_open := false - var _mall_size := 45 - var _mall_items := [] - var _inventory := [] - var _char_pos := Vector3.ZERO - var _main_vid := 10001 - var calls: Array[Dictionary] = [] - - func get_main_vid() -> int: return _main_vid - - func get_entity(vid: int) -> Dictionary: - if vid == _main_vid: - return {"vid": vid, "pos": _char_pos} - return {} - - func is_safebox_open() -> bool: return _safebox_open - func get_safebox_size() -> int: return _safebox_size - func get_safebox_gold() -> int: return _safebox_gold - func get_safebox_items() -> Array: return _safebox_items - - 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 get_inventory() -> Array: return _inventory - - func safebox_checkin(safe_pos: int, inv_window: int, inv_cell: int) -> bool: - calls.append({"method": "safebox_checkin", "args": [safe_pos, inv_window, inv_cell]}) - return true - - func safebox_checkout(safe_pos: int, inv_window: int, inv_cell: int) -> bool: - calls.append({"method": "safebox_checkout", "args": [safe_pos, inv_window, inv_cell]}) - return true - - func safebox_move(from_cell: int, to_cell: int, count: int) -> bool: - calls.append({"method": "safebox_move", "args": [from_cell, to_cell, count]}) - return true - - func safebox_close() -> bool: - _safebox_open = false - calls.append({"method": "safebox_close", "cmd": "/safebox_close"}) - return true - - func mall_checkout(mall_pos: int, inv_window: int, inv_cell: int) -> bool: - calls.append({"method": "mall_checkout", "args": [mall_pos, inv_window, inv_cell]}) - return true - - func mall_close() -> bool: - _mall_open = false - calls.append({"method": "mall_close", "cmd": "/mall_close"}) - return true - - func safebox_password(pw: String) -> bool: - calls.append({"method": "safebox_password", "cmd": "/safebox_password " + pw}) - return true - - func safebox_change_password(old_pw: String, new_pw: String) -> bool: - calls.append({"method": "safebox_change_password", "cmd": "/safebox_change_password %s %s" % [old_pw, new_pw]}) - return true - - func mall_password(pw: String) -> bool: - calls.append({"method": "mall_password", "cmd": "/mall_password " + pw}) - return true - -func _init() -> void: - print("\n=== Running test_safebox_mall_parity (Phase 4 Extension Parity) ===") - _run_all() - print("\n=======================================================") - if _fail == 0: - print("=== ALL SAFEBOX & MALL PARITY TESTS PASSED (%d checks) ===" % _pass) - quit(0) - else: - printerr("=== SOME CHECKS FAILED: %d pass, %d fail ===" % [_pass, _fail]) - quit(1) - -func _run_all() -> void: - var client := MockClient.new() - var proto := MockProto.new() - var mouse := MockMouseController.new() - var host := Control.new() - get_root().add_child(client) - get_root().add_child(mouse) - get_root().add_child(host) - - # ------------------------------------------------------------- - # Test 1: Safebox Open/Close, Auto-Open, and 10.0m Auto-Close - # ------------------------------------------------------------- - print("\n--- Test 1: Safebox Open/Close & 10.0m Limit Range (40250 uisafebox.py) ---") - var safe := SafeboxUI.new() - safe.item_mouse = mouse - host.add_child(safe) - safe.setup(client, host, proto) - - _ck(not safe.is_open(), "Safebox initially closed") - client._char_pos = Vector3(100.0, 50.0, 0.0) - client._safebox_open = true - client._safebox_size = 3 - client.safebox_opened.emit(3) - - _ck(safe.is_open(), "Safebox auto-opened on safebox_opened signal") - _ck(safe._has_open_pos and safe._open_char_pos == Vector3(100.0, 50.0, 0.0), "Safebox recorded opening character position (100, 50, 0)") - _ck(safe._page_buttons.size() == 3, "Safebox has 3 pages configured") - - # Move within 5m - client._char_pos = Vector3(104.0, 50.0, 0.0) - safe._process(0.016) - _ck(safe.is_open(), "Safebox remains open at 4.0m distance") - - # Move beyond 10m - client._char_pos = Vector3(112.0, 50.0, 0.0) - safe._process(0.016) - _ck(not safe.is_open(), "Safebox auto-closed after exceeding 10.0m limit range") - var has_safe_close := false - for c in client.calls: - if c.get("method") == "safebox_close" and c.get("cmd") == "/safebox_close": - has_safe_close = true - break - _ck(has_safe_close, "Safebox auto-close sent /safebox_close chat command to server") - - # Server closes safebox - client._safebox_open = true - safe.open() - _ck(safe.is_open(), "Safebox re-opened") - client.calls.clear() - client._safebox_open = false - client.safebox_closed.emit() - _ck(not safe.is_open(), "Safebox closed on safebox_closed server signal") - _ck(client.calls.is_empty(), "Safebox closed by server does not send /safebox_close back") - - # ------------------------------------------------------------- - # Test 2: Mall Open/Close & 10.0m Auto-Close - # ------------------------------------------------------------- - print("\n--- Test 2: Mall Open/Close & 10.0m Limit Range (40250 uisafebox.py MallWindow) ---") - var mall := MallUI.new() - mall.item_mouse = mouse - host.add_child(mall) - mall.setup(client, host, proto) - - _ck(not mall.is_open(), "Mall initially closed") - client._char_pos = Vector3(200.0, 80.0, 0.0) - client._mall_open = true - client._mall_size = 45 - client.mall_opened.emit(45) - - _ck(mall.is_open(), "Mall auto-opened on mall_opened signal") - _ck(mall._has_open_pos and mall._open_char_pos == Vector3(200.0, 80.0, 0.0), "Mall recorded opening character position (200, 80, 0)") - - # Move within 6m - client._char_pos = Vector3(200.0, 86.0, 0.0) - mall._process(0.016) - _ck(mall.is_open(), "Mall remains open at 6.0m distance") - - # Move beyond 10m - client._char_pos = Vector3(200.0, 95.0, 0.0) - client.calls.clear() - mall._process(0.016) - _ck(not mall.is_open(), "Mall auto-closed after exceeding 10.0m limit range") - var has_mall_close := false - for c in client.calls: - if c.get("method") == "mall_close" and c.get("cmd") == "/mall_close": - has_mall_close = true - break - _ck(has_mall_close, "Mall auto-close sent /mall_close chat command to server") - - # Server closes mall - client._mall_open = true - mall.open() - _ck(mall.is_open(), "Mall re-opened") - client.calls.clear() - client._mall_open = false - client.mall_closed.emit() - _ck(not mall.is_open(), "Mall closed on mall_closed server signal") - - # ------------------------------------------------------------- - # Test 3: Sockets & Attrs Data Preservation - # ------------------------------------------------------------- - print("\n--- Test 3: Sockets & Attrs Data Preservation in Safebox and Mall ---") - var rich_item := { - "cell": 0, - "vnum": 19, - "count": 1, - "flags": 0, - "anti_flags": 0, - "sockets": [28430, 28431, 0], - "attrs": [ - {"type": 1, "value": 500}, - {"type": 5, "value": 12} - ] - } - client._safebox_items = [rich_item] - client._mall_items = [rich_item] - client._safebox_open = true - client._mall_open = true - safe.refresh() - mall.refresh() - - var safe_tt := safe._tooltip_for(rich_item) - var mall_tt := mall._tooltip_for(rich_item) - _ck(safe_tt.contains("500") or safe_tt.contains("HP") or safe_tt.contains("生命"), "Safebox tooltip includes attribute 1 (HP +500)") - _ck(safe_tt.contains("12") or safe_tt.contains("STR") or safe_tt.contains("力量"), "Safebox tooltip includes attribute 2 (STR +12)") - _ck(mall_tt.contains("500") or mall_tt.contains("HP") or mall_tt.contains("生命"), "Mall tooltip includes attribute 1 (HP +500)") - _ck(mall_tt.contains("12") or mall_tt.contains("STR") or mall_tt.contains("力量"), "Mall tooltip includes attribute 2 (STR +12)") - _ck(String(safe._cells[0].tooltip_text) == "" and String(mall._cells[0].tooltip_text) == "", - "Safebox and Mall item slots do not create native duplicate tooltips") - safe._tooltip_bridge.show_item(safe._cells[0].get_meta("tooltip_item", {})) - _ck(safe._tooltip_bridge._view != null and safe._tooltip_bridge._view.visible, - "Safebox item hover uses the custom ItemTooltipView") - safe._tooltip_bridge.hide() - _ck(not safe._tooltip_bridge._view.visible, "Safebox leaving the slot hides the shared tooltip") - - # ------------------------------------------------------------- - # Test 4: Safebox Operations (Checkin, Checkout, Move) - # ------------------------------------------------------------- - print("\n--- Test 4: Safebox Operations (40250 Checkin, Checkout, Move) ---") - client._safebox_open = true - safe.open() - client.calls.clear() - client._inventory = [{"cell": 5, "vnum": 27001, "count": 20}] # Free slot 0 is empty - - # Drag drop from inventory (win 1, cell 5) into empty safebox slot 8 - var drop_payload := {"window": 1, "cell": 5, "vnum": 27001, "count": 20} - var drop_res := safe._drop_to_slot(drop_payload, 8) - _ck(drop_res, "Dropping inventory item into empty Safebox slot succeeds") - _ck(client.calls.size() > 0 and client.calls[0]["method"] == "safebox_checkin" and client.calls[0]["args"] == [8, 1, 5], - "Drop sent safebox_checkin(safe_pos=8, inv_win=1, inv_cell=5)") - - # Right-click inventory item -> deposit to next free safebox slot - client.calls.clear() - client._safebox_items = [{"cell": 0, "vnum": 10, "count": 1}] # Next free is 1 - safe.deposit(1, 5) - _ck(client.calls.size() > 0 and client.calls[0]["method"] == "safebox_checkin" and client.calls[0]["args"] == [1, 1, 5], - "Right-clicking inventory item triggers deposit into next free safebox slot 1") - - # Right-click safebox slot -> checkout to first free inventory slot - client.calls.clear() - var ev_rc := InputEventMouseButton.new() - ev_rc.button_index = MOUSE_BUTTON_RIGHT - ev_rc.pressed = true - safe._on_cell_input(0, ev_rc) - _ck(client.calls.size() > 0 and client.calls[0]["method"] == "safebox_checkout" and client.calls[0]["args"] == [0, 1, 0], - "Right-clicking safebox slot 0 triggers checkout to inventory free slot 0") - - # Safebox internal slot move (e.g. from cell 0 to cell 6) - client.calls.clear() - var safe_move_payload := {"window": 3, "cell": 0, "vnum": 10, "count": 1} - var move_res := safe._drop_to_slot(safe_move_payload, 6) - _ck(move_res, "Moving item between safebox slots succeeds") - _ck(client.calls.size() > 0 and client.calls[0]["method"] == "safebox_move" and client.calls[0]["args"] == [0, 6, 1], - "Internal safebox move sent safebox_move(from=0, to=6, count=1)") - - # ------------------------------------------------------------- - # Test 5: Mall Operations and MALL_CANNOT_INSERT Rejection - # ------------------------------------------------------------- - print("\n--- Test 5: Mall Operations & MALL_CANNOT_INSERT (40250 uisafebox.py:640) ---") - client.calls.clear() - client._inventory = [{"cell": 2, "vnum": 10, "count": 1}] # Free slot 0 is empty - client._mall_items = [{"cell": 4, "vnum": 27993, "count": 5}] - mall.refresh() - - # Right-click mall slot 4 -> checkout to inventory free slot 0 - var ev_mall_rc := InputEventMouseButton.new() - ev_mall_rc.button_index = MOUSE_BUTTON_RIGHT - ev_mall_rc.pressed = true - mall._on_grid_input(4, ev_mall_rc) - _ck(client.calls.size() > 0 and client.calls[0]["method"] == "mall_checkout" and client.calls[0]["args"] == [4, 1, 0], - "Right-clicking mall slot 4 triggers mall_checkout(mall_pos=4, inv_win=1, inv_cell=0)") - - # Attempt to deposit inventory item into mall slot -> rejected - client.calls.clear() - mouse.attach_item(1, 2, 10, 1) - var ev_mall_drop := InputEventMouseButton.new() - ev_mall_drop.button_index = MOUSE_BUTTON_LEFT - ev_mall_drop.pressed = true - mall._on_grid_input(7, ev_mall_drop) - _ck(not mouse.is_attached(), "Dragging item into Mall cancels attached mouse item") - _ck(mall._status.text == "无法将物品放入商城仓库!", "Mall rejects insertion with exact 40250 warning (MALL_CANNOT_INSERT)") - _ck(client.calls.is_empty(), "No packet sent on rejected mall deposit attempt") - - # Attempt drop via _drop_to_slot - var mall_drop_res := mall._drop_to_slot({"window": 1, "cell": 2, "vnum": 10, "count": 1}, 7) - _ck(not mall_drop_res, "mall._drop_to_slot strictly returns false") - - # ------------------------------------------------------------- - # Test 6: Password Protocols and Error Notifications - # ------------------------------------------------------------- - print("\n--- Test 6: Password Protocols & Wrong Password (40250 /safebox_password) ---") - client.calls.clear() - client.safebox_password("000000") - _ck(client.calls.size() > 0 and client.calls[0]["cmd"] == "/safebox_password 000000", - "Safebox password sends /safebox_password 000000") - - client.calls.clear() - client.safebox_change_password("000000", "123456") - _ck(client.calls.size() > 0 and client.calls[0]["cmd"] == "/safebox_change_password 000000 123456", - "Safebox change password sends /safebox_change_password 000000 123456") - - client.calls.clear() - client.mall_password("888888") - _ck(client.calls.size() > 0 and client.calls[0]["cmd"] == "/mall_password 888888", - "Mall password sends /mall_password 888888") - - client.safebox_wrong_password.emit() - _ck(safe._status.text == "仓库密码错误", "safebox_wrong_password signal shows 仓库密码错误") - - # Clean up - proto.queue_free() - safe.queue_free() - mall.queue_free() - client.queue_free() - mouse.queue_free() - host.queue_free() diff --git a/project/test_safebox_mall_parity.gd.uid b/project/test_safebox_mall_parity.gd.uid deleted file mode 100644 index 34c238ed..00000000 --- a/project/test_safebox_mall_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://e15w8667482o diff --git a/project/test_skill_ground_fix.gd b/project/test_skill_ground_fix.gd deleted file mode 100644 index 09524cbb..00000000 --- a/project/test_skill_ground_fix.gd +++ /dev/null @@ -1,154 +0,0 @@ -extends SceneTree - -const AssetRoot = preload("res://asset_root.gd") -const Quickbar = preload("res://ui/quickbar.gd") -const SkillTable = preload("res://ui/skill_table.gd") -const NetPlay = preload("res://net_play.gd") -const PlayerController = preload("res://player_controller.gd") - -func _init() -> void: - call_deferred("_run") - -func _run() -> void: - print("=== Running test_skill_ground_fix ===") - var fails := 0 - var check = func(cond: bool, msg: String): - if not cond: - fails += 1 - print("FAIL: ", msg) - else: - print("OK: ", msg) - - var assets_root := AssetRoot.path() - var st := SkillTable.new() - st.load_file(assets_root.path_join("locale/locale/en/skilldesc.txt")) - - # --- Test 1: Quickbar coordinate and heading parity with MapCoord --- - MapCoord.set_base(Vector2(409600, 896000)) - var player := Node3D.new() - player.position = Vector3(150.0, 175.3, 200.0) - player.rotation.y = deg_to_rad(45.0) - get_root().add_child(player) - - var ui := CanvasLayer.new() - get_root().add_child(ui) - - var client_mock = Node.new() - var script = GDScript.new() - script.source_code = """ -extends Node -var casts := [] -var uses := [] -func is_in_game() -> bool: return true -func get_target() -> Dictionary: return {"vid": 2000} -func get_main_vid() -> int: return 1 -func get_entity(_v: int) -> Dictionary: return {"race": 0, "kind": 2} -func get_skills() -> Array: return [{"id": 1, "level": 5, "master": 0}] -func use_skill(id: int, vid: int) -> bool: - uses.append([id, vid]) - return true -func cast_skill(mi: int, yaw: float, x: int, y: int, _arg: int = 0) -> bool: - casts.append([mi, yaw, x, y]) - return true -func quickslot_add(_slot: int, _type: int, _id: int) -> bool: - return true -""" - script.reload() - client_mock.set_script(script) - get_root().add_child(client_mock) - - var qb := Quickbar.new() - get_root().add_child(qb) - qb.setup(client_mock, st, ui, func() -> Node: return player) - # This fixture has no server quickslot callback; seed a local-only slot so - # the movement/heading assertions do not bypass the GC_QUICKSLOT contract. - qb.assign(0, "skill", 1, false) # Skill 1: samyeon (no target required) - - qb.activate(0) - - check.call(client_mock.get("casts").size() == 1, "activate sends cast_skill") - if client_mock.get("casts").size() == 1: - var c = client_mock.get("casts")[0] - var expected_yaw := MapCoord.yaw_to_heading(player.rotation.y) - var expected_xy := MapCoord.to_server_cm(player.position) - check.call(absf(c[1] - expected_yaw) < 0.01, "cast_skill heading matches MapCoord (%f vs %f)" % [c[1], expected_yaw]) - check.call(c[2] == int(expected_xy.x) and c[3] == int(expected_xy.y), - "cast_skill xy matches MapCoord.to_server_cm (%d, %d vs %d, %d)" % [c[2], c[3], int(expected_xy.x), int(expected_xy.y)]) - check.call(c[2] == 424600 and c[3] == 916000, "cast_skill contains map base offset (424600, 916000)") - - # --- Test 2: PlayerController height clamping when locked --- - var mock_world = Node.new() - var w_script = GDScript.new() - w_script.source_code = """ -extends Node -func sample_height(x: float, z: float) -> float: - # Sloped terrain: height increases with x and z - return 100.0 + x * 0.1 + z * 0.05 -""" - w_script.reload() - mock_world.set_script(w_script) - get_root().add_child(mock_world) - - var pc := PlayerController.new() - get_root().add_child(pc) - pc.player = player - pc.world = mock_world - pc.active = true - - # Set locked = true (as during skill cast) - pc.locked = true - player.position = Vector3(10.0, 50.0, 20.0) # Intentionally wrong y (50.0 instead of 102.0) - pc._process(0.016) - - var expected_h: float = float(mock_world.call("sample_height", player.position.x, player.position.z)) - check.call(absf(player.position.y - expected_h) < 0.001, - "player.position.y clamped to terrain even when pc.locked is true (%f vs %f)" % [player.position.y, expected_h]) - - # Move horizontally while locked (e.g. root motion / server push) - player.position.x = 50.0 - player.position.z = 80.0 - pc._process(0.016) - expected_h = float(mock_world.call("sample_height", player.position.x, player.position.z)) - check.call(absf(player.position.y - expected_h) < 0.001, - "player.position.y follows slope terrain while locked (%f vs %f)" % [player.position.y, expected_h]) - - # --- Test 3: NetPlay _on_net_moved height sampling on position snap --- - var np := NetPlay.new() - get_root().add_child(np) - np.pc = pc - np.client = client_mock - np._main_vid = 1 - - # MapCoord base is 409600, 896000 - # Suppose player is at world (50, 109, 80) - # Server sends snap to world (100, y, 200) -> server pos (419600, 916000) - var snap_server_pos = Vector3(4196.0, 0.0, -9160.0) # net frame cm/100 - var snap_entity = { - "vid": 1, - "pos": snap_server_pos - } - var client_script2 = GDScript.new() - client_script2.source_code = """ -extends Node -var ent := {} -func get_entity(_vid: int) -> Dictionary: return ent -func is_in_game() -> bool: return false -""" - client_script2.reload() - var client_mock2 = Node.new() - client_mock2.set_script(client_script2) - client_mock2.set("ent", snap_entity) - get_root().add_child(client_mock2) - np.client = client_mock2 - - np._on_net_moved(1) - - var target_world_pos: Vector3 = MapCoord.to_world(snap_server_pos) - var expected_snap_h: float = float(mock_world.call("sample_height", target_world_pos.x, target_world_pos.z)) - check.call(absf(player.position.x - target_world_pos.x) < 0.01 and absf(player.position.z - target_world_pos.z) < 0.01, - "player snapped to target coords") - check.call(absf(player.position.y - expected_snap_h) < 0.001, - "player.position.y on snap was sampled from new terrain height (%f vs %f)" % [player.position.y, expected_snap_h]) - - print("=== Completed test_skill_ground_fix with %d failures ===" % fails) - quit(fails) diff --git a/project/test_skill_ground_fix.gd.uid b/project/test_skill_ground_fix.gd.uid deleted file mode 100644 index 53045292..00000000 --- a/project/test_skill_ground_fix.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dt5euqqnupcir diff --git a/project/test_skill_inventory_parity.gd b/project/test_skill_inventory_parity.gd deleted file mode 100644 index bf394e1c..00000000 --- a/project/test_skill_inventory_parity.gd +++ /dev/null @@ -1,541 +0,0 @@ -# test_skill_inventory_parity.gd -# Headless parity test suite for Phase 4: -# 1. Skill usability gating (CheckSkillUsable, cooltime, SP, weapon type, arrow, range) -# 2. Inventory & equipment wear/unwear/move flow and EquipRules gating -# 3. Character status window calculations, formatting, and /stat commands -# 4. Quickbar persistence (CG_QUICKSLOT_ADD/DEL/SWAP) and server restore -extends SceneTree - -const PlayerSkill = preload("res://player_skill.gd") -const Quickbar = preload("res://ui/quickbar.gd") -const InventoryUI = preload("res://ui/inventory_ui.gd") -const CharStatusUI = preload("res://ui/char_status_ui.gd") -const EquipRules = preload("res://equip_rules.gd") - -var _checks_passed := 0 -var _checks_failed := 0 - -func _check(condition: bool, msg: String) -> void: - if condition: - _checks_passed += 1 - print(" [PASS] %s" % msg) - else: - _checks_failed += 1 - printerr(" [FAIL] %s" % msg) - push_error("FAIL: %s" % msg) - -class MockClient extends Node: - signal points_changed(points: Dictionary) - signal inventory_changed(window: int, cell: int) - signal quickslots_changed() - signal entity_main_set(vid: int) - signal skill_group_changed(group: int) - - var main_vid := 10001 - var entities := {} - var target := {} - var points := {} - var skills := [] - var skill_group := 1 - var quickslots := [] - var inventory := [] - var equipment := [] - - var used_skills := [] - var cast_skills := [] - var used_items := [] - var moved_items := [] - var quickslot_adds := [] - var quickslot_dels := [] - var quickslot_swaps := [] - var auto_confirm_quickslots := true - var chat_messages := [] - - func _init() -> void: - for i in 90: - inventory.append({}) - for i in 24: - equipment.append({}) - for i in 36: - quickslots.append({"pos": i, "type": 0, "ref": 0}) - - func get_main_vid() -> int: - return main_vid - - func get_entity(vid: int) -> Dictionary: - return entities.get(vid, {}) - - func get_target() -> Dictionary: - return target - - func get_skills() -> Array: - return skills - - func get_skill_group() -> int: - return skill_group - - func get_points() -> Dictionary: - return points - - func get_quickslots() -> Array: - return quickslots - - func get_inventory() -> Array: - return inventory - - func get_equipment() -> Array: - return equipment - - func use_skill(skill_id: int, target_vid: int) -> bool: - used_skills.append({"skill_id": skill_id, "target_vid": target_vid}) - return true - - func cast_skill(motion_idx: int, rot_deg: float, x: int, y: int, _arg: int = 0) -> bool: - cast_skills.append({"motion_idx": motion_idx, "rot_deg": rot_deg, "x": x, "y": y}) - return true - - func use_item(window: int, cell: int) -> bool: - used_items.append({"window": window, "cell": cell}) - return true - - func move_item(src_w: int, src_c: int, dst_w: int, dst_c: int, count: int) -> bool: - moved_items.append({"src_w": src_w, "src_c": src_c, "dst_w": dst_w, "dst_c": dst_c, "count": count}) - return true - - func quickslot_add(pos: int, type: int, ref: int) -> bool: - quickslot_adds.append({"pos": pos, "type": type, "ref": ref}) - if not auto_confirm_quickslots: - return true - quickslots[pos] = {"pos": pos, "type": type, "ref": ref} - # Simulate GC_QUICKSLOT_ADD rather than making Quickbar trust the request - # return value as local authority. - quickslots_changed.emit() - return true - - func quickslot_del(pos: int) -> bool: - quickslot_dels.append(pos) - if not auto_confirm_quickslots: - return true - quickslots[pos] = {"pos": pos, "type": 0, "ref": 0} - quickslots_changed.emit() - return true - - func quickslot_swap(pos: int, pos_to: int) -> bool: - quickslot_swaps.append({"from": pos, "to": pos_to}) - if not auto_confirm_quickslots: - return true - var tmp: Dictionary = quickslots[pos] - quickslots[pos] = quickslots[pos_to] - quickslots[pos_to] = tmp - quickslots[pos]["pos"] = pos - quickslots[pos_to]["pos"] = pos_to - quickslots_changed.emit() - return true - - func say(_type: int, text: String) -> bool: - chat_messages.append(text) - return true - -class MockSkillTable extends RefCounted: - var skills_data := { - # Skill 1: 三连斩 (Warrior sword attack) - 1: { - "is_attack": true, "is_can_use": true, "need_sp": 45, "target_range": 400, - "weapon_types": [0, 1], # SWORD, TWO_HAND - "is_need_bow": false, "is_toggle": false, "motion_idx": 1, - "is_horse_skill": false, "is_need_empty_bottle": false, "is_need_poison_bottle": false - }, - # Skill 2: 旋风斩 (Warrior sword attack, fan range) - 2: { - "is_attack": true, "is_can_use": true, "need_sp": 60, "target_range": 500, - "weapon_types": [0, 1], - "is_need_bow": false, "is_toggle": false, "motion_idx": 2, - "is_fan": true - }, - # Skill 3: 狂战士 (Warrior buff, can use for self, no weapon restriction) - 3: { - "is_attack": false, "is_can_use": true, "need_sp": 50, "target_range": 0, - "weapon_types": [], - "is_need_bow": false, "is_toggle": true, "can_use_for_me": true, "motion_idx": 3 - }, - # Skill 31: 穿透箭 (Ninja bow attack) - 31: { - "is_attack": true, "is_can_use": true, "need_sp": 35, "target_range": 2500, - "weapon_types": [2], # BOW - "is_need_bow": true, "is_toggle": false, "motion_idx": 31 - } - } - - func has(id: int) -> bool: return skills_data.has(id) - func is_attack(id: int) -> bool: return skills_data.get(id, {}).get("is_attack", false) - func is_can_use_skill(id: int) -> bool: return skills_data.get(id, {}).get("is_can_use", true) - func is_standing(id: int) -> bool: return true - func is_toggle(id: int) -> bool: return skills_data.get(id, {}).get("is_toggle", false) - func is_horse_skill(id: int) -> bool: return skills_data.get(id, {}).get("is_horse_skill", false) - func is_need_bow(id: int) -> bool: return skills_data.get(id, {}).get("is_need_bow", false) - func is_need_empty_bottle(id: int) -> bool: return skills_data.get(id, {}).get("is_need_empty_bottle", false) - func is_need_poison_bottle(id: int) -> bool: return skills_data.get(id, {}).get("is_need_poison_bottle", false) - func can_use_weapon_type(id: int, wtype: int) -> bool: - var list: Array = skills_data.get(id, {}).get("weapon_types", []) - return list.is_empty() or list.has(wtype) - func need_sp(id: int, _lvl: int) -> int: return skills_data.get(id, {}).get("need_sp", -1) - func need_hp(_id: int, _lvl: int) -> int: return 0 - func can_use_if_not_enough(_id: int) -> bool: return false - func target_range(id: int) -> int: return skills_data.get(id, {}).get("target_range", 0) - func can_use_for_me(id: int) -> bool: return skills_data.get(id, {}).get("can_use_for_me", false) - func is_auto_search_target(_id: int) -> bool: return true - func is_need_target(_id: int) -> bool: return false - func is_need_corpse(_id: int) -> bool: return false - func is_charge_skill(_id: int) -> bool: return false - func motion_idx_of(id: int) -> int: return skills_data.get(id, {}).get("motion_idx", 0) - func is_fan_range(id: int) -> bool: return skills_data.get(id, {}).get("is_fan", false) - func is_circle_range(_id: int) -> bool: return false - func target_count(_id: int, _lvl: int) -> int: return 3 - func fly_shape(_id: int) -> int: return 0 - func skill_type_of(_id: int) -> int: return 1 - func is_use_hp(_id: int) -> bool: return false - func can_change_direction(_id: int) -> bool: return true - func is_only_for_alliance(_id: int) -> bool: return false - func name_of(id: int) -> String: - match id: - 1: return "三连斩" - 2: return "旋风斩" - 3: return "狂战士" - 31: return "穿透箭" - return "技能 %d" % id - -func _init() -> void: - print("=== Running test_skill_inventory_parity (Phase 4 Parity) ===") - _test_skill_gating() - _test_inventory_equipment_flow() - _test_char_status_window() - _test_quickbar_persistence() - - print("\n=======================================================") - if _checks_failed == 0: - print("=== ALL PHASE 4 PARITY TESTS PASSED (%d checks) ===" % _checks_passed) - quit(0) - else: - printerr("=== PHASE 4 PARITY TESTS FAILED: %d failed, %d passed ===" % [_checks_failed, _checks_passed]) - quit(1) - -# ============================================================================== -# 1. 技能施法三道门检 (Skill Usability Gating) -# ============================================================================== -func _test_skill_gating() -> void: - print("\n--- Test 1: Skill Usability & Gating (40250 CheckSkillUsable) ---") - var client := MockClient.new() - var table := MockSkillTable.new() - var gate := PlayerSkill.new() - gate.setup(client, table) - - client.skills = [ - {"id": 1, "level": 1, "master": 0}, - {"id": 2, "level": 1, "master": 0}, - {"id": 3, "level": 1, "master": 0}, - {"id": 31, "level": 1, "master": 0} - ] - client.entities[client.main_vid] = {"vid": client.main_vid, "kind": 0, "dead": false, "in_safe": false} - - # 1.1 Dead player cannot act - gate.can_act = false - var res := gate.click_skill_slot(1) - _check(not res.ok and res.code == "CANNOT_ACT", "Dead or incapacitated player blocked (CANNOT_ACT)") - gate.can_act = true - - # 1.2 Safe zone attack skill check - gate.in_safe = true - res = gate.click_skill_slot(1) - _check(not res.ok and res.code == "IN_SAFE", "Attack skill blocked in safe zone (IN_SAFE)") - res = gate.click_skill_slot(3) - _check(res.ok, "Buff skill allowed in safe zone") - gate.in_safe = false - - # 1.3 Weapon type mismatch check - gate.weapon_sub_type = 2 # BOW - res = gate.check_skill_usable(1) # Requires SWORD (0) or TWO_HAND (1) - _check(not res.ok and res.code == "NOT_MATCHABLE_WEAPON", "Sword skill with Bow blocked (NOT_MATCHABLE_WEAPON)") - gate.weapon_sub_type = 0 # SWORD - res = gate.check_skill_usable(1) - _check(res.ok, "Sword skill with Sword allowed") - - # 1.4 Arrow count check for bow skill - gate.weapon_sub_type = 2 # BOW - gate.arrow_count = 0 - res = gate.check_skill_usable(31) - _check(not res.ok and res.code == "EMPTY_ARROW", "Bow skill without arrows blocked (EMPTY_ARROW)") - gate.arrow_count = 100 - res = gate.check_skill_usable(31) - _check(res.ok, "Bow skill with arrows allowed") - gate.weapon_sub_type = 0 - - # 1.5 Insufficient SP check - gate.cur_sp = 20 # Skill 1 needs 45 - res = gate.check_skill_usable(1) - _check(not res.ok and res.code == "NOT_ENOUGH_SP", "Skill with insufficient SP blocked (NOT_ENOUGH_SP)") - gate.cur_sp = 100 - res = gate.check_skill_usable(1) - _check(res.ok, "Skill with sufficient SP allowed") - - # 1.6 Cooltime gating - gate.slot_cd_end = Time.get_ticks_msec() / 1000.0 + 5.0 # 5s remaining - res = gate.check_skill_usable(1) - _check(not res.ok and res.code == "WAIT_COOLTIME", "Skill in cooldown blocked (WAIT_COOLTIME)") - gate.slot_cd_end = 0.0 # Expired - res = gate.check_skill_usable(1) - _check(res.ok, "Skill with expired cooldown allowed") - - # 1.7 Target distance check - var enemy_vid := 20001 - client.entities[enemy_vid] = {"vid": enemy_vid, "kind": 2, "dead": false, "in_safe": false} - client.target = {"vid": enemy_vid} - gate.target_distance = func(v: int) -> float: return 1000.0 if v == enemy_vid else -1.0 # 1000cm distance, skill range is 400cm - res = gate.use_skill(1) - # Target beyond range returns RESERVED for approach - _check(not res.ok and res.code == "RESERVED", "Target beyond skill range enters RESERVED state") - # Target in range (300cm < 400cm) - gate.target_distance = func(v: int) -> float: return 300.0 if v == enemy_vid else -1.0 - res = gate.use_skill(1) - _check(res.ok and res.target_vid == enemy_vid, "Target within skill range allowed with target_vid") - - client.queue_free() - -# ============================================================================== -# 2. 背包与装备槽位映射与穿脱流 (Inventory & Equipment Flow) -# ============================================================================== -func _test_inventory_equipment_flow() -> void: - print("\n--- Test 2: Inventory & Equipment Flow (40250 ItemPos & Equip) ---") - var client := MockClient.new() - var inv := InventoryUI.new() - root.add_child(inv) - inv.setup(null, client, null, "") - - # 2.1 Wire Coordinate Mapping - # Inventory cell 15 -> [WINDOW_INVENTORY (1), 15] - var w15 := inv._to_wire(15) - _check(w15[0] == 1 and w15[1] == 15, "Inventory slot 15 maps to [WINDOW_INVENTORY, 15]") - # Equipment cell 94 (WEAR_WEAPON = 4) -> [WINDOW_EQUIPMENT (2), 4] - var w_weapon := inv._to_wire(94) - _check(w_weapon[0] == 2 and w_weapon[1] == 4, "Equip slot 94 (WEAPON) maps to [WINDOW_EQUIPMENT, 4]") - # Equipment cell 90 (WEAR_BODY = 0) -> [WINDOW_EQUIPMENT (2), 0] - var w_body := inv._to_wire(90) - _check(w_body[0] == 2 and w_body[1] == 0, "Equip slot 90 (BODY) maps to [WINDOW_EQUIPMENT, 0]") - # New equipment cell 109 (Costume Body = wear 19) -> [WINDOW_INVENTORY, 109] - var w_costume := inv._to_wire(109) - _check(w_costume[0] == 1 and w_costume[1] == 109, "Costume Body slot 109 maps to [WINDOW_INVENTORY, 109]") - - # 2.2 Wearing an item: right-click inventory cell - client.used_items.clear() - inv.use(5) - _check(client.used_items.size() == 1 and client.used_items[0].window == 1 and client.used_items[0].cell == 5, - "Right-click inventory cell 5 sends send_item_use(WINDOW_INVENTORY, 5)") - - # 2.3 Unwearing an item: right-click equipment slot - client.used_items.clear() - inv.use(94) # Weapon slot (wear 4) - _check(client.used_items.size() == 1 and client.used_items[0].window == 2 and client.used_items[0].cell == 4, - "Right-click equipped weapon sends send_item_use(WINDOW_EQUIPMENT, 4) to unwear") - - # 2.4 Moving item: drag between inventory cells - client.moved_items.clear() - inv.move_to(10, 20, 1) - _check(client.moved_items.size() == 1 and - client.moved_items[0].src_w == 1 and client.moved_items[0].src_c == 10 and - client.moved_items[0].dst_w == 1 and client.moved_items[0].dst_c == 20, - "Moving item between inventory cells sends move_item(1, 10, 1, 20, 1)") - - # 2.5 Dragging item from inventory to equipment slot - client.moved_items.clear() - inv.move_to(3, 94, 1) # Slot 3 to Weapon slot (wear 4) - _check(client.moved_items.size() == 1 and - client.moved_items[0].src_w == 1 and client.moved_items[0].src_c == 3 and - client.moved_items[0].dst_w == 2 and client.moved_items[0].dst_c == 4, - "Dragging item to weapon slot sends move_item(1, 3, 2, 4, 1)") - - # 2.6 Equip rules rejection - # Item requiring level 50 on level 10 character - var high_lvl_item := { - "type": 1, "sub_type": 0, "anti_flags": 0, - "limits": [{"type": 1, "value": 50}], # LIMIT_LEVEL = 1 - "apply": [] - } - var ctx := {"level": 10, "race": 0, "st": 10, "dx": 10, "ht": 10, "iq": 10, "worn": {}} - var verdict := EquipRules.can_equip(high_lvl_item, -1, ctx) - _check(not verdict.ok and verdict.code == "LIMIT_LEVEL" and verdict.need == 50, - "EquipRules rejects item when character level is insufficient (LIMIT_LEVEL)") - - inv.queue_free() - client.queue_free() - -# ============================================================================== -# 3. 角色属性面板计算与加点 (Character Status Parity) -# ============================================================================== -func _test_char_status_window() -> void: - print("\n--- Test 3: Character Status Window (40250 uicharacter.py Parity) ---") - var client := MockClient.new() - var char_ui := CharStatusUI.new() - root.add_child(char_ui) - char_ui.setup(null, client, "") - - # Populate points array (EPointTypes from 40250 char.h / Packet.h) - var pts_arr := [] - for i in 255: - pts_arr.append(0) - - pts_arr[CharStatusUI.P_LEVEL] = 55 - pts_arr[CharStatusUI.P_EXP] = 120000 - pts_arr[CharStatusUI.P_NEXT_EXP] = 300000 - pts_arr[CharStatusUI.P_HP] = 4500 - pts_arr[CharStatusUI.P_MAX_HP] = 5000 - pts_arr[CharStatusUI.P_SP] = 1200 - pts_arr[CharStatusUI.P_MAX_SP] = 1500 - pts_arr[CharStatusUI.P_ST] = 90 - pts_arr[CharStatusUI.P_HT] = 75 - pts_arr[CharStatusUI.P_DX] = 40 - pts_arr[CharStatusUI.P_IQ] = 25 - pts_arr[CharStatusUI.P_MIN_ATK] = 320 - pts_arr[CharStatusUI.P_MAX_ATK] = 380 - pts_arr[CharStatusUI.P_ATT_GRADE_BONUS] = 50 - pts_arr[CharStatusUI.P_PARTY_ATT_GRADE] = 10 - pts_arr[CharStatusUI.P_DEF_GRADE] = 280 - pts_arr[CharStatusUI.P_DEF_GRADE_BONUS] = 20 - pts_arr[CharStatusUI.P_MAGIC_ATT_GRADE] = 150 - pts_arr[CharStatusUI.P_MIN_MAGIC_WEP] = 30 - pts_arr[CharStatusUI.P_MAX_MAGIC_WEP] = 50 - pts_arr[CharStatusUI.P_STAT] = 5 # 5 unused stat points - - client.points = { - "level": 55, "exp": 120000, "next_exp": 300000, - "hp": 4500, "max_hp": 5000, "sp": 1200, "max_sp": 1500, - "points": pts_arr - } - - # 3.1 Attack calculation: - # lo = min_atk (320) + bonus (50 + 10 = 60) = 380 - # hi = max_atk (380) + bonus (60) = 440 -> "380-440" - var get_pt := func(idx: int) -> int: return int(pts_arr[idx]) - var att_str := char_ui._att_text(get_pt) - _check(att_str == "380-440", "Total Attack text matches 40250 formula ('380-440')") - - # Single value attack when min == max - pts_arr[CharStatusUI.P_MIN_ATK] = 350 - pts_arr[CharStatusUI.P_MAX_ATK] = 350 - att_str = char_ui._att_text(get_pt) - _check(att_str == "410", "Total Attack when min==max matches single value ('410')") - - # 3.2 Defense calculation: - # def_grade (280) + def_grade_bonus (20) = 300 - var def_str := char_ui._def_text(get_pt) - _check(def_str == "300", "Total Defense text matches 40250 formula ('300')") - - # 3.3 Magic Attack calculation: - # mag_att (150) + min (30) = 180, mag_att (150) + max (50) = 200 -> "180-200" - var matt_str := char_ui._matt_text(get_pt) - _check(matt_str == "180-200", "Total Magic Attack matches 40250 formula ('180-200')") - - # 3.4 Stat allocation commands - client.chat_messages.clear() - char_ui._send_stat("/stat ht") - char_ui._send_stat("/stat iq") - char_ui._send_stat("/stat st") - char_ui._send_stat("/stat dx") - _check(client.chat_messages == ["/stat ht", "/stat iq", "/stat st", "/stat dx"], - "Stat addition sends exact 40250 chat commands (/stat ht, iq, st, dx)") - - client.chat_messages.clear() - char_ui._send_stat("/stat- st") - _check(client.chat_messages == ["/stat- st"], "Stat reset command sends exact 40250 /stat- command") - - char_ui.queue_free() - client.queue_free() - -# ============================================================================== -# 4. 快捷栏持久化与同步 (Quickbar Persistence & Sync) -# ============================================================================== -func _test_quickbar_persistence() -> void: - print("\n--- Test 4: Quickbar Persistence & Sync (40250 CG_QUICKSLOT_*) ---") - var client := MockClient.new() - var table := MockSkillTable.new() - var qb := Quickbar.new() - root.add_child(qb) - qb.setup(client, table, root, func(): return null, "") - - # 4.1 Assign Skill to Quickslot 0 - client.quickslot_adds.clear() - # assign skill 1 to local slot 0 - var ok := qb.assign(0, "skill", 1) - _check(ok, "Assigned skill 1 to quickbar slot 0") - _check(client.quickslot_adds.size() == 1 and - client.quickslot_adds[0].pos == 0 and - client.quickslot_adds[0].type == 2 and # 2 = SKILL - client.quickslot_adds[0].ref == 1, - "Skill assignment sends quickslot_add(pos=0, type=2, ref=1)") - - # 4.2 Assign Item to Quickslot 1 - client.quickslot_adds.clear() - ok = qb.assign(1, "item", 12) # Inventory cell 12 - _check(ok, "Assigned item (cell 12) to quickbar slot 1") - _check(client.quickslot_adds.size() == 1 and - client.quickslot_adds[0].pos == 1 and - client.quickslot_adds[0].type == 1 and # 1 = ITEM - client.quickslot_adds[0].ref == 12, - "Item assignment sends quickslot_add(pos=1, type=1, ref=12)") - - # 4.3 Assign Emote to Quickslot 2 - client.quickslot_adds.clear() - ok = qb.assign(2, "emote", 5) # Emote 5 - _check(ok, "Assigned emote 5 to quickbar slot 2") - _check(client.quickslot_adds.size() == 1 and - client.quickslot_adds[0].pos == 2 and - client.quickslot_adds[0].type == 3 and # 3 = EMOTE - client.quickslot_adds[0].ref == 5, - "Emote assignment sends quickslot_add(pos=2, type=3, ref=5)") - - # 4.4 Swap Quickslots 0 and 1 - client.quickslot_swaps.clear() - qb._swap(0, 1) - _check(client.quickslot_swaps.size() == 1 and - client.quickslot_swaps[0].from == 0 and - client.quickslot_swaps[0].to == 1, - "Quickslot swap sends quickslot_swap(0, 1)") - _check(qb._state[0].kind == "item" and qb._state[1].kind == "skill", - "Local quickslot state successfully swapped") - - # 4.5 Delete Quickslot - client.quickslot_dels.clear() - qb._clear(0) - _check(client.quickslot_dels == [0], "Clearing slot 0 sends quickslot_del(0)") - _check(qb._state[0].kind == "", "Slot 0 cleared locally") - - # 4.6 Server Full Restore - # Simulate server sending 36 slots array - client.quickslots = [ - {"pos": 0, "type": 2, "ref": 2}, # Skill 2 - {"pos": 1, "type": 1, "ref": 8}, # Item cell 8 - {"pos": 2, "type": 3, "ref": 1}, # Emote 1 - ] - for i in range(3, 36): - client.quickslots.append({"pos": i, "type": 0, "ref": 0}) - - qb.restore_from_server() - _check(qb._state[0].kind == "skill" and qb._state[0].id == 2, "Restored slot 0 is Skill 2") - _check(qb._state[1].kind == "item" and qb._state[1].id == 8, "Restored slot 1 is Item cell 8") - _check(qb._state[2].kind == "emote" and qb._state[2].id == 1, "Restored slot 2 is Emote 1") - _check(qb._state[3].kind == "", "Restored slot 3 is empty") - - # Request/GC ordering: an accepted request is not local authority until the - # corresponding GC_QUICKSLOT_* update arrives. - client.auto_confirm_quickslots = false - var before_kind := String(qb._state[0].kind) - var before_id := int(qb._state[0].id) - _check(qb.assign(0, "item", 12), "Delayed quickslot add request is accepted") - _check(qb._state[0].kind == before_kind and int(qb._state[0].id) == before_id, - "Delayed add does not change local slot before GC_QUICKSLOT_ADD") - client.quickslots[0] = {"pos": 0, "type": 1, "ref": 12} - client.quickslots_changed.emit() - _check(qb._state[0].kind == "item" and int(qb._state[0].id) == 12, - "GC_QUICKSLOT_ADD refreshes the local slot") - client.auto_confirm_quickslots = true - - qb.queue_free() - client.queue_free() diff --git a/project/test_skill_inventory_parity.gd.uid b/project/test_skill_inventory_parity.gd.uid deleted file mode 100644 index 5e628aa7..00000000 --- a/project/test_skill_inventory_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dldl4ea3ne1h4 diff --git a/project/test_skill_state_arg_parity.gd b/project/test_skill_state_arg_parity.gd deleted file mode 100644 index 1988722e..00000000 --- a/project/test_skill_state_arg_parity.gd +++ /dev/null @@ -1,51 +0,0 @@ -# test_skill_state_arg_parity —— 40250 FUNC_SKILL uArg 到动作视图的映射。 -# 覆盖 OnUseSkill: low nibble = loop count,bit 4 = moving-skill。 -extends SceneTree - -const NetWorld = preload("res://net_world.gd") - -class FakeSkillTable extends RefCounted: - func motion_name_by_idx(idx: int) -> String: - return "skill_%d" % idx - -class SkillView extends Node3D: - var calls := [] - var states := [] - func play_skill_motion(name: String, grade: int = 0, loop_count: int = 0, - moving_skill: bool = false) -> bool: - calls.append([name, grade, loop_count, moving_skill]) - return true - func set_anim_state(state: String) -> void: - states.append(state) - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - var nw := NetWorld.new() - var view := SkillView.new() - get_root().add_child(nw) - nw.skill_table = FakeSkillTable.new() - - # 0x12 = loop count 2 + IsUsingMovingSkill bit (1 << 4). - nw._apply_anim(view, NetWorld.FUNC_SKILL | 7, false, NetWorld.WALKMODE_RUN, false, 0x12) - _ck(view.calls == [["skill_7", 0, 2, true]], - "FUNC_SKILL uArg -> play_skill_motion(name, grade, loop_count, moving)") - - # A normal one-shot with no loop override must not inherit the moving bit. - nw._apply_anim(view, NetWorld.FUNC_SKILL | 3, false, NetWorld.WALKMODE_RUN, false, 0) - _ck(view.calls[-1] == ["skill_3", 0, 0, false], - "zero skill arg -> ordinary one-shot defaults") - - view.free() - nw.free() - if _fail == 0: - print("PASS: test_skill_state_arg_parity") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/test_skill_state_arg_parity.gd.uid b/project/test_skill_state_arg_parity.gd.uid deleted file mode 100644 index abcff5ad..00000000 --- a/project/test_skill_state_arg_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://calqx3qpinjuv diff --git a/project/test_target_board_parity.gd b/project/test_target_board_parity.gd deleted file mode 100644 index dcb6d49b..00000000 --- a/project/test_target_board_parity.gd +++ /dev/null @@ -1,122 +0,0 @@ -# test_target_board_parity.gd —— 40250 目标信息板 TargetBoard 1:1 回归测试套件 -# 运行方式: -# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_target_board_parity.gd -extends SceneTree - -const TargetUI = preload("res://ui/target_ui.gd") -const NetPlay = preload("res://net_play.gd") - -class MockClient extends Node: - var cleared_target_count := 0 - func clear_target() -> void: - cleared_target_count += 1 - func get_entity(vid: int) -> Dictionary: - if vid == 1001: - return {"vid": 1001, "name": "兽人首领", "level": 50, "grade": 4, "kind": 2, "dead": false} - if vid == 1002: - return {"vid": 1002, "name": "狂暴野狼", "level": 12, "grade": 0, "kind": 2, "dead": false} - return {} - -var _passed := 0 -var _failed := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_target_board_parity (Direction 1: TargetBoard 1:1) ===") - _test_board_sizing_and_naming() - _test_hp_gauge_and_closing() - _test_net_play_integration() - _finish() - -func _test_board_sizing_and_naming() -> void: - print("\n--- Test 1: Sizing & Name/Grade Prefix (uitarget.py:301) ---") - var client := MockClient.new() - get_root().add_child(client) - - var target_ui := TargetUI.new() - get_root().add_child(target_ui) - target_ui.setup(client, get_root()) - - _assert(target_ui.is_open() == false, "Target board initially hidden") - - # Open Boss target (Lv 50, Grade 4 = 5阶首领) - target_ui.open(1001, "兽人首领", 50, 4, false) - _assert(target_ui.is_open() == true, "Target board opened") - _assert(target_ui.get_target_name() == "[Lv. 50] (5阶首领) 兽人首领", "Name prefix formatted correctly with Lv and Grade") - - # Verify dynamic width formula: 200 + 7 * len("[Lv. 50] (5阶首领) 兽人首领") - # len is 19 chars -> 200 + 7 * 19 = 333 - var expected_w := 200.0 + 7.0 * float("[Lv. 50] (5阶首领) 兽人首领".length()) - _assert(target_ui._board.size.x == expected_w, "Board width auto-sizes to %.1f (200 + 7*len)" % expected_w) - - target_ui.queue_free() - client.queue_free() - -func _test_hp_gauge_and_closing() -> void: - print("\n--- Test 2: HP Gauge 130px & Close Button (ClearTarget) ---") - var client := MockClient.new() - get_root().add_child(client) - - var target_ui := TargetUI.new() - get_root().add_child(target_ui) - target_ui.setup(client, get_root()) - - target_ui.open(1002, "野狼", 12, 0, false) - target_ui.set_hp(60) - - _assert(target_ui.get_hp_percentage() == 60, "HP set to 60%") - # Gauge width = 130.0 * 0.6 = 78.0 - _assert(absf(target_ui._hp_gauge_fill.size.x - 78.0) < 0.01, "HP fill width is 78px (130 * 0.6)") - _assert(target_ui._hp_text.text == "60%", "HP text displays '60%'") - - # Close button click - target_ui._on_close_button_pressed() - _assert(target_ui.is_open() == false, "Target board closed after clicking close button") - _assert(client.cleared_target_count == 1, "client.clear_target() called upon close") - - target_ui.queue_free() - client.queue_free() - -func _test_net_play_integration() -> void: - print("\n--- Test 3: NetPlay _on_target_info Dispatching ---") - var client := MockClient.new() - get_root().add_child(client) - - var net_play := NetPlay.new() - get_root().add_child(net_play) - net_play.client = client - - var target_ui := TargetUI.new() - get_root().add_child(target_ui) - target_ui.setup(client, get_root()) - net_play.target_ui = target_ui - - # Simulate RecvTargetPacket for vid 1001 with 85% HP - net_play._on_target_info(1001, 85) - _assert(target_ui.is_open() == true, "NetPlay _on_target_info opened target_ui") - _assert(target_ui.get_target_vid() == 1001, "Target VID is 1001") - _assert(target_ui.get_hp_percentage() == 85, "Target HP percentage is 85%") - - # Clear target - net_play._clear_target() - _assert(target_ui.is_open() == false, "NetPlay _clear_target closed target_ui") - - target_ui.queue_free() - net_play.queue_free() - client.queue_free() - -func _finish() -> void: - print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - quit(0) - else: - printerr("SOME TESTS FAILED!\n") - quit(1) diff --git a/project/test_target_board_parity.gd.uid b/project/test_target_board_parity.gd.uid deleted file mode 100644 index d015c5f3..00000000 --- a/project/test_target_board_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://crr0btkehl7ux diff --git a/project/test_taskbar_quickslot_parity.gd b/project/test_taskbar_quickslot_parity.gd deleted file mode 100644 index bf1de8c7..00000000 --- a/project/test_taskbar_quickslot_parity.gd +++ /dev/null @@ -1,153 +0,0 @@ -# test_taskbar_quickslot_parity.gd —— 验证方向 3:底部主任务栏、4 珍珠经验球与鼠标控制器 40250 1:1 对齐 -extends SceneTree - -const Quickbar = preload("res://ui/quickbar.gd") - -class DummySkillTable extends RefCounted: - func motion_idx_of(_id: int) -> int: return 1 - func is_fan_range(_id: int) -> bool: return false - func is_circle_range(_id: int) -> bool: return false - func target_range(_id: int) -> float: return 500.0 - func target_count(_id: int, _lvl: int) -> int: return 1 - func fly_shape(_id: int) -> int: return 0 - func skill_name(id: int) -> String: - if id == 1: return "三连斩" - if id == 2: return "剑气" - return "技能 #%d" % id - -class DummyClient extends Node: - signal quickslots_changed - signal skills_changed - signal points_changed(pts: Dictionary) - var _qs: Array = [] - func get_quickslots() -> Array: return _qs - func get_points() -> Dictionary: return {"hp": 1000, "max_hp": 1000, "sp": 500, "max_sp": 500, "exp": 0, "next_exp": 1000} - func get_main_vid() -> int: return 1 - func get_entity(_vid: int) -> Dictionary: return {"race": 0, "dead": false} - func get_skills() -> Array: return [] - func get_skill_group() -> int: return 1 - func quickslot_add(pos: int, type: int, ref: int) -> bool: - _qs.append({"pos": pos, "type": type, "ref": ref}) - quickslots_changed.emit() - return true - -var _passed := 0 -var _failed := 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_taskbar_quickslot_parity (Direction 3: Taskbar & Quickslot 1:1) ===") - var root := Control.new() - root.size = Vector2(800, 600) - - var client := DummyClient.new() - var table := DummySkillTable.new() - var qb := Quickbar.new() - - qb.setup(client, table, root, func(): return null, "metin2-client/assets") - - test_exp_four_pearls_parity(qb) - test_page_flip_parity(qb) - test_mouse_mode_controllers(qb) - - print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed]) - if _failed > 0: - printerr("TEST FAILED!") - quit(1) - else: - print("ALL TESTS PASSED!") - quit(0) - -func test_exp_four_pearls_parity(qb: Node) -> void: - print("\n--- Test 1: 40250 uitaskbar.py 4-Pearl EXP Gauge Calculation ---") - # Case 1: 0% EXP - qb.set_exp(0, 1000) - var exp_board: Control = qb.get("_exp_board") - _assert(exp_board != null, "EXP gauge board exists") - _assert(exp_board.tooltip_text == "EXP : 0.00%", "0% EXP tooltip formatted 1:1") - - var clips: Array = qb.get("_exp_clips") - _assert(clips.size() == 4, "4 pearl clip controls present") - _assert(not clips[0].visible and not clips[1].visible and not clips[2].visible and not clips[3].visible, - "At 0% EXP, all 4 pearls are hidden/empty") - - # Case 2: 25% EXP (1st quarter full) - qb.set_exp(250, 1000) - _assert(exp_board.tooltip_text == "EXP : 25.00%", "25% EXP tooltip matches") - _assert(clips[0].visible, "Pearl 1 is fully filled and visible") - _assert(clips[0].size == Vector2(19, 19), "Pearl 1 is full 19x19 dimensions") - _assert(not clips[1].visible and not clips[2].visible and not clips[3].visible, - "Pearls 2..4 remain empty at 25%") - - # Case 3: 62.5% EXP (2 quarters full, 3rd quarter 50% filled) - # 625 / 1000 = quarter_pt 250, full_count = 2 (pearls 0 and 1 full). Pearl 2 fill_h = round(19 * 125/250) = 10 px - qb.set_exp(625, 1000) - _assert(exp_board.tooltip_text == "EXP : 62.50%", "62.5% EXP tooltip matches") - _assert(clips[0].visible and clips[0].size.y == 19, "Pearl 1 is full (19px)") - _assert(clips[1].visible and clips[1].size.y == 19, "Pearl 2 is full (19px)") - _assert(clips[2].visible and clips[2].size.y == 10, "Pearl 3 is partially filled (10px)") - _assert(not clips[3].visible, "Pearl 4 is empty") - - # Case 4: 100% EXP (All 4 pearls full) - qb.set_exp(1000, 1000) - _assert(exp_board.tooltip_text == "EXP : 100.00%", "100% EXP tooltip matches") - _assert(clips[0].visible and clips[1].visible and clips[2].visible and clips[3].visible, - "All 4 pearls are visible and full at 100% EXP") - -func test_page_flip_parity(qb: Node) -> void: - print("\n--- Test 2: Quickslot Multi-Page Flipping (Shift+1..4) ---") - _assert(qb.get_page() == 0, "Initial page is 0 (Page 1)") - - qb.set_page(1) - _assert(qb.get_page() == 1, "Flipped to page 1 (Page 2)") - - qb.set_page(2) - _assert(qb.get_page() == 2, "Flipped to page 2 (Page 3)") - - qb.set_page(3) - _assert(qb.get_page() == 3, "Flipped to page 3 (Page 4)") - - # SetQuickPage wraps by QUICKSLOT_MAX_LINE instead of rejecting the page. - qb.set_page(4) - _assert(qb.get_page() == 0, "Page 4 wraps to page 0") - qb.set_page(-1) - _assert(qb.get_page() == 3, "Page -1 wraps to page 3") - - # Flipped back to page 0 - qb.set_page(0) - _assert(qb.get_page() == 0, "Flipped back to page 0") - -func test_mouse_mode_controllers(qb: Node) -> void: - print("\n--- Test 3: Mouse Left/Right Button Mode Controllers ---") - var btn_left: Control = qb.get_button("mouse_left") - var btn_right: Control = qb.get_button("mouse_right") - _assert(btn_left != null, "mouse_left button exists") - _assert(btn_right != null, "mouse_right button exists") - - # Left mouse mode cycle: 0 -> 1 -> 2 -> 0 - _assert(qb.get_left_mouse_mode() == 0, "Initial left mouse mode is 0 (Move & Attack)") - qb.set_left_mouse_mode(1) - _assert(qb.get_left_mouse_mode() == 1, "Left mouse mode changed to 1 (Auto Attack)") - _assert("自动攻击" in btn_left.tooltip_text, "Left mouse button tooltip updated to Auto Attack") - qb.set_left_mouse_mode(2) - _assert(qb.get_left_mouse_mode() == 2, "Left mouse mode changed to 2 (Camera)") - _assert("镜头旋转" in btn_left.tooltip_text, "Left mouse button tooltip updated to Camera") - - # Right mouse mode & skill binding - qb.set_right_mouse_mode(0) - _assert(qb.get_right_mouse_mode() == 0, "Right mouse mode changed to 0 (Move & Attack)") - qb.set_right_mouse_mode(1) - _assert(qb.get_right_mouse_mode() == 1, "Right mouse mode changed to 1 (Camera)") - qb.set_right_mouse_mode(2) - _assert(qb.get_right_mouse_mode() == 2, "Right mouse mode changed to 2 (Skill)") - - qb.set_right_mouse_skill(1) - _assert(qb.get_right_mouse_skill() == 1, "Right mouse skill set to 1 (三连斩)") - _assert("三连斩" in btn_right.tooltip_text, "Right mouse button tooltip reflects selected skill name") diff --git a/project/test_taskbar_quickslot_parity.gd.uid b/project/test_taskbar_quickslot_parity.gd.uid deleted file mode 100644 index 608e76d1..00000000 --- a/project/test_taskbar_quickslot_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dij52xuddw3ql diff --git a/project/test_ui_char.gd b/project/test_ui_char.gd deleted file mode 100644 index 97c0c93e..00000000 --- a/project/test_ui_char.gd +++ /dev/null @@ -1,24 +0,0 @@ -extends SceneTree - -const UiAssets = preload("res://ui/ui_assets.gd") - -func _init() -> void: - var root := "../assets" - var vpath := "d:/ymir work/ui/public/Parameter_Slot_00.sub" - var tex = UiAssets.load_tex(root, vpath) - print("Parameter_Slot_00.sub tex: ", tex) - if tex is AtlasTexture: - var at := tex as AtlasTexture - print("Atlas rect: ", at.region) - if at.atlas is ImageTexture: - var img: Image = (at.atlas as ImageTexture).get_image() - print("Atlas size: ", img.get_size(), " format: ", img.get_format()) - var c := img.get_pixel(125, 235) - print("Pixel sample at 125,235: ", c) - var locale_sub := "locale/en/ui/windows/label_std_item1.sub" - var t2 = UiAssets.load_tex(root, locale_sub) - print("label_std_item1 tex: ", t2) - var win_sub := "d:/ymir work/ui/game/windows/label_std_item1.sub" - var t3 = UiAssets.load_tex(root, win_sub) - print("windows/label_std_item1 tex: ", t3) - quit(0) diff --git a/project/test_ui_char.gd.uid b/project/test_ui_char.gd.uid deleted file mode 100644 index d11d939a..00000000 --- a/project/test_ui_char.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cng8ie4n4jb3h diff --git a/project/test_warrior_mental_skills_regression.gd b/project/test_warrior_mental_skills_regression.gd deleted file mode 100644 index 9717fda3..00000000 --- a/project/test_warrior_mental_skills_regression.gd +++ /dev/null @@ -1,81 +0,0 @@ -extends SceneTree - -const AssetRoot = preload("res://asset_root.gd") -const PlayerView = preload("res://ui/player_view.gd") - -func _init() -> void: - call_deferred("_run") - -func _run() -> void: - print("=== Running test_warrior_mental_skills_regression ===") - var fails := 0 - var check = func(cond: bool, msg: String): - if not cond: - fails += 1 - print("FAIL: ", msg) - else: - print("OK: ", msg) - - var root_dir := AssetRoot.path() - var pv := PlayerView.new() - get_root().add_child(pv) - var ok = pv.build(root_dir, 0) # warrior male - check.call(ok, "warrior PlayerView built") - - var skills = ["gigongcham", "gyeoksan", "daejin", "cheongeun", "geompung"] - for sname in skills: - print("\n--- Testing skill: ", sname, " ---") - var finished := [false] - var on_finished = func(): - finished[0] = true - pv.anim.playback_finished.connect(on_finished, CONNECT_ONE_SHOT) - - var cast_ok = pv.play_skill_motion(sname, 0) - check.call(cast_ok, "play_skill_motion(%s) returned true" % sname) - check.call(pv._state == "__skill", "pv._state is '__skill'") - check.call(bool(pv.anim.get("playing")), "anim.playing is true at start") - - var dur = float(pv.anim.get_duration()) if pv.anim.has_method("get_duration") else 2.0 - # Run frames until playback_finished or timeout - var frames = 0 - while not finished[0] and frames < 400: - await process_frame - frames += 1 - - check.call(finished[0], "%s emitted playback_finished (frames=%d)" % [sname, frames]) - check.call(pv._state == "wait", "pv._state reset to 'wait'") - check.call(bool(pv.anim.get("playing")), "anim.playing is true after playback_finished") - - # Transition to run - pv.set_anim_state("run") - check.call(pv._state == "run", "pv._state changed to 'run'") - check.call(bool(pv.anim.get("playing")), "anim.playing is true during run") - - var t0 = float(pv.anim.get_time()) if pv.anim.has_method("get_time") else 0.0 - for f in range(15): - await process_frame - var t1 = float(pv.anim.get_time()) if pv.anim.has_method("get_time") else 0.0 - check.call(t1 > t0, "run animation is advancing (t0=%.3f, t1=%.3f)" % [t0, t1]) - - # Reset to wait for next skill - pv.set_anim_state("wait") - for f in range(5): - await process_frame - - # Clean up - if is_instance_valid(pv.anim): - pv.anim.free() - if is_instance_valid(pv.model): - pv.model.free() - pv.free() - if ClassDB.class_exists("Metin2Model"): - var m_dummy: Object = ClassDB.instantiate("Metin2Model") - m_dummy.call("clear_texture_cache") - m_dummy.free() - await process_frame - await process_frame - - print("\n=== Completed test_warrior_mental_skills_regression with %d failures ===" % fails) - if fails == 0: - print("ALL PASS") - quit(fails) diff --git a/project/test_warrior_mental_skills_regression.gd.uid b/project/test_warrior_mental_skills_regression.gd.uid deleted file mode 100644 index 1ccb9c6d..00000000 --- a/project/test_warrior_mental_skills_regression.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d1kyaggb8ep12 diff --git a/project/test_wasd_steering_parity.gd b/project/test_wasd_steering_parity.gd deleted file mode 100644 index 7906bf30..00000000 --- a/project/test_wasd_steering_parity.gd +++ /dev/null @@ -1,132 +0,0 @@ -# test_wasd_steering_parity.gd —— 验证 WASD 转向与连续方向切换对齐 40250: -# 1. 连续快速切换方向(W -> S, A -> D, 对角线切换等),俯仰角(X)与翻滚角(Z)始终保持 0,无翻跟头/倾倒。 -# 2. 偏航角(Y)平滑过渡且严格归一化在 [-PI, PI],越过 ±PI 边界时不发生每帧方向往复震荡。 -# 3. 40250 按键优先级(Up 优先于 Down,Left 优先于 Right):切换瞬间两键同时按住时不归零卡顿。 -# -# godot --headless --path project --script test_wasd_steering_parity.gd -extends SceneTree - -const PlayerController = preload("res://player_controller.gd") - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - else: - print(" [PASS] " + m) - -func _init() -> void: - await process_frame - _run() - if _fail == 0: - print("=== ALL WASD STEERING PARITY TESTS PASSED ===") - quit(0) - else: - printerr("=== FAILED: %d check(s) failed ===" % _fail) - quit(1) - -func _run() -> void: - var pc := PlayerController.new() - var player := Node3D.new() - get_root().add_child(player) - get_root().add_child(pc) - pc.player = player - pc.rotation_speed_deg = 1200.0 - - # ------------------------------------------------------------------------- - # Test 0: 40250 按键事件状态,不继承进入游戏前的系统按键 - # ------------------------------------------------------------------------- - print("\n--- Test 0: 进入游戏按键状态与 W/S 优先级 ---") - pc.stop() - _ck(pc._wasd() == Vector2.ZERO, "fresh/stop state cannot auto-move on game entry") - var w_down := InputEventKey.new() - w_down.keycode = KEY_W - w_down.pressed = true - pc.active = false - pc._unhandled_input(w_down) - pc.active = true - _ck(pc._wasd() == Vector2.ZERO, "W pressed during loading is not inherited by the game phase") - pc._unhandled_input(w_down) - _ck(pc._wasd() == Vector2(0, -1), "W KeyDown starts forward movement") - var s_down := InputEventKey.new() - s_down.keycode = KEY_S - s_down.pressed = true - pc._unhandled_input(s_down) - _ck(pc._wasd() == Vector2(0, -1), "W keeps priority while W and S overlap") - var w_up := InputEventKey.new() - w_up.keycode = KEY_W - w_up.pressed = false - pc._input(w_up) - _ck(pc._wasd() == Vector2(0, 1), "releasing W switches cleanly to held S") - pc.stop() - _ck(pc._wasd() == Vector2.ZERO, "stop clears all latched direction keys") - - # ------------------------------------------------------------------------- - # Test 1: 边界跨越平滑度与无翻跟头(X/Z 严格为 0) - # ------------------------------------------------------------------------- - print("\n--- Test 1: 边界跨越(±PI)平滑转向与 X/Z 稳定性 ---") - player.rotation = Vector3.ZERO - # 模拟从北(+PI)到西(-PI/2)再到南(0)再到东(PI/2)再到北(-PI) - var targets: Array[float] = [ - PI - 0.05, - -PI + 0.05, - 0.0, - PI / 2.0, - -PI / 2.0, - PI - ] - var flipped := false - var xz_tilted := false - for t in targets: - for step in range(30): - pc._turn_toward(t, 0.016) - if absf(player.rotation.x) > 1e-4 or absf(player.rotation.z) > 1e-4: - xz_tilted = true - if absf(player.rotation.y) > PI + 1e-3: - flipped = true - _ck(not xz_tilted, "Player rotation.x and rotation.z strictly remain 0.0 (no pitch/roll somersault)") - _ck(not flipped, "Player rotation.y strictly remains in [-PI, PI] without unbounded growth") - - # ------------------------------------------------------------------------- - # Test 2: 180° 急转弯不发生往复震荡(单向收敛) - # ------------------------------------------------------------------------- - print("\n--- Test 2: 180° 急转弯单向平滑收敛 ---") - player.rotation.y = 3.0 - var target_yaw := -2.8 - var prev_diff := 1e9 - var monotonic := true - for step in range(20): - var cur_yaw: float = wrapf(player.rotation.y, -PI, PI) - var diff: float = absf(wrapf(target_yaw - cur_yaw, -PI, PI)) - pc._turn_toward(target_yaw, 0.016) - var new_diff: float = absf(wrapf(target_yaw - player.rotation.y, -PI, PI)) - if new_diff > diff + 1e-4: - monotonic = false - if is_equal_approx(player.rotation.y, target_yaw): - break - _ck(monotonic, "Turn toward target is monotonic across the PI boundary (no back-and-forth oscillation)") - _ck(is_equal_approx(player.rotation.y, target_yaw), "Turn converges exactly to target_yaw") - - # ------------------------------------------------------------------------- - # Test 3: 连续 WASD 快速方向切换无卡死或翻滚 - # ------------------------------------------------------------------------- - print("\n--- Test 3: 连续高频 WASD 切换稳定性 ---") - var dirs := [ - Vector3(0, 0, -1), # W - Vector3(0, 0, 1), # S - Vector3(-1, 0, 0), # A - Vector3(1, 0, 0), # D - Vector3(-0.707, 0, -0.707), # WA - Vector3(0.707, 0, 0.707), # SD - Vector3(0.707, 0, -0.707), # WD - Vector3(-0.707, 0, 0.707), # SA - ] - var ok := true - for d in dirs: - var yaw := atan2(d.x, d.z) - for _step in range(15): - pc._turn_toward(yaw, 0.016) - if absf(player.rotation.x) > 1e-4 or absf(player.rotation.z) > 1e-4: - ok = false - _ck(ok, "Continuous 8-way rapid directional changes maintain perfect upright orientation") diff --git a/project/test_wasd_steering_parity.gd.uid b/project/test_wasd_steering_parity.gd.uid deleted file mode 100644 index cbcb5036..00000000 --- a/project/test_wasd_steering_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dcqpg7fl3j70k diff --git a/project/test_whisper_chat_parity.gd b/project/test_whisper_chat_parity.gd deleted file mode 100644 index 49b4143b..00000000 --- a/project/test_whisper_chat_parity.gd +++ /dev/null @@ -1,112 +0,0 @@ -# test_whisper_chat_parity.gd —— 40250 独立私聊窗口与任务栏闪烁通知回归测试 -extends SceneTree - -const SystemClass = preload("res://whisper_chat_system.gd") - -var _passed: int = 0 -var _failed: int = 0 - -func _assert(cond: bool, msg: String) -> void: - if cond: - _passed += 1 - print(" [PASS] %s" % msg) - else: - _failed += 1 - printerr(" [FAIL] %s" % msg) - -func _init() -> void: - print("\n=== Running test_whisper_chat_parity (Batch 27 Direction 2) ===") - _test_conversation_lifecycle() - _test_taskbar_blinking_notification() - _test_gamemaster_identification() - _test_ignore_blacklist_filtering() - _test_bidirectional_messaging() - - print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed]) - if _failed == 0: - print("ALL TESTS PASSED!\n") - else: - printerr("SOME TESTS FAILED!\n") - quit(0 if _failed == 0 else 1) - -func _test_conversation_lifecycle() -> void: - print("\n--- Test 1: Conversation Lifecycle (Open / Minimize / Close) ---") - var w = SystemClass.new("Hero") - - var conv = w.open_conversation("Alice") - _assert(conv != null, "Opened conversation with Alice") - _assert(conv.state == SystemClass.DialogState.VISIBLE, "Conversation is VISIBLE") - - w.minimize_conversation("Alice") - _assert(conv.state == SystemClass.DialogState.MINIMIZED, "Conversation MINIMIZED to taskbar") - - w.close_conversation("Alice") - _assert(conv.state == SystemClass.DialogState.CLOSED, "Conversation CLOSED") - -func _test_taskbar_blinking_notification() -> void: - print("\n--- Test 2: Taskbar Blinking & Unread Count ---") - var w = SystemClass.new("Hero") - - # Receive message from Bob without active window - var r = w.receive_whisper("Bob", "Hello there!") - _assert(r["delivered"] == true, "Message delivered") - _assert(r["triggered_blink"] == true, "Triggered taskbar blinking notification") - - var conv = w.conversations["Bob"] - _assert(conv.is_blinking == true, "Bob's conversation is blinking") - _assert(conv.unread_count == 1, "Unread count is 1") - - # Receive second message - w.receive_whisper("Bob", "Are you there?") - _assert(conv.unread_count == 2, "Unread count increased to 2") - - # Open conversation: should clear blinking and unread count - w.open_conversation("Bob") - _assert(conv.is_blinking == false, "Blinking cleared upon opening") - _assert(conv.unread_count == 0, "Unread count reset to 0") - _assert(conv.state == SystemClass.DialogState.VISIBLE, "Conversation state restored to VISIBLE") - -func _test_gamemaster_identification() -> void: - print("\n--- Test 3: Game Master [GM] Identification ---") - var w = SystemClass.new("Hero") - - var r = w.receive_whisper("[GM]Admin", "Server maintenance in 10 minutes.") - _assert(r["delivered"] == true, "GM message delivered") - var conv = w.conversations["[GM]Admin"] - _assert(conv.is_gamemaster == true, "Identified as Game Master") - _assert(conv.history[0].is_gm == true, "Message tagged with is_gm flag") - -func _test_ignore_blacklist_filtering() -> void: - print("\n--- Test 4: Ignore Blacklist Filtering ---") - var w = SystemClass.new("Hero") - - # Blacklist Spammer - var is_ign = w.toggle_ignore_target("Spammer") - _assert(is_ign == true, "Spammer added to ignore blacklist") - _assert(w.is_ignored("Spammer") == true, "Spammer is ignored") - - # Receive whisper from Spammer - var r = w.receive_whisper("Spammer", "Buy gold cheap!") - _assert(r["delivered"] == false, "Spammer whisper rejected") - _assert(r["blocked_by_ignore"] == true, "Blocked by ignore filter") - _assert(w.get_blinking_conversations().is_empty(), "No blinking notifications triggered for ignored user") - - # Un-ignore Spammer - var is_ign_again = w.toggle_ignore_target("Spammer") - _assert(is_ign_again == false, "Spammer removed from ignore blacklist") - var r2 = w.receive_whisper("Spammer", "Now you can hear me") - _assert(r2["delivered"] == true, "Whisper delivered after un-ignoring") - -func _test_bidirectional_messaging() -> void: - print("\n--- Test 5: Bidirectional Messaging History ---") - var w = SystemClass.new("Hero") - - w.send_whisper("Alice", "Hey Alice, let's do Demon Tower!") - w.current_time += 10 - w.receive_whisper("Alice", "Sure, meet at DT entrance!") - - var conv = w.conversations["Alice"] - _assert(conv.history.size() == 2, "Conversation history contains 2 messages") - _assert(conv.history[0].sender == "Hero", "First message sent by Hero") - _assert(conv.history[1].sender == "Alice", "Second message sent by Alice") - _assert(conv.history[1].timestamp == 1010, "Timestamp recorded correctly") diff --git a/project/test_whisper_chat_parity.gd.uid b/project/test_whisper_chat_parity.gd.uid deleted file mode 100644 index 23625f8c..00000000 --- a/project/test_whisper_chat_parity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://sxrkojeyqa3m diff --git a/project/testing/forest_viewpoints.gd b/project/testing/forest_viewpoints.gd deleted file mode 100644 index 379ea57f..00000000 --- a/project/testing/forest_viewpoints.gd +++ /dev/null @@ -1,195 +0,0 @@ -extends RefCounted - -## MAP-02 地图内验收的机位夹具(test/playable/forest-viewpoints..local.json)。 -## -## 机位必须由测试环境负责人在实服上确认(服务器全局 cm)。未配置的 flat/slope/dense -## 只能由地形/树木密度自动挑“候选”机位:照样截图和测量,但用例保持 BLOCKED, -## 不能冒充“经确认的机位”。传送点无法离线推导,缺配置即 BLOCKED。 - -const Config = preload("res://testing/playable_config.gd") - -const SCHEMA_VERSION := 1 -const KINDS := ["flat", "slope", "dense", "warp"] -const AUTO_KINDS := ["flat", "slope", "dense"] -const STATUSES := ["confirmed", "unconfirmed"] -const MAP_CELL_CM := 25600.0 -const DEFAULT_YAW_DEG := 45.0 -## 自动候选:点位离地图边缘、彼此之间和最近树干的最小距离(米),以及坡地的可行走上限。 -const AUTO_EDGE_MARGIN_M := 12.0 -const AUTO_SEPARATION_M := 20.0 -const AUTO_TREE_CLEARANCE_M := 3.0 -const AUTO_DENSITY_RADIUS_M := 12.0 -const AUTO_MAX_SLOPE_RATIO := 0.6 - -static func load_file(path: String) -> Dictionary: - var result := {"ok": false, "data": {}, "errors": []} - if path.strip_edges().is_empty(): - result.ok = true # 没有夹具文件:全部机位走候选/BLOCKED,而不是失败 - return result - if not FileAccess.file_exists(path): - result.errors.append("viewpoint file does not exist: %s" % path) - return result - var file := FileAccess.open(path, FileAccess.READ) - var parsed: Variant = JSON.parse_string(file.get_as_text()) if file else null - if not (parsed is Dictionary): - result.errors.append("viewpoint file root must be a JSON object") - return result - result.data = parsed - result.ok = true - return result - -## bounds = {"base": Vector2 cm, "size": Vector2i tiles}(GameScene._map_bounds 的结果)。 -## 返回 {errors, viewpoints, missing_kinds, races};viewpoints 带 local_m(地图本地米)。 -static func plan(data: Dictionary, map_key: String, bounds: Dictionary) -> Dictionary: - var errors: Array[String] = [] - var viewpoints: Array = [] - var races: Array = [] - var out := {"errors": errors, "viewpoints": viewpoints, "missing_kinds": [], "races": races} - if not data.is_empty(): - if not _is_int(data.get("schema_version", null)) or int(data.schema_version) != SCHEMA_VERSION: - errors.append("viewpoint schema_version must be %d" % SCHEMA_VERSION) - if not (data.get("maps", null) is Dictionary): - errors.append("viewpoint maps must be an object keyed by map_key") - Config._reject_secrets(data, "", errors) - var maps: Dictionary = data.get("maps", {}) if data.get("maps", {}) is Dictionary else {} - var entry: Variant = maps.get(map_key, {}) - if not (entry is Dictionary): - errors.append("maps.%s must be an object" % map_key) - entry = {} - var seen := {} - var listed: Variant = entry.get("viewpoints", []) - if not (listed is Array): - errors.append("maps.%s.viewpoints must be an array" % map_key) - listed = [] - for i in listed.size(): - var where := "maps.%s.viewpoints[%d]" % [map_key, i] - var item: Variant = listed[i] - if not (item is Dictionary): - errors.append("%s must be an object" % where) - continue - var id := String(item.get("id", "")).strip_edges() - if id.is_empty() or not id.is_valid_filename() or " " in id: - errors.append("%s.id must be a non-empty file-safe name" % where) - elif seen.has(id): - errors.append("%s.id duplicates %s" % [where, id]) - seen[id] = true - var kind := String(item.get("kind", "")) - if not (kind in KINDS): - errors.append("%s.kind must be one of %s" % [where, KINDS]) - var status := String(item.get("status", "")) - var confirmed_by := String(item.get("confirmed_by", "")).strip_edges() - if not (status in STATUSES): - errors.append("%s.status must be confirmed or unconfirmed" % where) - elif status == "confirmed" and confirmed_by.is_empty(): - errors.append("%s is confirmed but confirmed_by (role) is empty" % where) - elif status == "unconfirmed" and not confirmed_by.is_empty(): - errors.append("%s is unconfirmed but carries confirmed_by" % where) - var cm: Variant = item.get("server_cm", null) - var server_cm := Vector2(-1, -1) - if not (cm is Array) or cm.size() != 2 or not _is_int(cm[0]) or not _is_int(cm[1]) \ - or int(cm[0]) < 0 or int(cm[1]) < 0: - errors.append("%s.server_cm must be [x,y] non-negative integer centimeters" % where) - else: - server_cm = Vector2(int(cm[0]), int(cm[1])) - if not contains_server_cm(bounds, server_cm): - errors.append("%s.server_cm %s is outside %s" % [where, cm, map_key]) - var yaw: Variant = item.get("camera_yaw_deg", DEFAULT_YAW_DEG) - if not (yaw is float or yaw is int): - errors.append("%s.camera_yaw_deg must be a number" % where) - yaw = DEFAULT_YAW_DEG - viewpoints.append({"id": id, "kind": kind, "status": status, "source": "config", - "confirmed_by": confirmed_by, "camera_yaw_deg": float(yaw), "server_cm": server_cm, - "local_m": to_local_m(bounds, server_cm)}) - var race_list: Variant = entry.get("races", []) - if not (race_list is Array): - errors.append("maps.%s.races must be an array of mob vnums" % map_key) - else: - for value in race_list: - if not _is_int(value) or int(value) <= 0: - errors.append("maps.%s.races must contain positive integers" % map_key) - break - races.append(int(value)) - for kind in KINDS: - if not viewpoints.any(func(v: Dictionary) -> bool: return v.kind == kind): - out.missing_kinds.append(kind) - return out - -static func contains_server_cm(bounds: Dictionary, server_cm: Vector2) -> bool: - if bounds.is_empty(): - return false - var base: Vector2 = bounds.base - var size: Vector2i = bounds.size - return server_cm.x >= base.x and server_cm.y >= base.y \ - and server_cm.x < base.x + size.x * MAP_CELL_CM and server_cm.y < base.y + size.y * MAP_CELL_CM - -## 服务器全局 cm -> Metin2World 地图本地米(与 MapCoord.to_world 一致:+Z 朝南)。 -static func to_local_m(bounds: Dictionary, server_cm: Vector2) -> Vector2: - var base: Vector2 = bounds.get("base", Vector2.ZERO) - return (server_cm - base) * 0.01 - -static func to_server_cm(bounds: Dictionary, local_m: Vector2) -> Vector2: - var base: Vector2 = bounds.get("base", Vector2.ZERO) - return Vector2(roundf(local_m.x * 100.0 + base.x), roundf(local_m.y * 100.0 + base.y)) - -## samples: [{p: Vector2 本地米, range_m: 足迹内地形高差, span_m: 足迹宽度, blocked: bool}] -## trees: 树干本地米坐标。只挑 kinds 里请求的种类;挑不出来的种类不返回(调用方记 BLOCKED)。 -static func pick_candidates(samples: Array, trees: PackedVector2Array, size_m: Vector2, kinds: Array) -> Array: - var usable: Array = [] - for s: Dictionary in samples: - var p: Vector2 = s.p - if bool(s.blocked) or p.x < AUTO_EDGE_MARGIN_M or p.y < AUTO_EDGE_MARGIN_M \ - or p.x > size_m.x - AUTO_EDGE_MARGIN_M or p.y > size_m.y - AUTO_EDGE_MARGIN_M: - continue - var nearest := INF - var density := 0 - for t in trees: - var d := t.distance_to(p) - nearest = minf(nearest, d) - if d <= AUTO_DENSITY_RADIUS_M: - density += 1 - if nearest < AUTO_TREE_CLEARANCE_M: - continue - var slope := float(s.range_m) / maxf(float(s.span_m), 0.001) - usable.append({"p": p, "range_m": float(s.range_m), "slope_ratio": slope, "density": density}) - var chosen: Array = [] - var centre := size_m * 0.5 - for kind in ["flat", "dense", "slope"]: - if not (kind in kinds): - continue - var best: Dictionary = {} - for u: Dictionary in usable: - if chosen.any(func(c: Dictionary) -> bool: return (c.p as Vector2).distance_to(u.p) < AUTO_SEPARATION_M): - continue - if best.is_empty() or _better(kind, u, best, centre): - best = u - if best.is_empty(): - continue - if kind == "slope" and float(best.slope_ratio) <= 0.0: - continue - if kind == "dense" and int(best.density) == 0: - continue - var c := best.duplicate() - c["kind"] = kind - chosen.append(c) - return chosen - -static func _better(kind: String, a: Dictionary, b: Dictionary, centre: Vector2) -> bool: - match kind: - "flat": - if not is_equal_approx(float(a.range_m), float(b.range_m)): - return float(a.range_m) < float(b.range_m) - return (a.p as Vector2).distance_to(centre) < (b.p as Vector2).distance_to(centre) - "slope": - var a_ok := float(a.slope_ratio) <= AUTO_MAX_SLOPE_RATIO - var b_ok := float(b.slope_ratio) <= AUTO_MAX_SLOPE_RATIO - if a_ok != b_ok: - return a_ok - return float(a.slope_ratio) > float(b.slope_ratio) if a_ok else float(a.slope_ratio) < float(b.slope_ratio) - "dense": - if int(a.density) != int(b.density): - return int(a.density) > int(b.density) - return float(a.range_m) < float(b.range_m) - return false - -static func _is_int(value: Variant) -> bool: - return value is int or (value is float and is_equal_approx(value, roundf(value))) diff --git a/project/testing/forest_viewpoints.gd.uid b/project/testing/forest_viewpoints.gd.uid deleted file mode 100644 index a4738a1f..00000000 --- a/project/testing/forest_viewpoints.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://pcmal6a04puk diff --git a/project/testing/playable_config.gd b/project/testing/playable_config.gd deleted file mode 100644 index 4a6edad8..00000000 --- a/project/testing/playable_config.gd +++ /dev/null @@ -1,478 +0,0 @@ -class_name PlayableConfig - -## 首个联网内测的配置解析器。 -## -## 这是纯数据校验层:不读取账号密码、不创建网络连接、不修改游戏状态。 -## 生产测试入口必须先通过 validate(),任何缺少的必测字段都只能得到 BLOCKED, -## 不能由测试脚本临时补默认值后继续发包。 - -const SCHEMA_VERSION := 1 -const REQUIRED_KEYS := [ - "scenario_id", "protocol", "server", "character_slot", "map_key", - "waypoints_cm", "allowed_mob_vnums", "allowed_drop_vnums", "skill_cases", - "resolution", "loops", "timeout_seconds", -] -const SERVER_KEYS := ["server_index", "channel", "auth_host", "auth_port", "game_host", "game_port"] -const SUITES := ["playable", "full", "soak"] -const MAP_CELL_CM := 25600.0 -const GOLD_VNUM := 1 - -## 技能证据分两类:只有接收端分发(M2Client 信号)才算服务端结果。 -## 本地施法开始、特效节点生成只能作为补充证据,单独出现不能让用例 PASS。 -const SERVER_EVIDENCE := ["damage", "target_dead", "server_motion", "effect_cue", - "fly_cue", "affect_added", "sp_changed"] -const LOCAL_EVIDENCE := ["cast_started", "fx_spawned", "fx_finished"] -const SKILL_TARGETS := ["enemy", "self", "ground"] - -static func load_file(path: String) -> Dictionary: - var result := {"ok": false, "config": {}, "errors": []} - if path.strip_edges().is_empty(): - result.errors.append("config path is empty") - return result - if not FileAccess.file_exists(path): - result.errors.append("config file does not exist: %s" % path) - return result - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - result.errors.append("config file cannot be opened") - return result - var parsed: Variant = JSON.parse_string(file.get_as_text()) - file.close() - if not (parsed is Dictionary): - result.errors.append("config root must be a JSON object") - return result - var checked := validate(parsed) - result.config = parsed - result.errors = checked.errors - result.ok = bool(checked.ok) - return result - -static func validate(config: Dictionary) -> Dictionary: - var errors: Array[String] = [] - for key in REQUIRED_KEYS: - if not config.has(key): - errors.append("missing required key: %s" % key) - if not _is_int(config.get("schema_version", null)) or int(config.get("schema_version", -1)) != SCHEMA_VERSION: - errors.append("schema_version must be %d" % SCHEMA_VERSION) - if String(config.get("scenario_id", "")).strip_edges().is_empty(): - errors.append("scenario_id must not be empty") - var protocol := String(config.get("protocol", "")).strip_edges().to_lower() - if protocol != "classic": - errors.append("protocol must be classic") - var server: Variant = config.get("server", null) - if not (server is Dictionary): - errors.append("server must be an object selected from serverinfo") - else: - for key in SERVER_KEYS: - if not server.has(key): - errors.append("server.%s is missing; copy the address selected from serverinfo" % key) - if not _is_int(server.get("server_index", null)) or int(server.get("server_index", -1)) < 0: - errors.append("server.server_index must be a non-negative integer") - if not _is_int(server.get("channel", null)) or int(server.get("channel", 0)) < 1: - errors.append("server.channel must be a channel number (>= 1)") - for key in ["auth_host", "game_host"]: - if String(server.get(key, "")).strip_edges().is_empty(): - errors.append("server.%s must not be empty" % key) - for key in ["auth_port", "game_port"]: - if not _is_int(server.get(key, null)) or int(server.get(key, 0)) <= 0 or int(server.get(key, 0)) > 65535: - errors.append("server.%s must be 1..65535" % key) - if not _is_int(config.get("character_slot", null)) or int(config.get("character_slot", -1)) < 0: - errors.append("character_slot must be a non-negative integer") - var map_key := String(config.get("map_key", "")).strip_edges() - if map_key.is_empty(): - errors.append("map_key is empty; resolve it from the server/map fixture") - elif map_key.begins_with("/") or ".." in map_key.split("/") or "\\" in map_key: - errors.append("map_key must be a relative asset directory such as outdoortrent/metin2_map_trent") - var waypoints: Variant = config.get("waypoints_cm", null) - if not (waypoints is Array) or waypoints.is_empty(): - errors.append("waypoints_cm must contain at least one [x,y] centimeter point") - else: - for i in waypoints.size(): - var point: Variant = waypoints[i] - if not (point is Array) or point.size() != 2 or not _is_int(point[0]) or not _is_int(point[1]) \ - or int(point[0]) < 0 or int(point[1]) < 0: - errors.append("waypoints_cm[%d] must be [x,y] non-negative integer centimeters" % i) - var mobs: Variant = config.get("allowed_mob_vnums", null) - if not (mobs is Array) or mobs.is_empty(): - errors.append("allowed_mob_vnums must contain confirmed server mob vnums") - else: - _validate_positive_int_array(mobs, "allowed_mob_vnums", errors) - var drops: Variant = config.get("allowed_drop_vnums", null) - if not (drops is Array) or drops.is_empty(): - errors.append("allowed_drop_vnums must contain a deterministic pickup fixture") - else: - _validate_positive_int_array(drops, "allowed_drop_vnums", errors) - for value in drops: - if _is_int(value) and int(value) == GOLD_VNUM: - errors.append("allowed_drop_vnums must not contain gold (vnum 1); money is a separate points case") - _validate_skill_cases(config.get("skill_cases", null), errors) - var resolution: Variant = config.get("resolution", null) - if not (resolution is Array) or resolution.size() != 2 or not _is_int(resolution[0]) or not _is_int(resolution[1]) \ - or int(resolution[0]) <= 0 or int(resolution[1]) <= 0: - errors.append("resolution must be [width,height]") - if not _is_int(config.get("loops", null)) or int(config.get("loops", 0)) < 1: - errors.append("loops must be at least 1") - if not _is_int(config.get("timeout_seconds", null)) or int(config.get("timeout_seconds", 0)) < 1: - errors.append("timeout_seconds must be positive") - # This config is deliberately data-only. Reject the common accidental secret paths anywhere in the tree. - _reject_secrets(config, "", errors) - return {"ok": errors.is_empty(), "errors": errors} - -## test/playable/skill-cases.json:每职业 × {单体, 范围, 自身增益, 飞行效果} 的实际技能矩阵。 -## 条目只能是 unconfirmed(缺夹具 -> BLOCKED)、confirmed(环境负责人确认的已学技能)或 -## not_applicable(附清单确认记录)。脚本从不补技能 ID;confirmed 的 skill_id 必须属于该职业 -## 且不是被动技,每技能至少成功施放 SKILL_MATRIX_REPEATS 次。 -const SKILL_MATRIX_TYPES := ["single_target", "area", "self_buff", "flying"] -const SKILL_MATRIX_JOBS := ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"] -const SKILL_MATRIX_REPEATS := 10 - -static func skill_matrix_cases(matrix: Dictionary, job: String, skill_table: RefCounted = null) -> Dictionary: - var errors: Array[String] = [] - var blocked: Array[String] = [] - var not_applicable: Array[String] = [] - var cases: Array = [] - var result := {"errors": errors, "blocked": blocked, "not_applicable": not_applicable, "cases": cases} - if not _is_int(matrix.get("schema_version", null)) or int(matrix.get("schema_version", -1)) != SCHEMA_VERSION: - errors.append("skill matrix schema_version must be %d" % SCHEMA_VERSION) - var jobs: Variant = matrix.get("jobs", null) - if not (jobs is Dictionary): - errors.append("skill matrix jobs must be an object") - return result - for name in jobs: - if not (String(name) in SKILL_MATRIX_JOBS): - errors.append("skill matrix has unknown job: %s" % name) - if not (job in SKILL_MATRIX_JOBS) or not (jobs.get(job, null) is Dictionary): - errors.append("skill matrix has no entry for job %s" % job) - return result - var row: Dictionary = jobs[job] - for type in row: - if not (String(type) in SKILL_MATRIX_TYPES): - errors.append("%s has unknown skill type: %s" % [job, type]) - for type in SKILL_MATRIX_TYPES: - var where := "%s/%s" % [job, type] - var entry: Variant = row.get(type, null) - if not (entry is Dictionary): - errors.append("%s is missing" % where) - continue - match String(entry.get("status", "")): - "unconfirmed": - if entry.has("skill_id"): - errors.append("%s is unconfirmed but carries a skill_id" % where) - blocked.append(where) - "not_applicable": - if String(entry.get("checklist_ref", "")).strip_edges().is_empty(): - errors.append("%s not_applicable needs checklist_ref" % where) - not_applicable.append(where) - "confirmed": - var skill_id := int(entry.skill_id) if _is_int(entry.get("skill_id", null)) else 0 - if skill_table != null and skill_id > 0: - if String(skill_table.category_of(skill_id)) != job: - errors.append("%s skill_id %d does not belong to %s" % [where, skill_id, job]) - elif skill_table.is_passive(skill_id): - errors.append("%s skill_id %d is passive" % [where, skill_id]) - if entry.has("repeats") and _is_int(entry.repeats) and int(entry.repeats) < SKILL_MATRIX_REPEATS: - errors.append("%s repeats must be at least %d" % [where, SKILL_MATRIX_REPEATS]) - cases.append({"case_id": "%s-%s" % [job.to_lower(), type.replace("_", "-")], - "skill_id": entry.get("skill_id", null), "target": entry.get("target", "enemy"), - "repeats": entry.get("repeats", SKILL_MATRIX_REPEATS), - "required_evidence": entry.get("required_evidence", null)}) - _: - errors.append("%s status must be unconfirmed, confirmed or not_applicable" % where) - if not cases.is_empty(): - _validate_skill_cases(cases, errors) - elif blocked.is_empty(): - errors.append("%s has no confirmed skill case" % job) - return result - -## 必测用例清单由配置推导,客户端与父运行器共用同一份结果, -## 运行器在启动前落盘,校验器据此拒绝“少报一个用例”的 PASS。 -static func required_cases(config: Dictionary, suite: String) -> Array[String]: - var out: Array[String] = ["CONFIG-01", "NET-LOGIN-01", "NET-SELECT-01", "NET-WORLD-01"] - var waypoints: Variant = config.get("waypoints_cm", []) - if waypoints is Array: - for i in waypoints.size(): - out.append("NET-MOVE-%02d" % (i + 1)) - out.append_array(["NET-MOVE-CONFIRM-01", "NET-TARGET-01", "NET-ATTACK-01", "NET-DROP-01", "NET-PICKUP-01"]) - if suite == "full": - var skills: Variant = config.get("skill_cases", []) - if skills is Array: - for item in skills: - if item is Dictionary: - out.append(skill_case_id(item)) - if suite == "soak": - # STB-MEMORY-01 is a runner case (external RSS sampler); exits are counted by the runner. - out.append_array(SOAK_CASES) - return out - -## STB-01 §9.2 soak block. The minimums are the release acceptance numbers, not -## defaults: a shorter or smaller soak is rejected instead of silently reported as PASS. -## Warp routes and real transport faults need environment-owner confirmation; the -## unconfirmed status is valid config but the matching case stays BLOCKED. -const SOAK_CASES := ["STB-DURATION-01", "STB-FRAMES-01", "STB-RECONNECT-01", "STB-WARP-01", - "STB-DISCONNECT-01", "STB-RESIZE-01"] -const SOAK_MIN_DURATION_S := 7200 -const SOAK_MIN_REST_S := 30 -const SOAK_MIN_ROUNDS := 10 -const SOAK_MIN_RECONNECTS := 10 -const SOAK_MIN_EXITS := 10 -const SOAK_MIN_WARPS := 20 -const SOAK_RESOLUTION_COUNT := 3 -const SOAK_MIN_SWITCHES := 10 -const SOAK_TIMEOUT_MARGIN_S := 900 -const SOAK_MAX_UNREACHABLE_S := 20 -const SOAK_FAULT_TYPES := ["close", "unreachable"] - -## soak_client=false validates a soak block seen by another suite (the playable exit runs -## sharing the scenario): everything except the client timeout margin, which only the -## 2 h soak client needs. -static func validate_soak(config: Dictionary, soak_client := true) -> Dictionary: - var errors: Array[String] = [] - var soak: Variant = config.get("soak", null) - if not (soak is Dictionary): - errors.append("soak must be an object for the soak suite") - return {"ok": false, "errors": errors} - var minimums := {"duration_seconds": SOAK_MIN_DURATION_S, "rest_seconds": SOAK_MIN_REST_S, "warmup_rounds": 0, - "min_rounds": SOAK_MIN_ROUNDS, "reconnects": SOAK_MIN_RECONNECTS, "exits": SOAK_MIN_EXITS} - for key in minimums: - if not _is_int(soak.get(key, null)) or int(soak.get(key, -1)) < int(minimums[key]): - errors.append("soak.%s must be an integer >= %d" % [key, minimums[key]]) - var duration := int(soak.get("duration_seconds", 0)) if _is_int(soak.get("duration_seconds", null)) else 0 - if soak_client and _is_int(config.get("timeout_seconds", null)) and int(config.timeout_seconds) < duration + SOAK_TIMEOUT_MARGIN_S: - errors.append("timeout_seconds must be at least soak.duration_seconds + %d" % SOAK_TIMEOUT_MARGIN_S) - var resolutions: Variant = soak.get("resolutions", null) - if not (resolutions is Dictionary): - errors.append("soak.resolutions must be an object") - else: - var sizes: Variant = resolutions.get("sizes", null) - var seen := {} - if not (sizes is Array) or sizes.size() != SOAK_RESOLUTION_COUNT: - errors.append("soak.resolutions.sizes must list exactly %d logical sizes" % SOAK_RESOLUTION_COUNT) - else: - for i in sizes.size(): - var size: Variant = sizes[i] - if not (size is Array) or size.size() != 2 or not _is_int(size[0]) or not _is_int(size[1]) \ - or int(size[0]) <= 0 or int(size[1]) <= 0: - errors.append("soak.resolutions.sizes[%d] must be [width,height] logical points" % i) - continue - var key := "%dx%d" % [int(size[0]), int(size[1])] - if seen.has(key): - errors.append("soak.resolutions.sizes[%d] duplicates %s" % [i, key]) - seen[key] = true - if not _is_int(resolutions.get("switches_per_size", null)) or int(resolutions.get("switches_per_size", 0)) < SOAK_MIN_SWITCHES: - errors.append("soak.resolutions.switches_per_size must be >= %d" % SOAK_MIN_SWITCHES) - var warp: Variant = soak.get("warp", null) - if not (warp is Dictionary): - errors.append("soak.warp must be an object with status unconfirmed|confirmed") - else: - match String(warp.get("status", "")): - "unconfirmed": - pass - "confirmed": - if not _is_int(warp.get("required", null)) or int(warp.get("required", 0)) < SOAK_MIN_WARPS or int(warp.get("required", 0)) % 2 != 0: - errors.append("soak.warp.required must be an even integer >= %d (out-and-back legs)" % SOAK_MIN_WARPS) - for key in ["portal_cm", "return_portal_cm"]: - if not _is_cm_point(warp.get(key, null)): - errors.append("soak.warp.%s must be [x,y] non-negative integer centimeters" % key) - var destination := String(warp.get("destination_map_key", "")).strip_edges() - if destination.is_empty() or destination.begins_with("/") or ".." in destination.split("/") or "\\" in destination: - errors.append("soak.warp.destination_map_key must be a relative asset directory") - elif destination == String(config.get("map_key", "")): - errors.append("soak.warp.destination_map_key must differ from map_key") - if not (warp.get("cross_server", null) is bool): - errors.append("soak.warp.cross_server must be true or false as verified on the test server") - _: - errors.append("soak.warp.status must be unconfirmed or confirmed") - var faults: Variant = soak.get("faults", null) - if not (faults is Dictionary): - errors.append("soak.faults must be an object with status unconfirmed|confirmed") - else: - match String(faults.get("status", "")): - "unconfirmed": - pass - "confirmed": - # Only a test-connection proxy on 127.0.0.1; never global routes or firewall rules. - if String(faults.get("mode", "")) != "local_proxy": - errors.append("soak.faults.mode must be local_proxy") - if not _is_int(faults.get("per_type", null)) or int(faults.get("per_type", 0)) < 1: - errors.append("soak.faults.per_type must be a positive integer") - var seconds: Variant = faults.get("unreachable_seconds", null) - if not _is_int(seconds) or int(seconds) < 1 or int(seconds) > SOAK_MAX_UNREACHABLE_S: - errors.append("soak.faults.unreachable_seconds must be 1..%d" % SOAK_MAX_UNREACHABLE_S) - _check_fault_upstream(config, faults.get("upstream", null), errors) - _: - errors.append("soak.faults.status must be unconfirmed or confirmed") - _reject_secrets(soak, "soak.", errors) - return {"ok": errors.is_empty(), "errors": errors} - -## The proxy (script/playable_fault_proxy.mjs) listens on server.* which must be loopback, -## and forwards to upstream (the real test server). It only carries this client's connection. -static func _check_fault_upstream(config: Dictionary, upstream: Variant, errors: Array[String]) -> void: - var server: Variant = config.get("server", {}) - if not (server is Dictionary): - return - for key in ["auth_host", "game_host"]: - if String(server.get(key, "")) != "127.0.0.1": - errors.append("soak.faults local_proxy needs server.%s = 127.0.0.1 (the proxy listen address)" % key) - if not (upstream is Dictionary): - errors.append("soak.faults.upstream must give auth_host/auth_port/game_host/game_port of the real test server") - return - for role in ["auth", "game"]: - var host := String(upstream.get(role + "_host", "")).strip_edges() - var port: Variant = upstream.get(role + "_port", null) - if host.is_empty() or " " in host: - errors.append("soak.faults.upstream.%s_host is missing" % role) - if not _is_int(port) or int(port) < 1 or int(port) > 65535: - errors.append("soak.faults.upstream.%s_port must be 1..65535" % role) - elif host == String(server.get(role + "_host", "")) and int(port) == int(server.get(role + "_port", -1)): - errors.append("soak.faults.upstream.%s points at the proxy itself" % role) - -static func _is_cm_point(point: Variant) -> bool: - return point is Array and point.size() == 2 and _is_int(point[0]) and _is_int(point[1]) \ - and int(point[0]) >= 0 and int(point[1]) >= 0 - -static func skill_case_id(item: Dictionary) -> String: - return "CBT-%s" % String(item.get("case_id", "")).strip_edges() - -## Resolve the configured address through the same ServerInfo object the -## client uses. The config must copy the resolved address verbatim; any drift -## between the precheck target and the client connect target is an error. -static func resolve_server(config: Dictionary, serverinfo: RefCounted) -> Dictionary: - var errors: Array[String] = [] - var server: Dictionary = config.get("server", {}) if config.get("server", null) is Dictionary else {} - var index := int(server.get("server_index", -1)) - var channel := int(server.get("channel", 0)) - var entry: Dictionary = serverinfo.server(index) if serverinfo != null else {} - if entry.is_empty(): - errors.append("server_index %d is not present in serverinfo" % index) - return {"ok": false, "address": {}, "errors": errors} - var channels: Array = entry.get("channels", []) - if not (channel in channels): - errors.append("channel %d is not listed by serverinfo server %d" % [channel, index]) - var address: Dictionary = serverinfo.address(index, channel) - for key in ["auth_host", "game_host"]: - if String(server.get(key, "")) != String(address.get(key, "")): - errors.append("server.%s differs from serverinfo" % key) - for key in ["auth_port", "game_port"]: - if int(server.get(key, -1)) != int(address.get(key, -2)): - errors.append("server.%s differs from serverinfo" % key) - return {"ok": errors.is_empty(), "errors": errors, "address": { - "server_index": index, "channel": channel, - "auth_host": String(address.get("auth_host", "")), "auth_port": int(address.get("auth_port", 0)), - "game_host": String(address.get("game_host", "")), "game_port": int(address.get("game_port", 0)), - }} - -## Resource preconditions: the map must exist in the asset root and every -## waypoint must fall inside the same Setting.txt rectangle GameScene uses to -## resolve a map from the server position. -static func check_resources(config: Dictionary, assets_root: String) -> Dictionary: - var errors: Array[String] = [] - var root := assets_root.trim_suffix("/") - var map_key := String(config.get("map_key", "")) - var setting := root.path_join(map_key).path_join("Setting.txt") - if root.is_empty() or not DirAccess.dir_exists_absolute(root): - errors.append("asset root does not exist") - return {"ok": false, "errors": errors, "bounds": {}} - if not FileAccess.file_exists(setting): - errors.append("map_key has no Setting.txt in the asset root: %s" % map_key) - return {"ok": false, "errors": errors, "bounds": {}} - var bounds := map_bounds(setting) - if bounds.is_empty(): - errors.append("map Setting.txt lacks BasePosition/MapSize: %s" % map_key) - return {"ok": false, "errors": errors, "bounds": {}} - var base: Vector2 = bounds.base - var size: Vector2i = bounds.size - var waypoints: Array = config.get("waypoints_cm", []) - for i in waypoints.size(): - var point: Array = waypoints[i] - var x := float(point[0]) - var y := float(point[1]) - if x < base.x or y < base.y or x >= base.x + size.x * MAP_CELL_CM or y >= base.y + size.y * MAP_CELL_CM: - errors.append("waypoints_cm[%d] is outside %s bounds" % [i, map_key]) - return {"ok": errors.is_empty(), "errors": errors, - "bounds": {"base_cm": [base.x, base.y], "size_cells": [size.x, size.y]}} - -static func map_bounds(setting_path: String) -> Dictionary: - var f := FileAccess.open(setting_path, FileAccess.READ) - if f == null: - return {} - var base := Vector2.ZERO - var size := Vector2i.ZERO - var have_base := false - var have_size := false - while not f.eof_reached(): - var fields := f.get_line().replace("\t", " ").strip_edges().split(" ", false) - if fields.size() < 3: - continue - match String(fields[0]).to_lower(): - "baseposition": - base = Vector2(float(fields[1]), float(fields[2])) - have_base = true - "mapsize": - size = Vector2i(int(fields[1]), int(fields[2])) - have_size = size.x > 0 and size.y > 0 - if not have_base or not have_size: - return {} - return {"base": base, "size": size} - -static func _reject_secrets(value: Variant, prefix: String, errors: Array[String]) -> void: - if value is Dictionary: - for key in value: - var name := String(key).to_lower() - for forbidden in ["account", "username", "user_id", "login", "password", "passwd", "token", "secret"]: - if name == forbidden or name.ends_with("_" + forbidden): - errors.append("credentials must not be stored in config: %s%s" % [prefix, key]) - break - _reject_secrets(value[key], "%s%s." % [prefix, key], errors) - elif value is Array: - for i in value.size(): - _reject_secrets(value[i], "%s%d." % [prefix, i], errors) - -static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void: - for i in values.size(): - if not _is_int(values[i]) or int(values[i]) <= 0: - errors.append("%s[%d] must be a positive integer" % [name, i]) - -## JSON numbers arrive as float; accept only integral values. -static func _is_int(value: Variant) -> bool: - if value is int: - return true - return value is float and is_finite(value) and float(value) == floorf(value) - -static func _validate_skill_cases(skills: Variant, errors: Array[String]) -> void: - if not (skills is Array) or skills.is_empty(): - errors.append("skill_cases must contain at least one confirmed skill case") - else: - var seen := {} - for i in skills.size(): - if not (skills[i] is Dictionary): - errors.append("skill_cases[%d] must be an object" % i) - continue - var item: Dictionary = skills[i] - for key in ["case_id", "skill_id", "required_evidence"]: - if not item.has(key): - errors.append("skill_cases[%d] missing %s" % [i, key]) - var case_id := String(item.get("case_id", "")).strip_edges() - if case_id.is_empty(): - errors.append("skill_cases[%d].case_id is empty" % i) - elif seen.has(case_id): - errors.append("skill_cases[%d].case_id is duplicated: %s" % [i, case_id]) - seen[case_id] = true - if not _is_int(item.get("skill_id", null)) or int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255: - errors.append("skill_cases[%d].skill_id must be 1..254" % i) - if item.has("target") and not (String(item.target) in SKILL_TARGETS): - errors.append("skill_cases[%d].target must be one of %s" % [i, SKILL_TARGETS]) - if item.has("repeats") and (not _is_int(item.repeats) or int(item.repeats) < 1): - errors.append("skill_cases[%d].repeats must be a positive integer" % i) - var evidence: Variant = item.get("required_evidence", null) - if not (evidence is Array) or evidence.is_empty(): - errors.append("skill_cases[%d].required_evidence must be non-empty" % i) - continue - var has_server := false - for kind in evidence: - var name := String(kind) - if name in SERVER_EVIDENCE: - has_server = true - elif not (name in LOCAL_EVIDENCE): - errors.append("skill_cases[%d].required_evidence has unknown kind: %s" % [i, name]) - if not has_server: - errors.append("skill_cases[%d].required_evidence needs at least one server-side kind %s" % [i, SERVER_EVIDENCE]) diff --git a/project/testing/playable_config.gd.uid b/project/testing/playable_config.gd.uid deleted file mode 100644 index 06426176..00000000 --- a/project/testing/playable_config.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://82qi64ixdww6 diff --git a/project/testing/playable_flow.gd b/project/testing/playable_flow.gd deleted file mode 100644 index 48938268..00000000 --- a/project/testing/playable_flow.gd +++ /dev/null @@ -1,1357 +0,0 @@ -class_name PlayableFlow -extends RefCounted - -## NET-01 / CBT-01 联网闭环状态机(纯逻辑,可由假客户端驱动)。 -## -## 只经生产入口操作:AppFlow 自动登录/选角、PlayerController.walk_to、 -## NetPlay.select_target / set_attack_key / pick_ground_item、Quickbar.assign/activate。 -## 成功证据只来自 PlayableProbe 的 source=server 观察或 M2Client 状态查询; -## 本地位置、施法开始、特效节点只作补充,不单独构成服务端结果。 -## -## 所有时间来自注入的 clock_us,harness 可以不等待真实时间验证超时分支。 -## -## STB-01 soak:同一套 MOVE…PICKUP 按轮重复,按墙钟期限(clock_us 经过的时长)结束, -## 不按轮数或帧数。每轮后依次:真实传输故障(需确认的本地代理)、合法传送门往返 -## (需确认的路线)、三种逻辑分辨率切换(需窗口适配器)、≥30s 休息(内存低水位窗口)。 -## 主动 reconnect() 与真实故障分开计数;未确认的条件保持 BLOCKED,不互相替代。 - -signal finished(status: String) - -const Config = preload("res://testing/playable_config.gd") -const Metrics = preload("res://testing/playable_metrics.gd") - -const LOGIN_TIMEOUT_US := 30_000_000 -const SELECT_TIMEOUT_US := 45_000_000 -const WORLD_TIMEOUT_US := 60_000_000 -const MOVE_TIMEOUT_US := 20_000_000 -const CONFIRM_TIMEOUT_US := 60_000_000 -const TARGET_TIMEOUT_US := 30_000_000 -const ATTACK_TIMEOUT_US := 90_000_000 -const DROP_TIMEOUT_US := 30_000_000 -const PICKUP_TIMEOUT_US := 15_000_000 -const PICKUP_SETTLE_US := 2_000_000 -const SKILL_CAST_TIMEOUT_US := 10_000_000 -const SKILL_RETRY_US := 500_000 -## Soak: the scene must stay ready this long after a re-login before rebind checks run, -## so a node still fading out from the old connection is not mistaken for a live duplicate. -const REBIND_SETTLE_US := 1_000_000 -const FAULT_DISCONNECT_TIMEOUT_US := 30_000_000 -## Recovery budget after the transport fault ends: ReconnectUI retry delay + login + map load. -const FAULT_RECOVERY_GRACE_US := 120_000_000 -const WARP_TIMEOUT_US := 120_000_000 -const RESIZE_SETTLE_US := 500_000 -const RESIZE_TIMEOUT_US := 5_000_000 -const RESIZE_TOLERANCE_PX := 2 -const FRAME_HISTORY_CAP := 64 -const RECONNECT_STAGES := ["MOVE_CONFIRM", "WARP", "FAULT"] -const SOAK_PLAY_STAGES := ["MOVE", "TARGET", "ATTACK", "DROP", "PICKUP", "RESIZE", "REST"] -const SOAK_STEPS := ["FAULT", "WARP", "RESIZE", "REST"] - -const ARRIVE_M := 1.5 -const CORRECTION_CM := 500.0 -const CONFIRM_CM := 300.0 -const DROP_RADIUS_CM := 600.0 -const MAX_KILLS := 3 -const MONSTER_CH_TYPE := 2 -const SKILL_SLOT := 7 -const DEFAULT_SKILL_REPEATS := 10 -## One-shot effects must leave the tree within their MSE-defined lifetime + this grace. -const FX_GRACE_MS := 1000 -## PlayerSkill codes that mean the fixture (not the client) is missing something. -const FIXTURE_SKILL_CODES := ["NOT_YET_LEARN", "NO_SKILL_DATA", "PASSIVE", "NOT_MATCHABLE_WEAPON", "EMPTY_ARROW", "HAVE_TO_RIDE", "NO_RIDING_SKILL"] - -var report: RefCounted -var probe: Object -var flow: Object -var client: Object -var config: Dictionary = {} -var suite := "playable" -var allow_gameplay := false -var clock_us: Callable = func() -> int: return Time.get_ticks_usec() -## func(name: String) -> String; returns an evidence path relative to the report, or "". -var capture: Callable -## Soak only. PlayableMetrics fed once per frame by the live runner (null offline -> STB-FRAMES-01 BLOCKED). -var metrics: RefCounted -## Soak only. Window adapter (testing/playable_window.gd): availability / request_logical_size / -## logical_size / physical_size / viewport_size / screen_scale. null -> STB-RESIZE-01 BLOCKED. -var window: Object -## Unix wall-clock milliseconds for rest windows matched against the external RSS sampler. -var wall_ms: Callable = func() -> int: return int(Time.get_unix_time_from_system() * 1000.0) - -var stage := "BOOT" -var done := false -var status := "" - -var _stage_since := 0 -var _main_vid := 0 -var _bound_epoch := 0 -var _entered_seen := false -var _waypoint := 0 -var _expected_world := Vector3.ZERO -var _reconnect_epoch := 0 -var _reconnect_entered := 0 -var _rebind_since := -1 -var _confirm_target_cm := Vector2.ZERO -var _confirm_blocked := false -var _kills := 0 -var _abandoned := {} -var _target := {} -var _attack_down := false -var _damaged := false -var _dead := false -var _kill_pos_cm := Vector2.ZERO -var _pre_attack_iids := {} -var _drop := {} -var _pickup_before := 0 -var _pickup_removed_at := -1 -var _skill_index := 0 -var _skill_successes := 0 -var _skill_cast := {} -var _skill_next_try := 0 -var _skill_draining := false -var _open_fx := {} # fx_id -> {effect, lifetime_ms, spawned_us, case_id} -## §7.1 item 6: the first cast of each skill case is captured at its own event -## times (before / trigger / peak / end), not on a fixed one-second delay. -var _shots: Array = [] # evidence files attached to the skill case result -var _shot_phases := {} # phase -> true once attempted for this case -var _shot_fx := {} # fx_id -> peak due (us) for first-cast timed effects -var _shot_end_pending := false -var _start_us := 0 -# --- soak --- -var _round := 0 -var _rounds_done := 0 -var _soak_start_us := -1 -var _soak_wall_start_ms := 0 -var _soak_blocked := "" -var _active_reconnects := 0 -var _faults := {"close": 0, "unreachable": 0} -var _fault := {} -var _warps := 0 -var _warp_leg := 0 -var _warp_expected := "" -var _resize_index := -1 -var _resize_since := 0 -var _resize_before := Vector2i.ZERO -var _resize_counts := {} -var _resize_blocked := "" -var _severe_seen := {} -var _frame_history := {} # interaction (stage) -> last FRAME_HISTORY_CAP bools -var _repeated_severe := {} # interaction -> true once 2 of 3 consecutive repetitions had a >100ms frame - -func start() -> void: - _start_us = _now() - report.set_required_cases(Config.required_cases(config, suite)) - var server: Dictionary = config.get("server", {}) - var address: Dictionary = {} - if flow != null and flow.has_method("select_test_server"): - address = flow.select_test_server(int(server.get("server_index", -1)), int(server.get("channel", 0))) - var match_ok := not address.is_empty() - for key in ["auth_host", "game_host"]: - match_ok = match_ok and String(address.get(key, "")) == String(server.get(key, "")) - for key in ["auth_port", "game_port"]: - match_ok = match_ok and int(address.get(key, -1)) == int(server.get(key, -2)) - _event("address_selected", {"server_index": int(server.get("server_index", -1)), - "channel": int(server.get("channel", 0)), "address_match": match_ok}) - if not match_ok: - _finish_case("CONFIG-01", "FAIL", "client serverinfo address differs from the prechecked config address") - _finish() - return - report.add_case("CONFIG-01", "PASS", "config validated and client address matches precheck") - _enter("LOGIN") - -## Probe observation entry. Every observation is persisted as a contract event -## before it is interpreted. -func on_observed(kind: String, data: Dictionary) -> void: - var epoch := int(data.get("connection_epoch", 0)) - var stale := _bound_epoch > 0 and epoch != _bound_epoch and kind != "entered_game" and kind != "disconnected" - var payload: Dictionary = data.duplicate() - payload.erase("connection_epoch") - payload.erase("actor_vid") - payload.erase("target_vid") - payload.erase("received_us") - if stale: - payload["stale_epoch"] = true - report.record(kind, stage, _case_for_stage(), epoch, int(data.get("actor_vid", 0)), - int(data.get("target_vid", 0)), payload) - if done: - return - match kind: - "login_failed": - if stage == "LOGIN" or stage == "SELECT": - _finish_case("NET-LOGIN-01", "FAIL", "login rejected by server") - _finish() - "char_list": - if stage == "LOGIN": - var slot := int(config.get("character_slot", -1)) - if slot in data.get("slots", []): - report.add_case("NET-LOGIN-01", "PASS", "server char_list contains the configured slot") - _enter("SELECT") - else: - _finish_case("NET-LOGIN-01", "FAIL", "configured character slot is absent") - _finish() - "entered_game": - _entered_seen = true - _on_entered_game(epoch) - "disconnected": - _on_disconnected() - "entity_moved": - if stage == "MOVE" and not stale and int(data.get("actor_vid", 0)) == _main_vid: - _check_server_correction(data.get("pos_cm", null)) - "damage": - if stage == "ATTACK" and not stale and _is_target(int(data.get("target_vid", 0))) and int(data.get("amount", 0)) > 0: - _damaged = true - if stage == "SKILL" and not stale: - _skill_evidence("damage", int(data.get("target_vid", 0))) - "vitals_changed": - if stage == "ATTACK" and not stale and _is_target(int(data.get("actor_vid", 0))): - var hp := int(data.get("hp", -1)) - if hp >= 0 and int(_target.get("hp", -1)) > 0 and hp < int(_target.hp): - _damaged = true - _target["hp"] = hp - "target_info": - if stage == "ATTACK" and not stale and _is_target(int(data.get("target_vid", 0))) and int(data.get("hp_percent", 100)) < 100: - _damaged = true - "entity_dead": - var dead_vid := int(data.get("target_vid", 0)) - if stage == "ATTACK" and not stale and _is_target(dead_vid): - _dead = true - if stage == "SKILL" and not stale: - _skill_evidence("target_dead", dead_vid) - "entity_despawned": - if stage == "ATTACK" and not stale and _is_target(int(data.get("actor_vid", 0))) and not _dead: - _target_lost("target despawned before a server death result") - "server_motion": - if stage == "SKILL" and not stale and int(data.get("actor_vid", 0)) == _main_vid: - _skill_evidence("server_motion", int(data.get("target_vid", 0))) - "effect_cue", "fly_cue", "affect_added", "sp_changed": - if stage == "SKILL" and not stale: - var actor := int(data.get("actor_vid", 0)) - if kind in ["affect_added", "sp_changed"] or actor == _main_vid or actor == int(_skill_cast.get("target_vid", -1)): - _skill_evidence(kind, int(data.get("target_vid", 0))) - "cast_started": - if stage == "SKILL" and String(data.get("source", "")) == "local": - _on_cast_started(int(data.get("skill_id", 0)), int(data.get("target_vid", 0))) - "skill_rejected": - if stage == "SKILL": - _on_skill_rejected(int(data.get("skill_id", 0)), String(data.get("code", ""))) - "fx_spawned": - _on_fx_spawned(data) - if stage == "SKILL": - _skill_evidence(kind, 0) - _sample_fx_spawned(data) - "fx_finished": - if not _on_fx_finished(data): - return - if stage == "SKILL": - _skill_evidence(kind, 0) - _sample_fx_finished(data) - -func tick() -> void: - if done: - return - var now := _now() - var timeout_us := int(config.get("timeout_seconds", 120)) * 1_000_000 - if now - _start_us > timeout_us: - _fail_stage("overall scenario timeout") - return - if not _check_open_fx(now): - return - if stage == "SKILL": - _sample_due(now) - match stage: - "LOGIN": - if now - _stage_since > LOGIN_TIMEOUT_US: - _finish_case("NET-LOGIN-01", "FAIL", "no server char_list within 30s") - _finish() - "SELECT": - if _entered_seen: - var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0 - var main: Dictionary = client.get_entity(vid) if vid > 0 and client.has_method("get_entity") else {} - if vid > 0 and not main.is_empty(): - _main_vid = vid - _bound_epoch = _probe_epoch() - report.add_case("NET-SELECT-01", "PASS", "entered_game received and main entity is present") - _enter("WORLD") - return - if now - _stage_since > SELECT_TIMEOUT_US: - _finish_case("NET-SELECT-01", "FAIL", "entered_game/main entity not observed within 45s") - _finish() - "WORLD": - var ready := _world_ready() - if ready.ready: - if String(ready.map_key) != String(config.get("map_key", "")): - _event("map_mismatch", {"map_key": ready.map_key, "expected_map_key": config.map_key}) - _finish_case("NET-WORLD-01", "FAIL", "loaded map_key differs from the configured map") - _finish() - return - _pass("NET-WORLD-01", "scene, map, player and HUD ready") - if not allow_gameplay: - _block_remaining("gameplay_not_allowed") - _finish() - return - if suite == "soak": - _begin_soak(now) - return - _begin_move(0) - elif now - _stage_since > WORLD_TIMEOUT_US: - _event("world_not_ready", ready) - _finish_case("NET-WORLD-01", "FAIL", "scene/map/player/HUD not ready within 60s") - _finish() - "MOVE": - var player: Node3D = _context().get("player", null) - if player != null and is_instance_valid(player) \ - and Vector2(player.position.x, player.position.z).distance_to(Vector2(_expected_world.x, _expected_world.z)) <= ARRIVE_M: - _pass(_move_case(), "local arrival without server correction") - if _waypoint + 1 < config.waypoints_cm.size(): - _begin_move(_waypoint + 1) - else: - _begin_confirm() - elif now - _stage_since > MOVE_TIMEOUT_US: - _fail_stage("waypoint not reached within 20s") - "MOVE_CONFIRM": - _tick_confirm(now) - "TARGET": - _tick_target(now) - "ATTACK": - _tick_attack(now) - "DROP": - _tick_drop(now) - "PICKUP": - _tick_pickup(now) - "SKILL": - _tick_skill(now) - "FAULT": - _tick_fault(now) - "WARP": - _tick_warp(now) - "RESIZE": - _tick_resize(now) - "REST": - _tick_rest(now) - -## Cancel all pending input immediately. Safe to call repeatedly. -func cancel_inputs() -> void: - if _attack_down: - var net_play: Object = _context().get("net_play", null) - if net_play != null and is_instance_valid(net_play) and net_play.has_method("set_attack_key"): - net_play.set_attack_key(false) - _attack_down = false - _target = {} - _skill_cast = {} - -# --- stages ----------------------------------------------------------------- - -func _enter(next: String) -> void: - _track_frames(stage) - stage = next - _stage_since = _now() - var snap: Dictionary = probe.snapshot() if probe != null and probe.has_method("snapshot") else {} - var flow_snap := _flow_snapshot() - snap["state"] = next - snap["scene_ready"] = bool(flow_snap.get("scene_ready", false)) - snap["reconnecting"] = bool(flow_snap.get("reconnecting", false)) - snap["character_count"] = int(flow_snap.get("character_count", 0)) - _event("state_enter", snap) - -func _begin_move(index: int) -> void: - _waypoint = index - var point: Array = config.waypoints_cm[index] - _expected_world = MapCoord.to_world(Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01)) - _enter("MOVE") - var controller: Object = _context().get("pc", null) - if controller == null or not is_instance_valid(controller) or not controller.has_method("walk_to"): - _fail_stage("PlayerController.walk_to is unavailable") - return - controller.walk_to(_expected_world) - _event("move_requested", {"waypoint_index": index, "pos_cm": Vector2(float(point[0]), float(point[1]))}) - -func _check_server_correction(pos: Variant) -> void: - var player: Node3D = _context().get("player", null) - if not (pos is Vector3) or player == null or not is_instance_valid(player): - return - var local_cm := MapCoord.to_server_cm(player.position) - var distance := local_cm.distance_to(Vector2(pos.x, pos.y)) - if distance > CORRECTION_CM: - _event("server_correction", {"distance_cm": distance, "tolerance_cm": CORRECTION_CM, "waypoint_index": _waypoint}) - _fail_stage("server corrected the main character position by %.0fcm" % distance) - -func _begin_confirm() -> void: - var last: Array = config.waypoints_cm[config.waypoints_cm.size() - 1] - _confirm_target_cm = Vector2(float(last[0]), float(last[1])) - _arm_reconnect() - _confirm_blocked = false - _enter("MOVE_CONFIRM") - cancel_inputs() - if not client.has_method("reconnect") or not bool(client.reconnect()): - _confirm_blocked = true - _finish_case("NET-MOVE-CONFIRM-01", "BLOCKED", "reconnect is unavailable; no server position source") - if suite == "soak": - _finish_case("STB-RECONNECT-01", "BLOCKED", "reconnect is unavailable in round %d" % _round) - _finish() - return - _enter_target() - -func _tick_confirm(now: int) -> void: - var bound := _rebound(now) - if bound < 0: - return - if bound > 0: - var pos: Variant = client.get_entity(_main_vid).get("pos_cm", null) - var distance := Vector2(pos.x, pos.y).distance_to(_confirm_target_cm) - _event("server_position", {"main_vid": _main_vid, "pos_cm": Vector2(pos.x, pos.y), "distance_cm": distance, "tolerance_cm": CONFIRM_CM}) - if distance <= CONFIRM_CM: - _pass("NET-MOVE-CONFIRM-01", "server spawn position after re-login matches the last waypoint") - else: - _finish_case("NET-MOVE-CONFIRM-01", "FAIL", "server position after re-login is %.0fcm from the last waypoint" % distance) - _finish() - return - if suite == "soak": - _active_reconnects += 1 - _event("reconnect_verified", {"round": _round, "count": _active_reconnects}) - _enter_target() - return - if now - _stage_since > CONFIRM_TIMEOUT_US: - _finish_case("NET-MOVE-CONFIRM-01", "FAIL", "re-login did not return a main entity/scene within 60s") - _finish() - -func _enter_target() -> void: - _target = {} - _damaged = false - _dead = false - _enter("TARGET") - -func _tick_target(now: int) -> void: - var candidate := _pick_monster() - if not candidate.is_empty(): - var net_play: Object = _context().get("net_play", null) - if net_play == null or not is_instance_valid(net_play) or not net_play.has_method("select_target"): - _fail_stage("NetPlay.select_target is unavailable") - return - if bool(net_play.select_target(int(candidate.vid))): - _target = candidate - _target["epoch"] = _bound_epoch - _event("target_selected", {"race": candidate.race, "pos_cm": candidate.pos_cm, "attempt": _kills + 1}) - if not report.has_case("NET-TARGET-01"): - report.add_case("NET-TARGET-01", "PASS", "allowed live monster selected in the current connection epoch") - if stage == "TARGET": - _begin_attack() - return - _abandoned[int(candidate.vid)] = true - if now - _stage_since > TARGET_TIMEOUT_US: - if not report.has_case("NET-TARGET-01") or (suite == "soak" and _kills == 0): - _finish_case("NET-TARGET-01", "BLOCKED", "no allowed live monster in range (fixture)") - else: - _finish_case("NET-DROP-01", "BLOCKED", "no further allowed monster after %d kills" % _kills) - _finish() - -func _pick_monster() -> Dictionary: - if not client.has_method("get_entities"): - return {} - var allowed: Array = config.get("allowed_mob_vnums", []).map(func(v): return int(v)) - var main: Dictionary = client.get_entity(_main_vid) if client.has_method("get_entity") else {} - var origin: Variant = main.get("pos_cm", Vector3.ZERO) - var best := {} - var best_distance := INF - for entity in client.get_entities(): - var vid := int(entity.get("vid", 0)) - if vid <= 0 or _abandoned.has(vid) or bool(entity.get("dead", false)): - continue - if int(entity.get("ch_type", -1)) != MONSTER_CH_TYPE or not (int(entity.get("race", 0)) in allowed): - continue - var pos: Variant = entity.get("pos_cm", Vector3.ZERO) - var distance := Vector2(pos.x, pos.y).distance_to(Vector2(origin.x, origin.y)) if pos is Vector3 and origin is Vector3 else 0.0 - if distance < best_distance: - best_distance = distance - best = {"vid": vid, "race": int(entity.get("race", 0)), "hp": int(entity.get("hp", -1)), - "pos_cm": Vector2(pos.x, pos.y) if pos is Vector3 else Vector2.ZERO} - return best - -func _begin_attack() -> void: - _damaged = false - _dead = false - _pre_attack_iids = _ground_iids() - _enter("ATTACK") - var net_play: Object = _context().get("net_play", null) - if net_play == null or not net_play.has_method("set_attack_key"): - _fail_stage("NetPlay.set_attack_key is unavailable") - return - net_play.set_attack_key(true) - _attack_down = true - -func _tick_attack(now: int) -> void: - if _target.is_empty(): - return - var entity: Dictionary = client.get_entity(int(_target.vid)) if client.has_method("get_entity") else {} - var pos: Variant = entity.get("pos_cm", null) - if pos is Vector3: - _target["pos_cm"] = Vector2(pos.x, pos.y) - if _dead: - if not _damaged: - # A death without any HP/damage evidence is not a verified combat result. - cancel_inputs() - _fail_stage("target died without a received damage/HP change") - return - _kills += 1 - _kill_pos_cm = _target.get("pos_cm", Vector2.ZERO) - _abandoned[int(_target.vid)] = true - cancel_inputs() - _event("target_killed", {"attempt": _kills, "pos_cm": _kill_pos_cm}) - if not report.has_case("NET-ATTACK-01"): - report.add_case("NET-ATTACK-01", "PASS", "received damage/HP change and death for the selected target") - _drop = {} - _enter("DROP") - return - if now - _stage_since > ATTACK_TIMEOUT_US: - cancel_inputs() - _fail_stage("no server damage and death result within 90s") - -## NET-04: target vanished before death -> release input, never chase the old VID. -func _target_lost(reason: String) -> void: - _event("target_lost", {"attempt": _kills + 1, "reason": reason}) - _abandoned[int(_target.get("vid", 0))] = true - cancel_inputs() - _kills += 1 - if _kills >= MAX_KILLS: - _finish_case("NET-ATTACK-01", "BLOCKED", "target lost before death on %d attempts" % _kills) - _finish() - return - _enter_target() - -func _tick_drop(now: int) -> void: - var allowed: Array = config.get("allowed_drop_vnums", []).map(func(v): return int(v)) - for item in _ground_items(): - var iid := int(item.get("vid", 0)) - if _pre_attack_iids.has(iid): - continue - var vnum := int(item.get("vnum", 0)) - var pos_cm := _ground_pos_cm(item) - var distance := pos_cm.distance_to(_kill_pos_cm) - if vnum == Config.GOLD_VNUM: - _pre_attack_iids[iid] = true - _event("gold_drop_seen", {"iid": iid, "distance_cm": distance}) - continue - if not (vnum in allowed): - continue - var owner_ok := _owner_matches(String(item.get("owner", ""))) - if not owner_ok or distance > DROP_RADIUS_CM: - _pre_attack_iids[iid] = true - _event("drop_rejected", {"iid": iid, "vnum": vnum, "owner_match": owner_ok, "distance_cm": distance, "tolerance_cm": DROP_RADIUS_CM}) - continue - _drop = {"iid": iid, "vnum": vnum} - _event("drop_accepted", {"iid": iid, "vnum": vnum, "owner_match": true, "distance_cm": distance}) - _pass("NET-DROP-01", "new allowed ground item, pickable owner, near the killed target") - _begin_pickup() - return - if now - _stage_since > DROP_TIMEOUT_US: - if _kills < MAX_KILLS: - _event("no_drop", {"attempt": _kills}) - _enter_target() - else: - _finish_case("NET-DROP-01", "BLOCKED", "no allowed pickable drop after %d kills (fixture)" % _kills) - _finish() - -func _begin_pickup() -> void: - _pickup_before = _inventory_total(int(_drop.vnum)) - _pickup_removed_at = -1 - _enter("PICKUP") - var net_play: Object = _context().get("net_play", null) - if net_play == null or not net_play.has_method("pick_ground_item"): - _fail_stage("NetPlay.pick_ground_item is unavailable") - return - net_play.pick_ground_item(int(_drop.iid)) - _event("pickup_requested", {"iid": _drop.iid, "vnum": _drop.vnum, "before": _pickup_before}) - -func _tick_pickup(now: int) -> void: - var present := _ground_iids().has(int(_drop.iid)) - var after := _inventory_total(int(_drop.vnum)) - if not present and _pickup_removed_at < 0: - _pickup_removed_at = now - if not present and after > _pickup_before: - _event("pickup_verified", {"iid": _drop.iid, "vnum": _drop.vnum, "before": _pickup_before, "after": after, "delta": after - _pickup_before}) - _pass("NET-PICKUP-01", "ground item removed and inventory vnum total increased") - _after_closed_loop() - return - if _pickup_removed_at >= 0 and now - _pickup_removed_at > PICKUP_SETTLE_US: - _event("pickup_unverified", {"iid": _drop.iid, "vnum": _drop.vnum, "before": _pickup_before, "after": after}) - _fail_stage("ground item vanished without an inventory increase (taken by another player or inventory full)") - return - if now - _stage_since > PICKUP_TIMEOUT_US: - _fail_stage("pickup not accepted within 15s (inventory full, ownership or range)") - -func _after_closed_loop() -> void: - if suite == "soak": - _soak_continue("") - return - if suite == "full" and not config.get("skill_cases", []).is_empty(): - _skill_index = 0 - _begin_skill_case() - return - _finish() - -# --- CBT-01: skills ------------------------------------------------------------ - -func _begin_skill_case() -> void: - _skill_successes = 0 - _skill_cast = {} - _skill_next_try = 0 - _skill_draining = false - _shots = [] - _shot_phases = {} - _shot_fx = {} - _shot_end_pending = false - _enter("SKILL") - var item: Dictionary = config.skill_cases[_skill_index] - var quickbar: Object = _context().get("quickbar", null) - if quickbar == null or not is_instance_valid(quickbar) or not quickbar.has_method("assign"): - _fail_stage("Quickbar is unavailable") - return - # Local-only slot: no CG_QUICKSLOT_ADD persistence on the account. - if not bool(quickbar.assign(SKILL_SLOT, "skill", int(item.skill_id), false)): - _fail_stage("Quickbar.assign rejected the skill") - -func _tick_skill(now: int) -> void: - var item: Dictionary = config.skill_cases[_skill_index] - if _skill_draining: - # The case passes only after its one-shot effects actually left the tree; - # _check_open_fx fails it once one outlives lifetime + grace. - if _open_fx_for(_case_for_stage()).is_empty(): - _skill_draining = false - report.add_case(Config.skill_case_id(item), "PASS", "server evidence observed for every cast; effects cleaned up", - _shots.duplicate(), float(now - _stage_since) / 1000.0) - _skill_index += 1 - if _skill_index >= config.skill_cases.size(): - _finish() - else: - _begin_skill_case() - return - if not _skill_cast.is_empty(): - if _skill_missing().is_empty(): - _skill_successes += 1 - _event("skill_verified", {"skill_id": item.skill_id, "count": _skill_successes}) - _skill_cast = {} - _skill_next_try = now + SKILL_RETRY_US - if _skill_successes >= int(item.get("repeats", DEFAULT_SKILL_REPEATS)): - _skill_draining = true - _tick_skill(now) - return - if now - int(_skill_cast.started_us) > SKILL_CAST_TIMEOUT_US: - _fail_stage("cast %d missing evidence: %s" % [_skill_successes + 1, ",".join(_skill_missing())]) - return - if now - _stage_since > SKILL_CAST_TIMEOUT_US * (int(item.get("repeats", DEFAULT_SKILL_REPEATS)) + 2): - _fail_stage("skill case did not complete in its time budget") - return - if now < _skill_next_try: - return - if String(item.get("target", "enemy")) == "enemy": - var target := _pick_monster() - var net_play: Object = _context().get("net_play", null) - if target.is_empty() or net_play == null or not bool(net_play.select_target(int(target.vid))): - _skill_next_try = now + SKILL_RETRY_US - return - var quickbar: Object = _context().get("quickbar", null) - if _skill_successes == 0: - _shoot("before") - _skill_cast = {"pending": true, "skill_id": int(item.skill_id), "started_us": now, "evidence": {}, "casts": 0} - quickbar.activate(SKILL_SLOT) - if _skill_cast.get("pending", false) and int(_skill_cast.casts) == 0: - # No cast_started emitted synchronously: cooldown / gate silently held it. - _skill_cast = {} - _skill_next_try = now + SKILL_RETRY_US - -func _on_cast_started(skill_id: int, target_vid: int) -> void: - if _skill_cast.is_empty() and stage == "SKILL" and not _skill_draining \ - and skill_id == int(config.skill_cases[_skill_index].skill_id): - # An earlier activation was out of range and reserved (__ReserveUseSkill); - # NetPlay walked in and the reserved slot fired now. Track it as this case's cast. - _skill_cast = {"pending": true, "skill_id": skill_id, "started_us": _now(), "evidence": {}, "casts": 0, "reserved": true} - if _skill_cast.is_empty() or skill_id != int(_skill_cast.skill_id): - return - _skill_cast.casts = int(_skill_cast.casts) + 1 - if int(_skill_cast.casts) > 1: - _fail_stage("duplicate cast request for one activation") - return - _skill_cast.pending = false - if _skill_successes == 0: - _shoot("trigger") - # Bind the case to the target at cast time; later selection changes do not re-route evidence. - _skill_cast.target_vid = target_vid - _skill_cast.evidence["cast_started"] = true - -func _on_skill_rejected(skill_id: int, code: String) -> void: - var item: Dictionary = config.skill_cases[_skill_index] - if skill_id != int(item.skill_id): - return - _skill_cast = {} - if code in FIXTURE_SKILL_CODES: - _finish_case(Config.skill_case_id(item), "BLOCKED", "skill fixture not ready: %s" % code) - _finish() - return - _skill_next_try = _now() + SKILL_RETRY_US - -func _skill_evidence(kind: String, target_vid: int) -> void: - if _skill_cast.is_empty() or bool(_skill_cast.get("pending", false)): - return - if kind in ["damage", "target_dead"] and target_vid != int(_skill_cast.get("target_vid", 0)): - return - # Local kinds are recorded too, but validate() requires at least one server kind per case. - _skill_cast.evidence[kind] = true - -func _skill_missing() -> Array: - var missing: Array = [] - var item: Dictionary = config.skill_cases[_skill_index] - for kind in item.required_evidence: - if not _skill_cast.get("evidence", {}).has(String(kind)): - missing.append(String(kind)) - return missing - -# --- CBT-01: effect lifecycle ---------------------------------------------------- -# Evidence comes from EffectRegistry spawn / tree-exit boundaries, never node counts. - -func _on_fx_spawned(data: Dictionary) -> void: - var lifetime_ms := int(data.get("lifetime_ms", -1)) - if lifetime_ms < 0 or String(data.get("source", "")) != "local": - return # looping effects live as long as their owner - _open_fx[int(data.get("fx_id", 0))] = {"effect": String(data.get("effect", "")), - "lifetime_ms": lifetime_ms, "spawned_us": _now(), "case_id": _case_for_stage()} - -## Returns false when the finish violated the lifetime contract (the run is over). -func _on_fx_finished(data: Dictionary) -> bool: - var fx_id := int(data.get("fx_id", 0)) - var info: Dictionary = _open_fx.get(fx_id, {}) - _open_fx.erase(fx_id) - var lifetime_ms := int(data.get("lifetime_ms", -1)) - var elapsed_ms := int(data.get("elapsed_ms", -1)) - if lifetime_ms >= 0 and elapsed_ms > lifetime_ms + FX_GRACE_MS: - var case_id := String(info.get("case_id", _case_for_stage())) - _fx_overdue(case_id, String(data.get("effect", "")), lifetime_ms, elapsed_ms) - return false - return true - -func _check_open_fx(now: int) -> bool: - for fx_id in _open_fx.keys(): - var info: Dictionary = _open_fx[fx_id] - var elapsed_ms := (now - int(info.spawned_us)) / 1000 - if elapsed_ms > int(info.lifetime_ms) + FX_GRACE_MS: - _open_fx.erase(fx_id) - _fx_overdue(String(info.case_id), String(info.effect), int(info.lifetime_ms), elapsed_ms) - return false - return true - -func _shoot(phase: String) -> void: - if _shot_phases.has(phase) or not capture.is_valid() or _skill_index >= config.skill_cases.size(): - return - _shot_phases[phase] = true - var file := String(capture.call("%s-%s" % [Config.skill_case_id(config.skill_cases[_skill_index]), phase])) - _event("skill_capture", {"phase": phase, "screenshot": file}) - if not file.is_empty(): - _shots.append(file) - -## Peak = half of the effect's own defined lifetime, sampled on the tick it falls due. -func _sample_fx_spawned(data: Dictionary) -> void: - var lifetime_ms := int(data.get("lifetime_ms", -1)) - if lifetime_ms < 0 or _skill_successes > 0 or _skill_cast.is_empty() or _shot_phases.has("end"): - return - _shot_fx[int(data.get("fx_id", 0))] = _now() + lifetime_ms * 500 - -func _sample_fx_finished(data: Dictionary) -> void: - var fx_id := int(data.get("fx_id", 0)) - if not _shot_fx.has(fx_id): - return - if int(_shot_fx[fx_id]) > 0: - _shoot("peak") # finished before its half-life tick: take the last live frame - _shot_fx.erase(fx_id) - _shot_end_pending = _shot_fx.is_empty() - -func _sample_due(now: int) -> void: - for fx_id in _shot_fx: - if int(_shot_fx[fx_id]) > 0 and now >= int(_shot_fx[fx_id]): - _shoot("peak") - _shot_fx[fx_id] = 0 - if _shot_end_pending: - # One tick after the last first-cast effect left the tree. - _shot_end_pending = false - _shoot("end") - -func _open_fx_for(case_id: String) -> Array: - var out: Array = [] - for fx_id in _open_fx: - if String(_open_fx[fx_id].case_id) == case_id: - out.append(fx_id) - return out - -func _fx_overdue(case_id: String, effect: String, lifetime_ms: int, elapsed_ms: int) -> void: - var message := "effect %s not cleaned within lifetime %dms + %dms (elapsed %dms)" % [effect, lifetime_ms, FX_GRACE_MS, elapsed_ms] - _event("fx_overdue", {"effect": effect, "lifetime_ms": lifetime_ms, "elapsed_ms": elapsed_ms, - "open_effects": _open_fx.size()}) - cancel_inputs() - if report.has_case(case_id): - report.add_failure("%s: %s" % [case_id, message]) - else: - _finish_case(case_id, "FAIL", message) - _finish() - -# --- reconnect binding (NET-01 confirm, STB-01 soak) ------------------------------ - -func _arm_reconnect() -> void: - _reconnect_epoch = _probe_epoch() - _reconnect_entered = 0 - _rebind_since = -1 - -func _on_entered_game(epoch: int) -> void: - if stage in RECONNECT_STAGES: - if suite == "soak" and stage == "FAULT" and int(_fault.get("disconnected_us", -1)) < 0: - _soak_fail("STB-DISCONNECT-01", "FAIL", "entered_game before the requested transport fault disconnected") - return - _reconnect_entered += 1 - if suite == "soak" and _reconnect_entered > 1: - _soak_fail(_rebind_case(), "FAIL", "duplicate entered_game without a disconnect during %s" % stage) - return - if epoch > _reconnect_epoch: - _bound_epoch = epoch - elif suite == "soak" and stage in SOAK_PLAY_STAGES: - _fail_stage("unexpected entered_game during %s (no reconnect was requested)" % stage) - -## 0 = still waiting, 1 = main VID / scene / UI re-acquired in the new epoch, -1 = run finished. -## Soak additionally waits REBIND_SETTLE_US of continuous readiness, then checks for -## duplicate entity nodes and a target / attack key left over from the old connection. -func _rebound(now: int) -> int: - if _bound_epoch <= _reconnect_epoch: - _rebind_since = -1 - return 0 - var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0 - var main: Dictionary = client.get_entity(vid) if vid > 0 and client.has_method("get_entity") else {} - var pos: Variant = main.get("pos_cm", null) - var ready := _world_ready() - if vid <= 0 or not (pos is Vector3) or (pos.x == 0.0 and pos.y == 0.0) or not ready.ready \ - or bool(_flow_snapshot().get("reconnecting", false)): - _rebind_since = -1 - return 0 - _main_vid = vid - if suite != "soak": - return 1 - if _rebind_since < 0: - _rebind_since = now - if now - _rebind_since < REBIND_SETTLE_US: - return 0 - var checks := _rebind_checks() - _event("rebind_checked", {"main_vid": vid, "round": _round, "duplicate_vids": checks.duplicate_vids, - "target_residue": checks.target_residue, "map_key": ready.map_key, "state": checks.status}) - if checks.status != "PASS": - _soak_fail(_rebind_case(), checks.status, checks.reason) - return -1 - return 1 - -func _rebind_checks() -> Dictionary: - var out := {"status": "PASS", "reason": "", "duplicate_vids": [], "target_residue": []} - var context := _context() - var net_world: Variant = context.get("net_world", null) - var parent: Variant = net_world.get("parent") if net_world != null and is_instance_valid(net_world) and "parent" in net_world else null - if parent == null or not is_instance_valid(parent): - out.status = "BLOCKED" - out.reason = "NetWorld entity registry is unavailable; duplicate entity check not possible" - return out - var seen := {} - for child in parent.get_children(): - if child.is_queued_for_deletion() or not child.has_meta("vid"): - continue - var vid := int(child.get_meta("vid")) - if seen.has(vid) and not (vid in out.duplicate_vids): - out.duplicate_vids.append(vid) - seen[vid] = true - var net_play: Variant = context.get("net_play", null) - if net_play != null and is_instance_valid(net_play): - var target_vid := int(net_play.get("_target_vid")) if "_target_vid" in net_play else 0 - if target_vid > 0 and client.get_entity(target_vid).is_empty(): - out.target_residue.append("target_vid") - if "_attack_key_down" in net_play and bool(net_play.get("_attack_key_down")): - out.target_residue.append("attack_key_down") - if not out.duplicate_vids.is_empty(): - out.status = "FAIL" - out.reason = "duplicate entity nodes after re-login: %s" % [out.duplicate_vids] - elif not out.target_residue.is_empty(): - out.status = "FAIL" - out.reason = "old target/attack state kept after re-login: %s" % [out.target_residue] - return out - -func _rebind_case() -> String: - match stage: - "FAULT": return "STB-DISCONNECT-01" - "WARP": return "STB-WARP-01" - return "STB-RECONNECT-01" - -# --- STB-01: soak rounds ------------------------------------------------------------ - -func _soak_cfg() -> Dictionary: - var soak: Variant = config.get("soak", {}) - return soak if soak is Dictionary else {} - -func _begin_soak(now: int) -> void: - var checked := Config.validate_soak(config) - if not checked.ok: - _block_remaining("soak config invalid: %s" % ", ".join(checked.errors)) - _finish() - return - _soak_start_us = now - _soak_wall_start_ms = _wall_ms() - _round = 0 - for size in _soak_cfg().resolutions.sizes: - _resize_counts["%dx%d" % [int(size[0]), int(size[1])]] = 0 - _begin_round() - -func _begin_round() -> void: - _kills = 0 - _abandoned = {} - _event("soak_round", {"round": _round, "wall_ms": _wall_ms()}) - _begin_move(0) - -## Runs the post-pickup steps in SOAK_STEPS order, starting after `after`. -func _soak_continue(after: String) -> void: - var soak := _soak_cfg() - var start := SOAK_STEPS.find(after) + 1 - for i in range(start, SOAK_STEPS.size()): - match SOAK_STEPS[i]: - "FAULT": - if String(soak.faults.get("status", "")) == "confirmed" and not _next_fault_type().is_empty(): - _begin_fault() - return - "WARP": - var warp: Dictionary = soak.warp - if String(warp.get("status", "")) == "confirmed" and _warps < int(warp.required): - _begin_warp(0) - return - "RESIZE": - var available: Dictionary = window.availability() if window != null and window.has_method("availability") else {} - if window == null: - _resize_blocked = "no window adapter: logical resolution switching needs a windowed display" - elif not bool(available.get("ok", false)): - _resize_blocked = "window resize unavailable: %s" % String(available.get("reason", "unknown")) - else: - _begin_resize() - return - "REST": - _begin_rest() - return - -func _next_fault_type() -> String: - var per := int(_soak_cfg().faults.get("per_type", 0)) - var best := "" - for type in Config.SOAK_FAULT_TYPES: - if int(_faults[type]) < per and (best.is_empty() or int(_faults[type]) < int(_faults[best])): - best = type - return best - -## The fault itself is produced outside the client (script/playable_fault_proxy.mjs reads -## this event); the flow never calls reconnect() here — ReconnectUI's own retry recovers. -func _begin_fault() -> void: - var faults: Dictionary = _soak_cfg().faults - var type := _next_fault_type() - _fault = {"type": type, "disconnected_us": -1} - _arm_reconnect() - cancel_inputs() - _enter("FAULT") - _event("fault_request", {"type": type, "attempt": int(_faults[type]) + 1, "round": _round, - "timeout_ms": FAULT_DISCONNECT_TIMEOUT_US / 1000, - "unreachable_seconds": int(faults.unreachable_seconds) if type == "unreachable" else 0}) - -func _tick_fault(now: int) -> void: - var type := String(_fault.type) - if int(_fault.disconnected_us) < 0: - if now - _stage_since > FAULT_DISCONNECT_TIMEOUT_US: - _soak_fail("STB-DISCONNECT-01", "FAIL", "requested %s fault produced no disconnect within 30s" % type) - return - var bound := _rebound(now) - if bound < 0: - return - if bound > 0: - _faults[type] = int(_faults[type]) + 1 - _event("fault_recovered", {"type": type, "round": _round, "count": _faults[type], - "elapsed_ms": (now - int(_fault.disconnected_us)) / 1000}) - _fault = {} - _soak_continue("FAULT") - return - var budget := int(_soak_cfg().faults.unreachable_seconds) * 1_000_000 + FAULT_RECOVERY_GRACE_US - if now - int(_fault.disconnected_us) > budget: - _soak_fail("STB-DISCONNECT-01", "FAIL", "%s fault not recovered by the reconnect path within %ds" % [type, budget / 1_000_000]) - -## Out-and-back through the configured portals. Only a new server connection epoch that -## lands on the expected map counts; the client never moves nodes to fake a map change. -func _begin_warp(leg: int) -> void: - var warp: Dictionary = _soak_cfg().warp - _warp_leg = leg - var point: Array = warp.portal_cm if leg == 0 else warp.return_portal_cm - _warp_expected = String(warp.destination_map_key) if leg == 0 else String(config.map_key) - _arm_reconnect() - cancel_inputs() - _enter("WARP") - var controller: Object = _context().get("pc", null) - if controller == null or not is_instance_valid(controller) or not controller.has_method("walk_to"): - _soak_fail("STB-WARP-01", "FAIL", "PlayerController.walk_to is unavailable") - return - controller.walk_to(MapCoord.to_world(Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01))) - _event("warp_requested", {"leg": leg, "round": _round, "portal_cm": Vector2(float(point[0]), float(point[1])), - "expected_map_key": _warp_expected, "cross_server": bool(warp.cross_server)}) - -func _tick_warp(now: int) -> void: - var bound := _rebound(now) - if bound < 0: - return - var map_key := String(_flow_snapshot().get("map_key", "")) - if bound > 0: - if map_key != _warp_expected: - _event("map_mismatch", {"map_key": map_key, "expected_map_key": _warp_expected, "leg": _warp_leg}) - _soak_fail("STB-WARP-01", "FAIL", "server reconnect after the portal loaded %s instead of %s" % [map_key, _warp_expected]) - return - _warps += 1 - _event("warp_arrived", {"leg": _warp_leg, "round": _round, "count": _warps, "map_key": map_key, - "elapsed_ms": (now - _stage_since) / 1000}) - if _warp_leg == 0: - _begin_warp(1) - else: - _soak_continue("WARP") - return - if now - _stage_since > WARP_TIMEOUT_US: - var why := " (map_key changed without a new server connection)" if map_key == _warp_expected else "" - _soak_fail("STB-WARP-01", "FAIL", "no server reconnect into %s within 120s%s" % [_warp_expected, why]) - -func _begin_resize() -> void: - _resize_index = -1 - _enter("RESIZE") - _next_resize(_now()) - -func _next_resize(now: int) -> void: - var sizes: Array = _soak_cfg().resolutions.sizes - while true: - _resize_index += 1 - if _resize_index >= sizes.size(): - _soak_continue("RESIZE") - return - var size := _resize_target() - if metrics != null: - metrics.mark("resize", {"size": "%dx%d" % [size.x, size.y]}) - _resize_before = window.logical_size() - _resize_since = now - if bool(window.request_logical_size(size)): - return - var key := "%dx%d" % [size.x, size.y] - if _resize_blocked.is_empty(): - _resize_blocked = "logical size %s rejected by the window adapter (larger than the usable screen)" % key - _event("window_size_rejected", {"requested_size": size, "round": _round, "screen_scale": float(window.screen_scale())}) - -func _resize_target() -> Vector2i: - var item: Array = _soak_cfg().resolutions.sizes[_resize_index] - return Vector2i(int(item[0]), int(item[1])) - -func _tick_resize(now: int) -> void: - if now - _resize_since < RESIZE_SETTLE_US: - return - var size := _resize_target() - var logical: Vector2i = window.logical_size() - var viewport: Vector2i = window.viewport_size() - if absi(logical.x - size.x) <= RESIZE_TOLERANCE_PX and absi(logical.y - size.y) <= RESIZE_TOLERANCE_PX \ - and viewport.x > 0 and viewport.y > 0 and _world_ready().ready: - # Physical pixels differ from logical points on Retina; only the logical size is judged. - var switched := absi(_resize_before.x - size.x) > RESIZE_TOLERANCE_PX or absi(_resize_before.y - size.y) > RESIZE_TOLERANCE_PX - var key := "%dx%d" % [size.x, size.y] - if switched: - _resize_counts[key] = int(_resize_counts.get(key, 0)) + 1 - _event("window_resized", {"requested_size": size, "logical_size": logical, "physical_size": window.physical_size(), - "viewport_size": viewport, "screen_scale": float(window.screen_scale()), "round": _round, - "count": int(_resize_counts.get(key, 0)), "accepted": switched}) - _next_resize(now) - return - if now - _resize_since > RESIZE_TIMEOUT_US: - _event("window_resize_timeout", {"requested_size": size, "logical_size": logical, "viewport_size": viewport, - "physical_size": window.physical_size(), "screen_scale": float(window.screen_scale()), "round": _round}) - _soak_fail("STB-RESIZE-01", "FAIL", "window stayed at logical %dx%d after requesting %dx%d" % [logical.x, logical.y, size.x, size.y]) - -func _begin_rest() -> void: - cancel_inputs() - _enter("REST") - _event("soak_rest", {"round": _round, "state": "start", "wall_ms": _wall_ms()}) - -func _tick_rest(now: int) -> void: - if now - _stage_since < int(_soak_cfg().rest_seconds) * 1_000_000: - return - _event("soak_rest", {"round": _round, "state": "end", "wall_ms": _wall_ms()}) - _rounds_done = _round + 1 - if now - _soak_start_us >= int(_soak_cfg().duration_seconds) * 1_000_000: - _finish() - return - _round += 1 - _begin_round() - -func _soak_fail(case_id: String, case_status: String, reason: String) -> void: - cancel_inputs() - _finish_case(case_id, case_status, reason) - _finish() - -## §9.3: one repetition of an interaction = one stay in a stage; it is "severe" when -## a >100ms frame tagged with that phase arrived during the stay. -func _track_frames(previous: String) -> void: - if suite != "soak" or metrics == null or _soak_start_us < 0 or previous.is_empty(): - return - var phase := previous.to_lower() - var total := int(metrics.severe_by_phase().get(phase, 0)) - var delta := total - int(_severe_seen.get(phase, 0)) - _severe_seen[phase] = total - var history: Array = _frame_history.get(phase, []) - history.append(delta > 0) - if history.size() > FRAME_HISTORY_CAP: - history.pop_front() - _frame_history[phase] = history - if delta > 0: - _event("frame_severe", {"interaction": phase, "severe": delta, "round": _round}) - if history.size() >= 3 and not Metrics.repeated_severe({phase: history.slice(-3)}).is_empty(): - _repeated_severe[phase] = true - -func _close_soak() -> void: - var now := _now() - var soak := _soak_cfg() - var elapsed := now - _soak_start_us - var duration_s := int(soak.duration_seconds) - var failures: Array = report.failures() - if not report.has_case("STB-DURATION-01"): - if not failures.is_empty(): - report.add_case("STB-DURATION-01", "FAIL", "soak ended by a failure after %d rounds (%.0fs of %ds)" % [_rounds_done, elapsed / 1e6, duration_s]) - elif not _soak_blocked.is_empty(): - report.add_case("STB-DURATION-01", "BLOCKED", "soak ended early: %s" % _soak_blocked) - elif elapsed < duration_s * 1_000_000: - report.add_case("STB-DURATION-01", "BLOCKED", "wall-clock %.0fs is below the %ds soak" % [elapsed / 1e6, duration_s]) - elif _rounds_done < int(soak.min_rounds): - report.add_case("STB-DURATION-01", "BLOCKED", "%d rounds completed, need at least %d" % [_rounds_done, int(soak.min_rounds)]) - else: - report.add_case("STB-DURATION-01", "PASS", "%d move/combat/pickup rounds over %.0fs wall clock" % [_rounds_done, elapsed / 1e6]) - if not report.has_case("STB-FRAMES-01"): - if metrics == null: - report.add_case("STB-FRAMES-01", "BLOCKED", "no frame metrics attached to this run") - elif not _repeated_severe.is_empty(): - report.add_case("STB-FRAMES-01", "FAIL", ">100ms frames in 2 of 3 consecutive repetitions of: %s" % ", ".join(_repeated_severe.keys())) - else: - var unattributed: Dictionary = metrics.long_unattributed_by_phase() - unattributed.erase("loading") - var count := 0 - for phase in unattributed: - count += int(unattributed[phase]) - if count > 0: - report.add_case("STB-FRAMES-01", "BLOCKED", "%d gameplay frames >50ms need attribution: %s" % [count, unattributed]) - else: - report.add_case("STB-FRAMES-01", "PASS", "no repeated >100ms interaction; every gameplay frame >50ms has a marker") - if not report.has_case("STB-RECONNECT-01"): - var required := int(soak.reconnects) - if _active_reconnects >= required: - report.add_case("STB-RECONNECT-01", "PASS", "%d active reconnects re-acquired main VID, scene and UI without residue" % _active_reconnects) - else: - report.add_case("STB-RECONNECT-01", "BLOCKED", "%d of %d active reconnects completed" % [_active_reconnects, required]) - if not report.has_case("STB-WARP-01"): - var warp: Dictionary = soak.warp - if String(warp.get("status", "")) != "confirmed": - report.add_case("STB-WARP-01", "BLOCKED", "warp route not confirmed by the environment owner (portal, destination, cross-server support)") - elif _warps >= int(warp.required): - report.add_case("STB-WARP-01", "PASS", "%d map changes through configured portals, each on a new server connection" % _warps) - else: - report.add_case("STB-WARP-01", "BLOCKED", "%d of %d portal map changes completed" % [_warps, int(warp.required)]) - if not report.has_case("STB-DISCONNECT-01"): - var faults: Dictionary = soak.faults - if String(faults.get("status", "")) != "confirmed": - report.add_case("STB-DISCONNECT-01", "BLOCKED", "real transport faults (connection closed, short unreachability) need a confirmed local test proxy; active reconnect is not a substitute") - elif _next_fault_type().is_empty(): - report.add_case("STB-DISCONNECT-01", "PASS", "recovered from real faults %s" % [_faults]) - else: - report.add_case("STB-DISCONNECT-01", "BLOCKED", "real faults recovered %s, need %d per type" % [_faults, int(faults.per_type)]) - if not report.has_case("STB-RESIZE-01"): - var short: Array = [] - for key in _resize_counts: - if int(_resize_counts[key]) < int(soak.resolutions.switches_per_size): - short.append(key) - if not _resize_blocked.is_empty(): - report.add_case("STB-RESIZE-01", "BLOCKED", _resize_blocked) - elif short.is_empty(): - report.add_case("STB-RESIZE-01", "PASS", "switched to each logical size at least %d times: %s" % [int(soak.resolutions.switches_per_size), _resize_counts]) - else: - report.add_case("STB-RESIZE-01", "BLOCKED", "logical size switches %s below %d for %s" % [_resize_counts, int(soak.resolutions.switches_per_size), short]) - var interactions := {} - for phase in _frame_history: - var hits := 0 - for hit in _frame_history[phase]: - hits += int(bool(hit)) - interactions[phase] = {"recent": _frame_history[phase].size(), "severe": hits} - report.set_section("soak", {"duration_seconds": duration_s, "elapsed_seconds": elapsed / 1e6, "rounds": _rounds_done, - "rest_seconds": int(soak.rest_seconds), "warmup_rounds": int(soak.warmup_rounds), - "active_reconnects": _active_reconnects, "real_faults": _faults.duplicate(), "fault_mode": String(soak.faults.get("status", "")), - "warps": _warps, "warp_status": String(soak.warp.get("status", "")), "cross_server": soak.warp.get("cross_server", null), - "resizes": _resize_counts.duplicate(), "wall_start_ms": _soak_wall_start_ms, "wall_end_ms": _wall_ms(), - "interactions": interactions, "repeated_severe": _repeated_severe.keys(), - "exits": "counted by the runner (script/playable_soak.sh)", - "cold_start": "进程冷启动/系统缓存状态未知;未执行系统缓存清空"}) - -func _wall_ms() -> int: - return int(wall_ms.call()) if wall_ms.is_valid() else int(Time.get_unix_time_from_system() * 1000.0) - -# --- failure / teardown --------------------------------------------------------- - -func _on_disconnected() -> void: - if stage == "MOVE_CONFIRM" or (suite == "soak" and stage in ["WARP", "FAULT"]): - # Expected during a requested reconnect / portal / transport fault; the next - # entered_game after a disconnect is not a duplicate signal. - _reconnect_entered = 0 - if stage == "FAULT" and not _fault.is_empty() and int(_fault.disconnected_us) < 0: - _fault.disconnected_us = _now() - _event("fault_disconnected", {"type": _fault.type, "round": _round, - "elapsed_ms": (int(_fault.disconnected_us) - _stage_since) / 1000}) - return - cancel_inputs() - match stage: - "LOGIN": - _finish_case("NET-LOGIN-01", "FAIL", "disconnected before char_list") - "SELECT", "WORLD": - # NET-03: a disconnect while entering must end the run without half-loaded operations. - _finish_case(_case_for_stage(), "FAIL", "disconnected while entering the game") - _: - _finish_case(_case_for_stage(), "FAIL", "unexpected disconnect during %s" % stage) - _finish() - -func _fail_stage(reason: String) -> void: - cancel_inputs() - _finish_case(_case_for_stage(), "FAIL", reason) - _finish() - -func _finish_case(case_id: String, case_status: String, reason: String) -> void: - var soak_active := suite == "soak" and _soak_start_us >= 0 - if soak_active and case_status == "BLOCKED" and _soak_blocked.is_empty(): - _soak_blocked = "soak round %d %s: %s" % [_round, case_id, reason] - if report.has_case(case_id): - # A later soak round repeats cases that already have a result: record the round - # failure against the run instead of a duplicate case result. - if soak_active and case_status == "FAIL": - report.add_failure("soak round %d %s: %s" % [_round, case_id, reason]) - return - var evidence: Array = [] - if stage == "SKILL" and case_id == _case_for_stage(): - evidence.append_array(_shots) - if capture.is_valid(): - var path := String(capture.call(case_id)) - if not path.is_empty(): - evidence.append(path) - report.add_case(case_id, case_status, reason, evidence, float(_now() - _stage_since) / 1000.0) - -## PASS result; a soak round repeating an already-passed case records nothing new. -func _pass(case_id: String, reason: String) -> void: - if suite == "soak" and report.has_case(case_id): - return - report.add_case(case_id, "PASS", reason) - -func _block_remaining(reason: String) -> void: - for id in report.required_cases(): - if not report.has_case(String(id)): - report.add_case(String(id), "BLOCKED", reason) - -func _finish() -> void: - if done: - return - cancel_inputs() - if suite == "soak" and _soak_start_us >= 0: - _close_soak() - done = true - _event("finished", {"state": stage}) - status = String(report.finish().status) - finished.emit(status) - -# --- helpers --------------------------------------------------------------------- - -func _case_for_stage() -> String: - match stage: - "BOOT": return "CONFIG-01" - "LOGIN": return "NET-LOGIN-01" - "SELECT": return "NET-SELECT-01" - "WORLD": return "NET-WORLD-01" - "MOVE": return _move_case() - "MOVE_CONFIRM": return "NET-MOVE-CONFIRM-01" - "TARGET": return "NET-TARGET-01" if not report.has_case("NET-TARGET-01") else "NET-DROP-01" - "ATTACK": return "NET-ATTACK-01" if not report.has_case("NET-ATTACK-01") else "NET-DROP-01" - "DROP": return "NET-DROP-01" - "PICKUP": return "NET-PICKUP-01" - "SKILL": - var skills: Array = config.get("skill_cases", []) - return Config.skill_case_id(skills[_skill_index]) if _skill_index < skills.size() else "CBT" - "FAULT": return "STB-DISCONNECT-01" - "WARP": return "STB-WARP-01" - "RESIZE": return "STB-RESIZE-01" - "REST": return "STB-DURATION-01" - return stage - -func _move_case() -> String: - return "NET-MOVE-%02d" % (_waypoint + 1) - -func _event(kind: String, payload: Dictionary) -> void: - report.record(kind, stage, _case_for_stage(), _probe_epoch(), _main_vid, int(_target.get("vid", 0)), payload) - -func _now() -> int: - return int(clock_us.call()) - -func _probe_epoch() -> int: - return int(probe.connection_epoch) if probe != null else 0 - -## §9.1 segment tags for the live frame sampler; frames before the world is ready are "loading". -func frame_tags() -> Dictionary: - var ready := _world_ready() - var net_world: Variant = _context().get("net_world", null) - var parent: Variant = net_world.get("parent") if net_world != null and is_instance_valid(net_world) and "parent" in net_world else null - var actors: int = parent.get_child_count() if parent != null and is_instance_valid(parent) else 0 - return {"map": ready.map_key, "phase": stage.to_lower() if ready.ready else "loading", - "actor_count": Metrics.actor_bucket(actors)} - -func _is_target(vid: int) -> bool: - return not _target.is_empty() and vid == int(_target.vid) and int(_target.get("epoch", -1)) == _bound_epoch - -func _flow_snapshot() -> Dictionary: - if flow != null and is_instance_valid(flow) and flow.has_method("get_playable_snapshot"): - return flow.get_playable_snapshot() - return {} - -## Borrowed references: re-read every use, never cached across reconnect/scene reload. -func _context() -> Dictionary: - if flow != null and is_instance_valid(flow) and flow.has_method("get_playable_context"): - return flow.get_playable_context() - return {} - -func _world_ready() -> Dictionary: - var snap := _flow_snapshot() - var context := _context() - var player: Variant = context.get("player", null) - var hud: Variant = context.get("hud", null) - var player_ready: bool = player != null and is_instance_valid(player) - var hud_ready: bool = hud != null and is_instance_valid(hud) - var scene_ready := bool(snap.get("scene_ready", false)) - return {"ready": scene_ready and player_ready and hud_ready, "scene_ready": scene_ready, - "player_ready": player_ready, "hud_ready": hud_ready, "map_key": String(snap.get("map_key", ""))} - -func _ground_items() -> Array: - return client.get_ground_items() if client.has_method("get_ground_items") else [] - -func _ground_iids() -> Dictionary: - var out := {} - for item in _ground_items(): - out[int(item.get("vid", 0))] = true - return out - -func _ground_pos_cm(item: Dictionary) -> Vector2: - var pos: Variant = item.get("pos", null) - if pos is Vector3: - return Vector2(pos.x * 100.0, -pos.z * 100.0) - return Vector2(INF, INF) - -func _owner_matches(owner: String) -> bool: - if owner.is_empty(): - return true - var main: Dictionary = client.get_entity(_main_vid) if client.has_method("get_entity") else {} - return String(main.get("name", "")) == owner - -## Stacks are summed over every inventory cell, so a merge into an existing stack counts. -func _inventory_total(vnum: int) -> int: - if not client.has_method("get_inventory"): - return -1 - var total := 0 - for item in client.get_inventory(): - if int(item.get("vnum", 0)) == vnum: - total += maxi(1, int(item.get("count", 1))) - return total diff --git a/project/testing/playable_flow.gd.uid b/project/testing/playable_flow.gd.uid deleted file mode 100644 index 04bb42ac..00000000 --- a/project/testing/playable_flow.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b4rgv8d34qpjw diff --git a/project/testing/playable_metrics.gd b/project/testing/playable_metrics.gd deleted file mode 100644 index fb8fa1fa..00000000 --- a/project/testing/playable_metrics.gd +++ /dev/null @@ -1,292 +0,0 @@ -class_name PlayableMetrics -extends RefCounted - -## STB-01 §9.1 帧间隔与长帧指标(纯数据,时钟可注入)。 -## -## - frame() 每帧调用一次,记录与上一帧的 monotonic 间隔;首个调用只起表。 -## - 所有存储都有固定上限:当前窗口的样本在 capacity 大小的环形缓冲里, -## 已关闭窗口 / 长帧事件 / 标记 / 分段表各有上限,超出只计丢弃数, -## 测试自身不会因为长跑而无限增长内存。 -## - 窗口按注入时钟(墙钟期限)关闭,或在分段标签变化时关闭;不按帧数。 -## 窗口样本超过 capacity 时分位数只来自最近 capacity 个样本并标 truncated, -## max 与 >50ms / >100ms 计数始终精确。 -## - 分段标签只取 map / phase / actor_count / gpu_skin;actor_count 请用 actor_bucket() 分桶。 -## - 默认不逐帧打印,只保留长帧事件(>50ms),附带本帧和上一帧内的标记用于归因。 -## - GPU 内存拿不到时写 null + "unavailable",从不写 0。 - -const TAG_KEYS := ["map", "phase", "actor_count", "gpu_skin"] -const LONG_MS := 50.0 -const SEVERE_MS := 100.0 -## 9.3 标记:同步模型 build、动作 reload、GR2 解析、材质初次使用、实体批量生成、缓存命中等。 -const MARKER_DETAIL_KEYS := ["race", "count", "ms", "state", "map", "hits", "misses", "size", "round", "kind"] -const MAX_PENDING_MARKERS := 16 -const MAX_MARKER_KIND_LENGTH := 32 - -var capacity := 4096 -var window_us := 60_000_000 -var max_windows := 512 -var max_long_frames := 512 -var max_markers := 1024 -var max_segments := 64 -var print_frames := false -var clock_us: Callable = func() -> int: return Time.get_ticks_usec() - -var _ring := PackedFloat32Array() -var _ring_head := 0 -var _ring_len := 0 -var _window_count := 0 -var _window_over_long := 0 -var _window_over_severe := 0 -var _window_max := 0.0 -var _window_start_us := -1 -var _origin_us := -1 -var _last_frame_us := -1 -var _tags := {} -var _tag_key := "" -var _frames := 0 -var _windows: Array = [] -var _windows_dropped := 0 -var _segments := {} -var _unsegmented_frames := 0 -var _severe_by_phase := {} -var _long_unattributed_by_phase := {} -var _long_frames: Array = [] -var _long_frames_dropped := 0 -var _markers: Array = [] -var _markers_dropped := 0 -var _pending_markers: Array[String] = [] -var _previous_markers: Array[String] = [] - -func _init() -> void: - _ring.resize(capacity) - -func configure(options: Dictionary) -> void: - capacity = maxi(1, int(options.get("capacity", capacity))) - window_us = maxi(1, int(float(options.get("window_seconds", window_us / 1_000_000.0)) * 1_000_000.0)) - max_windows = maxi(1, int(options.get("max_windows", max_windows))) - max_long_frames = maxi(1, int(options.get("max_long_frames", max_long_frames))) - max_markers = maxi(1, int(options.get("max_markers", max_markers))) - max_segments = maxi(1, int(options.get("max_segments", max_segments))) - print_frames = bool(options.get("print_frames", false)) - _ring.resize(capacity) - _ring_head = 0 - _ring_len = 0 - -## Switches the segment. A different tag set closes the current window first. -func set_tags(tags: Dictionary) -> void: - var normalized := {} - for key in TAG_KEYS: - if tags.has(key): - normalized[key] = String(str(tags[key])).left(80) - var key := JSON.stringify(normalized) - if key == _tag_key: - return - if _window_count > 0: - close_window() - _tags = normalized - _tag_key = key - -func tags() -> Dictionary: - return _tags.duplicate() - -## Call once per rendered frame. The first call only arms the clock. -func frame(now_us := -1) -> void: - var now := now_us if now_us >= 0 else int(clock_us.call()) - if _last_frame_us >= 0: - add_interval_us(now - _last_frame_us, now) - else: - _arm(now) - _last_frame_us = now - -## Forget the previous frame time (e.g. before a deliberately unmeasured pause). -func reset_frame_clock() -> void: - _last_frame_us = -1 - -func add_interval_us(interval_us: int, now_us: int) -> void: - if _origin_us < 0: - _arm(now_us - interval_us) - if _window_start_us < 0: - _window_start_us = now_us - interval_us - var ms := float(interval_us) / 1000.0 - _ring[_ring_head] = ms - _ring_head = (_ring_head + 1) % capacity - _ring_len = mini(_ring_len + 1, capacity) - _window_count += 1 - _frames += 1 - _window_max = maxf(_window_max, ms) - var segment := _segment() - if not segment.is_empty(): - segment.frames = int(segment.frames) + 1 - segment.max_ms = maxf(float(segment.max_ms), ms) - var attribution: Array[String] = [] - for kind in _previous_markers + _pending_markers: - if not (kind in attribution): - attribution.append(kind) - _previous_markers = _pending_markers - _pending_markers = [] - if ms > LONG_MS: - _window_over_long += 1 - if not segment.is_empty(): - segment.over_50ms = int(segment.over_50ms) + 1 - _push_bounded(_long_frames, {"t_ms": _t_ms(now_us), "ms": ms, "tags": _tags.duplicate(), - "markers": attribution}, max_long_frames, "_long_frames_dropped") - if attribution.is_empty(): - var long_phase := String(_tags.get("phase", "")) - if _long_unattributed_by_phase.has(long_phase) or _long_unattributed_by_phase.size() < max_segments: - _long_unattributed_by_phase[long_phase] = int(_long_unattributed_by_phase.get(long_phase, 0)) + 1 - if ms > SEVERE_MS: - _window_over_severe += 1 - if not segment.is_empty(): - segment.over_100ms = int(segment.over_100ms) + 1 - var phase := String(_tags.get("phase", "")) - if _severe_by_phase.has(phase) or _severe_by_phase.size() < max_segments: - _severe_by_phase[phase] = int(_severe_by_phase.get(phase, 0)) + 1 - if print_frames: - print("FRAME ms=%.3f tags=%s" % [ms, _tag_key]) - if now_us - _window_start_us >= window_us: - close_window(now_us) - -## Attribution marker (model build, motion reload, GR2 parse, material first use, entity batch, ...). -func mark(kind: String, detail := {}) -> void: - var name := kind.left(MAX_MARKER_KIND_LENGTH) - var clean := {} - for key in detail: - if String(key) in MARKER_DETAIL_KEYS: - var value: Variant = detail[key] - clean[String(key)] = value if (value is int or value is float or value is bool) else String(str(value)).left(80) - var now := int(clock_us.call()) - _push_bounded(_markers, {"t_ms": _t_ms(now), "kind": name, "tags": _tags.duplicate(), "detail": clean}, - max_markers, "_markers_dropped") - if _pending_markers.size() < MAX_PENDING_MARKERS and not (name in _pending_markers): - _pending_markers.append(name) - -func close_window(now_us := -1) -> void: - if _window_count == 0: - return - var end := now_us if now_us >= 0 else maxi(_last_frame_us, _window_start_us) - var sorted := PackedFloat32Array() - sorted.resize(_ring_len) - for i in _ring_len: - sorted[i] = _ring[(_ring_head - _ring_len + i + capacity) % capacity] - sorted.sort() - var window := {"tags": _tags.duplicate(), "start_ms": _t_ms(_window_start_us), "end_ms": _t_ms(end), - "count": _window_count, "sampled": _ring_len, "truncated": _window_count > _ring_len, - "p50_ms": percentile(sorted, 0.50), "p95_ms": percentile(sorted, 0.95), "p99_ms": percentile(sorted, 0.99), - "max_ms": _window_max, "over_50ms": _window_over_long, "over_100ms": _window_over_severe} - _push_bounded(_windows, window, max_windows, "_windows_dropped") - var segment: Dictionary = _segments.get(_tag_key, {}) - if not segment.is_empty(): - segment.windows = int(segment.windows) + 1 - segment.worst_p99_ms = maxf(float(segment.worst_p99_ms), float(window.p99_ms)) - _ring_head = 0 - _ring_len = 0 - _window_count = 0 - _window_over_long = 0 - _window_over_severe = 0 - _window_max = 0.0 - _window_start_us = end - -func summary() -> Dictionary: - close_window() - var segments: Array = [] - for key in _segments: - segments.append(_segments[key].duplicate(true)) - return {"schema_version": 1, "capacity": capacity, "window_seconds": window_us / 1_000_000.0, - "thresholds_ms": [LONG_MS, SEVERE_MS], "print_frames": print_frames, "frames": _frames, - "segments": segments, "unsegmented_frames": _unsegmented_frames, - "windows": _windows.duplicate(true), "windows_dropped": _windows_dropped, - "long_frames": _long_frames.duplicate(true), "long_frames_dropped": _long_frames_dropped, - "markers": _markers.duplicate(true), "markers_dropped": _markers_dropped, - "severe_by_phase": _severe_by_phase.duplicate(), - "long_unattributed_by_phase": _long_unattributed_by_phase.duplicate()} - -func stored_sample_count() -> int: - return _ring_len - -## Cumulative >100ms counts per phase tag; diff two snapshots to judge one repetition. -func severe_by_phase() -> Dictionary: - return _severe_by_phase.duplicate() - -## Exact per-phase count of >50ms frames without a marker (not limited by max_long_frames). -func long_unattributed_by_phase() -> Dictionary: - return _long_unattributed_by_phase.duplicate() - -## Retained long frames that have no marker in their frame or the one before. They -## need manual attribution (§9.3); the automated gate never explains them away. -func unattributed_long_frames() -> Array: - return _long_frames.filter(func(item: Dictionary) -> bool: return item.markers.is_empty()) - -## Nearest-rank percentile of an ascending array. -static func percentile(sorted: PackedFloat32Array, q: float) -> float: - if sorted.is_empty(): - return 0.0 - var index := clampi(int(ceil(q * sorted.size())) - 1, 0, sorted.size() - 1) - return sorted[index] - -static func actor_bucket(count: int) -> String: - if count <= 0: - return "0" - for limit in [8, 16, 32, 64]: - if count <= limit: - return "%d-%d" % [limit / 2 + 1 if limit > 8 else 1, limit] - return "65+" - -## §9.3: an interaction repeated at least 3 times where 2 of any 3 consecutive -## repetitions contained a >100ms frame. history: interaction -> Array[bool]. -static func repeated_severe(history: Dictionary) -> Array[String]: - var out: Array[String] = [] - for key in history: - var runs: Array = history[key] - for i in range(0, runs.size() - 2): - var hits := int(bool(runs[i])) + int(bool(runs[i + 1])) + int(bool(runs[i + 2])) - if hits >= 2: - out.append(String(key)) - break - return out - -## MEMORY_STATIC is Godot's own allocator counter, not process RSS (the runner -## samples RSS externally). GPU memory has no reliable source here. -static func memory_snapshot() -> Dictionary: - var video := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)) - return {"memory_static_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC)) / 1024, - "memory_static_max_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC_MAX)) / 1024, - "gpu_memory_kib": null, "gpu_memory_status": "unavailable", - "godot_video_mem_kib": video / 1024 if video > 0 else null, - "godot_video_mem_note": "Godot RenderingDevice allocation counter; not system GPU memory"} - -## Cost of one sample on this machine; compare with the frame budget in reports. -static func measure_overhead(iterations := 20000) -> Dictionary: - var probe := new() - probe.configure({"capacity": 4096, "window_seconds": 3600}) - probe.set_tags({"map": "overhead", "phase": "probe", "actor_count": "1-8", "gpu_skin": false}) - var started := Time.get_ticks_usec() - for i in iterations: - probe.add_interval_us(16_000 + (i % 7) * 1000, (i + 1) * 16_000) - var elapsed := Time.get_ticks_usec() - started - return {"iterations": iterations, "total_us": elapsed, "per_sample_us": float(elapsed) / maxf(1.0, iterations)} - -func _arm(now_us: int) -> void: - if _origin_us < 0: - _origin_us = now_us - if _window_start_us < 0: - _window_start_us = now_us - -func _t_ms(us: int) -> float: - return float(us - maxi(_origin_us, 0)) / 1000.0 - -func _segment() -> Dictionary: - if _segments.has(_tag_key): - return _segments[_tag_key] - if _segments.size() >= max_segments: - _unsegmented_frames += 1 - return {} - var row := {"tags": _tags.duplicate(), "frames": 0, "windows": 0, "max_ms": 0.0, "worst_p99_ms": 0.0, - "over_50ms": 0, "over_100ms": 0} - _segments[_tag_key] = row - return row - -func _push_bounded(list: Array, item: Dictionary, limit: int, dropped_field: String) -> void: - if list.size() >= limit: - list.pop_front() - set(dropped_field, int(get(dropped_field)) + 1) - list.append(item) diff --git a/project/testing/playable_metrics.gd.uid b/project/testing/playable_metrics.gd.uid deleted file mode 100644 index 282ed79f..00000000 --- a/project/testing/playable_metrics.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bu1e7s5yb3p1j diff --git a/project/testing/playable_probe.gd b/project/testing/playable_probe.gd deleted file mode 100644 index 97873b16..00000000 --- a/project/testing/playable_probe.gd +++ /dev/null @@ -1,247 +0,0 @@ -class_name PlayableProbe -extends Node - -## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入, -## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。 -## -## 每条观察都带 source: -## "server" —— M2Client 接收端派发(EntityStore 变化 / 收包回调) -## "local" —— 本地 UI/表现层信号(施法开始、特效节点),不能单独作为服务端证据 -## -## connection_epoch:首个连接为 1;收到 disconnected 或在同一 epoch 内再次 -## entered_game(快速重连 / 换服不一定发 disconnected)时加一。VID 只在其 epoch -## 内有意义,状态机必须按新 epoch 重新绑定主角与目标。 - -signal observed(kind: String, payload: Dictionary) - -const SERVER_SIGNALS := { - "entered_game": "_on_entered_game", - "disconnected": "_on_disconnected", - "login_failed": "_on_login_failed", - "char_list": "_on_char_list", - "entity_main_set": "_on_entity_main_set", - "entity_spawned": "_on_entity_spawned", - "entity_despawned": "_on_entity_despawned", - "entity_moved": "_on_entity_moved", - "entity_dead": "_on_entity_dead", - "damage": "_on_damage", - "motion": "_on_motion", - "vitals_changed": "_on_vitals", - "points_changed": "_on_points_changed", - "target_info": "_on_target_info", - "ground_item_added": "_on_ground_item_added", - "ground_item_removed": "_on_ground_item_removed", - "item_picked_up": "_on_item_picked_up", - "inventory_changed": "_on_inventory_changed", - "affect_added": "_on_affect_added", - "effect_cue": "_on_effect_cue", - "fly_cue": "_on_fly_cue", - "fly_targeting": "_on_fly_targeting", -} - -var client: Object -var flow: Object -var connection_epoch := 1 -var _entered_in_epoch := false -var _connections: Array[Dictionary] = [] -var _last_sp := -1 - -func setup(app_flow: Object, m2client: Object) -> void: - flow = app_flow - client = m2client - if client == null: - return - for signal_name in SERVER_SIGNALS: - _bind(client, signal_name, Callable(self, SERVER_SIGNALS[signal_name])) - -## Subscribe to a local presentation object (quickbar / skill fx). Its events -## are tagged source=local and never satisfy a server-evidence requirement. -func watch_local(source: Object, signal_name: String, kind: String) -> void: - if source == null or not source.has_signal(signal_name): - return - var callback := func(a: Variant = null, b: Variant = null, c: Variant = null, - d: Variant = null, e: Variant = null) -> void: - _emit_local(kind, [a, b, c, d, e]) - _bind(source, signal_name, callback) - -func snapshot() -> Dictionary: - var alive := client != null and is_instance_valid(client) - var in_game: bool = alive and client.has_method("is_in_game") and client.is_in_game() - var main_vid := int(client.get_main_vid()) if alive and client.has_method("get_main_vid") else 0 - var entity_count: int = client.get_entities().size() if alive and client.has_method("get_entities") else 0 - var ground_count: int = client.get_ground_items().size() if alive and client.has_method("get_ground_items") else 0 - var flow_alive := flow != null and is_instance_valid(flow) - var state := int(flow.state()) if flow_alive and flow.has_method("state") else -1 - return {"in_game": in_game, "main_vid": main_vid, "entity_count": entity_count, - "ground_item_count": ground_count, "app_state": state} - -func disconnect_all() -> void: - for entry in _connections: - var source: Object = entry.source.get_ref() - var signal_name: String = entry.signal - var callback: Callable = entry.callback - if source != null and is_instance_valid(source) and source.has_signal(signal_name) \ - and source.is_connected(signal_name, callback): - source.disconnect(signal_name, callback) - _connections.clear() - -func bound_count() -> int: - _prune_connections() - return _connections.size() - -func _prune_connections() -> void: - for i in range(_connections.size() - 1, -1, -1): - if _connections[i].source.get_ref() == null: - _connections.remove_at(i) - -func _exit_tree() -> void: - disconnect_all() - -func _bind(source: Object, signal_name: String, callback: Callable) -> void: - # Registries are RefCounted and own parsed effect caches. Observing a scene - # must not keep it alive after reconnect; discard dead bindings on rebind. - _prune_connections() - if source.has_signal(signal_name) and not source.is_connected(signal_name, callback): - source.connect(signal_name, callback) - _connections.append({"source": weakref(source), "signal": signal_name, "callback": callback}) - -func _emit(kind: String, payload := {}, actor_vid := 0, target_vid := 0) -> void: - var data: Dictionary = payload.duplicate(true) - data["source"] = "server" - data["connection_epoch"] = connection_epoch - data["actor_vid"] = actor_vid - data["target_vid"] = target_vid - data["received_us"] = Time.get_ticks_usec() - observed.emit(kind, data) - -func _emit_local(kind: String, args: Array) -> void: - var data := {"source": "local", "connection_epoch": connection_epoch, - "actor_vid": 0, "target_vid": 0, "received_us": Time.get_ticks_usec()} - match kind: - "cast_started": - data["skill_id"] = int(args[0]) if args[0] != null else 0 - data["target_vid"] = int(args[1]) if args[1] != null else 0 - "skill_rejected": - data["skill_id"] = int(args[0]) if args[0] != null else 0 - data["code"] = String(args[1]) if args[1] != null else "" - "fx_spawned", "fx_finished": - # EffectRegistry lifecycle: (effect, fx_id, lifetime_ms[, elapsed_ms, reason]). - data["effect"] = String(args[0]).get_file().get_basename().left(80) if args[0] != null else "" - data["fx_id"] = int(args[1]) if args[1] != null else 0 - data["lifetime_ms"] = int(args[2]) if args[2] != null else -1 - if kind == "fx_finished": - data["elapsed_ms"] = int(args[3]) if args[3] != null else -1 - data["reason"] = String(args[4]) if args[4] != null else "" - _: - if args[0] is int: - data["skill_id"] = args[0] - observed.emit(kind, data) - -func _on_entered_game() -> void: - if _entered_in_epoch: - connection_epoch += 1 - _entered_in_epoch = true - _last_sp = -1 - _emit("entered_game") - -func _on_disconnected(reason: String) -> void: - _emit("disconnected", {"reason": reason.left(120)}) - connection_epoch += 1 - _entered_in_epoch = false - _last_sp = -1 - -func _on_login_failed(reason: String) -> void: - _emit("login_failed", {"reason": reason.left(120)}) - -func _on_char_list(chars: Array) -> void: - var slots: Array = [] - for character in chars: - if character is Dictionary: - slots.append(int(character.get("index", -1))) - _emit("char_list", {"count": chars.size(), "slots": slots}) - -func _on_entity_main_set(vid: int) -> void: - _emit("entity_main_set", {}, vid) - -func _on_entity_spawned(entity: Dictionary) -> void: - _emit("entity_spawned", {"race": int(entity.get("race", 0)), "ch_type": int(entity.get("ch_type", -1)), - "dead": bool(entity.get("dead", false)), "pos_cm": entity.get("pos_cm", Vector3.ZERO)}, - int(entity.get("vid", 0))) - -func _on_entity_despawned(vid: int) -> void: - _emit("entity_despawned", {}, vid) - -func _on_entity_moved(vid: int) -> void: - var pos: Variant = Vector3.ZERO - if client != null and client.has_method("get_entity"): - pos = client.get_entity(vid).get("pos_cm", Vector3.ZERO) - _emit("entity_moved", {"pos_cm": pos}, vid) - -func _on_entity_dead(vid: int) -> void: - _emit("entity_dead", {}, 0, vid) - -func _on_damage(vid: int, amount: int, flag: int) -> void: - _emit("damage", {"amount": amount, "flag": flag}, 0, vid) - -func _on_motion(vid: int, victim_vid: int, motion: int) -> void: - _emit("server_motion", {"motion": motion}, vid, victim_vid) - -func _on_vitals(vid: int) -> void: - var entity: Dictionary = client.get_entity(vid) if client != null and client.has_method("get_entity") else {} - _emit("vitals_changed", {"hp": int(entity.get("hp", -1)), "max_hp": int(entity.get("max_hp", -1)), - "dead": bool(entity.get("dead", false))}, vid) - -func _on_points_changed(points: Dictionary) -> void: - # points_changed carries the main character's point table. Only a change in - # SP is interesting as skill-resource evidence. - var sp := int(points.get("sp", points.get(8, -1))) if points.has("sp") or points.has(8) else -1 - if sp >= 0 and _last_sp >= 0 and sp != _last_sp: - _emit("sp_changed", {"before": _last_sp, "after": sp, "delta": sp - _last_sp}) - if sp >= 0: - _last_sp = sp - -func _on_target_info(vid: int, hp_percent: int) -> void: - _emit("target_info", {"hp_percent": hp_percent}, 0, vid) - -func _on_ground_item_added(item: Dictionary) -> void: - _emit("ground_item_added", {"iid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), - "owner_match": _owner_matches(String(item.get("owner", ""))), "pos_cm": _ground_pos_cm(item)}) - -func _on_ground_item_removed(vid: int) -> void: - _emit("ground_item_removed", {"iid": vid}) - -func _on_item_picked_up(vnum: int, count: int, source: String) -> void: - # `source` may be a player name; keep only whether it was attributed at all. - _emit("item_picked_up", {"vnum": vnum, "count": count, "selected": not source.is_empty()}) - -func _on_inventory_changed(window: int, cell: int) -> void: - _emit("inventory_changed", {"window": window, "cell": cell}) - -func _on_affect_added(affect: Dictionary) -> void: - _emit("affect_added", {"type": int(affect.get("type", 0))}) - -func _on_effect_cue(vid: int, name: String, special: int) -> void: - _emit("effect_cue", {"effect": name.get_file(), "special": special}, vid) - -func _on_fly_cue(kind: int, start_vid: int, end_vid: int) -> void: - _emit("fly_cue", {"type": kind}, start_vid, end_vid) - -func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, _append: bool) -> void: - _emit("fly_targeting", {"pos_cm": target_cm}, shooter_vid, target_vid) - -## Ownership is evaluated in-process so the owner's name never reaches a report: -## an empty owner is free-for-all, otherwise it must equal the main character. -func _owner_matches(owner: String) -> bool: - if owner.is_empty(): - return true - if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): - return false - var main: Dictionary = client.get_entity(int(client.get_main_vid())) - return String(main.get("name", "")) == owner - -func _ground_pos_cm(item: Dictionary) -> Vector2: - var pos: Variant = item.get("pos", null) - if pos is Vector3: - # M2Client ground pos is Godot metres (x, y-up, -y_cm); convert back to server cm. - return Vector2(roundf(pos.x * 100.0), roundf(-pos.z * 100.0)) - return Vector2(-1, -1) diff --git a/project/testing/playable_probe.gd.uid b/project/testing/playable_probe.gd.uid deleted file mode 100644 index ac305116..00000000 --- a/project/testing/playable_probe.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cyv8rusbrscco diff --git a/project/testing/playable_report.gd b/project/testing/playable_report.gd deleted file mode 100644 index 83b0fce4..00000000 --- a/project/testing/playable_report.gd +++ /dev/null @@ -1,251 +0,0 @@ -class_name PlayableReport - -## 联网测试进程内的报告构建器。 -## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。 -## -## 事件契约(events.jsonl 每行):monotonic_us、run_id、case_id、connection_epoch、 -## stage、kind、actor_vid、target_vid、payload。payload 只保留 PAYLOAD_KEYS -## 白名单字段;其它字段丢弃并计数,避免把角色名、聊天内容或环境变量写进报告。 - -const SCHEMA_VERSION := 1 -const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"] -const EVENT_KEYS := ["monotonic_us", "run_id", "case_id", "connection_epoch", "stage", - "kind", "actor_vid", "target_vid", "payload"] -const PAYLOAD_KEYS := [ - "source", "count", "race", "ch_type", "amount", "flag", "vnum", "window", "cell", - "special", "type", "motion", "reason_code", "hp", "max_hp", "hp_percent", "dead", - "pos_cm", "local_pos_cm", "distance_cm", "tolerance_cm", "waypoint_index", "skill_id", - "code", "state", "map_key", "expected_map_key", "iid", "before", "after", "delta", - "attempt", "owner_match", "allow_gameplay", "main_vid", "entity_count", - "ground_item_count", "app_state", "scene_ready", "hud_ready", "player_ready", - "in_game", "reconnecting", "character_count", "slot_present", "stale_epoch", - "elapsed_ms", "timeout_ms", "server_index", "channel", "address_match", "evidence", - "screenshot", "suite", "effect", "lifetime_ms", "selected", "accepted", "reason", "slots", - "fx_id", "open_effects", "phase", - # STB-01 soak - "round", "wall_ms", "requested_size", "logical_size", "physical_size", "viewport_size", - "screen_scale", "duplicate_vids", "target_residue", "portal_cm", "unreachable_seconds", - "cross_server", "interaction", "severe", "elapsed_s", "leg", "required", "size", -] -## Top-level report sections a suite may attach; anything else is rejected. -const SECTIONS := ["soak", "frame_metrics", "client_memory", "probe_overhead"] -## Values that look like credentials are masked even inside whitelisted keys. -const SECRET_KEYS := ["password", "passwd", "token", "secret", "account", "login", "username"] - -var _report: Dictionary = {} -var _started_us := 0 -var _events_path := "" -var _event_count := 0 -var _dropped_payload_keys := {} -var _redact_literals: Array[String] = [] - -func begin(run_id: String, suite: String, config: Dictionary, events_path := "") -> void: - _started_us = Time.get_ticks_usec() - _events_path = events_path - _event_count = 0 - _dropped_payload_keys = {} - # Account/password literals may appear inside server messages; never persist them. - _redact_literals.clear() - for name in ["MT_ACCOUNT", "MT_PASSWORD"]: - var literal := OS.get_environment(name) - if literal.length() >= 2: - _redact_literals.append(literal) - _report = { - "schema_version": SCHEMA_VERSION, - "run_id": run_id, - "suite": suite, - "status": "BLOCKED", - "build": _build_info(), - "environment": _environment_info(), - "cases": [], - "required_cases": [], - "events": events_path.get_file() if not events_path.is_empty() else "", - "event_count": 0, - "failures": [], - "blocked": [], - "coverage": {"required": 0, "passed": 0}, - "exit_gate": {"checked": false, "process_code": null, "errors": []}, - } - # Keep configuration useful for reproducing a run without copying any secret. - _report["scenario_id"] = String(config.get("scenario_id", "")) - -## Declares the case IDs that must appear; missing ones become BLOCKED on finish(). -func set_required_cases(ids: Array) -> void: - var out: Array = [] - for id in ids: - out.append(String(id)) - _report.required_cases = out - -func required_cases() -> Array: - return _report.get("required_cases", []).duplicate() - -func has_case(case_id: String) -> bool: - for item in _report.get("cases", []): - if String(item.id) == case_id: - return true - return false - -func case_status(case_id: String) -> String: - for item in _report.get("cases", []): - if String(item.id) == case_id: - return String(item.status) - return "" - -func add_case(case_id: String, status: String, reason := "", evidence: Array = [], duration_ms := 0.0) -> void: - if has_case(case_id): - _fail("duplicate case result for %s" % case_id) - return - var normalized := status.to_upper() - if not VALID_STATUSES.has(normalized): - normalized = "FAIL" - _fail("invalid case status for %s" % case_id) - var item := {"id": case_id, "status": normalized, "duration_ms": duration_ms, - "reason": _redact_text(reason), "evidence": _sanitize(evidence)} - _report.cases.append(item) - _report.coverage.required = int(_report.coverage.required) + 1 - if normalized == "PASS": - _report.coverage.passed = int(_report.coverage.passed) + 1 - elif normalized == "FAIL": - _fail("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "failed"]) - elif normalized == "BLOCKED": - _report.blocked.append("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "blocked"]) - -## A run-level failure that belongs to no open case (e.g. an effect from an -## already-passed case outliving its lifetime). Any failure makes the run FAIL. -func add_failure(message: String) -> void: - _fail(message) - -## Attach a whitelisted top-level section (sanitized like payloads). Returns false for unknown names. -func set_section(name: String, value: Dictionary) -> bool: - if _report.is_empty() or not (name in SECTIONS): - return false - _report[name] = _sanitize(value) - return true - -func failures() -> Array: - return _report.get("failures", []).duplicate() - -## Append one contract event. Returns the event actually written (for tests). -func record(kind: String, stage: String, case_id: String, connection_epoch: int, - actor_vid := 0, target_vid := 0, payload := {}) -> Dictionary: - var event := { - "monotonic_us": Time.get_ticks_usec(), - "run_id": String(_report.get("run_id", "")), - "case_id": case_id, - "connection_epoch": connection_epoch, - "stage": stage, - "kind": kind, - "actor_vid": actor_vid, - "target_vid": target_vid, - "payload": _whitelist(payload), - } - _event_count += 1 - _report.event_count = _event_count - if _events_path.is_empty(): - return event - var file := FileAccess.open(_events_path, FileAccess.READ_WRITE) - if file == null: - file = FileAccess.open(_events_path, FileAccess.WRITE) - if file == null: - _fail("cannot open events file") - return event - file.seek_end() - file.store_line(JSON.stringify(event)) - file.close() - return event - -func finish() -> Dictionary: - if _report.is_empty(): - return {} - for required in _report.required_cases: - if not has_case(String(required)): - add_case(String(required), "BLOCKED", "not_reached") - var required := int(_report.coverage.required) - var passed := int(_report.coverage.passed) - var failures: Array = _report.failures - if not failures.is_empty(): - _report.status = "FAIL" - elif required == 0 or passed != required or not _report.blocked.is_empty(): - _report.status = "BLOCKED" - else: - _report.status = "PASS" - if not _dropped_payload_keys.is_empty(): - _report["dropped_payload_keys"] = _dropped_payload_keys.keys() - _report.duration_seconds = float(Time.get_ticks_usec() - _started_us) / 1000000.0 - return _report - -func write(path: String) -> bool: - var report := finish() - if report.is_empty(): - return false - var file := FileAccess.open(path, FileAccess.WRITE) - if file == null: - return false - file.store_string(JSON.stringify(report, " ")) - file.close() - return true - -func _fail(message: String) -> void: - _report.failures.append(_redact_text(message)) - -func _build_info() -> Dictionary: - return { - "engine_sha256": OS.get_environment("MT_BUILD_ENGINE_SHA256"), - "extension_sha256": OS.get_environment("MT_BUILD_EXTENSION_SHA256"), - "pck_sha256": OS.get_environment("MT_BUILD_PCK_SHA256"), - "arch": OS.get_environment("MT_BUILD_ARCH"), - } - -func _environment_info() -> Dictionary: - var headless := DisplayServer.get_name() == "headless" - var size := DisplayServer.window_get_size() if not headless else Vector2i.ZERO - var logical := size - if not headless and DisplayServer.screen_get_scale() > 0.0: - logical = Vector2i(roundi(size.x / DisplayServer.screen_get_scale()), roundi(size.y / DisplayServer.screen_get_scale())) - return { - "os": OS.get_name(), - "renderer": RenderingServer.get_video_adapter_name(), - "resolution": [size.x, size.y], - "logical_resolution": [logical.x, logical.y], - } - -func _whitelist(payload: Dictionary) -> Dictionary: - var out := {} - for key in payload: - var name := String(key) - if name in PAYLOAD_KEYS: - out[name] = _sanitize(payload[key]) - else: - _dropped_payload_keys[name] = true - return out - -func _redact_text(text: String) -> String: - var out := text - for literal in _redact_literals: - out = out.replace(literal, "[redacted]") - return out - -func _sanitize(value: Variant) -> Variant: - if value is Dictionary: - var out := {} - for key in value: - var name := String(key).to_lower() - if name in SECRET_KEYS: - out[key] = "[redacted]" - else: - out[key] = _sanitize(value[key]) - return out - if value is Array: - var out_array: Array = [] - for item in value: - out_array.append(_sanitize(item)) - return out_array - if value is String or value is StringName: - return _redact_text(String(value)) - if value is Vector2 or value is Vector2i: - return [value.x, value.y] - if value is Vector3 or value is Vector3i: - return [value.x, value.y, value.z] - if value is Object: - return "[object]" - return value diff --git a/project/testing/playable_report.gd.uid b/project/testing/playable_report.gd.uid deleted file mode 100644 index 8a8d0ccf..00000000 --- a/project/testing/playable_report.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://uiha4rsvsy6j diff --git a/project/testing/playable_window.gd b/project/testing/playable_window.gd deleted file mode 100644 index 822bb5e9..00000000 --- a/project/testing/playable_window.gd +++ /dev/null @@ -1,65 +0,0 @@ -extends RefCounted - -## STB-RESIZE-01 的真实窗口适配器(PlayableFlow.window)。 -## -## macOS Retina 下 DisplayServer 的窗口尺寸是物理像素,逻辑尺寸 = 物理像素 / 屏幕缩放。 -## 流程只按逻辑尺寸判定;物理像素、缩放和视口尺寸一并写进事件,Retina 的 2x 不是分辨率错误。 -## 请求尺寸超出当前屏幕可用区域(含标题栏)时如实拒绝(request_logical_size 返回 false), -## 由流程记为 BLOCKED,不缩小目标冒充通过。headless / 全屏 / 最大化窗口不可调整。 - -var _viewport: Viewport - -func _init(viewport: Viewport = null) -> void: - _viewport = viewport - -func availability() -> Dictionary: - if DisplayServer.get_name() == "headless": - return {"ok": false, "reason": "headless display server has no window"} - match DisplayServer.window_get_mode(): - DisplayServer.WINDOW_MODE_FULLSCREEN, DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN: - return {"ok": false, "reason": "window is fullscreen; logical size switching needs windowed mode"} - DisplayServer.WINDOW_MODE_MAXIMIZED: - return {"ok": false, "reason": "window is maximized; logical size switching needs windowed mode"} - DisplayServer.WINDOW_MODE_MINIMIZED: - return {"ok": false, "reason": "window is minimized"} - if screen_scale() <= 0.0: - return {"ok": false, "reason": "screen scale unavailable"} - return {"ok": true, "reason": ""} - -func screen_scale() -> float: - return DisplayServer.screen_get_scale(DisplayServer.window_get_current_screen()) - -func physical_size() -> Vector2i: - return DisplayServer.window_get_size() - -func logical_size() -> Vector2i: - var scale := screen_scale() - var size := physical_size() - if scale <= 0.0: - return size - return Vector2i(roundi(size.x / scale), roundi(size.y / scale)) - -func viewport_size() -> Vector2i: - if _viewport == null or not is_instance_valid(_viewport): - return Vector2i.ZERO - return Vector2i(_viewport.get_visible_rect().size) - -## Returns false (no request sent) when the size cannot fit the usable screen area. -func request_logical_size(size: Vector2i) -> bool: - var scale := screen_scale() - if scale <= 0.0 or size.x <= 0 or size.y <= 0: - return false - var target := Vector2i(roundi(size.x * scale), roundi(size.y * scale)) - var screen := DisplayServer.window_get_current_screen() - var usable := DisplayServer.screen_get_usable_rect(screen) - var decorations := DisplayServer.window_get_size_with_decorations() - DisplayServer.window_get_size() - if target.x + decorations.x > usable.size.x or target.y + decorations.y > usable.size.y: - return false - DisplayServer.window_set_size(target) - # Keep the whole window on the usable area so the OS does not clamp the size back. - var position := DisplayServer.window_get_position() - var clamped := Vector2i(clampi(position.x, usable.position.x, usable.end.x - target.x - decorations.x), - clampi(position.y, usable.position.y, usable.end.y - target.y - decorations.y)) - if clamped != position: - DisplayServer.window_set_position(clamped) - return true diff --git a/project/testing/playable_window.gd.uid b/project/testing/playable_window.gd.uid deleted file mode 100644 index b8977ed7..00000000 --- a/project/testing/playable_window.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bv2u35qomry7a diff --git a/project/text_metrics.gd b/project/text_metrics.gd deleted file mode 100644 index 3d21fde5..00000000 --- a/project/text_metrics.gd +++ /dev/null @@ -1,115 +0,0 @@ -# text_metrics —— §8.8 seam ⑦ / ⑩ 收口:头顶子标签 / 掉落名条排版的文本尺寸 -# 从「字符数 * 约 7px」/「单行 15px」估算换成 CGraphicTextInstance::GetTextSize 的 -# 真实字体度量 1:1(增量 123 换宽 w、增量 124 换高 h、增量 125 加 owner 子串独立度量)。 -# ArrangeTextTail(PythonTextTail.cpp:211-320 角色块、:160-190 item 块)里 -# iNameWidth / iTitleWidth / iLevelWidth / guildW,以及 item 尾标框的 xSize / ySize -# (框 yEnd = ySize,:749 / :809),参考端全部来自 `pTextInstance->GetTextSize(&w, &h)`。 -# Godot 的 Font.get_string_size() 是 1:1 对应物——本模块把它包成纯静态查询,并在无字体 -# (headless / 未配主题)时回退到旧的字符数估宽 / 单行估高,保持 name_tail_layout / -# text_tail_arrange 的接口不变。 -# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd / damage_effect.gd / -# target_board.gd / pick_show.gd / name_tail_layout.gd / text_tail_arrange.gd 同一套 -# 纯静态镜像套路,本模块是第 30 个 canonical。) -# -# REF(40250 ClientVS22 source/ 为唯一基线): -# UserInterface/PythonTextTail.cpp:225 pGuildNameInstance->GetTextSize(&iWidth, &iHeight) -# UserInterface/PythonTextTail.cpp:233 pTextTail->pTextInstance->GetTextSize(&iNameWidth, &iNameHeight) -# UserInterface/PythonTextTail.cpp:240 pTitle->GetTextSize(&iTitleWidth, &iTitleHeight) -# UserInterface/PythonTextTail.cpp:267 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(有称号分支) -# UserInterface/PythonTextTail.cpp:297 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(只有等级分支) -# UserInterface/PythonTextTail.cpp:733 pOwnerTextInstance->GetTextSize(&xOwnerSize, &yOwnerSize) -# UserInterface/PythonTextTail.cpp:748 pTextInstance->GetTextSize(&xSize, &ySize)(SetTextTailOwnerText) -# UserInterface/PythonTextTail.cpp:809 pTextInstance->GetTextSize(&xSize, &ySize)(SetTextTailText,item 框尺寸) -# —— GetTextSize 由 CGraphicTextInstance 转发到字体的实际字形排版度量(含字距、非等宽), -# 不是「字符数 * 定宽」。poc 之前的 SUBTAG_CHAR_W_PX / TAG_CHAR_W_PX(7.0) 是它 w 的近似、 -# TAG_LINE_H_PX(15.0) 是它 h 的近似。 -# -# seam ⑦ / ⑩:本模块只负责「量文本宽 / 高」。有字体时 = get_string_size 的精确像素(1:1); -# 无字体时回退字符数估宽 / 单行估高(headless 测试、或工程未配 Label3D.font 且无 -# ThemeDB.fallback_font 的极端情况)。折算成世界坐标仍由调用方按各 Label3D 的 pixel_size -# 处理(见 name_tail_layout.subtag_world_x / text_tail_arrange)。子标签竖排 vs 参考同一 -# 屏幕行、无公会徽记 mark 实体这两条 seam ⑦ 遗留不在本模块范围。 -extends RefCounted - -# 无字体 / font_size 非法时的每字符估宽(=增量 121 之前 net_world.SUBTAG_CHAR_W_PX、 -# ground_items.TAG_CHAR_W_PX 用的 7.0)。仅作兜底,有字体时不参与。 -const FALLBACK_CHAR_W_PX := 7.0 - -# 无字体 / font_size 非法时的单行估高(=增量 124 之前 ground_items.TAG_LINE_H_PX 用的 -# 15.0)。仅作兜底,有字体时走 get_string_size(...).y。 -const FALLBACK_LINE_H_PX := 15.0 - -# GetTextSize(&w, &h) 1:1:text 在 font @ font_size 下的像素尺寸。 -# 空串 / font 为 null / font_size <= 0 -> Vector2.ZERO(对齐参考端未建 text instance -# 时该子标签不参与 ArrangeTextTail 的宽度计算)。 -static func text_size(font: Font, text: String, font_size: int) -> Vector2: - if text == "" or font == null or font_size <= 0: - return Vector2.ZERO - return font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) - -# ArrangeTextTail 里各 iXxxWidth 取的就是 GetTextSize 的 w。 -# 有字体且量得正宽 -> 精确像素;否则(无字体 / 非法 size)-> 字符数 * fallback_char_px。 -# 空串恒 0(参考端无 text instance -> 该项不占宽)。 -static func width_of(font: Font, text: String, font_size: int, - fallback_char_px := FALLBACK_CHAR_W_PX) -> float: - if text == "": - return 0.0 - var w := text_size(font, text, font_size).x - if w > 0.0: - return w - return float(text.length()) * fallback_char_px - -# Label3D 便捷入口:从节点自身取字体(Label3D.font 未设时回落 ThemeDB.fallback_font) -# 和 font_size,量出该 Label3D 当前文本的像素宽。label 为 null -> 0。 -static func label_width(label: Label3D, fallback_char_px := FALLBACK_CHAR_W_PX) -> float: - if label == null: - return 0.0 - var f: Font = label.font - if f == null: - f = ThemeDB.fallback_font - return width_of(f, label.text, label.font_size, fallback_char_px) - -# GetTextSize 的 h:item 尾标框 yEnd = ySize(PythonTextTail.cpp:749 / :809)。单行文本的 -# ySize ≈ 字体行高(ascent + descent),与具体字形无关。空串恒 0(参考端无 text -# instance -> 不占高);有字体量得正高 -> 精确像素;否则(无字体 / 非法 size)-> fallback。 -static func height_of(font: Font, text: String, font_size: int, - fallback_line_px := FALLBACK_LINE_H_PX) -> float: - if text == "": - return 0.0 - var h := text_size(font, text, font_size).y - if h > 0.0: - return h - return fallback_line_px - -# Label3D 便捷入口:同 label_width,量该 Label3D 当前文本的像素高。label 为 null -> 0。 -static func label_height(label: Label3D, fallback_line_px := FALLBACK_LINE_H_PX) -> float: - if label == null: - return 0.0 - var f: Font = label.font - if f == null: - f = ThemeDB.fallback_font - return height_of(f, label.text, label.font_size, fallback_line_px) - -# 增量 125(seam ⑩ 末尾 SetItemTextTailOwner):owner 后缀在参考端是与名字分开的 -# pOwnerTextInstance,单独 GetTextSize。poc 只有一个合并 Label3D,故提供「用某 Label3D 的 -# 字体 / 字号量任意子串」入口,让 ground_items 把名字子串、owner 子串分别度量。 -# label 为 null -> 回退(宽 = 字符数 * fallback_char_px;高 = 空串 0 / 非空 fallback_line_px)。 -static func string_width_like(label: Label3D, text: String, - fallback_char_px := FALLBACK_CHAR_W_PX) -> float: - if label == null: - return float(text.length()) * fallback_char_px - var f: Font = label.font - if f == null: - f = ThemeDB.fallback_font - return width_of(f, text, label.font_size, fallback_char_px) - -static func string_height_like(label: Label3D, text: String, - fallback_line_px := FALLBACK_LINE_H_PX) -> float: - if text == "": - return 0.0 - if label == null: - return fallback_line_px - var f: Font = label.font - if f == null: - f = ThemeDB.fallback_font - return height_of(f, text, label.font_size, fallback_line_px) diff --git a/project/text_metrics.gd.uid b/project/text_metrics.gd.uid deleted file mode 100644 index 6f9175b4..00000000 --- a/project/text_metrics.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://2wi17v5m0v51 diff --git a/project/text_metrics_test.gd b/project/text_metrics_test.gd deleted file mode 100644 index 178bd5f7..00000000 --- a/project/text_metrics_test.gd +++ /dev/null @@ -1,141 +0,0 @@ -# text_metrics_test —— §8.8 seam ⑦ / ⑩:文本尺寸换真实字体度量(GetTextSize 1:1)。 -# text_size(空 / null / 非法 size -> ZERO;真实度量为正、随串长 / 字号单调)、 -# width_of(空串 0 / 无字体回退字符数*px / 有字体 == text_size.x / font_size<=0 回退)、 -# label_width(Label3D.font 未设回落 ThemeDB.fallback_font / null -> 0)、 -# height_of / label_height(增量 124:空串 0 / 无字体回退 15px / 有字体 == text_size.y / -# 与串内容无关随字号单调 / font_size<=0 回退)、 -# string_width_like / string_height_like(增量 125:量 label 字体下的任意子串 —— owner -# 子串独立度量;label null -> 回退字符数*px / 单行估高;空串 -> 0)。 -# godot --headless --path project --script text_metrics_test.gd -# 退出码 0 = 全过。 -extends SceneTree - -const TextMetrics = preload("res://text_metrics.gd") - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _feq(a: float, b: float, m: String) -> void: - _ck(absf(a - b) < 0.0001, "%s (got %f want %f)" % [m, a, b]) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: text_metrics_test (§8.8 seam ⑦/⑩ 文本宽 w 123 / 高 h 124 / owner 子串独立度量 125 换真实字体度量 GetTextSize 1:1)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # --- 0. 常量 ----------------------------------------------------------- - _feq(TextMetrics.FALLBACK_CHAR_W_PX, 7.0, "FALLBACK_CHAR_W_PX = 旧 SUBTAG/TAG_CHAR_W_PX 7.0") - - var font: Font = ThemeDB.fallback_font - - # --- 1. text_size 退化输入 -> Vector2.ZERO -------------------------- - _ck(TextMetrics.text_size(font, "", 16) == Vector2.ZERO, "空串 -> ZERO") - _ck(TextMetrics.text_size(null, "abc", 16) == Vector2.ZERO, "font 为 null -> ZERO") - _ck(TextMetrics.text_size(font, "abc", 0) == Vector2.ZERO, "font_size 0 -> ZERO") - _ck(TextMetrics.text_size(font, "abc", -4) == Vector2.ZERO, "font_size 负 -> ZERO") - - # --- 2. text_size 真实度量(有 fallback_font 时)------------------ - if font != null: - var w5 := TextMetrics.text_size(font, "MMMMM", 24).x - var w10 := TextMetrics.text_size(font, "MMMMMMMMMM", 24).x - _ck(w5 > 0.0, "非空串 @ 有效字号 -> 宽为正") - _ck(w10 > w5, "更长的串更宽(GetTextSize 逐字形累加)") - var wSmall := TextMetrics.text_size(font, "Test", 12).x - var wBig := TextMetrics.text_size(font, "Test", 48).x - _ck(wBig > wSmall, "同串更大字号更宽") - # width_of 有字体时应完全等于 text_size(...).x - _feq(TextMetrics.width_of(font, "Hello", 20), - TextMetrics.text_size(font, "Hello", 20).x, "width_of 有字体 == text_size.x") - else: - printerr("note: ThemeDB.fallback_font 为 null,跳过真实度量断言(仅测回退路径)") - - # --- 3. width_of 回退与边界 -------------------------------------------- - _feq(TextMetrics.width_of(font, "", 16), 0.0, "空串宽恒 0") - _feq(TextMetrics.width_of(null, "abcd", 16, 7.0), 28.0, "无字体 -> 字符数 * 7 = 28") - _feq(TextMetrics.width_of(null, "abcd", 16), 4.0 * TextMetrics.FALLBACK_CHAR_W_PX, - "无字体默认回退用 FALLBACK_CHAR_W_PX") - _feq(TextMetrics.width_of(font, "ab", 0, 5.0), 10.0, "有字体但 font_size 0 -> 回退 2*5") - _feq(TextMetrics.width_of(null, "x", 16, 3.0), 3.0, "单字符回退 = char_px") - - # --- 4. label_width(Label3D)--------------------------------------- - _ck(TextMetrics.label_width(null) == 0.0, "label 为 null -> 0") - - var lbl := Label3D.new() - lbl.text = "WWWW" - lbl.font_size = 24 - get_root().add_child(lbl) - var lw := TextMetrics.label_width(lbl, 7.0) - if font != null: - _ck(lw > 0.0, "Label3D.font 未设 -> 回落 ThemeDB.fallback_font 量得正宽") - _feq(lw, TextMetrics.text_size(ThemeDB.fallback_font, "WWWW", 24).x, - "label_width 用节点自身 text / font_size") - else: - _feq(lw, 4.0 * 7.0, "无 fallback_font -> label_width 回退字符数*px") - var lbl_empty := Label3D.new() - lbl_empty.text = "" - lbl_empty.font_size = 24 - get_root().add_child(lbl_empty) - _feq(TextMetrics.label_width(lbl_empty), 0.0, "空 Label3D 宽 0") - - # --- 5. height_of / label_height(增量 124:GetTextSize 的 h)-------- - _feq(TextMetrics.FALLBACK_LINE_H_PX, 15.0, "FALLBACK_LINE_H_PX = 旧 TAG_LINE_H_PX 15.0") - _feq(TextMetrics.height_of(font, "", 16), 0.0, "空串高恒 0") - _feq(TextMetrics.height_of(null, "abcd", 16, 15.0), 15.0, "无字体 -> 回退 15") - _feq(TextMetrics.height_of(null, "abcd", 16), TextMetrics.FALLBACK_LINE_H_PX, - "无字体默认回退用 FALLBACK_LINE_H_PX") - _feq(TextMetrics.height_of(font, "ab", 0, 9.0), 9.0, "有字体但 font_size 0 -> 回退 9") - if font != null: - var h_short := TextMetrics.height_of(font, "x", 20) - var h_long := TextMetrics.height_of(font, "WWWWWWWWWW", 20) - _ck(h_short > 0.0, "非空串 @ 有效字号 -> 高为正") - _feq(h_short, h_long, "单行高与串内容无关(≈ 字体行高)") - var h12 := TextMetrics.height_of(font, "Test", 12) - var h48 := TextMetrics.height_of(font, "Test", 48) - _ck(h48 > h12, "更大字号更高") - _feq(TextMetrics.height_of(font, "Hello", 20), - TextMetrics.text_size(font, "Hello", 20).y, "height_of 有字体 == text_size.y") - _ck(TextMetrics.label_height(null) == 0.0, "label 为 null -> 高 0") - if font != null: - _feq(TextMetrics.label_height(lbl, 15.0), - TextMetrics.text_size(ThemeDB.fallback_font, "WWWW", 24).y, - "label_height 用节点自身 text / font_size、回落 fallback_font") - else: - _feq(TextMetrics.label_height(lbl, 15.0), 15.0, "无 fallback_font -> label_height 回退 15") - _feq(TextMetrics.label_height(lbl_empty), 0.0, "空 Label3D 高 0") - - # --- 6. string_width_like / string_height_like(增量 125:owner 子串独立度量)------ - _feq(TextMetrics.string_width_like(null, "abcd", 7.0), 28.0, "label null -> 字符数 * 7") - _feq(TextMetrics.string_width_like(null, "abcd"), 4.0 * TextMetrics.FALLBACK_CHAR_W_PX, - "label null 默认回退用 FALLBACK_CHAR_W_PX") - _feq(TextMetrics.string_width_like(null, "", 7.0), 0.0, "label null + 空串 -> 0") - _feq(TextMetrics.string_height_like(null, "", 15.0), 0.0, "空串高恒 0(不占行)") - _feq(TextMetrics.string_height_like(lbl, ""), 0.0, "有 label + 空串 -> 0") - _feq(TextMetrics.string_height_like(null, "abcd", 15.0), 15.0, "label null + 非空 -> 回退 15") - _feq(TextMetrics.string_height_like(null, "abcd"), TextMetrics.FALLBACK_LINE_H_PX, - "label null 默认回退用 FALLBACK_LINE_H_PX") - if font != null: - # 量的是「给定子串」而非 label.text(lbl.text = "WWWW") - _feq(TextMetrics.string_width_like(lbl, "Hello"), - TextMetrics.text_size(ThemeDB.fallback_font, "Hello", 24).x, - "string_width_like 用 label 字号量给定子串(非 label.text)") - _feq(TextMetrics.string_height_like(lbl, "Hello"), - TextMetrics.text_size(ThemeDB.fallback_font, "Hello", 24).y, - "string_height_like == text_size(子串).y") - _ck(TextMetrics.string_width_like(lbl, "WWWWWWWW") > TextMetrics.string_width_like(lbl, "WW"), - "更长子串更宽") - else: - _feq(TextMetrics.string_width_like(lbl, "Hello", 7.0), 5.0 * 7.0, - "无 fallback_font -> string_width_like 回退字符数*px") - _feq(TextMetrics.string_height_like(lbl, "Hello", 15.0), 15.0, - "无 fallback_font -> string_height_like 回退单行估高") - - lbl.queue_free() - lbl_empty.queue_free() diff --git a/project/text_metrics_test.gd.uid b/project/text_metrics_test.gd.uid deleted file mode 100644 index 6c3b73c4..00000000 --- a/project/text_metrics_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://xn5klnex8db2 diff --git a/project/text_tail.gd b/project/text_tail.gd deleted file mode 100644 index a2f8a363..00000000 --- a/project/text_tail.gd +++ /dev/null @@ -1,127 +0,0 @@ -# text_tail.gd —— 头顶「称号 / 等级 / 公会名」子标签的 1:1 迁移(§8.8) -# -# 参考(REF/ = /Users/shenlei/Work/mt/40250/ClientVS22/source/): -# UserInterface/InstanceBaseEffect.cpp:609 GetTitleColor —— uGrade>=TITLE_NUM 时白,否则 g_akD3DXClrTitle[uGrade] -# UserInterface/InstanceBaseEffect.cpp:621 AttachTextTail —— fTextTailHeight = IsMountingHorse()?110:10;末尾 if(m_dwLevel) UpdateTextTailLevel -# UserInterface/InstanceBaseEffect.cpp:654 UpdateTextTailLevel —— s_kLevelColor = D3DXCOLOR(152/255,255/255,51/255,1);"Lv %d" -# UserInterface/InstanceBaseEffect.cpp:664 RefreshTextTail —— SetCharacterTextTailColor(GetNameColor()) + 按 AlignmentGrade attach/detach 称号 -# UserInterface/InstanceBaseEffect.cpp:1107 RegisterTitleName —— g_TitleNameMap[i] = c_szTitleName -# UserInterface/InstanceBaseEffect.cpp:1131 RegisterTitleColor —— g_akD3DXClrTitle[u] = __RGBToD3DXColoru(r,g,b) -# UserInterface/InstanceBase.h:262 TITLE_NUM = 9 / TITLE_NONE = 4 -# UserInterface/InstanceBase.cpp:2053 SetAlignment -> RefreshTextTailTitle -> RefreshTextTail -# UserInterface/PythonTextTail.cpp:18 c_TextTail_Guild_Name_Color = 0xFFEFD3FF -# UserInterface/PythonTextTail.cpp:23 bPKTitleEnable = TRUE(EnablePKTitle) -# UserInterface/PythonTextTail.cpp:509 RegisterCharacterTextTail —— dwGuildID!=0 才建公会名标签;GetGuildName 失败 -> "Noname" -# UserInterface/PythonTextTail.cpp:890 AttachTitle / :943 AttachLevel —— 均 if(!bPKTitleEnable) return -# assets/root/introloading.py:287 TITLE_COLOR_DICT(注册序 0..8) -# assets/root/colorInfo.py:29 TITLE_RGB_GOOD_4 .. TITLE_RGB_EVIL_4 -# assets/root/localeInfo.py:215 TITLE_NAME_LIST = (PVP_LEVEL0..PVP_LEVEL8,) -# assets/root/constinfo.py:4 PVPMODE_ENABLE = 1;game.py:155 textTail.EnablePKTitle(constInfo.PVPMODE_ENABLE) -# -# 纯静态。调用方(net_world.gd)把网络快照 self_e({alignment,level,guild,mount_vnum,...}) -# 传进来,再自己把 title_name_key / 公会名解析成本地化字符串并落到 Label3D 上。 -extends RefCounted - -const EntityRules = preload("res://entity_rules.gd") - -# InstanceBase.h 匿名枚举 -const TITLE_NUM := 9 -const TITLE_NONE := 4 - -const _WHITE := Color(1, 1, 1) - -# UpdateTextTailLevel 的 s_kLevelColor = D3DXCOLOR(152/255, 255/255, 51/255, 1) -const LEVEL_COLOR := Color(152.0 / 255.0, 255.0 / 255.0, 51.0 / 255.0) -# PythonTextTail.cpp:18 c_TextTail_Guild_Name_Color = 0xFFEFD3FF(ARGB -> RGB 239,211,255) -const GUILD_NAME_COLOR := Color(239.0 / 255.0, 211.0 / 255.0, 255.0 / 255.0) -# CPythonGuild::GetGuildName 失败时的兜底名(PythonTextTail.cpp:551) -const GUILD_NAME_FALLBACK := "Noname" - -# g_akD3DXClrTitle:introloading.py TITLE_COLOR_DICT 经 RegisterTitleColor -> __RGBToD3DXColoru -# 写进的 9 个槽(idx0 GOOD_4 .. idx8 EVIL_4)。Color8(r,g,b) == D3DXCOLOR(r/255,g/255,b/255)。 -static func _title_colors() -> Array: - return [ - Color8(0, 204, 255), # 0 GOOD_4 - Color8(0, 144, 255), # 1 GOOD_3 - Color8(92, 110, 255), # 2 GOOD_2 - Color8(155, 155, 255), # 3 GOOD_1 - Color8(255, 255, 255), # 4 NORMAL(= TITLE_NONE,实际不会 attach) - Color8(207, 117, 0), # 5 EVIL_1 - Color8(235, 83, 0), # 6 EVIL_2 - Color8(227, 0, 0), # 7 EVIL_3 - Color8(255, 0, 0), # 8 EVIL_4 - ] - -# --- EnablePKTitle / bPKTitleEnable(PythonTextTail.cpp:23 / :938)-------------- -# game.py:155 起始 EnablePKTitle(constInfo.PVPMODE_ENABLE),constinfo.py:4 PVPMODE_ENABLE = 1。 -static var _pk_title_enabled := true - -static func set_pk_title_enabled(enabled: bool) -> void: - _pk_title_enabled = enabled - -static func is_pk_title_enabled() -> bool: - return _pk_title_enabled - -# --- CInstanceBase::GetTitleColor(InstanceBaseEffect.cpp:609)—— 逐行 ----------- -# 入参是 GetAlignmentGrade() 的结果(0..8),越界(>=TITLE_NUM)返回白。 -static func title_color(grade: int) -> Color: - if grade < 0 or grade >= TITLE_NUM: - return _WHITE - return _title_colors()[grade] - -# introloading.py __RegisterTitleName:chrmgr.RegisterTitleName(i, TITLE_NAME_LIST[i]), -# TITLE_NAME_LIST[i] == localeInfo.PVP_LEVEL。调用方用它查 Locale 表。 -static func title_name_key(grade: int) -> String: - return "PVP_LEVEL%d" % grade - -# UpdateTextTailLevel:sprintf(szText, "Lv %d", level) -static func level_text(level: int) -> String: - return "Lv %d" % level - -# AttachTextTail:fTextTailHeight = IsMountingHorse() ? 110.0f : 10.0f -static func text_tail_height(mounted: bool) -> float: - return 110.0 if mounted else 10.0 - -# --- CInstanceBase::RefreshTextTail 的称号分支(InstanceBaseEffect.cpp:664)------- -# 返回 { show_title, grade, name_key, color }。 -# iAlignmentGrade = GetAlignmentGrade(); -# if (TITLE_NONE == iAlignmentGrade) DetachTitle(); -# else if (g_TitleNameMap.has(grade)) AttachTitle(name, GetTitleColor()); -# AttachTitle/DetachTitle 均 if(!bPKTitleEnable) return —— 关闭时参考端「冻结」上一态; -# 本移植取「隐藏」(show_title=false),差异见 §8.8 seam ⑦。 -static func refresh_title(alignment: int) -> Dictionary: - var grade := EntityRules.alignment_grade(alignment) - var out := { - "show_title": false, - "grade": grade, - "name_key": title_name_key(grade), - "color": title_color(grade), - } - if not _pk_title_enabled: - return out - if grade == TITLE_NONE: - return out - # g_TitleNameMap 注册了 0..8 全部(introloading.__RegisterTitleName),故只排除 TITLE_NONE。 - if grade < 0 or grade >= TITLE_NUM: - return out - out["show_title"] = true - return out - -# --- AttachTextTail 的等级分支(InstanceBaseEffect.cpp:621 末 + UpdateTextTailLevel) -# if (m_dwLevel) UpdateTextTailLevel(m_dwLevel);AttachLevel 亦 if(!bPKTitleEnable) return。 -static func refresh_level(level: int) -> Dictionary: - var show := _pk_title_enabled and level > 0 - return { - "show_level": show, - "text": level_text(level) if show else "", - "color": LEVEL_COLOR, - } - -# --- RegisterCharacterTextTail 的公会名分支(PythonTextTail.cpp:543)-------------- -# dwGuildID != 0 才建公会名标签;CPythonGuild::GetGuildName(dwGuildID) 失败 -> "Noname"。 -# resolved_name 由调用方经 client.get_guild_name(guild_id) 解出(空串 = 解析失败)。 -static func refresh_guild(guild_id: int, resolved_name: String) -> Dictionary: - if guild_id == 0: - return {"show_guild": false, "name": "", "color": GUILD_NAME_COLOR} - var nm := resolved_name if resolved_name != "" else GUILD_NAME_FALLBACK - return {"show_guild": true, "name": nm, "color": GUILD_NAME_COLOR} diff --git a/project/text_tail.gd.uid b/project/text_tail.gd.uid deleted file mode 100644 index 6018f39f..00000000 --- a/project/text_tail.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bmlnjr2tyws0o diff --git a/project/text_tail_arrange.gd b/project/text_tail_arrange.gd deleted file mode 100644 index 24fff3af..00000000 --- a/project/text_tail_arrange.gd +++ /dev/null @@ -1,105 +0,0 @@ -# text_tail_arrange —— §8.8 seam ⑩ 收口:地面物品名条「投影后互相挤开防重叠」1:1。 -# CPythonTextTail::ArrangeTextTail 的 m_ItemTextTailList 块(PythonTextTail.cpp:160-190): -# 逐条 item 尾标,与其它每条比 isIn(AABB 相交),命中就把自己顶到对方框底下 -# `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(yTemp = 5),内层循环从头重跑, -# LimitCount 上限 20 次防死循环。isIn(:489-507)是标准 AABB:两框在 x、y 都有交叠即真。 -# 尾标框(:810-813 等):xStart = -w/2-2、xEnd = w/2+2、yStart = -2、yEnd = h。 -# (与 name_color / text_tail / chat_tail / name_show / damage_effect / target_board / -# pick_show / name_tail_layout 同一套纯静态镜像套路,本模块是第 29 个 canonical。) -# -# REF(40250 ClientVS22 source/ 为唯一基线): -# UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f -# UserInterface/PythonTextTail.cpp:153 ArrangeTextTail() -# :160 for (pInsertTextTail : m_ItemTextTailList) —— 逐条 item 名条 -# :164 int yTemp = 5; int LimitCount = 0; -# :167 for (pCompareTextTail : m_ItemTextTailList) —— 内层比对 -# :171 if (*itorCompare == *itor) { ++itorCompare; continue; } —— 跳过自己 -# :176 if (LimitCount >= 20) break; -# :180 if (isIn(pInsertTextTail, pCompareTextTail)) -# :182 pInsertTextTail->y = pCompareTextTail->y + pCompareTextTail->yEnd + yTemp; -# :184 itorCompare = m_ItemTextTailList.begin(); ++LimitCount; continue; —— 从头重跑 -# :189 ++itorCompare; -# UserInterface/PythonTextTail.cpp:489 isIn(pSource, pTarget): -# 框 Source = [x+xStart, x+xEnd] × [y+yStart, y+yEnd],Target 同; -# :500 if (x1S <= x2T && x2S >= x1T && y1S <= y2T && y2S >= y1T) return true; -# UserInterface/PythonTextTail.cpp:810-813 尾标框(SetTextTailOwnerText 等): -# xStart = -xSize/2 - 2、yStart = -2、xEnd = xSize/2 + 2、yEnd = ySize(xSize/ySize = 文本像素尺寸) -# UserInterface/PythonTextTail.cpp:706-737 SetItemTextTailOwner(dwVID, c_szName):掉落名条 -# 挂 owner 后缀。owner 非空时 owner 走独立 CGraphicTextInstance(黄 1,1,0 / CENTER / 文本 -# = c_szName + strOwnership,IDS_POSSESSIVE_MORPHENE 为空取字面 "'s"),单独 GetTextSize: -# :735 pTextTail->yEnd += float(yOwnerSize + 4) —— owner 另起一行、行距 4 -# :736 pTextTail->xStart = fMIN(xStart, -xOwnerSize/2 - 1) -# :737 pTextTail->xEnd = fMAX(xEnd, xOwnerSize/2 + 1) -# owner 为空时删 owner 实例、框复位成纯名字 xStart=-w/2-2 / xEnd=w/2+2 / yEnd=h。 -# -# seam ⑩:参考端在屏幕像素空间跑(y 向下),poc 地面物品名条是每物一个 billboard Label3D。 -# 本模块保持屏幕像素语义(ground_items 把 tag 世界锚点投影到屏幕、按文本长度估框),算出 -# 每条被下推的屏幕 y 后,poc 侧按 name_tail_layout 的同一 pixel_size 约定折成世界 y 向下的 -# 位移(屏幕向下 Δpx → 世界向下 Δpx*pixel_size)。文本框尺寸按字符数估(参考端是 -# GetTextSize 精确像素)= seam ⑩ 近似;只对 item 名条做,角色名字尾标不参与(同参考)。 -extends RefCounted - -const PUSH_GAP := 5.0 # ArrangeTextTail :164 的 yTemp -const MAX_ITERS := 20 # :176 的 LimitCount 上限(单条最多被下推的次数) - -# 尾标框相对锚点 (x, y) 的四边(PythonTextTail.cpp:810-813)。屏幕坐标,y 向下。 -static func box_start_x(w: float) -> float: return -w / 2.0 - 2.0 -static func box_end_x(w: float) -> float: return w / 2.0 + 2.0 -static func box_start_y() -> float: return -2.0 -static func box_end_y(h: float) -> float: return h - -# SetItemTextTailOwner(PythonTextTail.cpp:706-737)代入 poc 的 {w, h} 框:掉落名条挂上 -# 「owner + 所有格语素」后,去重叠框在纯名字框(name_w / name_h)基础上按 owner 独立 -# 文本度量(owner_w / owner_h)扩张: -# :735 yEnd += ownerH + 4 -> h = name_h + owner_h + 4 -# :737 xEnd = fMAX(nameW/2+2, ownerW/2+1) -# box_end_x(w) = w/2 + 2,令它等于 fMAX 结果 -> w = max(name_w, owner_w - 2) -# (框 x 两边对称,box_start_x 自动 = 参考 :736 的 fMIN 结果,无需单独算)。 -# owner_w / owner_h <= 0(无 owner,参考端删 owner 实例、框复位纯名字)-> {name_w, name_h}。 -static func item_box_with_owner(name_w: float, name_h: float, - owner_w := 0.0, owner_h := 0.0) -> Dictionary: - if owner_w <= 0.0 or owner_h <= 0.0: - return {"w": name_w, "h": name_h} - return {"w": maxf(name_w, owner_w - 2.0), "h": name_h + owner_h + 4.0} - -# isIn(PythonTextTail.cpp:489-507):两条尾标框在屏幕上是否相交(含边界,<= / >=)。 -# a / b 为 {x, y, w, h}(x, y = 锚点屏幕坐标;w, h = 文本像素尺寸)。 -static func boxes_overlap(a: Dictionary, b: Dictionary) -> bool: - var ax1: float = a["x"] + box_start_x(a["w"]) - var ay1: float = a["y"] + box_start_y() - var ax2: float = a["x"] + box_end_x(a["w"]) - var ay2: float = a["y"] + box_end_y(a["h"]) - var bx1: float = b["x"] + box_start_x(b["w"]) - var by1: float = b["y"] + box_start_y() - var bx2: float = b["x"] + box_end_x(b["w"]) - var by2: float = b["y"] + box_end_y(b["h"]) - return ax1 <= bx2 and ax2 >= bx1 and ay1 <= by2 and ay2 >= by1 - -# ArrangeTextTail :160-190 的 item 块。tails: Array[{x, y, w, h}](屏幕坐标,y 向下), -# 列表顺序即参考端 m_ItemTextTailList 顺序。返回等长 Array[float]:每条排布后的屏幕 y -# (未重叠的保持原值)。逐条 pInsert 与其它每条 pCompare 比 isIn,命中就把 pInsert 顶到 -# (pCompare.y + pCompare.yEnd + PUSH_GAP),内层从头重跑,单条最多下推 MAX_ITERS 次。 -# pCompare 用的是「已排布后」的 y(与参考端共用一张链表、y 就地改写一致)。 -static func arrange_item_tails(tails: Array) -> Array: - var ys: Array = [] - for t in tails: - ys.append(float(t["y"])) - var n := tails.size() - for i in n: - var limit := 0 - var j := 0 - while j < n: - if j == i: - j += 1 - continue - if limit >= MAX_ITERS: - break - var a := {"x": tails[i]["x"], "y": ys[i], "w": tails[i]["w"], "h": tails[i]["h"]} - var b := {"x": tails[j]["x"], "y": ys[j], "w": tails[j]["w"], "h": tails[j]["h"]} - if boxes_overlap(a, b): - ys[i] = ys[j] + box_end_y(tails[j]["h"]) + PUSH_GAP - j = 0 - limit += 1 - continue - j += 1 - return ys diff --git a/project/text_tail_arrange.gd.uid b/project/text_tail_arrange.gd.uid deleted file mode 100644 index 24803d7f..00000000 --- a/project/text_tail_arrange.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d3v7hqq4gtouf diff --git a/project/text_tail_arrange_test.gd b/project/text_tail_arrange_test.gd deleted file mode 100644 index d45694c9..00000000 --- a/project/text_tail_arrange_test.gd +++ /dev/null @@ -1,152 +0,0 @@ -# text_tail_arrange_test —— §8.8 seam ⑩:ArrangeTextTail item 尾标去重叠 + isIn 1:1。 -# boxes_overlap(isIn AABB,含边界)、box_* 四边(-w/2-2 / w/2+2 / -2 / h)、 -# arrange_item_tails(单条不动 / 两条不叠不动 / 两条叠 -> 后者顶到前者框底下 / -# 级联下推 / 病态全同位收敛在 MAX_ITERS 内)、 -# item_box_with_owner(增量 125:无 owner 退化 / 有 owner -> h += ownerH + 4、 -# owner 更宽时 w = ownerW - 2 使 box_end_x == ownerW/2 + 1)。 -# godot --headless --path project --script text_tail_arrange_test.gd -# 退出码 0 = 全过。 -extends SceneTree - -const TextTailArrange = preload("res://text_tail_arrange.gd") - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _feq(a: float, b: float, m: String) -> void: - _ck(absf(a - b) < 0.0001, "%s (got %f want %f)" % [m, a, b]) - -func _init() -> void: - _run() - if _fail == 0: - print("PASS: text_tail_arrange_test (§8.8 seam ⑩ ArrangeTextTail item 尾标去重叠 + isIn 1:1)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # --- 0. 常量 ------------------------------------------------------------- - _feq(TextTailArrange.PUSH_GAP, 5.0, "PUSH_GAP = yTemp = 5(:164)") - _feq(TextTailArrange.MAX_ITERS, 20.0, "MAX_ITERS = LimitCount 上限 20(:176)") - - # --- 1. 框四边(PythonTextTail.cpp:810-813)---------------------------- - _feq(TextTailArrange.box_start_x(40.0), -40.0 / 2.0 - 2.0, "xStart = -w/2 - 2 = -22") - _feq(TextTailArrange.box_end_x(40.0), 40.0 / 2.0 + 2.0, "xEnd = w/2 + 2 = 22") - _feq(TextTailArrange.box_start_y(), -2.0, "yStart = -2") - _feq(TextTailArrange.box_end_y(16.0), 16.0, "yEnd = h = 16") - - # --- 2. boxes_overlap(isIn,:489-507)------------------------------- - var a := {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0} # x∈[78,122] y∈[98,116] - var b_hit := {"x": 110.0, "y": 105.0, "w": 40.0, "h": 16.0} # x∈[88,132] y∈[103,121] - _ck(TextTailArrange.boxes_overlap(a, b_hit), "x、y 都交叠 -> 相交") - _ck(TextTailArrange.boxes_overlap(b_hit, a), "isIn 对称") - # 边界相接:inclusive <= / >= -> 视为相交 - var b_edge := {"x": 100.0 + 44.0, "y": 100.0, "w": 40.0, "h": 16.0} # x1T = 144-22 = 122 == x2S - _ck(TextTailArrange.boxes_overlap(a, b_edge), "边界相接(x2S == x1T)含边界 -> 相交") - # x 完全分离 - var b_farx := {"x": 100.0 + 45.0, "y": 100.0, "w": 40.0, "h": 16.0} # x1T = 145-22 = 123 > 122 - _ck(not TextTailArrange.boxes_overlap(a, b_farx), "x 分离 -> 不相交") - # y 完全分离 - var b_fary := {"x": 100.0, "y": 100.0 + 40.0, "w": 40.0, "h": 16.0} # y1T = 140-2 = 138 > 116 - _ck(not TextTailArrange.boxes_overlap(a, b_fary), "y 分离 -> 不相交") - - # --- 3. arrange_item_tails --------------------------------------------- - # 单条:不动 - var one := [{"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}] - _feq(TextTailArrange.arrange_item_tails(one)[0], 100.0, "单条 -> y 不变") - - # 两条不叠:都不动 - var two_far := [ - {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, - {"x": 100.0, "y": 300.0, "w": 40.0, "h": 16.0}, - ] - var r_far := TextTailArrange.arrange_item_tails(two_far) - _feq(r_far[0], 100.0, "不叠 -> 第 1 条不变") - _feq(r_far[1], 300.0, "不叠 -> 第 2 条不变") - - # 两条叠:外层先处理 i=0 —— 与 j=1 相交,把「自己」(第 1 条)顶到第 2 条框底下。 - # ys[0] = ys[1] + yEnd(第2条=16) + PUSH_GAP(5) = 100 + 16 + 5 = 121;ys[1] 不变。 - # 再看 i=1:此时第 1 条已在 y=121(x∈[78,122] y∈[119,137]),第 2 条 y=100 - # (y∈[98,116])——115? 119 > 116 -> 不再相交,第 2 条不动。 - var two_hit := [ - {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, - {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, - ] - var r_hit := TextTailArrange.arrange_item_tails(two_hit) - _feq(r_hit[0], 100.0 + 16.0 + 5.0, "两条同位 -> 第 1 条被顶到 121") - _feq(r_hit[1], 100.0, "两条同位 -> 第 2 条留原位") - _ck(not TextTailArrange.boxes_overlap( - {"x": 100.0, "y": r_hit[0], "w": 40.0, "h": 16.0}, - {"x": 100.0, "y": r_hit[1], "w": 40.0, "h": 16.0}), - "排布后两框不再相交") - - # 三条同位。逐条各自移动自己(参考端每个 pInsert 只改自己的 y): - # i=0:与 1 撞 -> ys[0] = 100 + 16 + 5 = 121;与 2(100)此时已不撞(121 的框 y∈[119,137])。 - # i=1:与 2(100)撞 -> 121;再与已排好的 0(121)撞 -> 121 + 21 = 142。 - # i=2:0 在 121、1 在 142,都在 100 上方(y 更大 = 屏幕更下),100 的槽已空出 -> 第 3 条不动。 - # 结果 [121, 142, 100]:三框互不相交(100→[98,116]、121→[119,137]、142→[140,158])。 - var three := [ - {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, - {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, - {"x": 100.0, "y": 100.0, "w": 40.0, "h": 16.0}, - ] - var r3 := TextTailArrange.arrange_item_tails(three) - _feq(r3[0], 121.0, "三条同位 -> 第 1 条 121") - _feq(r3[1], 142.0, "三条同位 -> 第 2 条 142") - _feq(r3[2], 100.0, "三条同位 -> 第 3 条留原位(前两条已让开)") - # 两两不再相交 - for p in [[0, 1], [0, 2], [1, 2]]: - _ck(not TextTailArrange.boxes_overlap( - {"x": 100.0, "y": r3[p[0]], "w": 40.0, "h": 16.0}, - {"x": 100.0, "y": r3[p[1]], "w": 40.0, "h": 16.0}), - "三条排布后 %d/%d 不相交" % p) - - # 病态:很多条全同位 —— 单条被下推次数封顶 MAX_ITERS,函数必然返回(不死循环)。 - var many: Array = [] - for k in 40: - many.append({"x": 0.0, "y": 0.0, "w": 30.0, "h": 12.0}) - var rm := TextTailArrange.arrange_item_tails(many) - _ck(rm.size() == 40, "40 条全同位 -> 正常返回 40 个 y(未死循环)") - # 第 1 条只需 1 次下推就让开其余全部(它们都还在 y=0):0 + 12 + 5 = 17。 - _feq(rm[0], 17.0, "第 1 条一次下推到 17") - # 靠后的条目下推次数封顶 MAX_ITERS:每次至多 +(12 + 5)= 17 -> <= 340。 - for k in 40: - _ck(rm[k] <= (12.0 + 5.0) * TextTailArrange.MAX_ITERS + 0.001, - "第 %d 条下推量不超过 MAX_ITERS * (h + gap) = 340" % (k + 1)) - - # --- 4. 列表顺序即 m_ItemTextTailList 顺序:靠前的先让位(各自移动自己)------- - var ordered := [ - {"x": 50.0, "y": 200.0, "w": 20.0, "h": 10.0}, - {"x": 50.0, "y": 203.0, "w": 20.0, "h": 10.0}, - ] - var ro := TextTailArrange.arrange_item_tails(ordered) - _ck(ro[0] > ro[1], "靠前的条目被顶到靠后条目下方(参考端各 pInsert 移动自己)") - _feq(ro[1], 203.0, "靠后的条目保持原位") - - # --- 5. item_box_with_owner(SetItemTextTailOwner, PythonTextTail.cpp:706-737)------ - # 无 owner(owner_w / owner_h <= 0)-> 退化为纯名字框 - var b0 := TextTailArrange.item_box_with_owner(40.0, 16.0) - _feq(b0["w"], 40.0, "无 owner -> w = name_w") - _feq(b0["h"], 16.0, "无 owner -> h = name_h") - _feq(TextTailArrange.item_box_with_owner(40.0, 16.0, 0.0, 12.0)["h"], 16.0, "owner_w<=0 -> 退化") - _feq(TextTailArrange.item_box_with_owner(40.0, 16.0, 30.0, 0.0)["h"], 16.0, "owner_h<=0 -> 退化") - # 有 owner:yEnd += ownerH + 4(:735) - var bo := TextTailArrange.item_box_with_owner(40.0, 16.0, 30.0, 12.0) - _feq(bo["h"], 16.0 + 12.0 + 4.0, "有 owner -> h = name_h + owner_h + 4 = 32") - _feq(bo["w"], 40.0, "owner 比名字窄 -> w 仍 = name_w") - _feq(TextTailArrange.box_end_x(bo["w"]), 40.0 / 2.0 + 2.0, "owner 窄 -> box_end_x(w) == nameW/2 + 2") - # owner 比名字宽 -> box_end_x 收进 fMAX(nameW/2+2, ownerW/2+1)(:737)-> w = owner_w - 2 - var bw := TextTailArrange.item_box_with_owner(20.0, 16.0, 50.0, 12.0) - _feq(bw["w"], 50.0 - 2.0, "owner 更宽 -> w = owner_w - 2 = 48") - _feq(TextTailArrange.box_end_x(bw["w"]), 50.0 / 2.0 + 1.0, "-> box_end_x(w) == ownerW/2 + 1(参考 :737 fMAX)") - # 顶到带 owner 的框底下时 push = yEnd + PUSH_GAP - var stacked := [ - {"x": 0.0, "y": 0.0, "w": 20.0, "h": 16.0}, - {"x": 0.0, "y": 0.0, "w": bo["w"], "h": bo["h"]}, - ] - _feq(TextTailArrange.arrange_item_tails(stacked)[0], bo["h"] + TextTailArrange.PUSH_GAP, - "靠前条被顶到带 owner 框底下 = (name_h + owner_h + 4) + PUSH_GAP = 37") diff --git a/project/text_tail_arrange_test.gd.uid b/project/text_tail_arrange_test.gd.uid deleted file mode 100644 index d5d0a010..00000000 --- a/project/text_tail_arrange_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c4hgw5bk51xyv diff --git a/project/text_tail_test.gd b/project/text_tail_test.gd deleted file mode 100644 index ce91a9ea..00000000 --- a/project/text_tail_test.gd +++ /dev/null @@ -1,110 +0,0 @@ -# text_tail_test —— §8.8 头顶「称号 / 等级 / 公会名」子标签 1:1: -# GetTitleColor(g_akD3DXClrTitle)、RefreshTextTail 的 TITLE_NONE / 越界分支、 -# UpdateTextTailLevel 的 "Lv %d" + s_kLevelColor、RegisterCharacterTextTail 的 -# dwGuildID!=0 / "Noname" 兜底、AttachTextTail 的 110/10 高度、EnablePKTitle 开关。 -# godot --headless --path project --script text_tail_test.gd -# 退出码 0 = 全过。 -extends SceneTree - -const TextTail = preload("res://text_tail.gd") -const EntityRules = preload("res://entity_rules.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: text_tail_test (§8.8 GetTitleColor / RefreshTextTail / UpdateTextTailLevel 1:1)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - # --- 0. 常量(InstanceBase.h / PythonTextTail.cpp)------------------------ - _ck(TextTail.TITLE_NUM == 9, "TITLE_NUM == 9") - _ck(TextTail.TITLE_NONE == 4, "TITLE_NONE == 4") - _ck(TextTail.GUILD_NAME_FALLBACK == "Noname", "guild fallback == \"Noname\"") - # s_kLevelColor = D3DXCOLOR(152/255, 255/255, 51/255, 1) - _ck(TextTail.LEVEL_COLOR.is_equal_approx(Color8(152, 255, 51)), "LEVEL_COLOR == (152,255,51)") - # c_TextTail_Guild_Name_Color = 0xFFEFD3FF -> RGB (239,211,255) - _ck(TextTail.GUILD_NAME_COLOR.is_equal_approx(Color8(239, 211, 255)), "GUILD_NAME_COLOR == 0xEFD3FF") - - # --- 1. GetTitleColor:g_akD3DXClrTitle[grade](introloading TITLE_COLOR_DICT) - _ck(TextTail.title_color(0).is_equal_approx(Color8(0, 204, 255)), "title_color(0) == GOOD_4 (0,204,255)") - _ck(TextTail.title_color(1).is_equal_approx(Color8(0, 144, 255)), "title_color(1) == GOOD_3 (0,144,255)") - _ck(TextTail.title_color(2).is_equal_approx(Color8(92, 110, 255)), "title_color(2) == GOOD_2 (92,110,255)") - _ck(TextTail.title_color(3).is_equal_approx(Color8(155, 155, 255)), "title_color(3) == GOOD_1 (155,155,255)") - _ck(TextTail.title_color(4).is_equal_approx(Color8(255, 255, 255)), "title_color(4) == NORMAL (255,255,255)") - _ck(TextTail.title_color(5).is_equal_approx(Color8(207, 117, 0)), "title_color(5) == EVIL_1 (207,117,0)") - _ck(TextTail.title_color(6).is_equal_approx(Color8(235, 83, 0)), "title_color(6) == EVIL_2 (235,83,0)") - _ck(TextTail.title_color(7).is_equal_approx(Color8(227, 0, 0)), "title_color(7) == EVIL_3 (227,0,0)") - _ck(TextTail.title_color(8).is_equal_approx(Color8(255, 0, 0)), "title_color(8) == EVIL_4 (255,0,0)") - # uGrade >= TITLE_NUM -> 白(GetTitleColor 的 s_kD3DXClrTitleDefault) - _ck(TextTail.title_color(9).is_equal_approx(Color(1, 1, 1)), "title_color(9) -> white") - _ck(TextTail.title_color(-1).is_equal_approx(Color(1, 1, 1)), "title_color(-1) -> white") - - # --- 2. title_name_key / level_text / text_tail_height 字面 --------------- - _ck(TextTail.title_name_key(0) == "PVP_LEVEL0", "title_name_key(0) == PVP_LEVEL0") - _ck(TextTail.title_name_key(8) == "PVP_LEVEL8", "title_name_key(8) == PVP_LEVEL8") - _ck(TextTail.level_text(1) == "Lv 1", "level_text(1) == \"Lv 1\"") - _ck(TextTail.level_text(99) == "Lv 99", "level_text(99) == \"Lv 99\"") - _ck(TextTail.text_tail_height(true) == 110.0, "text_tail_height(mounted) == 110") - _ck(TextTail.text_tail_height(false) == 10.0, "text_tail_height(on foot) == 10") - - # --- 3. RefreshTextTail 称号分支(按 GetAlignmentGrade)------------------ - TextTail.set_pk_title_enabled(true) - # alignment 0 -> grade 4 == TITLE_NONE -> 不显示(DetachTitle) - var t_none := TextTail.refresh_title(0) - _ck(int(t_none["grade"]) == 4, "align 0 -> grade 4") - _ck(not t_none["show_title"], "grade TITLE_NONE -> show_title false") - # alignment 12000 -> grade 0 -> 显示 GOOD_4 - var t_good := TextTail.refresh_title(12000) - _ck(int(t_good["grade"]) == 0 and t_good["show_title"], "align 12000 -> grade 0, show_title true") - _ck(t_good["name_key"] == "PVP_LEVEL0", "grade 0 name_key == PVP_LEVEL0") - _ck(t_good["color"].is_equal_approx(Color8(0, 204, 255)), "grade 0 title colour GOOD_4") - # alignment -12001 -> grade 8 -> EVIL_4 - var t_evil := TextTail.refresh_title(-12001) - _ck(int(t_evil["grade"]) == 8 and t_evil["show_title"], "align -12001 -> grade 8, show_title true") - _ck(t_evil["color"].is_equal_approx(Color8(255, 0, 0)), "grade 8 title colour EVIL_4") - # grade 3(align 1000)显示,grade 5(align -1)显示 - _ck(TextTail.refresh_title(1000)["show_title"], "grade 3 shows title") - _ck(TextTail.refresh_title(-1)["show_title"], "grade 5 shows title") - - # --- 4. UpdateTextTailLevel / AttachLevel ------------------------------ - var lv := TextTail.refresh_level(42) - _ck(lv["show_level"] and lv["text"] == "Lv 42", "level 42 -> \"Lv 42\"") - _ck(lv["color"].is_equal_approx(Color8(152, 255, 51)), "level colour == s_kLevelColor") - # m_dwLevel 0 -> AttachTextTail 不调 UpdateTextTailLevel - _ck(not TextTail.refresh_level(0)["show_level"], "level 0 -> no level tag") - - # --- 5. RegisterCharacterTextTail 公会名分支 -------------------------- - # dwGuildID == 0 -> 不建标签 - _ck(not TextTail.refresh_guild(0, "Whatever")["show_guild"], "guild id 0 -> no guild tag") - # guild != 0 且解出名字 - var g_ok := TextTail.refresh_guild(7, "RedKnights") - _ck(g_ok["show_guild"] and g_ok["name"] == "RedKnights", "guild 7 -> resolved name") - _ck(g_ok["color"].is_equal_approx(Color8(239, 211, 255)), "guild name colour") - # guild != 0 但 GetGuildName 失败(空串)-> "Noname" - var g_no := TextTail.refresh_guild(7, "") - _ck(g_no["show_guild"] and g_no["name"] == "Noname", "guild name lookup fail -> \"Noname\"") - - # --- 6. EnablePKTitle(FALSE):AttachTitle / AttachLevel 早退 ------------ - TextTail.set_pk_title_enabled(false) - _ck(not TextTail.is_pk_title_enabled(), "pk title disabled") - _ck(not TextTail.refresh_title(12000)["show_title"], "pk-title off -> title suppressed") - _ck(not TextTail.refresh_level(42)["show_level"], "pk-title off -> level suppressed") - # 公会名不受 bPKTitleEnable 影响(RegisterCharacterTextTail 里没这判) - _ck(TextTail.refresh_guild(7, "RedKnights")["show_guild"], "pk-title off -> guild name still shown") - # 还原静态状态,避免污染同进程后续测试 - TextTail.set_pk_title_enabled(true) - _ck(TextTail.refresh_title(12000)["show_title"], "restore: pk title on again") - - # --- 7. grade 与 entity_rules.alignment_grade 一致 -------------------- - for a in [15000, 9000, 5000, 2000, 500, -500, -5000, -9000, -15000]: - _ck(int(TextTail.refresh_title(a)["grade"]) == EntityRules.alignment_grade(a), - "grade matches alignment_grade(%d)" % a) diff --git a/project/text_tail_test.gd.uid b/project/text_tail_test.gd.uid deleted file mode 100644 index c1de01de..00000000 --- a/project/text_tail_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bumeoghswlc5e diff --git a/project/ui/atlas_ui.gd b/project/ui/atlas_ui.gd deleted file mode 100644 index 62569604..00000000 --- a/project/ui/atlas_ui.gd +++ /dev/null @@ -1,327 +0,0 @@ -# AtlasUI (P9) —— 对齐 CPythonMiniMap 的 Atlas 窗口:大地图、拖动、玩家 -# 标记和坐标查询。地图底图复用每个区块的 minimap.dds;缺贴图时仍显示 -# 可交互的中性底图,便于无资源/打包环境继续使用坐标查询。 -# -# var atlas := preload("res://ui/atlas_ui.gd").new() -# atlas.setup(world, ui_manager, func() -> Node3D: return player, "map_a1") -# atlas.toggle() # M 键 -extends Node - -class AtlasOverlay extends Control: - var owner_ui: Node - func _draw() -> void: - if owner_ui: - owner_ui._draw_world_marks(self) - -const VIEW_SIZE := Vector2(720, 520) -const CELL_PIXELS := 128 -const MARK_SIZE := 8.0 - -var world: Node -var client: Node -var ui: CanvasLayer -var player_getter: Callable -var map_name := "" - -var _win: Control -var _map_view: Control -var _map: TextureRect -var _overlay: Control -var _player_mark: ColorRect -var _coord: Label -var _dragging := false -var _drag_origin := Vector2.ZERO -var _map_origin := Vector2.ZERO -var _map_px := Vector2.ZERO -var _world_size_m := Vector2(1, 1) -var _center_position_adjust := Vector2.ZERO -var _signal_points: Array[Vector2] = [] -var _touches: Dictionary = {} -var _touch_dragging := false -var _touch_drag_origin := Vector2.ZERO -var _touch_map_origin := Vector2.ZERO - -func setup(metin_world: Node, ui_manager: CanvasLayer, get_player: Callable, - name := "", m2client: Node = null) -> void: - world = metin_world - client = m2client - ui = ui_manager - player_getter = get_player - map_name = name if name != "" else "地图" - -func is_open() -> bool: - return _win != null and is_instance_valid(_win) - -func toggle() -> void: - if is_open(): - close() - else: - open() - -func open() -> void: - if is_open() or ui == null: - return - _build_window() - ui.open(_win, false) - _update_player_marker() - -func close() -> void: - if is_open() and ui: - ui.close(_win) - _win = null - _touches.clear() - _touch_dragging = false - -# CPythonMiniMap::AddSignalPoint also calls AddWayPoint(TYPE_WAYPOINT), so the -# same quest-script coordinate must remain visible in the Atlas overlay, not -# only on the circular minimap. -func add_signal_point(x: float, y: float) -> void: - _signal_points.append(Vector2(x, y)) - if _overlay: - _overlay.queue_redraw() - -func clear_signal_points() -> void: - _signal_points.clear() - if _overlay: - _overlay.queue_redraw() - -func signal_point_count() -> int: - return _signal_points.size() - -# CPythonMiniMap::SetAtlasCenterPosition ultimately calls the atlas window's -# SetCenterPositionAdjust. Keep the same screen-space adjustment instead of -# treating this event as a world-coordinate teleport. -func set_center_position_adjust(x: int, y: int) -> void: - _center_position_adjust = Vector2(x, y) - if _map_view and _map: - _map_origin = (_map_view.size - _map.size) * 0.5 + _center_position_adjust - _map.position = _map_origin - _overlay.position = _map.position - -func reload_map(metin_world: Node, name := "") -> void: - world = metin_world - if name != "": - map_name = name - if is_open(): - close() - open() - -func _build_window() -> void: - _win = Control.new() - _win.name = "AtlasWindow" - _win.set_anchors_preset(Control.PRESET_CENTER) - _win.position = -VIEW_SIZE * 0.5 - _win.size = VIEW_SIZE - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var style := StyleBoxFlat.new() - style.bg_color = Color(0.055, 0.065, 0.09, 0.97) - style.border_color = Color(0.4, 0.45, 0.58, 0.9) - style.set_border_width_all(1) - style.set_corner_radius_all(6) - panel.add_theme_stylebox_override("panel", style) - _win.add_child(panel) - var title := Label.new() - title.text = "Atlas · %s" % map_name - title.position = Vector2(14, 8) - title.add_theme_font_size_override("font_size", 15) - _win.add_child(title) - var close_btn := Button.new() - close_btn.text = "×" - close_btn.position = Vector2(VIEW_SIZE.x - 42, 5) - close_btn.size = Vector2(32, 28) - close_btn.pressed.connect(close) - _win.add_child(close_btn) - _coord = Label.new() - _coord.position = Vector2(14, VIEW_SIZE.y - 30) - _coord.text = "坐标:--" - _coord.add_theme_font_size_override("font_size", 12) - _win.add_child(_coord) - _map_view = Control.new() - _map_view.name = "MapView" - _map_view.position = Vector2(14, 42) - _map_view.size = Vector2(VIEW_SIZE.x - 28, VIEW_SIZE.y - 80) - _map_view.clip_contents = true - _map_view.mouse_filter = Control.MOUSE_FILTER_STOP - _map_view.gui_input.connect(_on_map_input) - _win.add_child(_map_view) - _overlay = AtlasOverlay.new() - _overlay.owner_ui = self - _overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE - _map_view.add_child(_overlay) - _build_map() - -func _build_map() -> void: - var tiles := Vector2i(1, 1) - if world and world.has_method("get_map_size_tiles"): - tiles = world.get_map_size_tiles() - tiles.x = maxi(1, tiles.x) - tiles.y = maxi(1, tiles.y) - _world_size_m = Vector2(tiles.x * 256.0, tiles.y * 256.0) - _map_px = Vector2(tiles.x * CELL_PIXELS, tiles.y * CELL_PIXELS) - var image := Image.create_empty(int(_map_px.x), int(_map_px.y), false, Image.FORMAT_RGBA8) - image.fill(Color(0.12, 0.14, 0.18, 1.0)) - var loaded := 0 - for tx in tiles.x: - for ty in tiles.y: - if not world or not world.has_method("chunk_dir") or not world.has_method("load_dds"): - continue - var path: String = world.chunk_dir(tx, ty).path_join("minimap.dds") - if not FileAccess.file_exists(path): - continue - var tile: Image = world.load_dds(path) - if tile == null: - continue - tile.resize(CELL_PIXELS, CELL_PIXELS, Image.INTERPOLATE_BILINEAR) - image.blit_rect(tile, Rect2i(Vector2i.ZERO, Vector2i(CELL_PIXELS, CELL_PIXELS)), - Vector2i(tx * CELL_PIXELS, ty * CELL_PIXELS)) - loaded += 1 - var tex := ImageTexture.create_from_image(image) - _map = TextureRect.new() - _map.name = "AtlasTexture" - _map.texture = tex - _map.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _map.stretch_mode = TextureRect.STRETCH_SCALE - _map.size = _map_px - _map.mouse_filter = Control.MOUSE_FILTER_IGNORE - _map_origin = (_map_view.size - _map_px) * 0.5 + _center_position_adjust - _map.position = _map_origin - _map_view.add_child(_map) - _overlay.position = _map.position - _overlay.size = _map.size - _player_mark = ColorRect.new() - _player_mark.name = "PlayerMark" - _player_mark.color = Color(1.0, 0.85, 0.15, 1.0) - _player_mark.size = Vector2(MARK_SIZE, MARK_SIZE) - _player_mark.mouse_filter = Control.MOUSE_FILTER_IGNORE - _overlay.add_child(_player_mark) - _overlay.move_to_front() - if loaded == 0: - _coord.text = "坐标:--(缺 minimap.dds,仍可查询)" - -func _on_map_input(event: InputEvent) -> void: - if event is InputEventScreenTouch: - var touch := event as InputEventScreenTouch - if touch.pressed: - _touches[touch.index] = touch.position - if _touches.size() == 1: - _touch_dragging = true - _touch_drag_origin = touch.position - _touch_map_origin = _map.position - else: - # A second finger is a gesture boundary. Do not continue the - # first finger's drag while pinch/scroll state is ambiguous. - _touch_dragging = false - else: - _touches.erase(touch.index) - if _touches.is_empty(): - _touch_dragging = false - elif _touches.size() == 1: - # Re-arm panning for the remaining finger without jumping the map. - var remaining := int(_touches.keys()[0]) - _touch_dragging = true - _touch_drag_origin = _touches[remaining] - _touch_map_origin = _map.position - _map_view.accept_event() - return - if event is InputEventScreenDrag: - var drag := event as InputEventScreenDrag - if _touches.has(drag.index): - _touches[drag.index] = drag.position - if _touches.size() == 1 and _touch_dragging and _touches.has(drag.index): - _map.position = _touch_map_origin + drag.position - _touch_drag_origin - _overlay.position = _map.position - _map_view.accept_event() - return - if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: - _dragging = event.pressed - if _dragging: - _drag_origin = event.position - _map_origin = _map.position - _map_view.accept_event() - elif event is InputEventMouseMotion and _dragging: - _map.position = _map_origin + event.position - _drag_origin - _overlay.position = _map.position - _map_view.accept_event() - elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: - _recenter_map() - _map_view.accept_event() - -func _recenter_map() -> void: - _map_origin = (_map_view.size - _map.size) * 0.5 + _center_position_adjust - _map.position = _map_origin - _overlay.position = _map.position - -func _process(_dt: float) -> void: - if is_open(): - _update_player_marker() - _overlay.queue_redraw() - -func _player() -> Node3D: - return player_getter.call() if player_getter.is_valid() else null - -func _update_player_marker() -> void: - if _player_mark == null or not is_instance_valid(_player_mark): - return - var p := _player() - if p == null: - return - var world_pos: Vector3 = p.global_position - var px := Vector2(world_pos.x / _world_size_m.x * _map_px.x, - world_pos.z / _world_size_m.y * _map_px.y) - # Marker is a child of the overlay, whose origin tracks the draggable map. - _player_mark.position = px - Vector2(MARK_SIZE, MARK_SIZE) * 0.5 - var server_cm := MapCoord.to_server_cm(world_pos) - _coord.text = "坐标:X %d Y %d" % [roundi(server_cm.x), roundi(server_cm.y)] - -func _world_to_px(world_pos: Vector3) -> Vector2: - return Vector2(world_pos.x / _world_size_m.x * _map_px.x, - world_pos.z / _world_size_m.y * _map_px.y) - -func _draw_world_marks(canvas: CanvasItem) -> void: - if client == null: - return - if client.has_method("get_party") and client.has_method("get_entity"): - for member in client.get_party(): - var vid := int(member.get("vid", 0)) - if vid == 0: - continue - var entity: Dictionary = client.get_entity(vid) - if entity.is_empty(): - continue - var party_pos := _world_to_px(MapCoord.to_world(entity.get("pos", Vector3.ZERO))) - canvas.draw_circle(party_pos, 5.0, Color(0.35, 1.0, 0.55, 0.95)) - if bool(member.get("leader", false)): - canvas.draw_arc(party_pos, 8.0, 0.0, TAU, 20, Color(1.0, 0.9, 0.25, 0.95), 2.0) - # Guild areas arrive in global server centimetres, matching GC_LAND_LIST. - if client.has_method("get_land_areas"): - for area in client.get_land_areas(): - var x := float(area.get("x", 0)) - var y := float(area.get("y", 0)) - var w := float(area.get("width", 0)) - var h := float(area.get("height", 0)) - var p0 := _world_to_px(MapCoord.to_world(Vector3(x * 0.01, 0, -y * 0.01))) - var p1 := _world_to_px(MapCoord.to_world(Vector3((x + w) * 0.01, 0, -(y + h) * 0.01))) - var rect := Rect2(Vector2(min(p0.x, p1.x), min(p0.y, p1.y)), - Vector2(abs(p1.x - p0.x), abs(p1.y - p0.y))) - var col := Color(0.35, 0.95, 0.95, 0.8) if int(area.get("guild_id", 0)) == 0 \ - else Color(1.0, 0.75, 0.2, 0.9) - canvas.draw_rect(rect, col, false, 2.0) - # Observer points are exposed in the same network/Godot frame as entities. - if client.has_method("get_observers"): - for observer in client.get_observers(): - var p: Vector3 = observer.get("pos", Vector3.ZERO) - var op := _world_to_px(MapCoord.to_world(p)) - canvas.draw_colored_polygon(PackedVector2Array([op + Vector2(0, -6), op + Vector2(6, 0), - op + Vector2(0, 6), op + Vector2(-6, 0)]), Color(0.8, 0.35, 1.0, 0.95)) - # Quest/warp markers remain visible when the Atlas is used for navigation. - for signal_point in _signal_points: - var signal_world := MapCoord.to_world(Vector3(signal_point.x * 0.01, 0, - -signal_point.y * 0.01)) - var signal_px := _world_to_px(signal_world) - canvas.draw_circle(signal_px, 6.0, Color(1.0, 0.75, 0.15, 0.95)) - canvas.draw_arc(signal_px, 10.0, 0.0, TAU, 20, Color(1.0, 0.4, 0.1, 0.9), 2.0) - if client.has_method("get_world_markers"): - for marker in client.get_world_markers(): - var mp := _world_to_px(MapCoord.to_world(marker.get("pos", Vector3.ZERO))) - canvas.draw_rect(Rect2(mp - Vector2(4, 4), Vector2(8, 8)), Color(1.0, 0.55, 0.1, 0.95)) diff --git a/project/ui/atlas_ui.gd.uid b/project/ui/atlas_ui.gd.uid deleted file mode 100644 index d549fa88..00000000 --- a/project/ui/atlas_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://xm8a0g4337da diff --git a/project/ui/char_select_screen.gd b/project/ui/char_select_screen.gd deleted file mode 100644 index 58809b03..00000000 --- a/project/ui/char_select_screen.gd +++ /dev/null @@ -1,1038 +0,0 @@ -# CharSelectScreen —— Metin2 风格的选人界面。 -# -# var s := preload("res://ui/char_select_screen.gd").new() -# parent.add_child(s) -# s.setup(m2client, assets_root, chars) # chars = M2Client.char_list 数组 -# s.select_requested.connect(func(idx): client.select_character(idx)) -# s.back_requested.connect(_goto_login) -# -# 组成:全屏背景(select.jpg)+ 右侧 3D 角色(SubViewport 里的 PlayerView,缓转 + wait) -# + 左侧信息板(thinboard:帮会徽 / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷) -# + 左上职业名(locale name_.dds,解不出就用中文描边字)+ 底部 开始/创建/删除/退出。 -# 所有素材都是可选的:缺哪个降级哪个,绝不崩。 -extends Control - -const UiAssets = preload("res://ui/ui_assets.gd") -const UiKit = preload("res://ui_kit.gd") -const PlayerView = preload("res://ui/player_view.gd") -const EquipModel = preload("res://ui/equip_model.gd") -const Audio = preload("res://audio.gd") -const EffectRegistry = preload("res://fx/effect_registry.gd") -const DESIGN_SIZE := Vector2(1472, 854) - -signal select_requested(index: int) -signal delete_requested(index: int, private_code: String) -signal create_requested(spec: Dictionary) # {slot, name, job, shape, con, int, str, dex} -signal change_name_requested(index: int, name: String) -signal back_requested() - -const CLASS_CN := ["猛将", "刺客", "术士", "巫女"] -const CLASS_DIR := ["warrior", "assassin", "sura", "shaman"] -# 建号默认四维(CON, INT, STR, DEX)—— 对齐经典 Metin2 各职业起始属性。 -const JOB_BASE_STATS := { - 0: {"con": 4, "int": 3, "str": 6, "dex": 3}, # warrior - 1: {"con": 3, "int": 3, "str": 4, "dex": 6}, # assassin - 2: {"con": 3, "int": 5, "str": 5, "dex": 3}, # sura - 3: {"con": 4, "int": 6, "str": 3, "dex": 3}, # shaman -} -# §1.10:40250 的角色名上限是网络字段的 24 个 GB2312 字节,而不是 -# Godot/UTF-8 输入的 24 个字节。一个常用汉字在 GB2312 中占 2 字节,所以 -# 12 个汉字可以合法放入角色名字段;直接按 UTF-8 字节限制会把它错误拒绝。 -# 输入框只限制 Unicode 字符数量,最终的 GB2312 可表示性和 wire 字节容量由 -# M2Client/ClassicSession 在真正发包前统一校验。 -const CHAR_NAME_MAX_CHARS := 24 -const CHAR_NAME_MIN_CHARS := 2 - -const EMPIRE_KEY := {1: "EMPIRE_A", 2: "EMPIRE_B", 3: "EMPIRE_C"} -const EMPIRE_FALLBACK := {0: "—", 1: "神獸王國", 2: "天朝王國", 3: "眞魔王國"} -const EMPIRE_FLAG := {1: "a", 2: "b", 3: "c"} -const STAT_ROWS := [ - {"key": "ht", "label": "体力", "color": Color(0.90, 0.30, 0.30)}, - {"key": "iq", "label": "智力", "color": Color(0.85, 0.45, 0.95)}, - {"key": "st", "label": "力量", "color": Color(0.70, 0.55, 1.00)}, - {"key": "dx", "label": "敏捷", "color": Color(0.35, 0.75, 1.00)}, -] - -var _client: Node -var _assets := "" -var _chars: Array = [] -var _sel := 0 - -var _viewport: SubViewport -var _pivot: Node3D -var _cam: Camera3D -var _pv: Node # PlayerView -var _panel_box: VBoxContainer -var _class_img: TextureRect -var _class_lbl: Label -var _status: Label -var _slot_lbl: Label -var _last_empire := -1 -var _rename_pending := false -var _layout: Control -var _board: Control -var _proto: Node -var _audio: Node -var _fxr: RefCounted -var _starting := false -var _btn_start: Button -var _btn_create: Button -var _btn_delete: Button -var _btn_quit: Button -var _nav_buttons: Array[TextureButton] = [] - -func setup(client: Node, assets_root: String, chars: Array) -> void: - _client = client - _assets = assets_root - _chars = _pad_slots(chars) - _audio = Audio.new() - _audio.setup(_assets) - add_child(_audio) - _fxr = EffectRegistry.new() - _fxr.setup(_assets) - set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - _build_background() - _build_stage() - _layout = Control.new() - _layout.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_layout) - _build_class_name() - _build_panel() - _build_navigation() - _build_status() - resized.connect(_resize_layout) - _resize_layout() - _last_empire = _empire_id() - set_process(true) - _select(_first_occupied()) - -# char_list 只带占位了角色的槽;补齐到 slot_count 个(空槽 name=""),◀▶ 能走到空槽建号。 -func _pad_slots(chars: Array) -> Array: - var n := 3 - if _client and _client.has_method("get_slot_count"): - n = maxi(int(_client.get_slot_count()), chars.size()) - n = maxi(n, chars.size()) - var by_idx := {} - for c in chars: - by_idx[int(c.get("index", 0))] = c - var out: Array = [] - for i in n: - out.append(by_idx.get(i, {"index": i, "name": "", "job": 0, "level": 0})) - return out - -func _first_occupied() -> int: - for i in _chars.size(): - if String(_chars[i].get("name", "")) != "": - return i - return 0 - -# §1.10:角色名校验("" = 合法)。创建与改名共用同一实现。 -func _validate_char_name(nm: String) -> String: - if nm.length() < CHAR_NAME_MIN_CHARS: - return "名称太短" - if nm.length() > CHAR_NAME_MAX_CHARS: - return "名称太长(最多 %d 个字符)" % CHAR_NAME_MAX_CHARS - for ch in nm: - if ch.unicode_at(0) < 32: - return "名称包含非法字符" - return "" - -# 由 app_flow 在收到新的 char_list(建/删号后 M2Client 重发)时调用,保持当前选中槽。 -func set_chars(chars: Array) -> void: - var keep := _slot_index() - _chars = _pad_slots(chars) - for i in _chars.size(): - if int(_chars[i].get("index", -1)) == keep: - _sel = i - break - _sel = clampi(_sel, 0, maxi(_chars.size() - 1, 0)) - _refresh_class_name() - _refresh_panel() - _rebuild_model() - -func on_char_created(slot: int) -> void: - _close_dialog() - _set_status("角色已创建(槽位 %d)" % slot) - -func on_char_create_failed(reason_type: int) -> void: - if _rename_pending: - _rename_pending = false - const RENAME_R := {0: "名称不合法", 1: "该名称已被使用", 100: "角色槽位无效"} - _set_status("改名失败:%s" % RENAME_R.get(reason_type, "错误码 %d" % reason_type)) - return - const R := {1: "该名称已被使用", 2: "名称不合法", 3: "该槽位已有角色", 4: "权限不足"} - _set_status("创建失败:%s" % R.get(reason_type, "错误码 %d" % reason_type)) - -func on_char_name_send_failed() -> void: - _rename_pending = false - _set_status("改名请求发送失败") - -func on_char_name_changed(pid: int, name: String) -> void: - var matched := false - for c in _chars: - if int(c.get("id", 0)) == pid: - matched = true - c["name"] = name - c["change_name"] = false - if not matched and _rename_pending: - return - _rename_pending = false - _close_dialog() - _refresh_class_name() - _refresh_panel() - _set_status("角色已改名:%s" % name) - -func on_char_deleted(slot: int) -> void: - _close_dialog() - _set_status("角色已删除(槽位 %d)" % slot) - -func on_char_delete_failed() -> void: - var e: Node = _dialog_node() - if e: - var code := e.find_child("Code", true, false) - if code is LineEdit: - (code as LineEdit).text = "" - _set_status("删除失败:删除码(社交号)不正确") - -# --- 背景 --------------------------------------------------------------------- - -func _add_background(parent: Node) -> void: - var tex := UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/select/select.sub") - var cr := ColorRect.new() - cr.color = Color(0.05, 0.06, 0.07) - cr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - cr.mouse_filter = Control.MOUSE_FILTER_IGNORE - parent.add_child(cr) - if tex != null: - var tr := TextureRect.new() - tr.texture = tex - tr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - # The original stretches the 1024x768 atlas region to the window. - tr.stretch_mode = TextureRect.STRETCH_SCALE - tr.mouse_filter = Control.MOUSE_FILTER_IGNORE - parent.add_child(tr) - -func _build_background() -> void: - _add_background(self) - -# --- 右侧 3D 角色 ----------------------------------------------------------- - -func _build_stage() -> void: - var vpc := SubViewportContainer.new() - vpc.stretch = true - vpc.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - vpc.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(vpc) - - _viewport = SubViewport.new() - # Refine effects use D3D-style SRC_ALPHA/ONE additive blending. Rendering - # them into a transparent texture first makes their black RGB texels increase - # the render target alpha; the later CanvasItem alpha composite then turns - # those invisible texels into large black rectangles. 40250 draws the select - # background before the character/effects, so reproduce that order inside an - # opaque viewport instead of trying to encode additive light in straight RGBA. - _viewport.transparent_bg = false - _viewport.own_world_3d = true - _viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS - _viewport.msaa_3d = Viewport.MSAA_4X - vpc.add_child(_viewport) - - var background_canvas := CanvasLayer.new() - background_canvas.layer = -1 - _viewport.add_child(background_canvas) - var background_root := Control.new() - background_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - background_root.mouse_filter = Control.MOUSE_FILTER_IGNORE - background_canvas.add_child(background_root) - _add_background(background_root) - - var root := Node3D.new() - _viewport.add_child(root) - - var key := DirectionalLight3D.new() - key.rotation_degrees = Vector3(-32, 28, 0) - key.light_energy = 0.9 - key.light_color = Color(1.0, 0.96, 0.90) - root.add_child(key) - var rim := DirectionalLight3D.new() - rim.rotation_degrees = Vector3(-6, 200, 0) - rim.light_energy = 0.25 - rim.light_color = Color(0.75, 0.83, 1.0) - root.add_child(rim) - - var we := WorldEnvironment.new() - var e := Environment.new() - # Canvas layer -1 is the stage backdrop and is rendered before 3D. Parent UI - # remains outside the SubViewport and is drawn afterward as before. - e.background_mode = Environment.BG_CANVAS - e.background_canvas_max_layer = -1 - e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - e.ambient_light_color = Color(0.8, 0.8, 0.8) - e.ambient_light_energy = 0.65 - e.tonemap_mode = Environment.TONE_MAPPER_LINEAR - we.environment = e - root.add_child(we) - - _pivot = Node3D.new() - # Animation sampling no longer reapplies the exported InitialPlacement; - # the intro pose already faces the camera without a quarter-turn correction. - _pivot.rotation.y = 0.0 - root.add_child(_pivot) - - _cam = Camera3D.new() - _cam.fov = 10.0 - _cam.position = Vector3(0.0, 1.0, 4.0) - root.add_child(_cam) - _cam.make_current() - var listener := AudioListener3D.new() - listener.make_current() - _cam.add_child(listener) - -# 相机按模型包围球自适应:球心 + 半径/sin(fovV/2) 保证整模无论姿势都不出框, -# 再沿 -X 平移“半径的一小截”把人物推到右半屏(横向视锥比纵向宽,不会切到远侧手)。 -func _frame_model() -> void: - if _cam == null or _pv == null or not is_instance_valid(_pv): - return - var aabb := AABB() - var first := true - # GPU skinning happens in the vertex shader, so MeshInstance3D's mesh AABB - # remains in bind pose. Prefer the extension's matching deformed bounds for - # the main body; accessories continue through their own mesh bounds below. - if _pv.model and _pv.model.has_method("get_visual_aabb"): - var posed: AABB = _pv.model.call("get_visual_aabb") - if posed.size.length_squared() > 0.0: - aabb = (_pv.model as Node3D).global_transform * posed - first = false - for mi in (_pv as Node).find_children("*", "MeshInstance3D", true, false): - var m := mi as MeshInstance3D - if m.mesh == null: - continue - if m.get_parent() == _pv.model and m.name == "MeshInstance3D": - continue # already covered by Metin2Model.get_visual_aabb() - var wb: AABB = m.get_global_transform() * m.get_aabb() - aabb = wb if first else aabb.merge(wb) - first = false - if first: - return - var height := maxf(aabb.size.y, 1.0) - # Reference: head at 14%, feet at 85%, center at 59% of the window. - var view_height := height / 0.71 - var dist := view_height / (2.0 * tan(deg_to_rad(_cam.fov * 0.5))) - var aspect := size.x / maxf(size.y, 1.0) - var target := aabb.get_center() - target.x -= view_height * aspect * 0.09 - target.y -= height * 0.007 - _cam.position = target + Vector3(0, dist * sin(deg_to_rad(6.0)), dist * cos(deg_to_rad(6.0))) - _cam.look_at(target, Vector3.UP) - -# Metin2AnimPlayer enables the optional GPU skin path on its first animation -# sample. Its material/mesh swap changes the MeshInstance AABB after the -# initial CPU framing pass, so frame again after the extension confirms it. -func _on_model_visual_bounds_changed(pv: Node) -> void: - if pv == _pv and is_instance_valid(pv): - call_deferred("_frame_model") - -func _rebuild_model() -> void: - if _pivot == null: - return - if _pv != null and is_instance_valid(_pv): - _pv.hide() - _pv.queue_free() - _pv = null - var c := _cur() - if c.is_empty() or String(c.get("name", "")) == "": - return - var job := clampi(int(c.get("job", 0)), 0, 7) - var pump: Callable = _client.net_poll if _client and _client.has_method("net_poll") else Callable() - var pv := PlayerView.new() - _pivot.add_child(pv) - if pv.build(_assets, job, pump): - _pv = pv - pv._audio = _audio - if pv.model and pv.model.has_signal("visual_bounds_changed"): - pv.model.visual_bounds_changed.connect(_on_model_visual_bounds_changed.bind(pv)) - if _proto == null and ClassDB.class_exists("Metin2Proto"): - _proto = ClassDB.instantiate("Metin2Proto") - add_child(_proto) - var ip := AssetRoot.locale_file("item_proto") - if ip != "": - _proto.call("load_item_proto", ip) - var equip := EquipModel.new() - pv.add_child(equip) - equip.setup_remote(null, func() -> Node: return pv, _assets, job, - [int(c.get("main_part", 0)), 0, 0, int(c.get("hair_part", 0))], _proto) - # Expansion armor textures live beside their GR2, outside the base PC pack. - var body_path := String(pv.model.get("gr2_path")) - pv.model.set("texture_dir", body_path.get_base_dir()) - pv.model.set("lod_enabled", false) - pv.model.set("flip_winding", true) - pv._ground_model() - var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa") - if pv.anim and FileAccess.file_exists(intro): - pv.anim.set("anim_path", intro) - pv.anim.set("loop", true) - # 蒙皮 / LOD 定型要几帧,多 fit 两次取稳定包围盒 - for i in 4: - await get_tree().process_frame - if pv != _pv: - return - _frame_model() - else: - pv.queue_free() - -# --- 左上职业名 ----------------------------------------------------------- - -func _build_class_name() -> void: - _class_img = TextureRect.new() - _class_img.position = Vector2(92, 164) - _class_img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT - _class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE - _layout.add_child(_class_img) - - _class_lbl = Label.new() - _class_lbl.position = Vector2(120, 180) - _class_lbl.add_theme_font_size_override("font_size", 84) - _class_lbl.add_theme_color_override("font_color", Color(0.86, 0.11, 0.09)) - _class_lbl.add_theme_color_override("font_outline_color", Color(0.15, 0.05, 0.02)) - _class_lbl.add_theme_constant_override("outline_size", 10) - _class_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - _layout.add_child(_class_lbl) - -func _refresh_class_name() -> void: - var job := int(_cur().get("job", 0)) & 3 - var has_char := not _cur().is_empty() and String(_cur().get("name", "")) != "" - var tex: Texture2D = null - if has_char: - var cn: String = CLASS_DIR[job] - for loc in ["en", "de", "tr", "gr", "ru"]: - tex = UiAssets.load_tex(_assets, "locale/locale/%s/ui/select/name_%s.sub" % [loc, cn]) - if tex != null: - break - _class_img.texture = tex - _class_img.visible = tex != null - _class_lbl.visible = tex == null and has_char - _class_lbl.text = CLASS_CN[job] if has_char else "" - -# --- 左侧信息板 -------------------------------------------------------------- - -func _build_panel() -> void: - var board := UiKit.board(_assets, "thinboard", 16, 16) - _board = board - board.position = Vector2(119, 313) - board.size = Vector2(208, 323) - board.mouse_filter = Control.MOUSE_FILTER_IGNORE - _layout.add_child(board) - # 板底纹太透就垫一层 - var tint := ColorRect.new() - tint.color = Color(0.03, 0.05, 0.06, 0.62) - tint.position = board.position + Vector2(10, 10) - tint.size = board.size - Vector2(20, 20) - tint.mouse_filter = Control.MOUSE_FILTER_IGNORE - _layout.add_child(tint) - - _panel_box = VBoxContainer.new() - _panel_box.position = board.position + Vector2(14, 12) - _panel_box.custom_minimum_size = Vector2(180, 0) - _panel_box.add_theme_constant_override("separation", 6) - _layout.add_child(_panel_box) - -func _refresh_panel() -> void: - for c in _panel_box.get_children(): - _panel_box.remove_child(c) - c.queue_free() - var d := _cur() - var has_char := not d.is_empty() and String(d.get("name", "")) != "" - - # 帮会徽 + 帮会名 - var head := HBoxContainer.new() - head.add_theme_constant_override("separation", 10) - _panel_box.add_child(head) - var crest := TextureRect.new() - crest.custom_minimum_size = Vector2(70, 40) - crest.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - crest.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT - var gid := int(d.get("guild_id", 0)) - if has_char and gid != 0 and _client and _client.has_method("get_guild_mark_image"): - var gimg = _client.call("get_guild_mark_image", gid) - if gimg is Image and gimg.get_width() > 0: - crest.texture = ImageTexture.create_from_image(gimg) - if crest.texture == null and has_char: - crest.texture = _empire_flag_tex() # 无会徽时退国旗 - if crest.texture == null: - var ph := Image.create_empty(48, 48, false, Image.FORMAT_RGBA8) - ph.fill(Color(0.16, 0.20, 0.28, 0.9)) - crest.texture = ImageTexture.create_from_image(ph) - head.add_child(crest) - var gbox := VBoxContainer.new() - head.add_child(gbox) - gbox.add_child(_plain(_empire_name() if has_char else "—", 10, Color(0.95, 0.9, 0.7))) - var gname := String(d.get("guild_name", "")).strip_edges() - gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 10, Color(0.8, 0.82, 0.86))) - - _panel_box.add_child(_sep()) - - # 名称 / 等级 / 游戏时间 - _panel_box.add_child(_kv("名称", String(d.get("name", "")) if has_char else "空槽位")) - _panel_box.add_child(_kv("等级", str(int(d.get("level", 0))) if has_char else "-")) - var mins := int(d.get("play_minutes", 0)) - _panel_box.add_child(_kv("游戏时间", ("%d 时 %d 分" % [mins / 60, mins % 60]) if has_char else "-")) - - _panel_box.add_child(_sep()) - - # 四维 - for row in STAT_ROWS: - var rd: Dictionary = row - var v := int(d.get(rd["key"], 0)) if has_char else 0 - _panel_box.add_child(_stat(String(rd["label"]), v, Color(rd["color"]))) - - _panel_box.add_child(_sep()) - - # A fresh account has no empire yet. 40250 keeps the connection in the - # select phase and expects CG_EMPIRE before character creation/selection; - # expose that real protocol step instead of leaving the page apparently - # idle with a permanently disabled start button. - var empire_required := _empire_id() == 0 - if empire_required: - _panel_box.add_child(_plain("请选择阵营", 14, Color(0.95, 0.85, 0.55))) - var erow := HBoxContainer.new() - erow.add_theme_constant_override("separation", 6) - _panel_box.add_child(erow) - for eid in [1, 2, 3]: - var empire_id: int = eid - var eb := Button.new() - eb.text = _empire_name_for(empire_id) - eb.custom_minimum_size = Vector2(82, 30) - eb.pressed.connect(func(): - if _client == null or not _client.has_method("select_empire"): - _set_status("当前网络后端不支持选择阵营") - return - if bool(_client.call("select_empire", empire_id)): - _set_status("已发送阵营选择:%s" % _empire_name_for(empire_id)) - else: - _set_status("阵营选择发送失败") - ) - erow.add_child(eb) - - # 按钮 - var brow := GridContainer.new() - brow.columns = 2 - brow.add_theme_constant_override("h_separation", 8) - brow.add_theme_constant_override("v_separation", 8) - _panel_box.add_child(brow) - var start := Button.new() - start.text = "开始" - start.custom_minimum_size = Vector2(180, 22) - _style_button(start) - start.disabled = not has_char or empire_required or _starting - start.pressed.connect(_do_start) - _panel_box.add_child(start) - _panel_box.move_child(start, _panel_box.get_child_count() - 2) - start.visible = has_char - _btn_start = start - - var create := Button.new() - create.text = "创建" - create.custom_minimum_size = Vector2(180, 22) - _style_button(create) - create.disabled = has_char or empire_required or _starting - create.pressed.connect(_open_create_dialog) - _panel_box.add_child(create) - _panel_box.move_child(create, _panel_box.get_child_count() - 2) - create.visible = not has_char - _btn_create = create - - var del := Button.new() - del.text = "删除" - del.custom_minimum_size = Vector2(86, 20) - _style_button(del) - del.disabled = not has_char or empire_required or _starting - del.pressed.connect(_open_delete_dialog) - brow.add_child(del) - _btn_delete = del - - var quit := Button.new() - quit.text = "退出" - quit.custom_minimum_size = Vector2(86, 20) - _style_button(quit) - quit.disabled = _starting - quit.pressed.connect(func(): if not _starting: back_requested.emit()) - brow.add_child(quit) - _btn_quit = quit - -func _set_buttons_disabled(disabled: bool) -> void: - if _btn_start and is_instance_valid(_btn_start): - _btn_start.disabled = disabled - if _btn_create and is_instance_valid(_btn_create): - _btn_create.disabled = disabled - if _btn_delete and is_instance_valid(_btn_delete): - _btn_delete.disabled = disabled - if _btn_quit and is_instance_valid(_btn_quit): - _btn_quit.disabled = disabled - for b in _nav_buttons: - if b and is_instance_valid(b): - b.disabled = disabled - -func _style_button(button: Button) -> void: - button.add_theme_font_size_override("font_size", 10) - for state in ["normal", "hover", "pressed", "disabled"]: - var suffix := "02" if state == "hover" else "03" if state == "pressed" else "01" - var prefix := "XLarge" if button.custom_minimum_size.x > 100 else "Large" - var texture := UiAssets.load_tex(_assets, - "d:/ymir work/ui/public/%s_Button_%s.sub" % [prefix, suffix]) - if texture: - var textured := StyleBoxTexture.new() - textured.texture = texture - textured.set_texture_margin_all(3) - button.add_theme_stylebox_override(state, textured) - continue - var style := StyleBoxFlat.new() - style.bg_color = Color(0.22, 0.22, 0.20, 0.95) - if state == "hover": - style.bg_color = Color(0.32, 0.31, 0.27) - style.border_color = Color(0.48, 0.46, 0.39) - style.set_border_width_all(1) - style.set_corner_radius_all(3) - button.add_theme_stylebox_override(state, style) - -func _build_navigation() -> void: - _nav_buttons.clear() - for direction in [-1, 1]: - var button := TextureButton.new() - var side := "left" if direction < 0 else "right" - var base := "d:/ymir work/ui/intro/select/dragon_%s_button_" % side - button.texture_normal = UiAssets.load_tex(_assets, base + "01.sub") - button.texture_hover = UiAssets.load_tex(_assets, base + "02.sub") - button.texture_pressed = UiAssets.load_tex(_assets, base + "03.sub") - button.texture_disabled = UiAssets.load_tex(_assets, base + "01.sub") - button.position = Vector2(714 if direction < 0 else 1024, 712) - button.custom_minimum_size = Vector2(160, 44) - button.tooltip_text = "上一个角色" if direction < 0 else "下一个角色" - button.pressed.connect(func(): if not _starting: _select(wrapi(_sel + direction, 0, _chars.size()))) - _layout.add_child(button) - _nav_buttons.append(button) - -func _resize_layout() -> void: - if _layout: - _layout.size = DESIGN_SIZE - _layout.scale = size / DESIGN_SIZE - _frame_model() - -# --- 底部状态 -------------------------------------------------------------- - -func _build_status() -> void: - _status = Label.new() - _status.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) - _status.position = Vector2(24, -32) - _status.add_theme_font_size_override("font_size", 13) - _status.add_theme_color_override("font_color", Color(0.9, 0.92, 0.95)) - _status.add_theme_color_override("font_outline_color", Color(0, 0, 0)) - _status.add_theme_constant_override("outline_size", 4) - add_child(_status) - -func set_status(t: String) -> void: - _set_status(t) - -# 会徽下载完成后由 app_flow 调用,重绘信息板即可拿到真徽记。 -func refresh_crest() -> void: - _refresh_panel() - -func _set_status(t: String) -> void: - if _status: - _status.text = t - -# --- 选择 / 循环 ---------------------------------------------------------- - -func _select(i: int) -> void: - if _starting: - return - if _chars.is_empty(): - _sel = 0 - _refresh_class_name() - _refresh_panel() - return - _sel = clampi(i, 0, _chars.size() - 1) - if _slot_lbl: - _slot_lbl.text = "%d / %d" % [_sel + 1, _chars.size()] - _refresh_class_name() - _refresh_panel() - _rebuild_model() - var nm := String(_cur().get("name", "")) - _set_status("已选:%s" % nm if nm != "" else "空槽位(点“创建”建号)") - -func _do_start() -> void: - if _starting: - return - var d := _cur() - if d.is_empty() or String(d.get("name", "")) == "": - _set_status("空槽位不能进入") - return - # 40250's original select screen forces a character marked with - # change_name to complete the rename flow before entering the world. - if bool(d.get("change_name", false)): - _open_change_name_dialog() - return - - _starting = true - _set_buttons_disabled(true) - if _audio and _audio.has_method("play_sound"): - _audio.play_sound("sound/ui/click.wav") - if _audio.has_method("fade_out_bgm"): - _audio.fade_out_bgm(2.5) - - _set_status("进入游戏:%s …" % d.get("name")) - - var anim_dur := 2.2 - if _pv != null and is_instance_valid(_pv): - var intro: String = _pv.motion_dir.get_base_dir().path_join("intro/selected.msa") - if _pv.anim and FileAccess.file_exists(intro): - _pv.anim.set("loop", false) - _pv.anim.set("blend_time", 0.1) - _pv.anim.set("anim_path", intro) - _pv._refresh_sound_script(intro) - if _pv.anim.has_method("get_duration"): - var dur := float(_pv.anim.call("get_duration")) - if dur > 0.0: - anim_dur = dur - - # The real select window is always tree-owned, but keeping the signal - # usable when a headless/reconnect harness invokes the handler before the - # transient CanvasLayer is attached prevents the selection from being - # silently dropped. Once a tree-owned screen leaves the tree, it is being - # replaced and must not emit into the new phase. - var had_tree := is_inside_tree() - if had_tree and get_tree() != null: - await get_tree().create_timer(anim_dur).timeout - if not is_inside_tree(): - return - select_requested.emit(_slot_index()) - -func _unhandled_input(event: InputEvent) -> void: - if _dialog != null and is_instance_valid(_dialog): - return - if not (event is InputEventKey) or not event.pressed or event.echo: - return - if _starting: - return - var ke := event as InputEventKey - match ke.keycode: - KEY_ENTER, KEY_KP_ENTER: - if not _cur().is_empty() and String(_cur().get("name", "")) != "": - _do_start() - else: - _open_create_dialog() - KEY_LEFT: - _select(wrapi(_sel - 1, 0, _chars.size())) - KEY_RIGHT: - _select(wrapi(_sel + 1, 0, _chars.size())) - KEY_ESCAPE: - back_requested.emit() - KEY_1, KEY_2, KEY_3, KEY_4: - var slot := ke.keycode - KEY_1 - if slot < _chars.size(): - _select(slot) - -# --- 建号 / 删号弹窗 ------------------------------------------------------ - -var _dialog: Control - -func _close_dialog() -> void: - if _dialog and is_instance_valid(_dialog): - _dialog.queue_free() - _dialog = null - -func _dialog_node() -> Node: - return _dialog if _dialog and is_instance_valid(_dialog) else null - -func _modal_root(title: String, w: int, h: int) -> VBoxContainer: - _close_dialog() - var dim := ColorRect.new() - dim.color = Color(0, 0, 0, 0.55) - dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - add_child(dim) - _dialog = dim - var board := UiKit.board(_assets, "board", 24, 96) - board.set_anchors_preset(Control.PRESET_CENTER) - board.position = Vector2(-w / 2.0, -h / 2.0) - board.size = Vector2(w, h) - dim.add_child(board) - var bg := ColorRect.new() - bg.color = Color(0.04, 0.06, 0.07, 0.92) - bg.position = Vector2(10, 10) - bg.size = Vector2(w - 20, h - 20) - board.add_child(bg) - var box := VBoxContainer.new() - box.position = Vector2(24, 22) - box.custom_minimum_size = Vector2(w - 48, 0) - box.add_theme_constant_override("separation", 10) - board.add_child(box) - var t := _plain(title, 18, Color(0.95, 0.85, 0.55)) - box.add_child(t) - box.add_child(_sep()) - return box - -func _open_create_dialog() -> void: - var d := _cur() - if String(d.get("name", "")) != "": - _set_status("该槽位已有角色") - return - var slot := _slot_index() - # 空槽默认按 slot 序轮职业,让四个槽能各建一职 - var job := clampi(slot, 0, 3) - var box := _modal_root("创建角色 · 槽位 %d" % slot, 420, 360) - - var jrow := HBoxContainer.new() - jrow.add_theme_constant_override("separation", 8) - box.add_child(jrow) - jrow.add_child(_plain("职业", 13, Color(0.72, 0.78, 0.85))) - var jopt := OptionButton.new() - jopt.name = "Job" - for i in 4: - jopt.add_item("%s / %s" % [CLASS_CN[i], CLASS_DIR[i]], i) - jopt.selected = job - jrow.add_child(jopt) - - var nrow := HBoxContainer.new() - nrow.add_theme_constant_override("separation", 8) - box.add_child(nrow) - nrow.add_child(_plain("名称", 13, Color(0.72, 0.78, 0.85))) - var name_edit := LineEdit.new() - name_edit.name = "Name" - name_edit.max_length = CHAR_NAME_MAX_CHARS - name_edit.custom_minimum_size = Vector2(240, 0) - name_edit.placeholder_text = "角色名(中文最多 12 字,最终按 GB2312 字节校验)" - nrow.add_child(name_edit) - - # 四维(起始属性,只读,随职业变) - var stat_box := VBoxContainer.new() - stat_box.name = "Stats" - stat_box.add_theme_constant_override("separation", 5) - box.add_child(stat_box) - var render_stats := func(j: int) -> void: - for c in stat_box.get_children(): - c.queue_free() - var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0]) - stat_box.add_child(_stat("体力", int(bs["con"]), Color(0.90, 0.30, 0.30))) - stat_box.add_child(_stat("智力", int(bs["int"]), Color(0.85, 0.45, 0.95))) - stat_box.add_child(_stat("力量", int(bs["str"]), Color(0.70, 0.55, 1.00))) - stat_box.add_child(_stat("敏捷", int(bs["dex"]), Color(0.35, 0.75, 1.00))) - render_stats.call(job) - jopt.item_selected.connect(func(j: int): render_stats.call(j)) - - box.add_child(_sep()) - var brow := HBoxContainer.new() - brow.alignment = BoxContainer.ALIGNMENT_CENTER - brow.add_theme_constant_override("separation", 16) - box.add_child(brow) - var ok := Button.new(); ok.text = "创建"; ok.custom_minimum_size = Vector2(120, 34) - ok.pressed.connect(func(): - var nm := name_edit.text.strip_edges() - var err := _validate_char_name(nm) - if err != "": - _set_status(err) - return - var j := jopt.get_selected_id() - var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0]) - create_requested.emit({ - "slot": slot, "name": nm, "job": j, "shape": 0, - "con": int(bs["con"]), "int": int(bs["int"]), - "str": int(bs["str"]), "dex": int(bs["dex"]), - }) - _set_status("创建中…")) - brow.add_child(ok) - var cancel := Button.new(); cancel.text = "取消"; cancel.custom_minimum_size = Vector2(120, 34) - cancel.pressed.connect(_close_dialog) - brow.add_child(cancel) - -func _open_change_name_dialog() -> void: - var d := _cur() - if String(d.get("name", "")) == "": - _set_status("空槽位不能改名") - return - var slot := _slot_index() - var box := _modal_root("修改角色名称", 400, 250) - box.add_child(_plain("该角色需要先修改名称才能进入游戏。", 13, - Color(0.95, 0.75, 0.7))) - var nrow := HBoxContainer.new() - nrow.add_theme_constant_override("separation", 8) - box.add_child(nrow) - nrow.add_child(_plain("新名称", 13, Color(0.72, 0.78, 0.85))) - var name_edit := LineEdit.new() - name_edit.name = "Name" - name_edit.max_length = CHAR_NAME_MAX_CHARS - name_edit.custom_minimum_size = Vector2(220, 0) - name_edit.placeholder_text = "角色名(中文最多 12 字,最终按 GB2312 字节校验)" - nrow.add_child(name_edit) - box.add_child(_sep()) - var brow := HBoxContainer.new() - brow.alignment = BoxContainer.ALIGNMENT_CENTER - brow.add_theme_constant_override("separation", 16) - box.add_child(brow) - var ok := Button.new() - ok.text = "改名" - ok.custom_minimum_size = Vector2(120, 34) - ok.pressed.connect(func(): - var nm := name_edit.text.strip_edges() - var err := _validate_char_name(nm) - if err != "": - _set_status(err) - return - _rename_pending = true - _close_dialog() - _set_status("改名中…") - change_name_requested.emit(slot, nm)) - brow.add_child(ok) - var cancel := Button.new() - cancel.text = "取消" - cancel.custom_minimum_size = Vector2(120, 34) - cancel.pressed.connect(_close_dialog) - brow.add_child(cancel) - -func _open_delete_dialog() -> void: - var d := _cur() - if String(d.get("name", "")) == "": - return - var slot := _slot_index() - var box := _modal_root("删除角色", 400, 240) - box.add_child(_plain("将永久删除 “%s” (Lv.%d)。" % [d.get("name", ""), int(d.get("level", 0))], - 13, Color(0.95, 0.75, 0.7))) - var crow := HBoxContainer.new() - crow.add_theme_constant_override("separation", 8) - box.add_child(crow) - crow.add_child(_plain("删除码", 13, Color(0.72, 0.78, 0.85))) - var code := LineEdit.new() - code.name = "Code" - code.secret = true - code.max_length = 7 - code.custom_minimum_size = Vector2(200, 0) - code.placeholder_text = "社交号 / 删除码" - crow.add_child(code) - box.add_child(_sep()) - var brow := HBoxContainer.new() - brow.alignment = BoxContainer.ALIGNMENT_CENTER - brow.add_theme_constant_override("separation", 16) - box.add_child(brow) - var ok := Button.new(); ok.text = "删除"; ok.custom_minimum_size = Vector2(120, 34) - ok.pressed.connect(func(): - delete_requested.emit(slot, code.text) - _set_status("删除中…")) - brow.add_child(ok) - var cancel := Button.new(); cancel.text = "取消"; cancel.custom_minimum_size = Vector2(120, 34) - cancel.pressed.connect(_close_dialog) - brow.add_child(cancel) - -# --- 国家 -------------------------------------------------------------- - -func _empire_id() -> int: - if _client and _client.has_method("get_empire"): - return int(_client.get_empire()) - return 0 - -func _empire_name() -> String: - return _empire_name_for(_empire_id()) - -func _empire_name_for(e: int) -> String: - # 优先 locale_game.txt 的 EMPIRE_A/B/C - var key: String = EMPIRE_KEY.get(e, "") - if key != "": - var loc := _locale_line(key) - if loc != "": - return loc - return EMPIRE_FALLBACK.get(e, "—") - -static var _loc_cache: Dictionary = {} - -func _locale_line(key: String) -> String: - if _loc_cache.has(key): - return _loc_cache[key] - var val := "" - for rel in ["locale/locale/en/locale_game.txt", "locale/locale/de/locale_game.txt"]: - var p := _assets.path_join(rel) - if not FileAccess.file_exists(p): - continue - var f := FileAccess.open(p, FileAccess.READ) - while f and not f.eof_reached(): - var parts := f.get_line().split("\t", false) - if parts.size() >= 2 and String(parts[0]) == key: - val = String(parts[1]).strip_edges() - break - if val != "": - break - _loc_cache[key] = val - return val - -func _empire_flag_tex() -> Texture2D: - var f: String = EMPIRE_FLAG.get(_empire_id(), "") - if f == "": - return null - return UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/empire/empireflag_%s.sub" % f) - -func _process(_dt: float) -> void: - var empire := _empire_id() - if empire != _last_empire: - _last_empire = empire - _refresh_panel() - -# --- 小工具 -------------------------------------------------------------- - -func _cur() -> Dictionary: - return _chars[_sel] if _sel >= 0 and _sel < _chars.size() else {} - -func _slot_index() -> int: - return int(_cur().get("index", _sel)) - -func _plain(text: String, size: int, col: Color) -> Label: - var l := Label.new() - l.text = text - l.add_theme_font_size_override("font_size", size) - l.add_theme_color_override("font_color", col) - return l - -func _sep() -> Control: - var c := ColorRect.new() - c.color = Color(1, 1, 1, 0.12) - c.custom_minimum_size = Vector2(0, 2) - return c - -func _kv(k: String, v: String) -> HBoxContainer: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 8) - row.custom_minimum_size.y = 20 - var kl := _plain(k, 10, Color(0.82, 0.82, 0.80)) - kl.custom_minimum_size = Vector2(42, 0) - row.add_child(kl) - var value := _plain(v, 10, Color(1, 1, 1)) - value.size_flags_horizontal = Control.SIZE_EXPAND_FILL - value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - var style := StyleBoxFlat.new() - style.bg_color = Color(0, 0, 0, 0.85) - style.border_color = Color(0.4, 0.4, 0.37) - style.set_border_width_all(1) - value.add_theme_stylebox_override("normal", style) - row.add_child(value) - return row - -func _stat(label: String, value: int, col: Color) -> HBoxContainer: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 8) - row.custom_minimum_size.y = 20 - var kl := _plain(label, 10, Color(0.82, 0.82, 0.80)) - kl.custom_minimum_size = Vector2(26, 0) - row.add_child(kl) - var track := ColorRect.new() - track.color = Color(0, 0, 0, 0.5) - track.custom_minimum_size = Vector2(98, 8) - track.size_flags_vertical = Control.SIZE_SHRINK_CENTER - row.add_child(track) - var fill := ColorRect.new() - fill.color = col - fill.anchor_left = 0.0 - fill.anchor_top = 0.0 - fill.anchor_bottom = 1.0 - fill.offset_left = 1 - fill.offset_top = 1 - fill.offset_bottom = -1 - fill.offset_right = 1 + int(clampf(value / 90.0, 0.0, 1.0) * 96.0) - track.add_child(fill) - row.add_child(_plain(str(value), 10, Color(1, 1, 1))) - return row diff --git a/project/ui/char_select_screen.gd.uid b/project/ui/char_select_screen.gd.uid deleted file mode 100644 index 9e02c4cc..00000000 --- a/project/ui/char_select_screen.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://mvxphl3dhhlt diff --git a/project/ui/char_shadow.gd b/project/ui/char_shadow.gd deleted file mode 100644 index 04245b93..00000000 --- a/project/ui/char_shadow.gd +++ /dev/null @@ -1,43 +0,0 @@ -# CharShadow —— 角色脚下阴影(첫 버전). -# -# 参考端 `GameLib/MapOutdoorCharacterShadow.cpp` 把角色渲到小 RT + 投一张模糊暗贴图 -# 到脚下。这里按「结果」对齐,不复刻 RT: -# 1) 强制角色所有 MeshInstance3D `cast_shadow = ON`(挡住 world 里 OFF 的误伤) -# 2) 脚下一张径向渐变 `Decal`,投影到地形/物体上 —— 太阳接近正午投影很短时也有接触暗影 -# -# CharShadow.attach(entity_root_node3d) # 传实体根(不是内部 make_conv 的 Metin2Model) -extends RefCounted -class_name CharShadow - -static var _tex: Texture2D - -static func _blob_tex() -> Texture2D: - if _tex != null: - return _tex - var sz := 64 - var img := Image.create_empty(sz, sz, false, Image.FORMAT_RGBA8) - var c := sz * 0.5 - for y in sz: - for x in sz: - var d := Vector2(x + 0.5 - c, y + 0.5 - c).length() / c - var a: float = clampf(1.0 - d, 0.0, 1.0) - img.set_pixel(x, y, Color(0.0, 0.0, 0.0, a * a * 0.55)) - _tex = ImageTexture.create_from_image(img) - return _tex - -static func attach(root: Node3D, footprint := 1.4) -> void: - if root == null: - return - for m in root.find_children("*", "MeshInstance3D", true, false): - (m as MeshInstance3D).cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON - if root.has_node("BlobShadow"): - return - var dec := Decal.new() - dec.name = "BlobShadow" - dec.texture_albedo = _blob_tex() - # size = 投影盒 (x,z 足印;y 向下够到地面即可) - dec.size = Vector3(footprint, 2.5, footprint) - dec.modulate = Color(1, 1, 1, 0.55) - dec.position = Vector3(0, 0.6, 0) # 盒中心略高于脚,向下投 - dec.cull_mask = 0xFFFFF - root.add_child(dec) diff --git a/project/ui/char_shadow.gd.uid b/project/ui/char_shadow.gd.uid deleted file mode 100644 index 6d1baae1..00000000 --- a/project/ui/char_shadow.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://buc0fgb3nbkq6 diff --git a/project/ui/char_status_ui.gd b/project/ui/char_status_ui.gd deleted file mode 100644 index 592c2e4b..00000000 --- a/project/ui/char_status_ui.gd +++ /dev/null @@ -1,1277 +0,0 @@ -# CharStatusUI (P11) —— 角色状态窗(1:1 迁移 `assets/root/uicharacter.py` 的 STATUS 页)。 -# -# var cs := preload("res://ui/char_status_ui.gd").new() -# add_child(cs) -# cs.setup(ui_manager, m2client) -# cs.toggle() # V 键 -# -# 布局直接走 `assets/uiscript/uiscript/characterwindow.py`(UiScript → UiBuild), -# 数值绑定逐字对照 uicharacter.py.RefreshStatus: -# Level/Exp/RestExp、HP/SP、STR/DEX/HTH/INT、ATT/DEF、MATT/MDEF、ASPD/MSPD/CSPD/ER。 -# 加点:HTH/INT/STR/DEX 的 + / - 按钮发聊天命令 `/stat ht` `/stat- ht`(与原客户端 -# `statusPlusCommandDict` 完全一致)。points_changed 信号来时刷新。 -# -# 技能 / 表情 / 任务三页只做页签切换(保留 uiscript 静态布局),实际数据在各自 -# 的专用窗(SkillUI / QuestLog)里。 -extends Node - -const UiScript = preload("res://ui/uiscript.gd") -const UiBuild = preload("res://ui/ui_build.gd") -const UiAssets = preload("res://ui/ui_assets.gd") -const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd") -const SkillSlotMap = preload("res://skill_slot_map.gd") - -# --- EPointTypes(m2dev Packet.h),只列本窗要用的 --- -const P_LEVEL := 1 -const P_EXP := 3 -const P_NEXT_EXP := 4 -const P_HP := 5 -const P_MAX_HP := 6 -const P_SP := 7 -const P_MAX_SP := 8 -const P_ST := 12 # 근력 STR -const P_HT := 13 # 체력 HTH -const P_DX := 14 # 민첩 DEX -const P_IQ := 15 # 정신 INT -const P_ATT_POWER := 16 -const P_ATT_SPEED := 17 -const P_EVADE_RATE := 18 -const P_MOV_SPEED := 19 -const P_DEF_GRADE := 20 -const P_CASTING_SPEED := 21 -const P_MAGIC_ATT_GRADE := 22 -const P_MAGIC_DEF_GRADE := 23 -const P_STAT := 26 # 남은 능력치 포인트 -const P_SKILL := 27 # 남은 스킬 포인트 -const P_MIN_ATK := 29 -const P_MAX_ATK := 30 -const P_PARTY_ATT_GRADE := 91 # player.ATTACKER_BONUS -const P_ATT_GRADE_BONUS := 95 # player.ATT_BONUS -const P_DEF_GRADE_BONUS := 96 # player.DEF_BONUS -const P_MIN_MAGIC_WEP := 202 -const P_MAX_MAGIC_WEP := 203 - -const EMOTE_NAMES := { - 1: "喝彩", 2: "拍手", 3: "肯定", 4: "原谅", - 5: "愤怒", 6: "诱惑", 7: "悲伤", 8: "害羞", - 9: "欢呼", 10: "嘲讽", 11: "思考", 12: "跳舞 1", - 13: "跳舞 2", 14: "跳舞 3", 15: "跳舞 4", 16: "跳舞 5", - 17: "跳舞 6", 18: "大笑", - 51: "亲吻", 52: "法式深吻", 53: "耳光", -} - -const EMOTE_ICONS := { - 1: "ETC/ymir work/ui/game/windows/emotion_cheers_1.sub", - 2: "ETC/ymir work/ui/game/windows/emotion_clap.sub", - 3: "icon/icon/action/congratulation.tga", - 4: "icon/icon/action/forgive.tga", - 5: "icon/icon/action/angry.tga", - 6: "icon/icon/action/attractive.tga", - 7: "icon/icon/action/sad.tga", - 8: "icon/icon/action/shy.tga", - 9: "icon/icon/action/cheerup.tga", - 10: "icon/icon/action/banter.tga", - 11: "icon/icon/action/joy.tga", - 12: "icon/icon/action/dance1.tga", - 13: "icon/icon/action/dance2.tga", - 14: "icon/icon/action/dance3.tga", - 15: "icon/icon/action/dance4.tga", - 16: "icon/icon/action/dance5.tga", - 17: "icon/icon/action/dance6.tga", - 18: "ETC/ymir work/ui/game/windows/emotion_cheers_2.sub", - 51: "ETC/ymir work/ui/game/windows/emotion_kiss.sub", - 52: "ETC/ymir work/ui/game/windows/emotion_french_kiss.sub", - 53: "ETC/ymir work/ui/game/windows/emotion_slap.sub", -} - -# uicharacter.py statusPlusCommandDict / statusMinusCommandDict -const PLUS_CMD := {"HTH": "/stat ht", "INT": "/stat iq", "STR": "/stat st", "DEX": "/stat dx"} -const MINUS_CMD := {"HTH": "/stat- ht", "INT": "/stat- iq", "STR": "/stat- st", "DEX": "/stat- dx"} - -const STATES := ["STATUS", "SKILL", "EMOTICON", "QUEST"] -const PAGE := {"STATUS": "Character_Page", "SKILL": "Skill_Page", "EMOTICON": "Emoticon_Page", "QUEST": "Quest_Page"} -const TITLEBAR := {"STATUS": "Character_TitleBar", "SKILL": "Skill_TitleBar", "EMOTICON": "Emoticon_TitleBar", "QUEST": "Quest_TitleBar"} -const TAB_BUTTON := {"STATUS": "Tab_Button_01", "SKILL": "Tab_Button_02", "EMOTICON": "Tab_Button_03", "QUEST": "Tab_Button_04"} -const TAB_IMAGE := {"STATUS": "Tab_01", "SKILL": "Tab_02", "EMOTICON": "Tab_03", "QUEST": "Tab_04"} -const FACE_BY_JOB := ["face_warrior", "face_assassin", "face_sura", "face_shaman"] - -var ui: CanvasLayer # UiManager -var client: Node # M2Client -var skill_table: RefCounted # SkillTable -var item_mouse: Node # MouseController -var drag_skill_id := 0 # Quickbar 可读取的待拖拽技能 -var _preview_group := 1 # 40250 uicharacter.py curSelectedSkillGroup (group==0 时预览分支) -var assets_root := "" -var uiscript_dir := "" - -var _win: Dictionary = {} # { root, nodes } -var _state := "STATUS" -var _mobile_mode := false -var _mobile_root: Control -var _mobile_status_page: Control -var _mobile_emote_page: Control -var _mobile_labels := {} -var _mobile_stats := {} -var _mobile_equipment_labels: Array[Label] = [] -var _mobile_hint: Label -var _quest_showing_start_index := 0 - -func setup(ui_manager: CanvasLayer, m2client: Node, assets := "", st_table: RefCounted = null, imouse: Node = null) -> void: - ui = ui_manager - client = m2client - set_process(true) - assets_root = assets - if imouse != null: - item_mouse = imouse - if assets_root == "" and ui and "assets_root" in ui: - assets_root = ui.assets_root - uiscript_dir = assets_root.path_join("uiscript/uiscript") - if not DirAccess.dir_exists_absolute(uiscript_dir): - uiscript_dir = assets_root.path_join("uiscript") - skill_table = st_table - if skill_table == null: - var st_script := load("res://ui/skill_table.gd") - if st_script: - skill_table = st_script.new() - for lang in ["en", "common"]: - var desc_path := assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang) - if not FileAccess.file_exists(desc_path): - desc_path = assets_root.path_join("locale/%s/skilldesc.txt" % lang) - if FileAccess.file_exists(desc_path) and skill_table.has_method("load_file"): - skill_table.load_file(desc_path) - break - for lang in ["en", "common"]: - var tbl_path := assets_root.path_join("locale/locale/%s/skilltable.txt" % lang) - if not FileAccess.file_exists(tbl_path): - tbl_path = assets_root.path_join("locale/%s/skilltable.txt" % lang) - if FileAccess.file_exists(tbl_path) and skill_table.has_method("load_table"): - skill_table.load_table(tbl_path) - break - elif skill_table != null and skill_table.has_method("load_table") and skill_table.has_method("is_passive"): - if not skill_table.is_passive(121): - for lang in ["en", "common"]: - var tbl_path := assets_root.path_join("locale/locale/%s/skilltable.txt" % lang) - if not FileAccess.file_exists(tbl_path): - tbl_path = assets_root.path_join("locale/%s/skilltable.txt" % lang) - if FileAccess.file_exists(tbl_path): - skill_table.load_table(tbl_path) - break - if client and client.has_signal("points_changed"): - client.points_changed.connect(_on_points_changed) - if client and client.has_signal("entity_main_set"): - client.entity_main_set.connect(func(_v): if is_open(): _refresh()) - if client and client.has_signal("skills_changed"): - client.skills_changed.connect(func(): if is_open(): _refresh()) - if client and client.has_signal("skill_group_changed"): - client.skill_group_changed.connect(func(_g): if is_open(): _refresh()) - if ui and ui.has_signal("window_closed"): - ui.window_closed.connect(func(w): - if not _win.is_empty() and _win.get("root") == w: - _win = {} - ) - -func set_job(_j: String) -> void: - pass - -func set_mobile_mode(enabled: bool) -> void: - if enabled != _mobile_mode and is_open(): - var old_mode := _mobile_mode - _mobile_mode = true - close() - _mobile_mode = old_mode - _mobile_mode = enabled - -# --- open / close ----------------------------------------------------- - -func is_open() -> bool: - if _mobile_mode: - return _mobile_root != null and is_instance_valid(_mobile_root) - return not _win.is_empty() and is_instance_valid(_win.get("root")) - -func toggle() -> void: - if is_open(): close() - else: open("STATUS") - -func close() -> void: - if _mobile_mode: - if _mobile_root and is_instance_valid(_mobile_root) and ui: - ui.close(_mobile_root) - _mobile_root = null - _mobile_status_page = null - _mobile_emote_page = null - _mobile_labels.clear() - _mobile_stats.clear() - _mobile_equipment_labels.clear() - return - if is_open(): - ui.close(_win["root"]) - _win = {} - -func open(state := "") -> void: - if state != "" and state in STATES: - _state = state - if _mobile_mode: - _open_mobile() - return - if is_open(): - _set_state(_state) - _refresh() - return - var path := uiscript_dir.path_join("characterwindow.py") - if not FileAccess.file_exists(path): - push_warning("CharStatusUI: no characterwindow.py at " + path) - return - _win = ui.open_script(path, assets_root) - if not is_open(): - return - var root_w: Control = _win.get("root") as Control - if root_w: - var vp_size := Vector2(800.0, 600.0) - if root_w.is_inside_tree(): - vp_size = root_w.get_viewport_rect().size - elif ui and ui.is_inside_tree(): - vp_size = ui.get_viewport().get_visible_rect().size - var target_y: float = (vp_size.y - 37.0 - 361.0) / 2.0 - root_w.position = Vector2(24.0, maxf(10.0, target_y)) - _wire_titlebars() - _wire_tabs() - _wire_stat_buttons() - _wire_skills() - _wire_emoticons() - _wire_quests() - _set_state(_state) - _refresh() - -func _open_mobile() -> void: - if is_open(): - _mobile_set_state(_state) - _refresh_mobile() - return - _mobile_root = Control.new() - _mobile_root.name = "MobileCharacterWindow" - _mobile_root.size = Vector2(760, 340) - _mobile_root.set_meta("mobile_title", "角色") - _mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP - _build_mobile_view() - ui.open(_mobile_root) - _mobile_set_state(_state) - _refresh_mobile() - -func _build_mobile_view() -> void: - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - panel.add_theme_stylebox_override("panel", _mobile_panel_style()) - _mobile_root.add_child(panel) - - var tabs := HBoxContainer.new() - tabs.position = Vector2(14, 48) - tabs.size = Vector2(250, 38) - tabs.add_theme_constant_override("separation", 6) - _mobile_root.add_child(tabs) - var status_tab := MobileTouchButton.new() - status_tab.setup("属性 / 装备", Color(0.65, 0.78, 0.98, 0.95)) - status_tab.set_rect_style() - status_tab.custom_minimum_size = Vector2(118, 36) - status_tab.size = Vector2(118, 36) - status_tab.pressed.connect(func(): _mobile_set_state("STATUS")) - tabs.add_child(status_tab) - var emote_tab := MobileTouchButton.new() - emote_tab.setup("表情", Color(0.65, 0.78, 0.98, 0.95)) - emote_tab.set_rect_style() - emote_tab.custom_minimum_size = Vector2(86, 36) - emote_tab.size = Vector2(86, 36) - emote_tab.pressed.connect(func(): _mobile_set_state("EMOTICON")) - tabs.add_child(emote_tab) - - _mobile_status_page = Control.new() - _mobile_status_page.name = "StatusPage" - _mobile_status_page.position = Vector2(14, 92) - _mobile_status_page.size = Vector2(732, 236) - _mobile_root.add_child(_mobile_status_page) - _build_mobile_profile() - _build_mobile_stats() - _build_mobile_equipment() - - _mobile_emote_page = Control.new() - _mobile_emote_page.name = "EmotePage" - _mobile_emote_page.position = Vector2(14, 92) - _mobile_emote_page.size = Vector2(732, 236) - _mobile_root.add_child(_mobile_emote_page) - _build_mobile_emotes() - -func _build_mobile_profile() -> void: - var profile := Panel.new() - profile.position = Vector2(0, 0) - profile.size = Vector2(190, 236) - profile.add_theme_stylebox_override("panel", _mobile_panel_style(Color(0.035, 0.055, 0.09, 0.94), Color(0.28, 0.5, 0.78, 0.72))) - _mobile_status_page.add_child(profile) - var title := Label.new() - title.text = "角色信息" - title.position = Vector2(12, 10) - title.add_theme_font_size_override("font_size", 14) - title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54)) - profile.add_child(title) - for data in [["name", "角色"], ["guild", "公会"], ["level", "等级"], ["exp", "经验"], ["hp", "生命"], ["sp", "法力"]]: - var row := HBoxContainer.new() - row.position = Vector2(12, 38 + _mobile_labels.size() * 29) - row.size = Vector2(166, 24) - var caption := Label.new() - caption.text = String(data[1]) - caption.custom_minimum_size = Vector2(44, 22) - caption.add_theme_font_size_override("font_size", 11) - row.add_child(caption) - var value := Label.new() - value.name = String(data[0]) - value.size_flags_horizontal = Control.SIZE_EXPAND_FILL - value.add_theme_font_size_override("font_size", 11) - value.add_theme_color_override("font_color", Color(0.82, 0.9, 1.0)) - row.add_child(value) - profile.add_child(row) - _mobile_labels[String(data[0])] = value - -func _build_mobile_stats() -> void: - var stats := Panel.new() - stats.position = Vector2(200, 0) - stats.size = Vector2(270, 236) - stats.add_theme_stylebox_override("panel", _mobile_panel_style(Color(0.035, 0.055, 0.09, 0.94), Color(0.28, 0.5, 0.78, 0.72))) - _mobile_status_page.add_child(stats) - var title := Label.new() - title.text = "属性" - title.position = Vector2(12, 10) - title.add_theme_font_size_override("font_size", 14) - title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54)) - stats.add_child(title) - var points := Label.new() - points.name = "stat_points" - points.position = Vector2(146, 10) - points.size = Vector2(110, 22) - points.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - points.add_theme_font_size_override("font_size", 11) - points.add_theme_color_override("font_color", Color(0.96, 0.78, 0.35)) - stats.add_child(points) - _mobile_labels["stat_points"] = points - for i in 4: - var key: String = ["STR", "HTH", "DEX", "INT"][i] - var row := HBoxContainer.new() - row.position = Vector2(12 + (i % 2) * 128, 44 + (i / 2) * 58) - row.size = Vector2(118, 48) - var caption := Label.new() - caption.text = key - caption.custom_minimum_size = Vector2(34, 42) - caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - caption.add_theme_font_size_override("font_size", 12) - row.add_child(caption) - var value := Label.new() - value.name = "value" - value.custom_minimum_size = Vector2(34, 42) - value.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - value.add_theme_font_size_override("font_size", 13) - row.add_child(value) - var minus := MobileTouchButton.new() - minus.setup("-", Color(0.72, 0.74, 0.82, 0.9)) - minus.set_rect_style() - minus.custom_minimum_size = Vector2(24, 42) - minus.size = Vector2(24, 42) - minus.pressed.connect(func(): _send_stat(MINUS_CMD[key])) - row.add_child(minus) - var plus := MobileTouchButton.new() - plus.setup("+", Color(0.95, 0.72, 0.3, 0.95)) - plus.set_rect_style() - plus.custom_minimum_size = Vector2(24, 42) - plus.size = Vector2(24, 42) - plus.pressed.connect(func(): _send_stat(PLUS_CMD[key])) - row.add_child(plus) - stats.add_child(row) - _mobile_stats[key] = {"value": value, "minus": minus, "plus": plus} - var hint := Label.new() - hint.text = "分配属性点会通过服务器确认" - hint.position = Vector2(12, 174) - hint.size = Vector2(245, 40) - hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - hint.add_theme_font_size_override("font_size", 10) - hint.add_theme_color_override("font_color", Color(0.62, 0.72, 0.84)) - stats.add_child(hint) - -func _build_mobile_equipment() -> void: - var equipment := Panel.new() - equipment.position = Vector2(480, 0) - equipment.size = Vector2(252, 236) - equipment.add_theme_stylebox_override("panel", _mobile_panel_style(Color(0.035, 0.055, 0.09, 0.94), Color(0.28, 0.5, 0.78, 0.72))) - _mobile_status_page.add_child(equipment) - var title := Label.new() - title.text = "装备" - title.position = Vector2(12, 10) - title.add_theme_font_size_override("font_size", 14) - title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54)) - equipment.add_child(title) - var grid := GridContainer.new() - grid.name = "equipment_grid" - grid.columns = 4 - grid.position = Vector2(10, 38) - grid.size = Vector2(232, 188) - grid.add_theme_constant_override("h_separation", 5) - grid.add_theme_constant_override("v_separation", 5) - equipment.add_child(grid) - for i in 24: - var slot := Panel.new() - slot.custom_minimum_size = Vector2(53, 36) - var style := StyleBoxFlat.new() - style.bg_color = Color(0.08, 0.11, 0.17, 0.95) - style.border_color = Color(0.32, 0.45, 0.62, 0.8) - style.set_border_width_all(1) - style.set_corner_radius_all(4) - slot.add_theme_stylebox_override("panel", style) - var value := Label.new() - value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - value.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - value.set_anchors_preset(Control.PRESET_FULL_RECT) - value.add_theme_font_size_override("font_size", 9) - value.text = str(i + 1) - value.mouse_filter = Control.MOUSE_FILTER_IGNORE - slot.add_child(value) - grid.add_child(slot) - _mobile_equipment_labels.append(value) - -func _build_mobile_emotes() -> void: - var title := Label.new() - title.text = "点击发送表情" - title.position = Vector2(8, 8) - title.add_theme_font_size_override("font_size", 14) - title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54)) - _mobile_emote_page.add_child(title) - var grid := GridContainer.new() - grid.position = Vector2(8, 42) - grid.columns = 5 - grid.add_theme_constant_override("h_separation", 8) - grid.add_theme_constant_override("v_separation", 8) - _mobile_emote_page.add_child(grid) - for i in 9: - var button := MobileTouchButton.new() - button.setup("表情 %d" % (i + 1), Color(0.45, 0.7, 0.95, 0.92)) - button.set_rect_style() - button.custom_minimum_size = Vector2(112, 52) - button.size = Vector2(112, 52) - var emote_id := i + 1 - button.pressed.connect(func(): - if client and client.has_method("send_emoticon"): - client.send_emoticon(emote_id) - if _mobile_hint: _mobile_hint.text = "已发送表情 %d" % emote_id) - grid.add_child(button) - _mobile_hint = Label.new() - _mobile_hint.position = Vector2(8, 192) - _mobile_hint.size = Vector2(500, 28) - _mobile_hint.add_theme_font_size_override("font_size", 11) - _mobile_hint.add_theme_color_override("font_color", Color(0.7, 0.8, 0.94)) - _mobile_emote_page.add_child(_mobile_hint) - -func _mobile_set_state(state: String) -> void: - if state in STATES: - _state = state - if _mobile_status_page: - _mobile_status_page.visible = _state != "EMOTICON" - if _mobile_emote_page: - _mobile_emote_page.visible = _state == "EMOTICON" - -func _refresh_mobile() -> void: - if not is_open() or client == null: - return - var pd: Dictionary = client.get_points() if client.has_method("get_points") else {} - var pts: Array = pd.get("points", []) - var get := func(i: int) -> int: return int(pts[i]) if i >= 0 and i < pts.size() else 0 - _mobile_set_label("level", "Lv %d" % int(pd.get("level", get.call(P_LEVEL)))) - _mobile_set_label("exp", "%d / %d" % [_u32(int(pd.get("exp", get.call(P_EXP)))), _u32(int(pd.get("next_exp", get.call(P_NEXT_EXP))))]) - _mobile_set_label("hp", "%d / %d" % [int(pd.get("hp", get.call(P_HP))), int(pd.get("max_hp", get.call(P_MAX_HP)))]) - _mobile_set_label("sp", "%d / %d" % [int(pd.get("sp", get.call(P_SP))), int(pd.get("max_sp", get.call(P_MAX_SP)))]) - var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0 - var ent: Dictionary = client.get_entity(vid) if vid != 0 and client.has_method("get_entity") else {} - _mobile_set_label("name", String(ent.get("name", "冒险者"))) - var guild_id := int(ent.get("guild", 0)) - var guild_name := String(client.get_guild_name(guild_id)) if guild_id != 0 and client.has_method("get_guild_name") else "无" - _mobile_set_label("guild", guild_name if guild_name != "" else "无") - _mobile_set_label("stat_points", "可用点数:%d" % max(0, get.call(P_STAT))) - for key in _mobile_stats: - var source: int = int({"STR": P_ST, "HTH": P_HT, "DEX": P_DX, "INT": P_IQ}.get(key, 0)) - _mobile_stats[key]["value"].text = str(get.call(int(source))) - _mobile_stats[key]["plus"].visible = get.call(P_STAT) > 0 - _mobile_stats[key]["minus"].visible = get.call(P_STAT) > 0 - var equipment: Array = client.get_equipment() if client.has_method("get_equipment") else [] - for i in _mobile_equipment_labels.size(): - var item: Dictionary = equipment[i] if i < equipment.size() else {} - var vnum := int(item.get("vnum", 0)) - _mobile_equipment_labels[i].text = ("#%d" % vnum) if vnum != 0 else str(i + 1) - -func _mobile_set_label(key: String, value: String) -> void: - if _mobile_labels.has(key) and is_instance_valid(_mobile_labels[key]): - _mobile_labels[key].text = value - -func _mobile_panel_style(bg := Color(0.045, 0.065, 0.1, 0.96), border := Color(0.52, 0.68, 0.9, 0.82)) -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = bg - style.border_color = border - style.set_border_width_all(1) - style.set_corner_radius_all(8) - return style - -func _node(nm: String) -> Control: - if _win.is_empty(): - return null - var nodes: Dictionary = _win.get("nodes", {}) - var n = nodes.get(nm, null) - return n if n is Control else null - -# --- tab switching (uicharacter.py.SetState) ------------------------- - -func _wire_tabs() -> void: - for st: String in STATES: - var key := st - var btn := _node(TAB_BUTTON[key]) - if btn is BaseButton: - btn.pressed.connect(func(): _set_state(key)) - -func _set_state(state: String) -> void: - if not state in STATES: - return - _state = state - for st: String in STATES: - var on := (st == state) - var page := _node(PAGE[st]) - if page: - page.visible = on - var bar := _node(TITLEBAR[st]) - if bar: - bar.visible = on - var tab_img := _node(TAB_IMAGE[st]) - if tab_img: - tab_img.visible = on - var btn := _node(TAB_BUTTON[st]) - if btn is BaseButton and btn.toggle_mode: - btn.set_pressed_no_signal(on) - match state: - "SKILL": _refresh_skills() - "EMOTICON": _refresh_emotes() - "QUEST": _refresh_quests() - "STATUS": _refresh() - -# --- stat +/- buttons ---------------------------------------------------- - -func _wire_stat_buttons() -> void: - for key: String in PLUS_CMD: - var pb := _node(key + "_Plus") - if pb is BaseButton: - var cmd: String = PLUS_CMD[key] - pb.pressed.connect(func(): _send_stat(cmd)) - var mb := _node(key + "_Minus") - if mb is BaseButton: - var mcmd: String = MINUS_CMD[key] - mb.pressed.connect(func(): _send_stat(mcmd)) - -func _send_stat(cmd: String) -> void: - if client and client.has_method("say"): - client.say(0, cmd) # CHAT_TYPE_TALKING —— 与 net.SendChatPacket 一致 - -# --- value binding (uicharacter.py.RefreshStatus) ---------------------- - -func _on_points_changed(_pts: Dictionary) -> void: - if is_open(): - _refresh() - -func _u32(v: int) -> int: - return v & 0xFFFFFFFF - -func _refresh() -> void: - if not is_open() or client == null: - return - _refresh_status() - if _state == "SKILL": - _refresh_skills() - elif _state == "EMOTICON": - _refresh_emotes() - elif _state == "QUEST": - _refresh_quests() - -func _refresh_status() -> void: - var pd: Dictionary = client.get_points() if client.has_method("get_points") else {} - if pd.is_empty(): - return - var pts: Array = pd.get("points", []) - var get := func(i: int) -> int: - return int(pts[i]) if i >= 0 and i < pts.size() else 0 - - _set_text("Level_Value", str(get.call(P_LEVEL))) - _set_text("Exp_Value", str(_u32(int(pd.get("exp", get.call(P_EXP)))))) - var rest_exp := _u32(int(pd.get("next_exp", get.call(P_NEXT_EXP)))) - _u32(int(pd.get("exp", get.call(P_EXP)))) - _set_text("RestExp_Value", str(rest_exp)) - - var hp := int(pd.get("hp", get.call(P_HP))) - var max_hp := int(pd.get("max_hp", get.call(P_MAX_HP))) - var sp := int(pd.get("sp", get.call(P_SP))) - var max_sp := int(pd.get("max_sp", get.call(P_MAX_SP))) - _set_text("HP_Value", "%d/%d" % [hp, max_hp]) - _set_text("SP_Value", "%d/%d" % [sp, max_sp]) - - _set_text("STR_Value", str(get.call(P_ST))) - _set_text("DEX_Value", str(get.call(P_DX))) - _set_text("HTH_Value", str(get.call(P_HT))) - _set_text("INT_Value", str(get.call(P_IQ))) - - _set_text("ATT_Value", _att_text(get)) - _set_text("DEF_Value", _def_text(get)) - _set_text("MATT_Value", _matt_text(get)) - _set_text("MDEF_Value", str(get.call(P_MAGIC_DEF_GRADE))) - - _set_text("ASPD_Value", str(get.call(P_ATT_SPEED))) - _set_text("MSPD_Value", str(get.call(P_MOV_SPEED))) - _set_text("CSPD_Value", str(get.call(P_CASTING_SPEED))) - _set_text("ER_Value", str(get.call(P_EVADE_RATE))) - - _refresh_stat_points(get.call(P_STAT)) - _refresh_identity() - -# uicharacter.__GetTotalAtkText: (min|max) + ATT_BONUS + ATTACKER_BONUS -func _att_text(get: Callable) -> String: - var lo := int(get.call(P_MIN_ATK)) - var hi := int(get.call(P_MAX_ATK)) - var bonus := int(get.call(P_ATT_GRADE_BONUS)) + int(get.call(P_PARTY_ATT_GRADE)) - if lo == 0 and hi == 0: - # 服务器未下发 MIN/MAX_ATK 时退回等效攻击力(原客户端此处会显示 0) - var eff := int(get.call(P_ATT_POWER)) + bonus - return str(eff) - if lo == hi: - return str(lo + bonus) - return "%d-%d" % [lo + bonus, hi + bonus] - -# uicharacter.__GetTotalDefText: DEF_GRADE (+ DEF_BONUS if ADD_DEF_BONUS_ENABLE) -func _def_text(get: Callable) -> String: - var d := int(get.call(P_DEF_GRADE)) - var db := int(get.call(P_DEF_GRADE_BONUS)) - if db != 0: - d += db - return str(d) - -# uicharacter.__GetTotalMagAtkText: MAG_ATT + (MIN|MAX)_MAGIC_WEP -func _matt_text(get: Callable) -> String: - var base := int(get.call(P_MAGIC_ATT_GRADE)) - var lo := base + int(get.call(P_MIN_MAGIC_WEP)) - var hi := base + int(get.call(P_MAX_MAGIC_WEP)) - if lo == hi: - return str(lo) - return "%d-%d" % [lo, hi] - -# uicharacter.__RefreshStatusPlusButtonList -func _refresh_stat_points(stat_points: int) -> void: - _set_text("Status_Plus_Value", str(max(0, stat_points))) - var lbl := _node("Status_Plus_Label") - if lbl: - lbl.visible = stat_points > 0 - for key: String in PLUS_CMD: - var pb := _node(key + "_Plus") - if pb: - pb.visible = stat_points > 0 - -func _refresh_identity() -> void: - var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0 - var ent: Dictionary = client.get_entity(vid) if (vid != 0 and client.has_method("get_entity")) else {} - _set_text("Character_Name", String(ent.get("name", ""))) - var guild_id := int(ent.get("guild", 0)) - var guild_name := "" - if guild_id != 0 and client.has_method("get_guild_name"): - guild_name = String(client.get_guild_name(guild_id)) - _set_text("Guild_Name", guild_name) - # 职业头像:race % 4 -> warrior/assassin/sura/shaman - var face := _node("Face_Image") - if face is TextureRect: - var job := int(ent.get("race", 0)) % 4 - var tex := UiAssets.load_tex(assets_root, - "d:/ymir work/ui/game/windows/%s.sub" % FACE_BY_JOB[job]) - if tex: - face.texture = tex - -func _set_text(nm: String, value: String) -> void: - var n := _node(nm) - if n == null: - return - if n is Label: - n.text = value - elif n is Button: - n.text = value - elif n.has_method("set_text"): - n.set_text(value) - -func _wire_titlebars() -> void: - for tb_name: String in TITLEBAR.values(): - var tb := _node(tb_name) - if tb: - for btn in tb.find_children("*", "BaseButton", true, false): - if not btn.pressed.is_connected(close): - btn.pressed.connect(close) - -func _wire_skills() -> void: - var g1 := _node("Skill_Group_Button_1") - if g1 is BaseButton and not g1.pressed.is_connected(_on_group1_pressed): - g1.pressed.connect(_on_group1_pressed) - var g2 := _node("Skill_Group_Button_2") - if g2 is BaseButton and not g2.pressed.is_connected(_on_group2_pressed): - g2.pressed.connect(_on_group2_pressed) - -func _on_group1_pressed() -> void: - if _skill_group() == 0: - _preview_group = 1 - _refresh_skills() - if client and client.has_method("say"): - client.say(0, "/skillgroup 1") - -func _on_group2_pressed() -> void: - if _skill_group() == 0: - _preview_group = 2 - _refresh_skills() - if client and client.has_method("say"): - client.say(0, "/skillgroup 2") - -func _wire_emoticons() -> void: - pass - -func _wire_quests() -> void: - var sb := _node("Quest_ScrollBar") - if sb is Range: - if not sb.value_changed.is_connected(_on_quest_scroll_changed): - sb.value_changed.connect(_on_quest_scroll_changed) - -func _on_quest_scroll_changed(val: float) -> void: - _quest_showing_start_index = int(val) - _refresh_quests() - -func _refresh_skills() -> void: - if not is_open() or client == null: - return - var race := _main_race() - var group := _skill_group() - var job := race & 3 - var pd: Dictionary = client.get_points() if client.has_method("get_points") else {} - var pts: Array = pd.get("points", []) - var stat_skill: int = int(pts[P_SKILL]) if pts.size() > P_SKILL else 0 - _set_text("Active_Skill_Point_Value", str(max(0, stat_skill))) - _set_text("Support_Skill_Point_Value", "0") - var sk_pt_lbl := _node("Active_Skill_Point_Label") - if sk_pt_lbl: - sk_pt_lbl.visible = stat_skill > 0 - - var grp_name := _node("Active_Skill_Group_Name") - var g1_btn := _node("Skill_Group_Button_1") - var g2_btn := _node("Skill_Group_Button_2") - var g3_btn := _node("Skill_Group_Button_3") - if g3_btn: - g3_btn.visible = false - - var display_group := group - if group == 0: - # 40250 uicharacter.py: 当未选流派时显示两组按钮,供玩家预览切换 - if grp_name: grp_name.visible = false - if g1_btn: - g1_btn.visible = true - if "text" in g1_btn: g1_btn.text = _get_skill_group_name(job, 1) - if g1_btn is BaseButton: g1_btn.set_pressed_no_signal(_preview_group == 1) - if g2_btn: - g2_btn.visible = true - if "text" in g2_btn: g2_btn.text = _get_skill_group_name(job, 2) - if g2_btn is BaseButton: g2_btn.set_pressed_no_signal(_preview_group == 2) - display_group = _preview_group - else: - # 40250 uicharacter.py: 已选流派时隐藏切换按钮,只展示当前流派名 - if g1_btn: g1_btn.visible = false - if g2_btn: g2_btn.visible = false - if grp_name: - grp_name.visible = true - grp_name.text = _get_skill_group_name(job, group) - - var active_slot := _node("Skill_Active_Slot") - if active_slot: - for slot_idx in range(1, 9): - var sid := SkillSlotMap.skill_id_for_slot(race, display_group, slot_idx) - var cell := active_slot.get_node_or_null("slot_%d" % slot_idx) - if cell: - _populate_skill_cell(cell, sid, slot_idx) - var plus_cell := active_slot.get_node_or_null("slot_%d" % (20 + slot_idx)) - if plus_cell: - var pts_alloc := stat_skill if group > 0 else 0 - _populate_skill_plus(plus_cell, sid, pts_alloc) - - var etc_slot := _node("Skill_ETC_Slot") - if etc_slot: - for slot_idx in range(101, 113): - var sid := SkillSlotMap.skill_id_for_slot(race, display_group, slot_idx) - var cell := etc_slot.get_node_or_null("slot_%d" % slot_idx) - if cell: - _populate_skill_cell(cell, sid, slot_idx) - -func _populate_skill_cell(cell: Control, skill_id: int, slot_idx: int) -> void: - var btn: TextureButton = cell.get_node_or_null("Btn") - if btn == null: - btn = TextureButton.new() - btn.name = "Btn" - btn.set_anchors_preset(Control.PRESET_FULL_RECT) - btn.ignore_texture_size = true - btn.stretch_mode = TextureButton.STRETCH_SCALE - btn.mouse_filter = Control.MOUSE_FILTER_PASS - btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - cell.add_child(btn) - - var lvl_lbl: Label = cell.get_node_or_null("Lvl") - if lvl_lbl == null: - lvl_lbl = Label.new() - lvl_lbl.name = "Lvl" - lvl_lbl.position = Vector2(2, 16) - lvl_lbl.size = Vector2(28, 14) - lvl_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - lvl_lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM - lvl_lbl.add_theme_font_size_override("font_size", 10) - lvl_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(lvl_lbl) - - for conn in btn.gui_input.get_connections(): - btn.gui_input.disconnect(conn["callable"]) - - if skill_id <= 0: - btn.texture_normal = null - lvl_lbl.text = "" - cell.tooltip_text = "" - btn.set_drag_forwarding(Callable(), Callable(), Callable()) - return - - btn.texture_normal = _skill_icon(skill_id) - var sk_data := _get_player_skill(skill_id) - var lvl: int = int(sk_data.get("level", 0)) - var master: int = int(sk_data.get("master", 0)) - if master == 1: - lvl_lbl.text = "M%d" % lvl - lvl_lbl.modulate = Color(1.0, 0.4, 0.4) - elif master == 2: - lvl_lbl.text = "G%d" % lvl - lvl_lbl.modulate = Color(0.4, 0.7, 1.0) - elif master == 3: - lvl_lbl.text = "P" - lvl_lbl.modulate = Color(0.9, 0.3, 1.0) - elif lvl > 0: - lvl_lbl.text = str(lvl) - lvl_lbl.modulate = Color(1.0, 1.0, 1.0) - else: - lvl_lbl.text = "" - - var sk_name := _skill_name(skill_id) - cell.tooltip_text = "%s (Lv %d)" % [sk_name, lvl] if lvl > 0 else sk_name - - btn.gui_input.connect(_on_skill_btn_input.bind(skill_id, slot_idx)) - - btn.set_drag_forwarding( - func(_at_pos: Vector2): - if not _can_drag_skill(skill_id): - return null - drag_skill_id = skill_id - var preview := TextureRect.new() - preview.texture = btn.texture_normal - preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - preview.custom_minimum_size = Vector2(32, 32) - preview.size = Vector2(32, 32) - preview.position = -Vector2(16, 16) - btn.set_drag_preview(preview) - return { - "type": "skill", - "kind": "skill", - "id": skill_id, - "skill_id": skill_id, - "slot": slot_idx, - }, - Callable(), - Callable() - ) - -func _can_drag_skill(skill_id: int) -> bool: - if skill_id <= 0: - return false - if skill_table and skill_table.has_method("is_passive") and skill_table.is_passive(skill_id): - return false - return true - -func _find_quickbar() -> Node: - var root: Node = null - if is_inside_tree(): - var tree := get_tree() - if tree: root = tree.root - elif ui != null and is_instance_valid(ui) and ui.is_inside_tree(): - var tree := ui.get_tree() - if tree: root = tree.root - elif get_parent(): - root = get_parent() - if root: - for n in root.find_children("*", "Quickbar", true, false): - return n - for n in root.find_children("*", "Node", true, false): - if n.has_method("assign_mobile") and n.has_method("assign"): - return n - return null - -func _find_item_mouse() -> Node: - if item_mouse != null and is_instance_valid(item_mouse): - return item_mouse - var root: Node = null - if is_inside_tree(): - var tree := get_tree() - if tree: root = tree.root - elif ui != null and is_instance_valid(ui) and ui.is_inside_tree(): - var tree := ui.get_tree() - if tree: root = tree.root - elif get_parent(): - root = get_parent() - if root: - for n in root.find_children("*", "MouseController", true, false): - return n - for n in root.find_children("*", "Node", true, false): - if n.has_method("attach_skill") and n.has_method("attach_item"): - return n - return null - -func _on_skill_btn_input(ev: InputEvent, skill_id: int, slot_idx: int) -> void: - if not (ev is InputEventMouseButton and ev.pressed): - return - if ev.button_index == MOUSE_BUTTON_RIGHT: - _cast_skill(skill_id) - elif ev.button_index == MOUSE_BUTTON_LEFT: - if not _can_drag_skill(skill_id): - return - drag_skill_id = skill_id - # 40250 uicharacter.py: Ctrl + Left Click adds to first empty quickslot - if ev.ctrl_pressed or Input.is_key_pressed(KEY_CTRL): - var qb := _find_quickbar() - if qb and qb.has_method("assign_mobile"): - if qb.assign_mobile("skill", skill_id): - return - # 40250 mouseController.AttachObject(self, player.SLOT_TYPE_SKILL, srcSlotIndex, selectedSkillIndex) - var im := item_mouse if item_mouse else _find_item_mouse() - if im and im.has_method("attach_skill"): - im.attach_skill(skill_id, slot_idx, _skill_icon(skill_id), _skill_name(skill_id)) - -func _populate_skill_plus(cell: Control, skill_id: int, stat_skill: int) -> void: - var plus_btn: Button = cell.get_node_or_null("PlusBtn") - if plus_btn == null: - plus_btn = Button.new() - plus_btn.name = "PlusBtn" - plus_btn.set_anchors_preset(Control.PRESET_FULL_RECT) - plus_btn.text = "+" - plus_btn.add_theme_font_size_override("font_size", 10) - plus_btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - cell.add_child(plus_btn) - - var sk_data := _get_player_skill(skill_id) - var lvl: int = int(sk_data.get("level", 0)) - var master: int = int(sk_data.get("master", 0)) - var can_up := (skill_id > 0 and stat_skill > 0 and master == 0 and lvl < 20) - plus_btn.visible = can_up - for conn in plus_btn.pressed.get_connections(): - plus_btn.pressed.disconnect(conn["callable"]) - if can_up: - plus_btn.pressed.connect(func(): - if client and client.has_method("say"): - client.say(0, "/skillup %d" % skill_id)) - -func _refresh_emotes() -> void: - var solo := _node("SoloEmotionSlot") - if solo: - for id in range(1, 19): - var cell := solo.get_node_or_null("slot_%d" % id) - if cell: - _populate_emote_cell(cell, id) - var dual := _node("DualEmotionSlot") - if dual: - for id in [51, 52, 53]: - var cell := dual.get_node_or_null("slot_%d" % id) - if cell: - _populate_emote_cell(cell, id) - -func _populate_emote_cell(cell: Control, emote_id: int) -> void: - var btn: TextureButton = cell.get_node_or_null("Btn") - if btn == null: - btn = TextureButton.new() - btn.name = "Btn" - btn.set_anchors_preset(Control.PRESET_FULL_RECT) - btn.ignore_texture_size = true - btn.stretch_mode = TextureButton.STRETCH_SCALE - btn.mouse_filter = Control.MOUSE_FILTER_PASS - btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - cell.add_child(btn) - for conn in btn.gui_input.get_connections(): - btn.gui_input.disconnect(conn["callable"]) - for conn in btn.pressed.get_connections(): - btn.pressed.disconnect(conn["callable"]) - - var rel: String = EMOTE_ICONS.get(emote_id, "") - var tex: Texture2D = UiAssets.load_tex(assets_root, rel) if rel != "" else null - if tex: - btn.texture_normal = tex - var nm: String = EMOTE_NAMES.get(emote_id, "动作 %d" % emote_id) - cell.tooltip_text = nm - - btn.gui_input.connect(func(ev: InputEvent): - if not (ev is InputEventMouseButton and ev.pressed): - return - if ev.button_index == MOUSE_BUTTON_RIGHT: - if client and client.has_method("send_emoticon"): - client.send_emoticon(emote_id) - elif ev.button_index == MOUSE_BUTTON_LEFT: - if ev.ctrl_pressed or Input.is_key_pressed(KEY_CTRL): - var qb := _find_quickbar() - if qb and qb.has_method("assign_mobile"): - if qb.assign_mobile("emote", emote_id): - return - var im := item_mouse if item_mouse else _find_item_mouse() - if im and im.has_method("attach_emotion"): - im.attach_emotion(emote_id, tex, nm) - ) - - btn.set_drag_forwarding( - func(_at_pos: Vector2): - var preview := TextureRect.new() - preview.texture = tex - preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - preview.custom_minimum_size = Vector2(32, 32) - preview.size = Vector2(32, 32) - preview.position = -Vector2(16, 16) - btn.set_drag_preview(preview) - return { - "type": "emotion", - "kind": "emote", - "id": emote_id, - "emote_id": emote_id, - }, - Callable(), - Callable() - ) - -func _refresh_quests() -> void: - var quest_slot := _node("Quest_Slot") - if quest_slot == null or client == null: - return - var quests: Array = [] - if client.has_method("get_quests"): - var raw_quests: Array = client.get_quests() - for q in raw_quests: - if String(q.get("title", q.get("name", ""))).strip_edges() != "": - quests.append(q) - - var quest_count := quests.size() - var sb := _node("Quest_ScrollBar") - if sb is Range: - if quest_count > 5: - sb.visible = true - sb.max_value = maxi(0, quest_count - 5) - sb.value = _quest_showing_start_index - else: - sb.visible = false - _quest_showing_start_index = 0 - - for i in range(5): - var data_idx := _quest_showing_start_index + i - var nm_node := _node("Quest_Name_0%d" % i) - var time_node := _node("Quest_LastTime_0%d" % i) - var cnt_node := _node("Quest_LastCount_0%d" % i) - var cell := quest_slot.get_node_or_null("slot_%d" % i) - if cell == null and i < quest_slot.get_child_count(): - cell = quest_slot.get_child(i) - - if data_idx < quest_count: - var q: Dictionary = quests[data_idx] - var q_title := String(q.get("title", q.get("name", "任务 %d" % data_idx))) - var q_idx := int(q.get("index", data_idx)) - var counter_name := String(q.get("counter_name", "")) - var counter_val := int(q.get("counter_value", 0)) - var clock_name := String(q.get("clock_name", "")) - var clock_val := _quest_remaining(q) - - if nm_node: - nm_node.visible = true - nm_node.text = q_title - nm_node.mouse_filter = Control.MOUSE_FILTER_PASS - nm_node.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - for conn in nm_node.gui_input.get_connections(): - nm_node.gui_input.disconnect(conn["callable"]) - nm_node.gui_input.connect(func(ev: InputEvent): - if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT: - _select_quest(q_idx) - ) - - if cnt_node: - cnt_node.visible = true - if counter_name != "": - cnt_node.text = "%s : %d" % [counter_name, counter_val] - else: - cnt_node.text = "" - - if time_node: - time_node.visible = true - time_node.text = _format_quest_clock(clock_name, clock_val) - - if cell: - cell.mouse_filter = Control.MOUSE_FILTER_PASS - cell.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - for conn in cell.gui_input.get_connections(): - cell.gui_input.disconnect(conn["callable"]) - cell.gui_input.connect(func(ev: InputEvent): - if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT: - _select_quest(q_idx) - ) - else: - if nm_node: - nm_node.visible = false - nm_node.text = "" - if cnt_node: - cnt_node.visible = false - cnt_node.text = "" - if time_node: - time_node.visible = false - time_node.text = "" - if cell: - for conn in cell.gui_input.get_connections(): - cell.gui_input.disconnect(conn["callable"]) - -func _process(_delta: float) -> void: - if is_open() and _state == "QUEST": - _update_quest_clocks() - -func _quest_remaining(q: Dictionary) -> int: - var value := maxi(0, int(q.get("clock_value", 0))) - if not q.has("clock_start_ms"): - return value - var start_ms := int(q.get("clock_start_ms", 0)) - var elapsed_ms := maxi(0, Time.get_ticks_msec() - start_ms) - return maxi(0, value - int(elapsed_ms / 1000)) - -func _format_quest_clock(clock_name: String, clock_value: int) -> String: - if clock_name == "" or clock_value <= 0: - return "" - var minutes := clock_value / 60 - var seconds := clock_value % 60 - return "%s %02d:%02d" % [clock_name, minutes, seconds] - -func _update_quest_clocks() -> void: - if client == null: - return - var quests: Array = [] - if client.has_method("get_quests"): - for q in client.get_quests(): - if String(q.get("title", q.get("name", ""))).strip_edges() != "": - quests.append(q) - for i in range(5): - var data_idx := _quest_showing_start_index + i - var time_node := _node("Quest_LastTime_0%d" % i) - if time_node == null or data_idx < 0 or data_idx >= quests.size(): - continue - var q: Dictionary = quests[data_idx] - time_node.text = _format_quest_clock(String(q.get("clock_name", "")), _quest_remaining(q)) - -func _select_quest(quest_idx: int) -> void: - if client and client.has_method("script_button"): - # 40250 uicharacter.py:655 event.QuestButtonClick(-2147483648 + questIndex) - client.script_button(-2147483648 + quest_idx) - -func _main_race() -> int: - if client and client.has_method("get_main_vid") and client.has_method("get_entity"): - var vid := int(client.get_main_vid()) - if vid != 0: - return int(client.get_entity(vid).get("race", 0)) - return 0 - -const SKILL_GROUP_NAMES := { - 0: {1: "剑宗", 2: "气宗"}, - 1: {1: "锋刀", 2: "百羿"}, - 2: {1: "幻舞", 2: "黑魔"}, - 3: {1: "潜龙", 2: "狂雷"}, -} - -func _get_skill_group_name(job: int, grp: int) -> String: - var names: Dictionary = SKILL_GROUP_NAMES.get(job, {}) - return String(names.get(grp, "技能组 %d" % grp)) - -func _skill_group() -> int: - var grp := int(client.get_skill_group()) if client and client.has_method("get_skill_group") else 0 - if grp > 0: - return grp - return _infer_skill_group() - -func _infer_skill_group() -> int: - if client == null or not client.has_method("get_skills"): - return 0 - var race := _main_race() - for sk in client.get_skills(): - var sid := int(sk.get("id", 0)) - var lvl := int(sk.get("level", 0)) - if lvl <= 0 and int(sk.get("master", 0)) == 0: - continue - for g in [1, 2]: - var slot := SkillSlotMap.skill_slot_for_id(race, g, sid) - if slot >= 1 and slot <= 8: - return g - return 0 - -func _skill_icon(skill_id: int) -> Texture2D: - if assets_root == "": - return null - var data: Dictionary = skill_table.entry(skill_id) if skill_table and skill_table.has_method("entry") else {} - var job := String(data.get("job", "")).to_upper() - var motion := String(data.get("motion", "")).strip_edges() - if motion == "": - return null - var candidates: Array[String] = [] - const JOB_DIR := { "WARRIOR": "warrior", "ASSASSIN": "assassin", "SURA": "sura", "SHAMAN": "shaman" } - var grade := _skill_grade(skill_id) - var suffixes: Array[String] = ["_01", "_02", "_03"] - if grade == 1: - suffixes = ["_02", "_01", "_03"] - elif grade >= 2: - suffixes = ["_03", "_02", "_01"] - if JOB_DIR.has(job): - var dir := String(JOB_DIR[job]) - for sfx in suffixes: - candidates.append("ETC/ymir work/ui/skill/%s/%s%s.sub" % [dir, motion, sfx]) - elif job == "SUPPORT": - for sfx in suffixes: - candidates.append("ETC/ymir work/ui/skill/common/support/%s%s.sub" % [motion, sfx]) - candidates.append("ETC/ymir work/ui/skill/common/support/%s.sub" % motion) - elif job == "HORSE": - candidates.append("ETC/ymir work/ui/skill/common/horse/%s.sub" % motion) - elif job == "GUILD": - candidates.append("ETC/ymir work/ui/skill/common/guild/%s.sub" % motion) - for rel in candidates: - var tex: Texture2D = UiAssets.load_tex(assets_root, rel) - if tex != null: - return tex - return null - -func _skill_grade(skill_id: int) -> int: - if client and client.has_method("get_skills"): - for skill in client.get_skills(): - if int(skill.get("id", 0)) == skill_id: - return clampi(int(skill.get("master", 0)), 0, 3) - return 0 - -func _get_player_skill(skill_id: int) -> Dictionary: - if client and client.has_method("get_skills"): - for skill in client.get_skills(): - if int(skill.get("id", 0)) == skill_id: - return skill - return {} - -func _skill_name(skill_id: int) -> String: - if skill_table and skill_table.has_method("name_of"): - var nm := String(skill_table.name_of(skill_id)) - if nm != "": - return nm - return "技能 %d" % skill_id - -func _cast_skill(skill_id: int) -> void: - var tree := get_tree() - if tree and tree.root: - for n in tree.root.find_children("*", "Node", true, false): - if n.has_method("activate_skill_direct"): - n.activate_skill_direct(skill_id) - return - if client and client.has_method("use_skill"): - client.use_skill(skill_id, 0) diff --git a/project/ui/char_status_ui.gd.uid b/project/ui/char_status_ui.gd.uid deleted file mode 100644 index 69aae3b3..00000000 --- a/project/ui/char_status_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d1tgslry3nyo4 diff --git a/project/ui/chat_ui.gd b/project/ui/chat_ui.gd deleted file mode 100644 index c612059f..00000000 --- a/project/ui/chat_ui.gd +++ /dev/null @@ -1,549 +0,0 @@ -# ChatUI (P3) —— 聊天窗:多标签 + 输入行 + 频道前缀 + 系统/战斗日志。 -# -# var chat := preload("res://ui/chat_ui.gd").new() -# add_child(chat) -# chat.setup(m2client, canvas_parent) # canvas_parent: 一个 CanvasLayer/Control -# chat.focus_input() # Enter 键调 -# -# 输入行前缀: /w <名> <话> 私聊 · /g 公会 · /p 队伍 · /s 喊话 · 其它 普通。 -# 消费 M2Client:chat / whisper / item_picked_up。按类型分到 全部 / 私聊 / 系统 / 战斗 标签。 -extends Node - -const Locale = preload("res://locale.gd") -const WhisperChatSystem = preload("res://whisper_chat_system.gd") - -# EChatType (wire.h) -const T_TALKING := 0 -const T_INFO := 1 -const T_NOTICE := 2 -const T_PARTY := 3 -const T_GUILD := 4 -const T_SHOUT := 6 -const T_WHISPER := 7 -# PythonChat.h::EWhisperType (40250). Keep these distinct from EChatType: -# 1..4 are server error modes, 5 is GM, and 0xff is a system message. -const WHISPER_TYPE_CHAT := 0 -const WHISPER_TYPE_NOT_EXIST := 1 -const WHISPER_TYPE_TARGET_BLOCKED := 2 -const WHISPER_TYPE_SENDER_BLOCKED := 3 -const WHISPER_TYPE_ERROR := 4 -const WHISPER_TYPE_GM := 5 -const WHISPER_TYPE_SYSTEM := 255 -const FISHING_SUCCESS := 3 -const FISHING_FAIL := 4 -const FISHING_FISH := 5 - -const TABS := ["全部", "私聊", "系统", "战斗"] -const COLOR := { - 0: Color(0.92, 0.92, 0.92), # talking - 1: Color(0.55, 0.85, 1.0), # info - 2: Color(1.0, 0.85, 0.3), # notice - 3: Color(0.6, 0.85, 1.0), # party - 4: Color(0.5, 1.0, 0.6), # guild - 6: Color(1.0, 0.7, 0.4), # shout - 7: Color(1.0, 0.6, 0.95), # whisper -} - -signal line_added(tab: int, bbcode: String) - -var client: Node -var _root: Control -var _log := {} # tab_index -> RichTextLabel -var _input: LineEdit -var _tab := 0 -var _tab_btns := [] -var _max_lines := 200 -var _buffers := {} # tab -> Array[String] -var _locale: RefCounted -var _proto: Node -var _fishing_active := false -var _whisper_dialog: Panel -var _whisper_target: LineEdit -var _whisper_message: LineEdit -var _whisper_system: RefCounted -var _mobile_mode := false -var _tab_row: HBoxContainer - -func setup(m2client: Node, parent: Node, assets_root := "") -> void: - client = m2client - var player_name := "Hero" - if client and client.has_method("get_main_vid") and client.has_method("get_entity"): - var main: Dictionary = client.get_entity(int(client.get_main_vid())) - if not main.is_empty() and not String(main.get("name", "")).is_empty(): - player_name = String(main.get("name", "Hero")) - _whisper_system = WhisperChatSystem.new(player_name) - if assets_root != "": - _locale = Locale.new() - _locale.setup(assets_root, "en") - _build(parent) - if client: - if client.has_signal("chat"): - client.chat.connect(_on_chat) - if client.has_signal("whisper_received"): - client.whisper_received.connect(_on_whisper) - if client.has_signal("item_picked_up"): - client.item_picked_up.connect(_on_pickup) - if client.has_signal("fishing_event"): - client.fishing_event.connect(_on_fishing_event) - -func set_proto(item_proto: Node) -> void: - _proto = item_proto - -func _loc(key: String, fallback: String, args: Array = []) -> String: - if _locale and _locale.has(key): - return _locale.t(key, args) - return fallback % args if not args.is_empty() else fallback - -func on_fishing_feedback(code: String) -> void: - if code == "FISHING_WRONG_PLACE": - var s := _loc("FISHING_WRONG_PLACE", "You cannot go fishing here.") - _append(2, T_INFO, s) - _append(3, T_INFO, s) - -func focus_input() -> void: - if _input: - _input.grab_focus() - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - if _root: - _apply_mobile_layout() - _apply_mobile_whisper_layout() - if enabled and _root: - # Chat is an on-demand full-screen page on mobile; the landscape HUD has - # no permanent bottom chat strip. - _root.visible = false - -func close() -> void: - if _root: - _root.visible = false - _close_whisper() - -func get_mobile_windows() -> Array[Control]: - var result: Array[Control] = [] - if _root: - result.append(_root) - if _whisper_dialog: - result.append(_whisper_dialog) - return result - -# 预填 "/w <名> " 并聚焦(好友列表点名字时调)。 -func start_whisper(name: String) -> void: - if _whisper_dialog == null or name.strip_edges() == "": - return - _whisper_target.text = name.strip_edges() - if _whisper_system: - _whisper_system.open_conversation(_whisper_target.text) - _whisper_message.clear() - _whisper_dialog.visible = true - if _whisper_message.is_inside_tree(): - _whisper_message.grab_focus() - -func is_whisper_open() -> bool: - return _whisper_dialog != null and _whisper_dialog.visible - -func whisper_target() -> String: - return _whisper_target.text if _whisper_target != null else "" - -func whisper_system() -> RefCounted: - return _whisper_system - -func toggle_whisper_ignore(name: String) -> bool: - if _whisper_system == null: - return false - return _whisper_system.toggle_ignore_target(name.strip_edges()) - -func is_typing() -> bool: - return _input != null and _input.has_focus() - -# game.py ToggleChatLogWindow hides/shows the complete chat log while keeping -# the accumulated channel buffers. Enter can still focus the input afterward. -func toggle_log() -> void: - if _root: - _root.visible = not _root.visible - -func is_log_visible() -> bool: - return _root != null and _root.visible - -func set_view_chat(enabled: bool) -> void: - if _root: - _root.visible = enabled - -func set_log_visible(v: bool) -> void: - if _root: - _root.visible = v - -# --- 建 UI -------------------------------------------------------------- - -func _build(parent: Node) -> void: - _root = Control.new() - _root.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) - _root.position = Vector2(12, -232) - _root.size = Vector2(460, 220) - parent.add_child(_root) - - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.05, 0.06, 0.08, 0.72) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - - _tab_row = HBoxContainer.new() - _tab_row.position = Vector2(6, 4) - _root.add_child(_tab_row) - for i in TABS.size(): - var b := Button.new() - b.text = TABS[i] - b.toggle_mode = true - b.button_pressed = (i == 0) - b.add_theme_font_size_override("font_size", 11) - var idx := i - b.pressed.connect(func(): _select_tab(idx)) - _tab_row.add_child(b) - _tab_btns.append(b) - - for i in TABS.size(): - var rt := RichTextLabel.new() - rt.bbcode_enabled = true - rt.scroll_following = true - rt.selection_enabled = true - rt.position = Vector2(6, 30) - rt.size = Vector2(448, 158) - rt.visible = (i == 0) - rt.add_theme_font_size_override("normal_font_size", 12) - _root.add_child(rt) - _log[i] = rt - _buffers[i] = [] - - _input = LineEdit.new() - _input.placeholder_text = "回车发送 · /w 名 私聊 · /g 公会 · /p 队伍 · /s 喊话" - _input.position = Vector2(6, 192) - _input.size = Vector2(448, 24) - _input.add_theme_font_size_override("font_size", 12) - _input.text_submitted.connect(_on_submit) - _root.add_child(_input) - _build_whisper_dialog(parent) - _apply_mobile_layout() - _apply_mobile_whisper_layout() - -func _apply_mobile_layout() -> void: - if _root == null: - return - if not _mobile_mode: - _root.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) - _root.position = Vector2(12, -232) - _root.size = Vector2(460, 220) - if _tab_row: - _tab_row.position = Vector2(6, 4) - for rt in _log.values(): - (rt as RichTextLabel).position = Vector2(6, 30) - (rt as RichTextLabel).size = Vector2(448, 158) - if _input: - _input.position = Vector2(6, 192) - _input.size = Vector2(448, 24) - return - # One landscape page with a large text area and a keyboard-friendly input - # row. The HUD remains clear until the player explicitly opens chat. - _root.set_anchors_preset(Control.PRESET_TOP_LEFT) - _root.position = Vector2.ZERO - _root.size = Vector2(760, 340) - _root.set_meta("mobile_title", "聊天") - if _tab_row: - _tab_row.position = Vector2(14, 48) - _tab_row.size = Vector2(732, 40) - _tab_row.add_theme_constant_override("separation", 6) - for child in _tab_row.get_children(): - child.custom_minimum_size = Vector2(112, 38) - child.add_theme_font_size_override("font_size", 12) - for rt in _log.values(): - (rt as RichTextLabel).position = Vector2(14, 94) - (rt as RichTextLabel).size = Vector2(732, 190) - (rt as RichTextLabel).add_theme_font_size_override("normal_font_size", 14) - if _input: - _input.position = Vector2(14, 294) - _input.size = Vector2(732, 38) - _input.add_theme_font_size_override("font_size", 13) - -func _apply_mobile_whisper_layout() -> void: - if _whisper_dialog == null: - return - if not _mobile_mode: - _whisper_dialog.set_anchors_preset(Control.PRESET_CENTER) - _whisper_dialog.position = Vector2(-170, -82) - _whisper_dialog.size = Vector2(340, 164) - return - _whisper_dialog.set_anchors_preset(Control.PRESET_TOP_LEFT) - _whisper_dialog.position = Vector2.ZERO - _whisper_dialog.size = Vector2(480, 260) - _whisper_dialog.set_meta("mobile_title", "私聊") - var title := _whisper_dialog.get_child(0) as Label - if title: - title.position = Vector2(18, 50) - title.add_theme_font_size_override("font_size", 15) - var target_label := _whisper_dialog.get_child(1) as Label - if target_label: - target_label.position = Vector2(18, 94) - var message_label := _whisper_dialog.get_child(3) as Label - if message_label: - message_label.position = Vector2(18, 148) - if _whisper_target: - _whisper_target.position = Vector2(86, 90) - _whisper_target.size = Vector2(370, 42) - if _whisper_message: - _whisper_message.position = Vector2(86, 144) - _whisper_message.size = Vector2(370, 42) - _whisper_message.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_DEFAULT - var cancel := _whisper_dialog.get_node_or_null("Button") as Button - var send := _whisper_dialog.get_node_or_null("Button2") as Button - if cancel: - cancel.position = Vector2(174, 202) - cancel.size = Vector2(130, 44) - if send: - send.position = Vector2(316, 202) - send.size = Vector2(130, 44) - -func _build_whisper_dialog(parent: Node) -> void: - # root/game.py 的 OpenWhisperDialog 是独立窗口,不应把私聊目标写回公共 - # 聊天输入行。这里用 UiManager 同级的独立 Panel,保留自己的焦点和发送键。 - _whisper_dialog = Panel.new() - _whisper_dialog.name = "WhisperDialog" - _whisper_dialog.set_anchors_preset(Control.PRESET_CENTER) - _whisper_dialog.position = Vector2(-170, -82) - _whisper_dialog.size = Vector2(340, 164) - _whisper_dialog.visible = false - _whisper_dialog.mouse_filter = Control.MOUSE_FILTER_STOP - var bg := StyleBoxFlat.new() - bg.bg_color = Color(0.04, 0.05, 0.08, 0.97) - bg.border_color = Color(0.62, 0.36, 0.72, 0.95) - bg.set_border_width_all(1) - bg.set_corner_radius_all(5) - _whisper_dialog.add_theme_stylebox_override("panel", bg) - parent.add_child(_whisper_dialog) - - var title := Label.new() - title.text = "私聊" - title.position = Vector2(14, 10) - title.add_theme_font_size_override("font_size", 16) - _whisper_dialog.add_child(title) - var target_label := Label.new() - target_label.text = "对象" - target_label.position = Vector2(14, 45) - _whisper_dialog.add_child(target_label) - _whisper_target = LineEdit.new() - _whisper_target.position = Vector2(66, 41) - _whisper_target.size = Vector2(256, 26) - _whisper_target.editable = false - _whisper_target.mouse_filter = Control.MOUSE_FILTER_IGNORE - _whisper_dialog.add_child(_whisper_target) - var message_label := Label.new() - message_label.text = "内容" - message_label.position = Vector2(14, 80) - _whisper_dialog.add_child(message_label) - _whisper_message = LineEdit.new() - _whisper_message.position = Vector2(66, 76) - _whisper_message.size = Vector2(256, 26) - _whisper_message.placeholder_text = "输入私聊内容" - _whisper_message.text_submitted.connect(_on_whisper_submit) - _whisper_dialog.add_child(_whisper_message) - var cancel := Button.new() - cancel.text = "取消" - cancel.position = Vector2(170, 119) - cancel.size = Vector2(70, 28) - cancel.pressed.connect(_close_whisper) - _whisper_dialog.add_child(cancel) - var send := Button.new() - send.text = "发送" - send.position = Vector2(248, 119) - send.size = Vector2(74, 28) - send.pressed.connect(func(): _on_whisper_submit(_whisper_message.text)) - _whisper_dialog.add_child(send) - -func _close_whisper() -> void: - if _whisper_dialog: - _whisper_dialog.visible = false - if _whisper_message and _whisper_message.is_inside_tree(): - _whisper_message.release_focus() - -func _on_whisper_submit(text: String) -> void: - var to := _whisper_target.text.strip_edges() if _whisper_target else "" - var msg := text.strip_edges() - if to == "" or msg == "" or client == null: - return - client.whisper(to, msg) - if _whisper_system: - _whisper_system.send_whisper(to, msg) - _append(1, T_WHISPER, "→ %s: %s" % [to, msg]) - _append(0, T_WHISPER, "→ %s: %s" % [to, msg]) - _close_whisper() - -func _unhandled_input(event: InputEvent) -> void: - if not is_whisper_open() or not (event is InputEventKey): - return - var key := event as InputEventKey - if not key.pressed: - return - if key.keycode == KEY_ESCAPE: - _close_whisper() - get_viewport().set_input_as_handled() - elif key.keycode not in [KEY_ENTER, KEY_KP_ENTER]: - # 独立私聊窗口打开时,WASD / 快捷栏键不应穿透到世界。 - get_viewport().set_input_as_handled() - -func _select_tab(i: int) -> void: - _tab = i - for t in _log: - _log[t].visible = (t == i) - for k in _tab_btns.size(): - _tab_btns[k].button_pressed = (k == i) - -# --- 输入 ------------------------------------------------------------- - -func _on_submit(text: String) -> void: - _input.clear() - if _input.is_inside_tree(): - _input.release_focus() - var s := text.strip_edges() - if s == "" or client == null: - return - if s.begins_with("/w ") or s.begins_with("/whisper "): - var rest := s.substr(s.find(" ") + 1).strip_edges() - var sp := rest.find(" ") - if sp > 0: - var to := rest.substr(0, sp) - var msg := rest.substr(sp + 1).strip_edges() - client.whisper(to, msg) - if _whisper_system: - _whisper_system.send_whisper(to, msg) - _append(1, T_WHISPER, "→ %s: %s" % [to, msg]) - return - var map := {"/g ": T_GUILD, "/p ": T_PARTY, "/s ": T_SHOUT} - for pre in map: - if s.begins_with(pre): - client.say(map[pre], s.substr(3).strip_edges()) - return - client.say(T_TALKING, s) - -# --- 收消息 -------------------------------------------------------------- - -func _on_chat(type: int, vid: int, text: String) -> void: - var who := "" - if client and client.has_method("get_entity"): - var e: Dictionary = client.get_entity(vid) - if not e.is_empty(): - who = String(e.get("name", "")) - var body := ("%s: %s" % [who, text]) if who != "" else text - var tab := 0 - if type == T_INFO or type == T_NOTICE: - tab = 2 - _append(tab, type, body) - if tab != 0: - _append(0, type, body) # 全部标签也收一份 - -func _on_whisper(sub: int, from: String, text: String) -> void: - var tag := from - var line := text - if sub == WHISPER_TYPE_GM: - tag = "[GM] " + from - if _whisper_system: - var gm_result: Dictionary = _whisper_system.receive_whisper(from, text, true) - if not bool(gm_result.get("delivered", false)): - return - elif sub == WHISPER_TYPE_SYSTEM: - tag = "[系统]" - elif sub == WHISPER_TYPE_CHAT: - if _whisper_system: - var chat_result: Dictionary = _whisper_system.receive_whisper(from, text) - if not bool(chat_result.get("delivered", false)): - return - elif sub != WHISPER_TYPE_CHAT: - # game.py::OnRecvWhisperError converts modes 1..4 through - # localeInfo.WHISPER_ERROR and appends them as a system whisper. The - # native signal carries the wire text as a best-effort fallback; use a - # stable localized fallback when the server sends an empty error body. - tag = "[系统]" - if line == "": - line = _whisper_error_text(sub, from) - _append(1, T_WHISPER, "%s: %s" % [tag, line]) - _append(0, T_WHISPER, "%s: %s" % [tag, line]) - -func _whisper_error_text(sub: int, from: String) -> String: - match sub: - WHISPER_TYPE_NOT_EXIST: - return _loc("WHISPER_NOT_EXIST", "无法私聊:%s 不在线。", [from]) - WHISPER_TYPE_TARGET_BLOCKED: - return _loc("WHISPER_TARGET_BLOCKED", "无法私聊:%s 拒绝了私聊。", [from]) - WHISPER_TYPE_SENDER_BLOCKED: - return _loc("WHISPER_SENDER_BLOCKED", "无法私聊:%s 屏蔽了你。", [from]) - WHISPER_TYPE_ERROR: - return _loc("WHISPER_ERROR", "私聊失败。") - _: - return _loc("WHISPER_UNKNOWN_ERROR", "未知私聊错误(模式 %d,目标 %s)。", [sub, from]) - -func _on_pickup(vnum: int, count: int, from: String) -> void: - var s := "拾取 #%d x%d" % [vnum, count] - if from != "": - s += "(来自 %s)" % from - _append(2, T_INFO, s) - _append(3, T_INFO, s) - -func _on_fishing_event(subheader: int, info: int, _dir: int) -> void: - # RecvFishing only calls a UI callback for FAIL and FISH. SUCCESS is an - # animation transition; the later FISH event decides notify vs success - # from the actor's IsFishing() state. - if subheader == 0: # FISHING_START - _fishing_active = true - return - if subheader == 2: # FISHING_REACT - _fishing_active = true - return - if subheader == FISHING_FAIL: - _fishing_active = false - var fail := _loc("FISHING_FAILURE", "You lost the Bait.") - _append(2, T_INFO, fail) - _append(3, T_INFO, fail) - return - if subheader != FISHING_FISH: - if subheader == FISHING_SUCCESS: - _fishing_active = false - elif subheader == 1: # FISHING_STOP - _fishing_active = false - return - if info == 0: - var unknown := _loc("FISHING_UNKNOWN", "Something has taken the bait but you can't see what it is.") - _append(2, T_INFO, unknown) - _append(3, T_INFO, unknown) - return - var item: Dictionary = _proto.item(info) if _proto and _proto.has_method("item") else {} - # ClientVS22 silently ignores a fish event when item_proto has no record. - if item.is_empty(): - return - var name := String(item.get("locale_name", item.get("name", ""))) - if name == "": - name = "#%d" % info - var is_fish := int(item.get("type", -1)) == 12 # CItemData::ITEM_TYPE_FISH - var key := "" - var fallback := "" - if _fishing_active: - key = "FISHING_NOTIFY1" if is_fish else "FISHING_NOTIFY2" - fallback = "It looks like %s is hooked." if is_fish else "It looks like %s is on the hook." - else: - key = "FISHING_SUCCESS1" if is_fish else "FISHING_SUCCESS2" - fallback = "You captured %s!" if is_fish else "You have pulled %s out of the water!" - var s := _loc(key, fallback, [name]) - _append(2, T_INFO, s) - _append(3, T_INFO, s) - -# --- 追加行 -------------------------------------------------------------- - -func _append(tab: int, type: int, body: String) -> void: - var c: Color = COLOR.get(type, COLOR[0]) - var line := "[color=#%s]%s[/color]" % [c.to_html(false), body] - var buf: Array = _buffers[tab] - buf.append(line) - if buf.size() > _max_lines: - buf.pop_front() - var rt: RichTextLabel = _log[tab] - rt.text = "\n".join(buf) - line_added.emit(tab, line) diff --git a/project/ui/chat_ui.gd.uid b/project/ui/chat_ui.gd.uid deleted file mode 100644 index 1219553e..00000000 --- a/project/ui/chat_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bcbcym6kbm16i diff --git a/project/ui/cube_ui.gd b/project/ui/cube_ui.gd deleted file mode 100644 index cab59cc2..00000000 --- a/project/ui/cube_ui.gd +++ /dev/null @@ -1,366 +0,0 @@ -# CubeUI (첫 버전) —— 도깨비 방주 / 제작 (Cube) 窗。 -# -# var cu := preload("res://ui/cube_ui.gd").new() -# add_child(cu) -# cu.setup(m2client, canvas_parent, proto) # proto 可空 -# -# 服务器通过 GC_CHAT/COMMAND 的 "cube ..." 行驱动(见 EntityStore::apply_server_command): -# cube_opened(npc) → 显示 + 请求配方列表 -# cube_changed → 刷新配方 / 材料 / 金币 -# cube_result(vnum,count,ok) → 结果提示 -# cube_closed → 隐藏 -# 选中一个配方、把背包物品放入材料槽 → [制作] → M2Client.cube_make(index)。 -# 40250 服务端按材料槽匹配配方,`result_index` 只保留给 m2dev 后端;classic -# 会安全地忽略它并执行一次 `/cube make`。 -extends Node - -var client: Node -var proto: Node -var _root: Control -var _title: Label -var _status: Label -var _list: VBoxContainer -var _mat: VBoxContainer -var _make_btn: Button -var _sel := -1 -var _cube_slots: Dictionary = {} # cube index:int -> inventory cell:int -var _craft_in_flight := false -var _cube_npc_vnum := 0 -var _mobile_mode := false -var _box: VBoxContainer -var _recipe_scroll: ScrollContainer -var _material_scroll: ScrollContainer - -func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: - client = m2client - proto = proto_node - _build(parent) - if client.has_signal("cube_opened"): - client.cube_opened.connect(_on_open) - if client.has_signal("cube_closed"): - client.cube_closed.connect(func() -> void: _root.visible = false) - if client.has_signal("cube_changed"): - client.cube_changed.connect(refresh) - if client.has_signal("cube_result"): - client.cube_result.connect(_on_result) - if client.has_signal("inventory_changed"): - client.inventory_changed.connect(func(_window, _cell): refresh()) - -func is_open() -> bool: - return _root != null and _root.visible - -func get_mobile_window() -> Control: - return _root - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - _apply_mobile_layout() - -func close() -> void: - if _root: - _root.visible = false - _sel = -1 - _cube_slots.clear() - _craft_in_flight = false - -func _on_open(npc_vnum: int) -> void: - if npc_vnum <= 0: - return - var current: Dictionary = client.get_cube() if client.has_method("get_cube") else {} - if current.has("open") and not bool(current.get("open", false)): - return - var current_npc := int(current.get("npc_vnum", 0)) - if current_npc > 0 and current_npc != npc_vnum: - return - _sel = -1 - _cube_slots.clear() - _craft_in_flight = false - _cube_npc_vnum = npc_vnum - _apply_mobile_layout() - _root.visible = true - _title.text = "제작 (Cube) · NPC #%d" % npc_vnum - _status.text = "" - if client.has_method("cube_request_result_list"): - client.cube_request_result_list(npc_vnum) - refresh() - -func _on_result(vnum: int, count: int, ok: bool) -> void: - _craft_in_flight = false - if ok: - _status.text = "제작 성공: %s ×%d" % [_name_of(vnum), count] - # 40250 在成功后消耗材料;等待 inventory/cube_changed 的最终状态前, - # 先清掉本地映射,避免旧背包格继续显示为可用材料。 - _cube_slots.clear() - else: - _status.text = "제작 실패" - refresh() - -func _name_of(vnum: int) -> String: - if proto and proto.has_method("item"): - var d: Dictionary = proto.item(vnum) - var n := String(d.get("locale_name", d.get("name", ""))) - if n != "": - return n - return "#%d" % vnum - -func refresh() -> void: - if client == null or _root == null or not _root.visible: - return - var cube: Dictionary = client.get_cube() - if not cube.get("open", true): - _root.visible = false - return - var recipes: Array = cube.get("recipes", []) - var results: Array = cube.get("results", []) - for c in _list.get_children(): - c.queue_free() - var n: int = maxi(recipes.size(), results.size()) - if n == 0: - var e := Label.new() - e.text = "(이 NPC의 제작 목록 없음)" - _list.add_child(e) - for i in n: - var vnum := 0 - if i < recipes.size(): - vnum = int((recipes[i] as Dictionary).get("result_vnum", 0)) - if vnum == 0 and i < results.size(): - vnum = int((results[i] as Dictionary).get("vnum", 0)) - var b := Button.new() - b.toggle_mode = true - b.button_pressed = (i == _sel) - b.text = "%d. %s" % [i + 1, _name_of(vnum)] - var idx := i - b.pressed.connect(func() -> void: _select(idx)) - _list.add_child(b) - _refresh_materials() - -func _select(i: int) -> void: - _sel = i - for k in _list.get_child_count(): - var b := _list.get_child(k) - if b is Button: - b.button_pressed = (k == i) - # 材料信息可能未拉过 → 请求 - if client.has_method("cube_request_materials"): - client.cube_request_materials(i, 1) - _refresh_materials() - -func _refresh_materials() -> void: - for c in _mat.get_children(): - c.queue_free() - var cube: Dictionary = client.get_cube() - var recipes: Array = cube.get("recipes", []) - var recipe: Dictionary = _selected_recipe() - _make_btn.disabled = _sel < 0 or recipe.is_empty() or _craft_in_flight \ - or not _recipe_can_craft(recipe) - if _sel < 0 or _sel >= recipes.size(): - _mat.add_child(_lbl("재료 정보를 불러오는 중…", 12)) - else: - var r: Dictionary = recipe - _mat.add_child(_lbl("필요 골드: %d" % int(r.get("gold", 0)), 12)) - var groups: Array = r.get("material_groups", []) - if groups.is_empty(): - _mat.add_child(_lbl("(재료 없음)", 12)) - for g in groups: - var parts: Array = [] - for s in (g as Array): - parts.append("%s ×%d" % [_name_of(int((s as Dictionary).get("vnum", 0))), - int((s as Dictionary).get("count", 1))]) - _mat.add_child(_lbl("• " + " / ".join(parts), 12)) - if not _recipe_can_craft(r): - _mat.add_child(_lbl("(材料不足或未放入材料槽)", 11)) - elif client.has_method("get_points") and int(r.get("gold", 0)) > int(client.get_points().get("gold", 0)): - _make_btn.disabled = true - _mat.add_child(_lbl("(金币不足)", 11)) - - _mat.add_child(HSeparator.new()) - _mat.add_child(_lbl("재료 슬롯 (클릭하면 추가/삭제)", 12)) - for slot in _cube_slots.keys(): - var cell := int(_cube_slots[slot]) - var row := HBoxContainer.new() - var label := _lbl("cube[%d] ← bag[%d]" % [int(slot), cell], 11) - row.add_child(label) - var remove := Button.new() - remove.text = "삭제" - remove.pressed.connect(func() -> void: - if client.has_method("cube_delete_item") and client.cube_delete_item(int(slot)): - _cube_slots.erase(slot) - refresh()) - row.add_child(remove) - _mat.add_child(row) - - _mat.add_child(_lbl("背包物品:", 12)) - var used_cells := {} - for cell in _cube_slots.values(): - used_cells[int(cell)] = true - var inventory: Array = client.get_inventory() if client.has_method("get_inventory") else [] - var added := 0 - for item in inventory: - var inv_cell := int(item.get("cell", -1)) - var item_vnum := int(item.get("vnum", 0)) - if used_cells.has(inv_cell): - continue - if not _recipe_accepts_vnum(recipe, item_vnum): - continue - var free_slot := _first_free_cube_slot() - if free_slot < 0: - break - var add := Button.new() - add.text = "+ bag[%d] %s ×%d" % [inv_cell, _name_of(item_vnum), - int(item.get("count", 1))] - add.alignment = HORIZONTAL_ALIGNMENT_LEFT - add.pressed.connect(func() -> void: - var target := _first_free_cube_slot() - if target >= 0 and client.has_method("cube_add_item") and client.cube_add_item(target, inv_cell): - _cube_slots[target] = inv_cell - refresh()) - _mat.add_child(add) - added += 1 - if added == 0: - _mat.add_child(_lbl("(可用背包物品 없음)", 11)) - -func _selected_recipe() -> Dictionary: - if client == null or not client.has_method("get_cube"): - return {} - var recipes: Array = client.get_cube().get("recipes", []) - if _sel < 0 or _sel >= recipes.size(): - return {} - return recipes[_sel] as Dictionary - -func _recipe_accepts_vnum(recipe: Dictionary, vnum: int) -> bool: - if vnum <= 0: - return false - for group in recipe.get("material_groups", []): - for option in group as Array: - if int((option as Dictionary).get("vnum", 0)) == vnum: - return true - return false - -func _recipe_can_craft(recipe: Dictionary) -> bool: - if recipe.is_empty(): - return false - var provided := {} - if client.has_method("get_inventory"): - var inventory: Array = client.get_inventory() - for cell in _cube_slots.values(): - for item in inventory: - if int(item.get("cell", -1)) == int(cell): - var vnum := int(item.get("vnum", 0)) - provided[vnum] = int(provided.get(vnum, 0)) + maxi(1, int(item.get("count", 1))) - break - for group in recipe.get("material_groups", []): - var matched := false - for option in group as Array: - var material: Dictionary = option as Dictionary - var vnum := int(material.get("vnum", 0)) - var need := maxi(1, int(material.get("count", 1))) - if int(provided.get(vnum, 0)) >= need: - provided[vnum] = int(provided[vnum]) - need - matched = true - break - if not matched: - return false - return true - -func _first_free_cube_slot() -> int: - for i in 24: - if not _cube_slots.has(i): - return i - return -1 - -func _lbl(t: String, sz: int) -> Label: - var l := Label.new() - l.text = t - l.add_theme_font_size_override("font_size", sz) - return l - -func _apply_mobile_layout() -> void: - if _root == null or _box == null: - return - if _mobile_mode: - # Recipe selection and material/source-bag selection remain side by side - # in landscape. Both columns scroll independently so the craft action - # stays reachable when a recipe has many material alternatives. - _root.size = Vector2(720, 380) - _root.position = Vector2(-360, -190) - _box.position = Vector2(16, 48) - _box.size = Vector2(688, 318) - _box.custom_minimum_size = Vector2(688, 318) - _recipe_scroll.custom_minimum_size = Vector2(248, 254) - _material_scroll.custom_minimum_size = Vector2(430, 254) - _mat.custom_minimum_size = Vector2(414, 254) - else: - _root.size = Vector2(400, 380) - _root.position = Vector2(-200, -190) - _box.position = Vector2(16, 14) - _box.size = Vector2(368, 350) - _box.custom_minimum_size = Vector2(368, 350) - _recipe_scroll.custom_minimum_size = Vector2(180, 250) - _material_scroll.custom_minimum_size = Vector2(178, 250) - _mat.custom_minimum_size = Vector2(178, 250) - -func _build(parent: Node) -> void: - _root = Panel.new() - _root.set_anchors_preset(Control.PRESET_CENTER) - _root.position = Vector2(-200, -190) - _root.custom_minimum_size = Vector2(400, 380) - _root.size = Vector2(400, 380) - _root.visible = false - parent.add_child(_root) - _box = VBoxContainer.new() - var box := _box - box.position = Vector2(16, 14) - box.custom_minimum_size = Vector2(368, 0) - box.add_theme_constant_override("separation", 6) - _root.add_child(box) - _title = _lbl("제작 (Cube)", 16) - box.add_child(_title) - var split := HBoxContainer.new() - split.add_theme_constant_override("separation", 10) - box.add_child(split) - var lsc := ScrollContainer.new() - _recipe_scroll = lsc - lsc.custom_minimum_size = Vector2(180, 250) - split.add_child(lsc) - _list = VBoxContainer.new() - _list.add_theme_constant_override("separation", 3) - lsc.add_child(_list) - _material_scroll = ScrollContainer.new() - _material_scroll.custom_minimum_size = Vector2(178, 250) - _material_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - split.add_child(_material_scroll) - _mat = VBoxContainer.new() - _mat.custom_minimum_size = Vector2(178, 250) - _mat.add_theme_constant_override("separation", 3) - _material_scroll.add_child(_mat) - _status = _lbl("", 12) - _status.add_theme_color_override("font_color", Color(1, 0.9, 0.5)) - box.add_child(_status) - var brow := HBoxContainer.new() - brow.add_theme_constant_override("separation", 12) - box.add_child(brow) - _make_btn = Button.new() - _make_btn.text = "제작" - _make_btn.disabled = true - _make_btn.pressed.connect(func() -> void: - var recipe := _selected_recipe() - if _sel < 0 or recipe.is_empty(): - _status.text = "请先选择配方" - return - if not _recipe_can_craft(recipe): - _status.text = "材料不足或未放入材料槽" - refresh() - return - if client.has_method("cube_make") and client.cube_make(_sel): - _craft_in_flight = true - _status.text = "制作中…" - _make_btn.disabled = true) - brow.add_child(_make_btn) - var close := Button.new() - close.text = "닫기" - close.pressed.connect(func() -> void: - if client.has_method("cube_close"): - client.cube_close() - _root.visible = false) - brow.add_child(close) - _apply_mobile_layout() diff --git a/project/ui/cube_ui.gd.uid b/project/ui/cube_ui.gd.uid deleted file mode 100644 index d7655fe9..00000000 --- a/project/ui/cube_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ojp8p3ktvd2c diff --git a/project/ui/cursor_manager.gd b/project/ui/cursor_manager.gd deleted file mode 100644 index 1c95d98e..00000000 --- a/project/ui/cursor_manager.gd +++ /dev/null @@ -1,153 +0,0 @@ -# CursorManager —— 对齐 ClientVS22 mousemodule.py 的光标状态机。 -# -# 光标和“鼠标跟随物品”是两层:本节点只管理当前交互语义, -# MouseController 负责跟随鼠标的物品图标。使用 40250 的 .sub 纹理注册真实光标, -# 没有对应资源时回退到 Godot 系统光标。 -extends Node - -signal changed(shape: String) - -const UiAssets = preload("res://ui/ui_assets.gd") - -const NORMAL := "NORMAL" -const ATTACK := "ATTACK" -const TARGET := "TARGET" -const TALK := "TALK" -const CANT_GO := "CANT_GO" -const PICK := "PICK" -const DOOR := "DOOR" -const CHAIR := "CHAIR" -const MAGIC := "MAGIC" -const BUY := "BUY" -const SELL := "SELL" -const CAMERA_ROTATE := "CAMERA_ROTATE" -const HSIZE := "HSIZE" -const VSIZE := "VSIZE" -const HVSIZE := "HVSIZE" -const ITEM := "ITEM" - -const ALL_CURSORS := [ - NORMAL, ATTACK, TARGET, TALK, CANT_GO, PICK, DOOR, CHAIR, MAGIC, - BUY, SELL, CAMERA_ROTATE, HSIZE, VSIZE, HVSIZE, ITEM -] - -var _shape := NORMAL -var _assets_root := "" -var _custom_cursors_registered := false -var _cursor_images: Dictionary = {} - -func setup(assets_root: String) -> void: - _assets_root = assets_root - _register_custom_cursors() - -func _register_custom_cursors() -> void: - if _assets_root.is_empty(): - return - var cursors := { - NORMAL: "ETC/ymir work/ui/cursor/cursor.sub", - ATTACK: "ETC/ymir work/ui/cursor/cursor_attack.sub", - TARGET: "ETC/ymir work/ui/cursor/cursor_attack.sub", - TALK: "ETC/ymir work/ui/cursor/cursor_talk.sub", - CANT_GO: "ETC/ymir work/ui/cursor/cursor_no.sub", - PICK: "ETC/ymir work/ui/cursor/cursor_pick.sub", - DOOR: "ETC/ymir work/ui/cursor/cursor_door.sub", - CHAIR: "ETC/ymir work/ui/cursor/cursor_chair.sub", - MAGIC: "ETC/ymir work/ui/cursor/cursor_chair.sub", - BUY: "ETC/ymir work/ui/cursor/cursor_buy.sub", - SELL: "ETC/ymir work/ui/cursor/cursor_sell.sub", - CAMERA_ROTATE: "ETC/ymir work/ui/cursor/cursor_camera_rotate.sub", - HSIZE: "ETC/ymir work/ui/cursor/cursor_hsize.sub", - VSIZE: "ETC/ymir work/ui/cursor/cursor_vsize.sub", - HVSIZE: "ETC/ymir work/ui/cursor/cursor_hvsize.sub", - } - var shape_to_godot := { - NORMAL: Input.CURSOR_ARROW, - ATTACK: Input.CURSOR_CROSS, - TALK: Input.CURSOR_POINTING_HAND, - PICK: Input.CURSOR_CAN_DROP, - CANT_GO: Input.CURSOR_FORBIDDEN, - BUY: Input.CURSOR_DRAG, - SELL: Input.CURSOR_MOVE, - DOOR: Input.CURSOR_HELP, - CHAIR: Input.CURSOR_WAIT, - CAMERA_ROTATE: Input.CURSOR_BDIAGSIZE, - HSIZE: Input.CURSOR_HSIZE, - VSIZE: Input.CURSOR_VSIZE, - HVSIZE: Input.CURSOR_FDIAGSIZE, - } - var hotspots := { - HSIZE: Vector2(16, 16), - VSIZE: Vector2(16, 16), - HVSIZE: Vector2(16, 16), - } - for name in cursors: - var rel: String = cursors[name] - var tex: Texture2D = UiAssets.load_tex(_assets_root, rel) - if tex != null: - var img: Image = _extract_image(tex) - if img != null: - _cursor_images[name] = img - var hs: Vector2 = hotspots.get(name, Vector2.ZERO) - if shape_to_godot.has(name): - Input.set_custom_mouse_cursor(img, shape_to_godot[name], hs) - _custom_cursors_registered = true - -func _extract_image(tex: Texture2D) -> Image: - if tex is AtlasTexture: - var at := tex as AtlasTexture - if at.atlas: - var base_img: Image = at.atlas.get_image() - if base_img: - var r := Rect2i(int(at.region.position.x), int(at.region.position.y), int(at.region.size.x), int(at.region.size.y)) - return base_img.get_region(r) - elif tex: - return tex.get_image() - return null - -func set_cursor(shape: String) -> void: - if shape not in ALL_CURSORS: - shape = NORMAL - _shape = shape - Input.set_default_cursor_shape(_godot_shape(shape)) - if _cursor_images.has(shape): - var hs := Vector2(16, 16) if shape in [HSIZE, VSIZE, HVSIZE] else Vector2.ZERO - Input.set_custom_mouse_cursor(_cursor_images[shape], Input.CURSOR_ARROW, hs) - changed.emit(shape) - -func get_cursor() -> String: - return _shape - -func reset() -> void: - set_cursor(NORMAL) - -func _godot_shape(shape: String) -> Input.CursorShape: - match shape: - ATTACK, TARGET: - return Input.CURSOR_CROSS - PICK: - return Input.CURSOR_CAN_DROP - TALK: - return Input.CURSOR_POINTING_HAND - DOOR: - return Input.CURSOR_HELP - CHAIR, MAGIC: - return Input.CURSOR_WAIT - BUY: - return Input.CURSOR_DRAG - SELL: - return Input.CURSOR_MOVE - CAMERA_ROTATE: - return Input.CURSOR_BDIAGSIZE - HSIZE: - return Input.CURSOR_HSIZE - VSIZE: - return Input.CURSOR_VSIZE - HVSIZE: - return Input.CURSOR_FDIAGSIZE - CANT_GO: - return Input.CURSOR_FORBIDDEN - ITEM: - return Input.CURSOR_DRAG - _: - return Input.CURSOR_ARROW - diff --git a/project/ui/cursor_manager.gd.uid b/project/ui/cursor_manager.gd.uid deleted file mode 100644 index 659068a7..00000000 --- a/project/ui/cursor_manager.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ku3jcc4x80r diff --git a/project/ui/death_ui.gd b/project/ui/death_ui.gd deleted file mode 100644 index 373f2c6a..00000000 --- a/project/ui/death_ui.gd +++ /dev/null @@ -1,137 +0,0 @@ -# death_ui.gd —— 40250 死亡结算与复活面板(RestartDialog)1:1 实现 -# 严格对照: -# root/uirestart.py (RestartDialog) -# uiscript/restartdialog.py (200x88 ThinBoard) -# metin2/src/server/game/src/cmd_general.cpp:478 (10s restart_here cooldown) -extends Node - -const RESTART_HERE_COOLDOWN := 10.0 # 40250 官方 10 秒原地复活保护冷却 -const BOARD_WIDTH := 200.0 -const BOARD_HEIGHT := 88.0 - -var client: Node -var _root: Control -var _board: Panel -var _btn_restart_here: Button -var _btn_restart_town: Button -var _shown := false -var _here_cooldown_remaining := 0.0 - -func setup(m2client: Node, parent: Node) -> void: - client = m2client - _build(parent) - if client.has_signal("entity_dead"): - client.entity_dead.connect(_on_dead) - if client.has_signal("vitals_changed"): - client.vitals_changed.connect(_on_vitals) - -func _process(delta: float) -> void: - if not _shown: - return - if _here_cooldown_remaining > 0.0: - _here_cooldown_remaining -= delta - if _here_cooldown_remaining <= 0.0: - _here_cooldown_remaining = 0.0 - _btn_restart_here.disabled = false - _btn_restart_here.text = "原地复活" - else: - _btn_restart_here.disabled = true - _btn_restart_here.text = "原地复活 (%d 秒)" % int(ceilf(_here_cooldown_remaining)) - -func _on_dead(vid: int) -> void: - if client.has_method("get_main_vid") and vid == client.get_main_vid(): - show_dialog() - -func _on_vitals(vid: int) -> void: - if not _shown: - return - if client.has_method("get_main_vid") and vid == client.get_main_vid(): - var e: Dictionary = client.get_entity(vid) - if int(e.get("hp", 0)) > 0 and not e.get("dead", false): - hide_dialog() - -func show_dialog() -> void: - if _shown: - return - _shown = true - _here_cooldown_remaining = RESTART_HERE_COOLDOWN - _btn_restart_here.disabled = true - _btn_restart_here.text = "原地复活 (10 秒)" - _root.visible = true - -func hide_dialog() -> void: - _shown = false - _here_cooldown_remaining = 0.0 - _root.visible = false - -func is_open() -> bool: - return _shown and _root != null and _root.visible - -func _build(parent: Node) -> void: - _root = Control.new() - _root.name = "RestartDialog" - _root.set_anchors_preset(Control.PRESET_FULL_RECT) - _root.visible = false - _root.mouse_filter = Control.MOUSE_FILTER_STOP - parent.add_child(_root) - - # 全屏暗红暗角 - var dim := ColorRect.new() - dim.set_anchors_preset(Control.PRESET_FULL_RECT) - dim.color = Color(0.12, 0.0, 0.0, 0.6) - _root.add_child(dim) - - # 40250 uiscript/restartdialog.py 1:1 尺寸 200x88 ThinBoard - _board = Panel.new() - _board.name = "Board" - _board.set_anchors_preset(Control.PRESET_CENTER) - _board.position = Vector2(-BOARD_WIDTH / 2.0, -BOARD_HEIGHT / 2.0) - _board.size = Vector2(BOARD_WIDTH, BOARD_HEIGHT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.3333, 0.2941, 0.2588, 0.95) - sb.border_color = Color(0.45, 0.38, 0.22, 0.95) - sb.set_border_width_all(1) - sb.set_corner_radius_all(2) - _board.add_theme_stylebox_override("panel", sb) - _root.add_child(_board) - - # 40250 restart_here_button (10, 17), 180x26 - _btn_restart_here = Button.new() - _btn_restart_here.name = "RestartHereButton" - _btn_restart_here.position = Vector2(10, 17) - _btn_restart_here.size = Vector2(180, 26) - _btn_restart_here.text = "原地复活 (10 秒)" - _btn_restart_here.disabled = true - _btn_restart_here.add_theme_font_size_override("font_size", 11) - _btn_restart_here.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - _btn_restart_here.pressed.connect(_on_restart_here_pressed) - _board.add_child(_btn_restart_here) - - # 40250 restart_town_button (10, 47), 180x26 - _btn_restart_town = Button.new() - _btn_restart_town.name = "RestartTownButton" - _btn_restart_town.position = Vector2(10, 47) - _btn_restart_town.size = Vector2(180, 26) - _btn_restart_town.text = "在村庄复活" - _btn_restart_town.add_theme_font_size_override("font_size", 11) - _btn_restart_town.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - _btn_restart_town.pressed.connect(_on_restart_town_pressed) - _board.add_child(_btn_restart_town) - - # 40250 uirestart.py: OnPressEscapeKey / OnPressExitKey returns True (禁止 ESC 关闭) - _root.gui_input.connect(func(event: InputEvent): - if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: - _root.accept_event() - ) - -func _on_restart_here_pressed() -> void: - if _here_cooldown_remaining > 0.0: - return - _restart("/restart_here") - -func _on_restart_town_pressed() -> void: - _restart("/restart_town") - -func _restart(cmd: String) -> void: - if client and client.has_method("say"): - client.say(0, cmd) diff --git a/project/ui/death_ui.gd.uid b/project/ui/death_ui.gd.uid deleted file mode 100644 index beb6b253..00000000 --- a/project/ui/death_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://crxoqgwj72u71 diff --git a/project/ui/dialogs.gd b/project/ui/dialogs.gd deleted file mode 100644 index 1dc281ba..00000000 --- a/project/ui/dialogs.gd +++ /dev/null @@ -1,84 +0,0 @@ -# Dialogs (P1) —— 基于 UiManager 的通用弹窗:confirm / alert / input。 -# -# var dlg := preload("res://ui/dialogs.gd").new(ui_manager) # ui_manager: UiManager -# dlg.confirm("确定要删除吗?", func(): do_delete()) -# dlg.alert("已保存") -# dlg.input("输入名字", func(t): rename(t), "默认值") -# -# 走 UiManager.open(root, modal=true):ESC / 取消 关闭,回调只在确认时触发。 -extends RefCounted - -var ui: CanvasLayer # UiManager - -func _init(ui_manager: CanvasLayer) -> void: - ui = ui_manager - -func alert(text: String, on_close: Callable = Callable()) -> void: - var d := _panel(320, 130) - _label(d, text, 20, 24, 280) - _button(d, "确定", 130, 88, func(): - ui.close(d) - if on_close.is_valid(): on_close.call()) - ui.open(d, true) - -func confirm(text: String, on_ok: Callable, on_cancel: Callable = Callable()) -> void: - var d := _panel(340, 140) - _label(d, text, 20, 22, 300) - _button(d, "确定", 60, 96, func(): - ui.close(d) - if on_ok.is_valid(): on_ok.call()) - _button(d, "取消", 190, 96, func(): - ui.close(d) - if on_cancel.is_valid(): on_cancel.call()) - ui.open(d, true) - -func input(prompt: String, on_submit: Callable, default_text := "") -> void: - var d := _panel(360, 150) - _label(d, prompt, 20, 18, 320) - var le := LineEdit.new() - le.text = default_text - le.position = Vector2(20, 52) - le.size = Vector2(320, 28) - d.add_child(le) - _button(d, "确定", 70, 104, func(): - var t := le.text - ui.close(d) - if on_submit.is_valid(): on_submit.call(t)) - _button(d, "取消", 200, 104, func(): ui.close(d)) - le.grab_focus() - ui.open(d, true) - -# --- 小工具 --------------------------------------------------------------- - -func _panel(w: int, h: int) -> Control: - var p := Panel.new() - p.custom_minimum_size = Vector2(w, h) - p.size = Vector2(w, h) - p.position = Vector2((1920 - w) / 2.0, (1080 - h) / 2.0) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.11, 0.12, 0.15, 0.97) - sb.border_color = Color(0.4, 0.36, 0.28) - sb.set_border_width_all(1) - sb.set_corner_radius_all(4) - p.add_theme_stylebox_override("panel", sb) - p.set_meta("is_titlebar", true) # 整块可拖 - return p - -func _label(parent: Control, text: String, x: int, y: int, w: int) -> Label: - var l := Label.new() - l.text = text - l.position = Vector2(x, y) - l.size = Vector2(w, 0) - l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - parent.add_child(l) - return l - -func _button(parent: Control, text: String, x: int, y: int, on_press: Callable) -> Button: - var b := Button.new() - b.text = text - b.position = Vector2(x, y) - b.size = Vector2(90, 26) - b.pressed.connect(on_press) - parent.add_child(b) - return b diff --git a/project/ui/dialogs.gd.uid b/project/ui/dialogs.gd.uid deleted file mode 100644 index 88e20ba9..00000000 --- a/project/ui/dialogs.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dtrft82p6n3br diff --git a/project/ui/dragon_soul_ui.gd b/project/ui/dragon_soul_ui.gd deleted file mode 100644 index dcbdd70a..00000000 --- a/project/ui/dragon_soul_ui.gd +++ /dev/null @@ -1,170 +0,0 @@ -# DragonSoulUI (M4) —— 龙魂精炼窗:`ds_window_open` 来时弹。 -# -# var du := preload("res://ui/dragon_soul_ui.gd").new() -# add_child(du) -# du.setup(m2client, canvas_parent, proto) # proto 可空(出名字) -# -# 三种模式:升级(0) / 改良(1) / 精炼(2) —— 对应 CG_DRAGON_SOUL_REFINE 的 -# DS_SUB_DO_UPGRADE / IMPROVEMENT / REFINE。 -# 打开时右键背包道具 → 填进 15 格中的下一个空位(grid[0] = 龙魂,其余为材料)。 -# [执行] → `M2Client.ds_refine(mode, cells)`;`ds_refine_result(ok, sub, cell)` → 提示。 -extends Node - -const SLOTS := 15 -const MODE_NAMES := ["升级", "改良", "精炼"] - -var client: Node -var proto: Node -var _root: Control -var _mode := 2 -var _cells: Array[int] = [] # 背包 cell,按加入顺序 -var _mode_btns: Array[Button] = [] -var _slot_labels: Array[Label] = [] -var _status: Label - -func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: - client = m2client - proto = proto_node - _build(parent) - if client.has_signal("ds_window_open"): - client.ds_window_open.connect(_on_open) - if client.has_signal("ds_refine_result"): - client.ds_refine_result.connect(_on_result) - -func is_open() -> bool: - return _root != null and _root.visible - -func toggle() -> void: - if _root: - _root.visible = not _root.visible - -func _on_open() -> void: - _cells.clear() - _refresh() - if _root: - _root.visible = true - -# 供 game_scene._on_inv_context 调用:把背包 cell 塞进下一个空格。 -func add_cell(window: int, cell: int) -> void: - if window != 1: # 仅背包(WINDOW_INVENTORY) - return - if _cells.has(cell): - _cells.erase(cell) # 再次右键 = 取出 - elif _cells.size() < SLOTS: - _cells.append(cell) - _refresh() - -func _name_of(vnum: int) -> String: - if vnum == 0: - return "-" - if proto and proto.has_method("item"): - var d: Dictionary = proto.item(vnum) - var n := String(d.get("locale_name", d.get("name", ""))) - if n != "": - return n - return "#%d" % vnum - -func _cell_text(i: int) -> String: - if i >= _cells.size(): - return "[%d] —" % i - var c: int = _cells[i] - var vnum := 0 - if client and client.has_method("get_item"): - var it: Dictionary = client.get_item(1, c) - vnum = int(it.get("vnum", 0)) - var tag := "龙魂" if i == 0 else "材料" - return "[%d] %s %s (格%d)" % [i, tag, _name_of(vnum), c] - -func _refresh() -> void: - for i in SLOTS: - _slot_labels[i].text = _cell_text(i) - for m in 3: - _mode_btns[m].disabled = (m == _mode) - if _status: - _status.text = "模式:%s 已放 %d/%d" % [MODE_NAMES[_mode], _cells.size(), SLOTS] - -func _set_mode(m: int) -> void: - _mode = clampi(m, 0, 2) - _refresh() - -func _do() -> void: - if client and client.has_method("ds_refine") and not _cells.is_empty(): - client.ds_refine(_mode, _cells) - -func _on_result(ok: bool, sub_type: int, cell: int) -> void: - if not _status: - return - if ok: - _cells.clear() - _refresh() - _status.text = ("✔ 精炼成功(格%d)" % cell) if ok else ("✘ 精炼失败(原因码 %d)" % sub_type) - -func _build(parent: Node) -> void: - _root = Control.new() - _root.set_anchors_preset(Control.PRESET_CENTER) - _root.position = Vector2(-190, -220) - _root.size = Vector2(380, 440) - _root.visible = false - _root.set_meta("is_titlebar", true) - parent.add_child(_root) - - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.06, 0.05, 0.09, 0.98) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - - var title := Label.new() - title.text = "龙魂精炼" - title.position = Vector2(12, 8) - _root.add_child(title) - - var modes := HBoxContainer.new() - modes.position = Vector2(14, 34) - modes.add_theme_constant_override("separation", 8) - _root.add_child(modes) - for m in 3: - var b := Button.new() - b.text = MODE_NAMES[m] - b.custom_minimum_size = Vector2(112, 28) - b.pressed.connect(_set_mode.bind(m)) - modes.add_child(b) - _mode_btns.append(b) - - var grid := VBoxContainer.new() - grid.position = Vector2(16, 74) - grid.add_theme_constant_override("separation", 2) - _root.add_child(grid) - for i in SLOTS: - var l := Label.new() - l.add_theme_font_size_override("font_size", 12) - grid.add_child(l) - _slot_labels.append(l) - - _status = Label.new() - _status.position = Vector2(16, 366) - _root.add_child(_status) - - var row := HBoxContainer.new() - row.position = Vector2(16, 394) - row.add_theme_constant_override("separation", 12) - _root.add_child(row) - var go := Button.new() - go.text = "执行" - go.custom_minimum_size = Vector2(150, 30) - go.pressed.connect(_do) - row.add_child(go) - var clr := Button.new() - clr.text = "清空" - clr.custom_minimum_size = Vector2(90, 30) - clr.pressed.connect(func(): _cells.clear(); _refresh()) - row.add_child(clr) - var cancel := Button.new() - cancel.text = "关闭" - cancel.custom_minimum_size = Vector2(90, 30) - cancel.pressed.connect(func(): _root.visible = false) - row.add_child(cancel) - - _refresh() diff --git a/project/ui/dragon_soul_ui.gd.uid b/project/ui/dragon_soul_ui.gd.uid deleted file mode 100644 index 916c445e..00000000 --- a/project/ui/dragon_soul_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ce0wnqedo0ga7 diff --git a/project/ui/dungeon_result_ui.gd b/project/ui/dungeon_result_ui.gd deleted file mode 100644 index 8bb71f17..00000000 --- a/project/ui/dungeon_result_ui.gd +++ /dev/null @@ -1,106 +0,0 @@ -# DungeonResultUI —— CPythonEventManager::ShowDungeonResult 的轻量 Godot 载体。 -# -# 参考端收到 [DUNGEON_RESULT] 后直接调用 interface.ShowDungeonResult,并把 -# 九个整数交给独立结算窗口。本组件只负责呈现这九个服务端字段,不修改副本 -# 状态,也不凭空发奖励;奖励仍由网络协议驱动。 -extends Node - -const RESULT_KEYS := [ - "killstone_count", "killmob_count", "find_hidden", "hidden_total", - "use_potion", "is_revived", "killallmob", "total_time", "bonus_exp", -] - -var _root: Control -var _rows: VBoxContainer -var _result: Dictionary = {} - -func setup(parent: Node) -> void: - if _root != null: - return - _root = Control.new() - _root.name = "DungeonResult" - _root.set_anchors_preset(Control.PRESET_CENTER) - _root.position = Vector2(-190, -175) - _root.size = Vector2(380, 350) - _root.visible = false - _root.mouse_filter = Control.MOUSE_FILTER_STOP - _root.z_index = 120 - parent.add_child(_root) - - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var style := StyleBoxFlat.new() - style.bg_color = Color(0.055, 0.045, 0.035, 0.98) - style.border_color = Color(0.78, 0.62, 0.30, 1.0) - style.set_border_width_all(2) - style.set_corner_radius_all(6) - panel.add_theme_stylebox_override("panel", style) - _root.add_child(panel) - - var title := Label.new() - title.text = "副本结算" - title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - title.position = Vector2(16, 14) - title.size = Vector2(348, 32) - title.add_theme_font_size_override("font_size", 20) - _root.add_child(title) - - _rows = VBoxContainer.new() - _rows.position = Vector2(34, 58) - _rows.size = Vector2(312, 218) - _rows.add_theme_constant_override("separation", 5) - _root.add_child(_rows) - - var close_button := Button.new() - close_button.text = "关闭" - close_button.position = Vector2(130, 300) - close_button.size = Vector2(120, 32) - close_button.pressed.connect(close) - _root.add_child(close_button) - -func show_result(result: Dictionary) -> void: - if _root == null: - return - _result = result.duplicate(true) - for child in _rows.get_children(): - child.queue_free() - for spec in _display_rows(): - var row := Label.new() - row.text = str(spec[0]) + ":" + str(spec[1]) - row.add_theme_font_size_override("font_size", 14) - _rows.add_child(row) - _root.visible = true - -func open(result: Dictionary) -> void: - show_result(result) - -func close() -> void: - if _root: - _root.visible = false - -func is_open() -> bool: - return _root != null and _root.visible - -func result() -> Dictionary: - return _result.duplicate(true) - -func _display_rows() -> Array: - var elapsed := maxi(0, int(_result.get("total_time", 0))) - var mins := elapsed / 60 - var secs := elapsed % 60 - return [ - ["击杀石碑", int(_result.get("killstone_count", 0))], - ["击杀怪物", int(_result.get("killmob_count", 0))], - ["隐藏目标", "%d / %d" % [int(_result.get("find_hidden", 0)), int(_result.get("hidden_total", 0))]], - ["使用药水", int(_result.get("use_potion", 0))], - ["复活次数", int(_result.get("is_revived", 0))], - ["完成条件", "已完成" if int(_result.get("killallmob", 0)) != 0 else "未完成"], - ["用时", "%02d:%02d" % [mins, secs]], - ["奖励经验", int(_result.get("bonus_exp", 0))], - ] - -func _unhandled_input(event: InputEvent) -> void: - if is_open() and event is InputEventKey and event.pressed \ - and not event.echo and event.keycode == KEY_ESCAPE: - close() - get_viewport().set_input_as_handled() diff --git a/project/ui/dungeon_result_ui.gd.uid b/project/ui/dungeon_result_ui.gd.uid deleted file mode 100644 index 91c02dc1..00000000 --- a/project/ui/dungeon_result_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bwp78bnxnfvvx diff --git a/project/ui/equip_model.gd b/project/ui/equip_model.gd deleted file mode 100644 index a2ccc692..00000000 --- a/project/ui/equip_model.gd +++ /dev/null @@ -1,601 +0,0 @@ -# EquipModel (P2) —— 装备变化 → Metin2Model 部件切换。 -# -# var em := preload("res://ui/equip_model.gd").new() -# add_child(em) -# em.setup(m2client, item_list, model_getter, assets_root, race) -# -# 监听 M2Client.inventory_changed(window == EQUIPMENT),读 get_equipment(): -# WEAR_WEAPON(4) -> item_list.model(vnum) 解析后的真实路径,按左右手分给 -# model.weapon_gr2(PART_WEAPON)/ model.shield_gr2(PART_WEAPON_LEFT) -# (ActorInstanceAttach.cpp AttachWeapon:匕首双手、弓左手、骑马的扇双手) -# WEAR_SHIELD(10)-> 不渲染(参考端没有盾牌挂件,item_list 的盾也没有模型) -# WEAR_HEAD(1) -> 有模型的头盔 = 覆盖发型槽(Metin2 约定:头防替换头发) -# WEAR_BODY(0) -> race_spec.shape(armor_shape_of(vnum)) -> model.gr2_path (+ 换肤) -# armor_model_map[vnum] 若给了则优先 -# -# 时装(costume):Metin2 不发独立的 costume 槽包,服务器把 costume 的 vnum 直接写进 -# 主角实体的 parts[] 数组(ARMOR/HAIR)。所以每个渲染槽优先取 parts[],为 0 才回退 -# get_equipment()。这样 costume「自动」生效,也是渲染别的玩家用的同一条路。 -# -# §4.1 修改 4(部位遮挡):解出身体 shape 后过 PartHiding —— 动物 / 怪物时装 -# (shape 100-103)抑制武器 + 头发;婚纱(shape 201)非捧花时抑制武器;变身 -# (seam,暂无实体字段)会把身体 shape 归 0 且清武器 / 头发。刷新顺序对齐 -# InstanceBase.cpp ChangeArmor:身体 → 头发 → 武器(→ 盾)。 -extends Node - -const RaceSpec = preload("res://ui/race_spec.gd") -const PartHiding = preload("res://part_hiding.gd") -const EffectRegistry = preload("res://fx/effect_registry.gd") -const MotionEffectAnchor = preload("res://fx/motion_effect_anchor.gd") - -const WEAR_BODY := 0 -const WEAR_HEAD := 1 -const WEAR_WEAPON := 4 -const WEAR_SHIELD := 10 -# CHR_EQUIPPART_*(parts[] 下标) -const PART_ARMOR := 0 -const PART_WEAPON := 1 -const PART_HEAD := 2 -const PART_HAIR := 3 -const CHR_EQUIPPART_HAIR := 3 # 兼容旧引用 -# item_length.h EWeaponSubTypes(CItemData::GetWeaponType = bSubType) -const WEAPON_SUB_DAGGER := 1 -const WEAPON_SUB_BOW := 2 -const WEAPON_SUB_TWO_HANDED := 3 -const WEAPON_SUB_BELL := 4 -const WEAPON_SUB_FAN := 5 -const WEAPON_SUB_ARROW := 6 -const ITEM_TYPE_WEAPON := 1 -const ITEM_TYPE_ARMOR := 2 -const ARMOR_BODY := 0 -const HAND_RIGHT := 1 -const HAND_LEFT := 2 -const CLASS_OF := ["warrior", "assassin", "sura", "shaman"] - -var client: Node -var item_list: RefCounted # ItemList -var proto: Node # Metin2Proto(有的话 armor shape 走 item_proto values[3]) -var assets_root := "" -var race := -1 -var _model_getter: Callable -var _spec: RefCounted # RaceSpec(惰性) -var _spec_tried := false -var _last_weapon_vnum := -1 -var _last_body_vnum := -1 -var _last_head_vnum := -2 # -1 是「发型被遮挡」的有效态,用 -2 当「未应用」哨兵 -var _last_weapon_hands := -1 -var _last_hair_part := -1 -var main_getter: Callable = Callable() # func() -> int:主角 vid(取 parts 用) -var _remote_mode := false -var _remote_parts: Array = [] # 远端 PC 的 awPart[ARMOR..HAIR] -# armor vnum -> race .msm 的 shape index。默认:有 proto 用 item_proto values[3] -# (对齐客户端 __ArmorVnumToShape,SHAPE_VALUE_SLOT_INDEX=3),否则回退 =vnum。应用方可覆写。 -var armor_shape_of: Callable = func(vnum: int) -> int: return _armor_shape_default(vnum) -# 应用方可注入:vnum -> 身体 gr2 路径(优先于 race_spec) -var armor_model_map := {} -var effect_registry: RefCounted -var _weapon_refine_fx: Array[Node] = [] -var _armor_refine_fx: Array[Node] = [] - -func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "", - race_index := -1) -> void: - client = m2client - item_list = il - _model_getter = model_getter - assets_root = assets - race = race_index - if client and client.has_signal("inventory_changed"): - client.inventory_changed.connect(_on_inv_changed) - if client and client.has_signal("entity_main_set"): - client.entity_main_set.connect(func(v): - if race < 0 and client.has_method("get_entity"): - race = int(client.get_entity(v).get("race", -1)) - refresh()) - # GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 改了主角 parts(换装 / 时装)-> 全刷 - if client and client.has_signal("entity_info"): - client.entity_info.connect(func(v, _d): - if main_getter.is_valid() and int(main_getter.call()) == v: - refresh()) - # 上 / 下马改变扇子的左右手(__IsLeftHandWeapon: FAN && IsMountingHorse) - if client and client.has_signal("mount_changed"): - client.mount_changed.connect(func(v): - if main_getter.is_valid() and int(main_getter.call()) == v: - refresh()) - -# 远端 PC 不读取本地 get_equipment();参考端直接消费 -# GC_CHAR_ADDITIONAL_INFO / GC_CHARACTER_UPDATE 的 awPart[4]。 -func setup_remote(il: RefCounted, model_getter: Callable, assets := "", - race_index := -1, parts := [], proto_node: Node = null) -> void: - client = null - item_list = il - _model_getter = model_getter - assets_root = assets - race = race_index - proto = proto_node - _remote_mode = true - _remote_parts = parts.duplicate() if parts is Array else [] - while _remote_parts.size() < 4: - _remote_parts.append(0) - if _remote_parts.size() > 4: - _remote_parts = _remote_parts.slice(0, 4) - refresh() - -func set_race(r: int) -> void: - race = r - _spec = null - _spec_tried = false - -func _on_inv_changed(window: int, _cell: int) -> void: - if window == 2: # mtnet::WINDOW_EQUIPMENT - refresh() - -func refresh() -> void: - var model: Node = _model_getter.call() if _model_getter.is_valid() else null - if model == null or (client == null and not _remote_mode): - return - var eq: Array = client.get_equipment() if client != null and client.has_method("get_equipment") else [] - if not _remote_mode and eq.size() <= WEAR_WEAPON: - return - var parts := _parts() # 主角 parts[](时装 / 别的玩家用);空 -> 全回退 eq - - # --- 身体:parts[ARMOR] 优先(普通盔甲或时装),为 0 回退 WEAR_BODY ------------- - var body_vnum := _eff(parts, PART_ARMOR, eq, WEAR_BODY) - # 遮挡判定用的 shape(对齐 __ArmorVnumToShape:proto values[3] 非 0,否则 =vnum) - var raw_shape := int(armor_shape_of.call(body_vnum)) if armor_shape_of.is_valid() else body_vnum - var is_poly := _is_poly() - if body_vnum != _last_body_vnum: - _last_body_vnum = body_vnum - _apply_body(model, body_vnum) - - # --- 头 / 发:动物·怪物时装(shape 100-103)或变身 -> 隐藏;否则头盔覆盖 / 发型 ---- - var hair_off := PartHiding.hair_hidden(raw_shape, is_poly) - var head_vnum := _eff(parts, PART_HEAD, eq, WEAR_HEAD) - var head_key := -1 if hair_off else head_vnum - if head_key != _last_head_vnum: - _last_head_vnum = head_key - if hair_off: - model.set("hair_gr2", "") - model.set("hair_skin", "") - _last_hair_part = -1 # 恢复时强制重挂发型 - else: - _apply_head_or_hair(model, head_vnum) - - # --- 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON;过 PartHiding 遮挡 --------- - var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON) - var wpn_eff := PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly) - var hands := _weapon_hands(wpn_eff) - var weapon_changed := wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands - if weapon_changed: - _last_weapon_vnum = wpn_eff - _last_weapon_hands = hands - # 左手用 GetSubModelThing():item .msm 加载在参考端被注释掉,恒为同一个 gr2 - var wpath := _resolve_weapon(wpn_eff) - model.set("weapon_gr2", wpath if hands & HAND_RIGHT else "") - model.set("shield_gr2", wpath if hands & HAND_LEFT else "") - # 40250 __IsWeaponTrace excludes bell/fan/bow and creates the trace only for - # the remaining weapon types. Keep this decision at the item-data boundary, - # not inside PlayerView where a mesh path alone cannot identify a bow/fan. - if model.has_method("set_weapon_trace_enabled"): - model.call("set_weapon_trace_enabled", _weapon_trace_enabled(wpn_eff)) - # SetArmor / SetWeapon both call __GetRefinedEffect. - _refresh_weapon_refine_effect(model, _last_weapon_vnum) - _refresh_armor_refine_effect(model, _last_body_vnum) - -# ActorInstanceAttach.cpp __IsRightHandWeapon / __IsLeftHandWeapon -> HAND_* 位掩码 -static func refine_effect_level(vnum: int, item: Dictionary) -> int: - if vnum <= 0 or item.is_empty(): - return 0 - var sockets := int(item.get("gain_socket_pct", 0)) - if sockets != 3: - return 0 - return maxi(vnum % 10, 0) - -static func refine_effect_specs(vnum: int, item: Dictionary) -> Array: - var out: Array = [] - if vnum <= 0 or item.is_empty(): - return out - var item_type := int(item.get("type", -1)) - var subtype := int(item.get("sub_type", -1)) - var level := refine_effect_level(vnum, item) - if item_type == ITEM_TYPE_WEAPON: - if level < 7 or level > 9: - return out - var stem := "sword_%d" % level - match subtype: - WEAPON_SUB_DAGGER: - out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem}) - out.append({"slot": "left", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem}) - WEAPON_SUB_FAN: - out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_f.mse" % stem}) - WEAPON_SUB_BOW: - out.append({"slot": "left", "effect": "d:/ymir work/pc/common/effect/sword/%s_b.mse" % stem}) - WEAPON_SUB_BELL, WEAPON_SUB_ARROW: - out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem}) - _: - # Sword and two-handed sword share the normal sword effect. - out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s.mse" % stem}) - elif item_type == ITEM_TYPE_ARMOR and subtype == ARMOR_BODY: - # 40250 always layers these two Blue Steel armour effects, in addition - # to the regular +7/+8/+9 body effect. - if vnum >= 12010 and vnum <= 12049: - out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor-4-2-1.mse"}) - out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor-4-2-2.mse"}) - if level >= 7 and level <= 9: - out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor_%d.mse" % level}) - return out - -func _item(vnum: int) -> Dictionary: - if vnum > 0 and proto and proto.has_method("item"): - return proto.item(vnum) - return {} - -func _ensure_effect_registry() -> RefCounted: - if effect_registry == null and assets_root != "": - effect_registry = EffectRegistry.new() - effect_registry.call("setup", assets_root) - return effect_registry - -func _clear_refine_effects(nodes: Array[Node]) -> void: - for node in nodes: - if is_instance_valid(node) and not node.is_queued_for_deletion(): - node.queue_free() - nodes.clear() - -func _effect_bone(model: Node, slot: String) -> String: - match slot: - "right": - if model and "_right_bone" in model and String(model.get("_right_bone")) != "": - return String(model.get("_right_bone")) - return "equip_right_hand" - "left": - if model and "_left_bone" in model and String(model.get("_left_bone")) != "": - return String(model.get("_left_bone")) - return "equip_left" - "body": return "Bip01" - return "" - -func _effect_part(model: Node, slot: String) -> Node3D: - if model == null: - return null - match slot: - "right": return model.find_child("Weapon", true, false) as Node3D - "left": return model.find_child("Shield", true, false) as Node3D - "body": return model as Node3D - return model as Node3D - -func _spawn_bone_refine(registry: RefCounted, model: Node, spec: Dictionary) -> Node: - # Weapons with a non-identity Granny grip pre-transform (for - # example 03150): it moves the particles away even though the mesh is held. - if not model is Node3D or not ("anim" in model): - return null - var animator: Variant = model.get("anim") - if not animator is Node or not animator.has_method("get_effect_bone_pose"): - return null - var bone := _effect_bone(model, String(spec.get("slot", ""))) - if bone == "": - return null - var effect: Node = registry.call("spawn", String(spec.get("effect", "")), - model as Node3D, false) - if effect == null: - return null - var anchor := MotionEffectAnchor.new() - effect.add_child(anchor) - var event := {"attaching": true, "following": true, "independent": false, - "bone": bone, "pos": Vector3.ZERO} - if anchor.configure(model as Node3D, event): - return effect - effect.free() - return null - -func _spawn_refine_specs(model: Node, specs: Array, output: Array[Node]) -> void: - var registry := _ensure_effect_registry() - if registry == null or not registry.has_method("spawn"): - return - for spec in specs: - var parent := _effect_part(model, String(spec.get("slot", ""))) - var effect: Node = _spawn_bone_refine(registry, model, spec) - if effect == null and parent != null: - effect = registry.call("spawn", String(spec.get("effect", "")), parent, false) - if effect: - output.append(effect) - -func _refresh_weapon_refine_effect(model: Node, vnum: int) -> void: - _clear_refine_effects(_weapon_refine_fx) - _spawn_refine_specs(model, refine_effect_specs(vnum, _item(vnum)), _weapon_refine_fx) - -func _refresh_armor_refine_effect(model: Node, vnum: int) -> void: - _clear_refine_effects(_armor_refine_fx) - _spawn_refine_specs(model, refine_effect_specs(vnum, _item(vnum)), _armor_refine_fx) - -# A RemotePlayerView is assembled before NetWorld adds it to the SceneTree. -# Re-run only the effect attachment after _ready so skeleton-following anchors -# can resolve global transforms; do not reload body/weapon geometry. -func refresh_refine_effects() -> void: - var model: Node = _model_getter.call() if _model_getter.is_valid() else null - if model == null: - return - _refresh_armor_refine_effect(model, _last_body_vnum) - _refresh_weapon_refine_effect(model, _last_weapon_vnum) - -func _weapon_hands(vnum: int) -> int: - if vnum == 0: - return 0 - var sub := -1 - if proto and proto.has_method("item"): - sub = int(proto.item(vnum).get("sub_type", -1)) - if sub == WEAPON_SUB_DAGGER or (sub == WEAPON_SUB_FAN and _is_mounting()): - return HAND_RIGHT | HAND_LEFT - if sub == WEAPON_SUB_BOW: - return HAND_LEFT - return HAND_RIGHT - -func _weapon_trace_enabled(vnum: int) -> bool: - if vnum <= 0: - return false - var item := _item(vnum) - # If item_proto is not ready yet, preserve the ordinary sword default. The - # authoritative refresh repeats this decision once proto is available. - if item.is_empty(): - return true - if int(item.get("type", -1)) != ITEM_TYPE_WEAPON: - return false - var subtype := int(item.get("sub_type", -1)) - return subtype != WEAPON_SUB_BELL and subtype != WEAPON_SUB_FAN and subtype != WEAPON_SUB_BOW - -# IsMountingHorse:主角实体 mount_vnum 非 0。远端 PC 暂无坐骑状态输入(seam)。 -func _is_mounting() -> bool: - if _remote_mode or not main_getter.is_valid() or client == null or not client.has_method("get_entity"): - return false - var vid: int = main_getter.call() - return vid != 0 and int(client.get_entity(vid).get("mount_vnum", 0)) != 0 - -# §4.1 修改 4 seam:40250 实体表暂无变身字段(m2_client.cpp entity dict 无 poly_race / -# polymorph)。一旦 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带上变身 race,这里改成读 -# client.get_entity(main_getter.call()).get("poly_race", 0) != 0 即可。 -func _is_poly() -> bool: - return false - -# 有效 vnum:主角 parts[part_idx] 非 0 就用它(时装 / 远端玩家),否则用装备槽 -func _eff(parts: Array, part_idx: int, eq: Array, wear_slot: int) -> int: - if parts.size() > part_idx and int(parts[part_idx]) != 0: - return int(parts[part_idx]) - return int(eq[wear_slot].get("vnum", 0)) if eq.size() > wear_slot else 0 - -func _parts() -> Array: - if _remote_mode: - return _remote_parts - if not main_getter.is_valid() or client == null or not client.has_method("get_entity"): - return [] - var vid: int = main_getter.call() - if vid == 0: - return [] - var p = client.get_entity(vid).get("parts", []) - return p if p is Array else [] - -func _apply_head_or_hair(model: Node, head_vnum: int) -> void: - # 头盔有模型 -> 用它替换发型(Metin2:头防走 hair 槽) - if head_vnum != 0: - var hm := _resolve_weapon(head_vnum) # item_list.model 解析,复用武器解析器 - if hm != "": - model.set("hair_gr2", hm) - model.set("hair_skin", "") - return - # 没头盔(或头盔无模型)-> 回到角色发型 - _apply_hair_from_parts(model, true) - -func _apply_hair_from_parts(model: Node, force := false) -> void: - if model == null: - return - var hp := _hair_part() - if hp < 0 or (hp == _last_hair_part and not force): - return - _last_hair_part = hp - # 时装假发 / 大编号 -> parts[HAIR] 是 item vnum,走 item_list.model(同头盔) - if hp >= 100 and item_list: - var wig := _resolve_weapon(hp) - if wig != "": - model.set("hair_gr2", wig) - model.set("hair_skin", "") - return - # 小编号 -> race .msm 的 hair shape index - var e := _spec_hair(hp) - if e.is_empty(): - return - var m := _resolve_spec_asset(e, String(e.get("model", ""))) - var ts := _resolve_spec_asset(e, String(e.get("target_skin", ""))) - if m != "": - model.set("hair_gr2", m) - if ts != "": - model.set("hair_skin", ts) - -func _hair_part() -> int: - if _remote_mode: - return int(_remote_parts[PART_HAIR]) if _remote_parts.size() > PART_HAIR else -1 - if not main_getter.is_valid() or client == null or not client.has_method("get_entity"): - return -1 - var vid: int = main_getter.call() - if vid == 0: - return -1 - var e: Dictionary = client.get_entity(vid) - var parts = e.get("parts", []) - if parts is Array and parts.size() > CHR_EQUIPPART_HAIR: - return int(parts[CHR_EQUIPPART_HAIR]) - return -1 - -func set_hair(index: int) -> void: - if _remote_mode: - _set_remote_part(PART_HAIR, index) - refresh() - return - var model: Node = _model_getter.call() if _model_getter.is_valid() else null - if model == null: - return - var e := _spec_hair(index) - if e.is_empty(): - return - var m := _resolve_spec_asset(e, String(e.get("model", ""))) - var ts := _resolve_spec_asset(e, String(e.get("target_skin", ""))) - if m != "": - model.set("hair_gr2", m) - if ts != "": - model.set("hair_skin", ts) - -func set_armor(index: int) -> void: - if not _remote_mode: - return - _set_remote_part(PART_ARMOR, index) - refresh() - -func set_weapon(index: int) -> void: - if not _remote_mode: - return - _set_remote_part(PART_WEAPON, index) - refresh() - -func set_head(index: int) -> void: - if not _remote_mode: - return - _set_remote_part(PART_HEAD, index) - refresh() - -func _set_remote_part(part: int, value: int) -> void: - while _remote_parts.size() <= part: - _remote_parts.append(0) - _remote_parts[part] = value - -# --- body ------------------------------------------------------------------ - -# armor vnum -> item_proto values[3](非 0);否则 =vnum(对齐 __ArmorVnumToShape) -func _armor_shape_default(vnum: int) -> int: - if vnum > 1 and proto and proto.has_method("item"): - var it: Dictionary = proto.item(vnum) - var v = it.get("values", []) - if not it.is_empty() and v is Array and v.size() > 3: - return int(v[3]) - return vnum - -func _armor_specular(vnum: int) -> int: - if proto and proto.has_method("item"): - return int(proto.item(vnum).get("specular", 0)) - return 0 - -func _apply_body(model: Node, vnum: int) -> void: - if model == null: - return - if model.has_method("clear_skin_textures"): - model.call("clear_skin_textures") - # 强化等级越高越亮(PARITY §2.7:fSpecular = bSpecular / 100,无高光时重置为 0) - var sp := _armor_specular(vnum) - model.set("specular_power", sp / 100.0 if sp > 0 else 0.0) - if armor_model_map.has(vnum): - model.set("gr2_path", String(armor_model_map[vnum])) - return - var shape_idx: int = armor_shape_of.call(vnum) if armor_shape_of.is_valid() else vnum - var e := _spec_shape(shape_idx) - if e.is_empty(): - return # 没有对应 shape:保持当前身体 - var body := _resolve_spec_asset(e, String(e.get("model", ""))) - if body != "": - model.set("gr2_path", body) - # 换肤(同模型不同 TargetSkin):按 40250 SetMaterialImagePointer 材质名映射 - var src: String = e.get("source_skin", "") - var tgt: String = e.get("target_skin", "") - if tgt != "" and tgt != src: - var t := _resolve_spec_asset(e, tgt) - var s := _resolve_spec_asset(e, src) - if model.has_method("set_skin_texture"): - if t != "": - model.call("set_skin_texture", s if s != "" else src, t) - if model.has_method("set_surface_texture"): - if t != "": - model.call("set_surface_texture", 0, t) - -func _spec_shape(idx: int) -> Dictionary: - _ensure_spec() - return _spec.shape(idx) if _spec else {} - -func _spec_hair(idx: int) -> Dictionary: - _ensure_spec() - return _spec.hair(idx) if _spec else {} - -func _ensure_spec() -> void: - if _spec_tried: - return - _spec_tried = true - if race < 0 or assets_root == "": - return - var cls: String = CLASS_OF[race & 3] - var suffix := "w" if race in [1, 3, 4, 6] else "m" - # 40250 的 root 包把 _.msm 放在 root/ 下(playersettingmodule.py chrmgr.LoadLocalRaceData("warrior_m.msm"));m2dev 放在 root/msm/。 - for rel in ["root/%s_%s.msm" % [cls, suffix], - "root/msm/%s_%s.msm" % [cls, suffix], - "PC/ymir work/pc/%s/%s.msm" % [cls, cls], - "pc2/ymir work/pc2/%s/%s.msm" % [cls, cls], - "season1/season1/pc/%s.msm" % cls]: - var p := _resolve_asset(rel) - if p != "": - var rs := RaceSpec.new() - if rs.load_file(p): - _spec = rs - return - -# --- 路径解析 ------------------------------------------------------------ - -# 用 race_spec 的一条 shape/hair 记录 + 一个相对文件(model / *_skin)解析绝对路径。 -# 依次试: 规格文件目录/ · PathName(去盘符)+扫散包 · 按 basename 扫 pc// 与 pc2// -func _resolve_spec_asset(e: Dictionary, rel: String) -> String: - if rel == "": - return "" - var sdir := String(e.get("spec_dir", "")) - if sdir != "": - var p := sdir.path_join(rel) - if FileAccess.file_exists(p): - return p - var byp := _resolve_asset(String(e.get("path", "")) + rel) - if byp != "": - return byp - # 规格覆盖层(例如 root/msm/warrior_m.msm)可能只给 Model 名而没有 - # PathName。按 basename 回退时除职业根目录外,还必须查 hair/;否则 - # HairIndex 1001..1012 能解析却加载失败,并静默保留 PlayerView 的 - # hair_1_1 默认模型,造成选角发型遮脸。 - var bn := rel.get_file() - if race >= 0: - var cls: String = CLASS_OF[race & 3] - for sub in [ - "PC/ymir work/pc/%s/%s" % [cls, bn], - "PC/ymir work/pc/%s/hair/%s" % [cls, bn], - "pc2/ymir work/pc2/%s/%s" % [cls, bn], - "pc2/ymir work/pc2/%s/hair/%s" % [cls, bn], - ]: - var q := _resolve_asset(sub) - if q != "": - return q - return "" - -func _resolve_weapon(vnum: int) -> String: - if vnum == 0 or item_list == null: - return "" - var vpath: String = item_list.model(vnum) - if vpath == "": - var base := (vnum / 10) * 10 - vpath = "d:/ymir work/item/weapon/%05d.gr2" % base - return _resolve_asset(vpath) - -# "d:/ymir work/..." 或散包相对路径 -> /(/)... -func _resolve_asset(vpath: String) -> String: - if vpath == "" or assets_root == "": - return "" - var rel := vpath.replace("\\", "/") - if rel.length() >= 2 and rel[1] == ":": - rel = rel.substr(2) - rel = rel.lstrip("/") - var direct := assets_root.path_join(rel) - if FileAccess.file_exists(direct): - return direct - var da := DirAccess.open(assets_root) - if da: - for sub in da.get_directories(): - var cand := assets_root.path_join(sub).path_join(rel) - if FileAccess.file_exists(cand): - return cand - return "" diff --git a/project/ui/equip_model.gd.uid b/project/ui/equip_model.gd.uid deleted file mode 100644 index dd9cae44..00000000 --- a/project/ui/equip_model.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://eol27bnby30t diff --git a/project/ui/exchange_ui.gd b/project/ui/exchange_ui.gd deleted file mode 100644 index 63af2050..00000000 --- a/project/ui/exchange_ui.gd +++ /dev/null @@ -1,529 +0,0 @@ -# ExchangeUI (P8) —— 交易 / 换货窗。 -# -# var xu := preload("res://ui/exchange_ui.gd").new() -# add_child(xu) -# xu.setup(m2client, canvas_parent, proto) # proto 可空 -# -# 两栏:我方 / 对方,各列道具行 + 金币。底部:金币输入 + [放金币]、[接受]、[取消]。 -# `exchange_changed` 刷新(active=false 时自动关闭)。 -# inventory_ui 在交易开着时右键道具 → 调 xu.offer(win, cell)。 -extends Node - -const ItemTooltip = preload("res://ui/item_tooltip.gd") -const ItemTooltipBridge = preload("res://ui/item_tooltip_bridge.gd") - -var client: Node -var proto: Node -var _tooltip_builder: RefCounted -var _tooltip_bridge: RefCounted -var ui: CanvasLayer -var _root: Control -var _self_box: VBoxContainer -var _peer_box: VBoxContainer -var _self_gold: Label -var _peer_gold: Label -var _status: Label -var _accept_btn: Button -var _gold_input: LineEdit -var _next_display := 0 -var _offered_cells := {} -var _self_grid: GridContainer -var _exchange_cells: Dictionary = {} -var item_mouse: Node -var _mobile_mode := false -var _mobile_inventory_scroll: ScrollContainer -var _mobile_inventory_list: VBoxContainer - -const EXCHANGE_SLOT_COUNT := 12 -const INVENTORY_WINDOW := 1 -const EXCHANGE_LIMIT_RANGE := 1000.0 -const ITEM_ANTIFLAG_GIVE := 1 << 13 - -var _accept_pending := false -var _anchor := Vector2.ZERO -var _anchor_valid := false - -# EXCHANGE_GC_ALREADY / LESS_GOLD 的线上的子头值(服务器 exchange.cpp)。 -# GC_END 在成功与取消两种结局都会发,成败文案只走 CHAT_TYPE_INFO 聊天通道。 -const NOTICE_ALREADY := 6 -const NOTICE_LESS_GOLD := 7 -var _was_active := false -var _notice_shown := false -var _end_note: Label -var _end_note_timer: Timer - -func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: - client = m2client - proto = proto_node - ui = parent as CanvasLayer - _tooltip_builder = ItemTooltip.new() - _tooltip_builder.setup(AssetRoot.path(), "en", proto) - _tooltip_bridge = ItemTooltipBridge.new() - _tooltip_bridge.setup(parent, proto, AssetRoot.path()) - _build(parent) - if client.has_signal("exchange_changed"): - client.exchange_changed.connect(refresh) - -func is_open() -> bool: - return _root != null and _root.visible - -func get_mobile_window() -> Control: - return _root - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - if _root: - _apply_mobile_layout() - _refresh_mobile_inventory() - -func close() -> void: - if _tooltip_bridge: - _tooltip_bridge.hide() - if item_mouse and item_mouse.has_method("unregister_owner"): - item_mouse.unregister_owner(self) - if item_mouse.has_method("is_attached") and item_mouse.is_attached(): - item_mouse.cancel() - if _root: - _root.visible = false - _next_display = 0 - _offered_cells.clear() - _accept_pending = false - _anchor_valid = false - if _gold_input: - _gold_input.text = "" - -func offer(inv_window: int, inv_cell: int) -> void: - if not is_open() or inv_window < 0 or inv_cell < 0: - return - var key := "%d:%d" % [inv_window, inv_cell] - if _offered_cells.has(key): - _status.text = "该物品已经放入交易" - return - if not _can_give(_item_at(inv_window, inv_cell)): - return - var x: Dictionary = client.get_exchange() - var used := {} - for item in x.get("self_items", []): - used[int(item.get("slot", -1))] = true - for i in 12: - if not used.has(i): - _next_display = i - break - if used.size() >= 12: - _status.text = "交易物品栏已满" - return - if client.exchange_add_item(inv_window, inv_cell, _next_display): - _offered_cells[key] = true - _next_display = (_next_display + 1) % 12 - _status.text = "" - _refresh_mobile_inventory() - else: - _status.text = "交易请求发送失败" - -func _name_of(vnum: int) -> String: - if proto and proto.has_method("item"): - var d: Dictionary = proto.item(vnum) - var n := String(d.get("locale_name", d.get("name", ""))) - if n != "": - return n - return "#%d" % vnum - -func _tooltip_for(item: Dictionary) -> String: - var vnum := int(item.get("vnum", 0)) - if _tooltip_builder: - var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} - return _tooltip_builder.format(vnum, int(item.get("count", 1)), pd, item) - return "%s\n#%d ×%d" % [_name_of(vnum), vnum, int(item.get("count", 1))] - -func refresh() -> void: - if client == null: - return - var x: Dictionary = client.get_exchange() - if not x.get("active", false): - if _was_active: - # 窗口开着时服务器关掉了交易(成功或取消,二者同发 GC_END)。 - _show_end_note(int(x.get("notice", 0))) - _was_active = false - _notice_shown = false - _root.visible = false - _next_display = 0 - _offered_cells.clear() - _accept_pending = false - _anchor_valid = false - _gold_input.text = "" - _status.text = "" - return - _was_active = true - _root.visible = true - _capture_anchor() - _render_self_grid(x.get("self_items", [])) - _fill(_self_box, x.get("self_items", [])) - _fill(_peer_box, x.get("peer_items", [])) - _self_gold.text = "金币: %d" % int(x.get("self_gold", 0)) - _peer_gold.text = "金币: %d" % int(x.get("peer_gold", 0)) - var me := bool(x.get("self_accept", false)) - var peer := bool(x.get("peer_accept", false)) - # The server clears self_accept when either side changes the offer. Do not - # invent a local transaction state: mirror the authoritative bit exactly. - _accept_pending = me - _accept_btn.text = "已接受 ✓" if me else "接受" - _accept_btn.disabled = me - _accept_btn.modulate = Color(0.5, 1, 0.5) if me else Color(1, 1, 1) - _root.get_node("PeerAccept").text = "对方: 已接受" if peer else "对方: 未接受" - var notice := int(x.get("notice", 0)) - if notice != 0 and not _notice_shown: - _notice_shown = true - _status.text = _notice_text(notice) - else: - _status.text = "" - _refresh_mobile_inventory() - -func _notice_text(code: int) -> String: - match code: - NOTICE_ALREADY: - return "对方已经在交易中" - NOTICE_LESS_GOLD: - return "金币不足" - return "" - -# 交易被服务器关闭时的一条短暂浮动提示(成败详情以 CHAT_TYPE_INFO 聊天为准)。 -func _show_end_note(code: int) -> void: - if _root == null or _root.get_parent() == null: - return - if _end_note == null: - _end_note = Label.new() - _end_note.add_theme_font_size_override("font_size", 15) - _end_note.set_anchors_preset(Control.PRESET_CENTER_TOP) - _end_note.position = Vector2(-110, 130) - _end_note.size = Vector2(220, 24) - _end_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _root.get_parent().add_child(_end_note) - _end_note_timer = Timer.new() - _end_note_timer.one_shot = true - _end_note_timer.timeout.connect(func(): if _end_note: _end_note.visible = false) - add_child(_end_note_timer) - var extra := _notice_text(code) - _end_note.text = ("交易结束(%s)" % extra) if extra != "" else "交易结束,结果见聊天提示" - _end_note.visible = true - _end_note_timer.start(5.0) - -func _apply_mobile_layout() -> void: - if _root == null: - return - if _mobile_mode: - # The inventory candidate list is part of the same hosted surface, so it - # scales and receives the same safe-area treatment as the trade window. - _root.size = Vector2(688, 400) - _root.position = Vector2(-344, -200) - if _mobile_inventory_scroll: - _mobile_inventory_scroll.position = Vector2(452, 54) - _mobile_inventory_scroll.size = Vector2(224, 280) - _mobile_inventory_scroll.visible = true - else: - _root.size = Vector2(440, 360) - _root.position = Vector2(-220, -180) - if _mobile_inventory_scroll: - _mobile_inventory_scroll.position = Vector2.ZERO - _mobile_inventory_scroll.size = Vector2.ZERO - _mobile_inventory_scroll.visible = false - -func _refresh_mobile_inventory() -> void: - if not _mobile_mode or _mobile_inventory_list == null or not is_open(): - return - for child in _mobile_inventory_list.get_children(): - child.queue_free() - var inventory: Array = client.get_inventory() if client and client.has_method("get_inventory") else [] - var shown := 0 - for item in inventory: - var cell := int(item.get("cell", -1)) - var vnum := int(item.get("vnum", 0)) - if cell < 0 or vnum <= 0: - continue - if _offered_cells.has("%d:%d" % [INVENTORY_WINDOW, cell]): - continue - var button := Button.new() - button.alignment = HORIZONTAL_ALIGNMENT_LEFT - button.text = "%s ×%d · 点按加入" % [_name_of(vnum), int(item.get("count", 1))] - button.custom_minimum_size = Vector2(214, 34) - if _tooltip_bridge: - _tooltip_bridge.bind(button, func() -> Dictionary: return item.duplicate(true)) - var captured_cell := cell - button.pressed.connect(func() -> void: offer(INVENTORY_WINDOW, captured_cell)) - _mobile_inventory_list.add_child(button) - shown += 1 - if shown == 0: - var empty := Label.new() - empty.text = "(没有可交易物品)" - empty.add_theme_font_size_override("font_size", 12) - _mobile_inventory_list.add_child(empty) - -func _item_at(inv_window: int, inv_cell: int) -> Dictionary: - if client == null: - return {} - if client.has_method("get_item"): - var direct: Dictionary = client.get_item(inv_window, inv_cell) - if not direct.is_empty(): - return direct - if inv_window == INVENTORY_WINDOW and client.has_method("get_inventory"): - for item in client.get_inventory(): - if int(item.get("cell", -1)) == inv_cell: - return item - if inv_window == 2 and client.has_method("get_equipment"): - var equipment: Array = client.get_equipment() - if inv_cell >= 0 and inv_cell < equipment.size(): - return equipment[inv_cell] - return {} - -func _can_give(item_data: Dictionary) -> bool: - var vnum := int(item_data.get("vnum", 0)) - var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} - var flags := int(item_data.get("anti_flags", 0)) | int(proto_data.get("anti_flags", 0)) - if (flags & ITEM_ANTIFLAG_GIVE) != 0: - _status.text = "该物品不可交易" - return false - return true - -func _render_self_grid(rows: Array) -> void: - if _self_grid == null: - return - for pos in _exchange_cells: - var cell: Button = _exchange_cells[pos] - cell.text = "" - cell.tooltip_text = "" - cell.set_meta("tooltip_item", {}) - cell.set_meta("vnum", 0) - cell.set_meta("count", 0) - for row in rows: - var pos := int(row.get("slot", -1)) - if not _exchange_cells.has(pos): - continue - var vnum := int(row.get("vnum", 0)) - var count := int(row.get("count", 1)) - var cell: Button = _exchange_cells[pos] - cell.text = "%s%s" % [_name_of(vnum).substr(0, 6), (" ×%d" % count) if count > 1 else ""] - cell.tooltip_text = "" - cell.set_meta("tooltip_item", row.duplicate(true)) - cell.set_meta("vnum", vnum) - cell.set_meta("count", count) - -func _drop_to_slot(payload: Dictionary, display_pos: int) -> bool: - if payload.is_empty() or display_pos < 0 or display_pos >= EXCHANGE_SLOT_COUNT: - return false - if bool(payload.get("money", false)): - if int(client.get_exchange().get("self_gold", 0)) > 0: - _status.text = "金币已经放入,不能修改" - return false - var amount := int(payload.get("count", 0)) - if amount <= 0 or not client.has_method("exchange_add_gold"): - return false - return client.exchange_add_gold(amount) - var source_window := int(payload.get("window", -1)) - var source_cell := int(payload.get("cell", -1)) - if source_window not in [INVENTORY_WINDOW, 2] or source_cell < 0: - return false - var item_data := payload.duplicate(true) - var source_data := _item_at(source_window, source_cell) - for key in source_data: - if not item_data.has(key): - item_data[key] = source_data[key] - if not _can_give(item_data): - return false - for row in client.get_exchange().get("self_items", []): - if int(row.get("slot", -1)) == display_pos: - return false - var key := "%d:%d" % [source_window, source_cell] - if _offered_cells.has(key): - return false - if not client.has_method("exchange_add_item") or not client.exchange_add_item( - source_window, source_cell, display_pos): - _status.text = "交易请求发送失败" - return false - _offered_cells[key] = true - _refresh_mobile_inventory() - return true - -func _fill(box: VBoxContainer, rows: Array) -> void: - for c in box.get_children(): - c.queue_free() - for r in rows: - var l := Label.new() - l.text = "%s ×%d" % [_name_of(int(r.get("vnum", 0))), int(r.get("count", 1))] - if _tooltip_bridge: - _tooltip_bridge.bind(l, func() -> Dictionary: return r.duplicate(true)) - l.add_theme_font_size_override("font_size", 12) - box.add_child(l) - -func _on_put_gold() -> void: - var current := int(client.get_exchange().get("self_gold", 0)) - if current > 0: - _status.text = "金币已经放入,不能修改" - return - var raw := _gold_input.text.strip_edges() - if raw == "" or not raw.is_valid_int(): - _status.text = "请输入有效金币数量" - return - var g := int(raw) - if g <= 0 or g > 9999999: - _status.text = "金币数量必须在 1~9999999 之间" - return - if client.has_method("exchange_add_gold") and client.exchange_add_gold(g): - _gold_input.text = "" - _status.text = "" - else: - _status.text = "金币请求发送失败" - -func _on_accept() -> void: - if _accept_pending or _accept_btn.disabled: - return - if client.has_method("exchange_accept") and client.exchange_accept(): - _accept_pending = true - _accept_btn.disabled = true - else: - _status.text = "接受请求发送失败" - -func _on_cancel() -> void: - if client and client.has_method("exchange_cancel"): - client.exchange_cancel() - close() - -func _capture_anchor() -> void: - if _anchor_valid or client == null or not client.has_method("get_main_vid") \ - or not client.has_method("get_entity"): - return - var entity: Dictionary = client.get_entity(int(client.get_main_vid())) - var p = entity.get("pos_cm", null) - if p is Vector3: - _anchor = Vector2(p.x, p.y) - _anchor_valid = true - elif entity.has("x") and entity.has("y"): - _anchor = Vector2(float(entity.get("x", 0)), float(entity.get("y", 0))) - _anchor_valid = true - -func _current_position() -> Variant: - if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): - return null - var entity: Dictionary = client.get_entity(int(client.get_main_vid())) - var p = entity.get("pos_cm", null) - if p is Vector3: - return Vector2(p.x, p.y) - if entity.has("x") and entity.has("y"): - return Vector2(float(entity.get("x", 0)), float(entity.get("y", 0))) - return null - -func _process(_dt: float) -> void: - if not is_open() or not _anchor_valid: - return - var current = _current_position() - if current is Vector2 and (absf(current.x - _anchor.x) > EXCHANGE_LIMIT_RANGE \ - or absf(current.y - _anchor.y) > EXCHANGE_LIMIT_RANGE): - if client.has_method("exchange_cancel"): - client.exchange_cancel() - close() - -func _build(parent: Node) -> void: - _root = Control.new() - _root.set_anchors_preset(Control.PRESET_CENTER) - _root.position = Vector2(-220, -180) - _root.size = Vector2(440, 360) - _root.visible = false - _root.set_meta("is_titlebar", true) - parent.add_child(_root) - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.07, 0.08, 0.1, 0.97) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - var title := Label.new() - title.text = "交易" - title.position = Vector2(12, 8) - _root.add_child(title) - - var sl := Label.new() - sl.text = "我方" - sl.position = Vector2(20, 34) - _root.add_child(sl) - _self_box = VBoxContainer.new() - _self_box.position = Vector2(20, 56) - _self_box.custom_minimum_size = Vector2(200, 0) - _root.add_child(_self_box) - _self_grid = GridContainer.new() - _self_grid.columns = 6 - _self_grid.position = Vector2(20, 138) - _self_grid.add_theme_constant_override("h_separation", 3) - _self_grid.add_theme_constant_override("v_separation", 3) - _root.add_child(_self_grid) - for pos in EXCHANGE_SLOT_COUNT: - var cell := Button.new() - cell.custom_minimum_size = Vector2(52, 32) - cell.add_theme_font_size_override("font_size", 8) - _self_grid.add_child(cell) - _exchange_cells[pos] = cell - if _tooltip_bridge: - cell.mouse_entered.connect(func(): _tooltip_bridge.show_item(cell.get_meta("tooltip_item", {}))) - cell.mouse_exited.connect(func(): _tooltip_bridge.hide()) - if item_mouse and item_mouse.has_method("register_target"): - item_mouse.register_target(cell, - func(payload: Dictionary): return _drop_to_slot(payload, pos), self) - _self_gold = Label.new() - _self_gold.text = "金币: 0" - _self_gold.position = Vector2(20, 250) - _root.add_child(_self_gold) - - var pl := Label.new() - pl.text = "对方" - pl.position = Vector2(240, 34) - _root.add_child(pl) - _peer_box = VBoxContainer.new() - _peer_box.position = Vector2(240, 56) - _peer_box.custom_minimum_size = Vector2(180, 0) - _root.add_child(_peer_box) - _peer_gold = Label.new() - _peer_gold.text = "金币: 0" - _peer_gold.position = Vector2(240, 250) - _root.add_child(_peer_gold) - _status = Label.new() - _status.position = Vector2(20, 276) - _status.add_theme_font_size_override("font_size", 11) - _status.add_theme_color_override("font_color", Color(1, 0.65, 0.55)) - _root.add_child(_status) - - var pa := Label.new() - pa.name = "PeerAccept" - pa.text = "对方: 未接受" - pa.position = Vector2(240, 272) - pa.add_theme_font_size_override("font_size", 12) - _root.add_child(pa) - - var bottom := HBoxContainer.new() - bottom.position = Vector2(20, 310) - _root.add_child(bottom) - _gold_input = LineEdit.new() - _gold_input.placeholder_text = "金币" - _gold_input.custom_minimum_size = Vector2(90, 0) - # Godot will open the native numeric keyboard on Android/iOS while the - # validation below remains authoritative for desktop and hardware keyboards. - _gold_input.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER - bottom.add_child(_gold_input) - var put := Button.new() - put.text = "放金币" - put.pressed.connect(_on_put_gold) - bottom.add_child(put) - _accept_btn = Button.new() - _accept_btn.text = "接受" - _accept_btn.pressed.connect(_on_accept) - bottom.add_child(_accept_btn) - var cancel := Button.new() - cancel.text = "取消" - cancel.pressed.connect(_on_cancel) - bottom.add_child(cancel) - - _mobile_inventory_scroll = ScrollContainer.new() - _mobile_inventory_scroll.name = "MobileInventory" - _mobile_inventory_scroll.visible = false - _mobile_inventory_list = VBoxContainer.new() - _mobile_inventory_list.add_theme_constant_override("separation", 4) - _mobile_inventory_scroll.add_child(_mobile_inventory_list) - _root.add_child(_mobile_inventory_scroll) - _apply_mobile_layout() diff --git a/project/ui/exchange_ui.gd.uid b/project/ui/exchange_ui.gd.uid deleted file mode 100644 index a6ec1ed1..00000000 --- a/project/ui/exchange_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dupx028in7ymk diff --git a/project/ui/friend_ui.gd b/project/ui/friend_ui.gd deleted file mode 100644 index 68dc92a8..00000000 --- a/project/ui/friend_ui.gd +++ /dev/null @@ -1,298 +0,0 @@ -# FriendUI (P8) —— 好友 / 私聊名单窗(M 键)。 -# -# var fu := preload("res://ui/friend_ui.gd").new() -# add_child(fu) -# fu.setup(m2client, canvas_parent) -# fu.toggle() # M 键 -# -# 每行 = 在线点 + 名字 + [删除]。底部输入框 + [添加好友]。 -# `friends_changed` 刷新。双击某行 → emit `whisper_to(name)`(chat_ui 可接)。 -extends Node - -signal whisper_to(name: String) - -var client: Node -var _root: Control -var _list: VBoxContainer -var _name_edit: LineEdit -var _invite_dialog: ConfirmationDialog -var _pending_invite := "" -var _mobile_mode := false -var _title: Label -var _bottom: HBoxContainer -var _add_button: Button - -func setup(m2client: Node, parent: Node) -> void: - client = m2client - _build(parent) - if client.has_signal("friends_changed"): - client.friends_changed.connect(refresh) - if client.has_signal("friend_invite_ask"): - client.friend_invite_ask.connect(_on_friend_invite) - # 原版 messenger 是三固定分组(uimessenger.py MessengerFriendGroup/GuildGroup/ - # FamilyGroup);公会与情侣组数据变化时同样刷新。 - for sig in ["guild_changed", "lover_changed"]: - if client.has_signal(sig): - client.connect(sig, refresh) - -func is_open() -> bool: - return _root != null and _root.visible - -func get_mobile_window() -> Control: - return _root - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - _apply_mobile_layout() - if _invite_dialog and _invite_dialog.visible: - _popup_invite_dialog() - -func close() -> void: - if _root: - _root.visible = false - if _invite_dialog: - _invite_dialog.hide() - -func toggle() -> void: - _root.visible = not _root.visible - if _root.visible: - refresh() - -func refresh() -> void: - if not is_open() or client == null: - return - for c in _list.get_children(): - c.queue_free() - # 三固定分组(对齐原版 MessengerWindow):好友 / 公会 / 情侣。 - _section("好友") - var friends: Array = client.get_friends() if client.has_method("get_friends") else [] - if friends.is_empty(): - var e := Label.new() - e.text = "(好友列表为空)" - e.add_theme_font_size_override("font_size", 11) - e.modulate = Color(0.6, 0.6, 0.6) - _list.add_child(e) - else: - for f in friends: - _list.add_child(_row(f)) - _section("公会") - if client.has_method("get_guild") and client.get_guild().get("in_guild", false) \ - and client.has_method("get_guild_members"): - var members: Array = client.get_guild_members() - members.sort_custom(func(a, b): - return int(a.get("grade", 99)) < int(b.get("grade", 99))) - for m in members: - _list.add_child(_guild_row(m)) - else: - var g := Label.new() - g.text = "(未加入公会)" - g.add_theme_font_size_override("font_size", 11) - g.modulate = Color(0.6, 0.6, 0.6) - _list.add_child(g) - _section("情侣") - if client.has_method("get_lover") and client.get_lover().get("valid", false): - _list.add_child(_lover_row(client.get_lover())) - else: - var l := Label.new() - l.text = "(暂无)" - l.add_theme_font_size_override("font_size", 11) - l.modulate = Color(0.6, 0.6, 0.6) - _list.add_child(l) - -func _section(title: String) -> void: - var h := Label.new() - h.text = "— %s —" % title - h.add_theme_font_size_override("font_size", 12) - h.modulate = Color(0.75, 0.82, 0.95) - _list.add_child(h) - -# 公会组成员:在线态来自 GC_GUILD_LOGIN/LOGOUT;点按同样发起私聊。 -func _guild_row(m: Dictionary) -> Control: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(640, 46) if _mobile_mode else Vector2(260, 0) - var dot := Label.new() - dot.text = "●" - dot.custom_minimum_size = Vector2(28, 42) if _mobile_mode else Vector2.ZERO - dot.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - dot.modulate = Color(0.4, 0.9, 0.4) if m.get("online", false) else Color(0.4, 0.4, 0.4) - row.add_child(dot) - var nm := Button.new() - nm.text = String(m.get("name", "?")) - nm.flat = true - nm.custom_minimum_size = Vector2(450, 42) if _mobile_mode else Vector2(150, 0) - nm.alignment = HORIZONTAL_ALIGNMENT_LEFT - nm.pressed.connect(func() -> void: whisper_to.emit(String(m.get("name", "")))) - row.add_child(nm) - if int(m.get("grade", 1)) == 0: - var lead := Label.new() - lead.text = "会长" - lead.modulate = Color(0.95, 0.8, 0.4) - lead.add_theme_font_size_override("font_size", 10) - row.add_child(lead) - return row - -# 情侣组成员:lover_login/logout/near/far(CHAT_TYPE_COMMAND,marriage.cpp)。 -func _lover_row(lover: Dictionary) -> Control: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(640, 46) if _mobile_mode else Vector2(260, 0) - var dot := Label.new() - dot.text = "♥" - dot.custom_minimum_size = Vector2(28, 42) if _mobile_mode else Vector2.ZERO - dot.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - dot.modulate = Color(0.95, 0.4, 0.55) if lover.get("online", false) else Color(0.4, 0.4, 0.4) - row.add_child(dot) - var nm := Button.new() - nm.text = String(lover.get("name", "?")) - nm.flat = true - nm.custom_minimum_size = Vector2(450, 42) if _mobile_mode else Vector2(150, 0) - nm.alignment = HORIZONTAL_ALIGNMENT_LEFT - nm.pressed.connect(func() -> void: whisper_to.emit(String(lover.get("name", "")))) - row.add_child(nm) - if lover.get("near", false): - var near := Label.new() - near.text = "在身边" - near.modulate = Color(1.0, 0.6, 0.7) - near.add_theme_font_size_override("font_size", 10) - row.add_child(near) - return row - -func _row(f: Dictionary) -> Control: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(640, 46) if _mobile_mode else Vector2(260, 0) - var dot := Label.new() - dot.text = "●" - dot.custom_minimum_size = Vector2(28, 42) if _mobile_mode else Vector2.ZERO - dot.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - dot.modulate = Color(0.4, 0.9, 0.4) if f.get("online", false) else Color(0.4, 0.4, 0.4) - row.add_child(dot) - var nm := Button.new() - nm.text = String(f.get("name", "?")) - nm.flat = true - nm.custom_minimum_size = Vector2(450, 42) if _mobile_mode else Vector2(150, 0) - nm.alignment = HORIZONTAL_ALIGNMENT_LEFT - nm.pressed.connect(func() -> void: whisper_to.emit(String(f.get("name", "")))) - row.add_child(nm) - if f.get("mobile", false): - var mobile := Label.new() - mobile.text = "手机" - mobile.modulate = Color(0.45, 0.8, 1.0) - mobile.add_theme_font_size_override("font_size", 10) - row.add_child(mobile) - var del := Button.new() - del.text = "×" - del.custom_minimum_size = Vector2(64, 42) if _mobile_mode else Vector2.ZERO - del.pressed.connect(func() -> void: client.remove_friend(String(f.get("name", "")))) - row.add_child(del) - return row - -func _on_add() -> void: - var nm := _name_edit.text.strip_edges() - if nm != "": - client.add_friend(nm) - _name_edit.clear() - -func _on_friend_invite(name: String) -> void: - if client == null or name.strip_edges() == "": - return - _pending_invite = name - _invite_dialog.dialog_text = "接受“%s”的好友请求?" % name - _popup_invite_dialog() - -func _answer_invite(accept: bool) -> void: - var name := _pending_invite - _pending_invite = "" - if _invite_dialog: - _invite_dialog.hide() - if name != "" and client != null and client.has_method("friend_answer"): - client.friend_answer(name, accept) - -func _build(parent: Node) -> void: - _root = Control.new() - _root.set_anchors_preset(Control.PRESET_CENTER) - _root.position = Vector2(-150, -180) - _root.size = Vector2(300, 360) - _root.visible = false - _root.set_meta("is_titlebar", true) - _root.set_meta("mobile_title", "好友") - parent.add_child(_root) - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.07, 0.08, 0.1, 0.96) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - _title = Label.new() - _title.text = "好友" - _title.position = Vector2(12, 8) - _root.add_child(_title) - _list = VBoxContainer.new() - _list.position = Vector2(12, 34) - _list.size = Vector2(276, 278) - _list.add_theme_constant_override("separation", 3) - _root.add_child(_list) - _bottom = HBoxContainer.new() - _bottom.position = Vector2(12, 320) - _bottom.custom_minimum_size = Vector2(276, 0) - _root.add_child(_bottom) - _name_edit = LineEdit.new() - _name_edit.placeholder_text = "角色名" - _name_edit.custom_minimum_size = Vector2(190, 0) - _bottom.add_child(_name_edit) - _add_button = Button.new() - _add_button.text = "添加" - _add_button.pressed.connect(_on_add) - _bottom.add_child(_add_button) - _invite_dialog = ConfirmationDialog.new() - _invite_dialog.name = "FriendInviteDialog" - _invite_dialog.title = "好友请求" - _invite_dialog.ok_button_text = "接受" - _invite_dialog.cancel_button_text = "拒绝" - _invite_dialog.confirmed.connect(func() -> void: _answer_invite(true)) - _invite_dialog.canceled.connect(func() -> void: _answer_invite(false)) - # Keep the dialog outside the toggleable friend panel: requests must still - # be visible when the friend list window itself is closed. - parent.add_child(_invite_dialog) - if parent.has_method("track_mobile_modal"): - parent.track_mobile_modal(_invite_dialog) - _apply_mobile_layout() - -func _popup_invite_dialog() -> void: - if not is_instance_valid(_invite_dialog): - return - if _mobile_mode: - # Native Window remains the owner of the confirmation signals, while its - # mobile footprint is large enough for landscape touch targets and the - # software keyboard-safe input area. - _invite_dialog.set_meta("mobile_title", "好友请求") - _invite_dialog.min_size = Vector2i(480, 220) - _invite_dialog.size = Vector2i(480, 220) - _invite_dialog.popup_centered(Vector2i(480, 220)) - else: - _invite_dialog.popup_centered() - -func _apply_mobile_layout() -> void: - if _root == null or _title == null or _bottom == null: - return - if _mobile_mode: - _root.size = Vector2(680, 344) - _root.position = Vector2(-340, -172) - _title.position = Vector2(18, 48) - _title.add_theme_font_size_override("font_size", 16) - _list.position = Vector2(18, 88) - _list.size = Vector2(644, 190) - _bottom.position = Vector2(18, 288) - _bottom.custom_minimum_size = Vector2(644, 44) - _name_edit.custom_minimum_size = Vector2(520, 44) - _add_button.custom_minimum_size = Vector2(112, 44) - else: - _root.size = Vector2(300, 360) - _root.position = Vector2(-150, -180) - _title.position = Vector2(12, 8) - _title.remove_theme_font_size_override("font_size") - _list.position = Vector2(12, 34) - _list.size = Vector2(276, 278) - _bottom.position = Vector2(12, 320) - _bottom.custom_minimum_size = Vector2(276, 0) - _name_edit.custom_minimum_size = Vector2(190, 0) - _add_button.custom_minimum_size = Vector2(0, 0) diff --git a/project/ui/friend_ui.gd.uid b/project/ui/friend_ui.gd.uid deleted file mode 100644 index 21f513f4..00000000 --- a/project/ui/friend_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d0hn8nlem82an diff --git a/project/ui/game_option_ui.gd b/project/ui/game_option_ui.gd deleted file mode 100644 index db901a1c..00000000 --- a/project/ui/game_option_ui.gd +++ /dev/null @@ -1,392 +0,0 @@ -# GameOptionUI (P11) —— 游戏设置窗(1:1 迁移 `assets/root/uigameoption.py` `OptionDialog`)。 -# -# var go := preload("res://ui/game_option_ui.gd").new() -# add_child(go) -# go.setup(ui_manager, m2client, assets_root) -# go.toggle() # 由 system_menu_ui 的 game_option_button 打开 -# -# 布局走真 `assets/uiscript/uiscript/gameoptiondialog.py`。绑定逐字对照 uigameoption.py: -# block_{exchange,party,guild,whisper,friend,party_request}_button (toggle) → -# `/setblockmode `(EBlockAction 位:1<<0..1<<5)—— 真聊天命令 -# pvp_{peace,revenge,guild,free} (radio) → `/pkmode {0,1,4,2}` —— 真聊天命令 -# name_color / target_board / view_chat / always_show_name / show_damage / salestext (radio) -# —— 客户端显示开关,持久化到 user://system_option.cfg [gameopt];渲染节点按需读取这些设置。 -extends Node - -const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd") -# Packet.h EBlockAction -const BLOCK_BITS := { - "block_exchange_button": 1 << 0, - "block_party_button": 1 << 1, - "block_guild_button": 1 << 2, - "block_whisper_button": 1 << 3, - "block_friend_button": 1 << 4, - "block_party_request_button": 1 << 5, -} -# uigameoption: peace /pkmode 0, revenge /pkmode 1, free /pkmode 2, guild /pkmode 4 -const PK_CMD := {"pvp_peace": 0, "pvp_revenge": 1, "pvp_free": 2, "pvp_guild": 4} - -# 客户端显示开关:{radio 名 -> [cfg key, 该按钮代表的值]} -const DISPLAY_RADIOS := { - "name_color_normal": ["name_color", 0], "name_color_empire": ["name_color", 1], - "target_board_no_view": ["target_board", 0], "target_board_view": ["target_board", 1], - "view_chat_on_button": ["view_chat", 1], "view_chat_off_button": ["view_chat", 0], - "always_show_name_on_button": ["always_show_name", 1], "always_show_name_off_button": ["always_show_name", 0], - "show_damage_on_button": ["show_damage", 1], "show_damage_off_button": ["show_damage", 0], - "salestext_on_button": ["salestext", 1], "salestext_off_button": ["salestext", 0], -} -const RADIO_GROUPS := [ - ["name_color_normal", "name_color_empire"], - ["target_board_no_view", "target_board_view"], - ["view_chat_on_button", "view_chat_off_button"], - ["always_show_name_on_button", "always_show_name_off_button"], - ["show_damage_on_button", "show_damage_off_button"], - ["salestext_on_button", "salestext_off_button"], -] - -const LABELS := { - "titlename": "游戏设置", "name_color": "名字颜色", "target_board": "目标框", - "pvp_mode": "PK 模式", "block": "屏蔽", "chat": "聊天显示", - "always_show_name": "总显示名字", "effect_on_off": "伤害数字", "salestext_on_off": "叫卖文字", - "name_color_normal": "普通", "name_color_empire": "阵营", - "target_board_no_view": "关", "target_board_view": "开", - "pvp_peace": "和平", "pvp_revenge": "反击", "pvp_guild": "帮会", "pvp_free": "自由", - "block_exchange_button": "交易", "block_party_button": "组队", "block_guild_button": "帮会", - "block_whisper_button": "密语", "block_friend_button": "好友", "block_party_request_button": "组队申请", - "view_chat_on_button": "开", "view_chat_off_button": "关", - "always_show_name_on_button": "开", "always_show_name_off_button": "关", - "show_damage_on_button": "开", "show_damage_off_button": "关", - "salestext_on_button": "开", "salestext_off_button": "关", -} - -const CFG_PATH := "user://system_option.cfg" - -# uigameoption RefreshAlwaysShowName / 名字色 / 伤害数字等:radio 选中后广播给渲染层。 -# key ∈ DISPLAY_RADIOS 的 cfg key(name_color / always_show_name / show_damage / ...)。 -signal display_option_changed(key: String, value: int) - -var ui: CanvasLayer -var client: Node -var assets_root := "" -var uiscript_dir := "" - -var _win: Dictionary = {} -var _cfg := ConfigFile.new() -var _block_mode := 0 # blockMode(本地跟踪 + 服务端回包同步) -var _display := {} # cfg [gameopt] 快照 -var _mobile_mode := false -var _mobile_root: Control - -static func config_path() -> String: - var override_dir := OS.get_environment("MT_CONFIG_DIR") - if override_dir != "": - return override_dir.path_join("system_option.cfg") - return OS.get_temp_dir().path_join("mtgodot_system_option.cfg") \ - if DisplayServer.get_name() == "headless" else CFG_PATH - -func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void: - ui = ui_manager - client = m2client - assets_root = assets - if assets_root == "" and ui and "assets_root" in ui: - assets_root = ui.assets_root - uiscript_dir = assets_root.path_join("uiscript/uiscript") - if not DirAccess.dir_exists_absolute(uiscript_dir): - uiscript_dir = assets_root.path_join("uiscript") - _cfg.load(config_path()) - for k in ["name_color", "target_board", "view_chat", "always_show_name", "show_damage", "salestext"]: - _display[k] = int(_cfg.get_value("gameopt", k, 1 if k in ["target_board", "view_chat", "always_show_name", "show_damage"] else 0)) - _block_mode = int(_cfg.get_value("gameopt", "block_mode", 0)) - if client and client.has_signal("block_mode_changed"): - client.block_mode_changed.connect(_on_server_block_mode) - if ui and ui.has_signal("window_closed"): - ui.window_closed.connect(func(w): - if not _win.is_empty() and _win.get("root") == w: - _win = {} - ) - -func is_open() -> bool: - if _mobile_mode: - return _mobile_root != null and is_instance_valid(_mobile_root) - return not _win.is_empty() and is_instance_valid(_win.get("root")) - -func set_mobile_mode(enabled: bool) -> void: - if not enabled and _mobile_root != null: - var old_mode := _mobile_mode - _mobile_mode = true - close() - _mobile_mode = old_mode - _mobile_mode = enabled - -# 渲染层开局回填用:cfg [gameopt] 里某显示开关的当前值(0/1),缺省同 setup() 的默认。 -func display_value(key: String) -> int: - return int(_display.get(key, 0)) - -func toggle() -> void: - if is_open(): close() - else: open() - -func close() -> void: - if _mobile_mode: - if _mobile_root and is_instance_valid(_mobile_root) and ui: - ui.close(_mobile_root) - _mobile_root = null - return - var r: Control = _win.get("root", null) - _win = {} - if r and is_instance_valid(r) and ui: - ui.close(r) - -func open() -> void: - if _mobile_mode: - _open_mobile() - return - if is_open(): - return - var path := uiscript_dir.path_join("gameoptiondialog.py") - if not FileAccess.file_exists(path): - push_warning("GameOptionUI: no gameoptiondialog.py at " + path) - return - _win = ui.open_script(path, assets_root, false, true) - if not is_open(): - return - set_center_position() - _relabel() - _wire() - _sync() - -## 1:1 对齐 40250 uigameoption.py: self.SetCenterPosition() -func set_center_position(offset := Vector2.ZERO) -> void: - var r: Control = _win.get("root", null) - if r == null or not is_instance_valid(r): - return - if ui and ui.has_method("center_window"): - ui.center_window(r, offset) - else: - var vp_sz := Vector2(1920, 1080) - var vp := r.get_viewport() - if vp: - var vrect := vp.get_visible_rect().size - if vrect.x > 300 and vrect.y > 300: - vp_sz = vrect - elif ui and "screen" in ui and ui.screen.x > 0: - vp_sz = Vector2(ui.screen) - var sz := r.size - if sz.x <= 0 or sz.y <= 0: - sz = r.custom_minimum_size - r.position = ((vp_sz - sz) / 2.0 + offset).round() - -func _open_mobile() -> void: - if is_open(): - return - _mobile_root = Control.new() - _mobile_root.name = "MobileGameOptionWindow" - _mobile_root.size = Vector2(700, 360) - _mobile_root.set_meta("mobile_title", "游戏设置") - _mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - panel.add_theme_stylebox_override("panel", _mobile_panel_style()) - _mobile_root.add_child(panel) - var scroll := ScrollContainer.new() - scroll.name = "MobileGameOptionScroll" - scroll.position = Vector2(14, 48) - scroll.size = Vector2(672, 298) - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - scroll.mouse_filter = Control.MOUSE_FILTER_STOP - _mobile_root.add_child(scroll) - var content := VBoxContainer.new() - content.custom_minimum_size = Vector2(650, 0) - content.add_theme_constant_override("separation", 8) - scroll.add_child(content) - _add_mobile_section_title(content, "屏蔽设置") - _add_mobile_block_buttons(content) - _add_mobile_section_title(content, "PK 模式") - _add_mobile_pk_radios(content) - _add_mobile_section_title(content, "显示设置") - _add_mobile_display_radios(content) - ui.open(_mobile_root) - -func _add_mobile_section_title(parent: VBoxContainer, text: String) -> void: - var title := Label.new() - title.text = text - title.custom_minimum_size = Vector2(640, 28) - title.add_theme_font_size_override("font_size", 14) - title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54)) - parent.add_child(title) - -func _add_mobile_block_buttons(parent: VBoxContainer) -> void: - var grid := GridContainer.new() - grid.columns = 3 - grid.custom_minimum_size = Vector2(640, 94) - grid.add_theme_constant_override("h_separation", 6) - grid.add_theme_constant_override("v_separation", 6) - parent.add_child(grid) - for name in BLOCK_BITS: - var button := Button.new() - button.text = String(LABELS.get(name, name)) - button.toggle_mode = true - button.button_pressed = (_block_mode & int(BLOCK_BITS[name])) != 0 - button.custom_minimum_size = Vector2(206, 42) - var bit := int(BLOCK_BITS[name]) - button.pressed.connect(func(): _toggle_block(bit)) - grid.add_child(button) - -func _add_mobile_pk_radios(parent: VBoxContainer) -> void: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(640, 44) - row.add_theme_constant_override("separation", 6) - var buttons: Array[Button] = [] - for name in ["pvp_peace", "pvp_revenge", "pvp_guild", "pvp_free"]: - var button := Button.new() - button.text = String(LABELS.get(name, name)) - button.toggle_mode = true - button.custom_minimum_size = Vector2(145, 42) - button.button_pressed = false - var picked: String = name - button.pressed.connect(func(): - for other in buttons.size(): - buttons[other].set_pressed_no_signal(buttons[other] == button) - if client and client.has_method("say"): - client.say(0, "/pkmode %d" % PK_CMD[picked])) - buttons.append(button) - row.add_child(button) - var current: Variant = _cfg.get_value("gameopt", "pk_mode", -1) - if int(current) in PK_CMD.values(): - for i in PK_CMD.keys().size(): - if int(PK_CMD[PK_CMD.keys()[i]]) == int(current): - buttons[i].button_pressed = true - parent.add_child(row) - -func _add_mobile_display_radios(parent: VBoxContainer) -> void: - for group in RADIO_GROUPS: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(640, 44) - row.add_theme_constant_override("separation", 6) - var key: String = String(DISPLAY_RADIOS[group[0]][0]) - var caption := Label.new() - caption.text = String(LABELS.get(key, key)) - caption.custom_minimum_size = Vector2(126, 40) - caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - caption.add_theme_font_size_override("font_size", 12) - row.add_child(caption) - var buttons: Array[Button] = [] - var current := int(_display.get(key, 0)) - for name in group: - var button := Button.new() - button.text = String(LABELS.get(name, name)) - button.toggle_mode = true - button.custom_minimum_size = Vector2(112, 42) - button.button_pressed = int(DISPLAY_RADIOS[name][1]) == current - var selected: String = name - button.pressed.connect(func(): - for other in buttons.size(): - buttons[other].set_pressed_no_signal(buttons[other] == button) - var spec: Array = DISPLAY_RADIOS[selected] - _display[String(spec[0])] = int(spec[1]) - _save() - display_option_changed.emit(String(spec[0]), int(spec[1]))) - buttons.append(button) - row.add_child(button) - parent.add_child(row) - -func _mobile_panel_style() -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = Color(0.04, 0.065, 0.1, 0.98) - style.border_color = Color(0.52, 0.68, 0.9, 0.86) - style.set_border_width_all(1) - style.set_corner_radius_all(8) - return style - -func _node(nm: String) -> Control: - if _win.is_empty(): - return null - var n = _win.get("nodes", {}).get(nm, null) - return n if n is Control else null - -func _relabel() -> void: - for nm in LABELS: - var n := _node(nm) - if n and n.has_method("set_text"): - n.set_text(LABELS[nm]) - -func _wire() -> void: - # 屏蔽 toggle → /setblockmode - for nm in BLOCK_BITS: - var b := _node(nm) - if b is BaseButton: - b.toggle_mode = true - var bit: int = BLOCK_BITS[nm] - b.pressed.connect(func(): _toggle_block(bit)) - # PK radio → /pkmode - _bind_radio(PK_CMD.keys(), func(nm): - if client and client.has_method("say"): - client.say(0, "/pkmode %d" % PK_CMD[nm])) # CHAT_TYPE_TALKING - # 显示开关 radio → 持久化 - for group in RADIO_GROUPS: - _bind_radio(group, func(nm): - var spec: Array = DISPLAY_RADIOS[nm] - _display[spec[0]] = int(spec[1]) - _save() - display_option_changed.emit(String(spec[0]), int(spec[1]))) - var tb := _node("titlebar") - if tb: - for x in tb.find_children("*", "BaseButton", true, false): - x.pressed.connect(close) - -func _bind_radio(names: Array, on_pick: Callable) -> void: - var boxes := {} - for nm: String in names: - boxes[nm] = _node(nm) - for nm: String in names: - var b = boxes[nm] - if not (b is BaseButton): - continue - b.toggle_mode = true - var picked: String = nm - b.pressed.connect(func(): - for other: String in names: - if boxes[other] is BaseButton: - boxes[other].set_pressed_no_signal(other == picked) - on_pick.call(picked)) - -func _toggle_block(bit: int) -> void: - _block_mode ^= bit - if client and client.has_method("say"): - client.say(0, "/setblockmode %d" % _block_mode) - _cfg.set_value("gameopt", "block_mode", _block_mode) - var err := _cfg.save(config_path()) - if err != OK: - push_warning("GameOptionUI: save failed (%s): %s" % [err, config_path()]) - _sync_block() - -# 开窗时把状态回填到控件 -func _sync() -> void: - _sync_block() - for group in RADIO_GROUPS: - var key: String = DISPLAY_RADIOS[group[0]][0] - var cur := int(_display.get(key, 0)) - for nm in group: - var b := _node(nm) - if b is BaseButton: - b.set_pressed_no_signal(int(DISPLAY_RADIOS[nm][1]) == cur) - -func _sync_block() -> void: - for nm in BLOCK_BITS: - var b := _node(nm) - if b is BaseButton: - b.set_pressed_no_signal((_block_mode & int(BLOCK_BITS[nm])) != 0) - -func _on_server_block_mode(mask: int) -> void: - _block_mode = maxi(0, mask) - _cfg.set_value("gameopt", "block_mode", _block_mode) - _cfg.save(config_path()) - _sync_block() - -func _save() -> void: - for k in _display: - _cfg.set_value("gameopt", k, int(_display[k])) - _cfg.set_value("gameopt", "block_mode", _block_mode) - var err := _cfg.save(config_path()) - if err != OK: - push_warning("GameOptionUI: save failed (%s): %s" % [err, config_path()]) diff --git a/project/ui/game_option_ui.gd.uid b/project/ui/game_option_ui.gd.uid deleted file mode 100644 index fb0e1841..00000000 --- a/project/ui/game_option_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ubb2dufk08vo diff --git a/project/ui/ground_items.gd b/project/ui/ground_items.gd deleted file mode 100644 index e9120c84..00000000 --- a/project/ui/ground_items.gd +++ /dev/null @@ -1,570 +0,0 @@ -# GroundItems (P2) —— 世界里的掉落物:GC_ITEM_GROUND_ADD/DEL → 小物体 + 名条 + 拾取。 -# -# var gi := preload("res://ui/ground_items.gd").new() -# add_child(gi) -# gi.setup(m2client, mount_node, player_getter, proto, item_list) -# # 按拾取键时: gi.try_pickup() -> CPythonItem::GetCloseItem(DISTANCE_APPROX <= 300cm) -# -# M2Client.ground_item_added(Dict{vid,vnum,pos,owner}) / ground_item_removed(vid) 驱动。 -extends Node - -signal cannot_pick_item(owner_name: String) - -const PICKUP_RANGE := 3.0 -# CPythonItem::GetCloseItem 的最终硬编码边界;参考端虽然从 Python 传入 300/500, -# 但 GetCloseItem 实现实际固定用 300(UserInterface/PythonItem.cpp:650)。 -const PICKUP_DISTANCE_CM := 300 -# CPythonPlayer::__OnPressItem -> CInstanceBase::NEW_IsClickableDistanceDestPixelPosition。 -const CLICK_PICKUP_DISTANCE_CM := 150 -const ITEM_ANTIFLAG_DROP := 1 << 7 -const ITEM_ANTIFLAG_GIVE := 1 << 13 - -# §8.8 seam ⑩(CPythonTextTail::ArrangeTextTail, PythonTextTail.cpp:153-190):多条掉落 -# 名条投影到屏幕后互相挤开防重叠。参考端在屏幕像素空间跑(y 向下),poc 每物一个 -# billboard Label3D —— 把名条世界锚点投影到屏幕、按 Label3D 的真实字体度量取文本框,跑 -# text_tail_arrange.arrange_item_tails 得到每条被下推的屏幕 y,再按 Label3D 的 -# pixel_size 折回世界 y(屏幕向下 Δpx → 世界向下 Δpx*pixel_size)。 -# 文本框宽 / 高取名条 Label3D 的真实字体度量(TextMetrics.label_width / label_height -# = GetTextSize 的 w / h,1:1,增量 123 / 124;TAG_CHAR_W_PX / TAG_LINE_H_PX 仅作无字体兜底)。 -# 增量 125(SetItemTextTailOwner, PythonTextTail.cpp:706-737):有 owner 的掉落,参考端 -# owner 是与名字分开的 pOwnerTextInstance,单独 GetTextSize,令去重叠框 yEnd += ownerH + 4、 -# x 两边按 owner 半宽 ±1 外扩 —— 去重叠框把「名字」「owner + 所有格」两子串分别用 tag 的 -# 字体度量后走 TextTailArrange.item_box_with_owner 合成(框度量 1:1)。 -# 增量 126(SetItemTextTailOwner :712-745 + ArrangeTextTail :191-207):owner 后缀不再拼进 -# 名字串,而是 drop 节点下第二个 Label3D "owner_tag" —— CENTER 对齐、黄字 (1,1,0)、文本 -# = owner + strOwnership(:722,本地化语素为空时字面 "'s",经 setup(possessive) 注入); -# owner 落在尾标基点、名字实例下移 15px(:200 pTextInstance->SetPosition(x, y + 15.0f, z)); -# owner 为空时删 owner_tag(:741-745)。名字色仍走 _process 远近着色(参考名字实例取 -# pInsertTextTail->Color 的道具品阶色)。 -const TextTailArrange = preload("res://text_tail_arrange.gd") -const TextMetrics = preload("res://text_metrics.gd") -const TAG_BASE_Y := 0.75 # 名条相对掉落物的默认局部 y(未重叠时) -const TAG_CHAR_W_PX := 7.0 # 无字体时的每字符估宽兜底(有字体走 GetTextSize 的 w) -const TAG_LINE_H_PX := 15.0 # 无字体时的单行估高兜底(有字体走 GetTextSize 的 h) -const OWNER_POSSESSIVE := "'s" # SetItemTextTailOwner :722 strOwnership(IDS_POSSESSIVE_MORPHENE 为空时的字面值) -const OWNER_NAME_GAP_PX := 15.0 # ArrangeTextTail :200 pTextInstance->SetPosition(x, y + 15.0f, z):owner 行下方的名字行偏移 -const DEFAULT_ITEM_COLOR := Color(1.0, 0.9, 0.5) -const ITEM_GRADE_COLORS := { - 0: Color(1.0, 1.0, 1.0), - 1: Color(0.55, 0.85, 1.0), - 2: Color(1.0, 0.85, 0.25), - 3: Color(0.85, 0.45, 1.0), - 4: Color(1.0, 0.35, 0.35), -} - -var client: Node -var mount: Node3D -var proto: Node -var item_list: RefCounted -var _player_getter: Callable -var _camera_getter: Callable -var _world_getter: Callable -var _world: Node -var _by_vid := {} # vid -> Node3D -var _next_tcp_time_ms := 0 - -func get_pickable_distance() -> float: - var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null - if p and bool(p.get_meta("mounted", false)): - return 5.0 - return PICKUP_RANGE - -func _get_main_player_name() -> String: - if client == null: - return "" - if client.has_method("get_main_vid") and client.has_method("get_entity"): - var vid := int(client.get_main_vid()) - if vid > 0: - var e: Dictionary = client.get_entity(vid) - var n := String(e.get("name", "")) - if n != "": - return n - if client.has_method("get_character_name"): - return String(client.get_character_name()) - return "" - -func _is_party_member_by_name(name: String) -> bool: - if name == "" or client == null or not client.has_method("get_party"): - return false - var party: Variant = client.get_party() - if not (party is Array): - return false - for member in party: - if member is Dictionary and String(member.get("name", "")) == name: - return true - return false - -func _item_anti_flags(vnum: int, d: Dictionary) -> int: - if d.has("anti_flags"): - return int(d.get("anti_flags", 0)) - if proto and proto.has_method("item"): - var pd: Dictionary = proto.item(vnum) - return int(pd.get("anti_flags", 0)) - return 0 - -# CPythonPlayer::SendClickItemPacket:自己的物品直接允许;其他所有者只有在 -# 属于本队且物品没有 ITEM_ANTIFLAG_DROP / ITEM_ANTIFLAG_GIVE 时允许。 -func _can_pick_owned_item(owner: String, node: Node3D) -> bool: - if owner == "": - return true - var my_name := _get_main_player_name() - if owner == my_name: - return true - if not _is_party_member_by_name(owner): - return false - var anti_flags := int(node.get_meta("item_anti_flags", 0)) - return (anti_flags & (ITEM_ANTIFLAG_DROP | ITEM_ANTIFLAG_GIVE)) == 0 - -func _distance_approx_cm(a: Vector3, b: Vector3) -> int: - # 40250 DISTANCE_APPROX 使用地图平面 x/y;Godot 的世界平面对应 x/z。 - var dx := int(roundf(absf((a.x - b.x) * 100.0))) - var dy := int(roundf(absf((a.z - b.z) * 100.0))) - var min_d: int = dx if dx < dy else dy - var max_d: int = dy if dx < dy else dx - return (((max_d << 8) + (max_d << 3) - (max_d << 4) - (max_d << 1) + - (min_d << 7) - (min_d << 5) + (min_d << 3) - (min_d << 1)) >> 8) - -func _node_world_position(node: Node3D) -> Vector3: - return node.global_position if node.is_inside_tree() else node.position - -func _on_cannot_pick_item(owner: String) -> void: - cannot_pick_item.emit(owner) - print("[拾取] 这不是你的物品,无法拾取!(所有者: %s)" % owner) - -func _is_rare_drop(vnum: int, d: Dictionary) -> bool: - if vnum == 1: - return false - if vnum in [25040, 50513, 70024, 70031]: - return true - if proto and proto.has_method("item"): - var pd: Dictionary = proto.item(vnum) - var itype := int(pd.get("type", 0)) - if itype in [1, 2] and (vnum % 10) >= 7: - return true - if int(d.get("rarity", 0)) >= 2 or int(d.get("grade", 0)) >= 3: - return true - return false - -var assets_override := "" -var _possessive := OWNER_POSSESSIVE # setup(possessive) 注入;空 -> 字面 "'s"(SetItemTextTailOwner :722) - -func setup(m2client: Node, mount_node: Node3D, player_getter: Callable, - proto_node: Node = null, il: RefCounted = null, - camera_getter: Callable = Callable(), possessive := "", - world_getter: Callable = Callable()) -> void: - client = m2client - _possessive = possessive if possessive != "" else OWNER_POSSESSIVE # :722 == "" ? "'s" : loc - mount = mount_node - proto = proto_node - item_list = il - _player_getter = player_getter - _camera_getter = camera_getter - _world_getter = world_getter - if client.has_signal("ground_item_added"): - client.ground_item_added.connect(_on_added) - if client.has_signal("ground_item_removed"): - client.ground_item_removed.connect(_on_removed) - # 已在场的(重连) - if client.has_method("get_ground_items"): - for d in client.get_ground_items(): - _on_added(d) - -func _to_world_pos(raw_pos: Variant) -> Vector3: - if not (raw_pos is Vector3): - return Vector3.ZERO - var pos: Vector3 = MapCoord.to_world(raw_pos) - var w: Node = _world_getter.call() if _world_getter.is_valid() else _world - if w and w.has_method("sample_height"): - pos.y = float(w.call("sample_height", pos.x, pos.z)) + 0.1 - return pos - -func _on_added(d: Dictionary) -> void: - var vid := int(d.get("vid", 0)) - if vid == 0: - return - var vnum := int(d.get("vnum", 0)) - var owner := String(d.get("owner", "")) - var item_color := _item_color(vnum, d) - var pos := _to_world_pos(d.get("pos", Vector3.ZERO)) - if _by_vid.has(vid): - var existing: Node3D = _by_vid[vid] - if is_instance_valid(existing): - existing.position = pos - var existing_tag := existing.get_node("tag") as Label3D - existing_tag.text = _name_for(vnum) - existing_tag.modulate = item_color - existing.set_meta("item_name", existing_tag.text) - existing.set_meta("item_owner", owner) - existing.set_meta("item_color", item_color) - existing.set_meta("item_anti_flags", _item_anti_flags(vnum, d)) - _apply_owner_tag(existing, owner, item_color) - return - var node := Node3D.new() - node.name = "drop_%d" % vid - node.position = pos - - var root := "" if assets_override == "__none__" else (assets_override if assets_override != "" else (AssetRoot.path() if AssetRoot.available() else "")) - var model_path := "" - if root != "": - if vnum == 1: - for p in ["item/ymir work/item/etc/money.gr2", "item/etc/money.gr2"]: - var fp := root.path_join(p) - if FileAccess.file_exists(fp): - model_path = fp - break - else: - for p in ["item/ymir work/item/etc/item_bag.gr2", "item/etc/item_bag.gr2"]: - var fp := root.path_join(p) - if FileAccess.file_exists(fp): - model_path = fp - break - - if model_path != "" and ClassDB.class_exists("Metin2Model"): - var model: Node3D = ClassDB.instantiate("Metin2Model") - model.name = "model" - model.set("flip_winding", true) - model.set("texture_dir", model_path.get_base_dir()) - model.set("gr2_path", model_path) - node.add_child(model) - else: - var mesh := MeshInstance3D.new() - var box := BoxMesh.new() - box.size = Vector3(0.25, 0.25, 0.25) - mesh.mesh = box - mesh.position.y = 0.15 - var mat := StandardMaterial3D.new() - if vnum == 1: - mat.albedo_color = Color(1.0, 0.85, 0.2) - mat.metallic = 0.8 - mat.roughness = 0.2 - mat.emission_enabled = true - mat.emission = Color(0.5, 0.4, 0.1) - else: - mat.albedo_color = Color(1.0, 0.85, 0.3) - mat.emission_enabled = true - mat.emission = Color(0.6, 0.5, 0.1) - mesh.material_override = mat - node.add_child(mesh) - - var tag := Label3D.new() - tag.name = "tag" - tag.text = _name_for(vnum) # 名字实例只放名字,owner 走独立 owner_tag(:723 不再拼串) - tag.position.y = TAG_BASE_Y - tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED - tag.no_depth_test = true - tag.pixel_size = 0.005 - tag.modulate = item_color - node.add_child(tag) - node.set_meta("vid", vid) - node.set_meta("item_name", tag.text) - node.set_meta("item_owner", owner) - node.set_meta("item_color", item_color) - node.set_meta("item_vnum", vnum) - node.set_meta("item_anti_flags", _item_anti_flags(vnum, d)) - _apply_owner_tag(node, owner, item_color) - if _is_rare_drop(vnum, d): - var beam := MeshInstance3D.new() - beam.name = "light_beam" - var cyl := CylinderMesh.new() - cyl.top_radius = 0.08 - cyl.bottom_radius = 0.12 - cyl.height = 3.5 - beam.mesh = cyl - beam.position.y = 1.75 - var bmat := StandardMaterial3D.new() - bmat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - bmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - bmat.albedo_color = Color(1.0, 0.85, 0.2, 0.45) - bmat.emission_enabled = true - bmat.emission = Color(1.0, 0.85, 0.3) - beam.material_override = bmat - node.add_child(beam) - mount.add_child(node) - _by_vid[vid] = node - -func _on_removed(vid: int) -> void: - var n: Node3D = _by_vid.get(vid, null) - if n: - _by_vid.erase(vid) - n.queue_free() - -func clear_for_map_change() -> void: - for vid in _by_vid.keys(): - var node: Node3D = _by_vid[vid] - if is_instance_valid(node): - node.queue_free() - _by_vid.clear() - -func _process(dt: float) -> void: - var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null - if p == null: - return - var t := Time.get_ticks_msec() / 1000.0 - for vid in _by_vid: - var n: Node3D = _by_vid[vid] - if is_instance_valid(n): - var m := n.get_child(0) as MeshInstance3D - if m: - m.rotation.y = t * 2.0 - # 近了名条高亮 - var near := _distance_approx_cm(p.global_position, n.global_position) <= PICKUP_DISTANCE_CM - (n.get_node("tag") as Label3D).modulate = Color(0.4, 1.0, 0.4) if near else Color(1.0, 0.9, 0.5) - _arrange_tags() - -# ArrangeTextTail :153-190 的 item 块:把所有掉落名条投影到屏幕,跑 isIn 去重叠, -# 再把被下推的屏幕 Δy 折回各 Label3D 的世界局部 y。无相机 / 少于 2 条时全部回默认。 -func _arrange_tags() -> void: - var cam: Camera3D = _camera_getter.call() if _camera_getter.is_valid() else null - if cam == null: - return - # m_ItemTextTailList 顺序:按 vid 排序保证稳定(≈ 注册先后)。 - var vids: Array = _by_vid.keys() - vids.sort() - var tags: Array = [] # 与 boxes 平行:{tag, base_screen_y} - var boxes: Array = [] - for vid in vids: - var n: Node3D = _by_vid[vid] - if not is_instance_valid(n): - continue - var tag := n.get_node_or_null("tag") as Label3D - if tag == null: - continue - var anchor := n.global_position + Vector3(0, TAG_BASE_Y, 0) - if cam.is_position_behind(anchor): - _place_tail(n, tag, 0.0) - continue - var sp := cam.unproject_position(anchor) - tags.append({"n": n, "tag": tag, "y0": sp.y}) - # 名字子串 + owner 子串分开度量(SetItemTextTailOwner 的 pOwnerTextInstance), - # 再走 item_box_with_owner 合成去重叠框(yEnd += ownerH + 4)。 - var iname := String(n.get_meta("item_name", tag.text)) - var iowner := String(n.get_meta("item_owner", "")) - var owner_txt := (iowner + _possessive) if iowner != "" else "" - var nbox: Dictionary = TextTailArrange.item_box_with_owner( - TextMetrics.string_width_like(tag, iname, TAG_CHAR_W_PX), - TextMetrics.string_height_like(tag, iname, TAG_LINE_H_PX), - TextMetrics.string_width_like(tag, owner_txt, TAG_CHAR_W_PX), - TextMetrics.string_height_like(tag, owner_txt, TAG_LINE_H_PX)) - boxes.append({ - "x": sp.x, "y": sp.y, - "w": maxf(1.0, nbox["w"]), - "h": maxf(1.0, nbox["h"]), - }) - if boxes.size() < 2: - if boxes.size() == 1: - _place_tail(tags[0]["n"], tags[0]["tag"], 0.0) - return - var arranged: Array = TextTailArrange.arrange_item_tails(boxes) - for i in tags.size(): - var d_px: float = arranged[i] - float(tags[i]["y0"]) # 屏幕向下为正 - _place_tail(tags[i]["n"], tags[i]["tag"], d_px) - -# ArrangeTextTail :191-207:owner 存在时 owner 实例落在尾标基点 (x, y)、名字实例下移 15px -# (:200 pTextInstance->SetPosition(x, y + 15.0f, z));无 owner 时名字实例落在基点 (:206)。 -# d_px = 去重叠把该尾标下推的屏幕像素(0 = 未被推)。屏幕向下 → 世界向下(* pixel_size)。 -func _place_tail(n: Node3D, tag: Label3D, d_px: float) -> void: - var px: float = tag.pixel_size - var owner_tag := n.get_node_or_null("owner_tag") as Label3D - if owner_tag != null: - owner_tag.position.y = TAG_BASE_Y - d_px * px - tag.position.y = TAG_BASE_Y - (d_px + OWNER_NAME_GAP_PX) * px - else: - tag.position.y = TAG_BASE_Y - d_px * px - -# 移动端情境按钮用它判断附近是否有可拾取物,避免常驻占用 HUD。 -func has_nearby_item() -> bool: - var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null - if p == null: - return false - var p_pos: Vector3 = p.global_position if p.is_inside_tree() else p.position - for node in _by_vid.values(): - if not is_instance_valid(node): - continue - var n_pos: Vector3 = node.global_position if node.is_inside_tree() else node.position - if _distance_approx_cm(p_pos, n_pos) <= PICKUP_DISTANCE_CM: - return true - return false - -# 40250 1:1 PickCloseItem + SendClickItemPacket:500ms 节流、DISTANCE_APPROX、 -# 同距最高 VID 以及队伍 / anti-flag 所有权保护。 -func try_pickup() -> int: - var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null - if p == null or client == null: - return 0 - var now_ms := Time.get_ticks_msec() - if now_ms < _next_tcp_time_ms: - return 0 - - var p_pos: Vector3 = p.global_position if p.is_inside_tree() else p.position - var best_vid := 0 - var best_d := PICKUP_DISTANCE_CM + 1 - var best_node: Node3D = null - # std::map 按 VID 升序遍历;使用 <= 后,同距时后遍历的最高 VID 胜出。 - var vids: Array = _by_vid.keys() - vids.sort() - for vid in vids: - var n: Node3D = _by_vid[vid] - if not is_instance_valid(n): - continue - var d := _distance_approx_cm(p_pos, _node_world_position(n)) - if d <= PICKUP_DISTANCE_CM and d <= best_d: - best_d = d - best_vid = int(vid) - best_node = n - - if best_vid == 0: - return 0 - - var best_owner := String(best_node.get_meta("item_owner", "")) - if not _can_pick_owned_item(best_owner, best_node): - _on_cannot_pick_item(best_owner) - return 0 - - _next_tcp_time_ms = now_ms + 500 - if client.has_method("pickup_item"): - client.pickup_item(best_vid) - return best_vid - -func try_pickup_vid(vid: int) -> bool: - if client == null or not _by_vid.has(vid): - return false - var n: Node3D = _by_vid[vid] - if not is_instance_valid(n): - return false - var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null - if p == null: - return false - if _distance_approx_cm(_node_world_position(p), _node_world_position(n)) > CLICK_PICKUP_DISTANCE_CM: - return false - - var now_ms := Time.get_ticks_msec() - if now_ms < _next_tcp_time_ms: - return false - - var best_owner := String(n.get_meta("item_owner", "")) - if not _can_pick_owned_item(best_owner, n): - _on_cannot_pick_item(best_owner) - return false - - _next_tcp_time_ms = now_ms + 500 - if client.has_method("pickup_item"): - client.pickup_item(vid) - return true - -# Mouse hover probe used by PlayerController's cursor state. Ground drops are -# deliberately tested in screen space, so clicking a visible label / mesh -# selects PICK even when the item is not the nearest drop in world distance. -func hover_at(camera: Camera3D, screen_pos: Vector2) -> bool: - return pick_at(camera, screen_pos) != 0 - -# Pick ground item at mouse screen position (40250 __Pick + __OnClickItem + CPythonTextTail::Pick). -# 同时支持点选地面模型、头顶名称标签(tag)与所有者标签(owner_tag)。 -func pick_at(camera: Camera3D, screen_pos: Vector2) -> int: - if camera == null: - return 0 - var best_d := 64.0 - var best_vid := 0 - for vid in _by_vid: - var node: Node3D = _by_vid[vid] - if not is_instance_valid(node): - continue - if camera.is_position_behind(node.global_position): - continue - var test_pts: Array[Vector3] = [ - node.global_position + Vector3(0, 0.35, 0) - ] - var tag: Node = node.get_node_or_null("tag") - if tag is Node3D and is_instance_valid(tag): - test_pts.append(tag.global_position) - var otag: Node = node.get_node_or_null("owner_tag") - if otag is Node3D and is_instance_valid(otag): - test_pts.append(otag.global_position) - for tp in test_pts: - if camera.is_position_behind(tp): - continue - var p := camera.unproject_position(tp) - var d := p.distance_to(screen_pos) - if d < best_d: - best_d = d - best_vid = int(vid) - return best_vid - -func get_item_world_pos(vid: int) -> Variant: - var node: Node3D = _by_vid.get(vid, null) - if node and is_instance_valid(node): - return node.global_position if node.is_inside_tree() else node.position - return null - -func _name_for(vnum: int) -> String: - if vnum == 1: - return "Yang" - if proto: - var pd: Dictionary = proto.item(vnum) - if not pd.is_empty(): - return String(pd.get("locale_name", pd.get("name", "item %d" % vnum))) - if item_list and item_list.has(vnum): - return item_list.type_of(vnum) - return "item %d" % vnum - -# SetItemTextTailOwner :712-745:owner 非空 → 一个独立的 CENTER 黄字 (1,1,0) text instance, -# 文本 = owner + strOwnership(:722/:723);owner 为空 → 删该实例(:741-745)。 -# poc:drop 节点下第二个 Label3D "owner_tag"(渲染顺序在名字之后,:373)。 -func _apply_owner_tag(node: Node3D, owner: String, item_color := DEFAULT_ITEM_COLOR) -> void: - var owner_tag := node.get_node_or_null("owner_tag") as Label3D - if owner.is_empty(): - if owner_tag != null: - node.remove_child(owner_tag) - owner_tag.queue_free() - return - if owner_tag == null: - owner_tag = Label3D.new() - owner_tag.name = "owner_tag" - owner_tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER # :727 HORIZONTAL_ALIGN_CENTER - owner_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED - owner_tag.no_depth_test = true - owner_tag.pixel_size = 0.005 - owner_tag.position.y = TAG_BASE_Y - node.add_child(owner_tag) - owner_tag.modulate = item_color # :729 的 pInsertTextTail->Color - owner_tag.text = owner + _possessive - -func _item_color(vnum: int, drop: Dictionary) -> Color: - # GC_ITEM_GROUND_ADD 只带 vnum/owner/pos;优先消费服务端或 fixture 显式携带 - # 的颜色,其次消费 item proto 的扩展字段。老数据没有品阶字段时保留旧的 - # 金色 fallback,而不会把 owner 行错误固定成纯黄。 - var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} - for source in [drop, pd]: - for key in ["item_color", "rarity_color", "name_color", "color"]: - if source.has(key): - var parsed: Variant = _coerce_color(source[key]) - if parsed != null: - return parsed - for key in ["item_grade", "rarity", "quality", "grade"]: - if source.has(key): - var grade := int(source[key]) - if ITEM_GRADE_COLORS.has(grade): - return ITEM_GRADE_COLORS[grade] - return DEFAULT_ITEM_COLOR - -func _coerce_color(value: Variant) -> Variant: - if value is Color: - return value - if value is Array and value.size() >= 3: - var scale := 255.0 if float(value[0]) > 1.0 or float(value[1]) > 1.0 \ - or float(value[2]) > 1.0 else 1.0 - var alpha := float(value[3]) if value.size() > 3 else 1.0 - if alpha > 1.0: - alpha /= 255.0 - return Color(float(value[0]) / scale, float(value[1]) / scale, - float(value[2]) / scale, alpha) - if value is Dictionary and value.has_all(["r", "g", "b"]): - var scale := 255.0 if float(value["r"]) > 1.0 or float(value["g"]) > 1.0 \ - or float(value["b"]) > 1.0 else 1.0 - var alpha := float(value.get("a", 1.0)) - if alpha > 1.0: - alpha /= 255.0 - return Color(float(value["r"]) / scale, float(value["g"]) / scale, - float(value["b"]) / scale, alpha) - if value is String and not String(value).strip_edges().is_empty(): - return Color.from_string(String(value), DEFAULT_ITEM_COLOR) - return null diff --git a/project/ui/ground_items.gd.uid b/project/ui/ground_items.gd.uid deleted file mode 100644 index 9389d689..00000000 --- a/project/ui/ground_items.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://8x4b3cb2okdc diff --git a/project/ui/guild_ui.gd b/project/ui/guild_ui.gd deleted file mode 100644 index 3d97c8a2..00000000 --- a/project/ui/guild_ui.gd +++ /dev/null @@ -1,691 +0,0 @@ -# GuildUI (P8/M4) —— 公会窗(G 键):成员 / 技能 / 公会战 / 公告 四页。 -# -# var gu := preload("res://ui/guild_ui.gd").new() -# add_child(gu) -# gu.setup(m2client, canvas_parent, skill_table) # skill_table 可空(出公会技能名) -# gu.toggle() # G 键 -# -# `guild_changed` / `guild_skill_changed` / `guild_war_changed` 刷新,`guild_marks_ready` -# 到时刷会徽图。等级/日志页仍取决于协议是否提供对应数据,不在当前四页入口内伪造。 -extends Node - -const TABS := ["成员", "技能", "公会战", "公告"] -# EGuildWarState (wire.h) -const WAR_STATE_NAMES := ["无", "已宣战", "被拒", "收到宣战", "待开始", "取消", "交战中", "结束"] - -var client: Node -var skill_table: Object # SkillTable (tests may provide a Node stub) -var _ui_parent: Node -# 返回一张 Image(16×12,或任意会被转换/缩放)用作会徽上传源;null = 不显示上传按钮。 -var mark_image_provider: Callable -var _root: Control -var _title: Label -var _info: Label -var _mark: TextureRect -var _tabrow: HBoxContainer -var _scroll: ScrollContainer -var _stack: Control -var _tab := 0 -var _tab_btns: Array[Button] = [] -var _pages: Array[VBoxContainer] = [] -var _war_name_edit: LineEdit -var _upload_status: Label -var _create_dialog: ConfirmationDialog -var _create_name_edit: LineEdit -var _create_status: Label -var _mobile_mode := false -var _war_ask_dialog: ConfirmationDialog - -# GUILD_AUTH_*(服务器 guild.h:94-97):等级权限位。会长(grade 0)恒全权。 -const AUTH_ADD_MEMBER := 1 << 0 -const AUTH_REMOVE_MEMBER := 1 << 1 -const AUTH_NOTICE := 1 << 2 -const AUTH_USE_SKILL := 1 << 3 - -const MOBILE_SIZE := Vector2(700, 340) - -func setup(m2client: Node, parent: Node, table: Object = null) -> void: - client = m2client - skill_table = table - _ui_parent = parent - _build(parent) - for sig in ["guild_changed", "guild_skill_changed", "guild_war_changed", "guild_comments_changed"]: - if client.has_signal(sig): - client.connect(sig, refresh) - if client.has_signal("guild_make_requested"): - client.guild_make_requested.connect(_on_guild_make_requested) - if client.has_signal("guild_marks_ready"): - client.guild_marks_ready.connect(func(_n): refresh()) - if client.has_signal("guild_mark_uploaded"): - client.guild_mark_uploaded.connect(_on_mark_uploaded) - # GUILD_WAR_RECV_DECLARE(3) 时弹被宣战应答框(原版 AcceptGuildWarDialog: - # 接受发 /war ,拒绝发 /nowar ,均走 chat 通道)。 - if client.has_signal("guild_war_event"): - client.guild_war_event.connect(_on_guild_war_event) - -func is_open() -> bool: - return _root != null and _root.visible - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - _apply_mobile_layout() - if _mobile_mode and is_open() and _ui_parent and _ui_parent.has_method("reflow_mobile_window"): - _ui_parent.reflow_mobile_window(_root) - -func close() -> void: - if _root: - _root.visible = false - -func get_mobile_window() -> Control: - return _root - -func toggle() -> void: - _root.visible = not _root.visible - if _root.visible: - refresh() - -func _set_tab(i: int) -> void: - _tab = i - for t in _pages.size(): - _pages[t].visible = (t == i) - _tab_btns[t].disabled = (t == i) - refresh() - -func refresh() -> void: - if not is_open() or client == null: - return - var g: Dictionary = client.get_guild() - var in_guild: bool = g.get("in_guild", false) - _refresh_mark(int(g.get("id", 0)) if in_guild else 0) - if not in_guild: - _info.text = "(未加入公会)" - for p in _pages: - for c in p.get_children(): - c.queue_free() - return - _info.text = "%s Lv.%d 成员 %d/%d 资金 %d" % [ - g.get("name", "?"), int(g.get("level", 0)), - int(g.get("member_count", 0)), int(g.get("max_member_count", 0)), - int(g.get("gold", 0))] - match _tab: - 0: _fill_members() - 1: _fill_skills() - 2: _fill_war(int(g.get("id", 0))) - 3: _fill_comments() - -# 我的等级权限位(会长 grade 0 恒 0xF;否则取 grades[grade-1].auth)。 -# 成员/等级数据未同步(成员表为空、找不到自己)时**返回全权**——权限只是 UI 预判, -# 不可判定时不拦操作,由服务器权威兜底(与原版一致:原版也不在前端做这层门)。 -const AUTH_ALL := AUTH_ADD_MEMBER | AUTH_REMOVE_MEMBER | AUTH_NOTICE | AUTH_USE_SKILL - -func _my_auth() -> int: - if client == null or not client.has_method("get_guild_grades") \ - or not client.has_method("get_main_pid") \ - or not client.has_method("get_guild_members"): - return AUTH_ALL - var my_pid := int(client.get_main_pid()) - var my_grade := -1 - for m in client.get_guild_members(): - if int(m.get("pid", 0)) == my_pid: - my_grade = int(m.get("grade", -1)) - break - if my_grade == -1: - return AUTH_ALL # 成员表里还没有自己(未同步) - if my_grade == 0: - return AUTH_ALL # GUILD_LEADER_GRADE - var grades: Array = client.get_guild_grades() - if my_grade >= 1 and my_grade <= grades.size(): - return int(grades[my_grade - 1].get("auth", 0)) - return AUTH_ALL - -# 被宣战应答(对齐原版 __GuildWar_OpenAskDialog / OnAccept / OnDecline)。 -func _on_guild_war_event(state: int, opp_guild_id: int, opp_name: String) -> void: - if state != 3 or opp_guild_id == 0: # GUILD_WAR_RECV_DECLARE - return - if is_instance_valid(_war_ask_dialog): - _war_ask_dialog.queue_free() - _war_ask_dialog = ConfirmationDialog.new() - _war_ask_dialog.title = "公会战邀请" - var shown := opp_name if opp_name != "" else "#%d" % opp_guild_id - _war_ask_dialog.dialog_text = "公会 [%s] 向我方宣战,接受吗?" % shown - _war_ask_dialog.ok_button_text = "接受" - _war_ask_dialog.cancel_button_text = "拒绝" - _war_ask_dialog.confirmed.connect(func() -> void: - if client.has_method("declare_guild_war"): - client.declare_guild_war(opp_name) # /war - _war_ask_dialog.queue_free() - _war_ask_dialog = null) - _war_ask_dialog.canceled.connect(func() -> void: - if client.has_method("say") and opp_name != "": - client.say(0, "/nowar " + opp_name) # 原版 __GuildWar_OnDecline - _war_ask_dialog.queue_free() - _war_ask_dialog = null) - if _root and _root.get_parent(): - _root.get_parent().add_child(_war_ask_dialog) - if _mobile_mode: - _war_ask_dialog.set_meta("mobile_modal", true) - _war_ask_dialog.set_meta("mobile_title", "公会战邀请") - _war_ask_dialog.popup_centered() - -func _my_grade_is_leader() -> bool: - if client == null or not client.has_method("get_main_pid") \ - or not client.has_method("get_guild_members"): - return false - var my_pid := int(client.get_main_pid()) - for m in client.get_guild_members(): - if int(m.get("pid", 0)) == my_pid: - return int(m.get("grade", -1)) == 0 # GUILD_LEADER_GRADE - return false - -func _fill_members() -> void: - var page := _pages[0] - for c in page.get_children(): - c.queue_free() - - # 会徽上传(需要配了 mark server + 提供了图源) - var srv: Dictionary = client.get_mark_server() if client.has_method("get_mark_server") else {} - if mark_image_provider.is_valid() and int(srv.get("port", 0)) > 0: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 8) - page.add_child(row) - var ub := Button.new() - ub.text = "上传会徽" - ub.custom_minimum_size = Vector2(140, 40) if _mobile_mode else Vector2(96, 26) - ub.pressed.connect(_upload_mark) - row.add_child(ub) - _upload_status = Label.new() - _upload_status.add_theme_font_size_override("font_size", 11) - _upload_status.modulate = Color(0.7, 0.8, 0.7) - row.add_child(_upload_status) - - var grades: Array = client.get_guild_grades() - var members: Array = client.get_guild_members() - - # 公会资金存取(GUILD_CG_DEPOSIT/WITHDRAW_MONEY = 13/14)。40250 服务器把 - # 两个处理分支都禁用了(input_main.cpp 直接 return),但原版 uiguild.py 的 - # OnDeposit/OnWithdraw 仍照发——保持 1:1,结果以服务器回执为准。 - if client.has_method("guild_deposit_money"): - var money := HBoxContainer.new() - money.add_theme_constant_override("separation", 6) - page.add_child(money) - var ginfo: Dictionary = client.get_guild() - var mlabel := Label.new() - mlabel.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) - mlabel.custom_minimum_size = Vector2(300, 40) if _mobile_mode else Vector2(160, 0) - mlabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - mlabel.text = "公会资金: %d" % int(ginfo.get("gold", 0)) - money.add_child(mlabel) - var amount := LineEdit.new() - amount.placeholder_text = "金额" - amount.max_length = 9 - amount.custom_minimum_size = Vector2(160, 40) if _mobile_mode else Vector2(96, 26) - money.add_child(amount) - var dep := Button.new() - dep.text = "存入" - dep.tooltip_text = "40250 服务器已禁用公会存款" - dep.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 26) - dep.pressed.connect(func(): - var v := int(amount.text) - if v > 0: - client.guild_deposit_money(v)) - money.add_child(dep) - var wdr := Button.new() - wdr.text = "取出" - wdr.tooltip_text = "40250 服务器已禁用公会取款" - wdr.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 26) - wdr.pressed.connect(func(): - var v := int(amount.text) - if v > 0: - client.guild_withdraw_money(v)) - money.add_child(wdr) - - var can_kick := (_my_auth() & AUTH_REMOVE_MEMBER) != 0 - var my_pid := int(client.get_main_pid()) if client.has_method("get_main_pid") else 0 - members.sort_custom(func(a, b): return int(a.get("grade", 99)) < int(b.get("grade", 99))) - for m in members: - var gi := int(m.get("grade", 0)) - var gname := "" - if gi >= 1 and gi <= grades.size(): - gname = String(grades[gi - 1].get("name", "")) - var mrow := HBoxContainer.new() - mrow.add_theme_constant_override("separation", 6) - page.add_child(mrow) - var row := Label.new() - row.custom_minimum_size = Vector2(560, 34) if _mobile_mode else Vector2.ZERO - row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - row.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) - row.text = "%s Lv.%d [%s] 贡献 %d%s" % [ - String(m.get("name", "?")), int(m.get("level", 0)), - gname if gname != "" else str(gi), - int(m.get("offer", 0)), - (" 在线" if m.get("online", false) else " 离线") + (" ★" if m.get("general", false) else "")] - mrow.add_child(row) - # 成员管理:REMOVE_MEMBER 权限位可见的踢出按钮(自己与会长不可踢,服务器兜底)。 - var pid := int(m.get("pid", 0)) - if can_kick and pid != 0 and pid != my_pid and gi != 0: - var kick := Button.new() - kick.text = "踢出" - kick.custom_minimum_size = Vector2(72, 34) if _mobile_mode else Vector2(44, 24) - kick.pressed.connect(func(): - if client.has_method("guild_remove_member"): - client.guild_remove_member(pid)) - mrow.add_child(kick) - -func _fill_comments() -> void: - var page := _pages[3] - for c in page.get_children(): - c.queue_free() - if not client.has_method("get_guild_comments"): - var missing := Label.new() - missing.text = "(当前协议没有公告接口)" - page.add_child(missing) - return - var actions := HBoxContainer.new() - actions.add_theme_constant_override("separation", 5) - page.add_child(actions) - var board_status := Label.new() - board_status.add_theme_font_size_override("font_size", 11) - board_status.add_theme_color_override("font_color", Color(1, 0.65, 0.55)) - page.add_child(board_status) - var edit := LineEdit.new() - edit.placeholder_text = "公告内容(最多 50 字)" - edit.custom_minimum_size = Vector2(440, 40) if _mobile_mode else Vector2(245, 26) - actions.add_child(edit) - var post := Button.new() - post.text = "发布" - post.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(52, 26) - post.pressed.connect(func(): - var text := edit.text.strip_edges() - if text == "": - return - # 服务器 input_main.cpp:2819:'!' 前缀(公告)需要 GUILD_AUTH_NOTICE。 - if text.begins_with("!") and (_my_auth() & AUTH_NOTICE) == 0: - board_status.text = "发布公告需要相应权限" - return - board_status.text = "" - if client.has_method("guild_post_comment"): - client.guild_post_comment(text)) - actions.add_child(post) - var refresh_btn := Button.new() - refresh_btn.text = "刷新" - refresh_btn.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(52, 26) - refresh_btn.pressed.connect(func(): - if client.has_method("guild_refresh_comments"): - client.guild_refresh_comments()) - actions.add_child(refresh_btn) - var comments: Array = client.get_guild_comments() - if comments.is_empty(): - var none := Label.new() - none.text = "(暂无公告)" - page.add_child(none) - return - for item in comments: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 6) - page.add_child(row) - var label := Label.new() - label.custom_minimum_size = Vector2(560, 42) if _mobile_mode else Vector2(286, 0) - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - label.add_theme_font_size_override("font_size", 13 if _mobile_mode else 12) - label.text = "%s:%s" % [String(item.get("name", "?")), String(item.get("content", ""))] - row.add_child(label) - var delete := Button.new() - delete.text = "删" - delete.custom_minimum_size = Vector2(64, 40) if _mobile_mode else Vector2(32, 24) - delete.pressed.connect(func(): - if client.has_method("guild_delete_comment"): - client.guild_delete_comment(int(item.get("id", 0)))) - row.add_child(delete) - -func _guild_skill_rows() -> Array: - # skill_table 的 GUILD 分类给 id(升序);按顺序配 levels[i]。返回 [{id, name}]。 - var out := [] - if skill_table and skill_table.has_method("for_category"): - for id in skill_table.for_category("GUILD"): - out.append({"id": int(id), "name": skill_table.name_of(int(id))}) - return out - -func _fill_skills() -> void: - var page := _pages[1] - for c in page.get_children(): - c.queue_free() - var sk: Dictionary = client.get_guild_skill() - if not sk.get("valid", false): - var l := Label.new() - l.text = "(还没收到公会技能数据)" - page.add_child(l) - return - var head := Label.new() - head.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO - head.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - head.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) - head.modulate = Color(0.8, 0.9, 1.0) - head.text = "技能点 %d 公会点 %d / %d" % [ - int(sk.get("skill_point", 0)), int(sk.get("guild_point", 0)), int(sk.get("max_guild_point", 0))] - page.add_child(head) - var levels: Array = sk.get("levels", []) - var meta := _guild_skill_rows() - var auth := _my_auth() - var can_use_skill := (auth & AUTH_USE_SKILL) != 0 - # do_guildskillup 只认会长(GUILD_LEADER_GRADE);技能点 > 0 才显示升级按钮。 - var is_leader := auth == AUTH_ALL and _my_grade_is_leader() - var has_point := int(sk.get("skill_point", 0)) > 0 - for i in levels.size(): - var lv := int(levels[i]) - var nm := "公会技能 %d" % i - var sid := -1 - if i < meta.size(): - nm = String(meta[i].get("name", nm)) - sid = int(meta[i].get("id", -1)) - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 8) - page.add_child(row) - var lbl := Label.new() - lbl.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) - lbl.custom_minimum_size = Vector2(430, 40) if _mobile_mode else Vector2(240, 0) - lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - lbl.text = "%s Lv %d" % [nm, lv] - row.add_child(lbl) - # 施放:服务器 guild.cpp:1248 要求 GUILD_AUTH_USE_SKILL。 - if sid >= 0 and lv > 0 and can_use_skill: - var btn := Button.new() - btn.text = "施放" - btn.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 24) - btn.pressed.connect(func(): client.use_guild_skill(sid, 0)) - row.add_child(btn) - # 升级:/gskillup <技能 vnum>(cmd_general.cpp do_guildskillup,仅会长)。 - if sid >= 0 and is_leader and has_point: - var up := Button.new() - up.text = "升级" - up.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 24) - up.pressed.connect(func(): - if client.has_method("say"): - client.say(0, "/gskillup %d" % sid)) - row.add_child(up) - -func _fill_war(my_guild_id: int) -> void: - var page := _pages[2] - for c in page.get_children(): - c.queue_free() - - var cur: Dictionary = client.get_guild_war() - var st := int(cur.get("state", 0)) - var cur_lbl := Label.new() - cur_lbl.custom_minimum_size = Vector2(640, 36) if _mobile_mode else Vector2.ZERO - cur_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - cur_lbl.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) - cur_lbl.modulate = Color(1.0, 0.8, 0.7) - if st == 0: - cur_lbl.text = "当前无公会战" - else: - var on := String(cur.get("opp_name", "")) - cur_lbl.text = "对 %s : %s" % [ - on if on != "" else "#%d" % int(cur.get("opp_guild_id", 0)), - WAR_STATE_NAMES[st] if st < WAR_STATE_NAMES.size() else str(st)] - page.add_child(cur_lbl) - - # 宣战输入 - var decl := HBoxContainer.new() - decl.add_theme_constant_override("separation", 6) - page.add_child(decl) - _war_name_edit = LineEdit.new() - _war_name_edit.placeholder_text = "对方公会名" - _war_name_edit.custom_minimum_size = Vector2(440, 40) if _mobile_mode else Vector2(200, 26) - decl.add_child(_war_name_edit) - var db := Button.new() - db.text = "宣战" - db.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(60, 26) - db.pressed.connect(func(): - var nm := _war_name_edit.text.strip_edges() - if nm != "": - client.declare_guild_war(nm)) - decl.add_child(db) - - var hdr := Label.new() - hdr.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO - hdr.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - hdr.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) - hdr.text = "— 进行中的公会战 —" - page.add_child(hdr) - var wars: Array = client.get_guild_wars() - if wars.is_empty(): - var none := Label.new() - none.text = "(无)" - page.add_child(none) - for wpair in wars: - var sn := String(wpair.get("src_name", "")) - var dn := String(wpair.get("dst_name", "")) - var row := Label.new() - row.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO - row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - row.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) - var mark_self := " ◀我方" if int(wpair.get("src", 0)) == my_guild_id or int(wpair.get("dst", 0)) == my_guild_id else "" - row.text = "%s vs %s%s" % [ - sn if sn != "" else "#%d" % int(wpair.get("src", 0)), - dn if dn != "" else "#%d" % int(wpair.get("dst", 0)), - mark_self] - page.add_child(row) - -func _upload_mark() -> void: - var g: Dictionary = client.get_guild() - if not g.get("in_guild", false) or not mark_image_provider.is_valid(): - return - var img = mark_image_provider.call() - if img == null: - if _upload_status: - _upload_status.text = "没有会徽图" - return - var srv: Dictionary = client.get_mark_server() - var ok: bool = client.upload_guild_mark( - String(srv.get("host", "")), int(srv.get("port", 0)), int(g.get("id", 0)), img) - if _upload_status: - _upload_status.text = "上传中…" if ok else "上传发起失败" - -func _on_mark_uploaded(ok: bool) -> void: - if _upload_status: - _upload_status.text = "✔ 已上传(等服务器刷新)" if ok else "✘ 上传失败" - -# 40250 原版在收到 GC_REQUEST_MAKE_GUILD 后弹 InputDialog,再由玩家回送 -# CG_ANSWER_MAKE_GUILD。这里保留同样的“服务端请求 -> 输入 -> 应答”边界, -# 让名称校验发生在发包前,同时把最终权限 / 敏感词判断留给服务端。 -func _on_guild_make_requested() -> void: - if is_instance_valid(_create_dialog): - _popup_create_dialog() - _create_name_edit.grab_focus() - return - if _ui_parent == null or client == null: - return - _create_dialog = ConfirmationDialog.new() - _create_dialog.name = "GuildCreateDialog" - _create_dialog.title = "创建公会" - _create_dialog.dialog_text = "请输入公会名称(最多 12 字)" - _create_dialog.ok_button_text = "创建" - _create_dialog.cancel_button_text = "取消" - _create_name_edit = LineEdit.new() - _create_name_edit.name = "GuildName" - _create_name_edit.max_length = 12 - _create_name_edit.placeholder_text = "公会名称" - _create_name_edit.custom_minimum_size = Vector2(380, 44) if _mobile_mode else Vector2(250, 28) - _create_dialog.add_child(_create_name_edit) - _create_status = Label.new() - _create_status.name = "Status" - _create_status.add_theme_font_size_override("font_size", 13 if _mobile_mode else 11) - _create_status.modulate = Color(1.0, 0.7, 0.6) - _create_dialog.add_child(_create_status) - _ui_parent.add_child(_create_dialog) - _create_dialog.confirmed.connect(_on_guild_create_confirmed) - _create_dialog.canceled.connect(_close_guild_create_dialog) - if _ui_parent.has_method("track_mobile_modal"): - _ui_parent.track_mobile_modal(_create_dialog) - _popup_create_dialog() - _create_name_edit.grab_focus() - -func _on_guild_create_confirmed() -> void: - if not is_instance_valid(_create_dialog) or _create_name_edit == null: - return - var name := _create_name_edit.text.strip_edges() - var error := "" - if name.is_empty(): - error = "公会名称不能为空" - elif name.length() > 12: - error = "公会名称最多 12 字" - else: - for ch in name: - if ch.unicode_at(0) < 32: - error = "公会名称包含非法字符" - break - if error != "": - _create_status.text = error - _create_dialog.call_deferred("popup_centered", Vector2i(520, 270) if _mobile_mode else Vector2i(-1, -1)) - _create_name_edit.call_deferred("grab_focus") - return - if not client.has_method("guild_answer_make") or not bool(client.call("guild_answer_make", name)): - _create_status.text = "创建请求发送失败" - _create_dialog.call_deferred("popup_centered", Vector2i(520, 270) if _mobile_mode else Vector2i(-1, -1)) - _create_name_edit.call_deferred("grab_focus") - return - _close_guild_create_dialog() - -func _close_guild_create_dialog() -> void: - var dialog := _create_dialog - _create_dialog = null - _create_name_edit = null - _create_status = null - if is_instance_valid(dialog): - dialog.queue_free() - -func _popup_create_dialog() -> void: - if not is_instance_valid(_create_dialog): - return - if _mobile_mode: - _create_dialog.set_meta("mobile_title", "创建公会") - _create_dialog.min_size = Vector2i(520, 270) - _create_dialog.size = Vector2i(520, 270) - _create_dialog.popup_centered(Vector2i(520, 270)) - else: - _create_dialog.popup_centered() - -# 公会 id 变了就重新取会徽图(16x12,放大 3x 显示)。 -func _refresh_mark(guild_id: int) -> void: - if _mark == null: - return - if guild_id == 0 or client == null or not client.has_method("get_guild_mark_image"): - _mark.texture = null - _mark.visible = false - return - var img: Image = client.get_guild_mark_image(guild_id) - if img == null: - _mark.texture = null - _mark.visible = false - return - _mark.texture = ImageTexture.create_from_image(img) - _mark.visible = true - -func _build(parent: Node) -> void: - _root = Control.new() - _root.set_anchors_preset(Control.PRESET_CENTER) - _root.position = Vector2(-190, -210) - _root.size = Vector2(380, 420) - _root.visible = false - _root.set_meta("is_titlebar", true) - parent.add_child(_root) - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.07, 0.08, 0.1, 0.97) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - _title = Label.new() - _title.text = "公会" - _title.position = Vector2(12, 8) - _root.add_child(_title) - _mark = TextureRect.new() - _mark.position = Vector2(320, 6) - _mark.custom_minimum_size = Vector2(48, 36) # 16x12 放大 3x - _mark.size = Vector2(48, 36) - _mark.stretch_mode = TextureRect.STRETCH_SCALE - _mark.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - _mark.visible = false - _root.add_child(_mark) - _info = Label.new() - _info.position = Vector2(12, 32) - _info.add_theme_font_size_override("font_size", 12) - _info.modulate = Color(0.9, 0.85, 0.6) - _root.add_child(_info) - - _tabrow = HBoxContainer.new() - _tabrow.position = Vector2(12, 54) - _tabrow.add_theme_constant_override("separation", 4) - _root.add_child(_tabrow) - for i in TABS.size(): - var b := Button.new() - b.text = TABS[i] - b.custom_minimum_size = Vector2(84, 26) - b.pressed.connect(_set_tab.bind(i)) - _tabrow.add_child(b) - _tab_btns.append(b) - - _scroll = ScrollContainer.new() - _scroll.position = Vector2(12, 86) - _scroll.custom_minimum_size = Vector2(356, 322) - _root.add_child(_scroll) - _stack = Control.new() - _stack.custom_minimum_size = Vector2(350, 0) - _scroll.add_child(_stack) - for i in TABS.size(): - var page := VBoxContainer.new() - page.custom_minimum_size = Vector2(350, 0) - page.add_theme_constant_override("separation", 3) - page.visible = (i == 0) - _stack.add_child(page) - _pages.append(page) - _tab_btns[0].disabled = true - _apply_mobile_layout() - -func _apply_mobile_layout() -> void: - if _root == null or _title == null or _tabrow == null or _scroll == null or _stack == null: - return - if _mobile_mode: - _root.size = MOBILE_SIZE - _root.position = Vector2(-MOBILE_SIZE.x * 0.5, -MOBILE_SIZE.y * 0.5) - _title.visible = false - _info.position = Vector2(18, 46) - _info.size = Vector2(580, 26) - _info.add_theme_font_size_override("font_size", 14) - _mark.position = Vector2(624, 46) - _mark.size = Vector2(48, 36) - _tabrow.position = Vector2(18, 78) - _tabrow.size = Vector2(664, 40) - for b in _tab_btns: - b.custom_minimum_size = Vector2(160, 38) - b.add_theme_font_size_override("font_size", 13) - _scroll.custom_minimum_size = Vector2(664, 194) - _scroll.position = Vector2(18, 124) - _scroll.size = Vector2(664, 194) - _stack.custom_minimum_size = Vector2(648, 0) - for page in _pages: - page.custom_minimum_size = Vector2(648, 0) - page.add_theme_constant_override("separation", 6) - else: - _root.size = Vector2(380, 420) - _root.position = Vector2(-190, -210) - _title.visible = true - _title.position = Vector2(12, 8) - _info.position = Vector2(12, 32) - _info.remove_theme_font_size_override("font_size") - _mark.position = Vector2(320, 6) - _mark.size = Vector2(48, 36) - _tabrow.position = Vector2(12, 54) - _tabrow.size = Vector2(0, 0) - for b in _tab_btns: - b.custom_minimum_size = Vector2(84, 26) - b.remove_theme_font_size_override("font_size") - _scroll.custom_minimum_size = Vector2(356, 322) - _scroll.position = Vector2(12, 86) - _scroll.size = Vector2(356, 322) - _stack.custom_minimum_size = Vector2(350, 0) - for page in _pages: - page.custom_minimum_size = Vector2(350, 0) - page.add_theme_constant_override("separation", 3) diff --git a/project/ui/guild_ui.gd.uid b/project/ui/guild_ui.gd.uid deleted file mode 100644 index 14e5bf1a..00000000 --- a/project/ui/guild_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cy0ui47ok7xx7 diff --git a/project/ui/help_ui.gd b/project/ui/help_ui.gd deleted file mode 100644 index 1a76d170..00000000 --- a/project/ui/help_ui.gd +++ /dev/null @@ -1,278 +0,0 @@ -# HelpUI (P11 / §8.3) —— 游戏内帮助窗(1:1 迁移 `assets/root/uihelp.py` 的 -# `HelpWindow`,单页版:`ENABLE_HELP_MULTIPAGE = 0` → `LoadDialogSinglePage()`)。 -# -# var hw := preload("res://ui/help_ui.gd").new() -# add_child(hw) -# hw.setup(ui_manager, m2client, assets_root) -# hw.toggle() # H 键(非 Ctrl) -# hw.open() # ESC 系统菜单 help_button -# -# 布局走真 `assets/uiscript/uiscript/helpwindow.py`(UiScript → UiBuild)。 -# 文本逐字对齐 `uiScriptLocale.HELP_*`(`locale/locale//locale_interface.txt`); -# `ui_manager` 没接 locale 回调时,`helpwindow.py` 里的 `uiScriptLocale.X` 会被 -# UiScript 解析成 `` 占位,`_relabel()` 再用本工程的 `Locale` 表 + 英文兜底补回。 -# -# 入口逐字对照 `game.py:459 __PressHKey()`:非 Ctrl → `interface.OpenHelpWindow()` -# (Ctrl+H 是 `net.SendChatPacket("/user_horse_ride")`,不在本窗职责内)。 -# 关闭对照 `helpwindow.py` 的 `HelpWindow`:`close_button` / `OnPressEscapeKey` / -# `OnPressExitKey` / `OnKeyDown(DIK_H)` 都调用 `eventClose`。ESC 由 `UiManager` -# 的窗口栈统一处理(关最顶层);H 键复用 `toggle()`。 -extends Node - -const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd") -# helpwindow.py 的 name → uiScriptLocale key(逐字,含原脚本里重名的两个 `help_02`)。 -const FLOAT_ROWS := { - "help_01": "HELP_MOVE_KEY", - "help_02": "HELP_CONTROL_CAMERA_BY_MIDDLEBUTTON", # 原脚本第二个 help_02(后者覆盖) - "help_03": "HELP_SHOW_ALL_NAME", - "help_04": "HELP_OPEN_CHAT", - "help_05": "HELP_OPEN_WHISPER", - "help_06": "HELP_ATTACK_KEY", - "help_07": "HELP_OPEN_CHARACTER", - "help_08": "HELP_OPEN_SKILL", - "help_09": "HELP_OPEN_QUEST", - "help_10": "HELP_OPEN_INVENTORY", - "help_11": "HELP_OPEN_LOG", - "help_12": "HELP_OPEN_ZONEMAP", - "help_13": "HELP_OPEN_MINIMAP", - "help_14": "HELP_CHANGE_PK_MODE", - "help_15": "HELP_PICK_ITEM", - "help_16": "HELP_SCREEN_CAPTURE", - "help_17": "HELP_GUILD_WINDOW", - "help_18": "HELP_MESSENGER_WINDOW", - "help_19": "HELP_HELP", -} - -# 任务栏贴纸标签(helpwindow.py 的 `taskbar_help_*` 文本)。 -const TASKBAR_ROWS := { - "taskbar_help_01": "HELP_FURY", - "taskbar_help_02": "HELP_HP", - "taskbar_help_03": "HELP_SP", - "taskbar_help_04": "HELP_EXP", - "taskbar_help_05": "HELP_MOUSE_LEFT", - "taskbar_help_06": "HELP_MOUSE_RIGHT", - "taskbar_help_07": "HELP_QUICKSLOT", - "taskbar_help_08a": "HELP_SYSTEM_BUTTON", - "taskbar_help_08b": "HELP_CHARACTER_BUTTON1", - "taskbar_help_08c": "HELP_CHARACTER_BUTTON2", -} - -# uiScriptLocale 兜底(逐字取自 `locale/locale/en/locale_interface.txt`)—— -# 资产缺 locale 表或 UiManager 没接 locale 回调时用这份。 -const FALLBACK := { - "HELP_MOVE_KEY": "Panel: W, A, S, D or arrow keys", - "HELP_CONTROL_CAMERA_BY_RIGHTBUTTON": "Camera View: right or middle mouse button", - "HELP_CONTROL_CAMERA_BY_MIDDLEBUTTON": "Camera View: middle or right mouse button", - "HELP_SHOW_ALL_NAME": "Display Names: Alt", - "HELP_OPEN_CHAT": "Open chat window: Enter", - "HELP_OPEN_WHISPER": "Open Whisper Window: Shift + Enter", - "HELP_ATTACK_KEY": "Attack: left mouse button or space bar", - "HELP_OPEN_CHARACTER": "Open character window: C", - "HELP_OPEN_SKILL": "Open Skill Window: V", - "HELP_OPEN_QUEST": "Open Task Window: N", - "HELP_OPEN_INVENTORY": "Open inventory window: I", - "HELP_OPEN_LOG": "Open Chatlog: L", - "HELP_OPEN_ZONEMAP": "Open Large Map: M", - "HELP_OPEN_MINIMAP": "Open mini map: Shift + M", - "HELP_CHANGE_PK_MODE": "Change attack mode: change setting of left mouse button", - "HELP_PICK_ITEM": "Collect Items: ^ or Y or left mouse button", - "HELP_SCREEN_CAPTURE": "Save Screenshot: Print (will be saved in file \"Metin2\\screenshot\")", - "HELP_GUILD_WINDOW": "Open Guild Window: Alt + G", - "HELP_MESSENGER_WINDOW": "Open Friends List: Alt + M", - "HELP_HELP": "Display help: H", - "HELP_FURY": "Ingame Item Shop", - "HELP_HP": "Hit Points (HP)", - "HELP_SP": "Spell Points (SP)", - "HELP_EXP": "Experience", - "HELP_MOUSE_LEFT": "Function of the Left Mouse button", - "HELP_MOUSE_RIGHT": "Function of the Right Mouse button", - "HELP_QUICKSLOT": "Fast Access Fields", - "HELP_SYSTEM_BUTTON": "System buttons", - "HELP_CHARACTER_BUTTON1": "(Character Window, Inventory Window,", - "HELP_CHARACTER_BUTTON2": "List of Friends and Options)", - "CLOSE": "Close", -} - -var ui: CanvasLayer -var client: Node -var assets_root := "" -var uiscript_dir := "" - -var _win: Dictionary = {} -var _loc # Locale(res://locale.gd) -var _mobile_mode := false -var _mobile_root: Control -var _mobile_labels: Array[Label] = [] - -func setup(ui_manager: CanvasLayer, m2client: Node = null, assets := "") -> void: - ui = ui_manager - client = m2client - assets_root = assets - if assets_root == "" and ui and "assets_root" in ui: - assets_root = ui.assets_root - uiscript_dir = assets_root.path_join("uiscript/uiscript") - if not DirAccess.dir_exists_absolute(uiscript_dir): - uiscript_dir = assets_root.path_join("uiscript") - var LocaleScript = load("res://locale.gd") - if LocaleScript: - _loc = LocaleScript.new() - _loc.setup(assets_root, "en") - if ui and ui.has_signal("window_closed"): - ui.window_closed.connect(func(w): - if not _win.is_empty() and _win.get("root") == w: - _win = {} - ) - -# --- open / close --------------------------------------------------- - -func is_open() -> bool: - if _mobile_mode: - return _mobile_root != null and is_instance_valid(_mobile_root) - return not _win.is_empty() and is_instance_valid(_win.get("root")) - -func set_mobile_mode(enabled: bool) -> void: - if not enabled and _mobile_root != null: - var old_mode := _mobile_mode - _mobile_mode = true - close() - _mobile_mode = old_mode - _mobile_mode = enabled - -func toggle() -> void: - if is_open(): close() - else: open() - -func close() -> void: - if _mobile_mode: - if _mobile_root and is_instance_valid(_mobile_root) and ui: - ui.close(_mobile_root) - _mobile_root = null - _mobile_labels.clear() - return - var r: Control = _win.get("root", null) - _win = {} - if r and is_instance_valid(r) and ui: - ui.close(r) - -func open() -> void: - if _mobile_mode: - _open_mobile() - return - if is_open(): - return - var path := uiscript_dir.path_join("helpwindow.py") - if not FileAccess.file_exists(path): - push_warning("HelpUI: no helpwindow.py at " + path) - return - _win = ui.open_script(path, assets_root) - if not is_open(): - return - _relabel() - _wire() - -func _open_mobile() -> void: - if is_open(): - return - _mobile_root = Control.new() - _mobile_root.name = "MobileHelpWindow" - _mobile_root.size = Vector2(760, 340) - _mobile_root.set_meta("mobile_title", "帮助") - _mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - panel.add_theme_stylebox_override("panel", _mobile_panel_style()) - _mobile_root.add_child(panel) - var scroll := ScrollContainer.new() - scroll.position = Vector2(14, 48) - scroll.size = Vector2(732, 278) - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - scroll.mouse_filter = Control.MOUSE_FILTER_STOP - _mobile_root.add_child(scroll) - var list := VBoxContainer.new() - list.custom_minimum_size = Vector2(710, 0) - list.add_theme_constant_override("separation", 4) - scroll.add_child(list) - _mobile_labels.clear() - for key in FLOAT_ROWS.values(): - var row := Label.new() - row.text = "• " + _text(String(key)) - row.custom_minimum_size = Vector2(700, 28) - row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - row.add_theme_font_size_override("font_size", 13) - row.add_theme_color_override("font_color", Color(0.82, 0.9, 1.0)) - list.add_child(row) - _mobile_labels.append(row) - var task_hint := Label.new() - task_hint.text = "\n" + _text("HELP_QUICKSLOT") + " · " + _text("HELP_SYSTEM_BUTTON") - task_hint.add_theme_font_size_override("font_size", 11) - task_hint.add_theme_color_override("font_color", Color(0.66, 0.76, 0.9)) - list.add_child(task_hint) - ui.open(_mobile_root) - -func _node(nm: String) -> Control: - if _win.is_empty(): - return null - var n = _win.get("nodes", {}).get(nm, null) - return n if n is Control else null - -# --- content (uihelp.py.LoadDialogSinglePage + helpwindow.py 文本) --------- - -# 解析 `` 占位 / 应用 name→key 映射:先扫所有 Label(覆盖原脚本重名的两个 -# `help_02`),再按映射兜一遍(nodes dict 只留最后一个同名)。 -func _relabel() -> void: - var root: Control = _win.get("root") - if root: - for lbl in root.find_children("*", "Label", true, false): - var t := String(lbl.text) - if t.length() >= 2 and t.begins_with("<") and t.ends_with(">"): - lbl.text = _text(t.substr(1, t.length() - 2)) - for nm in FLOAT_ROWS: - _set_text(nm, _text(FLOAT_ROWS[nm])) - for nm in TASKBAR_ROWS: - _set_text(nm, _text(TASKBAR_ROWS[nm])) - _set_text("close_button", _text("CLOSE")) - -func _set_text(nm: String, s: String) -> void: - var n := _node(nm) - if n == null: - return - if n.has_method("set_text"): - n.set_text(s) - elif "text" in n: - n.text = s - -# uiScriptLocale. 的取值:优先本工程 Locale 表,否则英文兜底,否则原样 key。 -func _text(key: String) -> String: - if _loc != null and _loc.has(key): - return _loc.t(key) - return FALLBACK.get(key, key) - -# 已绑定的帮助行文本(供自检 / 调试)。 -func rows() -> Array: - if _mobile_mode: - var mobile_rows: Array = [] - for key in FLOAT_ROWS.values(): - mobile_rows.append(_text(String(key))) - return mobile_rows - var out: Array = [] - for nm in FLOAT_ROWS: - var n := _node(nm) - if n and "text" in n: - out.append(String(n.text)) - return out - -# --- wiring (uihelp.py.HelpWindow.SetCloseEvent) ------------------------ - -func _wire() -> void: - var btn := _node("close_button") - if btn is BaseButton: - btn.pressed.connect(close) - # helpwindow.py2 的多页按钮在单页版不存在;此处不绑。 - -func _mobile_panel_style() -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = Color(0.04, 0.065, 0.1, 0.98) - style.border_color = Color(0.52, 0.68, 0.9, 0.86) - style.set_border_width_all(1) - style.set_corner_radius_all(8) - return style diff --git a/project/ui/help_ui.gd.uid b/project/ui/help_ui.gd.uid deleted file mode 100644 index ed01de8d..00000000 --- a/project/ui/help_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dfoy732rqh7hd diff --git a/project/ui/hit_reaction.gd b/project/ui/hit_reaction.gd deleted file mode 100644 index 82795621..00000000 --- a/project/ui/hit_reaction.gd +++ /dev/null @@ -1,133 +0,0 @@ -# HitReaction —— CLIENT-GAP §3.7 受击方视图(PlayerView / MobView)共用的受击状态机。 -# __HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp)的分支判定、 -# __Shake + ShakeProcess(世界矩阵平移 ±rand()%10 cm)、CGraphicThingInstance::InsertDelay、 -# InterceptOnceMotion + PushOnceMotion + PushLoopMotion 的动作链。 -# 视图只提供「按状态名绑定一段一次性动作」的 Callable(bind(step) -> bool,资源缺失 = false, -# 等同 GetMotionKey 失败时 InterceptOnceMotion / PushOnceMotion 返回 FALSE)。 -extends RefCounted - -const SHAKE_TIME := 0.1 # __Shake(100):m_dwShakeTime = now + 100 ms -const SHAKE_RAND_CM := 10 # ShakeProcess:rand() % 10 -const LOOP_STATES := ["wait", "walk", "run"] -const DAMAGE_STATES := ["damage", "damage_back"] # IsDamage 类 -const KNOCKDOWN_STATES := ["knockdown", "knockdown_back"] # IsKnockDown:DAMAGE_FLYING(_BACK) -const STANDUP_STATES := ["standup", "standup_back"] # __IsStandUpMotion:STAND_UP(_BACK) -# CActorInstance::isLock:NORMAL_ATTACK / COMBO_ATTACK_* / SPECIAL_* / 钓鱼 / 表情跳舞等 -const LOCK_STATES := ["attack", "combo", "skill", "__motion", "emotion", "dig", - "fishing", "fishing_react", "fishing_catch", "fishing_fail"] -const SKILL_STATES := ["skill"] # IsUsingSkill:SKILL 段 / SPECIAL_1..6 - -var delay_left := 0.0 -var speed := 1.0 # 当前动作的 fSpeedRatio(InsertDelay 结束后恢复) -var shake_left := 0.0 -var queue: Array = [] # 剩余的 PushOnceMotion 步 -var resume := "wait" # 链尾 PushLoopMotion 的循环动作 -var active := false # 受击动作链正在播 -var real_dead := false # m_isRealDead:晕眩击倒后不起身 -var current_step := "" # 当前 InterceptOnce/PushOnce 动作 -var _shaking := false -var _shake_base := Vector3.ZERO - -static func is_reaction(state: String) -> bool: - return state in DAMAGE_STATES or state in KNOCKDOWN_STATES or state in STANDUP_STATES - -# __HitGood:返回候选动作链(按顺序尝试,首步绑定成功的那条生效)。 -func good(state: String, scalar: float, stunned: bool) -> Array: - if state in KNOCKDOWN_STATES: - return [] - if stunned: - return [] # IsStun() -> Die():死亡动作由服务器驱动(seam) - shake() - if state in LOCK_STATES: - return [] - if scalar < 0.0: - return [["damage"]] - return [["damage_back"], ["damage"]] - -# __HitGreate -func greate(state: String, scalar: float, stunned: bool) -> Array: - if state in KNOCKDOWN_STATES or state in STANDUP_STATES: - return [] - shake() - if state in SKILL_STATES: - return [] - if stunned: - if scalar < 0.0: - return [["knockdown"]] - return [["knockdown_back"], ["knockdown"]] - if scalar < 0.0: - return [["knockdown", "standup"]] - return [["knockdown_back", "standup_back"], ["knockdown", "standup"]] - -# __HitStone -func stone(stunned: bool) -> void: - if not stunned: - shake() - -func shake() -> void: - shake_left = SHAKE_TIME - -func insert_delay(d: float, anim: Object) -> void: - if d <= 0.0: - return - delay_left = d - if anim: - anim.set("time_scale", 0.0) - -# 绑定新动作时的 time_scale:InsertDelay 期间保持冻结。 -func scale_for(ratio: float) -> float: - speed = ratio - return 0.0 if delay_left > 0.0 else ratio - -# 首步绑定成功 -> 记下链尾要回的循环动作,剩余步入队。 -func start(chains: Array, current: String, bind: Callable, dead_after := false) -> bool: - for chain: Array in chains: - if chain.is_empty() or not bool(bind.call(String(chain[0]))): - continue - if not is_reaction(current): - resume = current if current in LOOP_STATES else "wait" - queue = chain.slice(1) - active = true - real_dead = dead_after - current_step = String(chain[0]) - return true - return false - -# 一段受击动作播完:绑下一步(缺资源的步跳过);返回 false = 链结束。 -func advance(bind: Callable) -> bool: - while not queue.is_empty(): - var step := String(queue.pop_front()) - if bool(bind.call(step)): - current_step = step - return true - active = false - current_step = "" - return false - -func cancel() -> void: - active = false - queue.clear() - real_dead = false - current_step = "" - -func tick(dt: float, anim: Object, model: Node3D) -> void: - if delay_left > 0.0: - delay_left -= dt - if delay_left <= 0.0 and anim: - anim.set("time_scale", speed) - if shake_left > 0.0: - shake_left -= dt - if model == null: - return - if shake_left > 0.0: - if not _shaking: - _shaking = true - _shake_base = model.position - model.position = _shake_base + Vector3(_rand_cm(), _rand_cm(), _rand_cm()) / 100.0 - elif _shaking: - _shaking = false - model.position = _shake_base - -static func _rand_cm() -> float: - var v := float(randi() % SHAKE_RAND_CM) - return v if randi() % 2 == 0 else -v diff --git a/project/ui/hit_reaction.gd.uid b/project/ui/hit_reaction.gd.uid deleted file mode 100644 index 6561b747..00000000 --- a/project/ui/hit_reaction.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d1nugtcuikfs4 diff --git a/project/ui/inventory_ui.gd b/project/ui/inventory_ui.gd deleted file mode 100644 index 073d23da..00000000 --- a/project/ui/inventory_ui.gd +++ /dev/null @@ -1,1014 +0,0 @@ -# InventoryUI (P2) —— 背包 + 装备窗(走 P1 uiscript 装载器)。 -# -# var inv := preload("res://ui/inventory_ui.gd").new() -# add_child(inv) -# inv.setup(ui_manager, m2client, proto, assets_root) -# inv.toggle() # I 键 -# -# 从 `assets/uiscript/inventorywindow.py` 建窗,用 M2Client.get_inventory/get_equipment -# 填格子,Metin2Proto 出名字,icon/item/.tga 出图标。右键=use_item, -# 拖到别的格=move_item。inventory_changed 信号来时刷新。 -# -# 格子编号约定(uiscript):0..89 背包,90+ 装备(wear = index-90)。 -# 新装备 / 腰带没有可靠的旧 uiscript 布局时,在背包右侧生成扩展面板。发包坐标见 -# `_to_wire()`:旧 11 格 wear(BODY..SHIELD)走 EQUIPMENT 窗(window=2, wear cell); -# costume/ring/belt(wear >= 19)与 belt 背包走 INVENTORY 窗(window=1)的全局 cell -# (109..113 / 152+belt cell,见 GameType.h `c_Costume_Slot_Start` / `c_Equipment_Belt`)。 -extends Node - -# 右键点道具时发出(window, cell 为 wire 坐标)。P8 的商店 / 交易 / 仓库窗接它。 -signal item_context(window: int, cell: int) - -# 背包物品右键穿戴被 EquipRules 前置门拦下时发出(code 见 equip_rules.gd,need = limit 要求值)。 -signal equip_rejected(code: String, need: int) - -# 若设置:右键道具时先调它 (window, cell) -> bool;返回 true 表示已处理,不再 use_item。 -var context_consumer: Callable = Callable() - -const UiAssets = preload("res://ui/ui_assets.gd") -const EquipRules = preload("res://equip_rules.gd") -const ItemTooltip = preload("res://ui/item_tooltip.gd") -const ItemTooltipView = preload("res://ui/item_tooltip_view.gd") -const QuietPanel = preload("res://ui/quiet_panel.gd") -const ChestSystem = preload("res://chest_system.gd") -const ItemAttrSystem = preload("res://item_attr_system.gd") -const MetinSocketSystem = preload("res://metin_socket_system.gd") -const ConsumableSystem = preload("res://consumable_system.gd") - -const EQUIP_BASE := 90 # uiscript EQUIPMENT_START_INDEX -const BELT_BASE := 200 # 仅 UI 内部索引,避免与旧 slot_index 相撞 -const BELT_SLOT_START := 152 # GameType.c_Belt_Inventory_Slot_Start -const BELT_CELL_COUNT := 16 -const WEAR_COUNT := 24 # get_equipment() 数组上限(server wear 保留位;实际用到 0..23) - -# wear 位置编号(GameType.h `EWearPositions` / `c_Equipment_*`,wear = 全局 cell - c_Equipment_Start(90))。 -# 旧 11 格(BODY..SHIELD)在客户端走 EQUIPMENT 窗(window=2, wear cell); -# ENABLE_NEW_EQUIPMENT_SYSTEM 的 costume/ring/belt 仍留在 INVENTORY 窗(window=1)的 -# 全局 cell 命名空间里 —— c_Costume_Slot_Start = 90+19, c_New_Equipment_Start = 90+21。 -const WEAR_MAX_NUM := 11 # ItemData.h EWearPositions::WEAR_MAX_NUM -const WEAR_COSTUME_BODY := 19 # GameType.h c_Costume_Slot_Body - c_Equipment_Start -const WEAR_COSTUME_HAIR := 20 # c_Costume_Slot_Hair - c_Equipment_Start -const WEAR_RING1 := 21 # c_Equipment_Ring1 - c_Equipment_Start -const WEAR_RING2 := 22 # c_Equipment_Ring2 - c_Equipment_Start -const WEAR_BELT := 23 # c_Equipment_Belt - c_Equipment_Start -const NEW_EQUIP_WEAR_START := WEAR_COSTUME_BODY # >= 此值的 wear 位置走 INVENTORY 全局 cell -const WINDOW_INVENTORY := 1 # mtnet::WINDOW_INVENTORY -const WINDOW_EQUIPMENT := 2 # mtnet::WINDOW_EQUIPMENT(旧 11 格 wear) -const WINDOW_SAFEBOX := 3 # global mouse payload source, not an inventory window -const WINDOW_MALL := 4 # global mouse payload source, not an inventory window -const WINDOW_BELT := 7 # m2dev belt-inventory window -const BELT_RULES := [1, 2, 4, 6, 3, 3, 4, 6, 5, 5, 5, 6, 7, 7, 7, 7] - -var ui: CanvasLayer # UiManager -var client: Node # M2Client -var proto: Node # Metin2Proto -var item_list: RefCounted # ItemList(可空;给了就用它的 icon 路径) -var item_tooltip: RefCounted -var assets_root := "" -var uiscript_dir := "" -var item_mouse: Node # MouseController(可空,兼容旧测试桩) - -var _win: Dictionary = {} # { root, nodes } -var _local_tooltip_view: Control = null -var _cells := {} # ui_index:int -> Panel -var _drag_from := -1 -var _combine_from := -1 # Shift 选中的“使用到物品”来源格 -var _hint: Label -var _money_button: BaseButton -var _mobile_mode := false -var _mobile_selected_ui := -1 -var _mobile_press_cell := -1 -var _mobile_touch_index := -1 -var _mobile_long_press_token := 0 -var _mobile_detail_dialog: Control -var _mobile_dragging := false -var _mobile_drag_ui := -1 -var _mobile_press_position_value := Vector2.ZERO -var _mobile_long_press_ready := false -const MOBILE_LONG_PRESS_SECONDS := 0.55 -const MOBILE_DRAG_THRESHOLD := 14.0 - -func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: String, - il: RefCounted = null) -> void: - ui = ui_manager - client = m2client - proto = proto_node - item_list = il - assets_root = assets - item_tooltip = ItemTooltip.new() - item_tooltip.setup(assets_root, "en", proto) - uiscript_dir = assets.path_join("uiscript/uiscript") - if not DirAccess.dir_exists_absolute(uiscript_dir): - uiscript_dir = assets.path_join("uiscript") - if client and client.has_signal("inventory_state_changed"): - # 40250 refreshes once after SetItemCount and once after sockets/attrs; - # consume the state snapshots so the first refresh sees the old sockets. - client.inventory_state_changed.connect(_on_inventory_state_changed) - elif client and client.has_signal("inventory_changed"): - client.inventory_changed.connect(_on_inv_changed) - if client and client.has_signal("item_highlighted"): - client.item_highlighted.connect(_on_item_highlighted) - if ui and ui.has_signal("window_closed"): - ui.window_closed.connect(func(w): - if not _win.is_empty() and _win.get("root") == w: - close() - ) - set_process_input(true) - -func is_open() -> bool: - return not _win.is_empty() and is_instance_valid(_win.get("root")) - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - mobile_selected_clear() - if enabled: - _set_hint("点按物品选择;再次点按使用,点另一格移动") - -func mobile_selected() -> int: - return _mobile_selected_ui - -func toggle() -> void: - if is_open(): close() - else: open() - -func close() -> void: - _hide_tooltip() - if _mobile_detail_dialog and is_instance_valid(_mobile_detail_dialog) and ui: - ui.close(_mobile_detail_dialog) - _mobile_detail_dialog = null - _mobile_long_press_token += 1 - _mobile_press_cell = -1 - _mobile_touch_index = -1 - _mobile_dragging = false - _mobile_drag_ui = -1 - _mobile_press_position_value = Vector2.ZERO - _mobile_long_press_ready = false - if item_mouse and item_mouse.has_method("unregister_owner"): - item_mouse.unregister_owner(self) - if item_mouse.has_method("is_attached") and item_mouse.is_attached(): - item_mouse.cancel() - var r: Control = _win.get("root", null) - _win = {} - _cells.clear() - _combine_from = -1 - _mobile_selected_ui = -1 - _hint = null - if r and is_instance_valid(r) and ui: - ui.close(r) - -func open() -> void: - if is_open(): - return - var path := uiscript_dir.path_join("inventorywindow.py") - if not FileAccess.file_exists(path): - push_warning("InventoryUI: no inventorywindow.py at " + path) - return - _win = ui.open_script(path, assets_root) - _index_cells(_win["root"]) - _add_extended_cells(_win["root"]) - _wire_cells() - _wire_money() - _wire_mall_button() - refresh() - -# --- 填充 --------------------------------------------------------------- - -func refresh() -> void: - if not is_open() or client == null: - return - for idx in _cells: - _clear_cell(_cells[idx]) - for d in client.get_inventory(): - var cell: Panel = _cells.get(int(d["cell"]), null) - if cell: - _fill_cell(cell, int(d["vnum"]), int(d["count"]), d) - var eq: Array = client.get_equipment() - for i in eq.size(): - var it: Dictionary = eq[i] - var cell: Panel = _cells.get(EQUIP_BASE + i, null) - if cell and int(it.get("vnum", 0)) != 0: - _fill_cell(cell, int(it["vnum"]), int(it.get("count", 1)), it) - var belt_items: Array = client.get_belt_inventory() if client.has_method("get_belt_inventory") else [] - for d in belt_items: - var cell: Panel = _cells.get(BELT_BASE + int(d["cell"]), null) - if cell: - _fill_cell(cell, int(d["vnum"]), int(d.get("count", 1)), d) - if is_instance_valid(_money_button): - _money_button.text = _format_gold(_current_gold()) - _apply_belt_locks(eq) - -func _on_inv_changed(_window: int, _cell: int) -> void: - refresh() - -func _on_inventory_state_changed(window: int, cell: int, item: Dictionary) -> void: - if not is_open(): - return - var ui_idx := _wire_to_ui(window, cell) - var slot: Panel = _cells.get(ui_idx, null) - if slot == null: - return - var vnum := int(item.get("vnum", 0)) - if vnum <= 0: - _clear_cell(slot) - else: - _fill_cell(slot, vnum, int(item.get("count", 1)), item) - if window == WINDOW_EQUIPMENT and client.has_method("get_equipment"): - _apply_belt_locks(client.get_equipment()) - -func _on_item_highlighted(window: int, cell: int) -> void: - if not is_open(): - return - var ui_idx := _wire_to_ui(window, cell) - var slot: Panel = _cells.get(ui_idx, null) - if slot == null or int(slot.get_meta("vnum", 0)) <= 0: - return - slot.set_meta("highlighted", true) - slot.modulate = Color.WHITE - var pulse := create_tween() - pulse.set_loops(2) - pulse.tween_property(slot, "modulate", Color(1.0, 0.82, 0.28, 1.0), 0.12) - pulse.tween_property(slot, "modulate", Color.WHITE, 0.12) - -func _fill_cell(cell: Panel, vnum: int, count: int, instance_data: Dictionary = {}) -> void: - _clear_cell(cell) - cell.set_meta("vnum", vnum) - cell.set_meta("count", count) - cell.set_meta("item_data", instance_data.duplicate(true)) - var name := "item %d" % vnum - var proto_data: Dictionary = {} - if proto: - proto_data = proto.item(vnum) - if not proto_data.is_empty(): - name = String(proto_data.get("locale_name", proto_data.get("name", name))) - var tooltip_body := "" - if item_tooltip: - tooltip_body = item_tooltip.format(vnum, count, proto_data, instance_data) - else: - tooltip_body = "%s\n#%d%s" % [name, vnum, (" x%d" % count) if count > 1 else ""] - # 40250 has one custom item-attribute panel owner. Do not also let Godot - # create its native tooltip popup; the mobile detail dialog reads this body. - cell.set_meta("tooltip_body", tooltip_body) - cell.tooltip_text = "" - var tex := _icon(vnum) - if tex: - var tr := TextureRect.new() - tr.name = "icon" - tr.texture = tex - tr.set_anchors_preset(Control.PRESET_FULL_RECT) - tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - tr.stretch_mode = TextureRect.STRETCH_SCALE - tr.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(tr) - else: - var lb := Label.new() - lb.name = "icon" - lb.text = name.substr(0, 6) - lb.add_theme_font_size_override("font_size", 9) - lb.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(lb) - if count > 1: - var cnt := Label.new() - cnt.name = "count" - cnt.text = str(count) - cnt.position = Vector2(2, 16) - cnt.add_theme_font_size_override("font_size", 9) - cnt.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(cnt) - -func _clear_cell(cell: Panel) -> void: - cell.set_meta("vnum", 0) - cell.set_meta("count", 0) - cell.set_meta("item_data", {}) - cell.set_meta("tooltip_body", "") - cell.set_meta("highlighted", false) - cell.modulate = Color.WHITE - cell.tooltip_text = "" - for c in cell.get_children(): - c.queue_free() - -func _icon(vnum: int) -> Texture2D: - var rel := "" - if item_list and item_list.has(vnum): - rel = item_list.icon(vnum) - if rel == "": - rel = "icon/item/%05d.tga" % ((vnum / 10) * 10) # basevnum 约定 - return UiAssets.load_tex(assets_root, rel) - -# --- 交互(右键用 / 拖动移动)--------------------------------------------- - -func _index_cells(root: Node) -> void: - _cells.clear() - for n in root.find_children("slot_*", "Panel", true, false): - if n.has_meta("slot_index"): - _cells[int(n.get_meta("slot_index"))] = n - -func _wire_cells() -> void: - for idx in _cells: - var cell: Panel = _cells[idx] - cell.set_script(QuietPanel) - var cell_idx: int = int(idx) - cell.gui_input.connect(func(e: InputEvent): _on_cell_input(cell_idx, e)) - cell.mouse_entered.connect(func(): _on_cell_mouse_entered(cell_idx)) - cell.mouse_exited.connect(func(): _on_cell_mouse_exited(cell_idx)) - if item_mouse and item_mouse.has_method("register_target"): - item_mouse.register_target(cell, func(payload: Dictionary): return _drop_mouse_item(payload, cell_idx), self) - -func _get_tooltip_view() -> Control: - if ui and ui.has_method("get_item_tooltip_view"): - return ui.get_item_tooltip_view(assets_root, proto, item_list) - if _local_tooltip_view == null or not is_instance_valid(_local_tooltip_view): - _local_tooltip_view = ItemTooltipView.new() - _local_tooltip_view.setup(assets_root, proto, item_list, item_tooltip) - _local_tooltip_view.visible = false - _local_tooltip_view.z_index = 200 - if _win.has("root") and is_instance_valid(_win["root"]): - _win["root"].add_child(_local_tooltip_view) - return _local_tooltip_view - -func _on_cell_mouse_entered(cell_idx: int) -> void: - if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached(): - return - var cell: Panel = _cells.get(cell_idx, null) - if cell == null: - return - var vnum := int(cell.get_meta("vnum", 0)) - if vnum <= 0: - _hide_tooltip() - return - var count := int(cell.get_meta("count", 1)) - var instance_data: Dictionary = cell.get_meta("item_data", {}) - var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} - var ctx: Dictionary = _equip_ctx() - if ctx.has("race"): - ctx["job"] = EquipRules.job_of_race(int(ctx["race"])) - ctx["sex"] = EquipRules.sex_of_race(int(ctx["race"])) - var tv := _get_tooltip_view() - if tv: - tv.show_item(vnum, count, proto_data, instance_data, ctx) - -func _on_cell_mouse_exited(_cell_idx: int) -> void: - _hide_tooltip() - -func _hide_tooltip() -> void: - var tv := _get_tooltip_view() - if tv: - tv.hide_tooltip() - -func _wire_money() -> void: - _money_button = _win.get("nodes", {}).get("Money_Slot", null) - if _money_button is BaseButton: - _money_button.pressed.connect(_on_money_pressed) - else: - _money_button = null - -func _wire_mall_button() -> void: - var mall_btn = _win.get("nodes", {}).get("MallButton", null) - if mall_btn is BaseButton: - mall_btn.pressed.connect(func(): - if client and client.has_method("say"): - client.say(0, "/click_mall") - ) - -func _current_gold() -> int: - if client and client.has_method("get_points"): - return maxi(0, int(client.get_points().get("gold", 0))) - return 0 - -func _format_gold(gold: int) -> String: - return str(gold) - -func _on_money_pressed() -> void: - if item_mouse == null or not item_mouse.has_method("attach_money"): - return - var gold := mini(_current_gold(), 9999999) - if gold <= 0: - return - _ask_count("金币数量", gold, func(amount: int): item_mouse.attach_money(amount, "inventory")) - -func _add_extended_cells(root: Control) -> void: - # 旧 inventorywindow.py 只排出前 11 个传统装备位。剩余能力 / 时装 / - # 戒指 / 腰带与 4x4 腰带背包在同一扩展面板中保持可拖放。 - var panel := Panel.new() - panel.name = "extended_equipment" - panel.position = Vector2(root.size.x + 8, 0) - panel.size = Vector2(204, 460) - var bg := StyleBoxFlat.new() - bg.bg_color = Color(0.04, 0.05, 0.07, 0.92) - bg.border_color = Color(0.45, 0.38, 0.2, 0.9) - bg.set_border_width_all(1) - panel.add_theme_stylebox_override("panel", bg) - root.add_child(panel) - # 旧 uiscript 只排 BODY..SHIELD(wear 0..10)。EWearPositions 里 11..18 无位置, - # 扩展面板只补 costume(19/20) / ring(21/22) / belt(23) 这 5 个真实新装备位。 - _add_grid(panel, "时装 / 戒指 / 腰带", WEAR_COSTUME_BODY, WEAR_BELT - WEAR_COSTUME_BODY + 1, EQUIP_BASE, Vector2(10, 30)) - _add_grid(panel, "腰带背包", 0, BELT_CELL_COUNT, BELT_BASE, Vector2(10, 242)) - _hint = Label.new() - _hint.position = Vector2(10, 430) - _hint.size = Vector2(184, 28) - _hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _hint.add_theme_font_size_override("font_size", 10) - _hint.text = "Shift+左键:使用到物品\nCtrl+右键:赠与目标;Alt+右键:按数量丢弃" - panel.add_child(_hint) - -func _add_grid(parent: Panel, title_text: String, first: int, count: int, base: int, pos: Vector2) -> void: - var title := Label.new() - title.text = title_text - title.position = pos - Vector2(0, 22) - title.add_theme_font_size_override("font_size", 13) - parent.add_child(title) - var grid := GridContainer.new() - grid.columns = 4 - grid.position = pos - grid.add_theme_constant_override("h_separation", 4) - grid.add_theme_constant_override("v_separation", 4) - parent.add_child(grid) - for local in count: - var cell := Panel.new() - cell.name = "slot_%d" % (base + first + local) - cell.custom_minimum_size = Vector2(42, 42) - cell.set_meta("slot_index", base + first + local) - var style := StyleBoxFlat.new() - style.bg_color = Color(0.12, 0.13, 0.16, 0.95) - style.border_color = Color(0.35, 0.32, 0.22, 0.9) - style.set_border_width_all(1) - cell.add_theme_stylebox_override("panel", style) - grid.add_child(cell) - _cells[base + first + local] = cell - -func _apply_belt_locks(eq: Array) -> void: - var grade := 0 - if eq.size() > WEAR_BELT: - var belt: Dictionary = eq[WEAR_BELT] - if int(belt.get("vnum", 0)) != 0 and proto: - var data: Dictionary = proto.item(int(belt["vnum"])) - var values: Array = data.get("values", []) - if not values.is_empty(): - grade = int(values[0]) - for local in BELT_CELL_COUNT: - var cell: Panel = _cells.get(BELT_BASE + local, null) - if cell == null: - continue - var locked: bool = grade < int(BELT_RULES[local]) - cell.set_meta("locked", locked) - cell.modulate = Color(0.45, 0.45, 0.45, 0.9) if locked else Color.WHITE - cell.mouse_filter = Control.MOUSE_FILTER_IGNORE if locked else Control.MOUSE_FILTER_STOP - if locked and int(cell.get_meta("vnum", 0)) == 0: - cell.tooltip_text = "需要腰带等级 %d" % BELT_RULES[local] - -func _on_cell_input(ui_idx: int, e: InputEvent) -> void: - if _mobile_mode and e is InputEventScreenTouch: - var touch := e as InputEventScreenTouch - if touch.pressed: - _begin_mobile_cell_press(ui_idx, touch.index, touch.position) - elif touch.index == _mobile_touch_index: - _finish_mobile_cell_press(ui_idx, touch.index) - var cell: Panel = _cells.get(ui_idx, null) - if cell: - cell.accept_event() - return - if _mobile_mode and e is InputEventScreenDrag: - _handle_mobile_cell_drag(ui_idx, e as InputEventScreenDrag) - var drag_cell: Panel = _cells.get(ui_idx, null) - if drag_cell: - drag_cell.accept_event() - return - if not (e is InputEventMouseButton): - return - var cell: Panel = _cells[ui_idx] - if bool(cell.get_meta("locked", false)): - return - var vnum := int(cell.get_meta("vnum", 0)) - if e.button_index == MOUSE_BUTTON_RIGHT and e.pressed and vnum != 0: - if e.shift_pressed: - _begin_combine(ui_idx) - return - if e.ctrl_pressed: - give_to_target(ui_idx) - return - if e.alt_pressed: - drop_count(ui_idx) - return - var w := _to_wire(ui_idx) - item_context.emit(w[0], w[1]) - if context_consumer.is_valid() and bool(context_consumer.call(w[0], w[1])): - return - _hide_tooltip() - use(ui_idx) - elif e.button_index == MOUSE_BUTTON_LEFT: - if e.pressed and vnum != 0: - if e.shift_pressed: - if _item_count(ui_idx) > 1: - _split_stack(ui_idx) - else: - _begin_combine(ui_idx) - return - if _combine_from != -1: - if _combine_from != ui_idx: - _use_to_item(_combine_from, ui_idx) - return - if item_mouse and item_mouse.has_method("attach_item"): - var wire := _to_wire(ui_idx) - var metadata: Dictionary = {} - if client.has_method("get_item"): - metadata = client.get_item(wire[0], wire[1]) - if item_mouse.attach_item(wire[0], wire[1], vnum, _item_count(ui_idx), - _icon(vnum), "inventory", metadata): - _drag_from = -1 - _hide_tooltip() - return - _drag_from = ui_idx - elif not e.pressed and _drag_from != -1: - var over := _cell_under_mouse() - if over != -1 and over != _drag_from: - move_to(_drag_from, over, 1) - _drag_from = -1 - -func _mobile_cell_tap(ui_idx: int) -> void: - var cell: Panel = _cells.get(ui_idx, null) - if cell == null or bool(cell.get_meta("locked", false)): - _set_hint("该格当前不可用") - return - var vnum := int(cell.get_meta("vnum", 0)) - if _mobile_selected_ui < 0: - if vnum == 0: - _set_hint("这里没有物品") - return - _mobile_selected_ui = ui_idx - _set_hint("已选择 %s;再次点按使用,点另一格移动" % _item_label(ui_idx)) - return - if _mobile_selected_ui == ui_idx: - use(ui_idx) - mobile_selected_clear() - return - var from := _mobile_selected_ui - mobile_selected_clear() - if move_to(from, ui_idx, 1): - _set_hint("已发送移动请求") - else: - _set_hint("物品移动请求发送失败") - -func _begin_mobile_cell_press(ui_idx: int, touch_index: int, - position := Vector2(-1, -1)) -> void: - var cell: Panel = _cells.get(ui_idx, null) - if cell == null or bool(cell.get_meta("locked", false)): - return - _mobile_press_cell = ui_idx - _mobile_touch_index = touch_index - _mobile_dragging = false - _mobile_drag_ui = -1 - _mobile_long_press_ready = false - var center: Vector2 = _cells[ui_idx].get_global_rect().get_center() - _mobile_press_position_value = position if position.x >= 0.0 and position.y >= 0.0 else center - _mobile_long_press_token += 1 - var token := _mobile_long_press_token - get_tree().create_timer(MOBILE_LONG_PRESS_SECONDS).timeout.connect(func(): - if token != _mobile_long_press_token or _mobile_press_cell != ui_idx: - return - _mobile_long_press_ready = true - _show_mobile_detail(ui_idx)) - -func _finish_mobile_cell_press(ui_idx: int, touch_index: int) -> void: - if touch_index != _mobile_touch_index or ui_idx != _mobile_press_cell: - return - if _mobile_dragging: - _mobile_dragging = false - _mobile_drag_ui = -1 - _mobile_press_cell = -1 - _mobile_touch_index = -1 - _mobile_press_position_value = Vector2.ZERO - _mobile_long_press_ready = false - return - _mobile_long_press_token += 1 - _mobile_touch_index = -1 - _mobile_press_cell = -1 - _mobile_long_press_ready = false - if _mobile_detail_dialog == null: - _mobile_cell_tap(ui_idx) - -func _handle_mobile_cell_drag(ui_idx: int, event: InputEventScreenDrag) -> void: - if event.index != _mobile_touch_index or ui_idx != _mobile_press_cell: - return - var distance := event.position.distance_to(_mobile_press_position()) - if _mobile_dragging: - return - # A long press first exposes the item detail, as on the reference tooltip - # path. Continuing to drag that same finger converts the gesture into a - # real item drag, so a user can move across inventory/equipment targets - # without relying on right-click or a tiny desktop drag handle. - if not _mobile_long_press_ready or distance < MOBILE_DRAG_THRESHOLD: - return - _start_mobile_drag(ui_idx, event.index, event.position) - -func _input(event: InputEvent) -> void: - # Once a long press has opened the detail card, the card can cover the - # source cell and steal GUI events. Keep the source touch at the feature - # level so dragging out of that card still reaches MouseController. - if not _mobile_mode or _mobile_touch_index < 0 or _mobile_press_cell < 0: - return - if event is InputEventScreenDrag and event.index == _mobile_touch_index: - _handle_mobile_cell_drag(_mobile_press_cell, event as InputEventScreenDrag) - elif event is InputEventScreenTouch and not event.pressed \ - and event.index == _mobile_touch_index: - _finish_mobile_cell_press(_mobile_press_cell, event.index) - -func _mobile_press_position() -> Vector2: - return _mobile_press_position_value - -func _start_mobile_drag(ui_idx: int, touch_index: int, position: Vector2) -> void: - var cell: Panel = _cells.get(ui_idx, null) - if cell == null or int(cell.get_meta("vnum", 0)) == 0: - return - if item_mouse == null or not item_mouse.has_method("attach_item"): - _set_hint("当前窗口不支持拖放") - return - var wire := _to_wire(ui_idx) - var metadata: Dictionary = client.get_item(wire[0], wire[1]) \ - if client and client.has_method("get_item") else {} - if _mobile_detail_dialog and is_instance_valid(_mobile_detail_dialog): - _close_mobile_detail() - _mobile_long_press_token += 1 - _mobile_dragging = true - _mobile_drag_ui = ui_idx - item_mouse.attach_item(wire[0], wire[1], int(cell.get_meta("vnum", 0)), - _item_count(ui_idx), _icon(int(cell.get_meta("vnum", 0))), "inventory", - metadata, touch_index, position) - _set_hint("拖动中:松手放置到目标格") - -func _show_mobile_detail(ui_idx: int) -> void: - var cell: Panel = _cells.get(ui_idx, null) - if cell == null or int(cell.get_meta("vnum", 0)) == 0 or ui == null: - return - if _mobile_detail_dialog and is_instance_valid(_mobile_detail_dialog): - ui.close(_mobile_detail_dialog) - var vnum := int(cell.get_meta("vnum", 0)) - var count := int(cell.get_meta("count", 1)) - var dialog := Panel.new() - dialog.name = "MobileItemDetail" - dialog.size = Vector2(360, 220) - dialog.set_meta("mobile_title", "物品详情") - dialog.set_meta("mobile_modal", true) - dialog.set_meta("mobile_modal_close", Callable(self, "_close_mobile_detail")) - var bg := StyleBoxFlat.new() - bg.bg_color = Color(0.04, 0.065, 0.1, 0.98) - bg.border_color = Color(0.72, 0.56, 0.25, 0.95) - bg.set_border_width_all(1) - bg.set_corner_radius_all(8) - dialog.add_theme_stylebox_override("panel", bg) - var title := Label.new() - title.text = _item_label(ui_idx) - title.position = Vector2(18, 52) - title.size = Vector2(324, 28) - title.add_theme_font_size_override("font_size", 15) - title.add_theme_color_override("font_color", Color(0.98, 0.84, 0.52)) - dialog.add_child(title) - var detail := Label.new() - detail.text = "物品编号:%d\n数量:%d\n\n%s" % [vnum, count, - String(cell.get_meta("tooltip_body", ""))] - detail.position = Vector2(18, 84) - detail.size = Vector2(324, 70) - detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - detail.add_theme_font_size_override("font_size", 10) - detail.add_theme_color_override("font_color", Color(0.78, 0.86, 0.96)) - dialog.add_child(detail) - var select := Button.new() - select.text = "选择" - select.position = Vector2(18, 168) - select.size = Vector2(100, 40) - select.pressed.connect(func(): - _close_mobile_detail() - _mobile_cell_tap(ui_idx)) - dialog.add_child(select) - var use_button := Button.new() - use_button.text = "使用" - use_button.position = Vector2(130, 168) - use_button.size = Vector2(100, 40) - use_button.pressed.connect(func(): - _close_mobile_detail() - use(ui_idx) - mobile_selected_clear()) - dialog.add_child(use_button) - var cancel := Button.new() - cancel.text = "关闭" - cancel.position = Vector2(242, 168) - cancel.size = Vector2(100, 40) - cancel.pressed.connect(_close_mobile_detail) - dialog.add_child(cancel) - _mobile_detail_dialog = dialog - ui.open(dialog, true) - -func _close_mobile_detail() -> void: - var dialog := _mobile_detail_dialog - _mobile_detail_dialog = null - if dialog and is_instance_valid(dialog) and ui: - ui.close(dialog) - -func _item_label(ui_idx: int) -> String: - var cell: Panel = _cells.get(ui_idx, null) - if cell == null: - return "物品" - var vnum := int(cell.get_meta("vnum", 0)) - if proto and proto.has_method("item") and vnum != 0: - var data: Dictionary = proto.item(vnum) - var name := String(data.get("locale_name", data.get("name", ""))) - if name != "": - return name - return "#%d" % vnum - -func mobile_selected_clear() -> void: - _mobile_selected_ui = -1 - -func _drop_mouse_item(payload: Dictionary, target_ui: int) -> bool: - if client == null or payload.is_empty() or target_ui < 0: - return false - if bool(_cells.get(target_ui, null).get_meta("locked", false)) if _cells.get(target_ui, null) else true: - return false - var source_window := int(payload.get("window", -1)) - var source_cell := int(payload.get("cell", -1)) - if source_cell < 0: - return false - var target := _to_wire(target_ui) - if source_cell == int(target[1]) and source_window == int(target[0]): - return true - if source_window == WINDOW_SAFEBOX: - return target[0] == WINDOW_INVENTORY and client.has_method("safebox_checkout") \ - and client.safebox_checkout(source_cell, target[0], target[1]) - if source_window == WINDOW_MALL: - return target[0] == WINDOW_INVENTORY and client.has_method("mall_checkout") \ - and client.mall_checkout(source_cell, target[0], target[1]) - if source_window not in [WINDOW_INVENTORY, 2]: - return false - var target_cell_node: Panel = _cells.get(target_ui, null) - var target_vnum := int(target_cell_node.get_meta("vnum", 0)) if target_cell_node else 0 - var source_vnum := int(payload.get("vnum", 0)) - if target_vnum != 0 and source_vnum != 0: - if ChestSystem.is_treasure_key(source_vnum) and ChestSystem.is_treasure_box(target_vnum): - if client and client.has_method("use_item_to_item"): - return client.use_item_to_item(source_window, source_cell, int(target[0]), int(target[1])) - if ItemAttrSystem.is_attr_scroll(source_vnum) or MetinSocketSystem.is_metin_stone(source_vnum) or MetinSocketSystem.is_clean_socket_scroll(source_vnum): - if client and client.has_method("use_item_to_item"): - return client.use_item_to_item(source_window, source_cell, int(target[0]), int(target[1])) - return move_wire(source_window, source_cell, int(target[0]), int(target[1]), - maxi(1, int(payload.get("count", 1)))) - -func move_wire(source_window: int, source_cell: int, target_window: int, - target_cell: int, count := 1) -> bool: - if client == null or not client.has_method("move_item"): - return false - return client.move_item(source_window, source_cell, target_window, target_cell, count) - -# 把 ui 格子 from 的物品挪到 ui 格子 to(发 CG_ITEM_MOVE) -func move_to(from_ui: int, to_ui: int, count := 1) -> bool: - if client == null: - return false - var a := _to_wire(from_ui) - var b := _to_wire(to_ui) - return client.move_item(a[0], a[1], b[0], b[1], count) - -# 用掉 ui 格子里的物品(发 CG_ITEM_USE)。 -# 对背包里的可穿戴物品(右键 = 穿),先跑 EquipRules.can_equip 前置门(§4.1 修改 2): -# 校验不过 → 发 equip_rejected 并不发包(服务器仍是最终仲裁)。 -func use(ui_idx: int) -> bool: - if client == null: - return false - if ui_idx < EQUIP_BASE: - var cell: Panel = _cells.get(ui_idx, null) - var vnum := int(cell.get_meta("vnum", 0)) if cell else 0 - if vnum != 0 and proto: - var pi: Dictionary = proto.item(vnum) - if EquipRules.is_equippable(pi): - var verdict := EquipRules.can_equip(pi, -1, _equip_ctx()) - if not bool(verdict["ok"]): - _set_hint(_equip_reject_hint(verdict)) - equip_rejected.emit(String(verdict["code"]), int(verdict["need"])) - return false - var w := _to_wire(ui_idx) - return client.use_item(w[0], w[1]) - -# 公开校验入口(tooltip / 拖放目标预览可用)。wear 传 -1 = 用物品默认槽。 -func can_equip_item(vnum: int, wear := -1) -> Dictionary: - if vnum == 0 or proto == null: - return {"ok": false, "code": "NO_ITEM", "need": 0} - return EquipRules.can_equip(proto.item(vnum), wear, _equip_ctx()) - -# EPointTypes(服务器 char.h):ST=12 HT=13 DX=14 IQ=15;MOUNT=109(骑乘中坐骑 vnum)。 -const POINT_ST := 12 -const POINT_HT := 13 -const POINT_DX := 14 -const POINT_IQ := 15 -const POINT_MOUNT := 109 - -# 组装 EquipRules.can_equip 需要的运行期上下文(服务器真实链所需字段全集)。 -# 属性四维从 get_points()["points"] 全量数组取;worn 是 {wear_cell: vnum} 穿戴表, -# 供 RING/UNIQUE 占用翻转与 UNIQUE 同组去重;polymorphed/riding 供 EquipItem 后置门。 -func _equip_ctx() -> Dictionary: - var ctx := {"race": 0, "empire": 0, "level": 0, "st": -1, "dx": -1, "ht": -1, "iq": -1, - "worn": {}, "polymorphed": false, "riding": false} - if client == null: - return ctx - if client.has_method("get_points"): - var p: Dictionary = client.get_points() - ctx["level"] = int(p.get("level", 0)) - var vals: Array = p.get("points", []) - if vals.size() > POINT_MOUNT: - ctx["st"] = int(vals[POINT_ST]) - ctx["ht"] = int(vals[POINT_HT]) - ctx["dx"] = int(vals[POINT_DX]) - ctx["iq"] = int(vals[POINT_IQ]) - ctx["riding"] = int(vals[POINT_MOUNT]) > 0 - # 穿戴表 {wear_cell: vnum}:旧 11 格来自 get_equipment()(下标即 wear cell); - # 新装备槽 19..23(costume/ring/belt)留在 INVENTORY 全局 cell 90+wear(见上方常量)。 - var worn := {} - if client.has_method("get_equipment"): - var eq: Array = client.get_equipment() - for i in eq.size(): - var vnum := int(eq[i].get("vnum", 0)) if eq[i] is Dictionary else 0 - if vnum != 0: - worn[i] = vnum - if client.has_method("get_inventory"): - for it in client.get_inventory(): - if it is Dictionary: - var cell := int(it.get("cell", -1)) - if cell >= EQUIP_BASE + WEAR_COSTUME_BODY and cell < EQUIP_BASE + WEAR_COUNT: - worn[cell - EQUIP_BASE] = int(it.get("vnum", 0)) - ctx["worn"] = worn - if client.has_method("get_entities"): - for e in client.get_entities(): - if bool(e.get("is_main", false)): - ctx["race"] = int(e.get("race", 0)) - ctx["empire"] = int(e.get("empire", 0)) - if int(e.get("level", 0)) > 0: - ctx["level"] = int(e.get("level", 0)) - ctx["polymorphed"] = int(e.get("polymorph", 0)) != 0 \ - or int(e.get("poly_vnum", 0)) != 0 - break - return ctx - -func _equip_reject_hint(verdict: Dictionary) -> String: - match String(verdict.get("code", "")): - "ANTI_SEX": return "该装备不符合你的性别" - "ANTI_JOB": return "该装备不符合你的职业" - "ANTI_EMPIRE": return "该装备不符合你的帝国" - "UNIQUE_DUP": return "同类特殊装备只能佩戴一件" - "POLYMORPH": return "变身状态下无法更换装备" - "RIDE_TUXEDO": return "骑乘状态下无法穿礼服" - "RIDE_ITEM": return "已经在骑乘中了" - "NOT_WEARABLE_HERE": return "该装备不能穿在这个位置" - "LIMIT_LEVEL": return "需要等级 %d" % int(verdict.get("need", 0)) - "LIMIT_STR": return "需要力量 %d" % int(verdict.get("need", 0)) - "LIMIT_DEX": return "需要敏捷 %d" % int(verdict.get("need", 0)) - "LIMIT_INT": return "需要智力 %d" % int(verdict.get("need", 0)) - "LIMIT_CON": return "需要体力 %d" % int(verdict.get("need", 0)) - return "无法装备" - -# Shift+左键(或 Shift+右键)选来源,再左键另一个非空格。 -func _begin_combine(ui_idx: int) -> void: - if _combine_from == ui_idx: - _combine_from = -1 - _set_hint("已取消物品对物品") - return - _combine_from = ui_idx - _drag_from = -1 - _set_hint("请选择目标物品(左键);再次 Shift 点击来源格可取消") - -func _split_stack(ui_idx: int) -> void: - var count := _item_count(ui_idx) - if count <= 1 or item_mouse == null or not item_mouse.has_method("attach_item"): - return - _ask_count("拾取数量", count, func(amount: int): - var wire := _to_wire(ui_idx) - var metadata: Dictionary = client.get_item(wire[0], wire[1]) if client.has_method("get_item") else {} - item_mouse.attach_item(wire[0], wire[1], int(_cells[ui_idx].get_meta("vnum", 0)), - amount, _icon(int(_cells[ui_idx].get_meta("vnum", 0))), "inventory", metadata)) - -func _use_to_item(source_ui: int, target_ui: int) -> bool: - var target: Panel = _cells.get(target_ui, null) - if target == null or int(target.get_meta("vnum", 0)) == 0: - _set_hint("目标格必须有物品") - return false - var source := _to_wire(source_ui) - var dest := _to_wire(target_ui) - _combine_from = -1 - _set_hint("已发送物品对物品请求") - return client != null and client.use_item_to_item(source[0], source[1], dest[0], dest[1]) - -# Ctrl+右键把物品赠与当前选中的实体;数量由用户确认。 -func give_to_target(ui_idx: int) -> void: - if client == null or not client.has_method("get_target"): - return - var target: Dictionary = client.get_target() - var target_vid := int(target.get("vid", 0)) - if target_vid == 0: - _set_hint("请先选中要赠与的角色") - return - var wire := _to_wire(ui_idx) - _ask_count("赠与数量", _item_count(ui_idx), func(amount: int): - client.give_item(target_vid, wire[0], wire[1], amount)) - -# Alt+右键丢弃;非金币物品走 CG_ITEM_DROP2,精确保留用户填写的数量。 -func drop_count(ui_idx: int) -> void: - if client == null: - return - var wire := _to_wire(ui_idx) - _ask_count("丢弃数量", _item_count(ui_idx), func(amount: int): - client.drop_item_count(wire[0], wire[1], 0, amount)) - -func _item_count(ui_idx: int) -> int: - var cell: Panel = _cells.get(ui_idx, null) - return max(1, int(cell.get_meta("count", 1))) if cell else 1 - -func _ask_count(title_text: String, max_count: int, on_confirm: Callable) -> void: - if ui == null: - return - var dialog := Panel.new() - dialog.position = Vector2(760, 420) - dialog.size = Vector2(360, 210) if _mobile_mode else Vector2(240, 132) - if _mobile_mode: - dialog.set_meta("mobile_title", title_text) - var bg := StyleBoxFlat.new() - bg.bg_color = Color(0.05, 0.06, 0.09, 0.97) - bg.border_color = Color(0.7, 0.55, 0.22, 0.9) - bg.set_border_width_all(1) - dialog.add_theme_stylebox_override("panel", bg) - var title := Label.new() - title.text = title_text - title.position = Vector2(18, 50) if _mobile_mode else Vector2(12, 10) - if _mobile_mode: - title.add_theme_font_size_override("font_size", 15) - dialog.add_child(title) - var amount_input: Control - if _mobile_mode: - var edit := LineEdit.new() - edit.name = "CountInput" - edit.text = "1" - edit.placeholder_text = "1 - %d" % max(1, max_count) - edit.max_length = 8 - edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER - edit.position = Vector2(18, 88) - edit.size = Vector2(324, 40) - amount_input = edit - dialog.add_child(edit) - else: - var spin := SpinBox.new() - spin.min_value = 1 - spin.max_value = max(1, max_count) - spin.value = 1 - spin.step = 1 - spin.allow_greater = false - spin.position = Vector2(12, 42) - spin.size = Vector2(216, 28) - amount_input = spin - dialog.add_child(spin) - var ok := Button.new() - ok.text = "确认" - ok.position = Vector2(18, 150) if _mobile_mode else Vector2(76, 88) - ok.size = Vector2(154, 44) if _mobile_mode else Vector2(58, 28) - ok.pressed.connect(func(): - var amount := int((amount_input as LineEdit).text) if amount_input is LineEdit else int((amount_input as SpinBox).value) - amount = clampi(amount, 1, max(1, max_count)) - ui.close(dialog) - on_confirm.call(amount)) - dialog.add_child(ok) - var cancel := Button.new() - cancel.text = "取消" - cancel.position = Vector2(188, 150) if _mobile_mode else Vector2(142, 88) - cancel.size = Vector2(154, 44) if _mobile_mode else Vector2(58, 28) - cancel.pressed.connect(func(): ui.close(dialog)) - dialog.add_child(cancel) - ui.open(dialog, true) - -func _set_hint(text: String) -> void: - if is_instance_valid(_hint): - _hint.text = text - -func _cell_under_mouse() -> int: - for idx in _cells: - var cell: Panel = _cells[idx] - if cell.get_global_rect().has_point(cell.get_global_mouse_position()): - return int(idx) - return -1 - -# ui 格子编号 -> (window, cell) -func _to_wire(ui_idx: int) -> Array: - if ui_idx >= BELT_BASE: - return [WINDOW_INVENTORY, BELT_SLOT_START + ui_idx - BELT_BASE] - if ui_idx >= EQUIP_BASE: - var wear := ui_idx - EQUIP_BASE - # ENABLE_NEW_EQUIPMENT_SYSTEM 的 costume(19/20) / ring(21/22) / belt(23) - # 在客户端里没有独立窗,仍用 INVENTORY 窗的全局 cell(109..113,见 GameType.h)。 - if wear >= NEW_EQUIP_WEAR_START: - return [WINDOW_INVENTORY, EQUIP_BASE + wear] - # 旧 11 格(BODY..SHIELD)走 EQUIPMENT 窗(window=2)+ 零基 wear cell。 - return [WINDOW_EQUIPMENT, wear] - return [WINDOW_INVENTORY, ui_idx] - -# wire item position -> the same UI slot namespace used by _to_wire(). -func _wire_to_ui(window: int, cell: int) -> int: - if window == WINDOW_EQUIPMENT: - return EQUIP_BASE + (cell - EQUIP_BASE if cell >= EQUIP_BASE else cell) - if window == WINDOW_BELT: - return BELT_BASE + (cell - BELT_SLOT_START if cell >= BELT_SLOT_START else cell) - if window == WINDOW_INVENTORY: - if cell >= BELT_SLOT_START: - return BELT_BASE + cell - BELT_SLOT_START - return cell - return -1 diff --git a/project/ui/inventory_ui.gd.uid b/project/ui/inventory_ui.gd.uid deleted file mode 100644 index 74098b09..00000000 --- a/project/ui/inventory_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b244uiisl84sk diff --git a/project/ui/item_list.gd b/project/ui/item_list.gd deleted file mode 100644 index 256b5c53..00000000 --- a/project/ui/item_list.gd +++ /dev/null @@ -1,55 +0,0 @@ -# ItemList (P2) —— 读 `locale/locale/common/item_list.txt`:vnum → icon / model 路径。 -# -# 格式(TSV,客户端 GameLib/ItemManager.cpp __LoadItemList): -# vnum TYPE iconpath [modelpath] -# 例: 19 WEAPON icon/item/00010.tga d:/ymir work/item/weapon/00010.gr2 -# -# var il := preload("res://ui/item_list.gd").new() -# il.load_file("/locale/locale/common/item_list.txt") -# il.icon(19) # "icon/item/00010.tga" -# il.model(19) # "d:/ymir work/item/weapon/00010.gr2" ("" if none) -extends RefCounted - -var _by_vnum := {} # vnum:int -> { type, icon, model } -var count := 0 - -func load_file(path: String) -> bool: - _by_vnum.clear() - count = 0 - if not FileAccess.file_exists(path): - return false - var f := FileAccess.open(path, FileAccess.READ) - if f == null: - return false - while not f.eof_reached(): - var line := f.get_line() - if line.strip_edges() == "": - continue - var t := line.split("\t", false) - if t.size() < 3: - continue - var vnum := int(t[0]) - if vnum == 0: - continue - _by_vnum[vnum] = { - "type": t[1].strip_edges(), - "icon": t[2].strip_edges(), - "model": t[3].strip_edges() if t.size() >= 4 else "", - } - count = _by_vnum.size() - return count > 0 - -func has(vnum: int) -> bool: - return _by_vnum.has(vnum) - -func icon(vnum: int) -> String: - var d: Dictionary = _by_vnum.get(vnum, {}) - return String(d.get("icon", "")) - -func model(vnum: int) -> String: - var d: Dictionary = _by_vnum.get(vnum, {}) - return String(d.get("model", "")) - -func type_of(vnum: int) -> String: - var d: Dictionary = _by_vnum.get(vnum, {}) - return String(d.get("type", "")) diff --git a/project/ui/item_list.gd.uid b/project/ui/item_list.gd.uid deleted file mode 100644 index 8a6df13e..00000000 --- a/project/ui/item_list.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://blbd4u5qn03fm diff --git a/project/ui/item_tooltip.gd b/project/ui/item_tooltip.gd deleted file mode 100644 index d99f497f..00000000 --- a/project/ui/item_tooltip.gd +++ /dev/null @@ -1,773 +0,0 @@ -# ItemTooltip —— item_proto + itemdesc + 物品实例状态的统一属性提示与 40250 模型构建。 -# -# 参考:ClientVS22/POC/assets/root/uitooltip.py::ItemToolTip.AddItemData。 -# 支持 40250 调色板、居中排版数据模型导出、攻防数值加算、固有属性与附加属性着色、 -# 适用职业判断(红字警示)、魂石槽图文解析与纯文本向下兼容。 -extends RefCounted - -# 40250 官方调色板 (uitooltip.py) -const TITLE_COLOR := Color(0.9490, 0.9058, 0.7568, 1.0) # #f2e7c1 浅金象牙白 (白装/普通) -const SPECIAL_TITLE_COLOR := Color(1.0, 0.7843, 0.0, 1.0) # #ffc800 亮金黄色 (有附加属性/稀有) -const NORMAL_COLOR := Color(0.7607, 0.7607, 0.7607, 1.0) # #c2c2c2 普通浅灰 -const FONT_COLOR := Color(0.7607, 0.7607, 0.7607, 1.0) -const PRICE_COLOR := Color(1.0, 0.7255, 0.4275, 1.0) # #ffb96d 价格金橙色 -const CONDITION_COLOR := Color(0.7451, 0.7059, 0.4902, 1.0) # #beb47d 说明/要求淡金 -const ENABLE_COLOR := Color(0.7607, 0.7607, 0.7607, 1.0) -const DISABLE_COLOR := Color(0.9, 0.4745, 0.4627, 1.0) # #e67976 限制不满足/警示红 -const POSITIVE_COLOR := Color(0.5411, 0.7254, 0.5568, 1.0) # #8ab98e 基础攻防与固有词条浅绿 -const NEGATIVE_COLOR := Color(0.9, 0.4745, 0.4627, 1.0) # #e67976 负向词条警示红 -const SPECIAL_POSITIVE_COLOR := Color(0.6911, 0.8754, 0.7068, 1.0) # #b0dfb4 附加属性1~5薄荷青绿 -const SPECIAL_POSITIVE_COLOR2 := Color(0.8824, 0.9804, 0.8824, 1.0)# #e1fae1 附加属性6~7浅薄荷绿 - -const ITEM_TYPE_WEAPON := 1 -const ITEM_TYPE_ARMOR := 2 -const ITEM_TYPE_LOTTERY := 8 -const ITEM_TYPE_FISH := 12 -const ITEM_TYPE_ROD := 13 -const ITEM_TYPE_UNIQUE := 16 -const ITEM_TYPE_PICK := 24 -const ITEM_TYPE_BLEND := 27 -const ITEM_TYPE_COSTUME := 28 -const ITEM_TYPE_DS := 29 -const ITEM_TYPE_RING := 33 -const ITEM_TYPE_BELT := 34 - -const CHARACTER_NAMES := ["战士", "刺客", "修罗", "萨满"] -const ANTIFLAG_FEMALE := 1 << 0 -const ANTIFLAG_MALE := 1 << 1 -const ANTIFLAG_WARRIOR := 1 << 2 -const ANTIFLAG_ASSASSIN := 1 << 3 -const ANTIFLAG_SURA := 1 << 4 -const ANTIFLAG_SHAMAN := 1 << 5 - -const LIMIT_NAMES := { - 1: "等级要求", - 2: "力量要求", - 3: "敏捷要求", - 4: "智力要求", - 5: "体力要求", - 6: "网吧要求", - 7: "实时期限", - 8: "首次使用期限", - 9: "穿戴计时", -} - -const TYPE_NAMES := { - 1: "武器", - 2: "防具", - 3: "使用品", - 4: "自动使用品", - 5: "材料", - 6: "特殊物品", - 7: "工具", - 8: "抽奖品", - 9: "金币", - 10: "魔石", - 11: "容器", - 12: "鱼", - 13: "鱼竿", - 14: "资源", - 16: "独特物品", - 17: "技能书", - 18: "任务物品", - 19: "变身物品", - 20: "宝箱", - 21: "宝箱钥匙", - 22: "遗忘书", - 23: "礼盒", - 24: "镐", - 25: "发型", - 26: "图腾", - 27: "混合物", - 28: "时装", - 29: "龙魂石", - 30: "特殊龙魂石", - 31: "提取物", - 32: "副货币", - 33: "戒指", - 34: "腰带", -} - -# APPLY_* 编号来自 ClientVS22/source/GameLib/ItemData.h::EApplyTypes。 -const APPLY_NAMES := { - 1: "最大生命", - 2: "最大法力", - 3: "体质", - 4: "智力", - 5: "力量", - 6: "敏捷", - 7: "攻击速度", - 8: "移动速度", - 9: "施法速度", - 10: "生命恢复", - 11: "法力恢复", - 12: "中毒概率", - 13: "眩晕概率", - 14: "缓慢概率", - 15: "暴击概率", - 16: "穿透概率", - 17: "对人族伤害", - 18: "对动物伤害", - 19: "对兽人伤害", - 20: "对秘境伤害", - 21: "对不死族伤害", - 22: "对恶魔伤害", - 23: "生命吸收", - 24: "法力吸收", - 25: "法力燃烧", - 26: "伤害转法力", - 27: "格挡", - 28: "闪避", - 29: "剑防御", - 30: "双手防御", - 31: "匕首防御", - 32: "铃防御", - 33: "扇防御", - 34: "弓防御", - 35: "火抗性", - 36: "雷抗性", - 37: "魔法抗性", - 38: "风抗性", - 39: "近战反射", - 40: "诅咒反射", - 41: "中毒抵抗", - 42: "击杀恢复法力", - 43: "经验加成", - 44: "金币加成", - 45: "掉落加成", - 46: "药水效果", - 47: "击杀恢复生命", - 48: "免疫眩晕", - 49: "免疫缓慢", - 50: "免疫跌落", - 51: "技能", - 52: "弓箭距离", - 53: "攻击等级", - 54: "防御等级", - 55: "魔法攻击等级", - 56: "魔法防御等级", - 57: "诅咒概率", - 58: "最大耐力", - 59: "对战士伤害", - 60: "对刺客伤害", - 61: "对武士伤害", - 62: "对萨满伤害", - 63: "对怪物伤害", - 64: "商城攻击加成", - 65: "商城防御加成", - 66: "商城经验加成", - 67: "商城掉落加成", - 68: "商城金币加成", - 69: "最大生命百分比", - 70: "最大法力百分比", - 71: "技能伤害", - 72: "普通攻击伤害", - 73: "技能防御", - 74: "普通攻击防御", - 75: "生命提取", - 76: "网吧经验加成", - 77: "网吧掉落加成", - 78: "战士抗性", - 79: "刺客抗性", - 80: "武士抗性", - 81: "萨满抗性", - 82: "能量", - 83: "防御", - 84: "时装属性加成", - 85: "魔法攻击百分比", - 86: "近战魔法攻击百分比", - 87: "冰抗性", - 88: "土抗性", - 89: "暗抗性", - 90: "抗暴击", - 91: "抗穿透", -} - -# 40250 uitooltip.py AFFECT_DICT 规定为百分比显示的词条 -const PERCENT_APPLIES := { - 7: true, # 攻击速度 - 8: true, # 移动速度 - 9: true, # 施法速度 - 10: true, # 生命恢复 - 11: true, # 法力恢复 - 12: true, # 中毒概率 - 13: true, # 眩晕概率 - 14: true, # 缓慢概率 - 15: true, # 暴击概率 - 16: true, # 穿透概率 - 17: true, # 对人族伤害 - 18: true, # 对动物伤害 - 19: true, # 对兽人伤害 - 20: true, # 对秘境伤害 - 21: true, # 对不死族伤害 - 22: true, # 对恶魔伤害 - 23: true, # 生命吸收 - 24: true, # 法力吸收 - 25: true, # 法力燃烧 - 26: true, # 伤害转法力 - 27: true, # 格挡 - 28: true, # 闪避 - 29: true, # 剑防御 - 30: true, # 双手防御 - 31: true, # 匕首防御 - 32: true, # 铃防御 - 33: true, # 扇防御 - 34: true, # 弓防御 - 35: true, # 火抗性 - 36: true, # 雷抗性 - 37: true, # 魔法抗性 - 38: true, # 风抗性 - 39: true, # 近战反射 - 40: true, # 诅咒反射 - 41: true, # 中毒抵抗 - 42: true, # 击杀恢复法力 - 43: true, # 经验加成 - 44: true, # 金币加成 - 45: true, # 掉落加成 - 46: true, # 药水效果 - 47: true, # 击杀恢复生命 - 57: true, # 诅咒概率 - 59: true, # 对战士伤害 - 60: true, # 对刺客伤害 - 61: true, # 对武士伤害 - 62: true, # 对萨满伤害 - 63: true, # 对怪物伤害 - 64: true, # 商城攻击加成 - 65: true, # 商城防御加成 - 66: true, # 商城经验加成 - 67: true, # 商城掉落加成 - 68: true, # 商城金币加成 - 69: true, # 最大生命百分比 - 70: true, # 最大法力百分比 - 71: true, # 技能伤害 - 72: true, # 普通攻击伤害 - 73: true, # 技能防御 - 74: true, # 普通攻击防御 - 78: true, # 战士抗性 - 79: true, # 刺客抗性 - 80: true, # 武士抗性 - 81: true, # 萨满抗性 - 85: true, # 魔法攻击百分比 - 86: true, # 近战魔法攻击百分比 - 87: true, # 冰抗性 - 88: true, # 土抗性 - 89: true, # 暗抗性 - 90: true, # 抗暴击 - 91: true, # 抗穿透 -} - -var _descriptions: Dictionary = {} -var _item_names: Dictionary = {} -var _skill_names: Dictionary = {} -var _proto_node: Object = null -var _assets_root: String = "" - -func setup(assets_root: String, lang := "", proto_node: Object = null) -> bool: - _descriptions.clear() - _item_names.clear() - _skill_names.clear() - _assets_root = assets_root - _proto_node = proto_node - if lang.is_empty(): - var LocaleScript = load("res://locale.gd") - if LocaleScript and LocaleScript.has_method("get_default_lang"): - lang = LocaleScript.get_default_lang() - else: - lang = "zh" - var names_path := assets_root.path_join("locale/locale/%s/item_names.txt" % lang) - if FileAccess.file_exists(names_path): - var nf := FileAccess.open(names_path, FileAccess.READ) - if nf: - while not nf.eof_reached(): - var line := nf.get_line().strip_edges() - if line.is_empty() or line.begins_with("#") or line.begins_with("VNUM"): - continue - var tab := line.find("\t") - if tab < 0: - tab = line.find(" ") - if tab < 0: - continue - var k := line.substr(0, tab).strip_edges() - var v := line.substr(tab + 1).strip_edges() - if k.is_valid_int(): - _item_names[int(k)] = v - var path := assets_root.path_join("locale/locale/%s/itemdesc.txt" % lang) - if FileAccess.file_exists(path): - var file := FileAccess.open(path, FileAccess.READ) - if file: - while not file.eof_reached(): - var parts := file.get_line().split("\t", true) - if parts.size() < 2: - continue - var vnum := int(parts[0].strip_edges()) - if vnum <= 0: - continue - _descriptions[vnum] = { - "name": parts[1].strip_edges(), - "description": parts[2].strip_edges() if parts.size() > 2 else "", - } - _load_skill_names(assets_root, lang) - return not _descriptions.is_empty() or not _skill_names.is_empty() or not _item_names.is_empty() - -func get_proto_node() -> Object: - return _proto_node - -func get_stone_info(stone_vnum: int) -> Dictionary: - var name := "" - if _item_names.has(stone_vnum): - name = String(_item_names[stone_vnum]) - var affect := "" - var icon_path := "" - if name.is_empty() and _proto_node and _proto_node.has_method("item"): - var sp: Dictionary = _proto_node.item(stone_vnum) - if not sp.is_empty(): - name = String(sp.get("locale_name", sp.get("name", ""))) - var sa: Array = sp.get("applies", []) - if not sa.is_empty(): - var atype := int(sa[0].get("type", 0)) - var aval := int(sa[0].get("value", 0)) - if atype != 0 and aval != 0: - affect = format_apply_string(atype, aval) - elif not affect.is_empty() or (_proto_node and _proto_node.has_method("item")): - var sp: Dictionary = _proto_node.item(stone_vnum) - if not sp.is_empty(): - var sa: Array = sp.get("applies", []) - if not sa.is_empty(): - var atype := int(sa[0].get("type", 0)) - var aval := int(sa[0].get("value", 0)) - if atype != 0 and aval != 0: - affect = format_apply_string(atype, aval) - if name.is_empty() and _descriptions.has(stone_vnum): - name = String(_descriptions[stone_vnum].get("name", "")) - if name.is_empty(): - name = "魂石 #%d" % stone_vnum - return {"name": name, "affect": affect, "icon_path": icon_path} - -func format_apply_string(apply_type: int, value: int) -> String: - var label: String = APPLY_NAMES.get(apply_type, "属性 %d" % apply_type) - if PERCENT_APPLIES.get(apply_type, false): - return "%s %+d%%" % [label, value] - return "%s %+d" % [label, value] - -# 核心 40250 数据模型导出(供 ItemTooltipView 九宫格控件渲染) -func build_model(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {}, - player_ctx: Dictionary = {}) -> Dictionary: - if vnum <= 0: - return {} - - var name: String = _item_names.get(vnum, "") - if name.is_empty(): - name = String(proto_data.get("locale_name", proto_data.get("name", ""))) - if name.is_empty() and _descriptions.has(vnum): - name = String(_descriptions[vnum].get("name", "")) - if name.is_empty(): - name = "物品 #%d" % vnum - var sockets: Array = instance_data.get("sockets", proto_data.get("sockets", [])) - name = _special_title(vnum, name, sockets) - - var attrs: Array = instance_data.get("attrs", proto_data.get("attrs", [])) - var has_attrs := false - for a in attrs: - if int(a.get("type", 0)) != 0 and int(a.get("value", 0)) != 0: - has_attrs = true - break - - var title_col := SPECIAL_TITLE_COLOR if has_attrs else TITLE_COLOR - - var desc_info: Dictionary = _descriptions.get(vnum, {}) - var desc_text := String(desc_info.get("description", "")) - var summary_text := String(desc_info.get("summary", "")) - - # 限制要求 - var limits_out: Array[Dictionary] = [] - var player_level: int = int(player_ctx.get("level", 0)) - for limit in proto_data.get("limits", []): - var limit_type := int(limit.get("type", 0)) - var limit_value := int(limit.get("value", 0)) - if limit_type == 6: - limits_out.append({"text": "PC房专用道具", "color": NORMAL_COLOR}) - continue - if limit_type == 1 and limit_value > 0: - var col := DISABLE_COLOR if (player_level > 0 and player_level < limit_value) else NORMAL_COLOR - limits_out.append({"text": "要求等级: %d" % limit_value, "color": col, "limit_type": 1, "value": limit_value}) - elif limit_type != 0 and limit_value > 0: - var lim_name: String = LIMIT_NAMES.get(limit_type, "限制 %d" % limit_type) - limits_out.append({"text": "%s: %d" % [lim_name, limit_value], "color": NORMAL_COLOR, "limit_type": limit_type, "value": limit_value}) - - # 基础战斗属性 (Weapon / Armor) - var stats_out: Array[Dictionary] = [] - var item_type := int(proto_data.get("type", 0)) - var item_subtype := int(proto_data.get("sub_type", proto_data.get("subtype", 0))) - var values: Array = proto_data.get("values", []) - if item_type == ITEM_TYPE_WEAPON and values.size() >= 6: - var min_attack := int(values[3]) + int(values[5]) - var max_attack := int(values[4]) + int(values[5]) - var min_magic := int(values[1]) + int(values[5]) - var max_magic := int(values[2]) + int(values[5]) - var atk_str := "" - if max_attack > min_attack: - atk_str = "攻击力 %d - %d" % [min_attack, max_attack] - elif min_attack > 0: - atk_str = "攻击力 %d" % min_attack - var mag_str := "" - if max_magic > min_magic: - mag_str = "魔法攻击 %d - %d" % [min_magic, max_magic] - elif min_magic > 0: - mag_str = "魔法攻击 %d" % min_magic - - if item_subtype == 4: # WEAPON_FAN 扇子先魔法后物理 - if not mag_str.is_empty(): - stats_out.append({"text": mag_str, "color": POSITIVE_COLOR}) - if not atk_str.is_empty(): - stats_out.append({"text": atk_str, "color": POSITIVE_COLOR}) - else: - if not atk_str.is_empty(): - stats_out.append({"text": atk_str, "color": POSITIVE_COLOR}) - if not mag_str.is_empty(): - stats_out.append({"text": mag_str, "color": POSITIVE_COLOR}) - elif item_type == ITEM_TYPE_ARMOR and values.size() >= 6: - var def_val := int(values[1]) + int(values[5]) * 2 - if def_val > 0: - stats_out.append({"text": "防御力 %d" % def_val, "color": POSITIVE_COLOR}) - if int(values[0]) > 0: - stats_out.append({"text": "魔法防御 %d" % int(values[0]), "color": POSITIVE_COLOR}) - - # 固有属性 (Proto Applies) - var applies_out: Array[Dictionary] = [] - for apply in proto_data.get("applies", []): - var atype := int(apply.get("type", 0)) - var aval := int(apply.get("value", 0)) - if atype == 0 or aval == 0: - continue - var astr := format_apply_string(atype, aval) - var acol := POSITIVE_COLOR if aval > 0 else NEGATIVE_COLOR - applies_out.append({"text": astr, "color": acol, "type": atype, "value": aval}) - - # 附加属性 (Instance Attrs,蓝字/强化属性) - var attrs_out: Array[Dictionary] = [] - for i in attrs.size(): - var attr: Dictionary = attrs[i] - var atype := int(attr.get("type", 0)) - var aval := int(attr.get("value", 0)) - if atype == 0 or aval == 0: - continue - var astr := format_apply_string(atype, aval) - var acol := SPECIAL_POSITIVE_COLOR if i < 5 else SPECIAL_POSITIVE_COLOR2 - if aval < 0: - acol = NEGATIVE_COLOR - attrs_out.append({"text": astr, "color": acol, "type": atype, "value": aval, "index": i}) - - # 穿戴职业 (Wearable Info) - var wearable_out: Dictionary = {} - if item_type == ITEM_TYPE_WEAPON or item_type == ITEM_TYPE_ARMOR or item_type == ITEM_TYPE_COSTUME: - var anti: int = int(proto_data.get("anti_flags", 0)) - var jobs: Array[String] = [] - var player_job: int = int(player_ctx.get("job", -1)) - var player_job_can := true - for j in 4: - if (anti & (1 << (2 + j))) == 0: - jobs.append(CHARACTER_NAMES[j]) - else: - if player_job == j: - player_job_can = false - var jobs_str := " ".join(jobs) - var jobs_col := DISABLE_COLOR if (player_job >= 0 and not player_job_can) else NORMAL_COLOR - var gender_str := "" - var gender_col := NORMAL_COLOR - var player_sex: int = int(player_ctx.get("sex", -1)) - if (anti & ANTIFLAG_FEMALE) != 0: - gender_str = "男性专用" - if player_sex == 1: - gender_col = DISABLE_COLOR - elif (anti & ANTIFLAG_MALE) != 0: - gender_str = "女性专用" - if player_sex == 0: - gender_col = DISABLE_COLOR - wearable_out = { - "header": "[ 穿戴职业 ]", - "jobs": jobs_str, - "jobs_color": jobs_col, - "gender": gender_str, - "gender_color": gender_col, - } - - # 魂石凹槽 - var sockets_out: Array[Dictionary] = [] - var has_sockets := false - for s in sockets: - if int(s) != 0: - has_sockets = true - break - if has_sockets or item_type == ITEM_TYPE_WEAPON or item_type == ITEM_TYPE_ARMOR: - for i in sockets.size(): - var sval := int(sockets[i]) - if sval == 0: - sockets_out.append({ - "index": i, - "type": "empty", - "name": "未使用的凹槽", - "name_color": NORMAL_COLOR, - "affect": "", - "affect_color": POSITIVE_COLOR, - "vnum": 0, - "icon_path": "", - }) - elif sval < 0: - sockets_out.append({ - "index": i, - "type": "broken", - "name": "损坏的凹槽", - "name_color": DISABLE_COLOR, - "affect": "", - "affect_color": POSITIVE_COLOR, - "vnum": sval, - "icon_path": "", - }) - elif sval == 1: - sockets_out.append({ - "index": i, - "type": "empty", - "name": "未使用的凹槽", - "name_color": NORMAL_COLOR, - "affect": "", - "affect_color": POSITIVE_COLOR, - "vnum": 1, - "icon_path": "", - }) - else: - var sinfo := get_stone_info(sval) - sockets_out.append({ - "index": i, - "type": "stone", - "name": sinfo.get("name", "魂石 #%d" % sval), - "name_color": NORMAL_COLOR, - "affect": sinfo.get("affect", ""), - "affect_color": POSITIVE_COLOR, - "vnum": sval, - "icon_path": sinfo.get("icon_path", ""), - }) - - # 价格与限时 - var price_val := int(instance_data.get("price", 0)) - var realtime_str := _get_realtime_text(proto_data, sockets, instance_data) - - return { - "vnum": vnum, - "title": name, - "title_color": title_col, - "count": count, - "desc": desc_text, - "summary": summary_text, - "limits": limits_out, - "stats": stats_out, - "applies": applies_out, - "attrs": attrs_out, - "wearable": wearable_out, - "sockets": sockets_out, - "price": price_val, - "realtime": realtime_str, - "item_type": item_type, - } - -func format(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {}) -> String: - if vnum <= 0: - return "" - var name: String = _item_names.get(vnum, "") - if name.is_empty(): - name = String(proto_data.get("locale_name", proto_data.get("name", ""))) - if name.is_empty() and _descriptions.has(vnum): - name = String(_descriptions[vnum].get("name", "")) - if name.is_empty(): - name = "物品 #%d" % vnum - var sockets: Array = instance_data.get("sockets", proto_data.get("sockets", [])) - name = _special_title(vnum, name, sockets) - var lines: Array[String] = [name] - if count > 1: - lines.append("数量:%d" % count) - var desc: Dictionary = _descriptions.get(vnum, {}) - if not String(desc.get("description", "")).is_empty(): - lines.append(String(desc["description"])) - - var item_type := int(proto_data.get("type", 0)) - var type_name: String = TYPE_NAMES.get(item_type, "") - if not type_name.is_empty(): - lines.append("类型:" + type_name) - var values: Array = proto_data.get("values", []) - if item_type == ITEM_TYPE_WEAPON and values.size() >= 6: - var min_attack := int(values[3]) + int(values[5]) - var max_attack := int(values[4]) + int(values[5]) - if min_attack > 0 or max_attack > 0: - lines.append("攻击力:%d - %d" % [min_attack, max_attack]) - if int(values[1]) > 0 or int(values[2]) > 0: - lines.append("魔法攻击:%d - %d" % [int(values[1]), int(values[2])]) - elif item_type == ITEM_TYPE_ARMOR and values.size() >= 6: - if int(values[1]) > 0: - lines.append("防御力:%d" % (int(values[1]) + int(values[5]) * 2)) - if int(values[0]) > 0: - lines.append("魔法防御:%d" % int(values[0])) - _append_special_item_data(lines, item_type, vnum, values, proto_data, instance_data) - - for limit in proto_data.get("limits", []): - var limit_type := int(limit.get("type", 0)) - var limit_value := int(limit.get("value", 0)) - if limit_type == 6: - lines.append("PC房专用道具") - continue - if limit_type != 0 and limit_value > 0: - lines.append("%s:%d" % [LIMIT_NAMES.get(limit_type, "限制 %d" % limit_type), limit_value]) - for apply in proto_data.get("applies", []): - _append_bonus(lines, int(apply.get("type", 0)), int(apply.get("value", 0))) - for attr in instance_data.get("attrs", proto_data.get("attrs", [])): - _append_bonus(lines, int(attr.get("type", 0)), int(attr.get("value", 0)), "附加") - - var has_socket := false - for socket in sockets: - if int(socket) != 0: - has_socket = true - break - if has_socket: - for i in sockets.size(): - var socket_value := int(sockets[i]) - lines.append("孔 %d:%s" % [i + 1, "空" if socket_value == 0 else ("损坏" if socket_value < 0 else "#%d" % socket_value)]) - - var refined_vnum := int(proto_data.get("refined_vnum", 0)) - if refined_vnum > 0: - lines.append("精炼后:#%d" % refined_vnum) - var refine_set := int(proto_data.get("refine_set", 0)) - if refine_set > 0: - lines.append("精炼套件:%d" % refine_set) - _append_realtime_limit(lines, proto_data, sockets, instance_data) - return "\n".join(lines) - -func _load_skill_names(assets_root: String, lang: String) -> void: - var path := assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang) - if not FileAccess.file_exists(path): - return - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - return - while not file.eof_reached(): - var parts := file.get_line().split("\t", false) - if parts.size() < 3: - continue - var skill_id := int(parts[0]) - var skill_name := parts[2].strip_edges() - if skill_id > 0 and skill_name != "": - _skill_names[skill_id] = skill_name - -func _special_title(vnum: int, item_name: String, sockets: Array) -> String: - if sockets.is_empty() or int(sockets[0]) <= 0: - return item_name - if vnum == 50300 or vnum == 70037 or vnum == 70055: - var skill_name := String(_skill_names.get(int(sockets[0]), "")) - return skill_name + " " + item_name if skill_name != "" else item_name - if vnum >= 70103 and vnum <= 70106 and _proto_node and _proto_node.has_method("mob"): - var mob: Dictionary = _proto_node.mob(int(sockets[0])) - var mob_name := String(mob.get("locale_name", mob.get("name", ""))) - return mob_name + " " + item_name if mob_name != "" else item_name - return item_name - -func _get_realtime_text(proto_data: Dictionary, sockets: Array, instance_data: Dictionary) -> String: - if bool(instance_data.get("realtime_rendered", false)) or sockets.is_empty(): - return "" - var limit_type := 0 - for limit in proto_data.get("limits", []): - var candidate := int(limit.get("type", 0)) - if candidate == 7 or candidate == 8 or candidate == 9: - limit_type = candidate - break - if limit_type == 0: - return "" - var value := int(sockets[0]) - var remaining := 0 - if limit_type == 8: - var used := int(sockets[1]) if sockets.size() > 1 else 0 - if used == 0: - return "首次使用后计时:%s" % _duration_text(value) - remaining = maxi(0, value - int(Time.get_unix_time_from_system())) - elif limit_type == 7: - remaining = maxi(0, value - int(Time.get_unix_time_from_system())) - else: - remaining = maxi(0, value) - if remaining <= 0: - return "已超时" - return _duration_text(remaining) - -func _append_realtime_limit(lines: Array[String], proto_data: Dictionary, sockets: Array, - instance_data: Dictionary) -> void: - var t := _get_realtime_text(proto_data, sockets, instance_data) - if not t.is_empty(): - if t.begins_with("首次使用"): - lines.append(t) - else: - lines.append("剩余时间:%s" % t) - -func _duration_text(seconds: int) -> String: - var minutes := seconds / 60 - var rest := seconds % 60 - var text := "%d秒" % rest - if minutes > 0: - text = "%d分%s" % [minutes, text] if rest > 0 else "%d分" % minutes - return text - -func _append_special_item_data(lines: Array[String], item_type: int, vnum: int, - values: Array, proto_data: Dictionary, instance_data: Dictionary) -> void: - var sockets: Array = instance_data.get("sockets", []) - if item_type == ITEM_TYPE_ROD or item_type == ITEM_TYPE_PICK: - if values.size() >= 3 and not sockets.is_empty(): - var level := int(values[0]) / 10 - var current_exp := int(sockets[0]) - var max_exp := int(values[2]) - lines.append("等级:%d" % level) - lines.append("经验:%d / %d" % [current_exp, max_exp]) - if max_exp > 0 and current_exp >= max_exp: - lines.append("经验已满,可升级") - elif item_type == ITEM_TYPE_FISH: - if not sockets.is_empty() and int(sockets[0]) > 0: - lines.append("长度:%.2f cm" % (float(sockets[0]) / 100.0)) - elif item_type == ITEM_TYPE_LOTTERY: - if sockets.size() >= 2 and (int(sockets[0]) != 0 or int(sockets[1]) != 0): - lines.append("彩票号码:%d" % int(sockets[0])) - lines.append("彩票步数:%d" % int(sockets[1])) - elif item_type == ITEM_TYPE_BLEND: - if sockets.size() >= 3 and int(sockets[0]) != 0: - var affect_type := int(sockets[0]) - var affect_value := int(sockets[1]) - _append_bonus(lines, affect_type, affect_value, "效果:") - var duration := int(sockets[2]) - lines.append("持续时间:%d 秒" % duration if duration > 0 else "无持续时间") - elif item_type == ITEM_TYPE_UNIQUE: - _append_item_remaining(lines, proto_data, sockets, instance_data) - elif item_type == ITEM_TYPE_DS: - var step := (vnum / 100) % 10 - var refine := (vnum / 10) % 10 - if step >= 0 and step <= 4: - lines.append("龙魂石阶段:%d" % (step + 1)) - lines.append("龙魂石强化:%d" % refine) - -func _append_item_remaining(lines: Array[String], proto_data: Dictionary, sockets: Array, - instance_data: Dictionary) -> void: - var remaining := int(instance_data.get("remaining_seconds", instance_data.get("duration", 0))) - if remaining <= 0 and not sockets.is_empty(): - var limit_type := int(instance_data.get("limit_type", 0)) - if limit_type == 0: - for limit in proto_data.get("limits", []): - if int(limit.get("type", 0)) != 0: - limit_type = int(limit.get("type", 0)) - break - if limit_type == 7 or limit_type == 8: - remaining = maxi(0, int(sockets[0]) - int(Time.get_unix_time_from_system())) - elif limit_type == 9: - remaining = int(sockets[0]) - elif sockets.size() >= 3: - remaining = int(sockets[2]) * 60 - if remaining > 0: - lines.append("剩余时间:%d 秒" % remaining) - -func _append_bonus(lines: Array[String], apply_type: int, value: int, prefix := "") -> void: - if apply_type == 0 or value == 0: - return - var label: String = APPLY_NAMES.get(apply_type, "属性 %d" % apply_type) - if not prefix.is_empty(): - label = prefix + label - lines.append("%s:%+d" % [label, value]) diff --git a/project/ui/item_tooltip.gd.uid b/project/ui/item_tooltip.gd.uid deleted file mode 100644 index 4eb71dce..00000000 --- a/project/ui/item_tooltip.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dqv4o4srhhl45 diff --git a/project/ui/item_tooltip_bridge.gd b/project/ui/item_tooltip_bridge.gd deleted file mode 100644 index 82036ca3..00000000 --- a/project/ui/item_tooltip_bridge.gd +++ /dev/null @@ -1,56 +0,0 @@ -# ItemTooltipBridge —— all item-bearing windows share the 40250 ItemTooltipView. -# -# Native Control.tooltip_text is deliberately disabled for item slots. The -# reference client owns one custom ItemToolTip instance and switches its data -# as the pointer moves between inventory, storage, exchange and shop windows. -extends RefCounted - -const ItemTooltipView = preload("res://ui/item_tooltip_view.gd") - -var _ui: CanvasLayer -var _parent: Node -var _proto: Node -var _assets_root := "" -var _item_list: RefCounted -var _view: Control - -func setup(parent: Node, proto: Node = null, assets_root := "", item_list: RefCounted = null) -> void: - _parent = parent - _ui = parent as CanvasLayer - _proto = proto - _assets_root = assets_root if assets_root != "" else AssetRoot.path() - _item_list = item_list - -func bind(control: Control, item_getter: Callable) -> void: - if control == null: - return - control.tooltip_text = "" - control.mouse_entered.connect(func() -> void: - var item = item_getter.call() - show_item(item if item is Dictionary else {})) - control.mouse_exited.connect(hide) - -func show_item(item: Dictionary) -> void: - var vnum := int(item.get("vnum", 0)) - if vnum <= 0: - hide() - return - var count := maxi(1, int(item.get("count", 1))) - var proto_data: Dictionary = _proto.item(vnum) if _proto and _proto.has_method("item") else {} - if _ui and _ui.has_method("show_item_tooltip"): - _ui.show_item_tooltip(vnum, count, proto_data, item, {}, _assets_root, _proto, _item_list) - return - if _view == null or not is_instance_valid(_view): - _view = ItemTooltipView.new() - _view.setup(_assets_root, _proto, _item_list) - _view.visible = false - _view.z_index = 200 - if _parent: - _parent.add_child(_view) - _view.show_item(vnum, count, proto_data, item, {}) - -func hide() -> void: - if _ui and _ui.has_method("hide_item_tooltip"): - _ui.hide_item_tooltip() - elif _view and is_instance_valid(_view): - _view.hide_tooltip() diff --git a/project/ui/item_tooltip_bridge.gd.uid b/project/ui/item_tooltip_bridge.gd.uid deleted file mode 100644 index 2a17e38e..00000000 --- a/project/ui/item_tooltip_bridge.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b7adwn43kmm2y diff --git a/project/ui/item_tooltip_view.gd b/project/ui/item_tooltip_view.gd deleted file mode 100644 index c372d3e4..00000000 --- a/project/ui/item_tooltip_view.gd +++ /dev/null @@ -1,303 +0,0 @@ -# ItemTooltipView —— 40250 标准 ThinBoard 悬浮窗控件。 -# -# 严格遵循 40250 uitooltip.py 规范: -# 1. 继承 ui.ThinBoard 视觉:九宫格古铜金属边框、半透明黑底、固定初始宽度 190px。 -# 2. 所有文本水平居中(SetHorizontalAlignCenter),带黑色文字描边。 -# 3. 严格的 40250 配色体系: -# - 普通装备浅金象牙白,蓝字/强化装备亮金色 -# - 攻击力、防御力加算公式及绿色高亮 -# - 固有词条绿色/负值红色,附加属性 1~5 薄荷青绿,6~7 浅薄荷绿 -# - 适用职业([ 穿戴职业 ] 及职业列表,不符合角色职业时警示红) -# - 等级限制(不满足角色等级时警示红) -# 4. 魂石凹槽图文解析:银色凹槽贴图 + 32x32 魂石图标 + 魂石名称与第一条属性描述。 -# 5. 浮动跟随鼠标或在格子旁边即时展现(0 延迟)。 -extends MarginContainer - -const ItemTooltip = preload("res://ui/item_tooltip.gd") -const UiKit = preload("res://ui_kit.gd") -const UiAssets = preload("res://ui/ui_assets.gd") - -var _vbox: VBoxContainer -var _bg: NinePatchRect -var _assets_root := "" -var _proto_node: Node = null -var _item_list: RefCounted = null -var _tooltip_builder: RefCounted - -var follow_mouse := true - -func _init() -> void: - name = "ItemTooltipView" - mouse_filter = Control.MOUSE_FILTER_IGNORE - custom_minimum_size = Vector2(190, 0) - add_theme_constant_override("margin_left", 8) - add_theme_constant_override("margin_right", 8) - add_theme_constant_override("margin_top", 10) - add_theme_constant_override("margin_bottom", 10) - -func setup(assets_root: String, proto: Node = null, ilist: RefCounted = null, builder: RefCounted = null) -> void: - _assets_root = assets_root - _proto_node = proto - _item_list = ilist - _tooltip_builder = builder if builder != null else ItemTooltip.new() - if builder == null and not _assets_root.is_empty(): - _tooltip_builder.setup(_assets_root, "en", _proto_node) - _rebuild_background() - -func _rebuild_background() -> void: - if _bg != null and is_instance_valid(_bg): - _bg.queue_free() - _bg = null - if not _assets_root.is_empty(): - _bg = UiKit.board(_assets_root, "thinboard") - if _bg: - _bg.name = "ThinBoardBg" - _bg.set_anchors_preset(Control.PRESET_FULL_RECT) - _bg.show_behind_parent = true - _bg.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_bg) - move_child(_bg, 0) - -func show_item(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {}, - player_ctx: Dictionary = {}) -> void: - if _tooltip_builder == null: - _tooltip_builder = ItemTooltip.new() - if not _assets_root.is_empty(): - _tooltip_builder.setup(_assets_root, "en", _proto_node) - var model: Dictionary = _tooltip_builder.build_model(vnum, count, proto_data, instance_data, player_ctx) - populate_from_model(model) - visible = true - if is_inside_tree(): - update_position() - -func hide_tooltip() -> void: - visible = false - -func populate_from_model(model: Dictionary) -> void: - if _vbox == null: - _vbox = VBoxContainer.new() - _vbox.name = "ContentVBox" - _vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE - _vbox.add_theme_constant_override("separation", 2) - add_child(_vbox) - - for c in _vbox.get_children(): - c.queue_free() - - if model.is_empty(): - return - - # 1. 标题 (Item Title) - var title_text: String = model.get("title", "") - var title_col: Color = model.get("title_color", ItemTooltip.TITLE_COLOR) - _vbox.add_child(_make_label(title_text, title_col, 12, true)) - - # 2. 数量 (Count > 1) - var count: int = int(model.get("count", 1)) - if count > 1: - _vbox.add_child(_make_label("数量:%d" % count, ItemTooltip.NORMAL_COLOR, 11)) - - # 3. 描述 (Description) - var desc: String = model.get("desc", "") - if not desc.is_empty(): - _vbox.add_child(_make_label(desc, ItemTooltip.NORMAL_COLOR, 10, false, true)) - - # 4. 概要/条件 (Summary) - var summary: String = model.get("summary", "") - if not summary.is_empty(): - _vbox.add_child(_make_label(summary, ItemTooltip.CONDITION_COLOR, 10, false, true)) - - # 5. 限制要求 (Limits) - var limits: Array = model.get("limits", []) - if not limits.is_empty(): - _vbox.add_child(_make_spacer(3)) - for lim in limits: - _vbox.add_child(_make_label(lim.get("text", ""), lim.get("color", ItemTooltip.NORMAL_COLOR), 11)) - - # 6. 基础战斗数值 (Stats: Attack Power, Defense) - var stats: Array = model.get("stats", []) - if not stats.is_empty(): - _vbox.add_child(_make_spacer(3)) - for s in stats: - _vbox.add_child(_make_label(s.get("text", ""), s.get("color", ItemTooltip.POSITIVE_COLOR), 11)) - - # 7. 固有属性 (Proto Applies) - var applies: Array = model.get("applies", []) - for a in applies: - _vbox.add_child(_make_label(a.get("text", ""), a.get("color", ItemTooltip.POSITIVE_COLOR), 11)) - - # 8. 附加属性 (Instance Attrs) - var attrs: Array = model.get("attrs", []) - for a in attrs: - _vbox.add_child(_make_label(a.get("text", ""), a.get("color", ItemTooltip.SPECIAL_POSITIVE_COLOR), 11)) - - # 9. 穿戴职业 (Wearable Info) - var wearable: Dictionary = model.get("wearable", {}) - if not wearable.is_empty(): - _vbox.add_child(_make_spacer(3)) - var hdr: String = wearable.get("header", "[ 穿戴职业 ]") - _vbox.add_child(_make_label(hdr, ItemTooltip.NORMAL_COLOR, 11)) - var jobs_text: String = wearable.get("jobs", "") - if not jobs_text.is_empty(): - var j_col: Color = wearable.get("jobs_color", ItemTooltip.NORMAL_COLOR) - _vbox.add_child(_make_label(jobs_text, j_col, 11)) - var gender_text: String = wearable.get("gender", "") - if not gender_text.is_empty(): - var g_col: Color = wearable.get("gender_color", ItemTooltip.NORMAL_COLOR) - _vbox.add_child(_make_label(gender_text, g_col, 11)) - - # 10. 魂石凹槽 (Sockets) - var sockets: Array = model.get("sockets", []) - if not sockets.is_empty(): - _vbox.add_child(_make_spacer(3)) - for sock in sockets: - _vbox.add_child(_make_socket_row(sock)) - - # 11. 价格 (Price) - var price: int = int(model.get("price", 0)) - if price > 0: - _vbox.add_child(_make_spacer(3)) - _vbox.add_child(_make_label("价格:%s 金币" % _format_price(price), ItemTooltip.PRICE_COLOR, 11)) - - # 12. 限时剩余时间 (Realtime) - var realtime: String = model.get("realtime", "") - if not realtime.is_empty(): - _vbox.add_child(_make_spacer(2)) - _vbox.add_child(_make_label("剩余时间:%s" % realtime, ItemTooltip.NORMAL_COLOR, 11)) - -func _make_label(text: String, color: Color, font_size := 11, bold := false, autowrap := false) -> Label: - var lbl := Label.new() - lbl.text = text - lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - lbl.add_theme_font_size_override("font_size", font_size) - lbl.add_theme_color_override("font_color", color) - lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.95)) - lbl.add_theme_constant_override("outline_size", 2) - lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - if autowrap: - lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - lbl.custom_minimum_size.x = 174 - return lbl - -func _make_spacer(height: int) -> Control: - var sp := Control.new() - sp.custom_minimum_size = Vector2(0, height) - sp.mouse_filter = Control.MOUSE_FILTER_IGNORE - return sp - -func _make_socket_row(sock: Dictionary) -> Control: - var row := HBoxContainer.new() - row.mouse_filter = Control.MOUSE_FILTER_IGNORE - row.add_theme_constant_override("separation", 6) - row.alignment = BoxContainer.ALIGNMENT_CENTER - - var icon_box := Control.new() - icon_box.custom_minimum_size = Vector2(36, 36) - icon_box.mouse_filter = Control.MOUSE_FILTER_IGNORE - - var slot_tex: Texture2D = null - if not _assets_root.is_empty(): - slot_tex = UiAssets.load_tex(_assets_root, "d:/ymir work/ui/game/windows/metin_slot_silver.sub") - if slot_tex: - var slot_rect := TextureRect.new() - slot_rect.texture = slot_tex - slot_rect.custom_minimum_size = Vector2(36, 36) - slot_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE - icon_box.add_child(slot_rect) - - var stone_vnum: int = int(sock.get("vnum", 0)) - if stone_vnum > 1: - var icon_tex := _resolve_item_icon(stone_vnum) - if icon_tex: - var icon_rect := TextureRect.new() - icon_rect.texture = icon_tex - icon_rect.position = Vector2(2, 2) - icon_rect.size = Vector2(32, 32) - icon_rect.custom_minimum_size = Vector2(32, 32) - icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - icon_rect.stretch_mode = TextureRect.STRETCH_SCALE - icon_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE - icon_box.add_child(icon_rect) - - row.add_child(icon_box) - - var text_box := VBoxContainer.new() - text_box.alignment = BoxContainer.ALIGNMENT_CENTER - text_box.mouse_filter = Control.MOUSE_FILTER_IGNORE - text_box.add_theme_constant_override("separation", 1) - - var name_lbl := Label.new() - name_lbl.text = String(sock.get("name", "")) - name_lbl.add_theme_font_size_override("font_size", 10) - name_lbl.add_theme_color_override("font_color", sock.get("name_color", ItemTooltip.NORMAL_COLOR)) - name_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.95)) - name_lbl.add_theme_constant_override("outline_size", 2) - name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - text_box.add_child(name_lbl) - - var affect_str: String = String(sock.get("affect", "")) - if not affect_str.is_empty(): - var aff_lbl := Label.new() - aff_lbl.text = affect_str - aff_lbl.add_theme_font_size_override("font_size", 10) - aff_lbl.add_theme_color_override("font_color", sock.get("affect_color", ItemTooltip.POSITIVE_COLOR)) - aff_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.95)) - aff_lbl.add_theme_constant_override("outline_size", 2) - aff_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - text_box.add_child(aff_lbl) - - row.add_child(text_box) - return row - -func _resolve_item_icon(vnum: int) -> Texture2D: - if _item_list and _item_list.has_method("icon"): - var p: String = _item_list.icon(vnum) - if not p.is_empty() and not _assets_root.is_empty(): - var tex := UiAssets.load_tex(_assets_root, p) - if tex: - return tex - if not _assets_root.is_empty(): - var rel := "icon/item/%d.tga" % vnum - var cand := UiAssets.load_tex(_assets_root, rel) - if cand: - return cand - return null - -func _format_price(val: int) -> String: - var s := str(val) - var res := "" - var cnt := 0 - for i in range(s.length() - 1, -1, -1): - res = s[i] + res - cnt += 1 - if cnt % 3 == 0 and i > 0: - res = "," + res - return res - -func _process(_delta: float) -> void: - if visible and follow_mouse: - update_position() - -func update_position(screen_size: Vector2 = Vector2.ZERO) -> void: - if not visible: - return - var mouse := get_global_mouse_position() - var w := size.x if size.x > 0 else custom_minimum_size.x - var h := size.y - if screen_size == Vector2.ZERO: - var vp := get_viewport() - if vp: - screen_size = vp.get_visible_rect().size - else: - screen_size = Vector2(1920, 1080) - - var x := mouse.x - w / 2.0 - var y := 0.0 - if mouse.y < screen_size.y - 320.0: - y = mouse.y + 24.0 - else: - y = mouse.y - h - 16.0 - - x = clampf(x, 8.0, maxf(8.0, screen_size.x - w - 8.0)) - y = clampf(y, 8.0, maxf(8.0, screen_size.y - h - 8.0)) - global_position = Vector2(x, y) diff --git a/project/ui/item_tooltip_view.gd.uid b/project/ui/item_tooltip_view.gd.uid deleted file mode 100644 index 152d9caf..00000000 --- a/project/ui/item_tooltip_view.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://sx713do85i5t diff --git a/project/ui/loading_screen.gd b/project/ui/loading_screen.gd deleted file mode 100644 index 34ee4465..00000000 --- a/project/ui/loading_screen.gd +++ /dev/null @@ -1,90 +0,0 @@ -# LoadingScreen (P10) —— 相位驱动的读取遮罩。 -# -# var ls := preload("res://ui/loading_screen.gd").new() -# add_child(ls) -# ls.setup(m2client) # 接 phase_changed;也可手动 show_for("...") / hide() -# -# 只有 PHASE = "loading"(载入地图)才铺全屏遮罩;login / select / game 都隐藏 -# —— select 阶段要露出选人界面,login 阶段登录框自己显示进度。 -extends CanvasLayer - -const HINTS := { - "loading": "载入地图…", -} - -var client: Node -var _panel: ColorRect -var _label: Label -var _spinner: Label -var _bar: ProgressBar -var _spin := 0.0 - -func setup(m2client: Node = null) -> void: - client = m2client - layer = 60 - _build() - if client and client.has_signal("phase_changed"): - client.phase_changed.connect(_on_phase) - if client and client.has_signal("disconnected"): - client.disconnected.connect(func(_r): hide_screen()) - -func _on_phase(phase: String) -> void: - if phase.to_lower() == "loading": - show_for(String(HINTS.get("loading", "载入地图…"))) - else: - # ClientVS22 owns the full-screen load window only in - # SetLoadingPhase(). SetLoginPhase(), SetSelectPhase() and - # SetGamePhase() all return control to their phase window; keeping the - # mask for GAME would leave an invisible input blocker over the world. - hide_screen() - -func show_for(text: String) -> void: - _label.text = text - _bar.visible = false - _panel.mouse_filter = Control.MOUSE_FILTER_STOP - _panel.visible = true - -func set_progress(frac: float) -> void: - _bar.visible = true - _bar.value = clampf(frac, 0.0, 1.0) * 100.0 - -func hide_screen() -> void: - _panel.mouse_filter = Control.MOUSE_FILTER_IGNORE - _panel.visible = false - -func is_showing() -> bool: - return _panel.visible - -func _process(dt: float) -> void: - if _panel.visible: - _spin += dt * 4.0 - _spinner.text = ["◐", "◓", "◑", "◒"][int(_spin) % 4] - -func _build() -> void: - _panel = ColorRect.new() - _panel.color = Color(0.02, 0.03, 0.05, 0.96) - _panel.set_anchors_preset(Control.PRESET_FULL_RECT) - _panel.mouse_filter = Control.MOUSE_FILTER_STOP - _panel.visible = false - add_child(_panel) - var box := VBoxContainer.new() - box.set_anchors_preset(Control.PRESET_CENTER) - box.position = Vector2(-120, -50) - box.custom_minimum_size = Vector2(240, 0) - box.alignment = BoxContainer.ALIGNMENT_CENTER - box.add_theme_constant_override("separation", 14) - _panel.add_child(box) - _spinner = Label.new() - _spinner.text = "◐" - _spinner.add_theme_font_size_override("font_size", 36) - _spinner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - box.add_child(_spinner) - _label = Label.new() - _label.text = "载入中…" - _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - box.add_child(_label) - _bar = ProgressBar.new() - _bar.custom_minimum_size = Vector2(240, 12) - _bar.show_percentage = false - _bar.visible = false - box.add_child(_bar) diff --git a/project/ui/loading_screen.gd.uid b/project/ui/loading_screen.gd.uid deleted file mode 100644 index 175f2da4..00000000 --- a/project/ui/loading_screen.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b6okrhdjyyuu4 diff --git a/project/ui/love_ui.gd b/project/ui/love_ui.gd deleted file mode 100644 index 8b0aa9e9..00000000 --- a/project/ui/love_ui.gd +++ /dev/null @@ -1,74 +0,0 @@ -# LoveUI — GC_LOVER_INFO / GC_LOVE_POINT_UPDATE 的常驻情侣状态。 -extends Node - -var client: Node -var _root: Panel -var _name: Label -var _bar: ProgressBar -var _mobile_mode := false - -func setup(m2client: Node, parent: Node) -> void: - client = m2client - _build(parent) - if client.has_signal("lover_changed"): - client.lover_changed.connect(func(_lover: Dictionary): refresh()) - refresh() - -func refresh() -> void: - if client == null or not client.has_method("get_lover"): - return - var lover: Dictionary = client.get_lover() - var valid := bool(lover.get("valid", false)) - # lover_divorce(CHAT_TYPE_COMMAND)→ store 清空 LoverInfo → valid=false 自动隐藏。 - _root.visible = valid and not _mobile_mode - if not valid: - return - # 上/下线与靠近/远离由服务器 lover_login/logout/near/far 命令驱动 - #(marriage.cpp;原版 affectShower 只在“靠近”时亮心形)。 - var online := bool(lover.get("online", false)) - var near := bool(lover.get("near", false)) - var state := "(在身边)" if near else "(在线)" if online else "(离线)" - _name.text = "♥ " + String(lover.get("name", "")) + state - _name.modulate = Color(1.0, 0.6, 0.72) if near else Color(1.0, 0.72, 0.82) - _bar.value = clampi(int(lover.get("love_point", 0)), 0, 100) - _bar.tooltip_text = "爱意值:%d%%" % int(_bar.value) - -func get_mobile_window() -> Control: - return _root - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - if enabled and _root: - _root.visible = false - -func toggle() -> void: - if _root: - var valid := false - if client and client.has_method("get_lover"): - valid = bool(client.get_lover().get("valid", false)) - _root.visible = valid and not _root.visible - -func _build(parent: Node) -> void: - _root = Panel.new() - _root.name = "LoveStatus" - _root.set_anchors_preset(Control.PRESET_TOP_RIGHT) - _root.position = Vector2(-238, 28) - _root.size = Vector2(220, 48) - var bg := StyleBoxFlat.new() - bg.bg_color = Color(0.14, 0.05, 0.10, 0.88) - bg.border_color = Color(0.95, 0.35, 0.55, 0.9) - bg.set_border_width_all(1) - bg.set_corner_radius_all(5) - _root.add_theme_stylebox_override("panel", bg) - parent.add_child(_root) - _name = Label.new() - _name.position = Vector2(9, 5) - _name.add_theme_font_size_override("font_size", 13) - _name.modulate = Color(1.0, 0.72, 0.82) - _root.add_child(_name) - _bar = ProgressBar.new() - _bar.position = Vector2(9, 27) - _bar.size = Vector2(202, 13) - _bar.max_value = 100 - _bar.show_percentage = false - _root.add_child(_bar) diff --git a/project/ui/love_ui.gd.uid b/project/ui/love_ui.gd.uid deleted file mode 100644 index fe4244db..00000000 --- a/project/ui/love_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bctp408qvphx6 diff --git a/project/ui/mall_ui.gd b/project/ui/mall_ui.gd deleted file mode 100644 index 0b41d28a..00000000 --- a/project/ui/mall_ui.gd +++ /dev/null @@ -1,373 +0,0 @@ -# MallUI (첫 버전) —— 道具商城仓库窗(창고몰 / item-mall storage)。 -# -# var mu := preload("res://ui/mall_ui.gd").new() -# add_child(mu) -# mu.setup(m2client, canvas_parent, proto) # proto 可空 -# -# `mall_opened` → 显示;`mall_changed` → 刷新。列出商城道具 + [取出](放进背包第一个空格)。 -extends Node - -const ItemTooltip = preload("res://ui/item_tooltip.gd") -const ItemTooltipBridge = preload("res://ui/item_tooltip_bridge.gd") - -var client: Node -var proto: Node -var _tooltip_builder: RefCounted -var _tooltip_bridge: RefCounted -var ui: CanvasLayer -var _root: Control -var _list: VBoxContainer -var _title: Label -var _status: Label -var _password_dialog: ConfirmationDialog -var item_mouse: Node -const MALL_WINDOW := 4 -const MALL_PAGE_SLOTS := 45 -const USE_MALL_LIMIT_RANGE_M := 10.0 - -var _grid: GridContainer -var _cells: Dictionary = {} -var _page_label: Label -var _page := 0 -var _mobile_mode := false -var _open_char_pos := Vector3.ZERO -var _has_open_pos := false - -func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: - client = m2client - proto = proto_node - ui = parent as CanvasLayer - _tooltip_builder = ItemTooltip.new() - _tooltip_builder.setup(AssetRoot.path(), "en", proto) - _tooltip_bridge = ItemTooltipBridge.new() - _tooltip_bridge.setup(parent, proto, AssetRoot.path()) - _build(parent) - if client.has_signal("mall_opened"): - client.mall_opened.connect(func(_s): open()) - if client.has_signal("mall_closed"): - client.mall_closed.connect(_close_local) - if client.has_signal("mall_changed"): - client.mall_changed.connect(refresh) - if client.has_signal("mall_password_required"): - client.mall_password_required.connect(_ask_password) - -func is_open() -> bool: - return _root != null and _root.visible - -func _get_main_char_pos() -> Variant: - if client == null: - return null - var vid := 0 - if client.has_method("get_main_vid"): - vid = int(client.get_main_vid()) - if vid != 0 and client.has_method("get_entity"): - var e: Dictionary = client.get_entity(vid) - if not e.is_empty() and e.has("pos"): - return e["pos"] - return null - -func _process(_delta: float) -> void: - if not is_open() or not _has_open_pos: - return - var cur = _get_main_char_pos() - if cur is Vector3: - if _open_char_pos.distance_to(cur) > USE_MALL_LIMIT_RANGE_M: - close() - -func open() -> void: - if _root: - _root.visible = true - var p = _get_main_char_pos() - if p is Vector3: - _open_char_pos = p - _has_open_pos = true - refresh() - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - -func get_mobile_window() -> Control: - return _root - -func close() -> void: - # 对齐原版 MallWindow.Close():先发 /mall_close 让服务器关掉商城仓库。 - if client and client.has_method("mall_close") and is_open(): - client.mall_close() - _close_local() - -func _close_local() -> void: - if _tooltip_bridge: - _tooltip_bridge.hide() - _has_open_pos = false - if item_mouse and item_mouse.has_method("unregister_owner"): - item_mouse.unregister_owner(self) - if item_mouse.has_method("is_attached") and item_mouse.is_attached(): - item_mouse.cancel() - if _root: - _root.visible = false - _page = 0 - if is_instance_valid(_password_dialog): - _password_dialog.queue_free() - _password_dialog = null - -func _name_of(vnum: int) -> String: - if proto and proto.has_method("item"): - var d: Dictionary = proto.item(vnum) - var n := String(d.get("locale_name", d.get("name", ""))) - if n != "": - return n - return "#%d" % vnum - -func _tooltip_for(item: Dictionary) -> String: - var vnum := int(item.get("vnum", 0)) - if _tooltip_builder: - var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} - return _tooltip_builder.format(vnum, int(item.get("count", 1)), pd, item) + "\n拖到背包取出" - return "%s\n#%d ×%d\n拖到背包取出" % [_name_of(vnum), vnum, int(item.get("count", 1))] - -func _first_free_inv() -> int: - var used := {} - for it in client.get_inventory(): - used[int(it.get("cell", -1))] = true - for i in range(90): - if not used.has(i): - return i - return -1 - -func refresh() -> void: - if client == null or _root == null: - return - _root.visible = client.is_mall_open() - if not _root.visible: - return - _status.text = "" - var pages := maxi(1, int(ceil(float(client.get_mall_size()) / float(MALL_PAGE_SLOTS)))) - _page = clampi(_page, 0, pages - 1) - _title.text = "道具商城仓库(第 %d/%d 页)" % [_page + 1, pages] - _page_label.text = "%d / %d" % [_page + 1, pages] - for c in _list.get_children(): - c.queue_free() - _render_grid() - var items: Array = client.get_mall_items() - if items.is_empty(): - var e := Label.new() - e.text = "(空)" - _list.add_child(e) - return - for it in items: - _list.add_child(_row(it)) - -func _render_grid() -> void: - if _grid == null: - return - for pos in _cells: - var cell: Button = _cells[pos] - cell.text = "" - cell.tooltip_text = "" - cell.set_meta("tooltip_item", {}) - cell.set_meta("vnum", 0) - cell.set_meta("count", 0) - var items: Array = client.get_mall_items() if client and client.has_method("get_mall_items") else [] - for it in items: - var absolute_pos := int(it.get("cell", -1)) - var pos := absolute_pos - _page * MALL_PAGE_SLOTS - if not _cells.has(pos): - continue - var vnum := int(it.get("vnum", 0)) - var count := int(it.get("count", 1)) - var cell: Button = _cells[pos] - cell.text = "%s%s" % [_name_of(vnum).substr(0, 7), (" ×%d" % count) if count > 1 else ""] - cell.tooltip_text = "" - cell.set_meta("tooltip_item", it.duplicate(true)) - cell.set_meta("vnum", vnum) - cell.set_meta("count", count) - -func _drop_to_slot(_payload: Dictionary, _local_pos: int) -> bool: - if is_instance_valid(_status): - _status.text = "无法将物品放入商城仓库!" - if item_mouse and item_mouse.has_method("cancel"): - item_mouse.cancel() - return false - -func _on_grid_input(local_pos: int, event: InputEvent) -> void: - if not (event is InputEventMouseButton and event.pressed): - return - if event.button_index == MOUSE_BUTTON_RIGHT: - var mall_pos := _page * MALL_PAGE_SLOTS + local_pos - var target := _first_free_inv() - if target < 0: - if is_instance_valid(_status): - _status.text = "背包已满" - return - if client and client.has_method("mall_checkout"): - client.mall_checkout(mall_pos, 1, target) - return - elif event.button_index == MOUSE_BUTTON_LEFT: - if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached(): - if is_instance_valid(_status): - _status.text = "无法将物品放入商城仓库!" - item_mouse.cancel() - return - if _mobile_mode: - _on_mobile_grid_tap(local_pos) - return - if not item_mouse or not item_mouse.has_method("attach_item"): - return - var cell: Button = _cells[local_pos] - var vnum := int(cell.get_meta("vnum", 0)) - if vnum == 0: - return - item_mouse.attach_item(MALL_WINDOW, _page * MALL_PAGE_SLOTS + local_pos, vnum, - int(cell.get_meta("count", 1)), null, "mall") - -func _on_mobile_grid_tap(local_pos: int) -> void: - if not _mobile_mode or client == null or not _cells.has(local_pos): - return - var cell: Button = _cells[local_pos] - var vnum := int(cell.get_meta("vnum", 0)) - if vnum == 0 or not client.has_method("mall_checkout"): - return - var target := _first_free_inv() - if target < 0: - _status.text = "背包已满" - return - if not client.mall_checkout(_page * MALL_PAGE_SLOTS + local_pos, 1, target): - _status.text = "取出请求发送失败" - -func _change_page(delta: int) -> void: - var pages := maxi(1, int(ceil(float(client.get_mall_size()) / float(MALL_PAGE_SLOTS)))) - _page = clampi(_page + delta, 0, pages - 1) - refresh() - -func _ask_password() -> void: - if is_instance_valid(_password_dialog): - _password_dialog.queue_free() - _password_dialog = ConfirmationDialog.new() - _password_dialog.title = "商城密码" - _password_dialog.dialog_text = "请输入 1~6 位密码" - _password_dialog.ok_button_text = "确认" - _password_dialog.cancel_button_text = "取消" - var edit := LineEdit.new() - edit.secret = true - edit.max_length = 6 - edit.placeholder_text = "密码" - edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER - edit.custom_minimum_size = Vector2(360, 44) if _mobile_mode else Vector2(220, 28) - _password_dialog.add_child(edit) - _password_dialog.confirmed.connect(func() -> void: - if not client.mall_password(edit.text) and is_instance_valid(_status): - _status.text = "密码格式无效" - _password_dialog.queue_free() - _password_dialog = null) - _password_dialog.canceled.connect(func() -> void: - _password_dialog.queue_free() - _password_dialog = null) - if _mobile_mode: - _password_dialog.set_meta("mobile_modal", true) - _password_dialog.set_meta("mobile_title", "商城密码") - _password_dialog.min_size = Vector2i(460, 230) - _password_dialog.size = Vector2i(460, 230) - _root.add_child(_password_dialog) - if _mobile_mode: - _password_dialog.popup_centered(Vector2i(460, 230)) - else: - _password_dialog.popup_centered() - -func _row(it: Dictionary) -> Control: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(300, 0) - var vnum := int(it.get("vnum", 0)) - var count := int(it.get("count", 1)) - var cell := int(it.get("cell", 0)) - var nm := Label.new() - nm.text = "%s ×%d" % [_name_of(vnum), count] - nm.custom_minimum_size = Vector2(220, 0) - nm.add_theme_font_size_override("font_size", 12) - if _tooltip_bridge: - _tooltip_bridge.bind(nm, func() -> Dictionary: return it.duplicate(true)) - row.add_child(nm) - if item_mouse: - nm.mouse_filter = Control.MOUSE_FILTER_STOP - nm.gui_input.connect(func(e: InputEvent): - if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed \ - and item_mouse.has_method("attach_item"): - item_mouse.attach_item(MALL_WINDOW, cell, vnum, count, null, "mall")) - var out := Button.new() - out.text = "取出" - out.pressed.connect(func() -> void: - var target := _first_free_inv() - if target < 0: - _status.text = "背包已满" - return - if not client.mall_checkout(cell, 1, target): - _status.text = "取出请求发送失败") - row.add_child(out) - return row - -func _build(parent: Node) -> void: - _root = Panel.new() - _root.set_anchors_preset(Control.PRESET_CENTER) - _root.position = Vector2(140, -180) - _root.custom_minimum_size = Vector2(340, 360) - _root.size = Vector2(340, 360) - _root.visible = false - parent.add_child(_root) - var box := VBoxContainer.new() - box.position = Vector2(16, 14) - box.custom_minimum_size = Vector2(308, 0) - box.add_theme_constant_override("separation", 6) - _root.add_child(box) - _title = Label.new() - _title.text = "道具商城仓库" - _title.add_theme_font_size_override("font_size", 16) - box.add_child(_title) - var pager := HBoxContainer.new() - pager.position = Vector2(230, 0) - pager.size = Vector2(90, 24) - var prev := Button.new() - prev.text = "‹" - prev.custom_minimum_size = Vector2(28, 24) - prev.pressed.connect(func(): _change_page(-1)) - pager.add_child(prev) - _page_label = Label.new() - _page_label.custom_minimum_size = Vector2(40, 24) - _page_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - pager.add_child(_page_label) - var next := Button.new() - next.text = "›" - next.custom_minimum_size = Vector2(28, 24) - next.pressed.connect(func(): _change_page(1)) - pager.add_child(next) - _root.add_child(pager) - _status = Label.new() - _status.add_theme_font_size_override("font_size", 11) - _status.add_theme_color_override("font_color", Color(1, 0.65, 0.55)) - box.add_child(_status) - var sc := ScrollContainer.new() - sc.custom_minimum_size = Vector2(308, 280) - box.add_child(sc) - _list = VBoxContainer.new() - _list.add_theme_constant_override("separation", 4) - sc.add_child(_list) - _list.visible = false - _grid = GridContainer.new() - _grid.columns = 5 - _grid.position = Vector2(16, 58) - _grid.add_theme_constant_override("h_separation", 4) - _grid.add_theme_constant_override("v_separation", 4) - _root.add_child(_grid) - for pos in MALL_PAGE_SLOTS: - var cell := Button.new() - cell.custom_minimum_size = Vector2(58, 30) - cell.add_theme_font_size_override("font_size", 9) - cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e)) - cell.pressed.connect(func(): _on_mobile_grid_tap(pos)) - if _tooltip_bridge: - cell.mouse_entered.connect(func(): _tooltip_bridge.show_item(cell.get_meta("tooltip_item", {}))) - cell.mouse_exited.connect(func(): _tooltip_bridge.hide()) - _grid.add_child(cell) - _cells[pos] = cell - var close := Button.new() - close.text = "关闭" - close.pressed.connect(func() -> void: _root.visible = false) - box.add_child(close) diff --git a/project/ui/mall_ui.gd.uid b/project/ui/mall_ui.gd.uid deleted file mode 100644 index bbbe2019..00000000 --- a/project/ui/mall_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bqlipbe67jyy6 diff --git a/project/ui/minimap.gd b/project/ui/minimap.gd deleted file mode 100644 index 8f3f9d8a..00000000 --- a/project/ui/minimap.gd +++ /dev/null @@ -1,378 +0,0 @@ -# Minimap (P9) —— 右上角圆形小地图。北朝上。 -# -# var mm := preload("res://ui/minimap.gd").new() -# add_child(mm) -# mm.setup(m2client, canvas_parent, func() -> Node3D: return player) -# -# 画:底圆 + 外环 + 玩家箭头 + 实体蓝点 + 怪红点 + NPC 黄点 + 传送青点 + 任务标记橙星 + -# 观战者紫色菱形 + 公会领地边框;支持 SetScale / 鼠标滚轮缩放。 -# 世界→图:Δ = blip.xz - player.xz,按 scale 转像素,(+x 右, -z 上),超出半径夹到边缘。 -extends Node - -signal map_pressed - -const RADIUS := 70.0 -const SCALE := 0.25 # 像素/米(越大越放大) -# CPythonMiniMap 的 waypoint 夹边留边:c_fMiniMapWindowRadius = min(w,h)/2 - 9.0f -# (m2dev PythonMiniMap.cpp:441)。信标越出此半径时,标记贴到圆周并改画方向箭头。 -const SIGNAL_RIM_INSET := 9.0 -const NPC_WARP_TYPE := 2 # NPCMark.type: 2 = 传送 -const MIN_SCALE := 0.10 -const MAX_SCALE := 1.25 -const OBSERVER_ADD := 0 -const OBSERVER_REMOVE := 1 -const OBSERVER_MOVE := 2 -const OBSERVER_INTERPOLATION_MS := 1000.0 - -var client: Node -var player_getter: Callable -var _root: Control -var _view: Control -var _ch_label: Label -var _coord_label: Label -var _btn_zoom_in: Button -var _btn_zoom_out: Button -var _btn_atlas: Button -var _scale := SCALE -var _signal_points: Array[Vector2] = [] -var _observer_tracks: Dictionary = {} - -func setup(m2client: Node, parent: Node, get_player: Callable) -> void: - client = m2client - player_getter = get_player - _build(parent) - if client and client.has_signal("channel_changed"): - client.channel_changed.connect(func(c): _ch_label.text = "CH %d" % c) - if client and client.has_signal("observer_event"): - client.observer_event.connect(_on_observer_event) - if client and client.has_signal("world_reset"): - client.world_reset.connect(_clear_observers) - _sync_observers_snapshot() - -func _build(parent: Node) -> void: - _root = Control.new() - _root.name = "MiniMap" - _root.set_anchors_preset(Control.PRESET_TOP_RIGHT) - _root.position = Vector2(-2 * RADIUS - 16, 12) - _root.size = Vector2(2 * RADIUS + 16, 2 * RADIUS + 38) - parent.add_child(_root) - - _view = Control.new() - _view.size = Vector2(2 * RADIUS, 2 * RADIUS) - _view.mouse_filter = Control.MOUSE_FILTER_IGNORE - _view.draw.connect(_draw_map) - _root.add_child(_view) - _root.gui_input.connect(_on_gui_input) - _root.mouse_filter = Control.MOUSE_FILTER_STOP - - _ch_label = Label.new() - _ch_label.position = Vector2(4, 2 * RADIUS + 2) - _ch_label.add_theme_font_size_override("font_size", 10) - _ch_label.text = "CH -" - _root.add_child(_ch_label) - - # 40250 uiminimap.py:440 实时坐标 "(X, Y)" - _coord_label = Label.new() - _coord_label.position = Vector2(46, 2 * RADIUS + 2) - _coord_label.size = Vector2(90, 16) - _coord_label.add_theme_font_size_override("font_size", 10) - _coord_label.text = "(0, 0)" - _root.add_child(_coord_label) - - # 40250 uiminimap.py: ScaleUp / ScaleDown / Atlas - _btn_zoom_in = Button.new() - _btn_zoom_in.name = "ScaleUpButton" - _btn_zoom_in.position = Vector2(2 * RADIUS - 2, 2 * RADIUS - 38) - _btn_zoom_in.size = Vector2(18, 18) - _btn_zoom_in.text = "+" - _btn_zoom_in.add_theme_font_size_override("font_size", 11) - _btn_zoom_in.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - _btn_zoom_in.pressed.connect(func(): set_scale(_scale + 0.05)) - _root.add_child(_btn_zoom_in) - - _btn_zoom_out = Button.new() - _btn_zoom_out.name = "ScaleDownButton" - _btn_zoom_out.position = Vector2(2 * RADIUS - 2, 2 * RADIUS - 18) - _btn_zoom_out.size = Vector2(18, 18) - _btn_zoom_out.text = "-" - _btn_zoom_out.add_theme_font_size_override("font_size", 11) - _btn_zoom_out.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - _btn_zoom_out.pressed.connect(func(): set_scale(_scale - 0.05)) - _root.add_child(_btn_zoom_out) - - _btn_atlas = Button.new() - _btn_atlas.name = "AtlasButton" - _btn_atlas.position = Vector2(2 * RADIUS - 2, 2 * RADIUS + 2) - _btn_atlas.size = Vector2(18, 18) - _btn_atlas.text = "M" - _btn_atlas.add_theme_font_size_override("font_size", 10) - _btn_atlas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - _btn_atlas.pressed.connect(func(): map_pressed.emit()) - _root.add_child(_btn_atlas) - -func _process(_dt: float) -> void: - if _coord_label: - var pl := _player() - if pl: - var pos: Vector3 = pl.global_position if pl.is_inside_tree() else pl.position - # 40250 uiminimap.py: (x // 100, y // 100) - _coord_label.text = "(%d, %d)" % [int(pos.x), int(-pos.z)] - if _view: - _view.queue_redraw() - -# Matches CPythonMiniMap::SetScale: callers may provide pixels per world metre. -# The wheel gesture is intentionally local to the minimap so world controls do -# not change when the pointer is elsewhere. -func set_scale(pixels_per_meter: float) -> void: - _scale = clampf(pixels_per_meter, MIN_SCALE, MAX_SCALE) - if _view: - _view.queue_redraw() - -func get_scale() -> float: - return _scale - -# Reparent the same live minimap view into the mobile top-right HUD. The -# drawing/data path stays shared; only the presentation container changes. -func mount_mobile(host: Control) -> void: - if host == null or _root == null: - return - var old_parent := _root.get_parent() - if old_parent: - old_parent.remove_child(_root) - host.add_child(_root) - _root.set_anchors_preset(Control.PRESET_TOP_LEFT) - _root.position = Vector2(0, 0) - _root.size = Vector2(140, 156) - _root.scale = Vector2(0.64, 0.64) - _root.mouse_filter = Control.MOUSE_FILTER_STOP - -func restore_desktop_layout() -> void: - if _root == null: - return - _root.scale = Vector2.ONE - _root.set_anchors_preset(Control.PRESET_TOP_RIGHT) - _root.position = Vector2(-2 * RADIUS - 16, 12) - _root.size = Vector2(2 * RADIUS, 2 * RADIUS + 16) - -# CPythonMiniMap::AddSignalPoint stores quest-script waypoints separately from -# server target markers. EventManager supplies absolute server centimetres. -func add_signal_point(x: float, y: float) -> void: - _signal_points.append(Vector2(x, y)) - if _view: - _view.queue_redraw() - -func clear_signal_points() -> void: - _signal_points.clear() - if _view: - _view.queue_redraw() - -func signal_point_count() -> int: - return _signal_points.size() - -# 1:1 复刻 m2dev CPythonMiniMap::Render 的 waypoint 夹边分支(PythonMiniMap.cpp:445-462): -# fDistanceFromCenter = sqrt(dx*dx + dy*dy) -# if fDistanceFromCenter >= radius: -# fRadian = atan2f(dy, dx); dx = radius*cos; dy = radius*sin -# 返回 {clamped, offset(相对圆心的像素偏移), angle(指向真实目标的弧度)}。 -static func signal_marker_geometry(delta_px: Vector2, rim_radius: float) -> Dictionary: - var angle := atan2(delta_px.y, delta_px.x) - if delta_px.length() >= rim_radius: - return { - "clamped": true, - "offset": Vector2(rim_radius * cos(angle), rim_radius * sin(angle)), - "angle": angle, - } - return {"clamped": false, "offset": delta_px, "angle": angle} - -func _signal_offset(world: Vector3, origin: Vector3) -> Dictionary: - var d := world - origin - # 与 _to_map 相同的世界→图轴向:world +x = 东(右),world −z = 北(上)。 - var delta_px := Vector2(d.x, d.z) * _scale - return signal_marker_geometry(delta_px, RADIUS - SIGNAL_RIM_INSET) - -# CPythonMiniMap::AddObserver / MoveObserver / RemoveObserver keep observer -# positions separate from ordinary character instances. The reference client -# interpolates each move over one second, so a packet burst does not make the -# purple observer marker teleport across the minimap. -func _observer_track(pos: Vector3, now_ms: int) -> Dictionary: - return {"cur": pos, "src": pos, "dst": pos, "started_ms": now_ms} - -func _set_observer_track(vid: int, pos: Vector3) -> void: - _observer_tracks[vid] = _observer_track(pos, Time.get_ticks_msec()) - -func _on_observer_event(kind: int, vid: int, pos: Vector2) -> void: - var net_pos := Vector3(pos.x, 0.0, pos.y) - match kind: - OBSERVER_ADD: - # AddObserver resets an existing observer to the packet position too. - _set_observer_track(vid, net_pos) - OBSERVER_REMOVE: - _observer_tracks.erase(vid) - OBSERVER_MOVE: - # Match MoveObserver: a move without a prior add is ignored. The - # snapshot sync below covers observers present before setup. - if not _observer_tracks.has(vid): - return - var cur := _observer_position(vid, net_pos) - var track: Dictionary = _observer_tracks[vid] - track["src"] = cur - track["dst"] = net_pos - track["cur"] = cur - track["started_ms"] = Time.get_ticks_msec() - _observer_tracks[vid] = track - -func _clear_observers() -> void: - _observer_tracks.clear() - -func _sync_observers_snapshot() -> void: - if client == null or not client.has_method("get_observers"): - return - # This seeds observers that arrived before the minimap connected. Once an - # observer_event stream exists, its transitions own the track's destination. - var has_events := client.has_signal("observer_event") - for o in client.get_observers(): - var vid := int(o.get("vid", 0)) - if vid == 0: - continue - var pos: Vector3 = o.get("pos", Vector3.ZERO) - if not has_events: - _set_observer_track(vid, pos) - elif not _observer_tracks.has(vid): - _set_observer_track(vid, pos) - -func _observer_position(vid: int, fallback: Vector3 = Vector3.ZERO) -> Vector3: - if not _observer_tracks.has(vid): - _set_observer_track(vid, fallback) - var track: Dictionary = _observer_tracks[vid] - var src: Vector3 = track.get("src", fallback) - var dst: Vector3 = track.get("dst", src) - var started_ms := float(track.get("started_ms", Time.get_ticks_msec())) - var f := clampf((float(Time.get_ticks_msec()) - started_ms) / OBSERVER_INTERPOLATION_MS, 0.0, 1.0) - var cur := src.lerp(dst, f) - track["cur"] = cur - _observer_tracks[vid] = track - return cur - -func _on_gui_input(event: InputEvent) -> void: - if event is InputEventScreenTouch and event.pressed: - map_pressed.emit() - _root.accept_event() - return - if event is InputEventMouseButton and event.pressed: - if event.button_index == MOUSE_BUTTON_LEFT: - map_pressed.emit() - _root.accept_event() - return - if event.button_index == MOUSE_BUTTON_WHEEL_UP: - set_scale(_scale * 1.15) - _root.accept_event() - elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: - set_scale(_scale / 1.15) - _root.accept_event() - -func _player() -> Node3D: - return player_getter.call() if player_getter.is_valid() else null - -func _to_map(world: Vector3, origin: Vector3) -> Vector2: - var d := world - origin - # screen: +x right, +y down. world −z = north -> screen up (−y). world +x = east -> right. - var p := Vector2(d.x, d.z) * _scale - if p.length() > RADIUS - 4.0: - p = p.normalized() * (RADIUS - 4.0) - return Vector2(RADIUS, RADIUS) + p - -# 圆周方向箭头(对应 __RenderTargetMark 的边缘指示;本工程无 .sub 资源,用三角形代替)。 -func _draw_signal_arrow(pos: Vector2, angle: float) -> void: - var dir := Vector2(cos(angle), sin(angle)) - var perp := Vector2(-dir.y, dir.x) - var tip := pos + dir * 7.0 - var base := pos - dir * 3.0 - _view.draw_colored_polygon( - PackedVector2Array([tip, base + perp * 4.0, base - perp * 4.0]), - Color(1.0, 0.6, 0.1, 0.95)) - -func _draw_map() -> void: - var c := Vector2(RADIUS, RADIUS) - _view.draw_circle(c, RADIUS, Color(0.05, 0.06, 0.08, 0.82)) - _view.draw_arc(c, RADIUS - 1.5, 0, TAU, 48, Color(0.6, 0.6, 0.7, 0.7), 2.0) - var pl := _player() - if pl == null or client == null: - return - var origin: Vector3 = pl.global_position - var party_vids := {} - if client.has_method("get_party"): - for member in client.get_party(): - var pvid := int(member.get("vid", 0)) - if pvid != 0: - party_vids[pvid] = bool(member.get("leader", false)) - - # 实体:怪红、其它蓝 - if client.has_method("get_entities"): - for e in client.get_entities(): - if bool(e.get("is_main", false)): - continue - var pos: Vector3 = e.get("pos", Vector3.ZERO) - var vid := int(e.get("vid", 0)) - var map_pos := _to_map(MapCoord.to_world(pos), origin) - var col := Color(0.35, 1.0, 0.55) if party_vids.has(vid) \ - else Color(0.4, 0.6, 1.0) if int(e.get("ch_type", 0)) != 2 else Color(1.0, 0.35, 0.3) - _view.draw_circle(map_pos, 2.5, col) - if bool(party_vids.get(vid, false)): - _view.draw_arc(map_pos, 4.5, 0.0, TAU, 16, Color(1.0, 0.9, 0.25, 0.9), 1.0) - - # NPC 标记:传送青、其它黄 - if client.has_method("get_npc_marks"): - for m in client.get_npc_marks(): - var col := Color(0.3, 0.9, 0.9) if int(m.get("type", 0)) == NPC_WARP_TYPE else Color(1.0, 0.85, 0.2) - _view.draw_circle(_to_map(MapCoord.to_world(m.get("pos", Vector3.ZERO)), origin), 3.0, col) - - # 观战者:原版在小地图上显示独立的观察点(紫色菱形)。 - if client.has_method("get_observers"): - _sync_observers_snapshot() - for o in client.get_observers(): - var vid := int(o.get("vid", 0)) - var observer_world := _observer_position(vid, o.get("pos", Vector3.ZERO)) - var op := _to_map(MapCoord.to_world(observer_world), origin) - var diamond := PackedVector2Array([op + Vector2(0, -4), op + Vector2(4, 0), - op + Vector2(0, 4), op + Vector2(-4, 0)]) - _view.draw_colored_polygon(diamond, Color(0.8, 0.35, 1.0, 0.95)) - - # 公会领地:GC_LAND_LIST 的全局厘米矩形,在小地图上画细边框; - # guild_id=0 使用中性青色,其余领地使用金色。 - if client.has_method("get_land_areas"): - for a in client.get_land_areas(): - var x := float(a.get("x", 0)) - var y := float(a.get("y", 0)) - var w := float(a.get("width", 0)) - var h := float(a.get("height", 0)) - var p0 := _to_map(MapCoord.to_world(Vector3(x * 0.01, 0, -y * 0.01)), origin) - var p1 := _to_map(MapCoord.to_world(Vector3((x + w) * 0.01, 0, -(y + h) * 0.01)), origin) - var rect := Rect2(Vector2(min(p0.x, p1.x), min(p0.y, p1.y)), - Vector2(abs(p1.x - p0.x), abs(p1.y - p0.y))) - var col := Color(0.3, 0.9, 0.9, 0.75) if int(a.get("guild_id", 0)) == 0 \ - else Color(1.0, 0.75, 0.2, 0.85) - _view.draw_rect(rect, col, false, 1.0) - - # 任务标记(EventManager quest signals,本地 waypoint,不是 GC_TARGET): - # 圆内画橙星;越出小地图半径则贴到圆周画指向目标的方向箭头(世界箭头 / 屏幕边缘指示)。 - for signal_point in _signal_points: - var signal_pos := MapCoord.to_world(Vector3(signal_point.x * 0.01, 0, -signal_point.y * 0.01)) - var geo := _signal_offset(signal_pos, origin) - var smp: Vector2 = c + geo["offset"] - if geo["clamped"]: - _draw_signal_arrow(smp, geo["angle"]) - else: - _view.draw_circle(smp, 5.0, Color(1.0, 0.75, 0.15, 0.95)) - _view.draw_arc(smp, 8.0, 0.0, TAU, 16, Color(1.0, 0.4, 0.1, 0.9), 1.5) - if client.has_method("get_world_markers"): - for m in client.get_world_markers(): - var mp := _to_map(MapCoord.to_world(m.get("pos", Vector3.ZERO)), origin) - _view.draw_rect(Rect2(mp - Vector2(3, 3), Vector2(6, 6)), Color(1.0, 0.6, 0.1)) - - # 玩家箭头(朝向) - var yaw: float = pl.rotation.y - var fwd := Vector2(sin(yaw), cos(yaw)) # world +? -> map;与 _to_map 的 (x,-z) 对齐 - var tip := c + fwd * 8.0 - var left := c + fwd.rotated(deg_to_rad(140)) * 6.0 - var right := c + fwd.rotated(deg_to_rad(-140)) * 6.0 - _view.draw_colored_polygon(PackedVector2Array([tip, left, right]), Color(1, 1, 1)) diff --git a/project/ui/minimap.gd.uid b/project/ui/minimap.gd.uid deleted file mode 100644 index 91744863..00000000 --- a/project/ui/minimap.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://nknmvwygsuda diff --git a/project/ui/mob_view.gd b/project/ui/mob_view.gd deleted file mode 100644 index 95d92ad6..00000000 --- a/project/ui/mob_view.gd +++ /dev/null @@ -1,720 +0,0 @@ -# MobView (真机字节校准收尾) —— 怪 / NPC 的真模型:Metin2Model + Metin2AnimPlayer。 -# -# var mv := preload("res://ui/mob_view.gd").new() -# if mv.build(assets_root, proto, race): # race = mob_proto vnum(GC_CHARACTER_ADD.race) -# ... -# mv.set_anim_state("run") # wait/run/attack/damage/dead/skill/emotion -# -# 目录:/*/ymir work/{monster,npc}// -# .gr2 = 基础网格,.dds = 贴图,motlist.txt = 动作索引表: -# GENERAL WAIT 00.msa 65 (组 动作名 文件 权重) -extends Node3D - -# net_world 的状态名 -> 40250 CRaceMotionData::EName。 -# motlist.txt 中的多个别名会先归并到同一个动作索引,再按权重选 vector 中的 -# 一个文件;这里保留名称表只为兼容没有 motlist 的测试替身。 -const STATE_MOTIONS := { - "wait": ["WAIT"], - "walk": ["WALK"], - "run": ["RUN"], - "attack": ["NORMAL_ATTACK"], - "combo": ["COMBO_ATTACK"], - "skill": ["SPECIAL", "SKILL1", "SKILL2", "SKILL3", "SKILL4", "SKILL5"], - "damage": ["FRONT_DAMAGE"], - "dead": ["FRONT_DEAD"], - "emotion": ["WAIT"], - # §3.7 受击动作步(motlist 名 -> NAME_DAMAGE_BACK / DAMAGE_FLYING(_BACK) / STAND_UP(_BACK))。 - # 不加别名:缺哪段就让 InterceptOnceMotion / PushOnceMotion 失败,与参考端一致。 - "damage_back": ["BACK_DAMAGE"], - "knockdown": ["FRONT_KNOCKDOWN"], - "knockdown_back": ["BACK_KNOCKDOWN"], - "standup": ["FRONT_STANDUP"], - "standup_back": ["BACK_STANDUP"], -} -# CRaceManager::__LoadRaceMotionList 的完整名称 -> EName 映射。 -# 后缀回退(WAIT3 -> WAIT 等)在 _npc_motion_index 中单独实现,不能把 -# FRONT_DAMAGE / BACK_DAMAGE 等不同方向错误地合并。 -const NPC_MOTION_INDEX := { - "SPAWN": 25, - "WAIT": 1, "WAIT1": 1, "WAIT2": 1, - "WALK": 2, "WALK1": 2, "WALK2": 2, - "RUN": 3, "RUN1": 3, "RUN2": 3, - "STOP": 32, - "DEAD": 11, - "COMBO_ATTACK": 14, "COMBO_ATTACK1": 15, "COMBO_ATTACK2": 16, - "NORMAL_ATTACK": 13, "NORMAL_ATTACK1": 13, "NORMAL_ATTACK2": 13, - "FRONT_DAMAGE": 5, "FRONT_DAMAGE1": 5, "FRONT_DAMAGE2": 5, "FRONT_DAMAGE3": 5, - "FRONT_DEAD": 11, "FRONT_DEAD1": 11, "FRONT_DEAD2": 11, - "FRONT_KNOCKDOWN": 6, "FRONT_KNOCKDOWN1": 6, - "FRONT_STANDUP": 7, "FRONT_STANDUP1": 7, - "BACK_DAMAGE": 8, "BACK_DAMAGE1": 8, - "BACK_DEAD": 12, "BACK_DEAD1": 12, "BACK_DEAD2": 12, - "BACK_KNOCKDOWN": 9, "BACK_KNOCKDOWN1": 9, - "BACK_STANDUP": 10, "BACK_STANDUP1": 10, - "SPECIAL": 33, "SPECIAL1": 34, "SPECIAL2": 35, - "SPECIAL3": 36, "SPECIAL4": 37, "SPECIAL5": 38, - "SKILL1": 171, "SKILL2": 172, "SKILL3": 173, "SKILL4": 174, "SKILL5": 175, -} -const STATE_MOTION_INDEXES := { - "wait": [1], "walk": [2], "run": [3], "attack": [13], "combo": [14], - "skill": [33, 171, 172, 173, 174, 175], "damage": [5], "dead": [11], - "emotion": [1], "damage_back": [8], "knockdown": [6], - "knockdown_back": [9], "standup": [7], "standup_back": [10], -} -const HitReaction = preload("res://ui/hit_reaction.gd") -const MsaMotion = preload("res://ui/msa_motion.gd") -const MOVE_STATES := ["walk", "run"] -const HitCollision = preload("res://hit_collision.gd") - -# CActorInstance::__SetMotion 尾:动作绑定完成。 -signal motion_bound(state: String) - -var model: Node # Metin2Model -var anim: Node # Metin2AnimPlayer -var _dir := "" -var _mesh_stem := "" -var _hit := HitReaction.new() -var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径 -var _motion_variants := {} # EName(int) -> [{motion, path, weight}, ...] -var _motlist_loaded := false -var _state := "" -var _move_speed_ratio := 1.0 -var _motion_hidden := false -var _last_resolution := {} -var _audio: Node -var _sound_instances: Array = [] -var _sound_frame := -1 - -# root/npclist.txt: vnum -> 模型代号(bear_brown / tiger_big / stray_dog…)。 -# 翻译版 mob_proto 把 szName 也本地化了("Brown Bear"),解不出目录,必须靠这张表。 -static var _npclist: Dictionary = {} -static var _race_src_name: Dictionary = {} -static var _npclist_loaded := false - -# race -> { "dir": String, "mesh_stem": String, "gr2": String, -# "motions": Dictionary, "motion_variants": Dictionary, "motlist_loaded": bool } -# 同一种怪/NPC 只解析一次目录、主网格与 motlist.txt,后续实例直接复用,避免数万次同步磁盘 IO。 -static var _race_spec_cache: Dictionary = {} - -static func clear_cache() -> void: - _race_spec_cache.clear() - -static func _load_npclist(assets_root: String) -> void: - if _npclist_loaded: - return - _npclist_loaded = true - for rel in ["root/npclist.txt", "npclist.txt", "locale/npclist.txt"]: - var p := assets_root.path_join(rel) - if not FileAccess.file_exists(p): - continue - var f := FileAccess.open(p, FileAccess.READ) - while f and not f.eof_reached(): - var line := f.get_line().strip_edges() - if line == "": - continue - var parts := line.split("\t", false) - if parts.size() < 2: - parts = line.split(" ", false) - if parts.size() < 2: - continue - var vnum := int(parts[0]) - var code := String(parts[1]).strip_edges() - if vnum > 0 and code != "": - _npclist[vnum] = code - elif vnum == 0 and parts.size() >= 3: - var src := String(parts[2]).strip_edges() - if code != "" and src != "": - _race_src_name[code] = src - break - -func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> bool: - if not ClassDB.class_exists("Metin2Model") or race < 1: - return false - var gr2 := "" - var cached: Variant = _race_spec_cache.get(race, null) - if cached is Dictionary: - _dir = String(cached.get("dir", "")) - _mesh_stem = String(cached.get("mesh_stem", "")) - gr2 = String(cached.get("gr2", "")) - _motions = (cached.get("motions", {}) as Dictionary).duplicate() - _motion_variants = (cached.get("motion_variants", {}) as Dictionary).duplicate(true) - _motlist_loaded = bool(cached.get("motlist_loaded", false)) - else: - _load_npclist(assets_root) - var spec := _find_model_spec(assets_root, proto, race) - if spec.is_empty(): - return false - _dir = String(spec.get("dir", "")) - _mesh_stem = String(spec.get("mesh_stem", "")) - gr2 = String(spec.get("gr2", "")) - _load_motlist(String(spec.get("motion_list", ""))) - _race_spec_cache[race] = { - "dir": _dir, - "mesh_stem": _mesh_stem, - "gr2": gr2, - "motions": _motions.duplicate(), - "motion_variants": _motion_variants.duplicate(true), - "motlist_loaded": _motlist_loaded, - } - if pump.is_valid(): pump.call() - model = ClassDB.instantiate("Metin2Model") - model.name = "Metin2Model" - # NPC/monster GR2 uses the same source winding as PCs. Without conversion, - # back-face culling exposes interiors and hides the outward-facing surfaces. - model.set("flip_winding", true) - model.set("texture_dir", _dir) - model.set("gr2_path", gr2) # 重:解 gr2 + 建网格 - if pump.is_valid(): pump.call() - add_child(model) - model.ready.connect(_ground_model) - _ground_model() - if ClassDB.class_exists("Metin2AnimPlayer"): - anim = ClassDB.instantiate("Metin2AnimPlayer") - anim.set("model_path", NodePath("../Metin2Model")) - anim.set("blend_time", 0.15) - add_child(anim) - if anim.has_signal("playback_finished"): - anim.playback_finished.connect(_on_playback_finished) - set_anim_state("wait") - CharShadow.attach(self, 1.6) # 怪脚印大一点 - return true - -func set_audio(audio_node: Node) -> void: - _audio = audio_node - _refresh_sound_script(String(anim.get("anim_path")) if anim else "") - -# 40250 CActorInstance::__HideEvent / __ShowEvent. Mob/NPC views expose the -# same actor-level seam as PlayerView even before net_world gets unified event -# routing for every remote actor. -func set_motion_hidden(hidden: bool) -> void: - _motion_hidden = hidden - if is_instance_valid(model) and model is Node3D: - (model as Node3D).visible = not hidden - -func is_motion_hidden() -> bool: - return _motion_hidden - -func set_anim_state(s: String) -> void: - # 40250 SetLoopMotion reaches __SetMotion even for the current motion, which - # also restores visibility after a CHARACTER_HIDE motion event. - if (s == _state and not _motion_hidden) or anim == null: - return - if HitReaction.is_reaction(s): - _hit.start([[s]], _state, _bind_reaction) - return - # 受击动作链播放中:循环动作请求留到链尾 PushLoopMotion 再回(seam:参考端动作队列) - if _hit.active and s in HitReaction.LOOP_STATES: - _hit.resume = s - return - # 40250 SetLoopMotion first resolves GetMotionKey. A missing key returns - # without cancelling the current motion or changing the logical state. - var resolution := resolve_motion(s) - _last_resolution = resolution - var msa := String(_last_resolution.path) - if msa == "": - # 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。 - return - _hit.cancel() - _state = s - _bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s)) - -# CActorInstance::SetMoveSpeed:m_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放 -# (Move -> SetLoopMotion(WALK/RUN, 0.15, m_fMovSpd))。>1100 时参考端不走动作累计, -# EntityStore 退回按 duration 插值,这里保持原速播放。 -func set_move_speed(moving_speed: int) -> void: - _move_speed_ratio = 1.0 if moving_speed <= 0 or moving_speed > 1100 \ - else float(moving_speed) / 100.0 - if anim != null and _state in MOVE_STATES and not _hit.active: - anim.set("time_scale", _hit.scale_for(_move_speed_ratio)) - -func _loop_speed(state: String) -> float: - return _move_speed_ratio if state in MOVE_STATES else 1.0 - -## 走 / 跑动作的根运动速度(cm/s,movSpd 100):x = walk,y = run。0 = 取不到。 -## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。 -func get_move_motion_speeds() -> Vector2: - var w := MsaMotion.move_speed(String(resolve_motion("walk").path)) - var r := MsaMotion.move_speed(String(resolve_motion("run").path)) - return Vector2(w, r) - -# CGraphicThingInstance::InsertDelay(fStiffenTime) -func insert_delay(d: float) -> void: - _hit.insert_delay(d, anim) - -# CActorInstance::__HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp) -func hit_good(scalar: float, stunned: bool) -> void: - if _hit.current_step in HitReaction.KNOCKDOWN_STATES or _hit.current_step in HitReaction.STANDUP_STATES: - return - _hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction) - -func hit_greate(scalar: float, stunned: bool) -> void: - if _hit.current_step in HitReaction.KNOCKDOWN_STATES or _hit.current_step in HitReaction.STANDUP_STATES: - return - _hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned) - -func hit_stone(stunned: bool) -> void: - _hit.stone(stunned) - -# CActorInstance::TEMP_Push large-correction tail: -# InterceptOnceMotion(DAMAGE_FLYING) + PushOnceMotion(STAND_UP). -func sync_push_motion() -> bool: - if anim == null: - return false - return _hit.start([["knockdown", "standup"], ["standup"]], _state, _bind_reaction) - -# 40250 CActorInstance::__Shake(100) -func shake() -> void: - _hit.shake() - -func is_in_hit_reaction() -> bool: - return _hit.active - -# 受击方防御球:怪目录的 .msm(<网格代号>.msm / <目录名>.msm / 目录里第一个 .msm)。 -func get_defending_spheres() -> Array: - if _dir == "": - return [] - var cands: Array = [] - if _mesh_stem != "": - cands.append(_dir.path_join(_mesh_stem + ".msm")) - cands.append(_dir.path_join(_dir.get_file() + ".msm")) - for p: String in cands: - if FileAccess.file_exists(p): - return HitCollision.parse_msm_defending(p) - var da := DirAccess.open(_dir) - if da: - for fn in da.get_files(): - if fn.to_lower().ends_with(".msm"): - return HitCollision.parse_msm_defending(_dir.path_join(fn)) - return [] - -func _on_playback_finished() -> void: - if not _hit.active: - return - if _hit.advance(_bind_reaction) or _hit.real_dead: - return - _state = "" - set_anim_state(_hit.resume) - -func _bind_reaction(step: String) -> bool: - if anim == null: - return false - var r := resolve_motion(step) - _last_resolution = r - var msa := String(r.path) - if msa == "": - return false - _bind(step, msa, false, 1.0) - return true - -func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void: - if not HitReaction.is_reaction(state): - _hit.cancel() - _state = state - # CActorInstance::__SetMotion calls __ShowEvent() once a valid motion key is - # selected, so a subsequent action cannot remain hidden accidentally. - if _motion_hidden: - set_motion_hidden(false) - anim.set("loop", loop) - if not loop and String(anim.get("anim_path")) == path: - anim.set("anim_path", "") # 同一段一次性动作再触发:先清,否则原生播放器视为无变化 - anim.set("anim_path", path) - anim.set("playing", true) - anim.set("time_scale", _hit.scale_for(speed_ratio)) - _refresh_sound_script(path) - motion_bound.emit(state) - -# Read-only motion resolver used by the forest acceptance harness. Returning -# the selected .msa before playback lets the test distinguish a real action -# from a silent "keep the previous animation" fallback. -func get_motion_path(state: String) -> String: - return String(resolve_motion(state).path) - -## requested_state / motion / path / fallback_reason。fallback_reason: -## "" 首选动作名命中 -## "client_alias:" 本客户端的同类动作别名(如 run -> WALK) -## "motlist_missing:" motlist 未列出,按目录里的 .msa 兜底 -## "reference_keep_current_motion" 资源没有任何候选动作;与旧客户端一致保留当前动作 -func resolve_motion(state: String) -> Dictionary: - var names: Array = STATE_MOTIONS.get(state, []) - var out := {"requested_state": state, "motion": "", "path": "", "fallback_reason": ""} - # Once a real motlist was loaded, use the same EName vector as CRaceData. - # Do not fall through to a different state (RUN -> WALK, DAMAGE -> WAIT) - # when the registered key is absent: SetLoopMotion would return FALSE. - if _motlist_loaded: - for motion_index: int in STATE_MOTION_INDEXES.get(state, []): - if not _motion_variants.has(motion_index): - continue - var selected: Dictionary = _pick_motion_variant(motion_index) - if selected.is_empty(): - continue - out.motion = String(selected.get("motion", "")) - out.path = String(selected.get("path", "")) - return out - out.fallback_reason = "reference_keep_current_motion" - return out - for mo: String in names: - if _motions.has(mo): - out.motion = mo - out.path = String(_motions[mo]) - if mo != String(names[0]): - out.fallback_reason = "client_alias:" + mo - return out - for mo: String in names: - var p := _dir.path_join(mo.to_lower() + ".msa") - if FileAccess.file_exists(p): - out.motion = mo - out.path = p - out.fallback_reason = "motlist_missing:" + p.get_file() - return out - out.fallback_reason = "reference_keep_current_motion" - return out - -func _pick_motion_variant(motion_index: int) -> Dictionary: - var variants: Array = _motion_variants.get(motion_index, []) - if variants.is_empty(): - return {} - if variants.size() == 1: - return variants[0] - # CActorInstance::GetRandomMotionKey: random() % 100, subtract each - # byPercentage in registration order, and leave sub-index 0 when the - # configured weights do not cover the whole [0, 100) interval. - var remaining := randi() % 100 - for variant: Dictionary in variants: - remaining -= int(variant.get("weight", 0)) - if remaining < 0: - return variant - return variants[0] - -func last_motion_resolution() -> Dictionary: - return _last_resolution.duplicate() - -func _process(delta: float) -> void: - _hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess - if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \ - or not bool(anim.get("playing")): - return - var current_time: float = anim.call("get_time") - var frame := int(floor(current_time * 60.0)) - if frame == _sound_frame: - return - _sound_frame = frame - _audio.update_sound_instances_3d(global_position.x, global_position.y, global_position.z, - frame, _sound_instances, true) - -func _refresh_sound_script(motion_path: String) -> void: - _sound_instances = [] - _sound_frame = -1 - if _audio == null or motion_path == "" or not motion_path.to_lower().ends_with(".msa"): - return - if _audio.has_method("load_mss_for_motion"): - _sound_instances = _audio.load_mss_for_motion(motion_path) - -# 供 net_world._on_info 用:真模型没有统一的名字节点时补一个。 -func set_display_name(n: String) -> void: - var tag := get_node_or_null("Label3D") as Label3D - if tag == null: - tag = Label3D.new() - tag.name = "Label3D" - tag.position.y = 2.1 - tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED - tag.no_depth_test = true - tag.pixel_size = 0.006 - add_child(tag) - tag.text = n - -func _ground_model() -> void: - if model and model.has_method("get_ground_offset"): - model.position.y = float(model.call("get_ground_offset")) - -# --- 内部 ------------------------------------------------------------------- - -func _folder_candidates(proto: Node, race: int) -> Array: - if proto == null or not proto.has_method("mob"): - return [] - var m: Dictionary = proto.mob(race) - if m.is_empty(): - return [] - var out: Array = [] - var raws: Array[String] = [String(m.get("name", "")), String(m.get("locale_name", ""))] - for raw in raws: - var s: String = raw.strip_edges().to_lower() - if s == "": - continue - for c in [s.replace(" ", "_"), s.replace(" ", ""), s.replace(" ", "-")]: - if c != "" and not out.has(c): - out.append(c) - return out - -func _load_motlist(motion_list_path: String = "") -> void: - _motions.clear() - _motion_variants.clear() - _motlist_loaded = false - var ml := motion_list_path if motion_list_path != "" else _dir.path_join("motlist.txt") - if not FileAccess.file_exists(ml): - return - _motlist_loaded = true - var f := FileAccess.open(ml, FileAccess.READ) - if f == null: - _motlist_loaded = false - return - var last_percent := 0 - var last_damage_path := "" - var has_spawn := false - while f and not f.eof_reached(): - # CMemoryTextFileLoader + sscanf treat tabs and repeated spaces as - # whitespace; normalize both before splitting. - var parts := f.get_line().strip_edges().replace("\t", " ").split(" ", false) - if parts.size() >= 3: - var motion := String(parts[1]).to_upper() - var file := String(parts[2]) - if parts.size() >= 4: - last_percent = int(parts[3]) - var motion_index := _npc_motion_index(motion) - if motion_index < 0: - continue - var abs := _dir.path_join(file) - if not FileAccess.file_exists(abs): - continue - var entry := {"motion": motion, "path": abs, "weight": last_percent} - if not _motion_variants.has(motion_index): - _motion_variants[motion_index] = [] - (_motion_variants[motion_index] as Array).append(entry) - # Keep the first direct alias for compatibility/debug output. The - # actual loaded path is always selected from _motion_variants. - if not _motions.has(motion): - _motions[motion] = abs - if motion_index == 25: - has_spawn = true - elif motion_index == 5: - last_damage_path = abs - if not has_spawn and last_damage_path != "": - # Preserve the reference loader's exact fallback: reuse the final - # parsed nPercent value, not the damage row's own weight. - var spawn_entry := {"motion": "SPAWN", "path": last_damage_path, "weight": last_percent} - _motion_variants[25] = [spawn_entry] - if not _motions.has("SPAWN"): - _motions["SPAWN"] = last_damage_path - -func _npc_motion_index(raw_motion: String) -> int: - var motion := raw_motion.to_upper() - if NPC_MOTION_INDEX.has(motion): - return int(NPC_MOTION_INDEX[motion]) - # CRaceManager accepts at most two trailing characters as an alias - # (WAIT3 / WAIT20 -> WAIT), but rejects unrelated names such as DAMAGE. - for trim_count in [1, 2]: - if motion.length() <= trim_count: - break - var base := motion.substr(0, motion.length() - trim_count) - if NPC_MOTION_INDEX.has(base): - return int(NPC_MOTION_INDEX[base]) - return -1 - -func _pick_base_gr2() -> String: - return _pick_base_gr2_in(_dir) - -func _pick_base_gr2_in(d: String) -> String: - var da := DirAccess.open(d) - if da == null: - return "" - for fn in da.get_files(): - if not fn.ends_with(".gr2"): - continue - if fn.contains("_lod_"): - continue - # 动作文件通常是纯数字命名(00.gr2 / 20_1.gr2) - var stem := fn.get_basename() - if stem.is_valid_int() or (stem.contains("_") and stem.split("_")[0].is_valid_int()): - continue - return d.path_join(fn) - return "" - -# MSM BaseModelFileName 解析:如 campfire.msm 指向 zone/b/obj/general_obj_campfire.gr2 -func _resolve_msm_base_model(assets_root: String, msm_path: String) -> String: - if not FileAccess.file_exists(msm_path): - return "" - var f := FileAccess.open(msm_path, FileAccess.READ) - if f == null: - return "" - var content := f.get_as_text() - var regex := RegEx.create_from_string("(?i)BaseModelFileName\\s+\"([^\"]+)\"") - if regex == null: - return "" - var match := regex.search(content) - if match == null: - return "" - var raw_path := match.get_string(1).replace("\\", "/").strip_edges() - var lower := raw_path.to_lower() - var rel := "" - for prefix in ["d:/ymir work/", "c:/ymir work/", "ymir work/", "/ymir work/"]: - if lower.begins_with(prefix): - rel = raw_path.substr(prefix.length()) - break - if rel == "": - rel = raw_path - for candidate in [ - assets_root.path_join("ymir work").path_join(rel), - assets_root.path_join(rel), - assets_root.path_join("Zone/ymir work").path_join(rel), - assets_root.path_join("NPC/ymir work").path_join(rel), - assets_root.path_join("Monster/ymir work").path_join(rel), - ]: - if FileAccess.file_exists(candidate): - return candidate - var da := DirAccess.open(assets_root) - if da: - for sub in da.get_directories(): - var c := assets_root.path_join(sub).path_join("ymir work").path_join(rel) - if FileAccess.file_exists(c): - return c - c = assets_root.path_join(sub).path_join(rel) - if FileAccess.file_exists(c): - return c - return "" - -func _resolve_msm_motion_list(msm_path: String) -> String: - if not FileAccess.file_exists(msm_path): - return "" - var f := FileAccess.open(msm_path, FileAccess.READ) - if f == null: - return "" - var regex := RegEx.create_from_string("(?i)MotionListFileName\\s+\\\"([^\\\"]+)\\\"") - if regex == null: - return "" - var match := regex.search(f.get_as_text()) - if match == null: - return "" - var raw := match.get_string(1).replace("\\", "/").strip_edges() - var path := msm_path.get_base_dir().path_join(raw) - return path if FileAccess.file_exists(path) else "" - -func _motion_list_for_dir(d: String, mesh_stem: String) -> String: - # RaceData::LoadRaceData gets the motion list name from the selected MSM. - # The plain motlist.txt fallback covers older NPC assets whose MSM has no - # MotionListFileName field and is not used when an explicit field exists. - var msm_candidates := [mesh_stem + ".msm", d.get_file() + ".msm", "shape.msm"] - for msm_name: String in msm_candidates: - var msm_path := d.path_join(msm_name) - if not FileAccess.file_exists(msm_path): - continue - var explicit := _resolve_msm_motion_list(msm_path) - if explicit != "": - return explicit - var msm_text := FileAccess.get_file_as_string(msm_path) - if msm_text.findn("MotionListFileName") >= 0: - return "" - var fallback := d.path_join("motlist.txt") - return fallback if FileAccess.file_exists(fallback) else "" - -func _find_model_spec(assets_root: String, proto: Node, race: int) -> Dictionary: - var codes: Array[String] = [] - var primary: String = _npclist.get(race, "") - if primary != "": - codes.append(primary) - for c: String in _folder_candidates(proto, race): - if not codes.has(c): - codes.append(c) - - for code in codes: - var spec := _resolve_code_spec(assets_root, code) - if not spec.is_empty(): - return spec - return {} - -func _resolve_code_spec(assets_root: String, code: String) -> Dictionary: - if code.begins_with("#"): - # 40250 LOAD_LOCAL_RESOURCE:直接相对路径(如 #season1/npc/chagirap/) - var rel := code.trim_prefix("#").strip_edges().trim_prefix("/").trim_suffix("/") - var stem := rel.get_file() - var candidate_dirs: Array[String] = [ - assets_root.path_join(rel), - assets_root.path_join(rel.replace("chenjo", "cheonjo")), - ] - var sub := rel.split("/")[0] - candidate_dirs.append(assets_root.path_join(sub).path_join(rel)) - candidate_dirs.append(assets_root.path_join(sub).path_join(rel.replace("chenjo", "cheonjo"))) - var da := DirAccess.open(assets_root) - if da: - for d in da.get_directories(): - candidate_dirs.append(assets_root.path_join(d).path_join("ymir work").path_join(rel.get_slice("/", 1)).path_join(stem)) - candidate_dirs.append(assets_root.path_join(d).path_join(rel)) - for d in candidate_dirs: - var spec := _check_dir_for_model(assets_root, d, stem) - if not spec.is_empty(): - return spec - return {} - - var mesh_stem := code - var folders: Array[String] = [] - # npclist vnum=0 别名(如 red_wild_boar -> wild_boar) - if _race_src_name.has(code): - folders.append(String(_race_src_name[code])) - folders.append(code) - var segs := code.split("_") - for cut in range(segs.size(), 0, -1): - var sub_folder := "_".join(segs.slice(0, cut)) - if not folders.has(sub_folder): - folders.append(sub_folder) - - for folder in folders: - var candidate_dirs: Array[String] = [ - assets_root.path_join("ymir work/monster/%s" % folder), - assets_root.path_join("ymir work/monster2/%s" % folder), - assets_root.path_join("ymir work/npc/%s" % folder), - assets_root.path_join("ymir work/npc2/%s" % folder), - assets_root.path_join("ymir work/guild/%s" % folder), - assets_root.path_join("Monster/ymir work/monster/%s" % folder), - assets_root.path_join("Monster/ymir work/monster2/%s" % folder), - assets_root.path_join("monster2/ymir work/monster2/%s" % folder), - assets_root.path_join("NPC/ymir work/npc/%s" % folder), - assets_root.path_join("NPC/ymir work/npc2/%s" % folder), - assets_root.path_join("npc2/ymir work/npc2/%s" % folder), - assets_root.path_join("guild/ymir work/guild/%s" % folder), - ] - var da := DirAccess.open(assets_root) - if da: - for sub_dir in da.get_directories(): - for rel in [ - "ymir work/monster/%s" % folder, - "ymir work/monster2/%s" % folder, - "ymir work/npc/%s" % folder, - "ymir work/npc2/%s" % folder, - "ymir work/guild/%s" % folder, - ]: - candidate_dirs.append(assets_root.path_join(sub_dir).path_join(rel)) - for d in candidate_dirs: - var spec := _check_dir_for_model(assets_root, d, mesh_stem) - if not spec.is_empty(): - return spec - return {} - -func _check_dir_for_model(assets_root: String, d: String, mesh_stem: String) -> Dictionary: - if not DirAccess.dir_exists_absolute(d): - return {} - # 1. 对应代号的网格 - var gr2 := d.path_join(mesh_stem + ".gr2") - if FileAccess.file_exists(gr2): - return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2, - "motion_list": _motion_list_for_dir(d, mesh_stem)} - # 2. 目录同名主网格 - gr2 = d.path_join(d.get_file() + ".gr2") - if FileAccess.file_exists(gr2): - return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2, - "motion_list": _motion_list_for_dir(d, mesh_stem)} - # 3. 目录里第一个非动作 .gr2 - gr2 = _pick_base_gr2_in(d) - if gr2 != "": - return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2, - "motion_list": _motion_list_for_dir(d, mesh_stem)} - # 4. 检查 .msm 的 BaseModelFileName(如 campfire.msm, flag_red.msm) - for msm_name in [mesh_stem + ".msm", d.get_file() + ".msm", "shape.msm"]: - var msm_p := d.path_join(msm_name) - if FileAccess.file_exists(msm_p): - var base_gr2 := _resolve_msm_base_model(assets_root, msm_p) - if base_gr2 != "": - return {"dir": d, "mesh_stem": mesh_stem, "gr2": base_gr2, - "motion_list": _resolve_msm_motion_list(msm_p)} - return {} - -func _find_dir(assets_root: String, folder: String) -> String: - var spec := _resolve_code_spec(assets_root, folder) - return String(spec.get("dir", "")) diff --git a/project/ui/mob_view.gd.uid b/project/ui/mob_view.gd.uid deleted file mode 100644 index 9090f447..00000000 --- a/project/ui/mob_view.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d06ypjevle2jf diff --git a/project/ui/mobile/mobile_hud.gd b/project/ui/mobile/mobile_hud.gd deleted file mode 100644 index 3b9a8ae2..00000000 --- a/project/ui/mobile/mobile_hud.gd +++ /dev/null @@ -1,342 +0,0 @@ -# MobileHud —— landscape in-game HUD. -# -# This is deliberately a presentation-only node. NetPlay updates it through -# the same setter contract as hud.gd, while MobileUiRoot wires its buttons to -# the existing feature controllers. -extends Control - -const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd") - -signal avatar_pressed -signal inventory_pressed -signal minimap_pressed -signal menu_pressed -signal quest_pressed -signal party_member_pressed(vid: int) -signal context_action_pressed - -const AVATAR_SIZE := 78.0 -const SAFE_MARGIN := 16.0 - -var client: Node -var _player_name: Label -var _avatar: TouchButton -var _hp_bar: ProgressBar -var _mp_bar: ProgressBar -var _hp_text: Label -var _mp_text: Label -var _level: Label -var _party_host: VBoxContainer -var _target_panel: Panel -var _target_name: Label -var _target_bar: ProgressBar -var _quest: TouchButton -var _context: TouchButton -var _mini_host: Control -var _mini_placeholder: Label -var _channel: Label -var _affects: HBoxContainer -var _client_bound := false - -func setup() -> void: - set_anchors_preset(Control.PRESET_FULL_RECT) - mouse_filter = Control.MOUSE_FILTER_IGNORE - _build_player_card() - _build_top_right() - _build_target() - _build_quest() - _build_context_action() - -func bind_client(m2client: Node) -> void: - if _client_bound and client == m2client: - _refresh_player() - _refresh_party() - return - client = m2client - _client_bound = client != null - if client and client.has_signal("party_changed"): - client.party_changed.connect(_refresh_party) - if client and client.has_signal("vitals_changed"): - client.vitals_changed.connect(func(_vid): _refresh_party()) - _refresh_player() - _refresh_party() - -func set_player_name(value: String) -> void: - if _player_name: - _player_name.text = value if value != "" else "冒险者" - -func get_minimap_host() -> Control: - return _mini_host - -func get_quest_button() -> Control: - return _quest - -func get_context_button() -> Control: - return _context - -func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void: - if _hp_bar: - _hp_bar.max_value = maxi(1, max_hp) - _hp_bar.value = clampi(hp, 0, maxi(1, max_hp)) - if _mp_bar: - _mp_bar.max_value = maxi(1, max_sp) - _mp_bar.value = clampi(sp, 0, maxi(1, max_sp)) - if _hp_text: - _hp_text.text = "%d/%d" % [hp, max_hp] - if _mp_text: - _mp_text.text = "%d/%d" % [sp, max_sp] - -func set_exp(xp: int, next_xp: int) -> void: - # The mobile card keeps the main HUD quiet; experience is shown in the - # level caption so it remains available without adding a bottom bar. - if _level and next_xp > 0: - _level.tooltip_text = "经验 %d / %d" % [xp, next_xp] - -func set_level(value: int) -> void: - if _level: - _level.text = "Lv %d" % value - -func set_energy(value: int, max_value: int = 100) -> void: - if _avatar: - _avatar.tooltip_text = "职业资源 %d/%d" % [value, max_value] - -func set_stamina(value: int, max_value: int) -> void: - if _avatar: - _avatar.tooltip_text = "体力 %d/%d" % [value, max_value] - -func set_affects(values: Array) -> void: - if _affects == null: - return - for child in _affects.get_children(): - child.queue_free() - for value in values: - var chip := Label.new() - chip.text = "◆" - chip.add_theme_font_size_override("font_size", 11) - chip.add_theme_color_override("font_color", Color.from_hsv( - fmod(float(int(value.get("type", 0))) * 0.13, 1.0), 0.55, 0.95)) - chip.tooltip_text = "状态 #%d" % int(value.get("type", 0)) - _affects.add_child(chip) - -func set_target(name: String, hp_pct: int) -> void: - if _target_panel == null: - _build_target() - _target_panel.visible = true - _target_name.text = name if name != "" else "目标" - _target_bar.value = clampi(hp_pct, 0, 100) - -func clear_target() -> void: - if _target_panel: - _target_panel.visible = false - -func set_channel(value: int) -> void: - if _channel: - _channel.text = "CH %d" % value - -func set_dungeon_destination(active: bool, _world_pos: Vector3) -> void: - if _quest and active: - _quest.set_caption("副本目标") - -func set_context_action(text: String, visible: bool) -> void: - if _context == null: - return - _context.set_caption(text) - _context.visible = visible - -func _build_player_card() -> void: - var card := Control.new() - card.position = Vector2(SAFE_MARGIN, SAFE_MARGIN) - card.size = Vector2(110, 122) - add_child(card) - _player_name = Label.new() - _player_name.text = "冒险者" - _player_name.position = Vector2(0, 0) - _player_name.size = Vector2(AVATAR_SIZE + 10, 20) - _player_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _player_name.add_theme_font_size_override("font_size", 13) - _player_name.add_theme_color_override("font_color", Color(0.98, 0.88, 0.61)) - _player_name.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.04, 0.95)) - _player_name.add_theme_constant_override("outline_size", 4) - card.add_child(_player_name) - - _avatar = TouchButton.new() - _avatar.setup("角\n色", Color(0.92, 0.72, 0.32, 0.95)) - _avatar.position = Vector2(5, 20) - _avatar.size = Vector2(AVATAR_SIZE, AVATAR_SIZE) - _avatar.pressed.connect(func(): avatar_pressed.emit()) - card.add_child(_avatar) - - _hp_bar = _bar(Color(0.86, 0.24, 0.22), Vector2(AVATAR_SIZE, 9)) - _hp_bar.position = Vector2(5, 100) - card.add_child(_hp_bar) - _hp_text = _bar_text(_hp_bar) - - _mp_bar = _bar(Color(0.24, 0.48, 0.93), Vector2(AVATAR_SIZE, 7)) - _mp_bar.position = Vector2(5, 111) - card.add_child(_mp_bar) - _mp_text = _bar_text(_mp_bar) - - _level = Label.new() - _level.text = "Lv 1" - _level.position = Vector2(86, 27) - _level.add_theme_font_size_override("font_size", 11) - _level.add_theme_color_override("font_color", Color(0.78, 0.86, 0.98)) - card.add_child(_level) - - _party_host = VBoxContainer.new() - _party_host.position = Vector2(0, 126) - _party_host.size = Vector2(196, 180) - _party_host.add_theme_constant_override("separation", 4) - add_child(_party_host) - - _affects = HBoxContainer.new() - _affects.position = Vector2(5, 121) - _affects.add_theme_constant_override("separation", 3) - card.add_child(_affects) - -func _build_top_right() -> void: - var actions := Control.new() - actions.set_anchors_preset(Control.PRESET_TOP_RIGHT) - actions.position = Vector2(-250, SAFE_MARGIN) - actions.size = Vector2(234, 98) - add_child(actions) - - var bag := TouchButton.new() - bag.setup("背包", Color(0.95, 0.72, 0.34, 0.95)) - bag.position = Vector2(0, 18) - bag.size = Vector2(56, 56) - bag.pressed.connect(func(): inventory_pressed.emit()) - actions.add_child(bag) - - _mini_host = Control.new() - _mini_host.position = Vector2(62, 0) - _mini_host.size = Vector2(96, 96) - _mini_host.mouse_filter = Control.MOUSE_FILTER_IGNORE - actions.add_child(_mini_host) - _mini_placeholder = Label.new() - _mini_placeholder.text = "地图" - _mini_placeholder.position = Vector2(28, 38) - _mini_placeholder.add_theme_font_size_override("font_size", 12) - _mini_placeholder.add_theme_color_override("font_color", Color(0.8, 0.9, 1.0, 0.85)) - _mini_placeholder.mouse_filter = Control.MOUSE_FILTER_IGNORE - _mini_host.add_child(_mini_placeholder) - - var map_button := TouchButton.new() - map_button.setup("", Color(0.45, 0.7, 0.95, 0.95)) - map_button.position = Vector2(62, 0) - map_button.size = Vector2(96, 96) - map_button.set_idle_modulate(Color(1, 1, 1, 0.03)) - map_button.pressed.connect(func(): minimap_pressed.emit()) - actions.add_child(map_button) - - var menu := TouchButton.new() - menu.setup("菜单", Color(0.58, 0.74, 0.96, 0.95)) - menu.position = Vector2(164, 18) - menu.size = Vector2(56, 56) - menu.pressed.connect(func(): menu_pressed.emit()) - actions.add_child(menu) - - _channel = Label.new() - _channel.text = "CH -" - _channel.position = Vector2(75, 98) - _channel.add_theme_font_size_override("font_size", 10) - _channel.add_theme_color_override("font_color", Color(0.64, 0.75, 0.9)) - actions.add_child(_channel) - -func _build_target() -> void: - _target_panel = Panel.new() - _target_panel.set_anchors_preset(Control.PRESET_CENTER_TOP) - _target_panel.position = Vector2(-112, 14) - _target_panel.size = Vector2(224, 48) - _target_panel.visible = false - _target_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE - _target_panel.add_theme_stylebox_override("panel", _panel_style(Color(0.12, 0.05, 0.06, 0.84), Color(0.82, 0.3, 0.3))) - add_child(_target_panel) - _target_name = Label.new() - _target_name.position = Vector2(10, 4) - _target_name.size = Vector2(204, 18) - _target_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _target_name.add_theme_font_size_override("font_size", 12) - _target_panel.add_child(_target_name) - _target_bar = _bar(Color(0.88, 0.24, 0.23), Vector2(204, 9)) - _target_bar.position = Vector2(10, 28) - _target_bar.max_value = 100 - _target_bar.value = 100 - _target_panel.add_child(_target_bar) - -func _build_quest() -> void: - _quest = TouchButton.new() - _quest.setup("任务 · 点击查看", Color(1.0, 0.72, 0.3, 0.9)) - _quest.set_anchors_preset(Control.PRESET_TOP_RIGHT) - _quest.position = Vector2(-244, 126) - _quest.size = Vector2(224, 45) - _quest.pressed.connect(func(): quest_pressed.emit()) - add_child(_quest) - -func _build_context_action() -> void: - _context = TouchButton.new() - _context.setup("交互", Color(0.92, 0.68, 0.31, 0.95)) - _context.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) - _context.position = Vector2(110, -116) - _context.size = Vector2(74, 48) - _context.visible = false - _context.pressed.connect(func(): context_action_pressed.emit()) - add_child(_context) - -func _refresh_player() -> void: - if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): - return - var entity: Dictionary = client.get_entity(client.get_main_vid()) - set_player_name(String(entity.get("name", "冒险者"))) - set_level(int(entity.get("level", 1))) - -func _refresh_party() -> void: - if _party_host == null: - return - for child in _party_host.get_children(): - child.queue_free() - if client == null or not client.has_method("get_party"): - return - var members: Array = client.get_party() - for member in members: - var vid := int(member.get("vid", 0)) - var row := TouchButton.new() - row.setup(" %s %d%%" % [String(member.get("name", "队员")), int(member.get("hp_pct", 0))], - Color(0.32, 0.52, 0.76, 0.8)) - row.set_rect_style() - row.custom_minimum_size = Vector2(184, 29) - row.size = Vector2(184, 29) - row.pressed.connect(func(): party_member_pressed.emit(vid)) - _party_host.add_child(row) - -func _bar(color: Color, bar_size: Vector2) -> ProgressBar: - var bar := ProgressBar.new() - bar.custom_minimum_size = bar_size - bar.size = bar_size - bar.min_value = 0 - bar.max_value = 100 - bar.value = 100 - bar.show_percentage = false - bar.mouse_filter = Control.MOUSE_FILTER_IGNORE - bar.add_theme_stylebox_override("background", _panel_style(Color(0.01, 0.02, 0.04, 0.78), Color(0.1, 0.14, 0.2, 0.6), 2)) - bar.add_theme_stylebox_override("fill", _panel_style(color, color, 2)) - return bar - -func _bar_text(bar: ProgressBar) -> Label: - var label := Label.new() - label.set_anchors_preset(Control.PRESET_FULL_RECT) - label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - label.add_theme_font_size_override("font_size", 8) - label.add_theme_color_override("font_color", Color(0.96, 0.98, 1.0)) - label.mouse_filter = Control.MOUSE_FILTER_IGNORE - bar.add_child(label) - return label - -func _panel_style(bg: Color, border: Color, width := 1) -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = bg - style.border_color = border - style.set_border_width_all(width) - style.set_corner_radius_all(6) - return style diff --git a/project/ui/mobile/mobile_hud.gd.uid b/project/ui/mobile/mobile_hud.gd.uid deleted file mode 100644 index eff42e5b..00000000 --- a/project/ui/mobile/mobile_hud.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bh0jqy5qycvad diff --git a/project/ui/mobile/mobile_input_overlay.gd b/project/ui/mobile/mobile_input_overlay.gd deleted file mode 100644 index 1bb56bf5..00000000 --- a/project/ui/mobile/mobile_input_overlay.gd +++ /dev/null @@ -1,222 +0,0 @@ -# MobileInputOverlay —— touch controls layered over the world. -# -# Child controls consume their own touch events, so PlayerController and -# GameCamera continue receiving unhandled world touches for tap-to-move, -# camera orbit and pinch zoom. -extends Control - -const Joystick := preload("res://ui/mobile/virtual_joystick.gd") -const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd") - -var player_controller: Node -var net_play: Node -var quickbar: Node -var ground_items: Node -var _joystick: Joystick -var _attack: TouchButton -var _potion: TouchButton -var _skills: Array[TouchButton] = [] -var _ui_touch_indices := {} -var _aim_slot := -1 -var _aim_direction := Vector2.ZERO -var _skip_skill_release := {} -var _aim_hint: Label - -func setup() -> void: - set_anchors_preset(Control.PRESET_FULL_RECT) - mouse_filter = Control.MOUSE_FILTER_IGNORE - _build_joystick() - _build_combat_pad() - _aim_hint = Label.new() - _aim_hint.name = "SkillAimHint" - _aim_hint.set_anchors_preset(Control.PRESET_CENTER_TOP) - _aim_hint.position = Vector2(-170, 70) - _aim_hint.size = Vector2(340, 30) - _aim_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _aim_hint.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _aim_hint.add_theme_font_size_override("font_size", 12) - _aim_hint.add_theme_color_override("font_color", Color(1.0, 0.86, 0.52)) - _aim_hint.add_theme_color_override("font_outline_color", Color(0.02, 0.03, 0.05, 0.95)) - _aim_hint.add_theme_constant_override("outline_size", 4) - _aim_hint.mouse_filter = Control.MOUSE_FILTER_IGNORE - _aim_hint.visible = false - add_child(_aim_hint) - set_process_input(true) - set_process_unhandled_input(true) - -func bind_controls(pc: Node, np: Node, qb: Node, ground: Node = null) -> void: - player_controller = pc - net_play = np - quickbar = qb - ground_items = ground - if _joystick: - if not _joystick.axis_changed.is_connected(_on_axis): - _joystick.axis_changed.connect(_on_axis) - if _attack: - if not _attack.press_state.is_connected(_on_attack_state): - _attack.press_state.connect(_on_attack_state) - -func _build_joystick() -> void: - _joystick = Joystick.new() - _joystick.setup(172.0) - _joystick.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) - _joystick.position = Vector2(20, -192) - _joystick.size = Vector2(172, 172) - add_child(_joystick) - -func _build_combat_pad() -> void: - _attack = TouchButton.new() - _attack.setup("普攻", Color(1.0, 0.42, 0.25, 0.98)) - _attack.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - _attack.position = Vector2(-126, -168) - _attack.size = Vector2(112, 112) - add_child(_attack) - - _potion = TouchButton.new() - _potion.setup("药", Color(0.38, 0.88, 0.58, 0.96)) - _potion.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - _potion.position = Vector2(-388, -72) - _potion.size = Vector2(52, 52) - _potion.pressed.connect(func(): - # The first page's slot 5 is the conventional consumable slot in the - # existing quickbar. Quickbar still validates item availability/server state. - if quickbar and quickbar.has_method("activate"): - quickbar.activate(4)) - add_child(_potion) - - for slot in 4: - var skill := TouchButton.new() - skill.setup("技%d" % (slot + 1), Color(0.34, 0.65, 0.98, 0.96)) - skill.set_gesture_enabled(true) - skill.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - skill.position = Vector2(-330 + slot * 58, -72) - skill.size = Vector2(52, 52) - var captured := slot - skill.long_pressed.connect(func(): _begin_skill_aim(captured, skill)) - skill.drag_changed.connect(func(_delta: Vector2): _update_skill_aim(captured, skill)) - skill.drag_released.connect(func(_delta: Vector2): _release_skill_aim(captured, skill)) - skill.released.connect(func(): _finish_short_skill_press(captured, skill)) - add_child(skill) - _skills.append(skill) - -func _on_axis(axis: Vector2) -> void: - if player_controller and player_controller.has_method("set_mobile_axis"): - player_controller.set_mobile_axis(axis) - -func _on_attack_state(down: bool) -> void: - if net_play and net_play.has_method("set_attack_key"): - net_play.set_attack_key(down) - -func _begin_skill_aim(slot: int, button: TouchButton) -> void: - if _aim_slot >= 0 and _aim_slot != slot: - return - _aim_slot = slot - _aim_direction = Vector2.ZERO - button.set_aiming(true) - if _aim_hint: - _aim_hint.text = "拖动调整方向,松手释放" - _aim_hint.visible = true - -func _update_skill_aim(slot: int, button: TouchButton) -> void: - if _aim_slot != slot: - return - var total := button.drag_total() - if total.length() >= TouchButton.DRAG_THRESHOLD: - # Screen Y grows downwards; keep the conventional joystick direction - # (up = forward) for Quickbar.activate_aimed(). - _aim_direction = Vector2(total.x, total.y).normalized() - if _aim_hint: - _aim_hint.text = "方向 %d° · 松手释放" % int(round(rad_to_deg(atan2(-_aim_direction.x, -_aim_direction.y)))) - -func _release_skill_aim(slot: int, button: TouchButton) -> void: - if _aim_slot != slot: - return - _skip_skill_release[slot] = true - if quickbar: - if quickbar.has_method("activate_aimed"): - quickbar.activate_aimed(slot, _aim_direction) - elif quickbar.has_method("activate"): - quickbar.activate(slot) - _end_skill_aim(button) - -func _finish_short_skill_press(slot: int, button: TouchButton) -> void: - if bool(_skip_skill_release.get(slot, false)): - _skip_skill_release.erase(slot) - return - if _aim_slot == slot: - _end_skill_aim(button) - return - if quickbar and quickbar.has_method("activate"): - quickbar.activate(slot) - -func _end_skill_aim(button: TouchButton = null) -> void: - if button: - button.set_aiming(false) - else: - for skill in _skills: - if skill: - skill.set_aiming(false) - _aim_slot = -1 - _aim_direction = Vector2.ZERO - if _aim_hint: - _aim_hint.visible = false - -func cancel_all() -> void: - if _joystick: - _joystick.cancel_press() - if _attack: - _attack.cancel_press() - if _potion: - _potion.cancel_press() - for skill in _skills: - if skill: - skill.cancel_press() - _end_skill_aim() - _skip_skill_release.clear() - for index in _ui_touch_indices.keys(): - if player_controller and player_controller.has_method("set_mobile_ui_touch"): - player_controller.set_mobile_ui_touch(int(index), false) - _ui_touch_indices.clear() - if player_controller: - if player_controller.has_method("clear_mobile_input"): - player_controller.clear_mobile_input() - elif player_controller.has_method("set_mobile_axis"): - player_controller.set_mobile_axis(Vector2.ZERO) - if net_play and net_play.has_method("set_attack_key"): - net_play.set_attack_key(false) - -func _notification(what: int) -> void: - if what == NOTIFICATION_APPLICATION_FOCUS_OUT \ - or what == NOTIFICATION_APPLICATION_PAUSED \ - or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT: - cancel_all() - -func _input(event: InputEvent) -> void: - if not player_controller or not player_controller.has_method("set_mobile_ui_touch"): - return - if event is InputEventScreenTouch: - var touch := event as InputEventScreenTouch - if touch.pressed: - var owns := _control_at(touch.position) != null - if owns: - _ui_touch_indices[touch.index] = true - player_controller.set_mobile_ui_touch(touch.index, true) - elif _ui_touch_indices.has(touch.index): - _ui_touch_indices.erase(touch.index) - player_controller.set_mobile_ui_touch(touch.index, false) - -func _control_at(screen_position: Vector2) -> Control: - var controls: Array[Control] = [] - if _joystick: - controls.append(_joystick) - if _attack: - controls.append(_attack) - if _potion: - controls.append(_potion) - for skill in _skills: - if skill: - controls.append(skill) - for control in controls: - if control.visible and control.get_global_rect().has_point(screen_position): - return control - return null diff --git a/project/ui/mobile/mobile_input_overlay.gd.uid b/project/ui/mobile/mobile_input_overlay.gd.uid deleted file mode 100644 index 8eebca62..00000000 --- a/project/ui/mobile/mobile_input_overlay.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bqyoh0sj72ro1 diff --git a/project/ui/mobile/mobile_menu_drawer.gd b/project/ui/mobile/mobile_menu_drawer.gd deleted file mode 100644 index 9bd78225..00000000 --- a/project/ui/mobile/mobile_menu_drawer.gd +++ /dev/null @@ -1,154 +0,0 @@ -# MobileMenuDrawer —— compact two-level menu for the top-right menu button. -# -# The drawer only routes actions. Feature nodes remain the owners of data, -# server state and permission checks. -extends Control - -const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd") - -signal item_selected(category: String, item: String) -signal opened -signal closed - -const CATEGORIES := ["社交", "成长", "活动", "商业", "系统"] -const ITEMS := { - "社交": ["聊天", "好友", "公会", "情侣", "观战"], - "成长": ["精炼", "龙魂", "Cube 制作"], - "活动": ["副本", "钓鱼"], - "商业": ["商店", "商城", "交易", "私人商店", "仓库", "兑换"], - "系统": ["系统设置", "游戏设置", "帮助", "选择角色", "登出", "退出游戏"], -} - -var _backdrop: ColorRect -var _panel: Panel -var _category_row: HBoxContainer -var _item_scroll: ScrollContainer -var _items: VBoxContainer -var _title: Label -var _hint: Label -var _active_category := "社交" - -func setup() -> void: - set_anchors_preset(Control.PRESET_FULL_RECT) - mouse_filter = Control.MOUSE_FILTER_IGNORE - _build() - visible = false - -func is_open() -> bool: - return visible - -func toggle() -> void: - if visible: - close() - else: - open() - -func open() -> void: - if visible: - return - visible = true - _refresh_items() - opened.emit() - -func close() -> void: - if not visible: - return - visible = false - closed.emit() - -func _build() -> void: - _backdrop = ColorRect.new() - _backdrop.color = Color(0.01, 0.02, 0.035, 0.38) - _backdrop.set_anchors_preset(Control.PRESET_FULL_RECT) - _backdrop.mouse_filter = Control.MOUSE_FILTER_STOP - _backdrop.gui_input.connect(func(event: InputEvent): - if event is InputEventScreenTouch and not event.pressed: - close() - elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed: - close()) - add_child(_backdrop) - - _panel = Panel.new() - _panel.set_anchors_preset(Control.PRESET_TOP_RIGHT) - _panel.position = Vector2(-366, 72) - _panel.size = Vector2(350, 300) - _panel.mouse_filter = Control.MOUSE_FILTER_STOP - _panel.add_theme_stylebox_override("panel", _panel_style()) - add_child(_panel) - - _title = Label.new() - _title.position = Vector2(18, 13) - _title.add_theme_font_size_override("font_size", 18) - _title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.52)) - _panel.add_child(_title) - - var close_button := TouchButton.new() - close_button.setup("×", Color(0.6, 0.75, 0.96, 0.9)) - close_button.set_rect_style() - close_button.position = Vector2(310, 7) - close_button.size = Vector2(32, 32) - close_button.custom_minimum_size = Vector2(32, 32) - close_button.pressed.connect(close) - _panel.add_child(close_button) - - _category_row = HBoxContainer.new() - _category_row.position = Vector2(14, 48) - _category_row.size = Vector2(322, 38) - _category_row.add_theme_constant_override("separation", 4) - _panel.add_child(_category_row) - for category in CATEGORIES: - var button := TouchButton.new() - button.setup(category, Color(0.45, 0.7, 0.95, 0.9)) - button.set_rect_style() - button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - button.custom_minimum_size = Vector2(0, 36) - var selected: String = String(category) - button.pressed.connect(func(): - _active_category = selected - _refresh_items()) - _category_row.add_child(button) - - _item_scroll = ScrollContainer.new() - _item_scroll.position = Vector2(16, 96) - _item_scroll.size = Vector2(318, 160) - _item_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - _item_scroll.mouse_filter = Control.MOUSE_FILTER_STOP - _panel.add_child(_item_scroll) - _items = VBoxContainer.new() - _items.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _items.add_theme_constant_override("separation", 5) - _item_scroll.add_child(_items) - - _hint = Label.new() - _hint.position = Vector2(18, 264) - _hint.size = Vector2(314, 26) - _hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _hint.add_theme_font_size_override("font_size", 11) - _hint.add_theme_color_override("font_color", Color(0.66, 0.72, 0.8)) - _panel.add_child(_hint) - -func _refresh_items() -> void: - if _panel == null: - return - _title.text = _active_category - _hint.text = "功能入口仍受当前场景和服务器状态限制。" - for child in _items.get_children(): - child.queue_free() - for item in ITEMS.get(_active_category, []): - var button := TouchButton.new() - button.setup(" " + String(item), Color(0.42, 0.65, 0.9, 0.8)) - button.set_rect_style() - button.custom_minimum_size = Vector2(0, 34) - var selected: String = String(item) - button.pressed.connect(func(): item_selected.emit(_active_category, selected)) - _items.add_child(button) - -func _panel_style() -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = Color(0.045, 0.065, 0.1, 0.96) - style.border_color = Color(0.52, 0.68, 0.9, 0.82) - style.set_border_width_all(1) - style.set_corner_radius_all(12) - style.shadow_color = Color(0, 0, 0, 0.45) - style.shadow_size = 12 - return style diff --git a/project/ui/mobile/mobile_menu_drawer.gd.uid b/project/ui/mobile/mobile_menu_drawer.gd.uid deleted file mode 100644 index 8000b9a8..00000000 --- a/project/ui/mobile/mobile_menu_drawer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c2ut2qatd5mbu diff --git a/project/ui/mobile/mobile_touch_button.gd b/project/ui/mobile/mobile_touch_button.gd deleted file mode 100644 index d1103158..00000000 --- a/project/ui/mobile/mobile_touch_button.gd +++ /dev/null @@ -1,226 +0,0 @@ -# MobileTouchButton —— a mouse and screen-touch friendly action surface. -# -# BaseButton's mouse emulation is intentionally disabled in project.godot, so -# the mobile layer owns the small amount of press/release handling it needs. -# This keeps a touch index alive until release and gives gameplay a reliable -# cancellation path when the app loses focus. -extends Control - -signal pressed -signal released -signal press_state(down: bool) -signal long_pressed -signal drag_started -signal drag_changed(delta: Vector2) -signal drag_released(delta: Vector2) - -var label_text := "" -var accent := Color(0.36, 0.64, 0.95, 0.95) -var idle_modulate := Color.WHITE -var circular := true -var active := false -var _touch_index := -1 -var _caption: Label -var _press_position := Vector2.ZERO -var _last_position := Vector2.ZERO -var _drag_delta := Vector2.ZERO -var _long_press_token := 0 -var _long_pressed := false -var _dragging := false -var _aiming := false -const LONG_PRESS_SECONDS := 0.55 -const DRAG_THRESHOLD := 10.0 - -func setup(text: String, color: Color = Color(0.36, 0.64, 0.95, 0.95)) -> void: - label_text = text - accent = color - mouse_filter = Control.MOUSE_FILTER_STOP - custom_minimum_size = Vector2(56, 56) - _caption = Label.new() - _caption.text = label_text - _caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _caption.set_anchors_preset(Control.PRESET_FULL_RECT) - _caption.mouse_filter = Control.MOUSE_FILTER_IGNORE - _caption.add_theme_font_size_override("font_size", 13) - _caption.add_theme_color_override("font_color", Color(0.94, 0.97, 1.0)) - _caption.add_theme_color_override("font_outline_color", Color(0.02, 0.03, 0.05, 0.95)) - _caption.add_theme_constant_override("outline_size", 3) - add_child(_caption) - queue_redraw() - -func set_caption(text: String) -> void: - label_text = text - if _caption: - _caption.text = text - -func set_idle_modulate(value: Color) -> void: - idle_modulate = value - modulate = value if not active else Color(0.82, 0.9, 1.0, value.a) - -## Enables the long-press/drag gesture used by mobile skill aiming and -## inventory movement. The ordinary pressed/released signals remain intact -## for buttons that do not need a gesture. -func set_gesture_enabled(enabled: bool) -> void: - set_meta("gesture_enabled", enabled) - -func was_long_pressed() -> bool: - return _long_pressed - -func is_dragging() -> bool: - return _dragging - -func drag_total() -> Vector2: - return _drag_delta - -func drag_end_position() -> Vector2: - return _last_position - -func set_aiming(value: bool) -> void: - _aiming = value - queue_redraw() - -func set_rect_style() -> void: - circular = false - queue_redraw() - -func _ready() -> void: - set_process_unhandled_input(true) - queue_redraw() - -func _gui_input(event: InputEvent) -> void: - if event is InputEventScreenTouch: - if event.pressed: - if _touch_index < 0: - _touch_index = event.index - _begin_press(_event_position(event)) - else: - if event.index == _touch_index: - _touch_index = -1 - _end_press(_event_position(event)) - accept_event() - return - if event is InputEventScreenDrag and event.index == _touch_index: - _update_drag(_event_position(event)) - accept_event() - return - if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: - if event.pressed: - _touch_index = -2 - _begin_press(_event_position(event)) - else: - _touch_index = -1 - _end_press(_event_position(event)) - accept_event() - return - if event is InputEventMouseMotion and _touch_index == -2: - _update_drag(_event_position(event)) - accept_event() - -func _event_position(event: InputEvent) -> Vector2: - if event is InputEventMouse: - return (event as InputEventMouse).position - if event is InputEventScreenTouch: - return (event as InputEventScreenTouch).position - if event is InputEventScreenDrag: - return (event as InputEventScreenDrag).position - return Vector2.ZERO - -func _begin_press(position: Vector2) -> void: - if active: - return - active = true - _press_position = position - _last_position = position - _drag_delta = Vector2.ZERO - _long_pressed = false - _dragging = false - _aiming = false - _long_press_token += 1 - var token := _long_press_token - if bool(get_meta("gesture_enabled", false)) and get_tree() != null: - get_tree().create_timer(LONG_PRESS_SECONDS).timeout.connect(func(): - if token != _long_press_token or not active or _long_pressed: - return - _long_pressed = true - _dragging = true - long_pressed.emit() - queue_redraw()) - modulate = Color(0.82, 0.9, 1.0, idle_modulate.a) - pressed.emit() - press_state.emit(true) - queue_redraw() - -func _update_drag(position: Vector2) -> void: - if not active: - return - var delta := position - _last_position - _last_position = position - _drag_delta = position - _press_position - if not bool(get_meta("gesture_enabled", false)): - return - if not _long_pressed: - if _drag_delta.length() > DRAG_THRESHOLD: - _long_press_token += 1 - return - if not _dragging: - _dragging = true - drag_started.emit() - drag_changed.emit(delta) - queue_redraw() - -func _end_press(position: Vector2) -> void: - if not active: - return - _update_drag(position) - _long_press_token += 1 - if _dragging and _long_pressed: - drag_released.emit(_drag_delta) - active = false - modulate = idle_modulate - released.emit() - press_state.emit(false) - queue_redraw() - -func cancel_press() -> void: - _long_press_token += 1 - _touch_index = -1 - if active: - # A cancelled gesture must never look like a completed drag to its - # receiver. Clear the gesture flags before ending the visual press. - _dragging = false - _long_pressed = false - _aiming = false - _end_press(_last_position) - else: - _long_pressed = false - _dragging = false - _aiming = false - -func _unhandled_input(event: InputEvent) -> void: - # A focus loss / window pause can leave a touch-up event outside the - # control. MobileUiRoot also calls cancel_all() for the full overlay. - if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index: - _touch_index = -1 - _end_press(_last_position) - -func _draw() -> void: - if not circular: - var rect_style := StyleBoxFlat.new() - rect_style.bg_color = Color(0.06, 0.1, 0.16, 0.86) - rect_style.border_color = accent - rect_style.set_border_width_all(1) - rect_style.set_corner_radius_all(6) - draw_style_box(rect_style, Rect2(Vector2.ZERO, size)) - return - var center := size * 0.5 - var radius := minf(size.x, size.y) * 0.5 - 3.0 - if radius <= 0.0: - return - draw_circle(center, radius, Color(0.025, 0.04, 0.07, 0.68)) - draw_arc(center, radius, 0.0, TAU, 48, accent, 3.0 if active or _aiming else 2.0) - draw_arc(center, radius - 5.0, 0.0, TAU, 48, Color(accent, 0.22), 1.0) - if _aiming: - draw_circle(center, 5.0, Color(1.0, 0.88, 0.52, 0.9)) - draw_arc(center, radius - 12.0, -PI * 0.5, PI * 0.5, 24, - Color(1.0, 0.88, 0.52, 0.8), 2.0) diff --git a/project/ui/mobile/mobile_touch_button.gd.uid b/project/ui/mobile/mobile_touch_button.gd.uid deleted file mode 100644 index 72d9e8d1..00000000 --- a/project/ui/mobile/mobile_touch_button.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dpg433tb2c1qn diff --git a/project/ui/mobile/mobile_ui_root.gd b/project/ui/mobile/mobile_ui_root.gd deleted file mode 100644 index 5690b1a2..00000000 --- a/project/ui/mobile/mobile_ui_root.gd +++ /dev/null @@ -1,453 +0,0 @@ -# MobileUiRoot —— the mobile presentation root. -# -# It owns only mobile layout, routing and touch surfaces. Existing feature -# controllers are injected after GameScene has created them and remain the -# source of truth for server-backed state. -extends CanvasLayer - -const MobileHud := preload("res://ui/mobile/mobile_hud.gd") -const MobileInputOverlay := preload("res://ui/mobile/mobile_input_overlay.gd") -const MobileMenuDrawer := preload("res://ui/mobile/mobile_menu_drawer.gd") -const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd") -const MobileWindowHost := preload("res://ui/mobile/mobile_window_host.gd") -const DESIGN_SIZE := Vector2(844, 390) - -var world: Node -var player: Node3D -var client: Node -var hud_view: Control -var input_overlay: Control -var menu_drawer: Control -var _avatar_panel: Panel -var _toast: Label -var _bindings := {} -var _content_root: Control -var _orientation_guard: ColorRect -var _orientation_label: Label -var _last_viewport_size := Vector2.ZERO -var _last_safe_rect := Rect2(-1, -1, 0, 0) -var ui: Node - -func setup(metin_world: Node, local_player: Node3D, m2client: Node = null) -> void: - world = metin_world - player = local_player - client = m2client - layer = 20 - _build() - _layout_root() - set_process(true) - if client: - hud_view.bind_client(client) - -func bind_controls(bindings: Dictionary) -> void: - _bindings = bindings - client = bindings.get("client", client) - ui = bindings.get("ui", ui) - if ui and ui.has_method("set_mobile_mode"): - ui.set_mobile_mode(true) - if hud_view: - hud_view.bind_client(client) - if input_overlay: - input_overlay.bind_controls(bindings.get("player_controller"), - bindings.get("net_play"), bindings.get("quickbar"), bindings.get("ground_items")) - if bindings.get("party_ui") and bindings["party_ui"].has_method("set_mobile_mode"): - bindings["party_ui"].set_mobile_mode(true) - if bindings.get("quickbar") and bindings["quickbar"].has_method("set_mobile_mode"): - bindings["quickbar"].set_mobile_mode(true) - if bindings.get("skills") and bindings["skills"].has_method("set_mobile_quickbar"): - bindings["skills"].set_mobile_quickbar(bindings.get("quickbar")) - if bindings.get("inventory") and bindings["inventory"].has_method("set_mobile_mode"): - bindings["inventory"].set_mobile_mode(true) - if bindings.get("quest_log") and bindings["quest_log"].has_method("set_mobile_mode"): - bindings["quest_log"].set_mobile_mode(true) - if bindings.get("chat") and bindings["chat"].has_method("set_mobile_mode"): - bindings["chat"].set_mobile_mode(true) - for key in ["char_status_ui", "skills", "friend_ui", "guild_ui", "shop_ui", "exchange_ui", "safebox_ui", "mall_ui", "cube_ui", "private_shop_ui", "refine_ui", "love_ui", "system_option_ui", "game_option_ui", "system_menu_ui"]: - var feature: Node = bindings.get(key) - if feature and feature.has_method("set_mobile_mode"): - feature.set_mobile_mode(true) - _register_mobile_windows() - var minimap: Node = bindings.get("minimap") - if minimap and minimap.has_method("mount_mobile") and hud_view.has_method("get_minimap_host"): - minimap.mount_mobile(hud_view.get_minimap_host()) - _wire_signals() - _connect_lifecycle() - -# NetPlay/HUD compatibility surface ------------------------------------- - -func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void: - if hud_view: - hud_view.set_vitals(hp, max_hp, sp, max_sp) - -func set_exp(xp: int, next_xp: int) -> void: - if hud_view: - hud_view.set_exp(xp, next_xp) - -func set_level(value: int) -> void: - if hud_view: - hud_view.set_level(value) - -func set_energy(value: int, max_value: int = 100) -> void: - if hud_view: - hud_view.set_energy(value, max_value) - -func set_stamina(value: int, max_value: int) -> void: - if hud_view: - hud_view.set_stamina(value, max_value) - -func set_affects(values: Array) -> void: - if hud_view: - hud_view.set_affects(values) - -func set_target(name: String, hp_pct: int) -> void: - if hud_view: - hud_view.set_target(name, hp_pct) - -func clear_target() -> void: - if hud_view: - hud_view.clear_target() - -func set_channel(value: int) -> void: - if hud_view: - hud_view.set_channel(value) - -func set_dungeon_destination(active: bool, world_pos: Vector3) -> void: - if hud_view: - hud_view.set_dungeon_destination(active, world_pos) - -func cancel_touch_state() -> void: - if input_overlay and input_overlay.has_method("cancel_all"): - input_overlay.cancel_all() - -static func requires_landscape(viewport_size: Vector2) -> bool: - return viewport_size.y > viewport_size.x and viewport_size.x > 0.0 - -func _process(_delta: float) -> void: - _layout_root() - _refresh_context_action() - -func _layout_root() -> void: - if _content_root == null or get_viewport() == null: - return - var viewport_size := get_viewport().get_visible_rect().size - if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: - return - var safe := _safe_viewport_rect(viewport_size) - var portrait := requires_landscape(viewport_size) - var geometry_changed := not viewport_size.is_equal_approx(_last_viewport_size) \ - or not safe.is_equal_approx(_last_safe_rect) - if _orientation_guard: - _orientation_guard.visible = portrait - if _content_root: - _content_root.visible = not portrait - _last_viewport_size = viewport_size - _last_safe_rect = safe - if portrait: - # The project requests landscape on mobile. The guard is still needed - # for rotation races and desktop previews so controls never collapse into - # a portrait layout for one or two frames. - cancel_touch_state() - return - if not geometry_changed: - return - var fit := minf(safe.size.x / DESIGN_SIZE.x, safe.size.y / DESIGN_SIZE.y) - var configured := float(ProjectSettings.get_setting("mt/ui/mobile_scale", 1.0)) - fit = maxf(0.1, fit * maxf(0.5, configured)) - _content_root.size = DESIGN_SIZE - _content_root.scale = Vector2.ONE * fit - _content_root.position = safe.position + (safe.size - DESIGN_SIZE * fit) * 0.5 - -func _safe_viewport_rect(viewport_size: Vector2) -> Rect2: - return MobileWindowHost.safe_rect_for(viewport_size, DisplayServer.get_display_safe_area()) - -func _refresh_context_action() -> void: - var ground: Node = _bindings.get("ground_items") - if ground == null or not ground.has_method("has_nearby_item"): - return - var available := bool(ground.has_nearby_item()) - if hud_view and hud_view.has_method("set_context_action"): - hud_view.set_context_action("拾取", available) - -func _build() -> void: - _content_root = Control.new() - _content_root.set_anchors_preset(Control.PRESET_TOP_LEFT) - _content_root.size = DESIGN_SIZE - _content_root.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_content_root) - - hud_view = MobileHud.new() - hud_view.setup() - _content_root.add_child(hud_view) - - input_overlay = MobileInputOverlay.new() - input_overlay.setup() - _content_root.add_child(input_overlay) - - menu_drawer = MobileMenuDrawer.new() - menu_drawer.setup() - _content_root.add_child(menu_drawer) - - hud_view.avatar_pressed.connect(_on_avatar_pressed) - hud_view.inventory_pressed.connect(_on_inventory_pressed) - hud_view.minimap_pressed.connect(_on_minimap_pressed) - hud_view.menu_pressed.connect(_on_menu_pressed) - hud_view.quest_pressed.connect(_on_quest_pressed) - hud_view.party_member_pressed.connect(_on_party_member_pressed) - hud_view.context_action_pressed.connect(_on_context_action_pressed) - menu_drawer.item_selected.connect(_on_menu_item) - _build_avatar_panel(_content_root) - _build_orientation_guard() - -func _build_orientation_guard() -> void: - _orientation_guard = ColorRect.new() - _orientation_guard.name = "LandscapeRequired" - _orientation_guard.color = Color(0.015, 0.025, 0.045, 0.98) - _orientation_guard.set_anchors_preset(Control.PRESET_FULL_RECT) - _orientation_guard.mouse_filter = Control.MOUSE_FILTER_STOP - _orientation_guard.visible = false - _orientation_guard.z_index = 200 - add_child(_orientation_guard) - _orientation_label = Label.new() - _orientation_label.text = "请横屏使用" - _orientation_label.set_anchors_preset(Control.PRESET_CENTER) - _orientation_label.position = Vector2(-120, -20) - _orientation_label.size = Vector2(240, 40) - _orientation_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _orientation_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _orientation_label.add_theme_font_size_override("font_size", 20) - _orientation_label.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58)) - _orientation_label.mouse_filter = Control.MOUSE_FILTER_IGNORE - _orientation_guard.add_child(_orientation_label) - -func _wire_signals() -> void: - # Calling bind_controls twice is safe during a preview rebuild; signals are - # connected once because this method is guarded by metadata. - if get_meta("mobile_signals_wired", false): - return - set_meta("mobile_signals_wired", true) - var np: Node = _bindings.get("net_play") - if np and np.has_signal("cannot_act"): - np.cannot_act.connect(func(code: String): _show_message(code)) - var quickbar: Node = _bindings.get("quickbar") - if quickbar and quickbar.has_signal("skill_rejected"): - quickbar.skill_rejected.connect(func(_sid: int, code: String): _show_message(code)) - -func _connect_lifecycle() -> void: - var life: Node = _bindings.get("lifecycle") - if life == null or get_meta("lifecycle_wired", false): - return - set_meta("lifecycle_wired", true) - if life.has_signal("back_requested"): - life.back_requested.connect(_on_back_requested) - if life.has_signal("resumed"): - life.resumed.connect(func(): cancel_touch_state()) - -func _on_back_requested() -> void: - if ui and ui.has_method("close_top") and bool(ui.close_top()): - return - if menu_drawer and menu_drawer.has_method("is_open") and menu_drawer.is_open(): - menu_drawer.close() - return - if _avatar_panel and _avatar_panel.visible: - _avatar_panel.visible = false - return - cancel_touch_state() - -func _register_mobile_windows() -> void: - if ui == null or not ui.has_method("track_mobile_window"): - return - _register_window(_bindings.get("quest_log"), "任务日志") - _register_window(_bindings.get("friend_ui"), "好友") - _register_window(_bindings.get("guild_ui"), "公会") - _register_window(_bindings.get("chat"), "聊天") - _register_window(_bindings.get("shop_ui"), "商店") - _register_window(_bindings.get("exchange_ui"), "交易") - _register_window(_bindings.get("safebox_ui"), "仓库") - _register_window(_bindings.get("mall_ui"), "商城仓库") - _register_window(_bindings.get("cube_ui"), "制作") - _register_window(_bindings.get("private_shop_ui"), "私人商店") - _register_window(_bindings.get("refine_ui"), "精炼") - -func _register_window(target: Node, title: String) -> void: - if target == null: - return - var close_cb := Callable(target, "close") if target.has_method("close") else Callable() - if target.has_method("get_mobile_windows"): - for root in target.get_mobile_windows(): - if root is Control: - ui.track_mobile_window(root, title, close_cb) - elif target.has_method("get_mobile_window"): - var root: Control = target.get_mobile_window() - if root: - ui.track_mobile_window(root, title, close_cb) - -func _build_avatar_panel(parent: Control) -> void: - _avatar_panel = Panel.new() - _avatar_panel.position = Vector2(112, 18) - _avatar_panel.size = Vector2(170, 218) - _avatar_panel.visible = false - _avatar_panel.mouse_filter = Control.MOUSE_FILTER_STOP - _avatar_panel.add_theme_stylebox_override("panel", _panel_style()) - parent.add_child(_avatar_panel) - var title := Label.new() - title.text = "角色" - title.position = Vector2(14, 10) - title.add_theme_font_size_override("font_size", 16) - title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54)) - _avatar_panel.add_child(title) - var close := TouchButton.new() - close.setup("×", Color(0.6, 0.75, 0.96, 0.9)) - close.set_rect_style() - close.position = Vector2(132, 5) - close.size = Vector2(30, 28) - close.pressed.connect(func(): _avatar_panel.visible = false) - _avatar_panel.add_child(close) - for data in [["角色 / 装备", "status"], ["技能", "skills"], ["坐骑", "mount"], ["表情", "emoticon"]]: - var button := TouchButton.new() - button.setup(String(data[0]), Color(0.45, 0.7, 0.95, 0.9)) - button.set_rect_style() - button.position = Vector2(12, 42 + (data[1] as String).length() * 0) - button.size = Vector2(146, 34) - button.position.y = 42 + _avatar_panel.get_child_count() * 0 - var action := String(data[1]) - button.pressed.connect(func(): _open_avatar_feature(action)) - _avatar_panel.add_child(button) - # Reposition after the title/close children have been added. - for i in range(4): - var button := _avatar_panel.get_child(2 + i) as TouchButton - if button: - button.position = Vector2(12, 44 + i * 39) - -func _on_avatar_pressed() -> void: - _avatar_panel.visible = not _avatar_panel.visible - -func _open_avatar_feature(action: String) -> void: - _avatar_panel.visible = false - var cs: Node = _bindings.get("char_status_ui") - var skills: Node = _bindings.get("skills") - match action: - "status": - if cs and cs.has_method("open"): cs.open("STATUS") - "emoticon": - if cs and cs.has_method("open"): cs.open("EMOTICON") - "skills", "mount": - if skills: - if skills.has_method("set_job"): - skills.set_job(_job_name()) - if skills.has_method("open"): skills.open() - -func _on_inventory_pressed() -> void: - var inventory: Node = _bindings.get("inventory") - if inventory and inventory.has_method("toggle"): - inventory.toggle() - -func _on_minimap_pressed() -> void: - var atlas: Node = _bindings.get("atlas_ui") - if atlas and atlas.has_method("open"): - atlas.open() - -func _on_menu_pressed() -> void: - if menu_drawer: - menu_drawer.toggle() - -func _on_quest_pressed() -> void: - var quests: Node = _bindings.get("quest_log") - if quests and quests.has_method("toggle"): - quests.toggle() - -func _on_party_member_pressed(vid: int) -> void: - if client and vid > 0 and client.has_method("set_target"): - client.set_target(vid) - -func _on_context_action_pressed() -> void: - var ground: Node = _bindings.get("ground_items") - if ground and ground.has_method("try_pickup") and int(ground.try_pickup()) != 0: - return - _show_message("附近没有可拾取的物品") - -func _on_menu_item(category: String, item: String) -> void: - if menu_drawer: - menu_drawer.close() - var target: Node - match category: - "社交": - match item: - "聊天": target = _bindings.get("chat"); _call_toggle(target, "toggle_log") - "好友": target = _bindings.get("friend_ui"); _call_toggle(target) - "公会": target = _bindings.get("guild_ui"); _call_toggle(target) - "情侣": target = _bindings.get("love_ui"); _call_toggle(target) - _: _show_message("观战功能将在进入观战状态后可用") - "成长": - if item == "龙魂": - _show_message("龙魂功能当前未开放") - else: - _show_message("请靠近对应 NPC 后使用:" + item) - "活动": - if item == "钓鱼": - var np: Node = _bindings.get("net_play") - if np and np.has_method("activate_fishing"): - _show_message(String(np.activate_fishing())) - else: - _show_message("请从任务或活动区域进入副本") - "商业": - if item == "商城": - if client and client.has_method("say"): client.say(0, "/in_game_mall") - else: - _show_message("请靠近对应 NPC 或摊位后使用:" + item) - "系统": - _system_action(item) - -func _system_action(item: String) -> void: - var system_menu: Node = _bindings.get("system_menu_ui") - var sys_opt: Node = _bindings.get("system_option_ui") - var game_opt: Node = _bindings.get("game_option_ui") - match item: - "系统设置": _call_open(sys_opt) - "游戏设置": _call_open(game_opt) - "帮助": - if system_menu and system_menu.has_method("open_help"): system_menu.open_help() - "选择角色": - if client and client.has_method("say"): client.say(0, "/phase_select") - "登出": - if client and client.has_method("say"): client.say(0, "/logout") - "退出游戏": get_tree().quit() - -func _call_toggle(target: Node, method := "toggle") -> void: - if target and target.has_method(method): - target.call(method) - -func _call_open(target: Node) -> void: - if target and target.has_method("open"): - target.open() - -func _job_name() -> String: - if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): - return "WARRIOR" - var race := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3 - return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][race] - -func _show_message(message: String) -> void: - if _toast == null: - _toast = Label.new() - _toast.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) - _toast.position = Vector2(-170, -62) - _toast.size = Vector2(340, 34) - _toast.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _toast.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _toast.add_theme_font_size_override("font_size", 12) - _toast.add_theme_color_override("font_color", Color(1.0, 0.86, 0.58)) - _toast.mouse_filter = Control.MOUSE_FILTER_IGNORE - _content_root.add_child(_toast) - _toast.text = message - _toast.visible = true - var tween := create_tween() - tween.tween_interval(1.8) - tween.tween_callback(func(): if _toast: _toast.visible = false) - -func _panel_style() -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = Color(0.04, 0.065, 0.1, 0.96) - style.border_color = Color(0.55, 0.7, 0.92, 0.9) - style.set_border_width_all(1) - style.set_corner_radius_all(10) - return style diff --git a/project/ui/mobile/mobile_ui_root.gd.uid b/project/ui/mobile/mobile_ui_root.gd.uid deleted file mode 100644 index 27a79ad1..00000000 --- a/project/ui/mobile/mobile_ui_root.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d0etc5yr8cuuy diff --git a/project/ui/mobile/mobile_window_host.gd b/project/ui/mobile/mobile_window_host.gd deleted file mode 100644 index 7f933aab..00000000 --- a/project/ui/mobile/mobile_window_host.gd +++ /dev/null @@ -1,163 +0,0 @@ -# MobileWindowHost —— touch-safe presentation for existing feature windows. -# -# Feature controllers keep ownership of data, packets and permissions. This -# helper only gives their Control roots a landscape-safe frame, a predictable -# back/close affordance and a screen-touch bridge for legacy Button nodes. -extends RefCounted - -const NAV_HEIGHT := 40.0 -const EDGE_MARGIN := 12.0 -const MIN_SCALE := 0.42 - -static func safe_rect(viewport: Viewport) -> Rect2: - if viewport == null: - return Rect2(Vector2.ZERO, Vector2(844, 390)) - var viewport_size := viewport.get_visible_rect().size - return safe_rect_for(viewport_size, DisplayServer.get_display_safe_area()) - -## Resolve a platform-reported safe area against the current logical viewport. -## Keeping this pure makes the edge cases testable without an Android/iOS -## device and avoids trusting desktop backends that return physical-screen -## coordinates unrelated to the current viewport. -static func safe_rect_for(viewport_size: Vector2, reported: Rect2) -> Rect2: - if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: - return Rect2(Vector2.ZERO, viewport_size) - if reported.size.x <= 0.0 or reported.size.y <= 0.0: - return Rect2(Vector2.ZERO, viewport_size) - var result := reported - # Some desktop backends report a screen-space safe area larger than the - # current viewport. Treat that as unavailable rather than moving windows - # outside the viewport. - if result.size.x < 1.0 or result.size.y < 1.0: - return Rect2(Vector2.ZERO, viewport_size) - if result.position.x < 0.0 or result.position.y < 0.0 \ - or result.end.x > viewport_size.x + 1.0 \ - or result.end.y > viewport_size.y + 1.0: - return Rect2(Vector2.ZERO, viewport_size) - return result - -static func content_size(root: Control) -> Vector2: - if root == null: - return Vector2(320, 240) - var result := root.size - if result.x <= 1.0 or result.y <= 1.0: - result = root.get_combined_minimum_size() - if result.x <= 1.0 or result.y <= 1.0: - result = Vector2(320, 240) - return result - -static func install(root: Control, area: Rect2, title: String, - close_cb: Callable, back_cb: Callable = Callable()) -> Dictionary: - if root == null: - return {} - var base_size := content_size(root) - var available := area.size - Vector2(EDGE_MARGIN * 2.0, EDGE_MARGIN * 2.0) - var scale_value := minf(available.x / base_size.x, available.y / base_size.y) - scale_value = maxf(MIN_SCALE, minf(1.0, scale_value)) - root.set_anchors_preset(Control.PRESET_TOP_LEFT) - root.size = base_size - root.pivot_offset = Vector2.ZERO - root.scale = Vector2.ONE * scale_value - root.position = area.position + (area.size - base_size * scale_value) * 0.5 - root.set_meta("mobile_hosted", true) - root.set_meta("mobile_base_size", base_size) - root.set_meta("mobile_scale", scale_value) - - var nav := root.get_node_or_null("__mobile_nav") as Control - if nav == null: - nav = Panel.new() - nav.name = "__mobile_nav" - nav.mouse_filter = Control.MOUSE_FILTER_STOP - nav.z_index = 100 - var nav_style := StyleBoxFlat.new() - nav_style.bg_color = Color(0.025, 0.045, 0.08, 0.96) - nav_style.border_color = Color(0.36, 0.6, 0.92, 0.82) - nav_style.border_width_bottom = 1 - nav.add_theme_stylebox_override("panel", nav_style) - root.add_child(nav) - nav.position = Vector2.ZERO - nav.size = Vector2(base_size.x, NAV_HEIGHT) - nav.visible = true - - var title_label := nav.get_node_or_null("Title") as Label - if title_label == null: - title_label = Label.new() - title_label.name = "Title" - title_label.position = Vector2(88, 0) - title_label.size = Vector2(maxf(80.0, base_size.x - 176.0), NAV_HEIGHT) - title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - title_label.add_theme_font_size_override("font_size", 15) - title_label.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58)) - title_label.mouse_filter = Control.MOUSE_FILTER_IGNORE - nav.add_child(title_label) - title_label.text = title if title != "" else String(root.name) - - var back := nav.get_node_or_null("Back") as Button - if back == null: - back = Button.new() - back.name = "Back" - back.text = "返回" - back.position = Vector2(8, 4) - back.size = Vector2(70, 32) - back.add_theme_font_size_override("font_size", 12) - nav.add_child(back) - if not back.has_meta("mobile_host_wired"): - back.set_meta("mobile_host_wired", true) - back.pressed.connect(func(): - if back_cb.is_valid(): - back_cb.call() - elif close_cb.is_valid(): - close_cb.call()) - - var close := nav.get_node_or_null("Close") as Button - if close == null: - close = Button.new() - close.name = "Close" - close.text = "×" - close.add_theme_font_size_override("font_size", 18) - nav.add_child(close) - if not close.has_meta("mobile_host_wired"): - close.set_meta("mobile_host_wired", true) - close.pressed.connect(func(): if close_cb.is_valid(): close_cb.call()) - close.position = Vector2(maxf(78.0, base_size.x - 78.0), 4) - close.size = Vector2(70, 32) - - _wire_buttons(root) - return {"size": base_size, "scale": scale_value, "area": area} - -static func uninstall(root: Control) -> void: - if root == null: - return - var nav := root.get_node_or_null("__mobile_nav") as Control - if nav: - nav.visible = false - root.set_meta("mobile_hosted", false) - -static func wire_buttons(root: Node) -> void: - if root: - _wire_buttons(root) - -static func _wire_buttons(root: Node) -> void: - for node in root.find_children("*", "BaseButton", true, false): - var button := node as BaseButton - if button == null or button.has_meta("mobile_touch_wired"): - continue - button.set_meta("mobile_touch_wired", true) - button.gui_input.connect(func(event: InputEvent): - _handle_button_touch(button, event)) - -static func _handle_button_touch(button: BaseButton, event: InputEvent) -> void: - if button == null or not is_instance_valid(button): - return - if event is InputEventScreenTouch: - var touch := event as InputEventScreenTouch - if touch.pressed: - if int(button.get_meta("mobile_touch_index", -1)) < 0: - button.set_meta("mobile_touch_index", touch.index) - else: - if int(button.get_meta("mobile_touch_index", -1)) == touch.index: - button.set_meta("mobile_touch_index", -1) - if not button.disabled: - button.pressed.emit() - button.accept_event() diff --git a/project/ui/mobile/mobile_window_host.gd.uid b/project/ui/mobile/mobile_window_host.gd.uid deleted file mode 100644 index 69a90f50..00000000 --- a/project/ui/mobile/mobile_window_host.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cqvgte43jbj2a diff --git a/project/ui/mobile/virtual_joystick.gd b/project/ui/mobile/virtual_joystick.gd deleted file mode 100644 index c5792589..00000000 --- a/project/ui/mobile/virtual_joystick.gd +++ /dev/null @@ -1,86 +0,0 @@ -# VirtualJoystick —— continuous left-thumb movement input. -extends Control - -signal axis_changed(axis: Vector2) - -const DEAD_ZONE := 0.12 -const KNOB_RADIUS := 29.0 - -var _touch_index := -1 -var _axis := Vector2.ZERO -var _knob := Vector2.ZERO - -func setup(diameter := 164.0) -> void: - custom_minimum_size = Vector2(diameter, diameter) - size = Vector2(diameter, diameter) - mouse_filter = Control.MOUSE_FILTER_STOP - queue_redraw() - -func axis() -> Vector2: - return _axis - -func _gui_input(event: InputEvent) -> void: - if event is InputEventScreenTouch: - if event.pressed and _touch_index < 0: - _touch_index = event.index - _update_from_position(event.position) - elif not event.pressed and event.index == _touch_index: - _touch_index = -1 - _set_axis(Vector2.ZERO) - accept_event() - return - if event is InputEventScreenDrag and event.index == _touch_index: - _update_from_position(event.position) - accept_event() - return - if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: - if event.pressed: - _touch_index = -2 - _update_from_position(event.position) - else: - _touch_index = -1 - _set_axis(Vector2.ZERO) - accept_event() - return - if event is InputEventMouseMotion and _touch_index == -2: - _update_from_position(event.position) - accept_event() - -func _update_from_position(pos: Vector2) -> void: - var center := size * 0.5 - var radius := maxf(1.0, minf(size.x, size.y) * 0.5 - KNOB_RADIUS - 8.0) - var delta := pos - center - var raw := delta / radius - var magnitude := minf(raw.length(), 1.0) - var direction := raw.normalized() if magnitude > 0.001 else Vector2.ZERO - var output := direction * magnitude - if output.length() < DEAD_ZONE: - output = Vector2.ZERO - else: - output = output.normalized() * ((output.length() - DEAD_ZONE) / (1.0 - DEAD_ZONE)) - _knob = direction * radius * magnitude - _set_axis(output) - queue_redraw() - -func _set_axis(value: Vector2) -> void: - var next := value.limit_length(1.0) - if next.is_equal_approx(_axis): - return - _axis = next - axis_changed.emit(_axis) - queue_redraw() - -func cancel_press() -> void: - _touch_index = -1 - _knob = Vector2.ZERO - _set_axis(Vector2.ZERO) - -func _draw() -> void: - var center := size * 0.5 - var outer := minf(size.x, size.y) * 0.5 - 5.0 - if outer <= 0.0: - return - draw_circle(center, outer, Color(0.025, 0.04, 0.07, 0.38)) - draw_arc(center, outer, 0.0, TAU, 48, Color(0.72, 0.83, 0.96, 0.42), 2.0) - draw_circle(center + _knob, KNOB_RADIUS, Color(0.18, 0.3, 0.48, 0.78)) - draw_arc(center + _knob, KNOB_RADIUS, 0.0, TAU, 32, Color(0.7, 0.85, 1.0, 0.8), 2.0) diff --git a/project/ui/mobile/virtual_joystick.gd.uid b/project/ui/mobile/virtual_joystick.gd.uid deleted file mode 100644 index 5847d2d8..00000000 --- a/project/ui/mobile/virtual_joystick.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://snrdki713in1 diff --git a/project/ui/motion_registry.gd b/project/ui/motion_registry.gd deleted file mode 100644 index fe15ac6f..00000000 --- a/project/ui/motion_registry.gd +++ /dev/null @@ -1,206 +0,0 @@ -# MotionRegistry —— 40250 playersettingmodule.py 的 RaceData motion 注册表适配。 -# -# 40250 不按文件名等权挑动作:RegisterCacheMotionData/SetMotionRandomWeight -# 先把 (mode, index, sub-index, weight) 注册到 CRaceData,随后 -# CActorInstance::GetRandomMotionKey 使用 random() % 100 和注册顺序选择。 -# 这里读取随客户端资源发布的 playersettingmodule.py,保留相同的注册顺序、 -# 重复项和权重;这样资源表才是唯一数据源,不能在 PlayerView 中复制一份常量。 -extends RefCounted - -const MODE_VALUES := { - "MOTION_MODE_GENERAL": 1, - "MOTION_MODE_ONEHAND_SWORD": 2, - "MOTION_MODE_TWOHAND_SWORD": 3, - "MOTION_MODE_DUALHAND_SWORD": 4, - "MOTION_MODE_BOW": 5, - "MOTION_MODE_FAN": 6, - "MOTION_MODE_BELL": 7, - "MOTION_MODE_FISHING": 8, - "MOTION_MODE_HORSE": 9, - "MOTION_MODE_HORSE_ONEHAND_SWORD": 10, - "MOTION_MODE_HORSE_TWOHAND_SWORD": 11, - "MOTION_MODE_HORSE_DUALHAND_SWORD": 12, - "MOTION_MODE_HORSE_BOW": 13, - "MOTION_MODE_HORSE_FAN": 14, - "MOTION_MODE_HORSE_BELL": 15, - "MOTION_MODE_WEDDING_DRESS": 16, -} - -# CRaceMotionData::NAME_* in GameLib/RaceMotionData.h. Only the indices used by -# PlayerView are required here; unresolved skill/guild expressions are skipped, -# exactly as an unregistered motion key is rejected by GetMotionKey. -const MOTION_VALUES := { - "MOTION_WAIT": 1, - "MOTION_WALK": 2, - "MOTION_RUN": 3, - "MOTION_DAMAGE": 5, - "MOTION_DAMAGE_FLYING": 6, - "MOTION_STAND_UP": 7, - "MOTION_DAMAGE_BACK": 8, - "MOTION_DAMAGE_FLYING_BACK": 9, - "MOTION_STAND_UP_BACK": 10, - "MOTION_DEAD": 11, - "MOTION_DIG": 12, - "MOTION_NORMAL_ATTACK": 13, - "MOTION_COMBO_ATTACK_1": 14, - "MOTION_COMBO_ATTACK_2": 15, - "MOTION_COMBO_ATTACK_3": 16, - "MOTION_COMBO_ATTACK_4": 17, - "MOTION_COMBO_ATTACK_5": 18, - "MOTION_COMBO_ATTACK_6": 19, - "MOTION_COMBO_ATTACK_7": 20, - "MOTION_COMBO_ATTACK_8": 21, -} - -const RACE_FUNCTIONS := [ - "__LoadGameWarriorEx", - "__LoadGameAssassinEx", - "__LoadGameSuraEx", - "__LoadGameShamanEx", -] - -var source_path := "" -var source_loaded := false -var _entries: Dictionary = {} - -static func load_for(assets_root: String, race: int) -> RefCounted: - var result := new() - result.load_from(assets_root, race) - return result - -func load_from(assets_root: String, race: int) -> void: - source_path = assets_root.path_join("root/playersettingmodule.py") - _entries.clear() - source_loaded = false - if not FileAccess.file_exists(source_path): - return - var file := FileAccess.open(source_path, FileAccess.READ) - if file == null: - return - var lines: Array[String] = [] - while not file.eof_reached(): - lines.append(file.get_line()) - file.close() - - # SetGeneralMotions is a Python helper invoked by each PC race function. - # Its `mode` parameter is the selected general mode in all four PC loaders. - _parse_lines(_function_block(lines, "SetGeneralMotions"), 1) - var function_name: String = RACE_FUNCTIONS[clampi(race & 3, 0, RACE_FUNCTIONS.size() - 1)] - _parse_lines(_function_block(lines, function_name), -1) - source_loaded = not _entries.is_empty() - -func variants(mode: int, index: int) -> Array: - var value: Variant = _entries.get(_key(mode, index), []) - return value.duplicate(true) if value is Array else [] - -func has_variants(mode: int, index: int) -> bool: - var value: Variant = _entries.get(_key(mode, index), []) - return value is Array and not value.is_empty() - -func has_mode(mode: int) -> bool: - var prefix := "%d:" % mode - for key in _entries.keys(): - if String(key).begins_with(prefix): - return true - return false - -func _key(mode: int, index: int) -> String: - return "%d:%d" % [mode, index] - -func _function_block(lines: Array[String], function_name: String) -> Array[String]: - var result: Array[String] = [] - var started := false - var prefix := "def " + function_name + "(" - for line in lines: - if not started: - if line.begins_with(prefix): - started = true - continue - if line.begins_with("def "): - break - result.append(line) - return result - -func _parse_lines(lines: Array[String], default_mode: int) -> void: - for raw in lines: - var line := raw - var comment := line.find("#") - if comment >= 0: - line = line.substr(0, comment) - line = line.strip_edges() - if line.is_empty(): - continue - _parse_registration(line, default_mode) - _parse_weight_update(line, default_mode) - -func _parse_registration(line: String, default_mode: int) -> void: - var marker := "RegisterCacheMotionData(" - var start := line.find(marker) - if start < 0: - return - var close := line.rfind(")") - if close <= start + marker.length(): - return - var args := line.substr(start + marker.length(), close - start - marker.length()).split(",") - if args.size() < 3: - return - var mode := _resolve(args[0], default_mode, MODE_VALUES) - var index := _resolve(args[1], -1, MOTION_VALUES) - var filename := _literal_filename(args[2]) - if mode < 0 or index < 0 or filename == "": - return - var weight := 100 - if args.size() >= 4: - weight = _integer_literal(args[3], 100) - var key := _key(mode, index) - var entries: Array = _entries.get(key, []) - entries.append({"name": filename, "weight": weight}) - _entries[key] = entries - -func _parse_weight_update(line: String, default_mode: int) -> void: - var marker := "SetMotionRandomWeight(" - var start := line.find(marker) - if start < 0: - return - var close := line.rfind(")") - if close <= start + marker.length(): - return - var args := line.substr(start + marker.length(), close - start - marker.length()).split(",") - if args.size() < 4: - return - var mode := _resolve(args[0], default_mode, MODE_VALUES) - var index := _resolve(args[1], -1, MOTION_VALUES) - var sub_index := _integer_literal(args[2], -1) - var weight := _integer_literal(args[3], -1) - if mode < 0 or index < 0 or sub_index < 0 or weight < 0: - return - var key := _key(mode, index) - var entries: Array = _entries.get(key, []) - if sub_index >= entries.size(): - return - var entry: Dictionary = entries[sub_index] - entry["weight"] = weight - entries[sub_index] = entry - _entries[key] = entries - -func _resolve(token: String, fallback: int, values: Dictionary) -> int: - var text := token.strip_edges() - if text == "mode": - return fallback if fallback >= 0 else 1 - if text.is_valid_int(): - return int(text) - var name := text.get_slice(".", text.get_slice_count(".") - 1) - return int(values.get(name, fallback)) - -func _integer_literal(token: String, fallback: int) -> int: - var text := token.strip_edges() - return int(text) if text.is_valid_int() else fallback - -func _literal_filename(token: String) -> String: - var text := token.strip_edges() - if text.length() < 2: - return "" - if (text.begins_with("\"") and text.ends_with("\"")) or \ - (text.begins_with("'") and text.ends_with("'")): - return text.substr(1, text.length() - 2) - return "" diff --git a/project/ui/motion_registry.gd.uid b/project/ui/motion_registry.gd.uid deleted file mode 100644 index a7ae0966..00000000 --- a/project/ui/motion_registry.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c4dlk0af5133g diff --git a/project/ui/mouse_controller.gd b/project/ui/mouse_controller.gd deleted file mode 100644 index 91443885..00000000 --- a/project/ui/mouse_controller.gd +++ /dev/null @@ -1,333 +0,0 @@ -# MouseController —— ClientVS22 mousemodule.py 的全局物品拖放层。 -# -# 目标控件通过 register_target() 注册;物品源调用 attach_item() 后,控制器 -# 接管鼠标释放,命中目标时调用回调,释放到窗口外 / 右键 / ESC / 失焦则取消。 -# 光标状态由 CursorManager 管理,跟随图标不参与世界点击。 -extends Node - -const CursorManager = preload("res://ui/cursor_manager.gd") - -var cursor: Node -var audio: Node -var _overlay: Control -var _icon: TextureRect -var _label: Label -var _attached: Dictionary = {} -var _targets: Array[Dictionary] = [] -var _touch_index := -1 -var _touch_position := Vector2.ZERO -var world_drop_handler: Callable - -func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -> void: - cursor = cursor_manager - audio = audio_node - _overlay = Control.new() - _overlay.name = "MouseItemOverlay" - _overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE - _overlay.z_index = 1000 - _overlay.visible = false - _overlay.size = Vector2(72, 72) - parent.add_child(_overlay) - _icon = TextureRect.new() - _icon.position = Vector2(0, 0) - _icon.size = Vector2(48, 48) - _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE - _overlay.add_child(_icon) - _label = Label.new() - _label.position = Vector2(2, 48) - _label.size = Vector2(68, 20) - _label.add_theme_font_size_override("font_size", 10) - _label.mouse_filter = Control.MOUSE_FILTER_IGNORE - _overlay.add_child(_label) - set_process_input(true) - -var _drag_started := false -var _attach_pos := Vector2(-1, -1) -var _just_attached := false - -func _on_attach() -> void: - _drag_started = false - _just_attached = true - if is_inside_tree(): - _attach_pos = get_viewport().get_mouse_position() - else: - _attach_pos = Vector2(-1, -1) - -func attach_item(window: int, cell: int, vnum: int, count: int, - texture: Texture2D = null, source := "inventory", metadata: Dictionary = {}, - touch_index := -1, touch_position := Vector2(-1, -1)) -> bool: - if vnum <= 0 or window < 0 or cell < 0: - return false - _attached = {"window": window, "cell": cell, "vnum": vnum, - "count": maxi(1, count), "source": source} - _touch_index = touch_index - if touch_position.x >= 0.0 and touch_position.y >= 0.0: - _touch_position = touch_position - # Keep the instance fields that the reference mouse module exposes to the - # receiving window (anti flags, sockets, attributes, ...), while retaining - # the canonical source coordinates supplied by the caller. - for key in metadata: - if key not in ["window", "cell", "vnum", "count", "source"]: - _attached[key] = metadata[key] - if _icon: - _icon.texture = texture - _icon.visible = texture != null - if _label: - _label.text = "#%d%s" % [vnum, (" ×%d" % count) if count > 1 else ""] - _label.visible = true - if _overlay: - _overlay.visible = true - if cursor and cursor.has_method("set_cursor"): - cursor.set_cursor(CursorManager.ITEM) - _play_ui("pick.wav") - _on_attach() - return true - -func attach_money(amount: int, source := "inventory") -> bool: - if amount <= 0: - return false - _attached = {"window": -1, "cell": -1, "vnum": -1, "count": amount, - "source": source, "money": true} - _touch_index = -1 - _touch_position = Vector2.ZERO - if _icon: - _icon.texture = null - _icon.visible = false - if _label: - _label.text = "金币 ×%d" % amount - _label.visible = true - if _overlay: - _overlay.visible = true - if cursor and cursor.has_method("set_cursor"): - cursor.set_cursor(CursorManager.ITEM) - _play_ui("pick.wav") - _on_attach() - return true - -func attach_skill(skill_id: int, slot_idx: int = 0, texture: Texture2D = null, - skill_name := "", touch_index := -1, touch_position := Vector2(-1, -1)) -> bool: - if skill_id <= 0: - return false - _attached = { - "source": "skill", - "skill_id": skill_id, - "slot_idx": slot_idx, - } - _touch_index = touch_index - if touch_position.x >= 0.0 and touch_position.y >= 0.0: - _touch_position = touch_position - if _icon: - _icon.texture = texture - _icon.visible = texture != null - if _label: - _label.text = skill_name if skill_name != "" else "技能 %d" % skill_id - _label.visible = true - if _overlay: - _overlay.visible = true - if cursor and cursor.has_method("set_cursor"): - cursor.set_cursor(CursorManager.ITEM) - _play_ui("pick.wav") - _on_attach() - return true - -func attach_emotion(emote_id: int, texture: Texture2D = null, - emote_name := "", touch_index := -1, touch_position := Vector2(-1, -1)) -> bool: - if emote_id <= 0: - return false - _attached = { - "source": "emotion", - "emote_id": emote_id, - } - _touch_index = touch_index - if touch_position.x >= 0.0 and touch_position.y >= 0.0: - _touch_position = touch_position - if _icon: - _icon.texture = texture - _icon.visible = texture != null - if _label: - _label.text = emote_name if emote_name != "" else "动作 %d" % emote_id - _label.visible = true - if _overlay: - _overlay.visible = true - if cursor and cursor.has_method("set_cursor"): - cursor.set_cursor(CursorManager.ITEM) - _play_ui("pick.wav") - _on_attach() - return true - -func cancel() -> void: - _attached.clear() - _touch_index = -1 - _touch_position = Vector2.ZERO - _drag_started = false - _attach_pos = Vector2(-1, -1) - _just_attached = false - if _overlay: - _overlay.visible = false - if cursor and cursor.has_method("reset"): - cursor.reset() - -func is_attached() -> bool: - return not _attached.is_empty() - -func attached() -> Dictionary: - return _attached.duplicate(true) - -func register_target(control: Control, callback: Callable, owner: Node = null) -> void: - if control == null or not callback.is_valid(): - return - _targets.append({"control": control, "callback": callback, "owner": owner}) - -func unregister_owner(owner: Node) -> void: - for i in range(_targets.size() - 1, -1, -1): - if _targets[i].get("owner", null) == owner: - _targets.remove_at(i) - -func _process(_dt: float) -> void: - if _overlay and _overlay.visible: - var position := get_viewport().get_mouse_position() - if _touch_index >= 0: - position = _touch_position - _overlay.global_position = position + Vector2(12, 12) - # 清理已被窗口销毁的目标,防止拖放回调落到旧 UI。 - for i in range(_targets.size() - 1, -1, -1): - var c: Control = _targets[i].get("control", null) - if c == null or not is_instance_valid(c): - _targets.remove_at(i) - -func _set_handled() -> void: - var vp := get_viewport() - if vp: - vp.set_input_as_handled() - -func _input(event: InputEvent) -> void: - if not is_attached(): - return - if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: - cancel() - _set_handled() - return - if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: - cancel() - _set_handled() - return - if event is InputEventMouseMotion: - if _attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 6.0: - _drag_started = true - return - if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: - if event.pressed: - # If user already picked up an item and left-clicks on a target: - if not _just_attached: - if _drop_at(event.position): - _set_handled() - return - # If clicked outside any target, cancel attachment - if _attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 6.0: - _play_ui("loginfail.wav") - cancel() - _set_handled() - return - else: - # Left mouse release (not pressed) - _just_attached = false - # If dragged, attempt drop at release position - if _drag_started or (_attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 8.0): - _drop_at(event.position) - _set_handled() - return - # If not dragged (clicked to pick up), keep item attached to cursor! - return - if event is InputEventScreenDrag and event.index == _touch_index: - _touch_position = event.position - _set_handled() - return - if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index: - _touch_position = event.position - _drop_at(event.position) - _set_handled() - -func _drop_at(position: Vector2) -> bool: - var payload := attached() - if payload.is_empty(): - return false - - var icon_center := position + Vector2(24, 24) - var best_idx := -1 - var best_dist := 999999.0 - - # Pass 1: exact hit test (cursor position or icon center) - for i in range(_targets.size() - 1, -1, -1): - var c: Control = _targets[i].get("control", null) - if c == null or not is_instance_valid(c): - _targets.remove_at(i) - continue - if not c.visible: - continue - var rect := c.get_global_rect() - if rect.has_point(position): - var d := position.distance_to(rect.get_center()) - if d < best_dist: - best_dist = d - best_idx = i - elif rect.has_point(icon_center): - var d := icon_center.distance_to(rect.get_center()) - if d < best_dist: - best_dist = d - best_idx = i - - # Pass 2: generous hit test with 16px tolerance margin - if best_idx < 0: - for i in range(_targets.size() - 1, -1, -1): - var c: Control = _targets[i].get("control", null) - if c == null or not is_instance_valid(c) or not c.visible: - continue - var rect := c.get_global_rect().grow(16.0) - if rect.has_point(position): - var d := position.distance_to(rect.get_center()) - if d < best_dist: - best_dist = d - best_idx = i - elif rect.has_point(icon_center): - var d := icon_center.distance_to(rect.get_center()) - if d < best_dist: - best_dist = d - best_idx = i - - if best_idx >= 0: - var cb: Callable = _targets[best_idx].get("callback", Callable()) - if cb.is_valid(): - var ok := false - if cb.get_argument_count() >= 2: - ok = bool(cb.call(payload, position)) - else: - ok = bool(cb.call(payload)) - if ok: - _play_ui("drop.wav") - cancel() - return true - - if world_drop_handler.is_valid(): - var ok := false - if world_drop_handler.get_argument_count() >= 2: - ok = bool(world_drop_handler.call(payload, position)) - else: - ok = bool(world_drop_handler.call(payload)) - if ok: - _play_ui("drop.wav") - cancel() - return true - - _play_ui("loginfail.wav") - cancel() - return false - -func _play_ui(name: String) -> void: - if audio and audio.has_method("play_ui"): - audio.play_ui(name) - -func _notification(what: int) -> void: - if what == NOTIFICATION_WM_WINDOW_FOCUS_OUT: - cancel() diff --git a/project/ui/mouse_controller.gd.uid b/project/ui/mouse_controller.gd.uid deleted file mode 100644 index 088b4184..00000000 --- a/project/ui/mouse_controller.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c7exn3c2tcjfj diff --git a/project/ui/msa_motion.gd b/project/ui/msa_motion.gd deleted file mode 100644 index fb1a99ae..00000000 --- a/project/ui/msa_motion.gd +++ /dev/null @@ -1,38 +0,0 @@ -# MsaMotion —— 读 .msa 文本里的根运动:移动动作每秒位移(cm/s,movSpd 100 时)。 -# -# MotionDuration 0.600000 -# Accumulation 0.00 -255.45 0.00 -# -# 服务端 CHARACTER::GetMoveMotionSpeed = -Accumulation.y / MotionDuration; -# 客户端 CActorInstance::AccumulationMovement 按同一累计量推进。这里取 xy 长度。 -extends RefCounted - -static var _cache := {} # path -> float - -static func move_speed(path: String) -> float: - if path == "" or not path.to_lower().ends_with(".msa"): - return 0.0 - if _cache.has(path): - return float(_cache[path]) - var speed := 0.0 - var f := FileAccess.open(path, FileAccess.READ) - if f: - var duration := 0.0 - var accum := Vector2.ZERO - while not f.eof_reached(): - var parts := _fields(f.get_line()) - if parts.size() < 2: - continue - match String(parts[0]): - "MotionDuration": - duration = String(parts[1]).to_float() - "Accumulation": - if parts.size() >= 3: - accum = Vector2(String(parts[1]).to_float(), String(parts[2]).to_float()) - if duration > 0.0: - speed = accum.length() / duration - _cache[path] = speed - return speed - -static func _fields(line: String) -> PackedStringArray: - return line.strip_edges().replace("\t", " ").split(" ", false) diff --git a/project/ui/msa_motion.gd.uid b/project/ui/msa_motion.gd.uid deleted file mode 100644 index f48129c6..00000000 --- a/project/ui/msa_motion.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://620gbknp3qd6 diff --git a/project/ui/party_ui.gd b/project/ui/party_ui.gd deleted file mode 100644 index 8a7102f5..00000000 --- a/project/ui/party_ui.gd +++ /dev/null @@ -1,327 +0,0 @@ -# PartyUI (P8/P11) —— 组队成员信息板(1:1 迁移 `assets/root/uiparty.py` 的 -# `PartyMemberInfoBoard` + `PartyMemberInfoWindow`;布局照 `partymemberinfoboard.py` 的 -# 每员 106×36 条:StateButton(角色图标) + NameSlot + Gauge(HP) + 附加图标行)。 -# -# var pu := preload("res://ui/party_ui.gd").new() -# add_child(pu) -# pu.setup(m2client, canvas_parent, dialogs) # dialogs 可空 -# -# 每员一条 strip:角色状态按钮(队长可点 → 分配角色 / 踢人)、名字(+★队长)、HP 条、 -# 附加效果格(affects[7] 非零 → 小图标 + tooltip)。整条点击 → 选中该员 vid。 -# 顶部:EXP 分配开关(均分 / 不均分 → party_set_distribute)+ 组队治疗(party_use_skill)。 -# `party_changed` 刷新;`party_invite_ask(leader_pid)` → 确认框 → party_answer。 -extends Node - -# PythonPlayer.h EPartyRole -const ROLE_NORMAL := 0 -const ROLE_ATTACKER := 2 -const ROLE_TANKER := 3 -const ROLE_BUFFER := 4 -const ROLE_SKILL_MASTER := 5 -const ROLE_HASTE := 6 -const ROLE_DEFENDER := 7 -const ROLE_LABEL := { - 0: "普", 1: "队", 2: "攻", 3: "坦", 4: "辅", 5: "宗", 6: "速", 7: "防", -} -const ROLE_MENU := [ - [ROLE_NORMAL, "普通"], [ROLE_ATTACKER, "攻击"], [ROLE_TANKER, "坦克"], - [ROLE_HASTE, "迅捷"], [ROLE_BUFFER, "辅助"], [ROLE_SKILL_MASTER, "宗师"], - [ROLE_DEFENDER, "防御"], -] -# Packet.h EPartyExpDistributionType -const EXP_NON_PARITY := 0 # 不均分(按贡献) -const EXP_PARITY := 1 # 均分 -# uiparty.PartyMemberInfoBoard.PARTY_SKILL_* -const PARTY_SKILL_HEAL := 1 -# affects[7] 槽位含义(暂定,按 partymemberinfoboard.py 图标顺序) -const AFFECT_LABEL := ["经验", "攻击", "坦克", "辅助", "宗师", "迅捷", "防御"] - -var client: Node -var dialogs: Node -var _root: Control -var _list: VBoxContainer -var _dist_btn: Button -var _heal_btn: Button -var _notice: Label -var _role_popup: Control = null -var _mobile_mode := false - -func setup(m2client: Node, parent: Node, dlg: Node = null) -> void: - client = m2client - dialogs = dlg - _build(parent) - if client.has_signal("party_changed"): - client.party_changed.connect(refresh) - if client.has_signal("party_invite_ask"): - client.party_invite_ask.connect(_on_invite) - if client.has_signal("party_request_denied"): - client.party_request_denied.connect(_on_request_denied) - if client.has_signal("vitals_changed"): - client.vitals_changed.connect(func(_v): if is_open(): refresh()) - refresh() - -func is_open() -> bool: - return _root != null and _root.visible - -func close() -> void: - if _root: - _root.visible = false - -# MobileUiRoot owns the compact party strip beneath the player avatar. Keep -# this controller alive for server updates, but hide its desktop window. -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - if enabled: - _dismiss_role_popup() - if _root: - _root.visible = false - -# --- 로컬 플레이어가 파티장인가 --- -func _local_is_leader() -> bool: - if client == null or not client.has_method("get_main_vid"): - return false - var my_vid := int(client.get_main_vid()) - for m in client.get_party(): - if int(m.get("vid", 0)) == my_vid: - return bool(m.get("leader", false)) - return false - -# --- 새로고침 ------------------------------------------------------------ - -func refresh() -> void: - if client == null or not client.has_method("get_party"): - return - _dismiss_role_popup() - for c in _list.get_children(): - _list.remove_child(c) - c.queue_free() - var members: Array = client.get_party() - if _mobile_mode: - _root.visible = false - return - _root.visible = not members.is_empty() - if members.is_empty(): - return - _refresh_dist_btn() - for m in members: - _list.add_child(_strip(m)) - -func _refresh_dist_btn() -> void: - if not is_instance_valid(_dist_btn): - return - var mode := int(client.get_party_distribute_mode()) if client.has_method("get_party_distribute_mode") else 0 - _dist_btn.text = "EXP:均分" if mode == EXP_PARITY else "EXP:不均分" - _dist_btn.disabled = not _local_is_leader() - if is_instance_valid(_heal_btn): - var leadership := _leadership_level() - _heal_btn.disabled = not _local_is_leader() or (leadership >= 0 and leadership < 18) - _heal_btn.tooltip_text = "队长 Leadership 达到 18 级后可用" if _heal_btn.disabled and leadership >= 0 and leadership < 18 else "组队治疗" - -func _leadership_level() -> int: - # The server's CParty::Update gates party roles/heal using the leader's - # Leadership skill. -1 means that the snapshot has not arrived yet; in - # that case the server remains authoritative and the controls stay usable. - if client == null or not client.has_method("get_skills"): - return -1 - var skills: Array = client.get_skills() - for skill in skills: - if int(skill.get("id", -1)) == 121: # SKILL_LEADERSHIP in 40250 - return int(skill.get("level", 0)) - return -1 - -func _role_unlock_level(role: int) -> int: - match role: - ROLE_ATTACKER: return 10 - ROLE_TANKER, ROLE_HASTE: return 20 - ROLE_BUFFER: return 25 - ROLE_SKILL_MASTER: return 35 - ROLE_DEFENDER: return 40 - _: return 0 - -func _role_allowed(role: int, current_role: int = -1) -> bool: - if role == ROLE_NORMAL or role == current_role: - return true - var leadership := _leadership_level() - return leadership < 0 or leadership >= _role_unlock_level(role) - -# --- 한 명 strip ------------------------------------------------------ - -func _strip(m: Dictionary) -> Control: - var pid := int(m.get("pid", 0)) - var vid := int(m.get("vid", 0)) - var is_leader := bool(m.get("leader", false)) - var role := int(m.get("state", 0)) & 0x7F - - var strip := PanelContainer.new() - strip.custom_minimum_size = Vector2(206, 40) - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 4) - strip.add_child(row) - - # StateButton —— 角色图标(队长可点) - var state_btn := Button.new() - state_btn.custom_minimum_size = Vector2(22, 34) - state_btn.text = "队" if is_leader else ROLE_LABEL.get(role, "普") - state_btn.tooltip_text = "队长" if is_leader else ("角色:%s" % ROLE_LABEL.get(role, "普")) - state_btn.disabled = not _local_is_leader() - state_btn.pressed.connect(func() -> void: _open_role_popup(state_btn, pid, role)) - row.add_child(state_btn) - - var col := VBoxContainer.new() - col.add_theme_constant_override("separation", 1) - col.size_flags_horizontal = Control.SIZE_EXPAND_FILL - row.add_child(col) - - # NameSlot / NamePrint - var name_lbl := Button.new() - name_lbl.flat = true - name_lbl.alignment = HORIZONTAL_ALIGNMENT_LEFT - name_lbl.text = ("★ " if is_leader else "") + String(m.get("name", "?")) - name_lbl.add_theme_font_size_override("font_size", 12) - name_lbl.pressed.connect(func() -> void: _select_member(vid)) - col.add_child(name_lbl) - - # Gauge (HP) - var bar := ProgressBar.new() - bar.min_value = 0 - bar.max_value = 100 - bar.value = int(m.get("hp_pct", 0)) - bar.show_percentage = false - bar.custom_minimum_size = Vector2(180, 8) - col.add_child(bar) - - # 附가효과 아이콘 행 - var affects: Array = m.get("affects", []) - var chips := HBoxContainer.new() - chips.add_theme_constant_override("separation", 2) - col.add_child(chips) - for i in affects.size(): - var v := int(affects[i]) - if v == 0: - continue - var chip := Label.new() - chip.text = AFFECT_LABEL[i] if i < AFFECT_LABEL.size() else "?%d" % i - chip.add_theme_font_size_override("font_size", 9) - chip.add_theme_color_override("font_color", Color(0.7, 0.9, 1.0)) - chip.tooltip_text = "%s +%d" % [chip.text, v] - chips.add_child(chip) - return strip - -func _select_member(vid: int) -> void: - if vid <= 0 or client == null: - return - if client.has_method("set_target"): - client.set_target(vid) - elif client.has_method("target"): - client.target(vid) - -# --- 角色分配 팝업 (uiparty.__ShowStateButton + OnSelectState / OnExpel) --- - -func _open_role_popup(anchor: Control, pid: int, cur_role: int) -> void: - _dismiss_role_popup() - if not _local_is_leader(): - return - var pop := VBoxContainer.new() - pop.add_theme_constant_override("separation", 2) - var bg := PanelContainer.new() - bg.add_child(pop) - for entry in ROLE_MENU: - var r: int = entry[0] - var b := Button.new() - b.text = entry[1] + (" ✓" if r == cur_role else "") - b.custom_minimum_size = Vector2(84, 22) - b.disabled = not _role_allowed(r, cur_role) - if b.disabled: - b.tooltip_text = "Leadership %d 级解锁" % _role_unlock_level(r) - b.pressed.connect(func() -> void: - if r == ROLE_NORMAL: - # OnSelectState(-1): 清掉当前角色 - client.party_set_state(pid, cur_role, false) - else: - client.party_set_state(pid, r, true) - _dismiss_role_popup()) - pop.add_child(b) - var expel := Button.new() - expel.text = "踢出队伍" - expel.custom_minimum_size = Vector2(84, 22) - expel.add_theme_color_override("font_color", Color(1, 0.5, 0.5)) - expel.pressed.connect(func() -> void: - client.party_leave(pid) # OnExpel -> SendPartyRemovePacket - _dismiss_role_popup()) - pop.add_child(expel) - bg.position = anchor.get_global_position() + Vector2(24, 0) - _root.get_parent().add_child(bg) - _role_popup = bg - -func _dismiss_role_popup() -> void: - if is_instance_valid(_role_popup): - _role_popup.queue_free() - _role_popup = null - -# --- 邀请确认 --------------------------------------------------------- - -func _on_invite(leader_pid: int) -> void: - var accept := func() -> void: client.party_answer(leader_pid, true) - var decline := func() -> void: client.party_answer(leader_pid, false) - if dialogs and dialogs.has_method("confirm"): - dialogs.confirm("有人邀请你加入队伍,接受?", accept, decline) - else: - accept.call() - -func _on_request_denied() -> void: - if is_instance_valid(_notice): - _notice.text = "组队请求被拒绝" - -# --- EXP 분배 / 组队治疗 -------------------------------------------- - -func _toggle_distribute() -> void: - if client == null or not _local_is_leader(): - return - var mode := int(client.get_party_distribute_mode()) if client.has_method("get_party_distribute_mode") else 0 - client.party_set_distribute(EXP_NON_PARITY if mode == EXP_PARITY else EXP_PARITY) - -func _party_heal() -> void: - if client and client.has_method("party_use_skill"): - client.party_use_skill(PARTY_SKILL_HEAL, 0) # OnPartyUseSkill - -# --- 레이아웃 ------------------------------------------------------------ - -func _build(parent: Node) -> void: - _root = Control.new() - _root.set_anchors_preset(Control.PRESET_TOP_LEFT) - _root.position = Vector2(16, 150) - _root.size = Vector2(226, 320) - _root.visible = false - parent.add_child(_root) - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.06, 0.07, 0.09, 0.85) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - - var header := HBoxContainer.new() - header.position = Vector2(8, 6) - header.add_theme_constant_override("separation", 6) - _root.add_child(header) - _dist_btn = Button.new() - _dist_btn.text = "EXP:均分" - _dist_btn.add_theme_font_size_override("font_size", 11) - _dist_btn.pressed.connect(_toggle_distribute) - header.add_child(_dist_btn) - var heal := Button.new() - heal.text = "组队治疗" - heal.add_theme_font_size_override("font_size", 11) - heal.pressed.connect(_party_heal) - _heal_btn = heal - header.add_child(heal) - _notice = Label.new() - _notice.add_theme_font_size_override("font_size", 10) - _notice.add_theme_color_override("font_color", Color(1, 0.65, 0.55)) - header.add_child(_notice) - - _list = VBoxContainer.new() - _list.position = Vector2(8, 32) - _list.add_theme_constant_override("separation", 4) - _root.add_child(_list) diff --git a/project/ui/party_ui.gd.uid b/project/ui/party_ui.gd.uid deleted file mode 100644 index 9d62ce84..00000000 --- a/project/ui/party_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d3654hrkbg1u0 diff --git a/project/ui/player_view.gd b/project/ui/player_view.gd deleted file mode 100644 index 839e8648..00000000 --- a/project/ui/player_view.gd +++ /dev/null @@ -1,870 +0,0 @@ -# PlayerView (P2) —— 本地玩家的真模型:Metin2Model + Metin2AnimPlayer。 -# -# var pv := preload("res://ui/player_view.gd").new() -# pv.build(assets_root, race) # race 0..7(GC_MAIN_CHARACTER.race) -# game_scene.set_player_model(pv) -# pv.set_anim_state("run") # wait/walk/run/attack/dead -# -# 转发 weapon_gr2 / gr2_path / hair_gr2 / hair_skin 属性及材质换肤调用给内部 -# Metin2Model,所以 equip_model.gd 可以直接操作外层 PlayerView。 -extends Node3D - -# race -> (class, 是否女). playersettingmodule: 0=WAR_M 1=ASN_W 2=SURA_M 3=SHA_W -# 4=WAR_W 5=ASN_M 6=SURA_W 7=SHA_M -const CLASS_OF := ["warrior", "assassin", "sura", "shaman"] -const FEMALE_RACES := [1, 3, 4, 6] -# playersettingmodule.py RegisterAttachingBoneName(按职业,男女同名): -# PART_WEAPON -> model.weapon_bone;PART_WEAPON_LEFT -> model.shield_bone("" = 未登记,不挂) -const WEAPON_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"] -const WEAPON_LEFT_BONE := ["", "equip_left", "", "equip_left"] -const HitReaction = preload("res://ui/hit_reaction.gd") -const MsaMotion = preload("res://ui/msa_motion.gd") -const MotionRegistry = preload("res://ui/motion_registry.gd") -const MOVE_STATES := ["walk", "run"] -const HitCollision = preload("res://hit_collision.gd") -const WeaponTrace = preload("res://ui/weapon_trace.gd") -# CRaceMotionData::MOTION_MODE_* -> playersettingmodule 注册动作用的目录(与 general 同级) -const MOTION_MODE_DIRS := {1: "general", 2: "onehand_sword", 3: "twohand_sword", 4: "dualhand_sword", - 5: "bow", 6: "fan", 7: "bell", 8: "fishing", 9: "horse", 10: "horse_onehand_sword", - 11: "horse_twohand_sword", 12: "horse_dualhand_sword", 13: "horse_bow", 14: "horse_fan", - 15: "horse_bell", 16: "wedding"} -const MOTION_NORMAL_ATTACK := 13 # CRaceMotionData::NAME_NORMAL_ATTACK;COMBO_ATTACK_1 = 14 -# 受击动作步 -> playersettingmodule RegisterMotionData 的文件名回退。 -# 实际选择优先消费 MotionRegistry 中的 (mode,index) 注册项及其权重。 -const REACTION_FILES := { - "damage": ["damage", "damage_1"], # NAME_DAMAGE - "damage_back": ["damage_2", "damage_3"], # NAME_DAMAGE_BACK - "knockdown": ["damage_flying"], # NAME_DAMAGE_FLYING - "knockdown_back": ["back_damage_flying"], # NAME_DAMAGE_FLYING_BACK - "standup": ["falling_stand"], # NAME_STAND_UP - "standup_back": ["back_falling_stand"], # NAME_STAND_UP_BACK -} -const MOTION_INDEX_BY_STATE := { - "wait": 1, - "walk": 2, - "run": 3, - "damage": 5, - "knockdown": 6, - "standup": 7, - "damage_back": 8, - "knockdown_back": 9, - "standup_back": 10, - "dead": 11, - "attack": 14, # PC playersettingmodule stores normal/first swing as COMBO_ATTACK_1 - "combo": 14, -} -const REACTION_INDEX := { - "damage": 5, - "damage_back": 8, - "knockdown": 6, - "knockdown_back": 9, - "standup": 7, - "standup_back": 10, -} - -# CActorInstance::__SetMotion 尾:动作绑定完成(net_play 据此清 m_HitDataMap / 连击段号)。 -signal motion_bound(state: String) - -var model: Node # Metin2Model -var anim: Node # Metin2AnimPlayer -var motion_dir := "" -var action_dir := "" -var _state := "" -var _move_speed_ratio := 1.0 -var _assets_root := "" -var _motion_registry: RefCounted -var _audio: Node -var _sound_instances: Array = [] -var _sound_frame := -1 -var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"] -var _left_bone := "" -var _race := 0 -var _skill_loop_count := 0 -var _skill_moving := false -var _motion_hidden := false -var _hit := HitReaction.new() -var _motion_mode := 1 # CRaceMotionData::MODE_GENERAL -var _weapon_trace: Node -var _weapon_traces: Array = [] # 40250 m_WeaponTraceVector:右手/左手逐部件保存 -var _weapon_trace_allowed := true -var _weapon_trace_reach_scale := 1.0 # CActorInstance::m_fReachScale - -# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下 -# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。 -func build(assets_root: String, race: int, pump := Callable()) -> bool: - _assets_root = assets_root - _race = race - if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"): - return false - var cls: String = CLASS_OF[race & 3] - # pc contains races 0..3 (including female assassin/shaman); pc2 is 4..7. - var pc_dir := "pc2/ymir work/pc2/%s" % cls if race >= 4 else "PC/ymir work/pc/%s" % cls - var base := _resolve_dir(assets_root, pc_dir) - if base == "": - return false - motion_dir = base.path_join("general") - action_dir = base.path_join("action") - _motion_registry = MotionRegistry.load_for(_assets_root, _race) - - model = ClassDB.instantiate("Metin2Model") - model.name = "Metin2Model" - # PC GR2 surfaces need the same outward winding in bind, CPU and GPU poses. - model.set("flip_winding", true) - model.set("texture_dir", base) - # 挂点骨骼名要在 gr2_path 之前设:Metin2Model 默认 equip_right_hand 只有战士骨架有, - # 其余职业找不到骨骼就不建 Weapon 节点(武器不在手上)。 - model.set("weapon_bone", WEAPON_BONE[race & 3]) - _left_bone = WEAPON_LEFT_BONE[race & 3] - if _left_bone != "": - model.set("shield_bone", _left_bone) - if pump.is_valid(): pump.call() - var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)]) - if body == "": - model.free() - model = null - return false - model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD - # This view is built off-tree: set_gr2_path only stores the path until ready. - # Validate the source explicitly rather than reading a not-yet-loaded report. - if String(model.call("probe_gr2", body)).contains("FAILED"): - model.free() - model = null - return false - if pump.is_valid(): pump.call() - var hair := _first_existing([base.path_join("hair/hair_1_1.gr2"), base.path_join("hair/hair_01.gr2")]) - if hair != "": - model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格 - if pump.is_valid(): pump.call() - add_child(model) - model.ready.connect(_ground_model) - _ground_model() - - if ClassDB.class_exists("Metin2AnimPlayer"): - anim = ClassDB.instantiate("Metin2AnimPlayer") - anim.set("model_path", NodePath("../Metin2Model")) - anim.set("loop", true) - anim.set("blend_time", 0.15) - add_child(anim) - if anim.has_signal("playback_finished"): - anim.playback_finished.connect(_on_playback_finished) - if pump.is_valid(): pump.call() - set_anim_state("wait") # 重:解 .msa - if pump.is_valid(): pump.call() - call_deferred("_refresh_weapon_trace") - CharShadow.attach(self, 1.3) # 脚下接触阴影 + 强制 cast_shadow - return true - -func set_audio(audio_node: Node) -> void: - _audio = audio_node - _refresh_sound_script(String(anim.get("anim_path")) if anim else "") - -# 40250 CActorInstance::__HideEvent / __ShowEvent. Keep the state on the -# actor view so a type-8 event cannot be undone by an unrelated child refresh. -func set_motion_hidden(hidden: bool) -> void: - _motion_hidden = hidden - if is_instance_valid(model) and model is Node3D: - (model as Node3D).visible = not hidden - -func is_motion_hidden() -> bool: - return _motion_hidden - -# 40250 CActorInstance::ProcessMotionEventFly 起点:FlyPosition 先按角色 -# 资源坐标转换,AttachingBoneName 再叠加当前骨骼平移。复用 motion effect -# 的同一坐标变换,确保本地箭矢与挂骨特效从同一个点出发。 -func motion_event_world_position(event: Dictionary) -> Vector3: - var source_variant: Variant = event.get("fly_pos", Vector3.ZERO) - var source: Vector3 = source_variant if source_variant is Vector3 else Vector3.ZERO - if is_instance_valid(model): - var bone := Transform3D.IDENTITY - if bool(event.get("fly_attaching", false)) and anim != null \ - and anim.has_method("get_effect_bone_pose"): - var bone_name := String(event.get("fly_bone", "")) - if bone_name != "": - var pose: Dictionary = anim.get_effect_bone_pose(bone_name) - if pose.has("transform"): - bone = pose["transform"] - var source_to_metres := Transform3D( - Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0)), - Vector3.ZERO) - var offset := Transform3D(Basis.IDENTITY, source) - return (model.global_transform * offset * bone * source_to_metres.affine_inverse()).origin - return global_transform * Vector3(source.x * 0.01, source.z * 0.01, -source.y * 0.01) - -# ActorInstanceAttach::__IsWeaponTrace / __DestroyWeaponTrace seam. EquipModel -# calls this after it knows the item subtype; direct PlayerView callers keep the -# default enabled behavior for ordinary sword-like weapon meshes. -func set_weapon_trace_enabled(enabled: bool) -> void: - _weapon_trace_allowed = enabled - if not enabled: - _destroy_weapon_trace() - call_deferred("_refresh_weapon_trace") - -func weapon_trace_state() -> Dictionary: - if _weapon_traces.is_empty(): - return {"present": false, "enabled": _weapon_trace_allowed} - var primary: Node = _weapon_traces[0] - var state: Dictionary = primary.debug_state() if is_instance_valid(primary) and primary.has_method("debug_state") else {} - var any_playing := false - for trace in _weapon_traces: - if is_instance_valid(trace) and trace.has_method("is_playing") and trace.is_playing(): - any_playing = true - state["count"] = _weapon_traces.size() - state["playing"] = any_playing - state["present"] = true - state["enabled"] = _weapon_trace_allowed - return state - -func weapon_reach_scale() -> float: - return _weapon_trace_reach_scale - -# 40250 PythonCharacterModule::chrWeaponTraceSetTexture / UseTextureWeaponTrace / -# UseAlphaWeaponTrace。当前端按 m_WeaponTraceVector 广播到每个武器部件。 -func weapon_trace_set_texture(path: String) -> void: - for trace in _weapon_traces: - if is_instance_valid(trace) and trace.has_method("set_texture"): - trace.set_texture(path) - -func weapon_trace_use_texture() -> void: - for trace in _weapon_traces: - if is_instance_valid(trace) and trace.has_method("use_texture_mode"): - trace.use_texture_mode() - -func weapon_trace_use_alpha() -> void: - for trace in _weapon_traces: - if is_instance_valid(trace) and trace.has_method("use_alpha"): - trace.use_alpha() - -func _set_weapon_trace_reach_scale(value: float) -> void: - _weapon_trace_reach_scale = maxf(value, 0.0) - for trace in _weapon_traces: - if is_instance_valid(trace) and trace.has_method("set_reach_scale"): - trace.set_reach_scale(_weapon_trace_reach_scale) - -func set_anim_state(s: String) -> void: - # 40250 SetLoopMotion still reaches __SetMotion for the current motion; - # that path calls __ShowEvent() when a previous CHARACTER_HIDE event left - # the actor hidden. Do not let the same-state fast path keep it invisible. - if (s == _state and not _motion_hidden) or anim == null or motion_dir == "": - return - if HitReaction.is_reaction(s): - _hit.start([[s]], _state, _bind_reaction) - return - # 受击动作链播放中:循环动作请求留到链尾 PushLoopMotion 再回(seam:参考端动作队列) - if _hit.active and s in HitReaction.LOOP_STATES: - _hit.resume = s - return - # 单次动作(技能、攻击、表情)播放中:普通循环动作(wait/walk/run)不得强行打断 - if _state in ["__skill", "attack", "combo", "__motion"] and s in HitReaction.LOOP_STATES: - if anim.has_method("get_duration") and anim.has_method("get_time"): - var d: float = float(anim.call("get_duration")) - var t: float = float(anim.call("get_time")) - if d > 0.0 and t < d and bool(anim.get("playing")): - return - else: - return - var msa := _motion_file(s) - # CActorInstance::SetLoopMotion/InterceptMotion resolve GetMotionKey - # before mutating the current motion node. A missing registered key must - # leave the old animation/state in place, rather than becoming a dead label. - if msa == "": - return - _hit.cancel() - _state = s - _bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s)) - -# CActorInstance::SetMoveSpeed:m_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放 -# (Move -> SetLoopMotion(WALK/RUN, 0.15, m_fMovSpd))。>1100 时参考端不走动作累计, -# EntityStore 退回按 duration 插值,这里保持原速播放。 -func set_move_speed(moving_speed: int) -> void: - _move_speed_ratio = 1.0 if moving_speed <= 0 or moving_speed > 1100 \ - else float(moving_speed) / 100.0 - if anim != null and _state in MOVE_STATES and not _hit.active: - anim.set("time_scale", _hit.scale_for(_move_speed_ratio)) - -func _loop_speed(state: String) -> float: - return _move_speed_ratio if state in MOVE_STATES else 1.0 - -## 走 / 跑动作的根运动速度(cm/s,movSpd 100):x = walk,y = run。0 = 取不到。 -## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。 -func get_move_motion_speeds() -> Vector2: - if motion_dir == "": - return Vector2.ZERO - var walk := _motion_file("walk") - var run := _motion_file("run") - return Vector2(MsaMotion.move_speed(walk), MsaMotion.move_speed(run)) - -# CInstanceBase::RefreshState selects every motion through m_wcurMotionMode, -# not only attack/combo. A two-handed weapon therefore uses twohand_sword for -# wait/walk/run/damage too; the left-hand grip is authored in those animations -# while the weapon model itself remains attached to PART_WEAPON (right hand). -func set_motion_mode(mode: int) -> void: - var next := mode if MOTION_MODE_DIRS.has(mode) else 1 - if next == _motion_mode: - return - _motion_mode = next - var resume := _state - if resume == "" or resume == "__motion": - resume = "wait" - _state = "" # force rebind even though the semantic state did not change - set_anim_state(resume) - -func get_motion_mode() -> int: - return _motion_mode - -# Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends -# the numeric motion id; paired emotions use the other entity's race to select -# kiss/french-kiss variants, exactly like ActDualEmotion in the native client. -func set_motion_id(motion: int, target_race: int = -1) -> bool: - if anim == null: - return false - var name := "" - if motion == 305: - name = "clap" - elif motion == 306: - name = "cheers_1" - elif motion == 307: - name = "cheers_2" - elif motion >= 308 and motion <= 311: - var job := motion - 308 - if motion == 308 and target_race >= 0: - job = target_race & 3 - name = "kiss_with_" + CLASS_OF[clampi(job, 0, 3)] - elif motion >= 312 and motion <= 315: - var french_job := motion - 312 - if motion == 312 and target_race >= 0: - french_job = target_race & 3 - name = "french_kiss_with_" + CLASS_OF[clampi(french_job, 0, 3)] - elif motion >= 316 and motion <= 319: - name = "slap_hit" - elif motion >= 320 and motion <= 323: - name = "slap_hurt" - elif motion == 324: - name = "dig" - elif motion >= 325 and motion <= 340: - name = "dance_%d" % (motion - 324) - elif motion == 341: - name = "congratulation" - elif motion == 342: - name = "forgive" - elif motion == 343: - name = "angry" - elif motion == 344: - name = "attractive" - elif motion == 345: - name = "sad" - elif motion == 346: - name = "shy" - elif motion == 347: - name = "cheerup" - elif motion == 348: - name = "banter" - elif motion == 349: - name = "joy" - elif motion == 5 or motion == 6 or motion == 8 or motion == 9: - set_anim_state("damage") - return true - elif motion == 11 or motion == 12: - set_anim_state("dead") - return true - elif motion >= 13 and motion <= 21: - set_anim_state("attack") - return true - else: - return false - - var msa := action_dir.path_join(name + ".msa") - if not FileAccess.file_exists(msa): - msa = motion_dir.path_join(name + ".msa") - if not FileAccess.file_exists(msa): - return false - _bind("__motion", msa, false, 1.0) - return true - -# §3.7 CActorInstance::__SetMotion(SSetMotionData{ MAKE_MOTION_KEY(mode, index), fSpeedRatio }): -# 攻击段动作按武器动作模式目录绑定(NORMAL_ATTACK/COMBO_ATTACK_1 使用 -# playersettingmodule 注册的 index 14,后续段使用 combo_0N.msa);普通武器模式 -# 缺 key 时由 CRaceData 只回退 GENERAL,HORSE_* 只回退 HORSE。 -func play_attack_motion(mode: int, index: int, speed_ratio: float) -> bool: - if anim == null or motion_dir == "": - return false - var names: Array = ["attack", "attack_1"] - if index > MOTION_NORMAL_ATTACK: - names = ["combo_%02d" % (index - MOTION_NORMAL_ATTACK)] - var registry_index := index if index > MOTION_NORMAL_ATTACK else MOTION_NORMAL_ATTACK + 1 - var msa := "" - for dir in _motion_dirs_for_motion(mode, registry_index): - var registry_mode := _motion_mode_for_dir(dir) - # PC playersettingmodule registers the normal swing under - # COMBO_ATTACK_1 (14), including the GENERAL attack/attack_1 pair. - # The network's NAME_NORMAL_ATTACK (13) therefore resolves to the same - # registered resource when the mode has no dedicated normal entry. - msa = _pick_registered_msa(dir, registry_mode, registry_index, names) - if msa == "" and (_motion_registry == null or not _motion_registry.source_loaded): - msa = _pick_msa(dir, names) - if msa != "": - break - if msa == "": - return false - _bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0) - return true - -# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310) -# 播放指定技能动作(.msa),依据 grade (0=普, 1=M, 2=G, 3=P) 读取对应阶位动作文件。 -func play_skill_motion(motion_name: String, grade: int = 0, loop_count: int = 0, - moving_skill: bool = false) -> bool: - if anim == null: - return false - var cls: String = CLASS_OF[_race & 3] - var pc_dir := "pc2/ymir work/pc2/%s/skill" % cls if _race >= 4 else "PC/ymir work/pc/%s/skill" % cls - var sdir := _resolve_dir(_assets_root, pc_dir) - if sdir == "": - return false - var suffix := "" - if grade >= 1: - suffix = "_%d" % (grade + 1) - var msa := sdir.path_join(motion_name + suffix + ".msa") - if not FileAccess.file_exists(msa): - msa = sdir.path_join(motion_name + ".msa") - if not FileAccess.file_exists(msa): - return false - _skill_loop_count = maxi(0, loop_count) - _skill_moving = moving_skill - _bind("__skill", msa, false, 1.0) - # 40250 applies the network/local uMotLoopCount after binding the motion; - # the native player then uses the MSA LoopData segment, not a whole-clip - # loop. Zero leaves the resource-defined LoopData count intact. - if anim.has_method("set_motion_loop_count"): - anim.call("set_motion_loop_count", _skill_loop_count) - # Keep the 40250 OnUseSkill payload visible to the animation/effect bridge; - # the native player still owns clip timing, while callers can distinguish a - # finite loop override from an ordinary one-shot and moving-skill motion. - anim.set_meta("skill_loop_count", _skill_loop_count) - anim.set_meta("skill_moving", _skill_moving) - return true - -func skill_motion_state() -> Dictionary: - return {"loop_count": _skill_loop_count, "moving": _skill_moving} - -const AFFECT_EFFECT_MAP := { - 15: {"file": "d:/ymir work/pc/warrior/effect/geom_sword_loop.mse", "bone": "equip_right_hand"}, # AFFECT_GEOMGYEONG - 16: {"file": "d:/ymir work/pc/warrior/effect/gyeokgongjang_loop.mse", "bone": ""}, # AFFECT_CHEONGEUN - 17: {"file": "d:/ymir work/pc/assassin/effect/gyeonggong_loop.mse", "bone": ""}, # AFFECT_GYEONGGONG - 19: {"file": "d:/ymir work/pc/sura/effect/gwigeom_loop.mse", "bone": "Bip01 R Finger2"}, # AFFECT_GWIGEOM - 20: {"file": "d:/ymir work/pc/sura/effect/fear_loop.mse", "bone": ""}, # AFFECT_FEAR - 21: {"file": "d:/ymir work/pc/sura/effect/jumagap_loop.mse", "bone": ""}, # AFFECT_JUMAGAP - 22: {"file": "d:/ymir work/pc/shaman/effect/3hosin_loop.mse", "bone": ""}, # AFFECT_HOSIN - 23: {"file": "d:/ymir work/pc/shaman/effect/boho_loop.mse", "bone": ""}, # AFFECT_BOHO - 24: {"file": "d:/ymir work/pc/shaman/effect/10kwaesok_loop.mse", "bone": ""}, # AFFECT_KWAESOK - 25: {"file": "d:/ymir work/pc/sura/effect/heuksin_loop.mse", "bone": ""}, # AFFECT_HEUKSIN - 26: {"file": "d:/ymir work/pc/sura/effect/muyeong_loop.mse", "bone": ""}, # AFFECT_MUYEONG - 29: {"file": "d:/ymir work/pc/shaman/effect/6gicheon_hand.mse", "bone": "Bip01 R Hand"}, # AFFECT_GICHEON - 30: {"file": "d:/ymir work/pc/shaman/effect/jeungryeok_hand.mse", "bone": "Bip01 L Hand"}, # AFFECT_JEUNGRYEOK - 32: {"file": "d:/ymir work/pc/sura/effect/pabeop_loop.mse", "bone": "Bip01 Head"}, # AFFECT_PABEOP - 3: {"file": "d:/ymir work/effect/hit/blow_poison/poison_loop.mse", "bone": "Bip01"}, # AFFECT_POISON - 4: {"file": "d:/ymir work/effect/affect/slow.mse", "bone": ""}, # AFFECT_SLOW - 5: {"file": "d:/ymir work/effect/etc/stun/stun_loop.mse", "bone": "Bip01 Head"}, # AFFECT_STUN -} - -var _fx: RefCounted -var _affect_nodes: Dictionary = {} - -func set_fx(fx_registry: RefCounted) -> void: - _fx = fx_registry - -func set_affect(affect_type: int, visible: bool, fx_registry: RefCounted = null) -> void: - var reg: RefCounted = fx_registry if fx_registry != null else _fx - # InstanceBaseEffect.cpp::__Warrior_SetGeomgyeongAffect changes the actor's - # reach scale independently of whether the visual MSE can be spawned. The - # same value is consumed by both WeaponTrace::Update and attack collision. - if affect_type == 15: - _set_weapon_trace_reach_scale(1.5 if visible else 1.0) - if not visible: - if _affect_nodes.has(affect_type): - var n: Node = _affect_nodes[affect_type] - _affect_nodes.erase(affect_type) - if is_instance_valid(n): - n.queue_free() - return - - if _affect_nodes.has(affect_type) or reg == null: - return - if not AFFECT_EFFECT_MAP.has(affect_type): - return - var spec: Dictionary = AFFECT_EFFECT_MAP[affect_type] - var fx_name: String = String(spec.get("file", "")) - if affect_type == 15 and _motion_mode == 3: - fx_name = "d:/ymir work/pc/warrior/effect/geom_spear_loop.mse" - if fx_name == "": - return - var effect: Node = reg.call("spawn", fx_name, self, false) - if effect == null: - return - var bone: String = String(spec.get("bone", "")) - if bone != "": - var anchor := preload("res://fx/motion_effect_anchor.gd").new() - effect.add_child(anchor) - var ev := {"attaching": true, "following": true, "independent": false, "bone": bone, "pos": Vector3.ZERO} - if not anchor.configure(self, ev): - effect.queue_free() - return - _affect_nodes[affect_type] = effect - -func set_affect_flags(flags: int, fx_registry: RefCounted = null) -> void: - for t in AFFECT_EFFECT_MAP.keys(): - var active: bool = (flags & (1 << t)) != 0 - set_affect(t, active, fx_registry) - -func clear_affects() -> void: - for t in _affect_nodes.keys(): - var n: Node = _affect_nodes[t] - if is_instance_valid(n): - n.queue_free() - _affect_nodes.clear() - _set_weapon_trace_reach_scale(1.0) - -# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。 -func insert_delay(d: float) -> void: - _hit.insert_delay(d, anim) - -# CActorInstance::__HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp)。 -# scalar = dot(攻击方朝向, 受击方朝向):< 0 面对面 -> 正面受击动作。 -func hit_good(scalar: float, stunned: bool) -> void: - # ActorInstanceBattle::__HitGood returns while NAME_DAMAGE_FLYING is - # active; the semantic resume state is still "run"/"wait", so checking - # _state alone would incorrectly replace the knockdown chain with DAMAGE. - if _hit.current_step in HitReaction.KNOCKDOWN_STATES: - return - _hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction) - -func hit_greate(scalar: float, stunned: bool) -> void: - # __HitGreate has both IsKnockDown and __IsStandUpMotion early returns. - if _hit.current_step in HitReaction.KNOCKDOWN_STATES or \ - _hit.current_step in HitReaction.STANDUP_STATES: - return - _hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned) - -func hit_stone(stunned: bool) -> void: - _hit.stone(stunned) - -# CActorInstance::TEMP_Push large-correction tail: -# InterceptOnceMotion(DAMAGE_FLYING) + PushOnceMotion(STAND_UP). -func sync_push_motion() -> bool: - if anim == null: - return false - return _hit.start([["knockdown", "standup"], ["standup"]], _state, _bind_reaction) - -# 40250 CActorInstance::__Shake(100) -func shake() -> void: - _hit.shake() - -# 受击方 m_DefendingPointInstanceList 的来源:root/msm/_.msm 的 DEFENDING 球。 -func get_defending_spheres() -> Array: - var sex := "w" if _race in FEMALE_RACES else "m" - return HitCollision.parse_msm_defending( - _assets_root.path_join("root/msm/%s_%s.msm" % [CLASS_OF[_race & 3], sex])) - -func _on_playback_finished() -> void: - # §3.7 受击动作链:InterceptOnceMotion -> PushOnceMotion(STAND_UP…) -> PushLoopMotion。 - # 链尾回循环动作 —— net_play polls is_in_hit_reaction() to drop its knockback gate. - if _hit.active: - if _hit.advance(_bind_reaction) or _hit.real_dead: - return - _state = "" - set_anim_state(_hit.resume) - return - if _state in ["__motion", "__skill", "attack", "combo"]: - _skill_loop_count = 0 - _skill_moving = false - _state = "" - set_anim_state("wait") - motion_bound.emit("wait") - _ground_model() - -func reset_skill_motion() -> void: - if _state in ["__skill", "__motion"]: - _skill_loop_count = 0 - _skill_moving = false - _state = "" - set_anim_state("wait") - motion_bound.emit("wait") - _ground_model() - -# True while the one-shot hit/knockback chain is still playing. -func is_in_hit_reaction() -> bool: - return _hit.active - -func _bind_reaction(step: String) -> bool: - if anim == null or motion_dir == "": - return false - var msa := _pick_mode_msa(REACTION_FILES.get(step, []), int(REACTION_INDEX.get(step, -1))) - if msa == "": - return false - _bind(step, msa, false, 1.0) - return true - -func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void: - if not HitReaction.is_reaction(state): - _hit.cancel() - _state = state - # CActorInstance::__SetMotion calls __ShowEvent() after a valid motion key - # is resolved, before the new motion starts. - if _motion_hidden: - set_motion_hidden(false) - # 40250 __SetMotion always hides the old trace before binding a new motion; - # only a successfully bound attack motion shows it again below. - for trace in _weapon_traces: - if is_instance_valid(trace): - trace.turn_off() - anim.set("loop", loop) - # A repeated one-shot can name the same clip; clear first because the native - # player treats assigning the current anim_path as a no-op. - if not loop and String(anim.get("anim_path")) == path: - anim.set("anim_path", "") - anim.set("anim_path", path) - anim.set("playing", true) - anim.set("time_scale", _hit.scale_for(speed_ratio)) - _refresh_sound_script(path) - if state in ["attack", "combo"] and _weapon_trace_allowed: - _refresh_weapon_trace() - for trace in _weapon_traces: - if is_instance_valid(trace): - trace.turn_on() - motion_bound.emit(state) - -func _pick_msa(dir: String, names: Array) -> String: - var found: Array = [] - for n in names: - var p := dir.path_join(String(n) + ".msa") - if FileAccess.file_exists(p): - found.append(p) - return "" if found.is_empty() else String(found[randi() % found.size()]) - -func _motion_mode_for_dir(dir: String) -> int: - var name := String(dir).get_file() - for mode in MOTION_MODE_DIRS: - if String(MOTION_MODE_DIRS[mode]) == name: - return int(mode) - return 1 - -func _pick_registered_msa(dir: String, mode: int, index: int, fallback_names: Array) -> String: - if _motion_registry == null or index < 0: - return "" - var registered: Array = _motion_registry.variants(mode, index) - if registered.is_empty(): - return "" - var available: Array = [] - for entry in registered: - var name := String(entry.get("name", "")) - if name == "": - continue - var path := String(dir).path_join(name) - if FileAccess.file_exists(path): - available.append({"path": path, "weight": int(entry.get("weight", 100))}) - if available.is_empty(): - return "" - if available.size() == 1: - return String(available[0]["path"]) - # CActorInstance::GetRandomMotionKey uses random() % 100, subtracts - # byPercentage in registration order, and leaves sub-index 0 on overflow. - var percentage := randi() % 100 - for entry in available: - percentage -= int(entry["weight"]) - if percentage < 0: - return String(entry["path"]) - return String(available[0]["path"]) - -func _mode_motion_dir() -> String: - var dirs := _motion_dirs_for_mode(_motion_mode) - return String(dirs[0]) if not dirs.is_empty() else "" - -# 40250 CRaceData::GetMotionKey:先确认 mode 已注册,再按 motion index 查找。 -# 普通模式缺 key 回退 GENERAL;HORSE_* 武器模式只回退 HORSE,HORSE 也缺失时失败。 -# 这条顺序不能只用“具体目录 -> general”,否则骑马武器的未注册动作会错误 -# 使用普通姿态,掩盖 InterceptMotion/PushMotion 的失败分支。 -func _motion_dir_for_mode(mode: int) -> String: - if motion_dir == "": - return "" - var base := motion_dir.get_base_dir() - var path := base.path_join(String(MOTION_MODE_DIRS.get(mode, ""))) - return path if path != "" and DirAccess.dir_exists_absolute(path) else "" - -func _motion_dirs_for_motion(mode: int, index: int) -> Array[String]: - if _motion_registry == null or not _motion_registry.source_loaded: - return _motion_dirs_for_mode(mode) - if not _motion_registry.has_mode(mode): - return [] - var selected := mode - if not _motion_registry.has_variants(mode, index): - selected = 9 if mode >= 10 and mode <= 15 else 1 - if not _motion_registry.has_variants(selected, index): - return [] - var path := _motion_dir_for_mode(selected) - return [path] if path != "" else [] - -func _motion_dirs_for_mode(mode: int) -> Array[String]: - if motion_dir == "": - return [] - var dirs: Array[String] = [] - var base := motion_dir.get_base_dir() - var specific := base.path_join(String(MOTION_MODE_DIRS.get(mode, "general"))) - if DirAccess.dir_exists_absolute(specific): - dirs.append(specific) - if mode >= 10 and mode <= 15: - var horse := base.path_join(String(MOTION_MODE_DIRS[9])) - if DirAccess.dir_exists_absolute(horse) and not dirs.has(horse): - dirs.append(horse) - if not dirs.has(motion_dir): - dirs.append(motion_dir) - return dirs - -func _motion_file(state: String) -> String: - var index := int(MOTION_INDEX_BY_STATE.get(state, -1)) - var fallback_names: Array = [state] - if state == "damage": - fallback_names = ["damage", "damage_1"] - elif state == "damage_back": - fallback_names = ["damage_2", "damage_3"] - elif state == "knockdown": - fallback_names = ["damage_flying"] - elif state == "knockdown_back": - fallback_names = ["back_damage_flying"] - elif state == "standup": - fallback_names = ["falling_stand"] - elif state == "standup_back": - fallback_names = ["back_falling_stand"] - elif state in ["attack", "combo"]: - fallback_names = ["attack", "attack_1"] if state == "attack" else ["combo_01"] - for dir in _motion_dirs_for_motion(_motion_mode, index): - if dir == "": - continue - if index >= 0: - var registered := _pick_registered_msa(dir, _motion_mode_for_dir(dir), index, fallback_names) - if registered != "": - return registered - for ext in [".msa", ".gr2"]: - if _motion_registry != null and _motion_registry.source_loaded: - continue - var path: String = String(dir).path_join(String(fallback_names[0]) + String(ext)) - if FileAccess.file_exists(path): - return path - return "" - -func _pick_mode_msa(names: Array, motion_index: int = -1) -> String: - for dir in _motion_dirs_for_motion(_motion_mode, motion_index): - var picked := _pick_registered_msa(dir, _motion_mode_for_dir(dir), motion_index, names) - if picked == "" and (_motion_registry == null or not _motion_registry.source_loaded): - picked = _pick_msa(dir, names) - if picked != "": - return picked - return "" - -func _process(delta: float) -> void: - _hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess - if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \ - or not bool(anim.get("playing")): - return - var current_time: float = anim.call("get_time") - var frame := int(floor(current_time * 60.0)) - if frame == _sound_frame: - return - _sound_frame = frame - _audio.update_sound_instances_3d(global_position.x, global_position.y, global_position.z, - frame, _sound_instances, true) - -func _refresh_sound_script(motion_path: String) -> void: - _sound_instances = [] - _sound_frame = -1 - if _audio == null or motion_path == "" or not motion_path.to_lower().ends_with(".msa"): - return - if _audio.has_method("load_mss_for_motion"): - _sound_instances = _audio.load_mss_for_motion(motion_path) - -func _set(prop: StringName, val: Variant) -> bool: - if String(prop) in _forward and model: - # ActorInstanceAttach.cpp AttachWeapon:该职业没登记 PART_WEAPON_LEFT 骨骼就不挂左手 - if String(prop) == "shield_gr2" and _left_bone == "": - val = "" - model.set(prop, val) - if String(prop) == "gr2_path": - call_deferred("_ground_model") - if String(prop) == "weapon_gr2": - if String(val) == "": - _destroy_weapon_trace() - else: - call_deferred("_refresh_weapon_trace") - return true - return false - -func _refresh_weapon_trace() -> void: - if not _weapon_trace_allowed or not is_instance_valid(model): - return - # ActorInstanceAttach.cpp creates one CWeaponTrace for each attached weapon - # part. Dagger and mounted fan can populate both PART_WEAPON and - # PART_WEAPON_LEFT; keep the same part order and never collapse them into a - # single trace. - var weapons: Array[Node3D] = [] - for part_name in ["Weapon", "Shield"]: - var part := model.find_child(part_name, true, false) as Node3D - if part != null and is_instance_valid(part): - weapons.append(part) - if weapons.is_empty(): - _destroy_weapon_trace() - return - while _weapon_traces.size() > weapons.size(): - var extra: Node = _weapon_traces.pop_back() - if is_instance_valid(extra): - extra.detach_weapon() - extra.free() - while _weapon_traces.size() < weapons.size(): - var trace: Node = WeaponTrace.new() - trace.name = "WeaponTrace_%d" % _weapon_traces.size() - add_child(trace) - _weapon_traces.append(trace) - for i in weapons.size(): - var trace: Node = _weapon_traces[i] - trace.attach_to_weapon(weapons[i]) - trace.set_reach_scale(_weapon_trace_reach_scale) - _weapon_trace = _weapon_traces[0] - -func _destroy_weapon_trace() -> void: - for trace in _weapon_traces: - if is_instance_valid(trace): - trace.detach_weapon() - trace.free() - _weapon_traces.clear() - _weapon_trace = null - -# EquipModel operates on the outer PlayerView, while the material palette lives -# on the native Metin2Model child. Keep these as explicit methods so MSM -# SourceSkin -> TargetSkin remaps reach both character select and the game. -func set_skin_texture(source: String, target: String) -> void: - if model and model.has_method("set_skin_texture"): - model.call("set_skin_texture", source, target) - -func clear_skin_textures() -> void: - if model and model.has_method("clear_skin_textures"): - model.call("clear_skin_textures") - -func _ground_model() -> void: - if model and model.has_method("get_ground_offset"): - model.position.y = float(model.call("get_ground_offset")) - -# --- 路径解析 ------------------------------------------------------------- - -func _resolve_dir(assets_root: String, rel: String) -> String: - var d := assets_root.path_join(rel) - if DirAccess.dir_exists_absolute(d): - return d - var da := DirAccess.open(assets_root) - if da: - for sub in da.get_directories(): - var c := assets_root.path_join(sub).path_join(rel) - if DirAccess.dir_exists_absolute(c): - return c - return "" - -func _first_existing(paths: Array) -> String: - for p: String in paths: - if FileAccess.file_exists(p): - return p - return "" diff --git a/project/ui/player_view.gd.uid b/project/ui/player_view.gd.uid deleted file mode 100644 index db2a8ddd..00000000 --- a/project/ui/player_view.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://nm24uhdtjn4d diff --git a/project/ui/private_shop_ui.gd b/project/ui/private_shop_ui.gd deleted file mode 100644 index c5623996..00000000 --- a/project/ui/private_shop_ui.gd +++ /dev/null @@ -1,529 +0,0 @@ -# PrivateShopUI —— 개인상점 개설창 (CG_MYSHOP).1:1 迁移 `assets/root/uiprivateshopbuilder.py` -# 的 `PrivateShopBuilder`(布局走真 `assets/uiscript/uiscript/privateshopbuilder.py`)。 -# -# var pu := preload("res://ui/private_shop_ui.gd").new() -# add_child(pu) -# pu.setup(m2client, ui_manager, proto, assets_root) -# pu.toggle() -# -# 布局:`privateshopbuilder.py`(board + TitleBar + NameLine + ItemSlot 5×8=40 grid + Ok/Close)。 -# 交互 = uiprivateshopbuilder.itemStock:左侧(挂在窗右边的附加面板)列背包候选,点一件 -# “拿起”,再点右侧 40 格的空格 → 价格输入(`uiCommon.MoneyInputDialog`)→ 落位 -# (`AddPrivateShopItemStock` + `itemStock[targetSlot]=(src)`);点已占用格 → 撤下 -# (`OnSelectItemSlot` / `DelPrivateShopItemStock`)。OkButton(`BuildPrivateShop`):stock 按 -# 格号排序、`display_pos = 格号`、上限 39(`PRIVATE_SHOP_ITEM_MAX_NUM`)打包成 -# `M2Client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])` -# (= `TPacketCGMyShop` + `TShopItemTable`×N)。40250 收摊用空的 `CG_MYSHOP` -# (服务端 `OpenMyShop` 会先关闭已有的个人摊位);`close_private_shop()` 已在 -# M2Client classic 分支按该语义发送。 -extends Node - -const ItemTooltip = preload("res://ui/item_tooltip.gd") -const ItemTooltipBridge = preload("res://ui/item_tooltip_bridge.gd") - -class PrivateShopDragButton extends Button: - var drag_entry: Dictionary = {} - - func _get_drag_data(_at_position: Vector2): - if drag_entry.is_empty(): - return null - var preview := Label.new() - preview.text = String(text) - preview.add_theme_color_override("font_color", Color(1, 0.9, 0.55)) - set_drag_preview(preview) - return {"private_shop_entry": drag_entry.duplicate(true)} - -const PRIVATE_SHOP_ITEM_MAX := 39 # PRIVATE_SHOP_ITEM_MAX_NUM (TPacketCGMyShop::bCount 上限) -const SIGN_MAX := 25 # NameLine input_limit -const ITEM_ANTIFLAG_GIVE := 1 << 13 -const ITEM_ANTIFLAG_MYSHOP := 1 << 16 - -var client: Node -var ui: CanvasLayer # UiManager -var proto: Node -var _tooltip_builder: RefCounted -var _tooltip_bridge: RefCounted -var assets_root := "" -var uiscript_dir := "" - -var _win: Dictionary = {} -var _sign: LineEdit -var _inv_list: VBoxContainer -var _cells: Dictionary = {} # 格号:int -> Panel -var _status: Label -var _stock: Dictionary = {} # 格号:int -> {cell, vnum, count, price} -var _picked = null # {cell, vnum, count} 等待落位 -var _price_dialog: Control = null -var _server_prices: Dictionary = {} # vnum -> last price returned by MyShopPriceList -var item_mouse: Node -const INVENTORY_WINDOW := 1 -var _mobile_mode := false - -func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, assets := "") -> void: - client = m2client - ui = ui_manager - proto = proto_node - assets_root = assets - if assets_root == "" and ui and "assets_root" in ui: - assets_root = ui.assets_root - _tooltip_builder = ItemTooltip.new() - _tooltip_builder.setup(assets_root if assets_root != "" else AssetRoot.path(), "en", proto) - _tooltip_bridge = ItemTooltipBridge.new() - _tooltip_bridge.setup(ui, proto, assets_root if assets_root != "" else AssetRoot.path()) - uiscript_dir = assets_root.path_join("uiscript/uiscript") - if not DirAccess.dir_exists_absolute(uiscript_dir): - uiscript_dir = assets_root.path_join("uiscript") - if client and client.has_signal("inventory_changed"): - client.inventory_changed.connect(func(_w, _c): if is_open(): _refresh()) - if client and client.has_signal("my_shop_price_list"): - client.my_shop_price_list.connect(_on_server_price) - if client and client.has_signal("private_shop_open_requested"): - client.private_shop_open_requested.connect(open) - -# --- open / close -------------------------------------------------- - -func is_open() -> bool: - return not _win.is_empty() and is_instance_valid(_win.get("root")) - -func get_mobile_window() -> Control: - return _win.get("root") if not _win.is_empty() else null - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - if is_open(): - _add_mobile_width(_win["root"]) - _refresh() - -func toggle() -> void: - if is_open(): close() - else: open() - -func close() -> void: - if _tooltip_bridge: - _tooltip_bridge.hide() - _dismiss_price_dialog() - if item_mouse and item_mouse.has_method("unregister_owner"): - item_mouse.unregister_owner(self) - if item_mouse.has_method("is_attached") and item_mouse.is_attached(): - item_mouse.cancel() - if is_open(): - var root: Control = _win["root"] - # An external mobile window is owned by UiManager's presentation stack; - # calling ui.close() here would call this method again through its close - # callback. Hide it and let UiManager restore the original parent. - if _mobile_mode and bool(root.get_meta("mobile_external", false)): - root.visible = false - else: - ui.close(root) - _win = {} - _cells.clear() - _stock.clear() - _picked = null - -func open() -> void: - if is_open(): - return - var path := uiscript_dir.path_join("privateshopbuilder.py") - if not FileAccess.file_exists(path): - push_warning("PrivateShopUI: no privateshopbuilder.py at " + path) - return - _stock.clear() - _picked = null - _win = ui.open_script(path, assets_root) - if not is_open(): - return - var root: Control = _win["root"] - _index_cells(root) - _overlay_name_edit(root) - _add_inv_panel(root) - if _mobile_mode and ui and ui.has_method("reflow_mobile_window"): - ui.reflow_mobile_window(root) - _wire_buttons(root) - _refresh() - -func _node(nm: String) -> Control: - if _win.is_empty(): - return null - var n = _win.get("nodes", {}).get(nm, null) - return n if n is Control else null - -func _index_cells(root: Node) -> void: - _cells.clear() - for n in root.find_children("slot_*", "Panel", true, false): - if not n.has_meta("slot_index"): - continue - var slot := int(n.get_meta("slot_index")) - _cells[slot] = n - if _tooltip_bridge: - n.mouse_entered.connect(func(): _tooltip_bridge.show_item(n.get_meta("tooltip_item", {}))) - n.mouse_exited.connect(func(): _tooltip_bridge.hide()) - var idx := slot - n.gui_input.connect(func(e: InputEvent) -> void: - if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed: - _on_slot_clicked(idx) - elif e is InputEventScreenTouch and e.pressed: - _on_slot_clicked(idx)) - # 同时支持从右侧背包候选区拖到空货位;点击选取仍保留给触屏/无鼠标场景。 - n.set_drag_forwarding(Callable(self, "_no_drag_data"), - Callable(self, "_can_drop_slot").bind(idx), - Callable(self, "_drop_slot").bind(idx)) - if item_mouse and item_mouse.has_method("register_target"): - item_mouse.register_target(n, - func(payload: Dictionary): return _drop_mouse_item(payload, idx), self) - -func _drop_mouse_item(payload: Dictionary, slot: int) -> bool: - if payload.is_empty() or slot < 0 or slot >= 40 or _stock.has(slot): - return false - if int(payload.get("window", -1)) != INVENTORY_WINDOW: - return false - var cell := int(payload.get("cell", -1)) - var vnum := int(payload.get("vnum", 0)) - if cell < 0 or vnum <= 0: - return false - var entry := payload.duplicate(true) - entry["cell"] = cell - entry["vnum"] = vnum - entry["count"] = maxi(1, int(payload.get("count", 1))) - if not _can_stock(entry): - return false - _picked = entry - _ask_price(slot) - return true - -# NameLine 在 uiscript 里是 text(Label)—— 盖一个 LineEdit 上去 -func _overlay_name_edit(root: Control) -> void: - var name_line := _node("NameLine") - _sign = LineEdit.new() - _sign.max_length = SIGN_MAX - _sign.placeholder_text = "상점 이름" - if name_line: - name_line.visible = false - var slot := name_line.get_parent() - _sign.position = name_line.position - _sign.size = Vector2(maxf(name_line.size.x, 150), 18) - (slot if slot is Control else root).add_child(_sign) - else: - _sign.position = Vector2(13, 35) - _sign.size = Vector2(157, 18) - root.add_child(_sign) - -func _wire_buttons(root: Control) -> void: - var ok := _node("OkButton") - if ok is BaseButton: - ok.pressed.connect(_ok) - var cl := _node("CloseButton") - if cl is BaseButton: - cl.pressed.connect(close) - var tb := _node("TitleBar") - if tb: - for b in tb.find_children("*", "BaseButton", true, false): - b.pressed.connect(close) - -# 원 클라는 메인 인벤토리에서 드래그 —— 여기선 창 오른쪽에 후보 패널을 붙임 -func _add_inv_panel(root: Control) -> void: - var panel := Panel.new() - panel.name = "inv_candidates" - panel.position = Vector2(root.size.x + 6, 0) - panel.size = Vector2(220, root.size.y) - var bg := StyleBoxFlat.new() - bg.bg_color = Color(0.06, 0.06, 0.09, 0.95) - bg.border_color = Color(0.4, 0.36, 0.24, 0.9) - bg.set_border_width_all(1) - panel.add_theme_stylebox_override("panel", bg) - root.add_child(panel) - _add_mobile_width(root) - var title := Label.new() - title.text = "배낭 (클릭 → 집기)" - title.position = Vector2(10, 8) - title.add_theme_font_size_override("font_size", 12) - panel.add_child(title) - var sc := ScrollContainer.new() - sc.position = Vector2(8, 30) - sc.size = Vector2(204, root.size.y - 66) - panel.add_child(sc) - _inv_list = VBoxContainer.new() - _inv_list.add_theme_constant_override("separation", 3) - sc.add_child(_inv_list) - _status = Label.new() - _status.position = Vector2(10, root.size.y - 30) - _status.add_theme_font_size_override("font_size", 11) - _status.add_theme_color_override("font_color", Color(1, 0.9, 0.5)) - panel.add_child(_status) - -func _add_mobile_width(root: Control) -> void: - if not _mobile_mode or root == null: - return - # Keep the candidate list inside the hosted/scaled root instead of leaving - # it as an overflow panel outside the mobile safe area. - root.size.x = maxf(root.size.x, 674.0) - -# --- 명칭 -------------------------------------------------------------- - -func _name_of(vnum: int) -> String: - if proto and proto.has_method("item"): - var d: Dictionary = proto.item(vnum) - var n := String(d.get("locale_name", d.get("name", ""))) - if n != "": - return n - return "#%d" % vnum - -# --- 새로고침 -------------------------------------------------------- - -func _refresh() -> void: - _refresh_inv() - _refresh_grid() - -func _refresh_inv() -> void: - if not is_instance_valid(_inv_list): - return - for c in _inv_list.get_children(): - _inv_list.remove_child(c) - c.queue_free() - var used := {} - for s in _stock.values(): - used[int(s["cell"])] = true - var inv: Array = client.get_inventory() if client and client.has_method("get_inventory") else [] - var shown := 0 - for it in inv: - var cell := int(it.get("cell", 0)) - if used.has(cell): - continue - var vnum := int(it.get("vnum", 0)) - var count := int(it.get("count", 1)) - var b := PrivateShopDragButton.new() - b.text = "%s ×%d" % [_name_of(vnum), count] - if _tooltip_bridge: - _tooltip_bridge.bind(b, func() -> Dictionary: return it.duplicate(true)) - b.alignment = HORIZONTAL_ALIGNMENT_LEFT - b.custom_minimum_size = Vector2(196, 22) - b.toggle_mode = true - var entry: Dictionary = it.duplicate(true) - entry["cell"] = cell - entry["vnum"] = vnum - entry["count"] = count - entry["anti_flags"] = int(it.get("anti_flags", 0)) - b.drag_entry = entry - b.pressed.connect(func() -> void: _pick(entry, b)) - if _picked != null and int(_picked["cell"]) == cell: - b.button_pressed = true - _inv_list.add_child(b) - shown += 1 - if shown == 0: - var l := Label.new() - l.text = "(배낭이 비어 있음)" - l.add_theme_font_size_override("font_size", 12) - _inv_list.add_child(l) - -func _refresh_grid() -> void: - for slot in _cells.keys(): - var cell: Panel = _cells[slot] - for ch in cell.get_children(): - cell.remove_child(ch) - ch.queue_free() - cell.tooltip_text = "" - cell.set_meta("tooltip_item", {}) - if _stock.has(slot): - var s: Dictionary = _stock[slot] - var nm := Label.new() - nm.text = _name_of(int(s["vnum"])).substr(0, 4) - nm.add_theme_font_size_override("font_size", 8) - nm.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(nm) - var pr := Label.new() - pr.text = str(int(s["price"])) - pr.position = Vector2(1, 18) - pr.add_theme_font_size_override("font_size", 8) - pr.add_theme_color_override("font_color", Color(0.95, 0.85, 0.5)) - pr.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(pr) - var tooltip_item := s.duplicate(true) - tooltip_item["count"] = int(s["count"]) - cell.set_meta("tooltip_item", tooltip_item) - cell.modulate = Color(1, 1, 1) - else: - cell.modulate = Color(0.72, 0.72, 0.82) if _picked != null else Color(1, 1, 1) - -# --- 상호작용 -------------------------------------------------------- - -func _pick(entry: Dictionary, _btn: Button) -> void: - if not _can_stock(entry): - return - if _picked != null and int(_picked["cell"]) == int(entry["cell"]): - _picked = null - else: - _picked = entry - _refresh_grid() - -func _no_drag_data(_at_position: Vector2): - return null - -func _can_drop_slot(_at_position: Vector2, data, slot: int) -> bool: - return slot >= 0 and slot < 40 and not _stock.has(slot) \ - and data is Dictionary and data.has("private_shop_entry") - -func _drop_slot(_at_position: Vector2, data, slot: int) -> void: - if not _can_drop_slot(Vector2.ZERO, data, slot): - return - var entry: Dictionary = data["private_shop_entry"] as Dictionary - if not _can_stock(entry): - return - _picked = entry.duplicate(true) - _ask_price(slot) - -func _source_item(cell: int) -> Dictionary: - if client == null: - return {} - if client.has_method("get_item"): - var direct: Dictionary = client.get_item(INVENTORY_WINDOW, cell) - if not direct.is_empty(): - return direct - if client.has_method("get_inventory"): - for item in client.get_inventory(): - if int(item.get("cell", -1)) == cell: - return item - return {} - -func _can_stock(entry: Dictionary) -> bool: - var item_data := _source_item(int(entry.get("cell", -1))) - var vnum := int(entry.get("vnum", item_data.get("vnum", 0))) - var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} - var flags := int(entry.get("anti_flags", 0)) | int(item_data.get("anti_flags", 0)) \ - | int(proto_data.get("anti_flags", 0)) - if (flags & (ITEM_ANTIFLAG_GIVE | ITEM_ANTIFLAG_MYSHOP)) != 0: - if is_instance_valid(_status): - _status.text = "该物品不可摆摊" - return false - return true - -func _on_slot_clicked(slot: int) -> void: - if _stock.has(slot): - _stock.erase(slot) - if is_instance_valid(_status): - _status.text = "" - _refresh() - return - if _picked == null: - return - _ask_price(slot) - -func _ask_price(slot: int) -> void: - _dismiss_price_dialog() - var dlg := Panel.new() - dlg.set_anchors_preset(Control.PRESET_TOP_LEFT) - dlg.size = Vector2(240, 128) - dlg.position = (_win["root"].size - dlg.size) * 0.5 - dlg.z_index = 200 - dlg.mouse_filter = Control.MOUSE_FILTER_STOP - dlg.set_meta("mobile_modal", _mobile_mode) - # UiManager closes mobile modals before the feature window itself. Keep the - # controller's reference in sync when Android back/ESC hides this custom - # panel instead of going through the Cancel button. - dlg.set_meta("mobile_modal_close", Callable(self, "_dismiss_price_dialog")) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.06, 0.06, 0.09, 0.98) - sb.border_color = Color(0.7, 0.55, 0.22, 0.9) - sb.set_border_width_all(1) - dlg.add_theme_stylebox_override("panel", sb) - var t := Label.new() - t.text = "판매 가격" - t.position = Vector2(12, 10) - dlg.add_child(t) - var default_price := int(_server_prices.get(int(_picked["vnum"]), 1)) - if _stock.has(slot): - default_price = int(_stock[slot].get("price", default_price)) - var price_input: Control - if _mobile_mode: - var edit := LineEdit.new() - edit.name = "PriceInput" - edit.text = str(default_price) - edit.placeholder_text = "输入价格" - edit.max_length = 10 - edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER - edit.position = Vector2(12, 40) - edit.size = Vector2(216, 32) - edit.text_submitted.connect(func(_text: String) -> void: - _place(slot, _parse_price(edit))) - price_input = edit - else: - var spin := SpinBox.new() - spin.min_value = 1 - spin.max_value = 2000000000 - spin.step = 1 - spin.value = default_price - spin.position = Vector2(12, 40) - spin.size = Vector2(216, 28) - price_input = spin - dlg.add_child(price_input) - var ok := Button.new() - ok.text = "확인" - ok.position = Vector2(60, 86) - ok.pressed.connect(func() -> void: - _place(slot, _parse_price(price_input))) - dlg.add_child(ok) - var cancel := Button.new() - cancel.text = "취소" - cancel.position = Vector2(130, 86) - cancel.pressed.connect(_dismiss_price_dialog) - dlg.add_child(cancel) - _win["root"].add_child(dlg) - _price_dialog = dlg - -func _parse_price(input: Control) -> int: - if input is SpinBox: - return int((input as SpinBox).value) - if input is LineEdit: - var raw := (input as LineEdit).text.strip_edges() - return int(raw) if raw.is_valid_int() else 0 - return 0 - -func _dismiss_price_dialog() -> void: - if is_instance_valid(_price_dialog): - _price_dialog.queue_free() - _price_dialog = null - -func _place(slot: int, price: int) -> void: - if slot < 0 or slot >= 40 or price <= 0 or price > 2000000000 or _picked == null: - _dismiss_price_dialog() - return - if not _can_stock(_picked): - _dismiss_price_dialog() - _picked = null - return - _stock[slot] = {"cell": int(_picked["cell"]), "vnum": int(_picked["vnum"]), - "count": int(_picked["count"]), "price": price} - _picked = null - _dismiss_price_dialog() - _refresh() - -func _on_server_price(vnum: int, price: int) -> void: - if vnum > 0 and price >= 0: - _server_prices[vnum] = price - -func _ok() -> void: - var sign := _sign.text.strip_edges() - if sign.is_empty() or sign.length() > SIGN_MAX: - if is_instance_valid(_status): - _status.text = "请输入 1~%d 个字符的摊位名称" % SIGN_MAX - return - if _stock.is_empty(): - if is_instance_valid(_status): - _status.text = "판매할 아이템을 올리세요" - return - var slots := _stock.keys() - slots.sort() - var items: Array = [] - for slot in slots: - if items.size() >= PRIVATE_SHOP_ITEM_MAX: - break - var s: Dictionary = _stock[slot] - items.append({"vnum": s["vnum"], "count": s["count"], "inv_cell": s["cell"], - "price": s["price"], "display_pos": slot}) - if client and client.has_method("open_private_shop"): - if client.open_private_shop(sign, items): - close() - elif is_instance_valid(_status): - _status.text = "摆摊请求发送失败" - -func _close_shop() -> void: - if client and client.has_method("close_private_shop"): - client.close_private_shop() diff --git a/project/ui/private_shop_ui.gd.uid b/project/ui/private_shop_ui.gd.uid deleted file mode 100644 index 372fa3ca..00000000 --- a/project/ui/private_shop_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://3gl3s2r0g74h diff --git a/project/ui/quest_curtain.gd b/project/ui/quest_curtain.gd deleted file mode 100644 index a0bbe67c..00000000 --- a/project/ui/quest_curtain.gd +++ /dev/null @@ -1,94 +0,0 @@ -# QuestCurtain (40250 uiquest.py:58-105 对齐) -# 剧情 / 过场运镜模式的 16:9 电影上下黑边(Curtain / Letterbox) -class_name QuestCurtain -extends Control - -const CURTAIN_SPEED := 200.0 -const MIN_BAR_HEIGHT := 50.0 - -var top_bar: ColorRect -var bottom_bar: ColorRect -var _bar_height := 60.0 -var _is_open := false -var _tween: Tween - -func _init() -> void: - name = "QuestCurtain" - set_anchors_preset(PRESET_FULL_RECT) - mouse_filter = MOUSE_FILTER_IGNORE - z_index = 95 - - top_bar = ColorRect.new() - top_bar.name = "TopBar" - top_bar.color = Color.BLACK - top_bar.mouse_filter = MOUSE_FILTER_IGNORE - add_child(top_bar) - - bottom_bar = ColorRect.new() - bottom_bar.name = "BottomBar" - bottom_bar.color = Color.BLACK - bottom_bar.mouse_filter = MOUSE_FILTER_IGNORE - add_child(bottom_bar) - - _reset_layout() - -func _ready() -> void: - get_viewport().size_changed.connect(_on_size_changed) - _reset_layout() - -func _get_screen_size() -> Vector2: - if is_inside_tree() and get_viewport(): - return get_viewport_rect().size - return Vector2(1024, 768) - -func _on_size_changed() -> void: - if _is_open: - _calculate_bar_height() - var sz := _get_screen_size() - top_bar.position = Vector2(0, 0) - top_bar.size = Vector2(sz.x, _bar_height) - bottom_bar.position = Vector2(0, sz.y - _bar_height) - bottom_bar.size = Vector2(sz.x, _bar_height) - else: - _reset_layout() - -func _calculate_bar_height() -> float: - var sz := _get_screen_size() - # 40250: (GetScreenHeight() - GetScreenWidth() * 9 // 16) // 2 - var h: float = floorf((sz.y - sz.x * 9.0 / 16.0) / 2.0) - if h < MIN_BAR_HEIGHT: - h = MIN_BAR_HEIGHT - _bar_height = h - return _bar_height - -func _reset_layout() -> void: - var sz := _get_screen_size() - _calculate_bar_height() - top_bar.position = Vector2(0, -_bar_height - 1.0) - top_bar.size = Vector2(sz.x, _bar_height) - bottom_bar.position = Vector2(0, sz.y + 1.0) - bottom_bar.size = Vector2(sz.x, _bar_height) - -func open(duration: float = 0.4) -> void: - _is_open = true - var sz := _get_screen_size() - _calculate_bar_height() - if _tween and _tween.is_valid(): - _tween.kill() - _tween = create_tween().set_parallel(true).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) - top_bar.size = Vector2(sz.x, _bar_height) - bottom_bar.size = Vector2(sz.x, _bar_height) - _tween.tween_property(top_bar, "position:y", 0.0, duration) - _tween.tween_property(bottom_bar, "position:y", sz.y - _bar_height, duration) - -func close(duration: float = 0.4) -> void: - _is_open = false - var sz := _get_screen_size() - if _tween and _tween.is_valid(): - _tween.kill() - _tween = create_tween().set_parallel(true).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) - _tween.tween_property(top_bar, "position:y", -_bar_height - 1.0, duration) - _tween.tween_property(bottom_bar, "position:y", sz.y + 1.0, duration) - -func is_open() -> bool: - return _is_open diff --git a/project/ui/quest_curtain.gd.uid b/project/ui/quest_curtain.gd.uid deleted file mode 100644 index bf253fa5..00000000 --- a/project/ui/quest_curtain.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ds2h7uklxv4vs diff --git a/project/ui/quest_dialog.gd b/project/ui/quest_dialog.gd deleted file mode 100644 index be28076f..00000000 --- a/project/ui/quest_dialog.gd +++ /dev/null @@ -1,1314 +0,0 @@ -# QuestDialog (P7) —— 渲染 GC_SCRIPT 的 NPC 对话(EventManager 脚本,客户端只渲染)。 -# -# var qd := preload("res://ui/quest_dialog.gd").new() -# add_child(qd) -# qd.setup(m2client, canvas_parent) -# -# 脚本格式(`CPythonEventManager`):纯文本 + `[命令 arg(值)]` token 交错。 -# [ENTER] 换行 -# [CLEAR] 清空 -# [NEXT] 「继续」按钮 -> M2Client.script_answer(254) -# [DONE] 「关闭」按钮 -> CloseSelf only; DoneEvent fires while parsing -# [QUESTION arg("是") arg("否")] 选项按钮 -> script_answer(0..N-1) -# [LETTER value("...")] 追加文本(旧写法) -# [INPUT] 显示文本输入框,提交 -> M2Client.quest_input() -# [SELECT_ITEM] 要求从背包选一颗魔石 -> 发 select_item_requested 信号(打开 SelectItemUI) -# [CONFIRM_WAIT] 等待:取消按钮按 BUTTON_TYPE_CANCEL 发送 script_answer(254) -# -# quest_confirm_ask(GC_QUEST_CONFIRM)也在这里弹 是/否 -> quest_confirm(yes, pid)。 -extends Node - -const UiAssets = preload("res://ui/ui_assets.gd") -const UiKit = preload("res://ui_kit.gd") - -signal opened() -signal closed() -# EventManager EVENT_TYPE_SELECT_ITEM —— 对应 interfacemodule.BINARY_OpenSelectItemWindow -signal select_item_requested() -# EventManager side effects that are owned by the interface layer in ClientVS22. -# Keeping them as signals makes the dialog usable with both the real client and -# headless fixtures without smuggling minimap/camera state into the parser. -signal quest_button_received(index: int, title: String, icon_type: String, icon_name: String) -signal quest_button_cleared(index: int) -signal map_signal_added(x: float, y: float) -signal map_signals_cleared() -signal atlas_center_requested(x: int, y: int) -signal camera_event_requested(kind: String, values: Array) -signal fade_event_requested(kind: String, speed: float) -signal camera_event_requested_full(kind: String, setting: Dictionary, blendtime: float) -signal fade_event_requested_full(kind: String, speed: float) -signal done_event() - -const MAX_CHOICES_PER_PAGE := 8 - -var client: Node -var proto: Node -var _assets_root := "" -var _root: Control -var _panel: Control -var _text: RichTextLabel -var _btnrow: VBoxContainer -var _image_layer: Control -var _confirm_pid := 0 -var _confirm_wait := false -var _active_set -var _active_commands: Array = [] -var _active_cursor := 0 -var _active_script := false -var _current_skin := 0 -var _current_left_w := 0.0 -var _current_choice_page := 0 -var _cached_choices: Array = [] - -signal dungeon_result_requested(result: Dictionary) - -func setup(m2client: Node, parent: Node, proto_node: Node = null, assets_root := "") -> void: - client = m2client - proto = proto_node - _assets_root = assets_root if assets_root != "" else AssetRoot.path() - set_process_unhandled_input(true) - set_process(true) - _build(parent) - if client.has_signal("script_dialog"): - client.script_dialog.connect(_on_script) - if client.has_signal("quest_confirm_ask"): - client.quest_confirm_ask.connect(_on_confirm) - -func set_proto(proto_node: Node) -> void: - # Proto is created after the dialog in GameScene because item/mob loading is - # expensive. Keep the dialog usable before that point, then resolve future - # [ITEM]/[MOB] tags against the real tables. - proto = proto_node - -func is_open() -> bool: - return _root != null and _root.visible - -func _process(delta: float) -> void: - if not _active_script or _active_set == null: - return - # The first headless frame may report a zero delta. ClientVS22 still runs - # one UpdateEventSet tick, so use one nominal 60 FPS frame as the floor. - _process_active(maxf(delta * 1000.0, 16.0)) - -# Deterministic entry point used by the frame loop and headless tests. The -# argument is milliseconds because EventManager's lLastDelayTime/lWaitingTime -# are millisecond values in ClientVS22. -func process_events(elapsed_ms: float) -> void: - if _active_script and _active_set != null: - _process_active(maxf(0.0, elapsed_ms)) - -func begin_script(src: String) -> void: - _active_set = EventSet.new() - _active_set.init_event_set() - _active_commands = _tokenize(src) - _active_cursor = 0 - _active_script = true - # RegisterEventSetFromString replaces a leading RUN_CINEMA event set with - # the referenced .msc file. A missing file makes registration fail in - # 40250; RecvScriptPacket then does not call OnScriptEventStart and must not - # leave an empty, locked quest window behind. - if not _active_commands.is_empty() and String(_active_commands[0].get("literal", "")) == "" \ - and String(_active_commands[0].get("name", "")).to_upper().trim_prefix("/") == "RUN_CINEMA": - var cinema := _arg_s(String(_active_commands[0].get("tok", "")), "value") - var cinema_text := _read_script_file(cinema) if cinema != "" else "" - if cinema_text == "": - _finish_runtime() - _root.visible = false - return - cinema_requested.emit(cinema) - _active_commands = _tokenize(cinema_text) - _present_event_set(_active_set) - _root.visible = true - opened.emit() - # Start the same nominal first tick immediately. This keeps the dialog - # responsive even when a SceneTree caller resumes before child _process() - # callbacks (notably in headless fixtures). - _process_active(16.0) - -func _read_script_file(vpath: String) -> String: - var rel := vpath.replace("\\", "/") - if rel.length() >= 2 and rel[1] == ":": - rel = rel.substr(2) - rel = rel.lstrip("/") - var candidates: Array[String] = [] - if _assets_root != "": - candidates.append(_assets_root.path_join(rel)) - var resolved := UiAssets._resolve(_assets_root, rel) - if resolved != "": - candidates.append(resolved) - for path in candidates: - if FileAccess.file_exists(path): - return FileAccess.get_file_as_string(path) - return "" - -func skip() -> void: - if not _active_script or _active_set == null: - return - # CPythonEventManager::Skip: an input lock is released; otherwise the - # current delay is forced to expire and the next command is consumed. - if _active_set.isLock: - _active_set.lLastDelayTime = 0 - _active_set.isLock = false - else: - _active_set.lLastDelayTime = -1 - _process_active(0.0) - -func end_event_process() -> void: - if _active_set != null: - _active_set.isWaitFlag = false - -func _process_active(elapsed_ms: float) -> void: - var remaining := elapsed_ms - var forced_step: bool = _active_set.lLastDelayTime < 0 - if forced_step: - _active_set.lLastDelayTime = 0 - while _active_script and _active_set != null: - if _active_set.isConfirmWait: - if int(Time.get_ticks_msec() / 1000) >= _active_set.iConfirmEndTime: - _active_set.isConfirmWait = false - _active_set.iConfirmEndTime = 0 - _confirm_wait = false - if client and client.has_method("quest_cancel"): - client.quest_cancel() - _active_script = false - close() - else: - _present_event_set(_active_set) - return - if _active_set.isWaitFlag or _active_set.isLock: - _present_event_set(_active_set) - return - if _active_set.lLastDelayTime > 0: - if remaining <= 0.0: - return - var wait := float(_active_set.lLastDelayTime) - if remaining < wait: - _active_set.lLastDelayTime -= int(remaining) - return - _active_set.lLastDelayTime = 0 - remaining -= wait - if _active_cursor >= _active_commands.size(): - _active_script = false - _active_set.isLock = true - _present_event_set(_active_set) - return - var command: Dictionary = _active_commands[_active_cursor] - _active_cursor += 1 - _process_event_set(_active_set, command) - _emit_runtime_side_effect(command, _active_set) - _present_event_set(_active_set) - forced_step = false - if _active_set.isWaitFlag or _active_set.isLock or _active_set.isConfirmWait: - return - if remaining <= 0.0: - return - -func _finish_runtime() -> void: - _active_script = false - _active_commands.clear() - _active_cursor = 0 - -func _emit_runtime_side_effect(command: Dictionary, es: EventSet) -> void: - var idx := _get_script_event_index(String(command.get("name", ""))) - if not idx.ok: - return - var tok := String(command.get("tok", "")) - match int(idx.type): - EVT_SELECT_ITEM: - select_item_requested.emit() - EVT_QUEST_BUTTON: - if not es.quest_buttons.is_empty(): - var q: Dictionary = es.quest_buttons.back() - quest_button_received.emit(int(q.get("index", -1)), String(q.get("title", "")), - String(q.get("icon_type", "")), String(q.get("icon_name", ""))) - EVT_QUEST_BUTTON_CLOSE: - quest_button_cleared.emit(_arg_i(tok, "idx", 0)) - EVT_ADD_MAP_SIGNAL: - map_signal_added.emit(_arg_f(tok, "x", 0), _arg_f(tok, "y", 1)) - EVT_CLEAR_MAP_SIGNAL: - map_signals_cleared.emit() - EVT_SET_CENTER_MAP_POSITION: - atlas_center_requested.emit(_arg_i(tok, "x", 0), _arg_i(tok, "y", 1)) - EVT_SET_CAMERA, EVT_BLEND_CAMERA, EVT_RESTORE_CAMERA: - var camera: Dictionary = es.camera_events.back() if not es.camera_events.is_empty() else {} - var kind := String(camera.get("kind", "")) - camera_event_requested.emit(kind, camera.get("values", [])) - camera_event_requested_full.emit(kind, camera.get("setting", {}).duplicate(true), - float(camera.get("blendtime", 0.0))) - EVT_FADE_OUT, EVT_FADE_IN, EVT_WHITE_OUT, EVT_WHITE_IN: - var fade: Dictionary = es.fade_events.back() if not es.fade_events.is_empty() else {} - var fade_kind := String(fade.get("kind", "")) - var fade_speed := float(fade.get("speed", 0.0)) - fade_event_requested.emit(fade_kind, fade_speed) - fade_event_requested_full.emit(fade_kind, fade_speed) - EVT_DUNGEON_RESULT: - dungeon_result_requested.emit(es.dungeon_result.duplicate()) - EVT_END_CONFIRM_WAIT: - _confirm_wait = false - _: - pass - -func _present_event_set(es: EventSet) -> void: - var parsed := _event_set_result(es) - if parsed.body.strip_edges() == "" and parsed.choices.is_empty() and not parsed.has_input \ - and not parsed.confirm_wait and not _active_script: - close() - return - if _panel: - # 40250 uiquest.py:328 - SKIN_CINEMA(5) and SKIN_NOWINDOW(1) hide background board - _panel.visible = (_current_skin != 1 and _current_skin != 5) - _text.text = parsed.body - _text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER if parsed.text_centered else HORIZONTAL_ALIGNMENT_LEFT - var width := _root.size.x - var height := _root.size.y - if parsed.window_size.size() >= 2: - width = maxi(320, int(parsed.window_size[0])) - height = maxi(220, int(parsed.window_size[1])) - _root.size = Vector2(width, height) - var left_w := _render_images(parsed.images, parsed.title_image) - _current_left_w = left_w - var content_w := maxf(200.0, width - 32.0 - left_w) - _text.position = Vector2(16.0 + left_w, 14.0) - _text.size = Vector2(content_w, height - 100.0) - _btnrow.position = Vector2(16.0 + left_w, height - 78.0) - _btnrow.custom_minimum_size = Vector2(content_w, 0) - _confirm_wait = parsed.confirm_wait - _fill_buttons(parsed.choices, parsed.has_next, parsed.has_input, parsed.confirm_wait, - String(parsed.get("next_button_type", ""))) - -# --- 收 GC_SCRIPT ------------------------------------------------------ - -func _on_script(skin: int, text: String) -> void: - _current_skin = skin - begin_script(text) - -func _emit_side_effects(parsed: Dictionary) -> void: - if parsed.has_select_item: - select_item_requested.emit() - for quest in parsed.quest_buttons: - quest_button_received.emit(int(quest.get("index", -1)), String(quest.get("title", "")), - String(quest.get("icon_type", "")), String(quest.get("icon_name", ""))) - for index in parsed.quest_button_closes: - quest_button_cleared.emit(int(index)) - for point in parsed.map_signals: - map_signal_added.emit(float(point.x), float(point.y)) - if parsed.clear_map_signals: - map_signals_cleared.emit() - if parsed.atlas_center.size() >= 2: - atlas_center_requested.emit(int(parsed.atlas_center[0]), int(parsed.atlas_center[1])) - for camera in parsed.camera_events: - var kind := String(camera.get("kind", "")) - camera_event_requested.emit(kind, camera.get("values", [])) - camera_event_requested_full.emit(kind, camera.get("setting", {}).duplicate(true), - float(camera.get("blendtime", 0.0))) - for fade in parsed.fade_events: - var fade_kind := String(fade.get("kind", "")) - var fade_speed := float(fade.get("speed", 0.0)) - fade_event_requested.emit(fade_kind, fade_speed) - fade_event_requested_full.emit(fade_kind, fade_speed) - if String(parsed.get("cinema_file", "")) != "": - cinema_requested.emit(String(parsed.cinema_file)) - -func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void: - _finish_runtime() - _confirm_pid = request_pid - _text.text = msg - _fill_confirm() - _root.visible = true - opened.emit() - -func _send_script_answer(answer: int) -> void: - if client and client.has_method("script_answer"): - client.script_answer(answer) - -func _escape_close() -> void: - # uiquest.py::OnPressEscapeKey dispatches from the currently visible - # button type. CANCEL and NEXT answer 254, DONE only closes, and a visible - # QUESTION answers with the last option before OnCancel/CloseSelf. The - # native quest_cancel() fallback is the stock 255 sentinel for a dialog that - # has no answer button at all. - var button_type := "" - var choices: Array = [] - if _active_set != null: - button_type = String(_active_set.next_button_type) - choices = _active_set.choices - if _confirm_wait or button_type == "CANCEL": - _send_script_answer(254) - elif button_type == "DONE": - pass - elif button_type == "NEXT": - _send_script_answer(254) - elif not choices.is_empty(): - _send_script_answer(choices.size() - 1) - elif client and client.has_method("quest_cancel"): - client.quest_cancel() - else: - _send_script_answer(255) - close() - -func close() -> void: - _finish_runtime() - if _root: - _root.visible = false - _confirm_wait = false - closed.emit() - -func _unhandled_input(event: InputEvent) -> void: - if not is_open() or not (event is InputEventKey): - return - var key := event as InputEventKey - if key.pressed and not key.echo and key.keycode == KEY_ESCAPE: - _escape_close() - var vp := get_viewport() - if vp: - vp.set_input_as_handled() - -# --- 脚本解析(CPythonEventManager 1:1)---------------------------------- -# -# 参照 REF/UserInterface/PythonEventManager.{h,cpp}: -# · EVENT_TYPE_MAP —— EventTypeMap(PythonEventManager.cpp:1148 起 42 条) -# · _get_script_event_index —— GetScriptEventIndex(:1093,前导 '/' = EVENT_POSITION_END) -# · EventSet —— SEventSet(PythonEventManager.h:98,字段名保持一致) -# · _process_event_set —— ProcessEventSet(:377 的 switch 逐 case 对拷) -# · EventSet.insert_line —— __InsertLine(:976) -# · EventSet.clear_line —— ClearLine(:953) -# · _make_question —— MakeQuestion(:884) -# · _make_next_button 效果 —— MakeNextButton(:878,本 POC 只置 has_next / iAdjustLine) -# -# 网络 GC_SCRIPT 里的裸文本被折成合成 LETTER(is_raw=true,跳过 iRestrictedCharacterCount -# 的 30 字断行——reference 脚本只有显式 [LETTER],裸文本是本客户端的扩展,逐字符断句会 -# 破坏服务器写好的句子)。 -# -# EventSet 现在由 _process_active 按 ClientVS22::UpdateEventSet 的毫秒延迟逐步消费; -# parse_script() 仍保留为离线兼容接口,用于一次性检查整个脚本的最终状态。 - -signal cinema_requested(filename: String) - -enum { - EVT_LETTER, EVT_COLOR, EVT_DELAY, EVT_ENTER, EVT_WAIT, EVT_CLEAR, EVT_QUESTION, - EVT_NEXT, EVT_DONE, - EVT_LEFT_IMAGE, EVT_TOP_IMAGE, EVT_BACKGROUND_IMAGE, EVT_IMAGE, - EVT_ADD_MAP_SIGNAL, EVT_CLEAR_MAP_SIGNAL, EVT_SET_MESSAGE_POSITION, - EVT_ADJUST_MESSAGE_POSITION, EVT_SET_CENTER_MAP_POSITION, - EVT_QUEST_BUTTON, EVT_QUEST_BUTTON_CLOSE, - EVT_SLEEP, EVT_SET_CAMERA, EVT_BLEND_CAMERA, EVT_RESTORE_CAMERA, - EVT_FADE_OUT, EVT_FADE_IN, EVT_WHITE_OUT, EVT_WHITE_IN, - EVT_CLEAR_TEXT, EVT_TEXT_HORIZONTAL_ALIGN_CENTER, EVT_TITLE_IMAGE, - EVT_RUN_CINEMA, EVT_DUNGEON_RESULT, - EVT_ITEM_NAME, EVT_MONSTER_NAME, - EVT_COLOR256, EVT_WINDOW_SIZE, - EVT_INPUT, EVT_CONFIRM_WAIT, EVT_END_CONFIRM_WAIT, - EVT_INSERT_IMAGE, EVT_SELECT_ITEM, -} - -# EventTypeMap(PythonEventManager.cpp:1148-1207)。键即脚本 tag,前导 '/' 由 -# _get_script_event_index 剥离后再查表。 -const EVENT_TYPE_MAP := { - "LETTER": EVT_LETTER, "COLOR": EVT_COLOR, "DELAY": EVT_DELAY, "ENTER": EVT_ENTER, - "WAIT": EVT_WAIT, "CLEAR": EVT_CLEAR, "QUESTION": EVT_QUESTION, "NEXT": EVT_NEXT, "DONE": EVT_DONE, - "LEFTIMAGE": EVT_LEFT_IMAGE, "TOPIMAGE": EVT_TOP_IMAGE, "BGIMAGE": EVT_BACKGROUND_IMAGE, "IMAGE": EVT_IMAGE, - "ADDMAPSIGNAL": EVT_ADD_MAP_SIGNAL, "CLEARMAPSIGNAL": EVT_CLEAR_MAP_SIGNAL, - "SETMSGPOS": EVT_SET_MESSAGE_POSITION, "ADJMSGPOS": EVT_ADJUST_MESSAGE_POSITION, - "SETCMAPPOS": EVT_SET_CENTER_MAP_POSITION, - "QUESTBUTTON": EVT_QUEST_BUTTON, "QUESTBUTTON_CLOSE": EVT_QUEST_BUTTON_CLOSE, - "SLEEP": EVT_SLEEP, "SET_CAMERA": EVT_SET_CAMERA, "BLEND_CAMERA": EVT_BLEND_CAMERA, - "RESTORE_CAMERA": EVT_RESTORE_CAMERA, "FADE_OUT": EVT_FADE_OUT, "FADE_IN": EVT_FADE_IN, - "WHITE_OUT": EVT_WHITE_OUT, "WHITE_IN": EVT_WHITE_IN, "CLEAR_TEXT": EVT_CLEAR_TEXT, - "TEXT_HORIZONTAL_ALIGN_CENTER": EVT_TEXT_HORIZONTAL_ALIGN_CENTER, "TITLE_IMAGE": EVT_TITLE_IMAGE, - "RUN_CINEMA": EVT_RUN_CINEMA, "DUNGEON_RESULT": EVT_DUNGEON_RESULT, - "ITEM": EVT_ITEM_NAME, "MOB": EVT_MONSTER_NAME, - "COLOR256": EVT_COLOR256, "WINDOW_SIZE": EVT_WINDOW_SIZE, - "INPUT": EVT_INPUT, "CONFIRM_WAIT": EVT_CONFIRM_WAIT, "END_CONFIRM_WAIT": EVT_END_CONFIRM_WAIT, - "INSERT_IMAGE": EVT_INSERT_IMAGE, "SELECT_ITEM": EVT_SELECT_ITEM, -} - -const EVENT_POSITION_START := 0 -const EVENT_POSITION_END := 1 -const C_NORMAL_WAITING_TIME := 10 # c_lNormal_Waiting_Time -const C_LINE_TEMP := 16 # c_fLine_Temp -const BOX_VISIBLE_LINE_COUNT := 5 - -# SEventSet(PythonEventManager.h:98)—— 字段名与 C++ 保持一致,另加本 POC 的 -# 行累加 / 副作用收集容器(顶替 CGraphicTextInstance 链表与 Python handler 回调)。 -class EventSet: - extends RefCounted - var ix := 0 - var iy := 0 - var iWidth := 0 - var iyLocal := 0 - var isLock := false - var lLastDelayTime := 0 - var iCurrentLetter := 0 - var CurrentColor := Color(1, 1, 1, 1) - var strCurrentLine := "" - var isConfirmWait := false - var iConfirmEndTime := 0 - var DiffuseColor := Color(1, 1, 1, 1) - var lWaitingTime := C_NORMAL_WAITING_TIME - var iRestrictedCharacterCount := 30 - var iVisibleStartLine := 0 - var iVisibleLineCount := BOX_VISIBLE_LINE_COUNT - var iAdjustLine := 0 - var isTextCenterMode := false - var isWaitFlag := false - var nAnswer := 0 - # --- POC 累加 --- - var lines: Array[String] = [] - var color_open := false - var end_confirm := false - var choices: Array[String] = [] - var has_next := false - var next_button_type := "" - var has_input := false - var has_select_item := false - var confirm_timeout := 0 - var window_size: Array[int] = [] - var images: Array[Dictionary] = [] - var insert_images: Array[Dictionary] = [] - var title_image := "" - var quest_buttons: Array[Dictionary] = [] - var quest_button_closes: Array[int] = [] - var map_signals: Array[Vector2] = [] - var clear_map_signals := false - var atlas_center: Array[int] = [] - var camera_events: Array[Dictionary] = [] - var fade_events: Array[Dictionary] = [] - var dungeon_result: Dictionary = {} - var cinema_file := "" - - func init_event_set() -> void: # __InitEventSet(PythonEventManager.cpp:72) - ix = 0; iy = 0; iWidth = 0; iyLocal = 0 - isLock = false - lLastDelayTime = 0 - iCurrentLetter = 0 - CurrentColor = Color(1, 1, 1, 1) - strCurrentLine = "" - isConfirmWait = false - iConfirmEndTime = 0 - DiffuseColor = Color(1, 1, 1, 1) - lWaitingTime = C_NORMAL_WAITING_TIME - iRestrictedCharacterCount = 30 - iVisibleStartLine = 0 - iVisibleLineCount = BOX_VISIBLE_LINE_COUNT - iAdjustLine = 0 - isTextCenterMode = false - isWaitFlag = false - # __InsertLine:init 时 pCurrentTextLine==NULL,不推行,只备一条空当前行。 - - func insert_line(_center := false) -> void: # __InsertLine(:976) - lines.append(strCurrentLine) - iyLocal += C_LINE_TEMP # __AddSpace(c_fLine_Temp) - strCurrentLine = "" - iCurrentLetter = 0 - - func clear_line() -> void: # ClearLine(:953) - lines.clear() - strCurrentLine = "" - iCurrentLetter = 0 - color_open = false - - func body_text() -> String: - var parts := lines.duplicate() - parts.append(strCurrentLine) - var s := "\n".join(parts) - if color_open: - s += "[/color]" - return s.strip_edges() - -# GetScriptEventIndex(PythonEventManager.cpp:1093)—— 前导 '/' = EVENT_POSITION_END。 -# -> { ok:bool, position:int, type:int } -func _get_script_event_index(raw_name: String) -> Dictionary: - var position := EVENT_POSITION_START - var name := raw_name - if name.begins_with("/"): - position = EVENT_POSITION_END - name = name.substr(1) - name = name.to_upper() - if not EVENT_TYPE_MAP.has(name): - return {"ok": false, "position": position, "type": -1} - return {"ok": true, "position": position, "type": int(EVENT_TYPE_MAP[name])} - -# -> { body, choices, has_next, has_input, has_select_item, confirm_wait, -# confirm_timeout, text_centered, window_size, images, title_image, -# quest_buttons, quest_button_closes, map_signals, clear_map_signals, -# atlas_center, camera_events, fade_events, -# insert_images, dungeon_result, adjust_line, waiting_time, -# is_locked, wait_flag, answer_count, cinema_file, end_confirm } -func parse_script(src: String) -> Dictionary: - var es := EventSet.new() - es.init_event_set() - var cmds := _tokenize(src) - # RUN_CINEMA 首命令特例(RegisterEventSetFromString:142 —— ScriptGroup.ReadCmd 读 - # 第一条命令)。本 POC 无法加载 .msc 事件文件,记录文件名走 seam;其余 token 照常 - # 处理。仅当 RUN_CINEMA 是脚本的第一个 token(无前导文本)时生效。 - if not cmds.is_empty() and String(cmds[0].get("literal", "")) == "" \ - and String(cmds[0].get("name", "")).to_upper().trim_prefix("/") == "RUN_CINEMA": - es.cinema_file = _arg_s(String(cmds[0].get("tok", "")), "value") - for cmd in cmds: - _process_event_set(es, cmd) - return { - "body": es.body_text(), - "choices": es.choices, - "has_next": es.has_next, - "next_button_type": es.next_button_type, - "has_input": es.has_input, - "has_select_item": es.has_select_item, - "confirm_wait": es.isConfirmWait, - "confirm_timeout": es.confirm_timeout, - "text_centered": es.isTextCenterMode, - "window_size": es.window_size, - "images": es.images, - "title_image": es.title_image, - "quest_buttons": es.quest_buttons, - "quest_button_closes": es.quest_button_closes, - "map_signals": es.map_signals, - "clear_map_signals": es.clear_map_signals, - "atlas_center": es.atlas_center, - "camera_events": es.camera_events, - "fade_events": es.fade_events, - # --- 增量 95 新增:EventSet 状态透出 --- - "insert_images": es.insert_images, - "dungeon_result": es.dungeon_result, - "adjust_line": es.iAdjustLine, - "waiting_time": es.lWaitingTime, - "is_locked": es.isLock, - "wait_flag": es.isWaitFlag, - "answer_count": es.nAnswer, - "cinema_file": es.cinema_file, - "end_confirm": es.end_confirm, - } - -func _event_set_result(es: EventSet) -> Dictionary: - return { - "body": es.body_text(), - "choices": es.choices, - "has_next": es.has_next, - "next_button_type": es.next_button_type, - "has_input": es.has_input, - "has_select_item": es.has_select_item, - "confirm_wait": es.isConfirmWait, - "confirm_timeout": es.confirm_timeout, - "text_centered": es.isTextCenterMode, - "window_size": es.window_size, - "images": es.images, - "title_image": es.title_image, - "quest_buttons": es.quest_buttons, - "quest_button_closes": es.quest_button_closes, - "map_signals": es.map_signals, - "clear_map_signals": es.clear_map_signals, - "atlas_center": es.atlas_center, - "camera_events": es.camera_events, - "fade_events": es.fade_events, - "insert_images": es.insert_images, - "dungeon_result": es.dungeon_result, - "adjust_line": es.iAdjustLine, - "waiting_time": es.lWaitingTime, - "is_locked": es.isLock, - "wait_flag": es.isWaitFlag, - "answer_count": es.nAnswer, - "cinema_file": es.cinema_file, - "end_confirm": es.end_confirm, - } - -# 把混排文本 / [tag ...] 拆成命令流。裸文本 -> { literal:String }; -# tag -> { name:String(大写,带/前缀), pos:int, tok:String(方括号内原文) }。 -func _tokenize(src: String) -> Array: - var out := [] - var i := 0 - var n := src.length() - while i < n: - var c := src[i] - if c == "[": - var close_i := src.find("]", i) - if close_i == -1: - out.append({"literal": src.substr(i)}) - break - var tok := src.substr(i + 1, close_i - i - 1).strip_edges() - i = close_i + 1 - if tok == "": - continue - var first := tok.split(" ", false)[0] - var pos := EVENT_POSITION_START - if first.begins_with("/"): - pos = EVENT_POSITION_END - out.append({"name": first.to_upper(), "pos": pos, "tok": tok}) - else: - var next_tag := src.find("[", i) - if next_tag == -1: - next_tag = n - var lit := src.substr(i, next_tag - i) - if lit != "": - out.append({"literal": lit}) - i = next_tag - return out - -# ProcessEventSet(PythonEventManager.cpp:377)的 switch 逐 case 对拷。 -func _process_event_set(es: EventSet, cmd: Dictionary) -> void: - var literal := String(cmd.get("literal", "")) - if literal != "": - _evt_letter(es, literal, true) - return - var idx := _get_script_event_index(String(cmd.get("name", ""))) - if not idx.ok: - # GetScriptEventIndex: 未知命令 -> PARSING ERROR,跳过(不进 switch)。 - return - var etype: int = idx.type - var pos: int = idx.position - var tok := String(cmd.get("tok", "")) - match etype: - EVT_LETTER: - var vv := _tag_strings(tok) - if vv.size() > 0: - _evt_letter(es, vv[0], false) - EVT_DELAY: - # START: lWaitingTime = atoi(value);END(/DELAY): 复位 c_lNormal_Waiting_Time。 - es.lWaitingTime = _arg_i(tok, "value", 0) if pos == EVENT_POSITION_START else C_NORMAL_WAITING_TIME - EVT_COLOR: - _evt_color(es, tok, pos, 1.0) - EVT_COLOR256: - _evt_color(es, tok, pos, 255.0) - EVT_ENTER: - es.insert_line() - EVT_WAIT: - es.iyLocal = 0 - es.isLock = true - EVT_CLEAR, EVT_CLEAR_TEXT: - es.clear_line() - EVT_QUESTION: - _make_question(es, tok) - EVT_NEXT: - # MakeNextButton(BUTTON_TYPE_NEXT); iAdjustLine += 2。 - es.has_next = true - es.next_button_type = "NEXT" - es.iAdjustLine += 2 - EVT_DONE: - # PythonEventManager::ProcessEventSet invokes DoneEvent immediately, - # then MakeNextButton(BUTTON_TYPE_DONE) creates a close-only button. - done_event.emit() - es.has_next = true - es.next_button_type = "DONE" - es.iAdjustLine += 2 - EVT_LEFT_IMAGE: - es.images.append({"panel": "LEFTIMAGE", "path": _arg_s(tok, "src")}) - EVT_TOP_IMAGE: - es.images.append({"panel": "TOPIMAGE", "path": _arg_s(tok, "src")}) - EVT_BACKGROUND_IMAGE: - es.images.append({"panel": "BGIMAGE", "path": _arg_s(tok, "src")}) - EVT_IMAGE: - var ipath := _arg_s(tok, "src", 0) - if ipath != "": - es.images.append({"x": _arg_i(tok, "x", 0), "y": _arg_i(tok, "y", 1), "path": ipath}) - EVT_INSERT_IMAGE: - var img := _arg_s(tok, "image_name") - es.insert_images.append({ - "image_name": img, - "image_type": _arg_s(tok, "image_type"), - "idx": _arg_i(tok, "idx"), - "title": _arg_s(tok, "title"), - "desc": _arg_s(tok, "desc"), - "index": _arg_i(tok, "index"), - "total": _arg_i(tok, "total"), - "is_item_icon": img == "", - }) - es.iAdjustLine += 2 - EVT_ADD_MAP_SIGNAL: - es.map_signals.append(Vector2(_arg_f(tok, "x", 0), _arg_f(tok, "y", 1))) - EVT_CLEAR_MAP_SIGNAL: - es.clear_map_signals = true - EVT_QUEST_BUTTON_CLOSE: - es.quest_button_closes.append(_arg_i(tok, "idx", 0)) - EVT_QUEST_BUTTON: - _make_quest_button(es, tok) - EVT_SET_MESSAGE_POSITION, EVT_ADJUST_MESSAGE_POSITION: - # PythonEventManager.cpp:562 / :566 —— 客户端故意留空(无副作用)。 - pass - EVT_SET_CENTER_MAP_POSITION: - es.atlas_center = [_arg_i(tok, "x", 0), _arg_i(tok, "y", 1)] - EVT_SLEEP: - es.lLastDelayTime = _arg_i(tok, "value", 0) - EVT_SET_CAMERA: - es.camera_events.append({"kind": "SET_CAMERA", "values": _tag_numbers(tok), - "setting": _camera_setting(tok)}) - EVT_BLEND_CAMERA: - es.camera_events.append({"kind": "BLEND_CAMERA", "values": _tag_numbers(tok), - "setting": _camera_setting(tok), "blendtime": _arg_f(tok, "blendtime")}) - EVT_RESTORE_CAMERA: - es.camera_events.append({"kind": "RESTORE_CAMERA", "values": []}) - EVT_FADE_OUT, EVT_FADE_IN, EVT_WHITE_OUT, EVT_WHITE_IN: - var kinds := {EVT_FADE_OUT: "FADE_OUT", EVT_FADE_IN: "FADE_IN", - EVT_WHITE_OUT: "WHITE_OUT", EVT_WHITE_IN: "WHITE_IN"} - es.fade_events.append({"kind": kinds[etype], "speed": _arg_f(tok, "speed", 0)}) - es.isWaitFlag = true # 等 EndEventProcess(seam:无逐帧循环,不真正阻塞)。 - EVT_TEXT_HORIZONTAL_ALIGN_CENTER: - es.isTextCenterMode = true - EVT_TITLE_IMAGE: - es.title_image = _arg_s(tok, "src", 0) - EVT_RUN_CINEMA: - # ProcessEventSet 无此 case(仅 RegisterEventSetFromString 首命令阶段处理)。 - pass - EVT_DUNGEON_RESULT: - es.dungeon_result = { - "killstone_count": _arg_i(tok, "killstone_count"), - "killmob_count": _arg_i(tok, "killmob_count"), - "find_hidden": _arg_i(tok, "find_hidden"), - "hidden_total": _arg_i(tok, "hidden_total"), - "use_potion": _arg_i(tok, "use_potion"), - "is_revived": _arg_i(tok, "is_revived"), - "killallmob": _arg_i(tok, "killallmob"), - "total_time": _arg_i(tok, "total_time"), - "bonus_exp": _arg_i(tok, "bonus_exp"), - } - EVT_ITEM_NAME: - _evt_item_name(es, _arg_i(tok, "value", 0)) - EVT_MONSTER_NAME: - _evt_mob_name(es, _arg_i(tok, "value", 0)) - EVT_WINDOW_SIZE: - var w := _arg_i(tok, "width", 0) - var h := _arg_i(tok, "height", 1) - if w > 0 and h > 0: - es.window_size = [w, h] - EVT_INPUT: - es.insert_line() - es.has_input = true - EVT_CONFIRM_WAIT: - es.confirm_timeout = _arg_i(tok, "timeout", 0) - es.isConfirmWait = true - # ClientVS22 stores the deadline in seconds (timeGetTime()/1000), - # not the timeout duration itself. - es.iConfirmEndTime = int(Time.get_ticks_msec() / 1000) + es.confirm_timeout - es.insert_line(true) - # MakeNextButton(BUTTON_TYPE_CANCEL) —— 取消按钮由 _fill_buttons(confirm_wait) 出。 - EVT_END_CONFIRM_WAIT: - es.isConfirmWait = false - es.iConfirmEndTime = 0 - es.end_confirm = true - EVT_SELECT_ITEM: - es.has_select_item = true - _: - pass - -# EVENT_TYPE_LETTER / 裸文本(is_raw)。 -func _evt_letter(es: EventSet, value: String, is_raw: bool) -> void: - es.strCurrentLine += value - if not is_raw: - es.iCurrentLetter += value.length() - if es.iCurrentLetter >= es.iRestrictedCharacterCount: - es.insert_line() - es.lLastDelayTime = es.lWaitingTime - -# EVENT_TYPE_COLOR / EVENT_TYPE_COLOR256。BBCode span:新 span 前先关旧的。 -func _evt_color(es: EventSet, tok: String, pos: int, divisor: float) -> void: - if es.color_open: - es.strCurrentLine += "[/color]" - es.color_open = false - if pos == EVENT_POSITION_START: - var kv := _tag_kv(tok) - var r: float - var g: float - var b: float - if kv.has("r") or kv.has("g") or kv.has("b"): - r = float(kv.get("r", "0")) - g = float(kv.get("g", "0")) - b = float(kv.get("b", "0")) - else: - var nums := _tag_numbers(tok) - if nums.size() < 3: - return - r = nums[0]; g = nums[1]; b = nums[2] - es.CurrentColor = Color(clampf(r / divisor, 0.0, 1.0), - clampf(g / divisor, 0.0, 1.0), clampf(b / divisor, 0.0, 1.0), 1.0) - es.strCurrentLine += "[color=#%s]" % es.CurrentColor.to_html(false) - es.color_open = true - else: - es.CurrentColor = Color(1, 1, 1, 1) - -# MakeQuestion(PythonEventManager.cpp:884)。 -func _make_question(es: EventSet, tok: String) -> void: - # 40250 官方标准格式:[QUESTION 1;选项一|2;选项二|...] - # 参考 40250 服务端 questlua.cpp:642 GotoSelectState 与 客户端 EterLib/parser.cpp:180 - var space_idx := tok.find(" ") - var body := tok.substr(space_idx + 1).strip_edges() if space_idx >= 0 else "" - if body.contains("|") or (body.contains(";") and not body.contains("arg(") and not body.contains("value(")): - var parts := body.split("|", false) - var options: Array[String] = [] - for p in parts: - var trimmed := p.strip_edges() - if trimmed.is_empty(): - continue - var semi := trimmed.find(";") - if semi >= 0: - var label := trimmed.substr(semi + 1).strip_edges() - options.append(label) - else: - options.append(trimmed) - if not options.is_empty(): - for opt in options: - es.choices.append(opt) - es.nAnswer = options.size() - return - - # 回落至 legacy 格式:[QUESTION arg("A") arg("B")] - var args := _tag_strings(tok) - if args.is_empty(): - return - for a in args: - es.choices.append(String(a)) - es.nAnswer = args.size() - -# EVENT_TYPE_QUEST_BUTTON(:558)—— idx/name/icon_type/icon_name,带位置回退。 -func _make_quest_button(es: EventSet, tok: String) -> void: - var kv := _tag_kv(tok) - var nums := _tag_numbers(tok) - var strs := _tag_strings(tok) - var qidx := -1 - if kv.has("idx") and String(kv["idx"]).is_valid_int(): - qidx = int(kv["idx"]) - elif nums.size() > 0: - qidx = int(nums[0]) - if qidx < 0: - return - var nm := String(kv.get("name", strs[0] if strs.size() > 0 else "")) - var itype := String(kv.get("icon_type", strs[1] if strs.size() > 1 else "")) - var iname := String(kv.get("icon_name", strs[2] if strs.size() > 2 else "")) - es.quest_buttons.append({"index": qidx, "title": nm, "icon_type": itype, "icon_name": iname}) - -func _evt_item_name(es: EventSet, vnum: int) -> void: - var nm := _quest_item_name([float(vnum)]) - es.strCurrentLine += nm - es.iCurrentLetter += nm.length() - if es.iCurrentLetter >= es.iRestrictedCharacterCount: - es.insert_line() - es.lLastDelayTime = es.lWaitingTime - -func _evt_mob_name(es: EventSet, vnum: int) -> void: - var nm := _quest_mob_name([float(vnum)]) - es.strCurrentLine += nm - es.iCurrentLetter += nm.length() - if es.iCurrentLetter >= es.iRestrictedCharacterCount: - es.insert_line() - es.lLastDelayTime = es.lWaitingTime - -# GetCameraSettingFromArgList(PythonEventManager.cpp:48)。 -func _camera_setting(tok: String) -> Dictionary: - return { - "x": _arg_i(tok, "x"), "y": _arg_i(tok, "y"), "z": _arg_i(tok, "z"), - "up": _arg_i(tok, "up"), "view": _arg_i(tok, "view"), "cross": _arg_i(tok, "cross"), - "distance": _arg_i(tok, "distance"), "rot": _arg_i(tok, "rot"), "pitch": _arg_i(tok, "pitch"), - } - -# --- arg 提取(keyword 优先,位置回退)-------------------------------- - -# key(val) / key("val") / key;val 的键值对。重复键取最后一个。 -func _tag_kv(tok: String) -> Dictionary: - var out := {} - var raw := tok.substr(tok.find(" ") + 1) if tok.find(" ") >= 0 else "" - var i := 0 - var n := raw.length() - while i < n: - while i < n and raw[i] in [" ", "\t", ","]: - i += 1 - var start := i - while i < n and not (raw[i] in [" ", "\t", ",", "(", ";"]): - i += 1 - if i >= n: - break - var key := raw.substr(start, i - start) - var val := "" - if raw[i] == "(": - var depth := 1 - i += 1 - var vs := i - while i < n and depth > 0: - if raw[i] == "(": - depth += 1 - elif raw[i] == ")": - depth -= 1 - if depth == 0: - break - i += 1 - val = raw.substr(vs, i - vs) - i += 1 - elif raw[i] == ";": - i += 1 - var vs2 := i - while i < n and not (raw[i] in [" ", "\t", ","]): - i += 1 - val = raw.substr(vs2, i - vs2) - val = val.strip_edges() - if val.length() >= 2 and val[0] == '"' and val[val.length() - 1] == '"': - val = val.substr(1, val.length() - 2) - if key != "": - out[key] = val - return out - -func _arg_s(tok: String, key: String, fallback_idx: int = -1) -> String: - var kv := _tag_kv(tok) - if kv.has(key): - return String(kv[key]) - if fallback_idx >= 0: - var strs := _tag_strings(tok) - if fallback_idx < strs.size(): - return String(strs[fallback_idx]) - return "" - -func _arg_f(tok: String, key: String, fallback_idx: int = -1) -> float: - var kv := _tag_kv(tok) - if kv.has(key) and String(kv[key]).is_valid_float(): - return float(kv[key]) - if fallback_idx >= 0: - var nums := _tag_numbers(tok) - if fallback_idx < nums.size(): - return nums[fallback_idx] - return 0.0 - -func _arg_i(tok: String, key: String, fallback_idx: int = -1) -> int: - return int(_arg_f(tok, key, fallback_idx)) - -func _tag_strings(tok: String) -> Array[String]: - var quoted: Array[String] = [] - for raw in _rx_args(tok): - quoted.append(String(raw)) - if not quoted.is_empty(): - return quoted - var out: Array[String] = [] - var raw := tok.substr(tok.find(" ") + 1).strip_edges() if tok.find(" ") >= 0 else "" - for part in raw.replace(",", ";").split(";", false): - var value := part.strip_edges() - if value in ["value", "arg"] or value.is_empty(): - continue - if value.begins_with("value("): - value = value.trim_prefix("value(").trim_suffix(")") - if value.begins_with("arg("): - value = value.trim_prefix("arg(").trim_suffix(")") - if not value.is_empty(): - out.append(value) - return out - -func _tag_numbers(tok: String) -> Array[float]: - var out: Array[float] = [] - var raw := tok.substr(tok.find(" ") + 1).strip_edges() if tok.find(" ") >= 0 else "" - raw = raw.replace(",", " ").replace(";", " ").replace("(", " ").replace(")", " ") - for part in raw.split(" ", false): - var value := part.strip_edges() - if value.is_valid_float(): - out.append(float(value)) - return out - -func _quest_item_name(values: Array[float]) -> String: - if values.is_empty(): - return "" - var vnum := int(values[0]) - if proto and proto.has_method("item"): - var data: Dictionary = proto.item(vnum) - var name := String(data.get("locale_name", data.get("name", ""))) - if name != "": - return name - return "物品 #%d" % vnum - -func _quest_mob_name(values: Array[float]) -> String: - if values.is_empty(): - return "" - var vid := int(values[0]) - if client and client.has_method("get_entity"): - var entity: Dictionary = client.get_entity(vid) - var name := String(entity.get("name", "")) - if name != "": - return name - return "怪物 #%d" % vid - -# 抓 tok 里所有 arg("...") / value("...") 的字符串 -func _rx_args(tok: String) -> Array: - var out := [] - var i := 0 - while true: - var q1 := tok.find('"', i) - if q1 == -1: - break - var q2 := tok.find('"', q1 + 1) - if q2 == -1: - break - out.append(tok.substr(q1 + 1, q2 - q1 - 1)) - i = q2 + 1 - return out - -# --- UI -------------------------------------------------------------- - -func _build(parent: Node) -> void: - _root = Control.new() - _root.name = "QuestDialogWindow" - _root.set_anchors_preset(Control.PRESET_CENTER) - _root.position = Vector2(-175, -150) - _root.size = Vector2(350, 300) - _root.visible = false - _root.set_meta("is_titlebar", true) - parent.add_child(_root) - - # 40250 uiscript/questdialog.py: type "thinboard", width 350, height 300 - var thinboard_np := UiKit.board(_assets_root, "thinboard") - if thinboard_np and thinboard_np.texture: - thinboard_np.set_anchors_preset(Control.PRESET_FULL_RECT) - thinboard_np.mouse_filter = Control.MOUSE_FILTER_IGNORE - _root.add_child(thinboard_np) - _panel = thinboard_np - else: - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.04, 0.04, 0.05, 0.75) - sb.border_color = Color(0.5, 0.42, 0.3) - sb.set_border_width_all(1) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - _panel = panel - - _image_layer = Control.new() - _image_layer.set_anchors_preset(Control.PRESET_FULL_RECT) - _image_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE - _root.add_child(_image_layer) - - _text = RichTextLabel.new() - _text.bbcode_enabled = true - _text.position = Vector2(16, 14) - _text.size = Vector2(318, 190) - _text.add_theme_font_size_override("normal_font_size", 13) - _root.add_child(_text) - - _btnrow = VBoxContainer.new() - _btnrow.position = Vector2(16, 210) - _btnrow.custom_minimum_size = Vector2(318, 0) - _btnrow.add_theme_constant_override("separation", 5) - _root.add_child(_btnrow) - -func _render_images(images: Array, title_image: String) -> float: - if _image_layer == null: - return 0.0 - for child in _image_layer.get_children(): - _image_layer.remove_child(child) - child.queue_free() - var left_w := 0.0 - for spec in images: - var path := String(spec.get("path", "")) - var tex: Texture2D = UiAssets.load_tex(_assets_root, path) - if tex == null: - continue - var image := TextureRect.new() - image.texture = tex - image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - image.mouse_filter = Control.MOUSE_FILTER_IGNORE - var panel_name := String(spec.get("panel", "")) - if panel_name == "BGIMAGE": - image.set_anchors_preset(Control.PRESET_FULL_RECT) - elif panel_name == "TOPIMAGE": - image.position = Vector2(0, 0) - image.size = Vector2(_root.size.x, minf(84.0, tex.get_size().y)) - elif panel_name == "LEFTIMAGE": - var img_w := minf(160.0, tex.get_size().x) - var img_h := minf(_root.size.y - 28.0, tex.get_size().y) - image.position = Vector2(10, 14) - image.size = Vector2(img_w, img_h) - left_w = maxf(left_w, img_w + 14.0) - else: - image.position = Vector2(float(spec.get("x", 0)), float(spec.get("y", 0))) - image.size = tex.get_size() - _image_layer.add_child(image) - if title_image != "": - var title_tex: Texture2D = UiAssets.load_tex(_assets_root, title_image) - if title_tex: - var title := TextureRect.new() - title.texture = title_tex - title.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - title.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - title.mouse_filter = Control.MOUSE_FILTER_IGNORE - title.position = Vector2((_root.size.x - title_tex.get_size().x) * 0.5, 4) - title.size = title_tex.get_size() - _image_layer.add_child(title) - return left_w - -func _clear_buttons() -> void: - for c in _btnrow.get_children(): - _btnrow.remove_child(c) - c.queue_free() - -func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false, - next_button_type := "") -> void: - _clear_buttons() - if has_input: - var input := LineEdit.new() - input.placeholder_text = "请输入内容" - input.custom_minimum_size = Vector2(maxf(200.0, _root.size.x - 32.0 - _current_left_w), 28) - _btnrow.add_child(input) - var submit := _mkbtn("确定") - submit.pressed.connect(func(): _submit_input(input)) - input.text_submitted.connect(func(_text): _submit_input(input)) - if input.is_inside_tree(): - input.grab_focus() - else: - input.ready.connect(func(): - if input.is_inside_tree(): - input.grab_focus() - , CONNECT_ONE_SHOT) - return - if confirm_wait: - var cancel := _mkbtn("取消") - cancel.pressed.connect(func(): - # MakeNextButton(BUTTON_TYPE_CANCEL): answer 254, then OnCancel. - if client and client.has_method("script_answer"): - client.script_answer(254) - elif client and client.has_method("quest_cancel"): - client.quest_cancel() - close()) - return - if choices.is_empty(): - var is_done := next_button_type == "DONE" - var b := _mkbtn("关闭" if is_done else ("继续" if has_next else "关闭")) - b.custom_minimum_size = Vector2(100, 26) - b.size_flags_horizontal = Control.SIZE_SHRINK_CENTER - b.pressed.connect(func(): - if is_done: - # ClientVS22 uiquest.py::MakeNextButton(BUTTON_TYPE_DONE) - # calls CloseSelf() and does not send SCRIPT_ANSWER(254). - close() - return - if client and client.has_method("script_answer"): - client.script_answer(254) - close()) - return - _render_choices(choices, 0) - -func _render_choices(choices: Array, page: int = 0) -> void: - _cached_choices = choices - _current_choice_page = page - _clear_buttons() - var total := choices.size() - if total == 0: - return - if total <= MAX_CHOICES_PER_PAGE: - for i in total: - var idx := i - var b := _mkbtn(String(choices[i])) - b.pressed.connect(func(): - if client and client.has_method("script_answer"): - client.script_answer(idx) - close()) - return - - # Multi-page pagination (40250 uiquest.py parity) - var start_idx := page * MAX_CHOICES_PER_PAGE - var end_idx := mini(start_idx + MAX_CHOICES_PER_PAGE, total) - var total_pages := int(ceil(float(total) / float(MAX_CHOICES_PER_PAGE))) - - for i in range(start_idx, end_idx): - var idx := i - var b := _mkbtn(String(choices[i])) - b.pressed.connect(func(): - if client and client.has_method("script_answer"): - client.script_answer(idx) - close()) - - var nav_row := HBoxContainer.new() - nav_row.alignment = BoxContainer.ALIGNMENT_CENTER - nav_row.add_theme_constant_override("separation", 12) - _btnrow.add_child(nav_row) - - if page > 0: - var prev_btn := _mkbtn("上一页") - prev_btn.custom_minimum_size = Vector2(80, 26) - prev_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER - _btnrow.remove_child(prev_btn) - prev_btn.pressed.connect(func(): _render_choices(_cached_choices, page - 1)) - nav_row.add_child(prev_btn) - - var page_label := Label.new() - page_label.text = "%d / %d" % [page + 1, total_pages] - page_label.add_theme_font_size_override("font_size", 12) - nav_row.add_child(page_label) - - if page < total_pages - 1: - var next_btn := _mkbtn("下一页") - next_btn.custom_minimum_size = Vector2(80, 26) - next_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER - _btnrow.remove_child(next_btn) - next_btn.pressed.connect(func(): _render_choices(_cached_choices, page + 1)) - nav_row.add_child(next_btn) - -func _submit_input(input: LineEdit) -> void: - var val := input.text.strip_edges() - if client and client.has_method("quest_input"): - client.quest_input(val) - close() - -func _fill_confirm() -> void: - _clear_buttons() - var row := HBoxContainer.new() - row.alignment = BoxContainer.ALIGNMENT_CENTER - row.add_theme_constant_override("separation", 16) - _btnrow.add_child(row) - for spec in [["接受", true], ["拒绝", false]]: - var yes: bool = spec[1] - var b := Button.new() - b.text = spec[0] - b.custom_minimum_size = Vector2(100, 28) - - var sb_up := StyleBoxFlat.new() - sb_up.bg_color = Color(0.6, 0.6, 0.6, 0.25) - sb_up.set_corner_radius_all(3) - sb_up.set_border_width_all(1) - sb_up.border_color = Color(0.8, 0.8, 0.8, 0.25) - var sb_over := StyleBoxFlat.new() - sb_over.bg_color = Color(0.87, 0.87, 1.0, 0.35) - sb_over.set_corner_radius_all(3) - sb_over.set_border_width_all(1) - sb_over.border_color = Color(1.0, 1.0, 1.0, 0.5) - b.add_theme_stylebox_override("normal", sb_up) - b.add_theme_stylebox_override("hover", sb_over) - b.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) - b.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) - b.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.7)) - - b.pressed.connect(func(): - if client and client.has_method("quest_confirm"): - client.quest_confirm(yes, _confirm_pid) - close()) - row.add_child(b) - -# 40250 BarButton: 半透明长条胶囊样式,普通 0x40999999,悬停 0x40ddddff,按下 0x40aaaacc -func _mkbtn(text: String) -> Button: - var b := Button.new() - b.text = text - var w := maxf(200.0, _root.size.x - 32.0 - _current_left_w) - b.custom_minimum_size = Vector2(w, 26) - b.clip_text = true - - var sb_up := StyleBoxFlat.new() - sb_up.bg_color = Color(0.6, 0.6, 0.6, 0.25) - sb_up.set_corner_radius_all(3) - sb_up.set_border_width_all(1) - sb_up.border_color = Color(0.8, 0.8, 0.8, 0.25) - - var sb_over := StyleBoxFlat.new() - sb_over.bg_color = Color(0.87, 0.87, 1.0, 0.35) - sb_over.set_corner_radius_all(3) - sb_over.set_border_width_all(1) - sb_over.border_color = Color(1.0, 1.0, 1.0, 0.5) - - var sb_down := StyleBoxFlat.new() - sb_down.bg_color = Color(0.67, 0.67, 0.8, 0.35) - sb_down.set_corner_radius_all(3) - sb_down.set_border_width_all(1) - sb_down.border_color = Color(0.7, 0.7, 0.9, 0.5) - - b.add_theme_stylebox_override("normal", sb_up) - b.add_theme_stylebox_override("hover", sb_over) - b.add_theme_stylebox_override("pressed", sb_down) - b.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) - - b.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) - b.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.7)) - b.add_theme_color_override("font_pressed_color", Color(0.9, 0.9, 0.9)) - b.add_theme_font_size_override("font_size", 12) - b.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - - _btnrow.add_child(b) - return b diff --git a/project/ui/quest_dialog.gd.uid b/project/ui/quest_dialog.gd.uid deleted file mode 100644 index 93c158e2..00000000 --- a/project/ui/quest_dialog.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://8irhkh75qgra diff --git a/project/ui/quest_log.gd b/project/ui/quest_log.gd deleted file mode 100644 index 36949e4e..00000000 --- a/project/ui/quest_log.gd +++ /dev/null @@ -1,454 +0,0 @@ -# QuestLog (P7) —— 任务日志窗:GC_QUEST_INFO 的条目列表。 -# -# var ql := preload("res://ui/quest_log.gd").new() -# add_child(ql) -# ql.setup(m2client, canvas_parent) -# ql.toggle() # J 键 -# -# 每条: 图标(占位) + 标题 + counter「名: 值」 + clock「名: 值」。 -# `quest_info` 信号来时刷新。空标题的条目视为「已完成 / 移除」。 -extends Node - -const UiAssets = preload("res://ui/ui_assets.gd") -const QUEST_MAX_NUM := 5 -const QUEST_EVENT_BUTTON_BASE := -2147483648 - -# interfacemodule.__ArrangeQuestButton(interfacemodule.py:1357-1386)1:1 布局参数: -# xPos = 20(无组队窗)/ 130 = 100 + 30(组队窗显示时);(+15 RTL,本迁移不做 RTL) -# yPos = 170 * screenHeight // 600 -# yCount = (screenHeight - 330) // 63 每列容纳的按钮数 -# 第 i 个: x = xPos + (i // yCount) * 100 ; y = yPos + (i % yCount) * 63 -const QUEST_BTN_X := 20 -const QUEST_BTN_X_PARTY := 130 -const QUEST_BTN_COL_W := 100 -const QUEST_BTN_ROW_H := 63 -const QUEST_BTN_SIZE := Vector2(198, 48) - -signal quest_selected(index: int) - -var client: Node -var _root: Control -var _list: VBoxContainer -var _quest_scrollbar: VScrollBar -var _quests: Array = [] -var _quest_start_index := 0 -var _visible_quest_indices: Array[int] = [] -var _button_root: Control -var _button_list: Control -var _quest_buttons_visible := true -var _quest_buttons_suppressed := false -var _quest_button_data: Dictionary = {} -var _quest_button_order: Array[int] = [] # 最新在前:BINARY_RecvQuest 的 questButtonList.insert(0, btn) -var _party_shown := false -var _party_probe: Callable = Callable() -var _screen_size_override := Vector2.ZERO # >0 时覆盖 viewport 尺寸(离线回归用) -var _mobile_mode := false -var _clock_remaining: Dictionary = {} -var _clock_labels: Dictionary = {} -var _clock_accumulator := 0.0 -var _proto: Node -var _item_list: RefCounted -var _assets_root := "" - -func setup(m2client: Node, parent: Node) -> void: - client = m2client - set_process(true) - _build(parent) - if client.has_signal("quest_info"): - client.quest_info.connect(func(_i): refresh()) - _refresh_quest_buttons() - -func set_item_sources(proto_node: Node, item_list: RefCounted, assets_root: String) -> void: - _proto = proto_node - _item_list = item_list - _assets_root = assets_root - _refresh_quest_buttons() - -func is_open() -> bool: - return _root != null and _root.visible - -func get_mobile_window() -> Control: - return _root - -func close() -> void: - if _root: - _root.visible = false - -func toggle() -> void: - _root.visible = not _root.visible - if _root.visible: - refresh() - -# game.py Ctrl+Q toggles the quest-button strip. This migration has the quest -# entries in the quest-log surface, so keep the same state boundary explicit; -# a future HUD strip can bind this without changing the input contract. -func toggle_buttons() -> void: - _quest_buttons_visible = not _quest_buttons_visible - _update_button_visibility() - -func quest_buttons_visible() -> bool: - return _quest_buttons_visible - -func set_buttons_suppressed(suppressed: bool) -> void: - _quest_buttons_suppressed = suppressed - _update_button_visibility() - -# EventManager QUESTBUTTON / QUESTBUTTON_CLOSE interface. The quest-log -# snapshot and the transient quest-letter strip are separate in ClientVS22. -func recv_quest(index: int, title: String, icon_type := "file", icon_name := "") -> void: - if index < 0: - return - # BINARY_RecvQuest:同 index 先 __DestroyQuestButton 旧的,再 questButtonList.insert(0, btn) - # —— 即无论新旧都移到最前。 - _quest_button_data[index] = {"title": title, "icon_type": icon_type, "icon_name": icon_name} - _quest_button_order.erase(index) - _quest_button_order.push_front(index) - _refresh_quest_buttons() - -func clear_quest(index: int) -> void: - _quest_button_data.erase(index) - _quest_button_order.erase(index) - _refresh_quest_buttons() - -func quest_button_order() -> Array[int]: - return _quest_button_order.duplicate() - -# __ArrangeQuestButton 的 xPos 取决于 self.wndParty.IsShow()。组队窗可见性在本迁移里 -# 由 party_ui 的成员数驱动(无信号),故用一个探针回调,在 _process 里轮询变化。 -func set_party_probe(probe: Callable) -> void: - _party_probe = probe - -func set_party_shown(shown: bool) -> void: - if shown == _party_shown: - return - _party_shown = shown - _refresh_quest_buttons() - -# MobileHud supplies one compact task entry at the right side of the -# landscape HUD. Keep the quest-letter data here, but hide the desktop -# absolute-positioned buttons while mobile mode is active. -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - _update_button_visibility() - -func set_screen_size_override(size: Vector2) -> void: - _screen_size_override = size - _refresh_quest_buttons() - -func _screen_size() -> Vector2: - if _screen_size_override.x > 0.0 and _screen_size_override.y > 0.0: - return _screen_size_override - var vp := get_viewport() - if vp: - return vp.get_visible_rect().size - return Vector2(800, 600) - -# interfacemodule.py:1371 yCount = (screenHeight - 330) // 63 -# 参考端无下限,screenHeight < 393 会 ZeroDivision;这里夹到 1(防御性偏离,seam)。 -static func quest_button_y_count(screen_height: float) -> int: - return maxi(1, int(screen_height - 330.0) / 63) - -# interfacemodule.py:1370 yPos = 170 * screenHeight // 600 -static func quest_button_y_pos(screen_height: float) -> int: - return int(170.0 * screen_height) / 600 - -# interfacemodule.py:1375 -# btn.SetPosition(xPos + (int(count // yCount) * 100), yPos + (count % yCount * 63)) -static func quest_button_slot_pos(slot: int, y_count: int, x_pos: int, y_pos: int) -> Vector2: - var col := slot / y_count - var row := slot % y_count - return Vector2(x_pos + col * QUEST_BTN_COL_W, y_pos + row * QUEST_BTN_ROW_H) - -func quest_button_position(index: int) -> Vector2: - var slot := _quest_button_order.find(index) - if slot < 0: - return Vector2(-1, -1) - var screen := _screen_size() - return quest_button_slot_pos(slot, quest_button_y_count(screen.y), - QUEST_BTN_X_PARTY if _party_shown else QUEST_BTN_X, quest_button_y_pos(screen.y)) - -func quest_button_count() -> int: - return _quest_button_data.size() - -func quest_count() -> int: - return _quests.size() - -func quest_start_index() -> int: - return _quest_start_index - -func visible_quest_indices() -> Array[int]: - return _visible_quest_indices.duplicate() - -func _quest_remaining(q: Dictionary) -> int: - var value := maxi(0, int(q.get("clock_value", 0))) - if not q.has("clock_start_ms"): - return value - var start_ms := int(q.get("clock_start_ms", 0)) - var elapsed_ms := maxi(0, Time.get_ticks_msec() - start_ms) - return maxi(0, value - int(elapsed_ms / 1000)) - -func scroll_ratio() -> float: - if _quest_scrollbar == null or _quest_scrollbar.max_value <= 0.0: - return 0.0 - return _quest_scrollbar.value / _quest_scrollbar.max_value - -func set_scroll_ratio(ratio: float) -> void: - if _quest_scrollbar == null: - return - _quest_scrollbar.value = clampf(ratio, 0.0, 1.0) * _quest_scrollbar.max_value - -func select_quest(index: int) -> void: - if index < 0 or index >= _quests.size(): - return - var quest_index := int(_quests[index].get("index", -1)) - if quest_index < 0: - return - if client and client.has_method("script_button"): - client.script_button(QUEST_EVENT_BUTTON_BASE + quest_index) - quest_selected.emit(quest_index) - -func refresh() -> void: - if not is_open() or client == null: - return - _quests.clear() - for q in client.get_quests(): - if String(q.get("title", "")).strip_edges() != "": - _quests.append(q) - _quest_start_index = mini(_quest_start_index, maxi(0, _quests.size() - QUEST_MAX_NUM)) - if _quest_scrollbar: - _quest_scrollbar.max_value = maxi(0, _quests.size() - QUEST_MAX_NUM) - _quest_scrollbar.value = _quest_start_index - _quest_scrollbar.visible = _quests.size() > QUEST_MAX_NUM - _render_quests() - -func _render_quests() -> void: - for c in _list.get_children(): - c.queue_free() - _clock_labels.clear() - _clock_remaining.clear() - _clock_accumulator = 0.0 - _visible_quest_indices.clear() - if _quests.is_empty(): - var e := Label.new() - e.text = "(无进行中的任务)" - e.add_theme_font_size_override("font_size", 12) - _list.add_child(e) - return - var end_index := mini(_quests.size(), _quest_start_index + QUEST_MAX_NUM) - for data_index in range(_quest_start_index, end_index): - var q: Dictionary = _quests[data_index] - _visible_quest_indices.append(data_index) - var quest_index := int(q.get("index", -1)) - var clock_name := String(q.get("clock_name", "")) - if quest_index >= 0 and clock_name != "": - _clock_remaining[quest_index] = _quest_remaining(q) - var box := VBoxContainer.new() - box.add_theme_constant_override("separation", 1) - box.mouse_filter = Control.MOUSE_FILTER_STOP - box.gui_input.connect(func(event): - if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: - select_quest(data_index) - ) - var heading := HBoxContainer.new() - heading.add_theme_constant_override("separation", 6) - var icon_path := String(q.get("icon", "")) - if icon_path == "": - icon_path = "season1/icon/scroll_open.tga" - var icon_tex: Texture2D = UiAssets.load_tex(AssetRoot.path(), icon_path) - if icon_tex: - var icon := TextureRect.new() - icon.texture = icon_tex - icon.custom_minimum_size = Vector2(24, 24) - icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - icon.mouse_filter = Control.MOUSE_FILTER_IGNORE - heading.add_child(icon) - var text_column := VBoxContainer.new() - text_column.mouse_filter = Control.MOUSE_FILTER_IGNORE - var t := Label.new() - t.text = "◆ " + String(q.get("title", "")) - t.add_theme_font_size_override("font_size", 13) - t.mouse_filter = Control.MOUSE_FILTER_IGNORE - text_column.add_child(t) - var cn := String(q.get("counter_name", "")) - if cn != "": - var cl := Label.new() - cl.text = " %s: %d" % [cn, int(q.get("counter_value", 0))] - cl.add_theme_font_size_override("font_size", 11) - cl.modulate = Color(0.8, 0.85, 0.7) - cl.mouse_filter = Control.MOUSE_FILTER_IGNORE - text_column.add_child(cl) - if clock_name != "": - var kl := Label.new() - kl.add_theme_font_size_override("font_size", 11) - kl.modulate = Color(0.7, 0.8, 0.9) - kl.mouse_filter = Control.MOUSE_FILTER_IGNORE - text_column.add_child(kl) - _clock_labels[quest_index] = kl - _update_clock_label(quest_index, kl, clock_name) - heading.add_child(text_column) - heading.mouse_filter = Control.MOUSE_FILTER_IGNORE - box.add_child(heading) - _list.add_child(box) - -func _process(delta: float) -> void: - # 组队窗可见性变化 → 重排(__ArrangeQuestButton 每帧读 wndParty.IsShow())。 - if _party_probe.is_valid(): - var shown := bool(_party_probe.call()) - if shown != _party_shown: - _party_shown = shown - _refresh_quest_buttons() - if _clock_remaining.is_empty(): - return - _clock_accumulator += delta - if _clock_accumulator < 1.0: - return - var elapsed := int(_clock_accumulator) - _clock_accumulator -= float(elapsed) - for q in _quests: - var quest_index := int(q.get("index", -1)) - if quest_index < 0 or not _clock_remaining.has(quest_index): - continue - # CPythonQuest::GetQuestLastTime derives this from iStartTime on every - # update. Recompute from the captured start instead of decrementing a - # window-local copy, so closing and reopening the window cannot reset time. - _clock_remaining[quest_index] = _quest_remaining(q) - if is_open() and _clock_labels.has(quest_index): - _update_clock_label(quest_index, _clock_labels[quest_index], "") - -func _update_clock_label(quest_index: int, label: Label, name: String) -> void: - var remaining := int(_clock_remaining.get(quest_index, 0)) - var prefix := name - if prefix == "": - var old := String(label.text) - prefix = old.get_slice(":", 0) if old.contains(":") else "时间" - if remaining <= 0: - label.text = " %s:已超时" % prefix - return - var minutes := remaining / 60 - var seconds := remaining % 60 - var value := "%d秒" % seconds - if minutes > 0: - value = "%d分%s" % [minutes, value] if seconds > 0 else "%d分" % minutes - label.text = " %s:%s" % [prefix, value] - -func _build(parent: Node) -> void: - # 任务信件按钮条:绝对定位以 1:1 复刻 __ArrangeQuestButton 的多列布局, - # 因此 root 落在屏幕原点,按钮坐标里已含 xPos / yPos。 - _button_root = Control.new() - _button_root.set_anchors_preset(Control.PRESET_FULL_RECT) - _button_root.position = Vector2.ZERO - _button_root.mouse_filter = Control.MOUSE_FILTER_IGNORE - parent.add_child(_button_root) - _button_list = Control.new() - _button_list.set_anchors_preset(Control.PRESET_FULL_RECT) - _button_list.mouse_filter = Control.MOUSE_FILTER_IGNORE - _button_root.add_child(_button_list) - - _root = Control.new() - _root.set_anchors_preset(Control.PRESET_TOP_RIGHT) - _root.position = Vector2(-320, 60) - _root.size = Vector2(300, 360) - _root.visible = false - _root.set_meta("is_titlebar", true) - parent.add_child(_root) - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.07, 0.08, 0.1, 0.95) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - var title := Label.new() - title.text = "任务日志" - title.position = Vector2(12, 8) - _root.add_child(title) - _list = VBoxContainer.new() - _list.position = Vector2(12, 34) - _list.size = Vector2(260, 310) - _list.custom_minimum_size = Vector2(260, 310) - _list.add_theme_constant_override("separation", 8) - _root.add_child(_list) - _quest_scrollbar = VScrollBar.new() - _quest_scrollbar.position = Vector2(278, 34) - _quest_scrollbar.size = Vector2(14, 310) - _quest_scrollbar.step = 1.0 - _quest_scrollbar.page = 1.0 - _quest_scrollbar.visible = false - _quest_scrollbar.value_changed.connect(_on_quest_scroll) - _root.add_child(_quest_scrollbar) - -func _on_quest_scroll(value: float) -> void: - var next_start := clampi(roundi(value), 0, maxi(0, _quests.size() - QUEST_MAX_NUM)) - if next_start == _quest_start_index: - return - _quest_start_index = next_start - _render_quests() - -func _refresh_quest_buttons() -> void: - if _button_list == null: - return - for child in _button_list.get_children(): - child.queue_free() - # 顺序 = questButtonList(最新在前)。清掉 order 里 data 已删的残留 index。 - var order: Array[int] = [] - for idx in _quest_button_order: - if _quest_button_data.has(idx): - order.append(int(idx)) - _quest_button_order = order - var screen := _screen_size() - var x_pos := QUEST_BTN_X_PARTY if _party_shown else QUEST_BTN_X - var y_pos := quest_button_y_pos(screen.y) - var y_count := quest_button_y_count(screen.y) - var slot := 0 - for index in _quest_button_order: - var spec: Dictionary = _quest_button_data[index] - var button := Button.new() - button.custom_minimum_size = QUEST_BTN_SIZE - button.size = QUEST_BTN_SIZE - button.position = quest_button_slot_pos(slot, y_count, x_pos, y_pos) - button.alignment = HORIZONTAL_ALIGNMENT_LEFT - button.text = " " + String(spec.get("title", "")) - button.tooltip_text = String(spec.get("title", "")) - button.add_theme_font_size_override("font_size", 12) - if String(spec.get("icon_type", "")) == "highlight": - button.modulate = Color(1.0, 0.85, 0.35) - var tex := _quest_icon(spec) - if tex: - button.icon = tex - button.expand_icon = true - button.icon_max_width = 32 - var captured := int(index) - button.pressed.connect(func(): _start_quest(captured)) - _button_list.add_child(button) - slot += 1 - _update_button_visibility() - -func _quest_icon(spec: Dictionary) -> Texture2D: - var icon_type := String(spec.get("icon_type", "")) - var icon_name := String(spec.get("icon_name", "")) - if icon_type == "item": - var vnum := int(icon_name) - var path := "" - if _item_list and _item_list.has_method("icon"): - path = String(_item_list.icon(vnum)) - if path == "": - path = "icon/item/%05d.tga" % ((vnum / 10) * 10) - return UiAssets.load_tex(_assets_root if _assets_root != "" else AssetRoot.path(), path) - if icon_name != "": - return UiAssets.load_tex(_assets_root if _assets_root != "" else AssetRoot.path(), icon_name) - return null - -func _start_quest(index: int) -> void: - if client and client.has_method("script_button"): - client.script_button(index) - # ClientVS22 removes the letter as soon as it is clicked. The server can - # still re-send it if the quest remains actionable. - _quest_button_data.erase(index) - _quest_button_order.erase(index) - _refresh_quest_buttons() - -func _update_button_visibility() -> void: - if _button_root: - _button_root.visible = not _mobile_mode and _quest_buttons_visible \ - and not _quest_buttons_suppressed and not _quest_button_data.is_empty() diff --git a/project/ui/quest_log.gd.uid b/project/ui/quest_log.gd.uid deleted file mode 100644 index 4a73b240..00000000 --- a/project/ui/quest_log.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dw1hym64mtt6g diff --git a/project/ui/quickbar.gd b/project/ui/quickbar.gd deleted file mode 100644 index f456f836..00000000 --- a/project/ui/quickbar.gd +++ /dev/null @@ -1,1651 +0,0 @@ -# Quickbar (P6) —— 服务端 36 槽;参考客户端本地显示 4 页 × 8 格(32 格)。 -# -# var qb := preload("res://ui/quickbar.gd").new() -# add_child(qb) -# qb.setup(m2client, skill_table, canvas_parent, player_getter) -# qb.assign(0, "skill", 1) # 当前页 0 号格放技能 id 1 -# qb.assign(1, "item", 5) # 当前页 1 号格放背包 5 格的药水 -# # 输入:数字 1..4 -> 本页 0..3,F1..F4 -> 本页 4..7。 -# -# 技能施放顺序对齐 ClientVS22/PythonPlayerSkill.cpp:先由本地动作生成 -# FUNC_SKILL|motion + arg,再发送 CG_USE_SKILL(skill, target)。 -# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm, arg)(默认朝向 = 玩家 yaw); -# arg 对齐 CActorInstance::__OnUseSkill:低 4 位是动作循环次数,第 4 位是移动技能。 -# 移动端按住技能按钮时,activate_aimed() 会把拖动方向转换为相同的 -# server heading;校验、冷却和目标选择仍走 activate() 的同一条链路。 -# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。 -extends Node - -const PlayerSkill := preload("res://player_skill.gd") -const SkillSlotMap := preload("res://skill_slot_map.gd") -const UiAssets := preload("res://ui/ui_assets.gd") - -const JOB_DIR := { - "WARRIOR": "warrior", - "ASSASSIN": "assassin", - "SURA": "sura", - "SHAMAN": "shaman", -} - -signal skill_activated(skill_id: int) # game_scene 接它播技能特效 -signal skill_cast_started(skill_id: int, target_vid: int) # resolved target, not later selection -# §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案 -signal skill_rejected(skill_id: int, code: String) - -# 底部任务栏功能按钮信号(40250 uitaskbar.py 对齐) -signal character_requested -signal inventory_requested -signal messenger_requested -signal safebox_requested -signal system_requested -signal chat_requested - -const CM := 100.0 -const DEFAULT_CD := 2.0 -const SLOTS_PER_PAGE := 8 -const PAGE_COUNT := 4 -const SLOT_COUNT := 36 # QUICKSLOT_MAX_NUM;最后 4 格没有本地显示页 - -var client: Node -var table: RefCounted # SkillTable -var proto: Node: - set(v): - proto = v - _refresh_page() -var net_play: Node # NetPlay(可空)——技能三层校验的运行期上下文来源 -var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1) -var _player_getter: Callable -var _root: Control -var _dock: Control -var _buttons := {} -var item_mouse: Node -var _assets_root := "" -var _slots := [] # 当前页 UI:[{btn, cd, lbl, icon, grade}] -var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}] -var _skill_cooldowns := {} # skill identity survives slot moves/restores within this UI session -var _page := 0 -var _last_activation_reserved := false # 最近一次激活落到 __ReserveUseSkill(射程外) -var _move_from := -1 -var _mobile_mode := false -var _left_mouse_mode := 0 # 0: 移动与攻击, 1: 自动攻击, 2: 镜头视角 -var _right_mouse_mode := 2 # 0: 移动与攻击, 1: 镜头视角, 2: 技能施放 -var _right_mouse_skill_id := 0 - -# 40250 TaskBar 控件引用 -var _hp_board: Control -var _hp_recovery_bar: ColorRect -var _hp_fill: Control -var _hp_gauge_img: TextureRect -var _hp_frames: Array[Texture2D] = [] -var _sp_board: Control -var _sp_recovery_bar: ColorRect -var _sp_fill: Control -var _sp_gauge_img: TextureRect -var _sp_frames: Array[Texture2D] = [] -var _st_board: Control -var _st_fill: Control -var _st_gauge_img: TextureRect -var _st_frames: Array[Texture2D] = [] -var _gauge_timer := 0.0 -var _gauge_idx := 0 -var _exp_board: Control -var _exp_points: Array[TextureRect] = [] -var _exp_clips: Array[Control] = [] -var _rampage_gauge: TextureRect -var _rampage_frames: Array[Texture2D] = [] -var _rampage_idx := 0 -var _rampage_timer := 0.0 -var _quick_page_num_img: TextureRect -var _cur_hp := 0 -var _max_hp := 0 -var _cur_sp := 0 -var _max_sp := 0 -var _cur_st := 0 -var _max_st := 0 -var _cur_exp := 0 -var _next_exp := 0 - -func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable, - assets_root_override := "") -> void: - client = m2client - table = skill_table - _player_getter = player_getter - _assets_root = assets_root_override if assets_root_override != "" else AssetRoot.path() - _skill_gate = PlayerSkill.new() - _skill_gate.setup(client, table) - for _i in SLOT_COUNT: - _state.append({"kind": "", "id": 0, "cd_end": 0.0}) - _build(parent) - if client.has_signal("skill_cooldown_end"): - client.skill_cooldown_end.connect(_on_cd_end) - if client.has_signal("quickslots_changed"): - client.quickslots_changed.connect(restore_from_server) - if client.has_signal("skills_changed"): - client.skills_changed.connect(func(): - restore_from_server() - _refresh_page()) - if client.has_signal("skill_group_changed"): - client.skill_group_changed.connect(func(_group: int): restore_from_server()) - if client.has_signal("entity_main_set"): - client.entity_main_set.connect(func(_vid: int): restore_from_server()) - # 物品槽引用的是背包 cell:装备/销毁/丢弃后服务器 SyncQuickslot 会推 - # quickslots_changed;这里再挂 inventory_changed 兜住时序窗口(置灰失效引用)。 - if client.has_signal("inventory_changed"): - client.inventory_changed.connect(func(_w, _c): _refresh_page()) - # 死亡/复活:死亡中槽位全部置灰(对齐原版 POS_DEAD 全链路禁用)。 - if client.has_signal("entity_dead"): - client.entity_dead.connect(_on_entity_dead) - # 变身(AFFECT_POLYMORPH=220):技能槽整体遮罩,结束(re-broadcast)后解除。 - if client.has_signal("affect_added"): - client.affect_added.connect(_on_affect_added) - if client.has_signal("affect_removed"): - client.affect_removed.connect(_on_affect_removed) - if client.has_signal("points_changed"): - client.points_changed.connect(_on_points_changed) - if client.has_method("get_points"): - _on_points_changed(client.get_points()) - restore_from_server() - -# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。 -# 参考 ESlotType:1 道具 / 2 技能 / 3 表情 / 4 商店(不可执行)。 -func restore_from_server() -> void: - if client == null or not client.has_method("get_quickslots"): - return - var restored := [] - for _i in SLOT_COUNT: - restored.append({"kind": "", "id": 0, "cd_end": 0.0}) - for qs in client.get_quickslots(): - var pos := int(qs.get("pos", -1)) - if pos < 0 or pos >= SLOT_COUNT: - continue - match int(qs.get("type", 0)): - 1: restored[pos] = {"kind": "item", "id": int(qs.get("ref", 0)), "cd_end": 0.0} - 2: - var skill_slot := int(qs.get("ref", 0)) - var skill_id := _skill_id_from_slot(skill_slot) - # Unknown custom slots remain usable as a compatibility fallback; all - # stock 40250 slots are mapped above and therefore show the correct - # skill tree icon (e.g. warrior group 2 slot 2 -> skill 17, not 2). - if skill_id == 0: - skill_id = skill_slot - restored[pos] = {"kind": "skill", "id": skill_id, "ref": skill_slot, - "cd_end": float(_skill_cooldowns.get(skill_id, 0.0))} - 3: restored[pos] = {"kind": "emote", "id": int(qs.get("ref", 0)), "cd_end": 0.0} - _state = restored - _refresh_page() - -func assign(slot: int, kind: String, id: int, persist := true) -> bool: - if slot < 0 or slot >= SLOTS_PER_PAGE: - return false - if kind not in ["skill", "item", "emote"] or id <= 0: - return false - var global := _global_slot(slot) - if persist and client and client.has_method("quickslot_add"): - var type := 2 if kind == "skill" else 1 if kind == "item" else 3 if kind == "emote" else 0 - var server_ref := _skill_slot_for_id(id) if kind == "skill" else id - if server_ref == 0: - server_ref = id - if type == 0 or not client.quickslot_add(global, type, server_ref): - return false - # The reference client does not mutate its local quickslot here. The - # server's GC_QUICKSLOT_ADD packet is consumed by EntityStore and emits - # quickslots_changed, which calls restore_from_server(). - return true - _state[global] = {"kind": kind, "id": id, - "ref": _skill_slot_for_id(id) if kind == "skill" else id, - "cd_end": float(_skill_cooldowns.get(id, 0.0)) if kind == "skill" else 0.0} - _refresh_slot(slot) - return true - -func _main_race() -> int: - if client and client.has_method("get_main_vid") and client.has_method("get_entity"): - var vid := int(client.get_main_vid()) - if vid != 0: - return int(client.get_entity(vid).get("race", 0)) - return 0 - -func _skill_group() -> int: - var grp := int(client.get_skill_group()) if client and client.has_method("get_skill_group") else 0 - if grp > 0: - return grp - grp = _infer_skill_group() - if grp > 0: - return grp - return 1 - -func _infer_skill_group() -> int: - if client == null or not client.has_method("get_skills"): - return 0 - var race := _main_race() - for sk in client.get_skills(): - var sid := int(sk.get("id", 0)) - var lvl := int(sk.get("level", 0)) - if lvl <= 0 and int(sk.get("master", 0)) == 0: - continue - for g in [1, 2]: - var slot := SkillSlotMap.skill_slot_for_id(race, g, sid) - if slot >= 1 and slot <= 8: - return g - return 0 - -func _skill_id_from_slot(slot: int) -> int: - return SkillSlotMap.skill_id_for_slot(_main_race(), _skill_group(), slot) - -func _skill_slot_for_id(skill_id: int) -> int: - return SkillSlotMap.skill_slot_for_id(_main_race(), _skill_group(), skill_id) - -# Mobile skill pages cannot rely on drag-and-drop into the small PC quickbar. -# Put a selected skill/item/emote into the first free slot on the current page -# while keeping the same server-backed CG_QUICKSLOT_ADD path. -func assign_mobile(kind: String, id: int) -> bool: - for slot in SLOTS_PER_PAGE: - if _state[_global_slot(slot)].kind == "": - return assign(slot, kind, id) - return false - -func mobile_slot_state(slot: int) -> Dictionary: - if slot < 0 or slot >= SLOTS_PER_PAGE: - return {} - return _state[_global_slot(slot)].duplicate(true) - -func mobile_swap(local_a: int, local_b: int) -> bool: - if local_a < 0 or local_a >= SLOTS_PER_PAGE or local_b < 0 or local_b >= SLOTS_PER_PAGE: - return false - if local_a == local_b: - return true - _swap(local_a, local_b) - return true - -func activate(slot: int) -> void: - _activate_slot(slot, Vector2.ZERO) - -## Mobile long-press skill release. `screen_direction` uses screen space: -## right is +X and up is -Y. A zero vector intentionally falls back to the -## player's current facing, so a long press without a drag is still usable. -func activate_aimed(slot: int, screen_direction: Vector2) -> void: - _activate_slot(slot, screen_direction) - -func _activate_slot(slot: int, screen_direction: Vector2) -> void: - if slot < 0 or slot >= SLOTS_PER_PAGE: - return - var global := _global_slot(slot) - var s: Dictionary = _state[global] - if s.kind == "" or s.id == 0: - return - if _now() < s.cd_end: - return - # 死亡中不可使用(服务器 POS_DEAD 拦截一切使用;这里提前本地拦截)。 - if _player_dead(): - return - if s.kind == "skill": - var mi: int = _motion_idx_for_skill(int(s.id)) - var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null - var yaw := 0.0 - var xy := Vector2.ZERO - if p: - yaw = MapCoord.yaw_to_heading(p.rotation.y) - xy = MapCoord.to_server_cm(p.position) - if screen_direction.length() > 0.01: - yaw = _aim_heading(p, screen_direction, yaw) - var target_vid := 0 - if client.has_method("get_target"): - target_vid = int(client.get_target().get("vid", 0)) - # §3.8 修改 1:三层合法性校验(ClickSkillSlot -> __CanUseSkill / - # __CheckSkillUsable / __UseSkill)。挡下就发 skill_rejected(静默码除外), - # TOGGLE_OFF 只发 use_skill 关掉开关技、不施法。 - if _skill_gate: - if net_play and net_play.has_method("skill_context"): - var ctx: Dictionary = net_play.skill_context() - for k in ctx: - _skill_gate.set(k, ctx[k]) - _skill_gate.slot_cd_end = s.cd_end - var gate: Dictionary = _skill_gate.click_skill_slot(int(s.id)) - if not bool(gate.get("ok", false)): - var code := String(gate.get("code", "")) - if code == "GUILD_ROUTED": - _activate_guild_skill(int(s.id)) - return - # PythonPlayerSkill::__CheckSpecialSkill(123) 不是失败:参考端 - # 直接进入 NEW_Fishing,并把 EQUIP_FISHING_ROD 作为唯一错误反馈。 - if code == "SPECIAL_SKILL" and int(s.id) == PlayerSkill.SKILL_INDEX_FISHING \ - and net_play and net_play.has_method("activate_fishing"): - var fishing_result := String(net_play.activate_fishing()) - if fishing_result == "OK": - skill_activated.emit(int(s.id)) - elif not PlayerSkill.is_silent_code(fishing_result): - skill_rejected.emit(int(s.id), fishing_result) - return - if code == "RESERVED": - # __ProcessEnemySkillTargetRange:射程外不发施法,蓄力技未预约时先 - # __SendUseSkill(slot, 0),再 __ReserveUseSkill 交给 NetPlay 趋近。 - _last_activation_reserved = true - if net_play and net_play.has_method("reserve_use_skill"): - var already: bool = net_play.has_method("is_use_skill_reserved") \ - and bool(net_play.is_use_skill_reserved(global)) - if bool(gate.get("charge", false)) and not already and client.has_method("use_skill"): - client.use_skill(int(s.id), 0) - net_play.reserve_use_skill(int(gate.get("target_vid", 0)), global, float(gate.get("range_cm", 0))) - return - if code == "TOGGLE_OFF": - if client.has_method("use_skill"): - client.use_skill(int(s.id), 0) - skill_activated.emit(int(s.id)) - elif not PlayerSkill.is_silent_code(code): - skill_rejected.emit(int(s.id), code) - return - var resolved := int(gate.get("target_vid", 0)) - if resolved != 0: - target_vid = resolved - # PythonPlayerEventHandler::OnUseSkill 先上行 FUNC_SKILL|motion + arg, - # 然后 __SendUseSkill 才发送 CG_USE_SKILL。动作包失败时不产生后续技能意图。 - var motion_sent := true - if client.has_method("cast_skill"): - var motion_arg: int = 0 - if table and table.has_method("motion_state_arg"): - motion_arg = int(table.call("motion_state_arg", int(s.id), _skill_level(int(s.id)))) - motion_sent = bool(client.cast_skill(mi, yaw, int(xy.x), int(xy.y), motion_arg)) - if not motion_sent: - return - skill_cast_started.emit(int(s.id), target_vid) - var intent_sent := true - if client.has_method("use_skill"): - intent_sent = client.use_skill(int(s.id), target_vid) - if intent_sent: - s.cd_end = _now() + _skill_cooldown(int(s.id)) - _state[global] = s - _skill_cooldowns[int(s.id)] = s.cd_end - for peer in _state: - if peer.kind == "skill" and peer.id == s.id: - peer.cd_end = s.cd_end - # §3.4:扇形 / 圆形技能的附加 fly-targeting(主目标已在 use_skill 里发过; - # PythonPlayerSkill.cpp:684 的 `if (dwTargetMaxCount>0 …)` 补目标块)。 - if target_vid != 0 and net_play and table \ - and net_play.has_method("send_fly_targeting") \ - and (table.is_fan_range(int(s.id)) or table.is_circle_range(int(s.id))): - var lvl := _skill_level(int(s.id)) - net_play.send_fly_targeting(target_vid, float(table.target_range(int(s.id))), - table.target_count(int(s.id), lvl), table.fly_shape(int(s.id))) - skill_activated.emit(int(s.id)) - elif s.kind == "item": - client.use_item(1, s.id) # WINDOW_INVENTORY - elif s.kind == "emote" and client.has_method("send_emoticon"): - client.send_emoticon(s.id) - -func _activate_guild_skill(skill_id: int) -> void: - if _skill_gate == null or not _skill_gate.has_method("use_guild_skill"): - return - _skill_gate.slot_cd_end = float(_skill_cooldowns.get(skill_id, 0.0)) - var gate: Dictionary = _skill_gate.use_guild_skill(skill_id) - if not bool(gate.get("ok", false)): - var code := String(gate.get("code", "")) - if not PlayerSkill.is_silent_code(code): - skill_rejected.emit(skill_id, code) - return - var motion_idx: int = int(table.motion_idx_of(skill_id)) if table else 0 - var motion_sent := true - if client.has_method("cast_skill"): - # UseGuildSkill calls NEW_UseSkill(..., loop=1, moving=false). - motion_sent = bool(client.cast_skill(motion_idx, 0.0, 0, 0, 1)) - if not motion_sent: - return - skill_cast_started.emit(skill_id, 0) - if not client.has_method("use_guild_skill") or not client.use_guild_skill(skill_id, 0): - return - var cd_end := _now() + _skill_cooldown(skill_id) - _skill_cooldowns[skill_id] = cd_end - for peer in _state: - if peer.kind == "skill" and peer.id == skill_id: - peer.cd_end = cd_end - skill_activated.emit(skill_id) - -## 从技能窗口右键直接激活技能 -func activate_skill_direct(skill_id: int) -> void: - if skill_id <= 0 or _player_dead(): - return - var s := {"kind": "skill", "id": skill_id, "cd_end": float(_skill_cooldowns.get(skill_id, 0.0))} - if _now() < s.cd_end: - return - var mi: int = _motion_idx_for_skill(skill_id) - var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null - var yaw := 0.0 - var xy := Vector2.ZERO - if p: - yaw = MapCoord.yaw_to_heading(p.rotation.y) - xy = MapCoord.to_server_cm(p.position) - var target_vid := 0 - if client.has_method("get_target"): - target_vid = int(client.get_target().get("vid", 0)) - if _skill_gate: - if net_play and net_play.has_method("skill_context"): - var ctx: Dictionary = net_play.skill_context() - for k in ctx: - _skill_gate.set(k, ctx[k]) - _skill_gate.slot_cd_end = s.cd_end - var gate: Dictionary = _skill_gate.click_skill_slot(skill_id) - if not bool(gate.get("ok", false)): - var code := String(gate.get("code", "")) - if code == "GUILD_ROUTED": - _activate_guild_skill(skill_id) - return - if code == "TOGGLE_OFF": - if client.has_method("use_skill"): - client.use_skill(skill_id, 0) - skill_activated.emit(skill_id) - elif not PlayerSkill.is_silent_code(code): - skill_rejected.emit(skill_id, code) - return - var resolved := int(gate.get("target_vid", 0)) - if resolved != 0: - target_vid = resolved - # Direct skill-window activation follows the same OnUseSkill -> SendUseSkill order. - var motion_sent := true - if client.has_method("cast_skill"): - var motion_arg: int = 0 - if table and table.has_method("motion_state_arg"): - motion_arg = int(table.call("motion_state_arg", skill_id, _skill_level(skill_id))) - motion_sent = bool(client.cast_skill(mi, yaw, int(xy.x), int(xy.y), motion_arg)) - if not motion_sent: - return - skill_cast_started.emit(skill_id, target_vid) - var intent_sent := true - if client.has_method("use_skill"): - intent_sent = client.use_skill(skill_id, target_vid) - if intent_sent: - var cd_end := _now() + _skill_cooldown(skill_id) - _skill_cooldowns[skill_id] = cd_end - for peer in _state: - if peer.kind == "skill" and peer.id == skill_id: - peer.cd_end = cd_end - if target_vid != 0 and net_play and table \ - and net_play.has_method("send_fly_targeting") \ - and (table.is_fan_range(skill_id) or table.is_circle_range(skill_id)): - var lvl := _skill_level(skill_id) - net_play.send_fly_targeting(target_vid, float(table.target_range(skill_id)), - table.target_count(skill_id, lvl), table.fly_shape(skill_id)) - skill_activated.emit(skill_id) - -func _aim_heading(player_node: Node3D, screen_direction: Vector2, fallback: float) -> float: - # Keep the historical POC heading convention as the base and add the - # camera-relative drag angle. This makes an upward drag equal to the - # existing one-tap cast, while right/left drags rotate it predictably. - var angle_from_up := rad_to_deg(atan2(screen_direction.x, -screen_direction.y)) - var camera_heading := player_node.rotation.y - if net_play and "camera" in net_play and net_play.camera \ - and net_play.camera.has_method("heading"): - camera_heading = float(net_play.camera.heading()) - var camera_delta := rad_to_deg(camera_heading - player_node.rotation.y) - return fposmod(fallback + camera_delta + angle_from_up, 360.0) - -# 服务器权威的死亡态(GC add chr state DEAD);复活后 entity_info 会再触发刷新。 -func _player_dead() -> bool: - if client == null or not client.has_method("get_main_vid"): - return false - var me: Dictionary = client.get_entity(client.get_main_vid()) - return me != null and bool(me.get("dead", false)) - -# 变身态(POINT_POLYMORPH / affect 220):技能槽遮罩的数据源。 -func _polymorphed() -> bool: - if client == null or not client.has_method("get_main_vid"): - return false - var me: Dictionary = client.get_entity(client.get_main_vid()) - return me != null and (int(me.get("polymorph", 0)) != 0 or int(me.get("poly_vnum", 0)) != 0) - -func _on_entity_dead(vid: int) -> void: - if client and client.has_method("get_main_vid") and vid == client.get_main_vid(): - _refresh_page() - -# 只关心 AFFECT_POLYMORPH(220) 的增减,避免每次 buff 变化都整页重刷。 -const AFFECT_POLYMORPH := 220 - -func _on_affect_added(affect: Dictionary) -> void: - if int(affect.get("type", 0)) == AFFECT_POLYMORPH: - _refresh_page() - -func _on_affect_removed(type: int) -> void: - if type == AFFECT_POLYMORPH: - _refresh_page() - -# net_play 的 MODE_USE_SKILL 预约进射程后回调(use_skill_hook)——slot 为全局快捷栏 -# 索引。返回是否确实发起了施法(对齐参考端 `if (__UseSkill(slot)) __ClearReservedAction()`)。 -func activate_reserved(global_slot: int) -> bool: - if global_slot < 0 or global_slot >= SLOT_COUNT: - return false - var local := global_slot - _page * SLOTS_PER_PAGE - if local < 0 or local >= SLOTS_PER_PAGE: - return false - var s: Dictionary = _state[global_slot] - if s.kind != "skill" or s.id == 0 or _now() < s.cd_end: - return false - _last_activation_reserved = false - activate(local) - # 再次落到射程外时新预约已建立,不能让 NetPlay 把它当作已消费清掉。 - return not _last_activation_reserved - -# client.get_skills() 里该技能的当前等级(quickbar 施法时算 target_count 用)。 -func _skill_level(sid: int) -> int: - if client == null or not client.has_method("get_skills"): - return 0 - for sk in client.get_skills(): - if int(sk.get("id", 0)) == sid: - return int(sk.get("level", 0)) - return 0 - -# CPythonSkill::GetSkillMotionIndex(iGrade);保留旧 SkillTable 的单参数回退, -# 让最小测试桩和第三方表仍可工作。 -func _motion_idx_for_skill(skill_id: int) -> int: - if table == null: - return 0 - var grade := _skill_grade(skill_id) - if table.has_method("motion_idx_for_grade"): - return int(table.call("motion_idx_for_grade", skill_id, grade)) - return int(table.motion_idx_of(skill_id)) - -func get_page() -> int: - return _page - -func set_page(page: int) -> void: - # CPythonPlayer::SetQuickPage wraps the requested page by - # QUICKSLOT_MAX_LINE. Keyboard/game code and recovery paths can call this - # entry point directly with -1 or PAGE_COUNT. - var normalized := posmod(page, PAGE_COUNT) - if normalized == _page: - return - _page = normalized - _move_from = -1 - _refresh_page() - -func get_left_mouse_mode() -> int: - return _left_mouse_mode - -func set_left_mouse_mode(mode: int) -> void: - _left_mouse_mode = posmod(mode, 3) - _update_mouse_mode_buttons() - -func get_right_mouse_mode() -> int: - return _right_mouse_mode - -func set_right_mouse_mode(mode: int) -> void: - _right_mouse_mode = posmod(mode, 3) - _update_mouse_mode_buttons() - -func set_right_mouse_skill(skill_id: int) -> void: - _right_mouse_skill_id = skill_id - _right_mouse_mode = 2 - _update_mouse_mode_buttons() - -func get_right_mouse_skill() -> int: - return _right_mouse_skill_id - -func _update_mouse_mode_buttons() -> void: - var btn_l: BaseButton = _buttons.get("mouse_left", null) - if btn_l: - match _left_mouse_mode: - 0: btn_l.tooltip_text = "攻击 / 移动 (鼠标左键)" - 1: btn_l.tooltip_text = "自动攻击 (鼠标左键)" - 2: btn_l.tooltip_text = "镜头旋转 (鼠标左键)" - var btn_r: BaseButton = _buttons.get("mouse_right", null) - if btn_r: - match _right_mouse_mode: - 0: btn_r.tooltip_text = "攻击 / 移动 (鼠标右键)" - 1: btn_r.tooltip_text = "镜头旋转 (鼠标右键)" - 2: - if _right_mouse_skill_id > 0 and table and table.has_method("skill_name"): - btn_r.tooltip_text = "技能快捷施放: %s (鼠标右键)" % table.skill_name(_right_mouse_skill_id) - else: - btn_r.tooltip_text = "技能快捷施放 (鼠标右键)" - -func toggle_visible() -> void: - if _root: - _root.visible = not _root.visible - if _dock: - _dock.visible = not _dock.visible - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - if _root: - _root.visible = not enabled - if _dock: - _dock.visible = not enabled - -func get_dock() -> Control: - return _dock - -func get_button(btn_name: String) -> Control: - return _buttons.get(btn_name, null) - -func _exit_tree() -> void: - if _dock and is_instance_valid(_dock): - _dock.queue_free() - _dock = null - -func is_mobile_mode() -> bool: - return _mobile_mode - -func is_visible() -> bool: - return _root != null and _root.visible - -func _global_slot(local_slot: int) -> int: - return _page * SLOTS_PER_PAGE + local_slot - -func _clear(slot: int) -> void: - if slot < 0 or slot >= SLOTS_PER_PAGE: - return - var global := _global_slot(slot) - if _state[global].kind == "": - return - if client and client.has_method("quickslot_del"): - if not client.quickslot_del(global): - return - # 40250 changes the authoritative local slot only from - # GC_QUICKSLOT_DEL. Do not clear the Godot mirror optimistically: a - # rejected, delayed, or out-of-order request must remain recoverable from - # the last server snapshot. - return - _state[global] = {"kind": "", "id": 0, "cd_end": 0.0} - _refresh_slot(slot) - -func _swap(local_a: int, local_b: int) -> void: - if local_a < 0 or local_a >= SLOTS_PER_PAGE or local_b < 0 or local_b >= SLOTS_PER_PAGE: - return - if local_a == local_b: - return - var a := _global_slot(local_a) - var b := _global_slot(local_b) - if client and client.has_method("quickslot_swap"): - if not client.quickslot_swap(a, b): - return - # GC_QUICKSLOT_SWAP is the only local state transition. - return - var tmp: Dictionary = _state[a] - _state[a] = _state[b] - _state[b] = tmp - _refresh_slot(local_a) - _refresh_slot(local_b) - -# --- UI --------------------------------------------------------------- - -func _build(parent: Node) -> void: - _root = Control.new() - _root.name = "TaskBar" - _root.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) - _root.offset_top = -37 - _root.offset_bottom = 0 - _root.offset_left = 0 - _root.offset_right = 0 - _root.custom_minimum_size = Vector2(0, 37) - _root.z_index = 10 - _root.mouse_filter = Control.MOUSE_FILTER_PASS - parent.add_child(_root) - - # 1. Base_Board_01: TaskBar_Base.tga 全屏平铺底纹 - var base_bg := TextureRect.new() - base_bg.name = "Base_Board_01" - base_bg.set_anchors_preset(Control.PRESET_FULL_RECT) - base_bg.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/taskbar_base.tga") - base_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - base_bg.stretch_mode = TextureRect.STRETCH_TILE - base_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE - _root.add_child(base_bg) - - # 2. Gauge_Board: gauge.sub 位于 (0, -10) - var gauge_board := TextureRect.new() - gauge_board.name = "Gauge_Board" - gauge_board.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/gauge.sub") - gauge_board.position = Vector2(0, -10) - gauge_board.size = Vector2(158, 47) - gauge_board.mouse_filter = Control.MOUSE_FILTER_PASS - _root.add_child(gauge_board) - - # Rampage 暴气红球(17帧循环) - _rampage_gauge = TextureRect.new() - _rampage_gauge.name = "RampageGauge" - _rampage_gauge.position = Vector2(8, 4) - _rampage_gauge.size = Vector2(40, 40) - _rampage_gauge.mouse_filter = Control.MOUSE_FILTER_IGNORE - gauge_board.add_child(_rampage_gauge) - _rampage_frames.clear() - for i in range(17): - var frame_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/rampage_01/%02d.sub" % i) - if frame_tex: - _rampage_frames.append(frame_tex) - if not _rampage_frames.is_empty(): - _rampage_gauge.texture = _rampage_frames[0] - - # HPGauge_Board: (59, 14, 95, 11) - _hp_board = Control.new() - _hp_board.name = "HPGauge_Board" - _hp_board.position = Vector2(59, 14) - _hp_board.size = Vector2(95, 11) - _hp_board.mouse_filter = Control.MOUSE_FILTER_STOP - _hp_board.tooltip_text = "HP : 0 / 0" - gauge_board.add_child(_hp_board) - - _hp_recovery_bar = ColorRect.new() - _hp_recovery_bar.name = "HPRecoveryGaugeBar" - _hp_recovery_bar.color = Color(1.0, 0.0, 0.0, 0.33) - _hp_recovery_bar.position = Vector2(0, 0) - _hp_recovery_bar.size = Vector2(0, 11) - _hp_recovery_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE - _hp_recovery_bar.visible = false - _hp_board.add_child(_hp_recovery_bar) - - _hp_fill = Control.new() - _hp_fill.name = "HPFill" - _hp_fill.clip_contents = true - _hp_fill.size = Vector2(95, 11) - _hp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE - _hp_board.add_child(_hp_fill) - - _hp_gauge_img = TextureRect.new() - _hp_gauge_img.name = "HPGauge" - _hp_gauge_img.size = Vector2(95, 11) - _hp_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE - _hp_fill.add_child(_hp_gauge_img) - - _hp_frames.clear() - for i in range(1, 8): - var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/hpgauge/%02d.tga" % i) - if tex: - _hp_frames.append(tex) - if not _hp_frames.is_empty(): - _hp_gauge_img.texture = _hp_frames[0] - - # SPGauge_Board: (59, 24, 95, 11) - _sp_board = Control.new() - _sp_board.name = "SPGauge_Board" - _sp_board.position = Vector2(59, 24) - _sp_board.size = Vector2(95, 11) - _sp_board.mouse_filter = Control.MOUSE_FILTER_STOP - _sp_board.tooltip_text = "SP : 0 / 0" - gauge_board.add_child(_sp_board) - - _sp_recovery_bar = ColorRect.new() - _sp_recovery_bar.name = "SPRecoveryGaugeBar" - _sp_recovery_bar.color = Color(0.0, 0.0, 1.0, 0.33) - _sp_recovery_bar.position = Vector2(0, 0) - _sp_recovery_bar.size = Vector2(0, 11) - _sp_recovery_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE - _sp_recovery_bar.visible = false - _sp_board.add_child(_sp_recovery_bar) - - _sp_fill = Control.new() - _sp_fill.name = "SPFill" - _sp_fill.clip_contents = true - _sp_fill.size = Vector2(95, 11) - _sp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE - _sp_board.add_child(_sp_fill) - - _sp_gauge_img = TextureRect.new() - _sp_gauge_img.name = "SPGauge" - _sp_gauge_img.size = Vector2(95, 11) - _sp_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE - _sp_fill.add_child(_sp_gauge_img) - - _sp_frames.clear() - for i in range(1, 8): - var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/spgauge/%02d.tga" % i) - if tex: - _sp_frames.append(tex) - if not _sp_frames.is_empty(): - _sp_gauge_img.texture = _sp_frames[0] - - # STGauge_Board: (59, 38, 95, 6) - _st_board = Control.new() - _st_board.name = "STGauge_Board" - _st_board.position = Vector2(59, 38) - _st_board.size = Vector2(95, 6) - _st_board.mouse_filter = Control.MOUSE_FILTER_STOP - _st_board.tooltip_text = "ST : 0 / 0" - gauge_board.add_child(_st_board) - - _st_fill = Control.new() - _st_fill.name = "STFill" - _st_fill.clip_contents = true - _st_fill.size = Vector2(95, 6) - _st_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE - _st_board.add_child(_st_fill) - - _st_gauge_img = TextureRect.new() - _st_gauge_img.name = "STGauge" - _st_gauge_img.size = Vector2(95, 6) - _st_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE - _st_fill.add_child(_st_gauge_img) - - _st_frames.clear() - for i in range(1, 8): - var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/stgauge/%02d.tga" % i) - if tex: - _st_frames.append(tex) - if not _st_frames.is_empty(): - _st_gauge_img.texture = _st_frames[0] - - # 3. EXP_Gauge_Board: (158, 0, 105, 37) - _exp_board = TextureRect.new() - _exp_board.name = "EXP_Gauge_Board" - _exp_board.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/exp_gauge.sub") - _exp_board.position = Vector2(158, 0) - _exp_board.size = Vector2(105, 37) - _exp_board.mouse_filter = Control.MOUSE_FILTER_STOP - _exp_board.tooltip_text = "EXP : 0.00%" - _root.add_child(_exp_board) - - _exp_points.clear() - _exp_clips.clear() - for i in 4: - var clip_box := Control.new() - clip_box.name = "EXPGauge_Clip_%02d" % (i + 1) - clip_box.position = Vector2(5 + i * 25, 9) - clip_box.size = Vector2(19, 19) - clip_box.clip_contents = true - clip_box.mouse_filter = Control.MOUSE_FILTER_IGNORE - clip_box.visible = false - _exp_board.add_child(clip_box) - _exp_clips.append(clip_box) - - var pt := TextureRect.new() - pt.name = "EXPGauge_%02d" % (i + 1) - pt.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/exp_gauge_point.sub") - pt.position = Vector2(0, 0) - pt.size = Vector2(19, 19) - pt.mouse_filter = Control.MOUSE_FILTER_IGNORE - clip_box.add_child(pt) - _exp_points.append(pt) - - # 4. 中央控制栏 (CenterBar): 水平居中 (SCREEN_WIDTH/2 - 128 到 SCREEN_WIDTH/2 + 237) - var center_bar := Control.new() - center_bar.name = "CenterBar" - center_bar.anchor_left = 0.5 - center_bar.anchor_right = 0.5 - center_bar.anchor_top = 0.0 - center_bar.anchor_bottom = 0.0 - center_bar.offset_left = -128 - center_bar.offset_right = 237 - center_bar.offset_top = 0 - center_bar.offset_bottom = 37 - center_bar.size = Vector2(365, 37) - center_bar.mouse_filter = Control.MOUSE_FILTER_PASS - _root.add_child(center_bar) - - # LeftMouseButton: (0, 3) -> SCREEN_WIDTH/2 - 128 - var mouse_left := _create_taskbar_btn("LeftMouseButton", - "ETC/ymir work/ui/game/taskbar/mouse_button_move_01.sub", - "ETC/ymir work/ui/game/taskbar/mouse_button_move_02.sub", - "ETC/ymir work/ui/game/taskbar/mouse_button_move_03.sub", - "攻击 / 移动 (鼠标左键)", func(): set_left_mouse_mode(_left_mouse_mode + 1)) - mouse_left.position = Vector2(0, 3) - center_bar.add_child(mouse_left) - _buttons["mouse_left"] = mouse_left - - # quickslot_board: (42, 0, 283, 37) -> SCREEN_WIDTH/2 - 86 - var qb_board := Control.new() - qb_board.name = "quickslot_board" - qb_board.position = Vector2(42, 0) - qb_board.size = Vector2(283, 37) - qb_board.mouse_filter = Control.MOUSE_FILTER_PASS - center_bar.add_child(qb_board) - - if item_mouse and item_mouse.has_method("register_target"): - item_mouse.register_target(qb_board, func(payload: Dictionary, pos: Vector2 = Vector2.ZERO): - return _drop_on_quickslot_board(payload, pos), self) - - qb_board.set_drag_forwarding( - Callable(), - func(_at_pos: Vector2, data: Variant) -> bool: - return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"], - func(at_pos: Vector2, data: Variant) -> void: - if data is Dictionary: - var slot_idx := -1 - if at_pos.x < 135.0: - slot_idx = clampi(int(floor(at_pos.x / 32.0)), 0, 3) - elif at_pos.x >= 135.0: - slot_idx = clampi(int(floor((at_pos.x - 142.0) / 32.0)), 0, 3) + 4 - if slot_idx >= 0 and slot_idx < 8: - _apply_drag_data_to_slot(data, slot_idx) - ) - - _slots.clear() - var slot_base_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/slot_base.sub") - - # quick_slot_1: 4 格 (0..3) - var qs1 := Control.new() - qs1.name = "quick_slot_1" - qs1.position = Vector2(0, 3) - qs1.size = Vector2(128, 32) - qb_board.add_child(qs1) - - qs1.set_drag_forwarding( - Callable(), - func(_at_pos: Vector2, data: Variant) -> bool: - return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"], - func(at_pos: Vector2, data: Variant) -> void: - if data is Dictionary: - var slot_idx := clampi(int(floor(at_pos.x / 32.0)), 0, 3) - _apply_drag_data_to_slot(data, slot_idx) - ) - - for i in 4: - var slot_ctrl := _create_quickslot_widget(i, slot_base_tex, "ETC/ymir work/ui/game/taskbar/%d.sub" % (i + 1)) - slot_ctrl.position = Vector2(i * 32, 0) - qs1.add_child(slot_ctrl) - - # ChatButton / ExpandButton: (128, 1) - var chat_btn := _create_chat_btn() - chat_btn.position = Vector2(128, 1) - qb_board.add_child(chat_btn) - _buttons["chat"] = chat_btn - - # quick_slot_2: 4 格 (4..7) - var qs2 := Control.new() - qs2.name = "quick_slot_2" - qs2.position = Vector2(142, 3) - qs2.size = Vector2(128, 32) - qb_board.add_child(qs2) - - qs2.set_drag_forwarding( - Callable(), - func(_at_pos: Vector2, data: Variant) -> bool: - return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"], - func(at_pos: Vector2, data: Variant) -> void: - if data is Dictionary: - var slot_idx := clampi(int(floor(at_pos.x / 32.0)), 0, 3) + 4 - _apply_drag_data_to_slot(data, slot_idx) - ) - - for i in range(4, 8): - var slot_ctrl := _create_quickslot_widget(i, slot_base_tex, "ETC/ymir work/ui/game/taskbar/f%d.sub" % (i - 3)) - slot_ctrl.position = Vector2((i - 4) * 32, 0) - qs2.add_child(slot_ctrl) - - # QuickSlotBoard (翻页板): (272, 0, 11, 37) - var qsb := Control.new() - qsb.name = "QuickSlotBoard" - qsb.position = Vector2(272, 0) - qsb.size = Vector2(11, 37) - qb_board.add_child(qsb) - - var num_box := TextureRect.new() - num_box.name = "QuickSlotNumberBox" - num_box.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_button_board.sub") - num_box.position = Vector2(1, 15) - num_box.size = Vector2(11, 11) - num_box.mouse_filter = Control.MOUSE_FILTER_IGNORE - qsb.add_child(num_box) - - var page_up := TextureButton.new() - page_up.name = "QuickPageUpButton" - page_up.position = Vector2(1, 9) - page_up.size = Vector2(11, 6) - page_up.tooltip_text = "上一页 (Shift+1)" - page_up.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_01.sub") - page_up.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_02.sub") - page_up.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_03.sub") - page_up.pressed.connect(func(): set_page(posmod(_page - 1, PAGE_COUNT))) - qsb.add_child(page_up) - - _quick_page_num_img = TextureRect.new() - _quick_page_num_img.name = "QuickPageNumber" - _quick_page_num_img.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/1.sub") - _quick_page_num_img.position = Vector2(3, 15) - _quick_page_num_img.size = Vector2(7, 9) - _quick_page_num_img.mouse_filter = Control.MOUSE_FILTER_IGNORE - qsb.add_child(_quick_page_num_img) - - var page_down := TextureButton.new() - page_down.name = "QuickPageDownButton" - page_down.position = Vector2(1, 24) - page_down.size = Vector2(11, 6) - page_down.tooltip_text = "下一页 (Shift+2)" - page_down.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_01.sub") - page_down.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_02.sub") - page_down.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_03.sub") - page_down.pressed.connect(func(): set_page(posmod(_page + 1, PAGE_COUNT))) - qsb.add_child(page_down) - - # RightMouseButton: (333, 3) -> SCREEN_WIDTH/2 + 205 - var mouse_right := _create_taskbar_btn("RightMouseButton", - "ETC/ymir work/ui/game/taskbar/mouse_button_move_01.sub", - "ETC/ymir work/ui/game/taskbar/mouse_button_move_02.sub", - "ETC/ymir work/ui/game/taskbar/mouse_button_move_03.sub", - "技能快捷施放 (鼠标右键)", func(): set_right_mouse_mode(_right_mouse_mode + 1)) - mouse_right.position = Vector2(333, 3) - center_bar.add_child(mouse_right) - _buttons["mouse_right"] = mouse_right - _update_mouse_mode_buttons() - - _refresh_page() - _build_dock(_root) - -func _create_quickslot_widget(i: int, slot_base_tex: Texture2D, corner_sub: String) -> Control: - var slot := Panel.new() - slot.name = "slot_%d" % i - slot.custom_minimum_size = Vector2(32, 32) - slot.size = Vector2(32, 32) - if slot_base_tex: - var sbt := StyleBoxTexture.new() - sbt.texture = slot_base_tex - slot.add_theme_stylebox_override("panel", sbt) - else: - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.1, 0.11, 0.14, 0.9) - sb.border_color = Color(0.35, 0.32, 0.26) - sb.set_border_width_all(1) - slot.add_theme_stylebox_override("panel", sb) - - var icon := TextureRect.new() - icon.name = "icon" - icon.position = Vector2(0, 0) - icon.size = Vector2(32, 32) - icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - icon.mouse_filter = Control.MOUSE_FILTER_IGNORE - slot.add_child(icon) - - # 40250 原版角标 (1..4 / F1..F4) 在左上角 (3, 3) 处显示 - if corner_sub != "": - var corner_tex := UiAssets.load_tex(_assets_root, corner_sub) - if corner_tex: - var corner := TextureRect.new() - corner.name = "Corner" - corner.texture = corner_tex - corner.position = Vector2(3, 3) - corner.size = corner_tex.get_size() - corner.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - corner.mouse_filter = Control.MOUSE_FILTER_IGNORE - slot.add_child(corner) - - var lbl := Label.new() - lbl.name = "lbl" - lbl.position = Vector2(2, 16) - lbl.size = Vector2(28, 14) - lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - lbl.add_theme_font_size_override("font_size", 9) - lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) - lbl.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95)) - lbl.add_theme_constant_override("shadow_offset_x", 1) - lbl.add_theme_constant_override("shadow_offset_y", 1) - lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - slot.add_child(lbl) - - var grade := Label.new() - grade.name = "grade" - grade.position = Vector2(16, 16) - grade.size = Vector2(14, 14) - grade.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - grade.add_theme_font_size_override("font_size", 9) - grade.add_theme_color_override("font_color", Color(1.0, 0.9, 0.45)) - grade.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95)) - grade.add_theme_constant_override("shadow_offset_x", 1) - grade.add_theme_constant_override("shadow_offset_y", 1) - grade.mouse_filter = Control.MOUSE_FILTER_IGNORE - slot.add_child(grade) - - var cd := ColorRect.new() - cd.name = "cd" - cd.color = Color(0, 0, 0, 0.55) - cd.set_anchors_preset(Control.PRESET_FULL_RECT) - cd.visible = false - cd.mouse_filter = Control.MOUSE_FILTER_IGNORE - slot.add_child(cd) - - var idx := i - slot.gui_input.connect(func(e: InputEvent): - if e is InputEventMouseButton and e.pressed: - if e.button_index == MOUSE_BUTTON_RIGHT: - if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached(): - if item_mouse.has_method("cancel"): - item_mouse.cancel() - _move_from = -1 - elif e.shift_pressed: - _clear(idx) - else: - activate(idx) - elif e.button_index == MOUSE_BUTTON_LEFT: - if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached(): - _try_drop(idx) - else: - var sk_ui := _find_skill_ui() - if sk_ui and sk_ui.drag_skill_id != 0: - _try_drop(idx) - elif _move_from >= 0: - _try_drop(idx) - else: - activate(idx)) - _slots.append({"btn": slot, "cd": cd, "lbl": lbl, "icon": icon, "grade": grade}) - slot.set_drag_forwarding( - Callable(), - func(_at_pos: Vector2, data: Variant) -> bool: - return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"], - func(_at_pos: Vector2, data: Variant) -> void: - if data is Dictionary: - _apply_drag_data_to_slot(data, idx) - ) - if item_mouse and item_mouse.has_method("register_target"): - var local_idx: int = i - item_mouse.register_target(slot, - func(payload: Dictionary, _pos: Vector2 = Vector2.ZERO): return _drop_mouse_item(payload, local_idx), self) - return slot - -func _build_dock(parent: Node) -> void: - if _dock and is_instance_valid(_dock): - _dock.queue_free() - _dock = Control.new() - _dock.name = "TaskBarDock" - _dock.anchor_left = 1.0 - _dock.anchor_top = 0.0 - _dock.anchor_right = 1.0 - _dock.anchor_bottom = 0.0 - _dock.offset_left = -178 - _dock.offset_top = 0 - _dock.offset_right = 0 - _dock.offset_bottom = 37 - _dock.size = Vector2(178, 37) - _dock.mouse_filter = Control.MOUSE_FILTER_PASS - parent.add_child(_dock) - - # 1. 仓库 (Safebox / Mall) -> SCREEN_WIDTH - 178 - var btn_safe := _create_taskbar_btn( - "SafeboxButton", - "ETC/ymir work/ui/game/taskbar/mall_button_01.tga", - "ETC/ymir work/ui/game/taskbar/mall_button_02.tga", - "ETC/ymir work/ui/game/taskbar/mall_button_03.tga", - "仓库", - func(): safebox_requested.emit() - ) - btn_safe.position = Vector2(0, 3) - _buttons["safebox"] = btn_safe - _dock.add_child(btn_safe) - - # 2. 角色 (C) -> SCREEN_WIDTH - 144 - var btn_char := _create_taskbar_btn( - "CharacterButton", - "ETC/ymir work/ui/game/taskbar/character_button_01.sub", - "ETC/ymir work/ui/game/taskbar/character_button_02.sub", - "ETC/ymir work/ui/game/taskbar/character_button_03.sub", - "角色 (C)", - func(): character_requested.emit() - ) - btn_char.position = Vector2(34, 3) - _buttons["character"] = btn_char - _dock.add_child(btn_char) - - # 3. 道具 (I) -> SCREEN_WIDTH - 110 - var btn_inv := _create_taskbar_btn( - "InventoryButton", - "ETC/ymir work/ui/game/taskbar/inventory_button_01.sub", - "ETC/ymir work/ui/game/taskbar/inventory_button_02.sub", - "ETC/ymir work/ui/game/taskbar/inventory_button_03.sub", - "道具 (I)", - func(): inventory_requested.emit() - ) - btn_inv.position = Vector2(68, 3) - _buttons["inventory"] = btn_inv - _dock.add_child(btn_inv) - - # 4. 好友 / 社群 (Alt+M) -> SCREEN_WIDTH - 76 - var btn_msg := _create_taskbar_btn( - "MessengerButton", - "ETC/ymir work/ui/game/taskbar/community_button_01.sub", - "ETC/ymir work/ui/game/taskbar/community_button_02.sub", - "ETC/ymir work/ui/game/taskbar/community_button_03.sub", - "社群 (Alt+M)", - func(): messenger_requested.emit() - ) - btn_msg.position = Vector2(102, 3) - _buttons["messenger"] = btn_msg - _dock.add_child(btn_msg) - - # 5. 系统设置 (ESC) -> SCREEN_WIDTH - 42 - var btn_sys := _create_taskbar_btn( - "SystemButton", - "ETC/ymir work/ui/game/taskbar/system_button_01.sub", - "ETC/ymir work/ui/game/taskbar/system_button_02.sub", - "ETC/ymir work/ui/game/taskbar/system_button_03.sub", - "系统 (ESC)", - func(): system_requested.emit() - ) - btn_sys.position = Vector2(136, 3) - _buttons["system"] = btn_sys - _dock.add_child(btn_sys) - -func _on_points_changed(pd: Dictionary) -> void: - if pd.is_empty(): - return - var pts: Array = pd.get("points", []) - var get_pt := func(i: int) -> int: return int(pts[i]) if i >= 0 and i < pts.size() else 0 - var hp := int(pd.get("hp", get_pt.call(5))) - var max_hp := int(pd.get("max_hp", get_pt.call(6))) - var sp := int(pd.get("sp", get_pt.call(7))) - var max_sp := int(pd.get("max_sp", get_pt.call(8))) - var st: int = int(get_pt.call(9)) - var max_st: int = int(get_pt.call(10)) - if max_st <= 0: - max_st = 100 - st = 100 - var exp_val := int(pd.get("exp", get_pt.call(3))) - var next_exp_val := int(pd.get("next_exp", get_pt.call(4))) - set_hp(hp, max_hp) - set_sp(sp, max_sp) - set_st(st, max_st) - set_exp(exp_val, next_exp_val) - -func set_hp(cur: int, max_val: int) -> void: - _cur_hp = cur - _max_hp = max_val - if _hp_fill: - var pct := clampf(float(_cur_hp) / maxf(1.0, float(_max_hp)), 0.0, 1.0) - _hp_fill.size = Vector2(int(roundf(95.0 * pct)), 11) - if _hp_board: - _hp_board.tooltip_text = "HP : %d / %d" % [_cur_hp, _max_hp] - -func set_sp(cur: int, max_val: int) -> void: - _cur_sp = cur - _max_sp = max_val - if _sp_fill: - var pct := clampf(float(_cur_sp) / maxf(1.0, float(_max_sp)), 0.0, 1.0) - _sp_fill.size = Vector2(int(roundf(95.0 * pct)), 11) - if _sp_board: - _sp_board.tooltip_text = "SP : %d / %d" % [_cur_sp, _max_sp] - -func set_st(cur: int, max_val: int) -> void: - _cur_st = cur - _max_st = max_val - if _st_fill: - var pct := clampf(float(_cur_st) / maxf(1.0, float(_max_st)), 0.0, 1.0) - _st_fill.size = Vector2(int(roundf(95.0 * pct)), 6) - if _st_board: - _st_board.tooltip_text = "ST : %d / %d" % [_cur_st, _max_st] - -func set_exp(cur: int, next_val: int) -> void: - _cur_exp = cur - _next_exp = next_val - var quarter_pt := _next_exp / 4 - var full_count := 0 - if quarter_pt > 0: - full_count = mini(4, _cur_exp / quarter_pt) - - for i in 4: - if i < _exp_clips.size() and _exp_clips[i]: - var clip: Control = _exp_clips[i] - var pt: TextureRect = _exp_points[i] if i < _exp_points.size() else null - var base_x := 5 + i * 25 - var base_y := 9 - if _next_exp <= 0 or _cur_exp <= 0: - clip.visible = false - elif i < full_count: - clip.position = Vector2(base_x, base_y) - clip.size = Vector2(19, 19) - if pt: - pt.position = Vector2(0, 0) - clip.visible = true - elif i == full_count and full_count < 4 and quarter_pt > 0: - var frac := float(_cur_exp % quarter_pt) / float(quarter_pt) - var fill_h := int(roundf(19.0 * frac)) - if fill_h > 0: - clip.position = Vector2(base_x, base_y + 19 - fill_h) - clip.size = Vector2(19, fill_h) - if pt: - pt.position = Vector2(0, fill_h - 19) - clip.visible = true - else: - clip.visible = false - else: - clip.visible = false - - if _exp_board: - var pct := float(_cur_exp) / maxf(1.0, float(_next_exp)) * 100.0 - _exp_board.tooltip_text = "EXP : %.2f%%" % pct - -func _create_taskbar_btn(btn_name: String, rel_nrm: String, rel_ovr: String, rel_dwn: String, tooltip: String, on_press: Callable) -> Control: - var btn := TextureButton.new() - btn.name = btn_name - btn.custom_minimum_size = Vector2(32, 32) - btn.tooltip_text = tooltip - btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - if _assets_root != "": - btn.texture_normal = UiAssets.load_tex(_assets_root, rel_nrm) - btn.texture_hover = UiAssets.load_tex(_assets_root, rel_ovr) - btn.texture_pressed = UiAssets.load_tex(_assets_root, rel_dwn) - if btn.texture_normal == null: - var fallback := Button.new() - fallback.name = btn_name - fallback.text = tooltip.substr(0, 2) - fallback.custom_minimum_size = Vector2(32, 32) - fallback.tooltip_text = tooltip - fallback.pressed.connect(on_press) - return fallback - btn.pressed.connect(on_press) - return btn - -func _create_chat_btn() -> Control: - var btn := TextureButton.new() - btn.name = "ChatButton" - btn.custom_minimum_size = Vector2(14, 35) - btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER - btn.tooltip_text = "聊天 (L)" - btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - if _assets_root != "": - btn.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_01.sub") - btn.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_02.sub") - btn.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_03.sub") - if btn.texture_normal == null: - var fallback := Button.new() - fallback.name = "ChatButton" - fallback.text = "C" - fallback.custom_minimum_size = Vector2(14, 35) - fallback.size_flags_vertical = Control.SIZE_SHRINK_CENTER - fallback.tooltip_text = "聊天 (L)" - fallback.pressed.connect(func(): chat_requested.emit()) - return fallback - btn.pressed.connect(func(): chat_requested.emit()) - return btn - -func _drop_mouse_payload(payload: Dictionary, slot: int) -> bool: - if payload.is_empty(): - return false - var src := String(payload.get("source", "")) - if src == "inventory": - if int(payload.get("window", -1)) != 1 or int(payload.get("cell", -1)) < 0: - return false - # QuickSlot inventory entries store the source inventory cell, not the vnum. - return assign(slot, "item", int(payload.get("cell", -1))) - elif src == "skill": - var sid := int(payload.get("skill_id", 0)) - if sid > 0: - return assign(slot, "skill", sid) - elif src == "emotion": - var eid := int(payload.get("emote_id", 0)) - if eid > 0: - return assign(slot, "emote", eid) - return false - -func _drop_mouse_item(payload: Dictionary, slot: int) -> bool: - return _drop_mouse_payload(payload, slot) - -func _apply_drag_data_to_slot(data: Dictionary, slot_idx: int) -> bool: - if data.is_empty() or slot_idx < 0 or slot_idx >= SLOTS_PER_PAGE: - return false - var k := String(data.get("kind", data.get("type", ""))) - if k == "skill": - var sid := int(data.get("id", data.get("skill_id", 0))) - if sid > 0: - return assign(slot_idx, "skill", sid) - elif k in ["emotion", "emote"]: - var eid := int(data.get("id", data.get("emote_id", 0))) - if eid > 0: - return assign(slot_idx, "emote", eid) - elif k == "item": - var cell := int(data.get("cell", -1)) - if cell >= 0: - return assign(slot_idx, "item", cell) - return false - -func _drop_on_quickslot_board(payload: Dictionary, global_pos: Vector2) -> bool: - if _root == null or not is_instance_valid(_root): - return false - var qb_board: Control = _root.find_child("quickslot_board", true, false) - if qb_board == null: - return false - var local_pos := qb_board.get_global_transform().affine_inverse() * global_pos - var slot_idx := -1 - if local_pos.x < 135.0: - slot_idx = clampi(int(floor(local_pos.x / 32.0)), 0, 3) - elif local_pos.x >= 135.0: - slot_idx = clampi(int(floor((local_pos.x - 142.0) / 32.0)), 0, 3) + 4 - if slot_idx >= 0 and slot_idx < 8: - return _drop_mouse_payload(payload, slot_idx) - return false - -func _try_drop(slot: int) -> void: - if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached(): - var payload: Dictionary = item_mouse.attached() if item_mouse.has_method("attached") else {} - if not payload.is_empty() and _drop_mouse_payload(payload, slot): - if item_mouse.has_method("cancel"): - item_mouse.cancel() - _move_from = -1 - return - # skill_ui 把待拖技能放在 drag_skill_id;这里落点接 - var sk_ui := _find_skill_ui() - if sk_ui and sk_ui.drag_skill_id != 0: - assign(slot, "skill", sk_ui.drag_skill_id) - sk_ui.drag_skill_id = 0 - _move_from = -1 - return - if _move_from >= 0 and _move_from != slot: - _swap(_move_from, slot) - _move_from = -1 - return - if _state[_global_slot(slot)].kind != "": - _move_from = slot - -func _find_skill_ui() -> Node: - var tree := get_tree() - if tree == null or tree.root == null: - return null - for n in tree.root.find_children("*", "Node", true, false): - if "drag_skill_id" in n and int(n.drag_skill_id) != 0: - return n - return null - -func _refresh_slot(slot: int) -> void: - if slot < 0 or slot >= _slots.size(): - return - var s: Dictionary = _state[_global_slot(slot)] - var txt := "" - var tex: Texture2D = null - var grade_text := "" - var vnum := 0 - var count := 0 - var item_name := "" - if s.kind == "skill" and table: - txt = table.name_of(s.id).substr(0, 5) - tex = _skill_icon(int(s.id)) - grade_text = _skill_grade_text(int(s.id)) - elif s.kind == "item": - if client and client.has_method("get_item"): - var it: Dictionary = client.get_item(1, s.id) - vnum = int(it.get("vnum", 0)) - count = int(it.get("count", 1)) - if vnum > 0: - tex = _item_icon(vnum) - txt = str(count) if count > 1 else "" - if proto and proto.has_method("item"): - var idata: Dictionary = proto.item(vnum) - item_name = String(idata.get("locale_name", idata.get("name", ""))) - if item_name == "": - item_name = "item %d" % vnum - else: - txt = "#%d" % s.id - elif s.kind == "emote": - txt = "E%d" % s.id - - # 引用失效(物品已装备/销毁/掉落,服务器 SyncQuickslot 尚未推或已删)→ 置灰。 - var dim := false - if s.kind == "item": - if vnum == 0: - dim = true - txt = "×%d" % s.id - # 变身中技能槽整体遮罩(服务器只放行箭矢类物品);死亡中全部置灰。 - if (s.kind == "skill" and _polymorphed()) or _player_dead(): - dim = true - - var icon: TextureRect = _slots[slot].icon - icon.texture = tex - icon.visible = tex != null - _slots[slot].btn.modulate = Color(0.42, 0.42, 0.42) if dim else Color(1, 1, 1) - - var lbl: Label = _slots[slot].lbl - if tex != null and s.kind == "item": - lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - lbl.position = Vector2(2, 16) - lbl.size = Vector2(28, 14) - lbl.text = txt - elif tex == null: - lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT - lbl.position = Vector2(2, 12) - lbl.size = Vector2(28, 14) - lbl.text = txt - else: - lbl.text = "" - - _slots[slot].grade.text = grade_text - _slots[slot].grade.visible = grade_text != "" and tex != null - - # Tooltip - if s.kind == "skill" and table: - _slots[slot].btn.tooltip_text = table.name_of(s.id) - elif s.kind == "item" and vnum > 0: - _slots[slot].btn.tooltip_text = "%s\n#%d%s" % [item_name, vnum, (" x%d" % count) if count > 1 else ""] - else: - _slots[slot].btn.tooltip_text = "" - -func _item_icon(vnum: int) -> Texture2D: - if _assets_root == "" or vnum <= 0: - return null - var rel := "icon/item/%05d.tga" % vnum - var tex: Texture2D = UiAssets.load_tex(_assets_root, rel) - if tex == null: - rel = "icon/item/%05d.tga" % ((vnum / 10) * 10) - tex = UiAssets.load_tex(_assets_root, rel) - return tex - -func _skill_grade(skill_id: int) -> int: - if client and client.has_method("get_skills"): - for skill in client.get_skills(): - if int(skill.get("id", 0)) == skill_id: - return clampi(int(skill.get("master", 0)), 0, 3) - return 0 - -func _skill_grade_text(skill_id: int) -> String: - match _skill_grade(skill_id): - 1: return "M" - 2: return "G" - 3: return "P" - _: return "" - -func _skill_icon(skill_id: int) -> Texture2D: - if _assets_root == "" or table == null or not table.has_method("entry"): - return null - var data: Dictionary = table.entry(skill_id) - var job := String(data.get("job", "")).to_upper() - var motion := String(data.get("motion", "")).strip_edges() - if motion == "": - return null - var candidates: Array[String] = [] - if JOB_DIR.has(job): - var dir := String(JOB_DIR[job]) - for suffix in _skill_icon_suffixes(skill_id): - candidates.append("ETC/ymir work/ui/skill/%s/%s%s.sub" % [dir, motion, suffix]) - elif job == "SUPPORT": - for suffix in _skill_icon_suffixes(skill_id): - candidates.append("ETC/ymir work/ui/skill/common/support/%s%s.sub" % [motion, suffix]) - candidates.append("ETC/ymir work/ui/skill/common/support/%s.sub" % motion) - elif job == "HORSE": - candidates.append("ETC/ymir work/ui/skill/common/horse/%s.sub" % motion) - elif job == "GUILD": - candidates.append("ETC/ymir work/ui/skill/common/guild/%s.sub" % motion) - for rel in candidates: - var tex: Texture2D = UiAssets.load_tex(_assets_root, rel) - if tex != null: - return tex - return null - -func _skill_icon_suffixes(skill_id: int) -> Array[String]: - var grade := _skill_grade(skill_id) - var first := "_01" - if grade == 1: - first = "_02" - elif grade >= 2: - first = "_03" - var out: Array[String] = [first] - for suffix in ["_03", "_02", "_01"]: - if not out.has(suffix): - out.append(suffix) - return out - -func _refresh_page() -> void: - if _quick_page_num_img: - var page_sub := "ETC/ymir work/ui/game/taskbar/%d.sub" % (_page + 1) - _quick_page_num_img.texture = UiAssets.load_tex(_assets_root, page_sub) - if _root and _root.has_node("page"): - _root.get_node("page").text = "F%d" % (_page + 1) - for slot in _slots.size(): - _refresh_slot(slot) - -func _on_cd_end(skill_id: int) -> void: - _skill_cooldowns.erase(skill_id) - for i in _state.size(): - var s: Dictionary = _state[i] - if s.kind == "skill" and s.id == skill_id: - s.cd_end = 0.0 - _state[i] = s - -func _process(dt: float) -> void: - var t := _now() - for slot in _slots.size(): - var s: Dictionary = _state[_global_slot(slot)] - var on_cd: bool = t < s.cd_end - _slots[slot].cd.visible = on_cd - if on_cd: - var duration := _skill_cooldown(int(s.id)) - var frac: float = clampf((s.cd_end - t) / duration, 0.0, 1.0) - _slots[slot].cd.anchor_top = 1.0 - frac - - # 暴气红球旋转动画 (delay 6 帧即约 0.08s 步进) - _rampage_timer += dt - if _rampage_timer >= 0.08: - _rampage_timer = 0.0 - if not _rampage_frames.is_empty() and _rampage_gauge: - _rampage_idx = (_rampage_idx + 1) % _rampage_frames.size() - _rampage_gauge.texture = _rampage_frames[_rampage_idx] - - # HP/SP/ST 水流动波纹动画 (40250 delay 6 帧 = 0.08s 步进,循环 7 帧) - _gauge_timer += dt - if _gauge_timer >= 0.08: - _gauge_timer = 0.0 - _gauge_idx = (_gauge_idx + 1) % 7 - if not _hp_frames.is_empty() and _hp_gauge_img: - _hp_gauge_img.texture = _hp_frames[_gauge_idx % _hp_frames.size()] - if not _sp_frames.is_empty() and _sp_gauge_img: - _sp_gauge_img.texture = _sp_frames[_gauge_idx % _sp_frames.size()] - if not _st_frames.is_empty() and _st_gauge_img: - _st_gauge_img.texture = _st_frames[_gauge_idx % _st_frames.size()] - -func _skill_cooldown(skill_id: int) -> float: - var base := DEFAULT_CD - if table and table.has_method("cooldown_of"): - var level := 0 - if client and client.has_method("get_skills"): - for skill in client.get_skills(): - if int(skill.get("id", 0)) == skill_id: - level = int(skill.get("level", 0)) - break - var configured := float(table.cooldown_of(skill_id, level)) - if configured > 0.0: - base = configured - - # CPythonPlayer::__RunCoolTime (PythonPlayerSkill.cpp:796) applies the - # exact POINT_CASTING_SPEED piecewise scale after GetSkillCoolTime(). - # M2Client exposes the named field; the points array fallback keeps this - # compatible with headless/test clients that only provide raw points. - # POINT_CASTING_SPEED is a percentage-like point whose neutral/default - # value is 100 in the reference client. Keep that neutral value while a - # headless fixture has not supplied a points snapshot yet. - var casting_speed := 100 - if client and client.has_method("get_points"): - var points: Dictionary = client.get_points() - if points.has("casting_speed"): - casting_speed = int(points.get("casting_speed", 0)) - else: - var values: Array = points.get("points", []) - if values.size() > 21: - casting_speed = int(values[21]) - var speed_scale := 100 - var speed_delta := 100 - casting_speed - if speed_delta > 0: - speed_scale = 100 + speed_delta - elif speed_delta < 0: - speed_scale = 10000 / (100 - speed_delta) - return base * float(speed_scale) / 100.0 - -func _now() -> float: - return Time.get_ticks_msec() / 1000.0 diff --git a/project/ui/quickbar.gd.uid b/project/ui/quickbar.gd.uid deleted file mode 100644 index eac73a75..00000000 --- a/project/ui/quickbar.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cunyyq6g5bs6m diff --git a/project/ui/quiet_button.gd b/project/ui/quiet_button.gd deleted file mode 100644 index d6564f39..00000000 --- a/project/ui/quiet_button.gd +++ /dev/null @@ -1,9 +0,0 @@ -# QuietButton —— 拦截 Godot 4 引擎内置 Tooltip 弹窗。 -# -# 防止引擎在商店槽位悬停或点击时自动弹出原生简陋的灰色 TooltipPanel, -# 确保所有商品装备与道具均由 40250 官方规范的 ItemTooltipView (ThinBoard) 独占呈现。 -# 同时完整保留 BaseButton.tooltip_text 字符串供测试校验复用。 -extends Button - -func _get_tooltip(_at_position: Vector2) -> String: - return "" diff --git a/project/ui/quiet_button.gd.uid b/project/ui/quiet_button.gd.uid deleted file mode 100644 index 3af109a4..00000000 --- a/project/ui/quiet_button.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b3uq3sf8dy087 diff --git a/project/ui/quiet_panel.gd b/project/ui/quiet_panel.gd deleted file mode 100644 index 079aa69b..00000000 --- a/project/ui/quiet_panel.gd +++ /dev/null @@ -1,9 +0,0 @@ -# QuietPanel —— 拦截 Godot 4 引擎内置 Tooltip 弹窗。 -# -# 防止引擎在悬停或点击时自动弹出原生简陋的灰色 TooltipPanel, -# 确保所有装备与道具均由 40250 官方规范的 ItemTooltipView (ThinBoard) 独占呈现。 -# 同时完整保留 Control.tooltip_text 字符串供测试校验及移动端详情模态窗复用。 -extends Panel - -func _get_tooltip(_at_position: Vector2) -> String: - return "" diff --git a/project/ui/quiet_panel.gd.uid b/project/ui/quiet_panel.gd.uid deleted file mode 100644 index b4bece28..00000000 --- a/project/ui/quiet_panel.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://st31pbybiodp diff --git a/project/ui/race_spec.gd b/project/ui/race_spec.gd deleted file mode 100644 index 80313060..00000000 --- a/project/ui/race_spec.gd +++ /dev/null @@ -1,161 +0,0 @@ -# RaceSpec (P2) —— 解析角色种族规格文件 `.msm`(文本树,非二进制 mesh)。 -# -# 客户端 GameLib/RaceDataFile.cpp `CRaceData::LoadRaceData`。格式是 Metin2 的 -# CTextFileLoader 花括号树: -# ScriptType RaceDataScript -# BaseModelFileName "D:/YMIR WORK/pc/warrior/warrior_novice.GR2" -# Group ShapeData { -# PathName "d:/ymir Work/pc/warrior/" -# ShapeDataCount 26 -# Group ShapeData00 { ShapeIndex 0 Model "warrior_novice.gr2" -# SourceSkin "..dds" TargetSkin "..dds" } -# ... -# } -# Group HairData { ... 同结构,HairIndex/Model/SourceSkin/TargetSkin ... } -# -# var rs := preload("res://ui/race_spec.gd").new() -# rs.load_file("/PC/ymir work/pc/warrior/warrior.msm") # 或 season1/pc/warrior.msm -# rs.shape(9) # { model:"warrior_cheongrin.gr2", source_skin:"..", target_skin:"..", path:"d:/ymir Work/pc/warrior/" } -# rs.hair(3) # 同上 -extends RefCounted - -var base_model := "" -var shape_path := "" -var hair_path := "" -var shapes := {} # shape_index:int -> { model, source_skin, target_skin } -var hairs := {} # hair_index:int -> { model, source_skin, target_skin } -var _dir := "" # 规格文件所在目录(资产实际就在这旁边) - -func load_file(path: String) -> bool: - shapes.clear() - hairs.clear() - if not FileAccess.file_exists(path): - return false - _dir = path.get_base_dir() - var toks := _tokenize(FileAccess.get_file_as_string(path)) - var i := [0] - var tree := _parse_block(toks, i, true) # 顶层当一个隐式 block - base_model = _s(tree.get("BaseModelFileName", "")) - var sd: Dictionary = tree.get("__group_ShapeData", {}) - shape_path = _s(sd.get("PathName", "")) - for k in sd: - if String(k).begins_with("__group_ShapeData") and sd[k] is Dictionary: - var g: Dictionary = sd[k] - if g.has("ShapeIndex"): - shapes[int(g["ShapeIndex"])] = _entry(g) - var hd: Dictionary = tree.get("__group_HairData", {}) - hair_path = _s(hd.get("PathName", "")) - for k in hd: - if String(k).begins_with("__group_HairData") and hd[k] is Dictionary: - var g: Dictionary = hd[k] - if g.has("HairIndex"): - hairs[int(g["HairIndex"])] = _entry(g) - return shapes.size() > 0 - -func shape(idx: int) -> Dictionary: - var e: Dictionary = shapes.get(idx, {}) - if e.is_empty(): - return {} - e = e.duplicate() - e["path"] = String(e.get("special_path", "")) - if e["path"] == "": - e["path"] = shape_path - e["spec_dir"] = _dir - return e - -func hair(idx: int) -> Dictionary: - var e: Dictionary = hairs.get(idx, {}) - if e.is_empty(): - return {} - e = e.duplicate() - e["path"] = String(e.get("special_path", "")) - if e["path"] == "": - e["path"] = hair_path - e["spec_dir"] = _dir - return e - -# --- internals -------------------------------------------------------- - -func _entry(g: Dictionary) -> Dictionary: - return { - "model": _s(g.get("Model", g.get("local_model", ""))).strip_edges(), - "source_skin": _s(g.get("SourceSkin", "")).strip_edges(), - "target_skin": _s(g.get("TargetSkin", "")).strip_edges(), - "special_path": _s(g.get("SpecialPath", "")).strip_edges(), - } - -func _s(v) -> String: - return v if v is String else str(v) - -# 词法:按空白切;"..." 是一个 token;{ } 单独 token;# / // 到行尾注释 -func _tokenize(src: String) -> Array: - var out := [] - var i := 0 - var n := src.length() - while i < n: - var c := src[i] - if c in [" ", "\t", "\r", "\n"]: - i += 1 - elif c == '"': - var j := i + 1 - var s := "" - while j < n and src[j] != '"': - s += src[j] - j += 1 - out.append("" + s) # 前缀标记「这是带引号的字符串值」 - i = j + 1 - elif c == "{" or c == "}": - out.append(c) - i += 1 - elif c == "#" or (c == "/" and i + 1 < n and src[i + 1] == "/"): - while i < n and src[i] != "\n": - i += 1 - else: - var j := i - while j < n and not (src[j] in [" ", "\t", "\r", "\n", "{", "}", '"']): - j += 1 - out.append(src.substr(i, j - i)) - i = j - return out - -# 解析一个 { } 块(或 top=true 的隐式顶层)为 Dictionary。 -# KEY VALUE -> d[KEY] = VALUE -# Group NAME { ... } -> d["__group_NAME"] = -func _parse_block(toks: Array, i: Array, top := false) -> Dictionary: - var d := {} - var guard := 0 - while i[0] < toks.size(): - guard += 1 - if guard > 100000: - break - var t = toks[i[0]] - if t == "}": - i[0] += 1 - return d - if t is String and t == "Group": - i[0] += 1 - var name := String(toks[i[0]]) if i[0] < toks.size() else "?" - i[0] += 1 - if i[0] < toks.size() and toks[i[0]] == "{": - i[0] += 1 - d["__group_" + name] = _parse_block(toks, i) - continue - if t == "{": - i[0] += 1 - continue - # KEY VALUE - var key: String = str(_tokval(t)) - i[0] += 1 - if i[0] < toks.size() and toks[i[0]] != "}" and toks[i[0]] != "{": - d[key] = _tokval(toks[i[0]]) - i[0] += 1 - if top and i[0] >= toks.size(): - break - return d - -func _tokval(t): - if t is String and t.begins_with(String.chr(1)): - return t.substr(1) - if t is Dictionary and t.has("str"): - return t["str"] - return str(t) diff --git a/project/ui/race_spec.gd.uid b/project/ui/race_spec.gd.uid deleted file mode 100644 index 99cf3b68..00000000 --- a/project/ui/race_spec.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bp7yu41d0cuja diff --git a/project/ui/reconnect_ui.gd b/project/ui/reconnect_ui.gd deleted file mode 100644 index a16988a8..00000000 --- a/project/ui/reconnect_ui.gd +++ /dev/null @@ -1,158 +0,0 @@ -# ReconnectUI (P10) —— 断线遮罩 + 自动重连倒计时 + 手动重连。 -# -# var rc := preload("res://ui/reconnect_ui.gd").new() -# add_child(rc) -# rc.setup(m2client) -# -# `disconnected(reason)` → 显示遮罩 + "N 秒后自动重连";到点调 client.reconnect()。 -# `entered_game` 或任何非 offline 的 phase_changed → 隐藏。 -# 按钮:立即重连 / 停止(不再自动)。 -extends CanvasLayer - -const MobileWindowHost := preload("res://ui/mobile/mobile_window_host.gd") - -const AUTO_DELAY := 5.0 -const MAX_TRIES := 5 - -var client: Node -var _panel: ColorRect -var _msg: Label -var _count: Label -var _left := 0.0 -var _tries := 0 -var _auto := true -var _armed := false -var _mobile_mode := false -var _box: VBoxContainer -var _last_viewport_size := Vector2.ZERO - -func setup(m2client: Node) -> void: - client = m2client - layer = 70 - _build() - if client.has_signal("disconnected"): - client.disconnected.connect(_on_disconnected) - if client.has_signal("entered_game"): - client.entered_game.connect(func(): _dismiss()) - if client.has_signal("phase_changed"): - client.phase_changed.connect(func(p): - # Keep the reconnect surface visible while auth/game handshaking is in - # progress. Hiding it on the first "login" phase left players with a - # dead-looking world and no retry feedback on mobile. - if not _armed and String(p).to_lower() not in ["offline", "", "close", "failed"]: - _dismiss()) - MobileWindowHost.wire_buttons(self) - set_process(true) - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - _apply_layout() - -func dismiss() -> void: - _dismiss() - -func set_error(reason: String) -> void: - if _msg: - _msg.text = "重连失败:%s" % reason - if _count: - _count.text = "将自动重试" - -# AppFlow uses this same owner when the classic phase bridge reports a -# LoginPhase transition without a separate transport callback. Keeping the -# arming logic here preserves one countdown/retry owner for both event orders. -func show_for_disconnect(reason: String) -> void: - _on_disconnected(reason) - -func is_showing() -> bool: - return _panel.visible - -func _on_disconnected(reason: String) -> void: - _msg.text = "连接断开:%s" % reason - _panel.visible = true - _armed = true - _auto = true - _tries = 0 - _left = AUTO_DELAY - _apply_layout() - -func _dismiss() -> void: - _panel.visible = false - _armed = false - -func _retry_now() -> void: - if client == null or not client.has_method("reconnect"): - return - _tries += 1 - _count.text = "重连中…(第 %d 次)" % _tries - _left = AUTO_DELAY - var ok: bool = client.reconnect() - if not ok: - _count.text = "重连失败,%0.0f 秒后再试" % AUTO_DELAY - -func _process(dt: float) -> void: - _apply_layout() - if not _panel.visible or not _armed: - return - if not _auto: - _count.text = "自动重连已停止" - return - if _tries >= MAX_TRIES: - _count.text = "已重试 %d 次,仍失败" % _tries - return - _left -= dt - _count.text = "%0.0f 秒后自动重连(%d/%d)" % [maxf(_left, 0.0), _tries, MAX_TRIES] - if _left <= 0.0: - _retry_now() - -func _build() -> void: - _panel = ColorRect.new() - _panel.color = Color(0.05, 0.02, 0.02, 0.92) - _panel.set_anchors_preset(Control.PRESET_FULL_RECT) - _panel.visible = false - add_child(_panel) - var box := VBoxContainer.new() - _box = box - box.set_anchors_preset(Control.PRESET_CENTER) - box.position = Vector2(-160, -70) - box.custom_minimum_size = Vector2(320, 0) - box.add_theme_constant_override("separation", 12) - _panel.add_child(box) - _msg = Label.new() - _msg.text = "连接断开" - _msg.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _msg.add_theme_font_size_override("font_size", 16) - box.add_child(_msg) - _count = Label.new() - _count.text = "" - _count.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - box.add_child(_count) - var row := HBoxContainer.new() - row.alignment = BoxContainer.ALIGNMENT_CENTER - box.add_child(row) - var now_btn := Button.new() - now_btn.text = "立即重连" - now_btn.custom_minimum_size = Vector2(150, 46) - now_btn.pressed.connect(_retry_now) - row.add_child(now_btn) - var stop_btn := Button.new() - stop_btn.text = "停止" - stop_btn.custom_minimum_size = Vector2(120, 46) - stop_btn.pressed.connect(func(): _auto = false) - row.add_child(stop_btn) - -func _apply_layout() -> void: - if _panel == null or get_viewport() == null: - return - var viewport_size := get_viewport().get_visible_rect().size - if viewport_size.is_equal_approx(_last_viewport_size) and not _mobile_mode: - return - _last_viewport_size = viewport_size - if not _mobile_mode: - return - var area := MobileWindowHost.safe_rect(get_viewport()) - var width := minf(520.0, maxf(300.0, area.size.x - 32.0)) - _box.position = area.position + Vector2((area.size.x - width) * 0.5, - (area.size.y - 190.0) * 0.5) - _box.size = Vector2(width, 190.0) - _msg.add_theme_font_size_override("font_size", 18) - _count.add_theme_font_size_override("font_size", 15) diff --git a/project/ui/reconnect_ui.gd.uid b/project/ui/reconnect_ui.gd.uid deleted file mode 100644 index 310b8fd1..00000000 --- a/project/ui/reconnect_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://db2sxruqo6v3j diff --git a/project/ui/refine_ui.gd b/project/ui/refine_ui.gd deleted file mode 100644 index e81948a6..00000000 --- a/project/ui/refine_ui.gd +++ /dev/null @@ -1,468 +0,0 @@ -# RefineUI —— 1:1 复刻官方 40250 uirefine.py:RefineDialogNew 与 QuestionDialog2。 -# -# 协议驱动: -# GC_REFINE_INFORMATION (119) -> refine_ask({type, pos, src_vnum, result_vnum, cost, prob, materials}) -# 发送发包: -# 确定 -> CG_REFINE (96): client.refine(pos, type) -# 取消/ESC/超距 -> CG_REFINE (96): client.refine(255, 255) -# 40250 规则: -# 1. prob == 100 或 type == 5 时直接 Accept,免弹二次确认窗; -# 2. type == 3 (铁匠石) 弹彻底摧毁+概率加成警告; -# 3. type == 2 (祝福卷轴) 弹降级警告; -# 4. 其他 (普通铁匠) 弹彻底摧毁警告; -# 5. 材料根据背包实际存量显示白字(>= 需求)或红字(< 需求); -# 6. 离场超 10.0m 自动发送取消发包并关闭。 -extends Node - -const QuestionDialog2Scene = null -const USE_REFINE_LIMIT_RANGE_M := 10.0 - -var client: Node -var proto: Node -var audio: Node -var _root: Control -var _board: Panel -var _title_bar: Control -var _title_label: Label -var _close_btn: Button - -var _preview_slot: Panel -var _preview_icon: TextureRect -var _text: RichTextLabel -var _material_container: VBoxContainer -var _prob_label: Label -var _cost_label: Label -var _action_row: HBoxContainer -var _refine_button: Button -var _cancel_button: Button - -# QuestionDialog2 确认弹窗 -var _question_dialog: Control -var _qd_message1: Label -var _qd_message2: Label -var _qd_accept_btn: Button -var _qd_cancel_btn: Button - -var _cur := {} -var _mobile_mode := false -var _open_char_pos := Vector3.ZERO -var _has_open_pos := false - -func setup(m2client: Node, parent: Node, proto_node: Node = null, audio_node: Node = null) -> void: - client = m2client - proto = proto_node - audio = audio_node - _build(parent) - if client != null: - if client.has_signal("refine_ask"): - client.refine_ask.connect(_on_ask) - if client.has_signal("refine_result"): - client.refine_result.connect(_on_result) - -func is_open() -> bool: - return _root != null and _root.visible - -func get_mobile_window() -> Control: - return _root - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - _apply_mobile_layout() - -func _name_of(vnum: int) -> String: - if vnum == 0: - return "-" - if proto and proto.has_method("item"): - var d: Dictionary = proto.item(vnum) - var n := String(d.get("locale_name", d.get("name", ""))) - if n != "": - return n - return "#%d" % vnum - -func _get_inventory_count(vnum: int) -> int: - if client == null or not client.has_method("get_inventory"): - return 0 - var total := 0 - for item in client.get_inventory(): - if int(item.get("vnum", 0)) == vnum: - total += maxi(1, int(item.get("count", 1))) - return total - -func _get_main_char_pos() -> Variant: - if client == null: - return null - var vid := 0 - if client.has_method("get_main_vid"): - vid = int(client.get_main_vid()) - if vid != 0 and client.has_method("get_entity"): - var e: Dictionary = client.get_entity(vid) - if not e.is_empty() and e.has("pos"): - return e["pos"] - return null - -func _process(_delta: float) -> void: - if not is_open() or not _has_open_pos: - return - var cur = _get_main_char_pos() - if cur is Vector3: - if _open_char_pos.distance_to(cur) > USE_REFINE_LIMIT_RANGE_M: - cancel_refine() - -func _unhandled_input(event: InputEvent) -> void: - if not is_open(): - return - if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: - cancel_refine() - -func _on_ask(info: Dictionary) -> void: - _cur = info - var pos_val = _get_main_char_pos() - if pos_val is Vector3: - _open_char_pos = pos_val - _has_open_pos = true - else: - _has_open_pos = false - - var src_vnum := int(info.get("src_vnum", 0)) - var result_vnum := int(info.get("result_vnum", 0)) - var prob := int(info.get("prob", 0)) - var cost := int(info.get("cost", 0)) - var materials: Array = info.get("materials", []) - - # 1. 组装向下兼容的 _text(供 headless 自动化测试断言及快速阅读) - var lines := PackedStringArray() - lines.append("[b]%s[/b] → [b]%s[/b]" % [_name_of(src_vnum), _name_of(result_vnum)]) - lines.append("成功率 %d%% 费用 %d 金" % [prob, cost]) - if not materials.is_empty(): - lines.append("材料:") - for m in materials: - lines.append(" %s ×%d" % [_name_of(int(m.get("vnum", 0))), int(m.get("count", 1))]) - _text.text = "\n".join(lines) - - # 2. 40250 视觉:底部成功率与费用 - _prob_label.text = "成功率: %d%%" % prob - _cost_label.text = "费用: %d 金" % cost - var cur_gold := 0 - if client and client.has_method("get_gold"): - cur_gold = int(client.get_gold()) - elif client and client.has_method("get_points"): - cur_gold = int(client.get_points().get("gold", 0)) - if cur_gold > 0 and cur_gold < cost: - _cost_label.modulate = Color(1.0, 0.33, 0.33) - else: - _cost_label.modulate = Color(1.0, 1.0, 1.0) - - # 3. 40250 视觉:材质栏 AppendMaterial 动态排布 - for child in _material_container.get_children(): - _material_container.remove_child(child) - child.queue_free() - - for m in materials: - var mvnum := int(m.get("vnum", 0)) - var mcount := int(m.get("count", 1)) - var owned := _get_inventory_count(mvnum) - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 8) - row.custom_minimum_size = Vector2(250, 32) - - # Slot - var slot := Panel.new() - slot.custom_minimum_size = Vector2(32, 32) - var slot_sb := StyleBoxFlat.new() - slot_sb.bg_color = Color(0.12, 0.1, 0.08, 0.9) - slot_sb.border_color = Color(0.35, 0.3, 0.22, 1.0) - slot_sb.set_border_width_all(1) - slot.add_theme_stylebox_override("panel", slot_sb) - var mat_icon := TextureRect.new() - mat_icon.set_anchors_preset(Control.PRESET_FULL_RECT) - mat_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - mat_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - slot.add_child(mat_icon) - row.add_child(slot) - - # ThinBoard with label - var tb := Panel.new() - tb.custom_minimum_size = Vector2(190, 28) - tb.size_flags_horizontal = Control.SIZE_EXPAND_FILL - var tb_sb := StyleBoxFlat.new() - tb_sb.bg_color = Color(0.15, 0.12, 0.1, 0.8) - tb_sb.set_corner_radius_all(2) - tb.add_theme_stylebox_override("panel", tb_sb) - - var lbl := Label.new() - lbl.set_anchors_preset(Control.PRESET_FULL_RECT) - lbl.text = " %s x %02d" % [_name_of(mvnum), mcount] - lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - # 40250 存量足显示白字,不足显示红字 0xffff5555 - if owned >= mcount: - lbl.modulate = Color(0.87, 0.87, 0.87) - else: - lbl.modulate = Color(1.0, 0.33, 0.33) - tb.add_child(lbl) - row.add_child(tb) - - _material_container.add_child(row) - - # 4. 自适应尺寸 - var base_h := 220 - var mats_h := materials.size() * 36 - var new_h := base_h + mats_h - _root.size = Vector2(340, new_h) - - if _question_dialog: - _question_dialog.visible = false - _root.visible = true - -func _on_accept_clicked() -> void: - if _cur.is_empty(): - return - var prob := int(_cur.get("prob", 0)) - var rtype := int(_cur.get("type", 0)) - - # 40250 uirefine.py:385:成功率 100% 或 type == 5 直接执行,不弹确认框 - if prob == 100 or rtype == 5: - _accept() - return - - # 唤起 QuestionDialog2 - _open_question_dialog(rtype) - -func _open_question_dialog(rtype: int) -> void: - if _question_dialog == null: - return - # 40250 对齐文案: - _qd_message2.text = "确定要继续吗?" - if rtype == 3: - _qd_message1.text = "强化失败时装备将会被彻底销毁,同时成功率提升!" - elif rtype == 2: - _qd_message1.text = "强化失败时装备等级将会降低1级!" - else: - _qd_message1.text = "强化失败时装备将会被彻底销毁!" - - _question_dialog.visible = true - _question_dialog.move_to_front() - -func _accept() -> void: - if client and client.has_method("refine") and not _cur.is_empty(): - client.refine(int(_cur.get("pos", 0)), int(_cur.get("type", 0))) - close() - -func cancel_refine() -> void: - # 40250 uirefine.py:418: net.SendRefinePacket(255, 255) - if client and client.has_method("refine") and not _cur.is_empty(): - client.refine(255, 255) - close() - -func close() -> void: - if _question_dialog: - _question_dialog.visible = false - if _root: - _root.visible = false - _cur = {} - _has_open_pos = false - -func _on_result(ok: bool) -> void: - _cur = {} - _text.text = "[b]精炼成功[/b]" if ok else "[b]精炼失败[/b]" - if _prob_label: - _prob_label.text = "强化成功!" if ok else "强化失败!" - _prob_label.modulate = Color(0.2, 1.0, 0.4) if ok else Color(1.0, 0.3, 0.3) - if _cost_label: - _cost_label.text = "" - for child in _material_container.get_children(): - _material_container.remove_child(child) - child.queue_free() - - if audio and audio.has_method("play_ui"): - if ok: - audio.play_ui("refine_success.wav") - else: - audio.play_ui("refine_fail.wav") - - _root.visible = true - -func _apply_mobile_layout() -> void: - if _root == null: - return - if _mobile_mode: - _root.size = Vector2(460, 320) - _root.position = Vector2(-230, -160) - else: - _root.size = Vector2(340, 260) - _root.position = Vector2(-170, -130) - -func _build(parent: Node) -> void: - _root = Control.new() - _root.name = "RefineDialogNew" - _root.size = Vector2(340, 260) - _root.position = Vector2(200, 150) - _root.size = Vector2(340, 260) - _root.visible = false - _root.set_meta("is_titlebar", true) - parent.add_child(_root) - - # 主底板 Board - _board = Panel.new() - _board.name = "Board" - _board.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.08, 0.06, 0.05, 0.98) - sb.border_color = Color(0.45, 0.38, 0.28, 1.0) - sb.set_border_width_all(2) - sb.set_corner_radius_all(4) - _board.add_theme_stylebox_override("panel", sb) - _root.add_child(_board) - - # 标题栏 TitleBar (40250 经典红标题栏) - _title_bar = Panel.new() - _title_bar.name = "TitleBar" - _title_bar.position = Vector2(8, 8) - _title_bar.size = Vector2(324, 26) - var title_sb := StyleBoxFlat.new() - title_sb.bg_color = Color(0.45, 0.1, 0.1, 0.95) - title_sb.set_corner_radius_all(2) - _title_bar.add_theme_stylebox_override("panel", title_sb) - _root.add_child(_title_bar) - - _title_label = Label.new() - _title_label.name = "TitleName" - _title_label.set_anchors_preset(Control.PRESET_FULL_RECT) - _title_label.text = "强化" - _title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _title_bar.add_child(_title_label) - - _close_btn = Button.new() - _close_btn.text = "×" - _close_btn.position = Vector2(300, 2) - _close_btn.size = Vector2(22, 22) - _close_btn.pressed.connect(cancel_refine) - _title_bar.add_child(_close_btn) - - # 物品展示与说明 - _text = RichTextLabel.new() - _text.bbcode_enabled = true - _text.position = Vector2(16, 40) - _text.size = Vector2(308, 60) - _root.add_child(_text) - - # 材料列表容器 (AppendMaterial) - _material_container = VBoxContainer.new() - _material_container.position = Vector2(16, 106) - _material_container.size = Vector2(308, 50) - _material_container.add_theme_constant_override("separation", 4) - _root.add_child(_material_container) - - # 底部区域:成功率 + 费用 - var bottom_info := VBoxContainer.new() - bottom_info.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) - bottom_info.offset_top = -90 - bottom_info.offset_bottom = -45 - bottom_info.offset_left = 16 - bottom_info.offset_right = -16 - bottom_info.alignment = BoxContainer.ALIGNMENT_CENTER - _root.add_child(bottom_info) - - _prob_label = Label.new() - _prob_label.name = "SuccessPercentage" - _prob_label.text = "成功率: --%" - _prob_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - bottom_info.add_child(_prob_label) - - _cost_label = Label.new() - _cost_label.name = "Cost" - _cost_label.text = "费用: -- 金" - _cost_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - bottom_info.add_child(_cost_label) - - # 底部按钮行:AcceptButton ("精炼") 与 CancelButton ("取消") - _action_row = HBoxContainer.new() - _action_row.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) - _action_row.offset_top = -42 - _action_row.offset_bottom = -10 - _action_row.offset_left = 16 - _action_row.offset_right = -16 - _action_row.alignment = BoxContainer.ALIGNMENT_CENTER - _action_row.add_theme_constant_override("separation", 24) - _root.add_child(_action_row) - - _refine_button = Button.new() - _refine_button.name = "AcceptButton" - _refine_button.text = "精炼" - _refine_button.custom_minimum_size = Vector2(110, 30) - _refine_button.pressed.connect(_on_accept_clicked) - _action_row.add_child(_refine_button) - - _cancel_button = Button.new() - _cancel_button.name = "CancelButton" - _cancel_button.text = "取消" - _cancel_button.custom_minimum_size = Vector2(110, 30) - _cancel_button.pressed.connect(cancel_refine) - _action_row.add_child(_cancel_button) - - # 构建内嵌 QuestionDialog2 - _build_question_dialog(parent) - -func _build_question_dialog(parent: Node) -> void: - _question_dialog = Control.new() - _question_dialog.name = "QuestionDialog2" - _question_dialog.size = Vector2(290, 120) - _question_dialog.position = Vector2(225, 200) - _question_dialog.visible = false - _question_dialog.z_index = 100 - parent.add_child(_question_dialog) - - var qd_board := Panel.new() - qd_board.name = "board" - qd_board.set_anchors_preset(Control.PRESET_FULL_RECT) - var qd_sb := StyleBoxFlat.new() - qd_sb.bg_color = Color(0.12, 0.08, 0.06, 0.98) - qd_sb.border_color = Color(0.65, 0.5, 0.3, 1.0) - qd_sb.set_border_width_all(2) - qd_sb.set_corner_radius_all(4) - qd_board.add_theme_stylebox_override("panel", qd_sb) - _question_dialog.add_child(qd_board) - - _qd_message1 = Label.new() - _qd_message1.name = "message1" - _qd_message1.position = Vector2(12, 14) - _qd_message1.size = Vector2(266, 32) - _qd_message1.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _qd_message1.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _qd_message1.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _question_dialog.add_child(_qd_message1) - - _qd_message2 = Label.new() - _qd_message2.name = "message2" - _qd_message2.position = Vector2(12, 48) - _qd_message2.size = Vector2(266, 24) - _qd_message2.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _qd_message2.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _qd_message2.text = "确定要继续吗?" - _question_dialog.add_child(_qd_message2) - - var qd_btn_row := HBoxContainer.new() - qd_btn_row.position = Vector2(12, 78) - qd_btn_row.size = Vector2(266, 32) - qd_btn_row.alignment = BoxContainer.ALIGNMENT_CENTER - qd_btn_row.add_theme_constant_override("separation", 24) - _question_dialog.add_child(qd_btn_row) - - _qd_accept_btn = Button.new() - _qd_accept_btn.name = "accept" - _qd_accept_btn.text = "是" - _qd_accept_btn.custom_minimum_size = Vector2(80, 28) - _qd_accept_btn.pressed.connect(func(): - _question_dialog.visible = false - _accept() - ) - qd_btn_row.add_child(_qd_accept_btn) - - _qd_cancel_btn = Button.new() - _qd_cancel_btn.name = "cancel" - _qd_cancel_btn.text = "否" - _qd_cancel_btn.custom_minimum_size = Vector2(80, 28) - _qd_cancel_btn.pressed.connect(func(): - _question_dialog.visible = false - ) - qd_btn_row.add_child(_qd_cancel_btn) diff --git a/project/ui/refine_ui.gd.uid b/project/ui/refine_ui.gd.uid deleted file mode 100644 index 0786fa77..00000000 --- a/project/ui/refine_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dfnssxwhbe1qb diff --git a/project/ui/remote_player_view.gd b/project/ui/remote_player_view.gd deleted file mode 100644 index 92127328..00000000 --- a/project/ui/remote_player_view.gd +++ /dev/null @@ -1,76 +0,0 @@ -# RemotePlayerView —— ClientVS22 的远端 PC 表现层。 -# -# 远端 PC 与本地玩家共用 PlayerView 的 race / GR2 / MSA 装载,但装备来源 -# 必须是 awPart[CHR_EQUIPPART_ARMOR..HAIR],不能读取本地 get_equipment()。 -extends "res://ui/player_view.gd" - -const EquipModel = preload("res://ui/equip_model.gd") - -var remote_equip: Node -var _remote_parts: Array = [] -var _remote_proto: Node - -const ITEM_TYPE_WEAPON := 1 -const MODE_GENERAL := 1 -const MODE_ONEHAND_SWORD := 2 -const MODE_TWOHAND_SWORD := 3 -const MODE_DUALHAND_SWORD := 4 -const MODE_BOW := 5 -const MODE_FAN := 6 -const MODE_BELL := 7 -const WEAPON_MODE := {0: MODE_ONEHAND_SWORD, 1: MODE_DUALHAND_SWORD, 2: MODE_BOW, - 3: MODE_TWOHAND_SWORD, 4: MODE_BELL, 5: MODE_FAN} - -func _ready() -> void: - if remote_equip and remote_equip.has_method("refresh_refine_effects"): - remote_equip.call_deferred("refresh_refine_effects") - -func build_remote(assets_root: String, race: int, parts: Array, item_list: RefCounted, - proto: Node = null, pump := Callable(), effects: RefCounted = null) -> bool: - if not build(assets_root, race, pump): - return false - _remote_proto = proto - _remote_parts = parts.duplicate() - while _remote_parts.size() < 4: - _remote_parts.append(0) - if _remote_parts.size() > 4: - _remote_parts = _remote_parts.slice(0, 4) - remote_equip = EquipModel.new() - remote_equip.name = "RemoteEquipModel" - add_child(remote_equip) - remote_equip.effect_registry = effects - remote_equip.setup_remote(item_list, func() -> Node: return self, assets_root, race, - _remote_parts, proto) - _refresh_weapon_motion() - return true - -# net_world 按 NetworkActorManager.cpp:473 的顺序调用这四个字段。 -func set_armor(vnum: int) -> void: - _set_remote_part(EquipModel.PART_ARMOR, vnum) - -func set_weapon(vnum: int) -> void: - _set_remote_part(EquipModel.PART_WEAPON, vnum) - _refresh_weapon_motion() - -func set_head(vnum: int) -> void: - _set_remote_part(EquipModel.PART_HEAD, vnum) - -func set_hair(vnum: int) -> void: - _set_remote_part(EquipModel.PART_HAIR, vnum) - -func _set_remote_part(index: int, vnum: int) -> void: - while _remote_parts.size() <= index: - _remote_parts.append(0) - _remote_parts[index] = vnum - if remote_equip: - remote_equip._set_remote_part(index, vnum) - remote_equip.refresh() - -func _refresh_weapon_motion() -> void: - var vnum := int(_remote_parts[EquipModel.PART_WEAPON]) if _remote_parts.size() > EquipModel.PART_WEAPON else 0 - var mode := MODE_GENERAL - if vnum > 0 and _remote_proto and _remote_proto.has_method("item"): - var item: Dictionary = _remote_proto.item(vnum) - if int(item.get("type", -1)) == ITEM_TYPE_WEAPON: - mode = int(WEAPON_MODE.get(int(item.get("sub_type", -1)), MODE_GENERAL)) - set_motion_mode(mode) diff --git a/project/ui/remote_player_view.gd.uid b/project/ui/remote_player_view.gd.uid deleted file mode 100644 index baabfea2..00000000 --- a/project/ui/remote_player_view.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bf22s7b7sym1 diff --git a/project/ui/safebox_ui.gd b/project/ui/safebox_ui.gd deleted file mode 100644 index 86ef3cff..00000000 --- a/project/ui/safebox_ui.gd +++ /dev/null @@ -1,779 +0,0 @@ -# SafeboxUI (P8) —— 仓库窗(1:1 对齐 40250 `root/uisafebox.py` 与 `uiscript/safeboxwindow.py`)。 -# -# var bu := preload("res://ui/safebox_ui.gd").new() -# add_child(bu) -# bu.setup(m2client, canvas_parent, proto) -# -# 采用 40250 经典 176×373 羊皮纸金属 Board 边框与 TitleBar。 -# 5×9 格子(45 格/页),使用 Slot_Base.sub 底图。 -# 底部单选分页按键(I / II / III)、修改密码与关闭大按钮。 -# 右键格子道具直接取出至背包;在背包中右键直接存入仓库;支持鼠标拖放。 -extends Node - -const ItemTooltip = preload("res://ui/item_tooltip.gd") -const ItemTooltipBridge = preload("res://ui/item_tooltip_bridge.gd") -const UiKit = preload("res://ui_kit.gd") -const UiBuild = preload("res://ui/ui_build.gd") -const UiAssets = preload("res://ui/ui_assets.gd") - -var client: Node -var proto: Node -var _tooltip_builder: RefCounted -var _tooltip_bridge: RefCounted -var _assets_root := "" -var _root: Control -var _board_panel: Control -var _list: VBoxContainer -var _gold: Label -var _title: Label -var _status: Label -var _password_dialog: ConfirmationDialog -var _grid_container: Control -var _cells: Dictionary = {} -var _page_buttons: Array[Button] = [] -var _page_button_container: HBoxContainer -var _change_pw_btn: Button -var _exit_btn: Button -var _page_label: Label -var _page := 0 -var item_mouse: Node -var _mobile_mode := false -var _mobile_inventory_title: Label -var _mobile_inventory_scroll: ScrollContainer -var _mobile_inventory_list: VBoxContainer -var _mobile_deposit_pending := {} -var _change_pw_dialog: ConfirmationDialog - -const SAFE_PAGE_SLOTS := 45 -const SAFE_WINDOW := 3 -const INVENTORY_WINDOW := 1 -const USE_SAFEBOX_LIMIT_RANGE_M := 10.0 - -var ui: CanvasLayer -var _open_char_pos := Vector3.ZERO -var _has_open_pos := false - -func setup(m2client: Node, parent: Node, proto_node: Node = null, assets_override := "") -> void: - client = m2client - proto = proto_node - ui = parent as CanvasLayer - _assets_root = assets_override if assets_override != "" else AssetRoot.path() - _tooltip_builder = ItemTooltip.new() - _tooltip_builder.setup(_assets_root, "en", proto) - _tooltip_bridge = ItemTooltipBridge.new() - _tooltip_bridge.setup(parent, proto, _assets_root) - _build(parent) - if client and client.has_signal("safebox_opened"): - client.safebox_opened.connect(func(_size): open()) - if client and client.has_signal("safebox_closed"): - client.safebox_closed.connect(_close_local) - if client and client.has_signal("safebox_changed"): - client.safebox_changed.connect(refresh) - if client and client.has_signal("inventory_changed"): - client.inventory_changed.connect(func(_w = 0, _c = 0): _refresh_inventory_candidates()) - if client and client.has_signal("safebox_password_required"): - client.safebox_password_required.connect(func(): _ask_password("safebox")) - if client and client.has_signal("safebox_wrong_password"): - client.safebox_wrong_password.connect(func(): - if is_instance_valid(_status): - _status.text = "仓库密码错误") - if ui and ui.has_signal("window_closed"): - ui.window_closed.connect(func(w): - if w == _root and is_open(): - close() - ) - -func is_open() -> bool: - return _root != null and _root.visible - -func _get_main_char_pos() -> Variant: - if client == null: - return null - var vid := 0 - if client.has_method("get_main_vid"): - vid = int(client.get_main_vid()) - if vid != 0 and client.has_method("get_entity"): - var e: Dictionary = client.get_entity(vid) - if not e.is_empty() and e.has("pos"): - return e["pos"] - return null - -func _process(_delta: float) -> void: - if not is_open() or not _has_open_pos: - return - var cur = _get_main_char_pos() - if cur is Vector3: - if _open_char_pos.distance_to(cur) > USE_SAFEBOX_LIMIT_RANGE_M: - close() - -func open() -> void: - if _root: - var vp_size := Vector2(800.0, 600.0) - if _root.is_inside_tree(): - vp_size = _root.get_viewport_rect().size - elif ui and ui.is_inside_tree(): - vp_size = ui.get_viewport().get_visible_rect().size - _root.position.x = maxf(0.0, (vp_size.x - _root.size.x) / 2.0) - _root.position.y = maxf(0.0, (vp_size.y - _root.size.y) / 2.0) - _root.visible = true - if ui and ui.has_method("open"): - ui.open(_root) - var p = _get_main_char_pos() - if p is Vector3: - _open_char_pos = p - _has_open_pos = true - refresh() - -func toggle_window() -> void: - if is_open(): - close() - else: - open() - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - _apply_mobile_layout() - _refresh_inventory_candidates() - -func get_mobile_window() -> Control: - return _root - -func close() -> void: - # 对齐原版 SafeboxWindow.Close():先发 /safebox_close 让服务器同步关仓 - if client and client.has_method("safebox_close") and is_open(): - client.safebox_close() - _close_local() - -func _close_local() -> void: - if _tooltip_bridge: - _tooltip_bridge.hide() - _has_open_pos = false - if item_mouse and item_mouse.has_method("unregister_owner"): - item_mouse.unregister_owner(self) - if item_mouse.has_method("is_attached") and item_mouse.is_attached(): - item_mouse.cancel() - if _root: - _root.visible = false - if ui and "_stack" in ui: - ui._stack.erase(_root) - if ui.has_method("_restack"): - ui._restack() - _page = 0 - _mobile_deposit_pending.clear() - if is_instance_valid(_password_dialog): - _password_dialog.queue_free() - _password_dialog = null - -func deposit(inv_window: int, inv_cell: int) -> void: - if not is_open() or inv_window < 0 or inv_cell < 0: - return - if inv_window == INVENTORY_WINDOW and _mobile_deposit_pending.has(inv_cell): - if is_instance_valid(_status): - _status.text = "该物品的存入请求已发送" - return - var safe_pos := _next_free_slot() - if safe_pos < 0: - if is_instance_valid(_status): - _status.text = "仓库已满" - return - if not client.has_method("safebox_checkin"): - if is_instance_valid(_status): - _status.text = "存入请求发送失败" - return - if not client.safebox_checkin(safe_pos, inv_window, inv_cell): - if is_instance_valid(_status): - _status.text = "存入请求发送失败" - return - if inv_window == INVENTORY_WINDOW: - _mobile_deposit_pending[inv_cell] = true - if is_instance_valid(_status): - _status.text = "已发送存入请求" - -func _next_free_slot() -> int: - var used := {} - if client and client.has_method("get_safebox_items"): - for it in client.get_safebox_items(): - used[int(it.get("cell", -1))] = true - var cap: int = maxi(1, int(client.get_safebox_size()) if client and client.has_method("get_safebox_size") else 1) * 45 - for i in range(cap): - if not used.has(i): - return i - return -1 - -func _name_of(vnum: int) -> String: - if proto and proto.has_method("item"): - var d: Dictionary = proto.item(vnum) - var n := String(d.get("locale_name", d.get("name", ""))) - if n != "": - return n - return "#%d" % vnum - -func _tooltip_for(item: Dictionary) -> String: - var vnum := int(item.get("vnum", 0)) - if _tooltip_builder: - var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} - return _tooltip_builder.format(vnum, int(item.get("count", 1)), pd, item) - return "%s\n#%d ×%d" % [_name_of(vnum), vnum, int(item.get("count", 1))] - -func refresh() -> void: - if client == null: - return - _root.visible = client.is_safebox_open() if client.has_method("is_safebox_open") else true - if not _root.visible: - return - if is_instance_valid(_status): - _status.text = "" - var pages := maxi(1, int(client.get_safebox_size())) - _page = clampi(_page, 0, pages - 1) - if is_instance_valid(_title): - _title.text = "仓库" if not _mobile_mode else "仓库(第 %d/%d 页)" % [_page + 1, pages] - if is_instance_valid(_page_label): - _page_label.text = "%d / %d" % [_page + 1, pages] - if is_instance_valid(_gold): - _gold.text = "仓库金币: %d" % client.get_safebox_gold() - for i in _page_buttons.size(): - var btn: Button = _page_buttons[i] - btn.visible = i < pages - btn.set_pressed_no_signal(i == _page) - _render_grid() - for c in _list.get_children(): - c.queue_free() - var items: Array = client.get_safebox_items() if client and client.has_method("get_safebox_items") else [] - if items.is_empty(): - var e := Label.new() - e.text = "(空)" - _list.add_child(e) - else: - for it in items: - _list.add_child(_row(it)) - _refresh_inventory_candidates() - -func _render_grid() -> void: - for pos in _cells: - var cell: Control = _cells[pos] - _clear_grid_cell(cell) - var items: Array = client.get_safebox_items() if client and client.has_method("get_safebox_items") else [] - for it in items: - var absolute_pos := int(it.get("cell", -1)) - var pos := absolute_pos - _page * SAFE_PAGE_SLOTS - if not _cells.has(pos): - continue - var cell: Control = _cells[pos] - _fill_grid_cell(cell, it) - -func _clear_grid_cell(cell: Control) -> void: - cell.tooltip_text = "" - cell.set_meta("tooltip_item", {}) - cell.set_meta("vnum", 0) - cell.set_meta("count", 0) - for c in cell.get_children(): - c.queue_free() - -func _fill_grid_cell(cell: Control, item: Dictionary) -> void: - var vnum := int(item.get("vnum", 0)) - var count := int(item.get("count", 1)) - cell.set_meta("vnum", vnum) - cell.set_meta("count", count) - cell.tooltip_text = "" - cell.set_meta("tooltip_item", item.duplicate(true)) - - var tex := _icon(vnum) - if tex != null: - var tr := TextureRect.new() - tr.name = "Icon" - tr.texture = tex - tr.set_anchors_preset(Control.PRESET_FULL_RECT) - tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - tr.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(tr) - else: - var lbl := Label.new() - lbl.name = "Icon" - lbl.text = _name_of(vnum).substr(0, 4) - lbl.add_theme_font_size_override("font_size", 9) - lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(lbl) - - if count > 1: - var cnt_lbl := Label.new() - cnt_lbl.name = "Count" - cnt_lbl.text = str(count) - cnt_lbl.position = Vector2(2, 16) - cnt_lbl.size = Vector2(28, 14) - cnt_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - cnt_lbl.add_theme_font_size_override("font_size", 10) - cnt_lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 0.8)) - cnt_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(cnt_lbl) - -func _icon(vnum: int) -> Texture2D: - if _assets_root == "": - return null - var rel := "icon/item/%05d.tga" % vnum - var tex := UiAssets.load_tex(_assets_root, rel) - if tex == null: - rel = "icon/item/%05d.tga" % ((vnum / 10) * 10) - tex = UiAssets.load_tex(_assets_root, rel) - return tex - -func _drop_to_slot(payload: Dictionary, local_pos: int) -> bool: - if payload.is_empty() or not _cells.has(local_pos): - return false - var safe_pos := _page * SAFE_PAGE_SLOTS + local_pos - var cell: Control = _cells[local_pos] - if int(cell.get_meta("vnum", 0)) != 0: - return false - var source_window := int(payload.get("window", -1)) - var source_cell := int(payload.get("cell", -1)) - if source_cell < 0: - return false - if source_window in [1, 2] and client.has_method("safebox_checkin"): - return client.safebox_checkin(safe_pos, source_window, source_cell) - if source_window == SAFE_WINDOW and client.has_method("safebox_move"): - return client.safebox_move(source_cell, safe_pos, 1) - return false - -func _on_cell_input(local_pos: int, ev: InputEvent) -> void: - if not (ev is InputEventMouseButton and ev.pressed): - return - if ev.button_index == MOUSE_BUTTON_RIGHT: - var safe_pos := _page * SAFE_PAGE_SLOTS + local_pos - var target := _first_free_inv() - if target < 0: - if is_instance_valid(_status): _status.text = "背包已满" - return - if client and client.has_method("safebox_checkout"): - client.safebox_checkout(safe_pos, 1, target) - elif ev.button_index == MOUSE_BUTTON_LEFT: - if _mobile_mode: - _on_mobile_grid_tap(local_pos) - return - if item_mouse and item_mouse.has_method("attach_item"): - var cell: Control = _cells.get(local_pos, null) - if cell: - var vnum := int(cell.get_meta("vnum", 0)) - if vnum > 0: - item_mouse.attach_item(SAFE_WINDOW, _page * SAFE_PAGE_SLOTS + local_pos, vnum, - int(cell.get_meta("count", 1)), null, "safebox") - -func _on_mobile_grid_tap(local_pos: int) -> void: - if client == null or not _cells.has(local_pos): - return - var cell: Control = _cells[local_pos] - if int(cell.get_meta("vnum", 0)) == 0 or not client.has_method("safebox_checkout"): - return - var target := _first_free_inv() - if target < 0: - if is_instance_valid(_status): - _status.text = "背包已满" - return - if not client.safebox_checkout(_page * SAFE_PAGE_SLOTS + local_pos, 1, target): - if is_instance_valid(_status): - _status.text = "取出请求发送失败" - -func _select_page(index: int) -> void: - _page = index - for i in _page_buttons.size(): - var btn: Button = _page_buttons[i] - btn.set_pressed_no_signal(i == _page) - if _page_label: - _page_label.text = "%d / 3" % (_page + 1) - if _title: - _title.text = "仓库" if not _mobile_mode else "仓库(第 %d/3 页)" % (_page + 1) - _render_grid() - -func _change_page(delta: int) -> void: - var pages := maxi(1, int(client.get_safebox_size()) if client and client.has_method("get_safebox_size") else 3) - _select_page(posmod(_page + delta, pages)) - -func _apply_mobile_layout() -> void: - if _root == null: - return - if _mobile_mode: - _root.size = Vector2(688, 400) - _root.position = Vector2(-344, -200) - if _grid_container: - _grid_container.position = Vector2(12, 54) - if _mobile_inventory_title: - _mobile_inventory_title.visible = true - _mobile_inventory_title.position = Vector2(350, 48) - if _mobile_inventory_scroll: - _mobile_inventory_scroll.visible = true - _mobile_inventory_scroll.position = Vector2(350, 76) - _mobile_inventory_scroll.size = Vector2(326, 292) - if _gold: - _gold.visible = true - _gold.position = Vector2(12, 350) - if _change_pw_btn: - _change_pw_btn.position = Vector2(12, 316) - if _exit_btn: - _exit_btn.position = Vector2(174, 316) - else: - # 桌面端也显示背包候选面板(右侧),方便存入物品。 - _root.size = Vector2(530, 418) - if _grid_container: - _grid_container.position = Vector2(8, 35) - if _mobile_inventory_title: - _mobile_inventory_title.visible = true - _mobile_inventory_title.position = Vector2(184, 35) - if _mobile_inventory_scroll: - _mobile_inventory_scroll.visible = true - _mobile_inventory_scroll.position = Vector2(184, 56) - _mobile_inventory_scroll.size = Vector2(336, 296) - if _gold: - _gold.visible = false - if _page_button_container: - _page_button_container.position = Vector2(10, 333) - if _change_pw_btn: - _change_pw_btn.position = Vector2(51, 360) - if _exit_btn: - _exit_btn.position = Vector2(51, 381) - -func _refresh_inventory_candidates(_w: int = 0, _c: int = 0) -> void: - if _mobile_inventory_list == null or not is_open(): - return - for child in _mobile_inventory_list.get_children(): - child.queue_free() - var inventory: Array = client.get_inventory() if client and client.has_method("get_inventory") else [] - var shown := 0 - for item in inventory: - var cell := int(item.get("cell", -1)) - var vnum := int(item.get("vnum", 0)) - if cell < 0 or vnum <= 0 or _mobile_deposit_pending.has(cell): - continue - var button := Button.new() - button.alignment = HORIZONTAL_ALIGNMENT_LEFT - button.text = "%s ×%d · 点按存入" % [_name_of(vnum), int(item.get("count", 1))] - button.custom_minimum_size = Vector2(316, 34) - if _tooltip_bridge: - _tooltip_bridge.bind(button, func() -> Dictionary: return item.duplicate(true)) - var captured_cell := cell - button.pressed.connect(func() -> void: deposit(INVENTORY_WINDOW, captured_cell)) - _mobile_inventory_list.add_child(button) - shown += 1 - if shown == 0: - var empty := Label.new() - empty.text = "(没有可存入物品)" - empty.add_theme_font_size_override("font_size", 12) - _mobile_inventory_list.add_child(empty) - -func _ask_password(kind: String) -> void: - if is_instance_valid(_password_dialog): - _password_dialog.queue_free() - _password_dialog = ConfirmationDialog.new() - _password_dialog.title = "商城密码" if kind == "mall" else "仓库密码" - _password_dialog.dialog_text = "请输入 1~6 位密码" - _password_dialog.ok_button_text = "确认" - _password_dialog.cancel_button_text = "取消" - var edit := LineEdit.new() - edit.secret = true - edit.max_length = 6 - edit.placeholder_text = "密码" - edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER - edit.custom_minimum_size = Vector2(360, 44) if _mobile_mode else Vector2(220, 28) - _password_dialog.add_child(edit) - _password_dialog.confirmed.connect(func() -> void: - var sent: bool = client.safebox_password(edit.text) if kind == "safebox" else client.mall_password(edit.text) - if not sent and is_instance_valid(_status): - _status.text = "密码格式无效" - _password_dialog.queue_free() - _password_dialog = null) - _password_dialog.canceled.connect(func() -> void: - _password_dialog.queue_free() - _password_dialog = null) - if _mobile_mode: - _password_dialog.set_meta("mobile_modal", true) - _password_dialog.set_meta("mobile_title", "商城密码" if kind == "mall" else "仓库密码") - _password_dialog.min_size = Vector2i(460, 230) - _password_dialog.size = Vector2i(460, 230) - _root.add_child(_password_dialog) - if _mobile_mode: - _password_dialog.popup_centered(Vector2i(460, 230)) - else: - _password_dialog.popup_centered() - -func _ask_change_password() -> void: - if not is_open(): - return - if is_instance_valid(_change_pw_dialog): - _change_pw_dialog.queue_free() - _change_pw_dialog = ConfirmationDialog.new() - _change_pw_dialog.title = "修改仓库密码" - _change_pw_dialog.dialog_text = "旧密码 / 新密码(1~6 位)/ 再输入一次新密码" - _change_pw_dialog.ok_button_text = "确认" - _change_pw_dialog.cancel_button_text = "取消" - var box := VBoxContainer.new() - var edits: Array[LineEdit] = [] - for hint in ["旧密码", "新密码", "确认新密码"]: - var row := HBoxContainer.new() - var lbl := Label.new() - lbl.text = hint - lbl.custom_minimum_size = Vector2(96, 0) - row.add_child(lbl) - var edit := LineEdit.new() - edit.secret = true - edit.max_length = 6 - edit.placeholder_text = hint - edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER - edit.custom_minimum_size = Vector2(360, 44) if _mobile_mode else Vector2(160, 28) - row.add_child(edit) - box.add_child(row) - edits.append(edit) - _change_pw_dialog.add_child(box) - _change_pw_dialog.confirmed.connect(func() -> void: - var old_pw := edits[0].text - var new_pw := edits[1].text - if new_pw != edits[2].text: - if is_instance_valid(_status): - _status.text = "两次输入的新密码不一致" - elif old_pw.is_empty() or new_pw.is_empty() or new_pw.length() > 6 or old_pw.length() > 6: - if is_instance_valid(_status): - _status.text = "密码须为 1~6 位" - elif not client.safebox_change_password(old_pw, new_pw): - if is_instance_valid(_status): - _status.text = "修改密码请求发送失败" - else: - if is_instance_valid(_status): - _status.text = "已提交修改密码请求" - _change_pw_dialog.queue_free() - _change_pw_dialog = null) - _change_pw_dialog.canceled.connect(func() -> void: - _change_pw_dialog.queue_free() - _change_pw_dialog = null) - if _mobile_mode: - _change_pw_dialog.set_meta("mobile_modal", true) - _change_pw_dialog.set_meta("mobile_title", "修改仓库密码") - _change_pw_dialog.min_size = Vector2i(560, 320) - _change_pw_dialog.size = Vector2i(560, 320) - _root.add_child(_change_pw_dialog) - if _mobile_mode: - _change_pw_dialog.popup_centered(Vector2i(560, 320)) - else: - _change_pw_dialog.popup_centered() - -func _row(it: Dictionary) -> Control: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(300, 0) - var nm := Label.new() - nm.text = "%s ×%d" % [_name_of(int(it.get("vnum", 0))), int(it.get("count", 1))] - nm.custom_minimum_size = Vector2(220, 0) - nm.add_theme_font_size_override("font_size", 12) - if _tooltip_bridge: - _tooltip_bridge.bind(nm, func() -> Dictionary: return it.duplicate(true)) - row.add_child(nm) - var out := Button.new() - out.text = "取出" - var cell := int(it.get("cell", 0)) - out.pressed.connect(func() -> void: - var target := _first_free_inv() - if target < 0: - if is_instance_valid(_status): _status.text = "背包已满" - return - if not client.safebox_checkout(cell, 1, target): - if is_instance_valid(_status): _status.text = "取出请求发送失败") - row.add_child(out) - return row - -func _first_free_inv() -> int: - var used := {} - if client and client.has_method("get_inventory"): - for it in client.get_inventory(): - used[int(it.get("cell", -1))] = true - for i in range(90): - if not used.has(i): - return i - return -1 - -func _build(parent: Node) -> void: - _root = Control.new() - _root.name = "SafeboxWindow" - _root.size = Vector2(176, 418) - parent.add_child(_root) - var vp_size := Vector2(800.0, 600.0) - if _root.is_inside_tree(): - vp_size = _root.get_viewport_rect().size - elif ui and ui.is_inside_tree(): - vp_size = ui.get_viewport().get_visible_rect().size - _root.position.x = maxf(0.0, (vp_size.x - _root.size.x) / 2.0) - _root.position.y = maxf(0.0, (vp_size.y - _root.size.y) / 2.0) - _root.visible = false - _root.set_meta("is_titlebar", true) - - # 1. 经典羊皮纸金属 Board 边框 - var board_np := UiKit.board(_assets_root, "board", 32, 128) - if board_np and board_np.texture: - board_np.set_anchors_preset(Control.PRESET_FULL_RECT) - board_np.mouse_filter = Control.MOUSE_FILTER_IGNORE - _root.add_child(board_np) - else: - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.06, 0.08, 0.09, 0.97) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - - # 2. 官方 TitleBar - var titlebar := UiBuild._titlebar(_assets_root, 161, {}) - titlebar.name = "TitleBar" - titlebar.position = Vector2(8, 7) - _root.add_child(titlebar) - - _title = Label.new() - _title.name = "TitleName" - _title.text = "仓库" - _title.position = Vector2(0, 0) - _title.size = Vector2(161, 23) - _title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.6)) - _title.mouse_filter = Control.MOUSE_FILTER_IGNORE - titlebar.add_child(_title) - - var close_btn: Button = titlebar.find_child("CloseButton", true, false) - if close_btn: - close_btn.pressed.connect(close) - - # 3. 5×9 GridSlotWindow (8, 35) - _grid_container = Control.new() - _grid_container.name = "wndItem" - _grid_container.position = Vector2(8, 35) - _grid_container.size = Vector2(160, 288) - _root.add_child(_grid_container) - - var slot_base_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/Slot_Base.sub") - for pos in SAFE_PAGE_SLOTS: - var col := pos % 5 - var row := pos / 5 - var cell := Panel.new() - cell.name = "slot_%d" % pos - cell.position = Vector2(col * 32, row * 32) - cell.custom_minimum_size = Vector2(32, 32) - cell.size = Vector2(32, 32) - cell.set_meta("safe_pos", pos) - cell.mouse_filter = Control.MOUSE_FILTER_STOP - if slot_base_tex: - var sbt := StyleBoxTexture.new() - sbt.texture = slot_base_tex - cell.add_theme_stylebox_override("panel", sbt) - else: - var sbf := StyleBoxFlat.new() - sbf.bg_color = Color(0.12, 0.12, 0.15) - cell.add_theme_stylebox_override("panel", sbf) - - cell.gui_input.connect(func(e: InputEvent): _on_cell_input(pos, e)) - if _tooltip_bridge: - cell.mouse_entered.connect(func(): _tooltip_bridge.show_item(cell.get_meta("tooltip_item", {}))) - cell.mouse_exited.connect(func(): _tooltip_bridge.hide()) - if item_mouse and item_mouse.has_method("register_target"): - item_mouse.register_target(cell, - func(payload: Dictionary): return _drop_to_slot(payload, pos), self) - _grid_container.add_child(cell) - _cells[pos] = cell - - # 4. 单选分页按键 (I, II, III) - 40250 原版在 y = 333 (418 - 85) - _page_button_container = HBoxContainer.new() - _page_button_container.name = "PageButtons" - _page_button_container.position = Vector2(10, 333) - _page_button_container.size = Vector2(156, 20) - _page_button_container.alignment = BoxContainer.ALIGNMENT_CENTER - _page_button_container.add_theme_constant_override("separation", 6) - _root.add_child(_page_button_container) - - for p_idx in 3: - var p_btn := Button.new() - p_btn.name = "PageBtn_%d" % p_idx - p_btn.text = ["I", "II", "III"][p_idx] - p_btn.custom_minimum_size = Vector2(44, 18) - p_btn.toggle_mode = true - var t1 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/windows/tab_button_middle_01.sub") - var t2 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/windows/tab_button_middle_02.sub") - var t3 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/windows/tab_button_middle_03.sub") - if t1: - var sb1 := StyleBoxTexture.new(); sb1.texture = t1 - p_btn.add_theme_stylebox_override("normal", sb1) - if t2: - var sb2 := StyleBoxTexture.new(); sb2.texture = t2 - p_btn.add_theme_stylebox_override("hover", sb2) - if t3: - var sb3 := StyleBoxTexture.new(); sb3.texture = t3 - p_btn.add_theme_stylebox_override("pressed", sb3) - p_btn.pressed.connect(func(): _select_page(p_idx)) - _page_button_container.add_child(p_btn) - _page_buttons.append(p_btn) - - # 5. 修改密码与关闭大按钮 - 40250 原版居中纵向排列 - _change_pw_btn = _create_large_button("ChangePasswordButton", "修改密码", Vector2(51, 360), func(): _ask_change_password()) - _change_pw_btn.size = Vector2(74, 21) - _root.add_child(_change_pw_btn) - - _exit_btn = _create_large_button("ExitButton", "关闭", Vector2(51, 381), func(): close()) - _exit_btn.size = Vector2(74, 21) - _root.add_child(_exit_btn) - - # 兼容字段(测试与移动端使用) - _gold = Label.new() - _gold.name = "Gold" - _gold.visible = false - _root.add_child(_gold) - - _page_label = Label.new() - _page_label.name = "PageLabel" - _page_label.visible = false - _root.add_child(_page_label) - - _status = Label.new() - _status.name = "Status" - _status.visible = false - _root.add_child(_status) - - _list = VBoxContainer.new() - _list.name = "List" - _list.visible = false - _root.add_child(_list) - - # 移动端适配容器 - _mobile_inventory_title = Label.new() - _mobile_inventory_title.text = "背包候选 · 点按存入" - _mobile_inventory_title.position = Vector2(350, 48) - _mobile_inventory_title.visible = false - _root.add_child(_mobile_inventory_title) - - _mobile_inventory_scroll = ScrollContainer.new() - _mobile_inventory_scroll.position = Vector2(350, 76) - _mobile_inventory_scroll.size = Vector2(326, 292) - _mobile_inventory_scroll.visible = false - _root.add_child(_mobile_inventory_scroll) - - _mobile_inventory_list = VBoxContainer.new() - _mobile_inventory_list.add_theme_constant_override("separation", 6) - _mobile_inventory_scroll.add_child(_mobile_inventory_list) - - _select_page(0) - _apply_mobile_layout() - -func _create_large_button(btn_name: String, text: String, pos: Vector2, on_click: Callable) -> Button: - var btn := Button.new() - btn.name = btn_name - btn.text = text - btn.position = pos - btn.size = Vector2(74, 22) - btn.custom_minimum_size = Vector2(74, 22) - btn.add_theme_font_size_override("font_size", 11) - var t1 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/large_button_01.sub") - var t2 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/large_button_02.sub") - var t3 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/large_button_03.sub") - if t1: - var sb1 := StyleBoxTexture.new(); sb1.texture = t1 - btn.add_theme_stylebox_override("normal", sb1) - if t2: - var sb2 := StyleBoxTexture.new(); sb2.texture = t2 - btn.add_theme_stylebox_override("hover", sb2) - if t3: - var sb3 := StyleBoxTexture.new(); sb3.texture = t3 - btn.add_theme_stylebox_override("pressed", sb3) - btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - btn.pressed.connect(on_click) - return btn diff --git a/project/ui/safebox_ui.gd.uid b/project/ui/safebox_ui.gd.uid deleted file mode 100644 index f4176320..00000000 --- a/project/ui/safebox_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dqnnrie05cy1p diff --git a/project/ui/select_item_ui.gd b/project/ui/select_item_ui.gd deleted file mode 100644 index 82e5ddb1..00000000 --- a/project/ui/select_item_ui.gd +++ /dev/null @@ -1,217 +0,0 @@ -# SelectItemUI (P11) —— 选魔石窗(1:1 迁移 `assets/root/uiselectitem.py`)。 -# -# var si := preload("res://ui/select_item_ui.gd").new() -# add_child(si) -# si.setup(ui_manager, m2client, proto, assets_root, item_list) -# si.open() # 由 quest_dialog.select_item_requested 触发 -# -# 布局走 `assets/uiscript/uiscript/selectitemwindow.py`(UiScript → UiBuild)。 -# 逐字对照 uiselectitem.py.RefreshSlot:遍历背包前 `INVENTORY_PAGE_SIZE*2` 格, -# 只保留 `item.IsMetin`(proto.type == ITEM_TYPE_METIN 10)且 `GetItemGrade <= 2` -# (= 物品内部名最后一位数字,非数字按 0)的物品,最多 54 个。 -# 点格子 -> `net.SendSelectItemPacket(inventoryCell)`(= M2Client.script_select_item)+ 关窗; -# 关闭 / ExitButton -> `net.SendSelectItemPacket(0)` + 关窗(uiselectitem.Close)。 -extends Node - -const UiAssets = preload("res://ui/ui_assets.gd") - -const ITEM_TYPE_METIN := 10 # GameLib/ItemData.h EItemType -const INVENTORY_PAGE_SIZE := 45 # player.INVENTORY_PAGE_SIZE -const MAX_ITEMS := 54 # uiselectitem: slotPos > 54 -> break -const WINDOW_INVENTORY := 1 - -var ui: CanvasLayer # UiManager -var client: Node # M2Client -var proto: Node # Metin2Proto -var item_list: RefCounted # ItemListDB(可空) -var assets_root := "" -var uiscript_dir := "" - -var _win: Dictionary = {} # { root, nodes } -var _cells := {} # slot_index:int -> Panel -var _slot_to_inv := {} # 选择窗格号 -> 背包格号(inventorySlotPosDict) -var _sent := false # 已发过一次 select(避免关窗再补发 0) - -func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets := "", - il: RefCounted = null) -> void: - ui = ui_manager - client = m2client - proto = proto_node - item_list = il - assets_root = assets - if assets_root == "" and ui and "assets_root" in ui: - assets_root = ui.assets_root - uiscript_dir = assets_root.path_join("uiscript/uiscript") - if not DirAccess.dir_exists_absolute(uiscript_dir): - uiscript_dir = assets_root.path_join("uiscript") - if client and client.has_signal("inventory_changed"): - client.inventory_changed.connect(func(_w, _c): if is_open(): _refresh()) - -# --- open / close ---------------------------------------------------- - -func is_open() -> bool: - return not _win.is_empty() and is_instance_valid(_win.get("root")) - -func toggle() -> void: - if is_open(): close() - else: open() - -# uiselectitem.Close: net.SendSelectItemPacket(0) 再隐藏 -func close() -> void: - if is_open(): - if not _sent and client and client.has_method("script_select_item"): - client.script_select_item(0) - ui.close(_win["root"]) - _win = {} - _cells.clear() - _slot_to_inv.clear() - -func open() -> void: - if is_open(): - _refresh() - return - var path := uiscript_dir.path_join("selectitemwindow.py") - if not FileAccess.file_exists(path): - push_warning("SelectItemUI: no selectitemwindow.py at " + path) - return - _sent = false - _win = ui.open_script(path, assets_root) - if not is_open(): - return - _index_cells(_win["root"]) - _wire() - _refresh() - -func _node(nm: String) -> Control: - if _win.is_empty(): - return null - var n = _win.get("nodes", {}).get(nm, null) - return n if n is Control else null - -func _index_cells(root: Node) -> void: - _cells.clear() - for n in root.find_children("slot_*", "Panel", true, false): - if n.has_meta("slot_index"): - _cells[int(n.get_meta("slot_index"))] = n - -func _wire() -> void: - for idx in _cells: - var cell: Panel = _cells[idx] - cell.gui_input.connect(func(e: InputEvent): _on_cell_input(int(idx), e)) - var exit_btn := _node("ExitButton") - if exit_btn is BaseButton: - exit_btn.pressed.connect(close) - var tb := _node("TitleBar") - if tb: - for b in tb.find_children("*", "BaseButton", true, false): - b.pressed.connect(close) - -# --- fill (uiselectitem.RefreshSlot) -------------------------------- - -func _item_grade(vnum: int) -> int: - # player.GetItemGrade: 物品内部名最后一位数字(非数字 -> 0) - var nm := "" - if proto: - var pd: Dictionary = proto.item(vnum) - nm = String(pd.get("name", "")) - if nm == "": - return 0 - var last := nm.substr(nm.length() - 1, 1) - return int(last) if last.is_valid_int() else 0 - -func _is_metin(vnum: int) -> bool: - if proto == null: - return false - var pd: Dictionary = proto.item(vnum) - return int(pd.get("type", 0)) == ITEM_TYPE_METIN - -func _refresh() -> void: - if not is_open() or client == null: - return - for idx in _cells: - _clear_cell(_cells[idx]) - _slot_to_inv.clear() - - # 背包按格号索引,只看前 INVENTORY_PAGE_SIZE*2 格(uiselectitem 的 range) - var by_cell := {} - for d in client.get_inventory(): - by_cell[int(d["cell"])] = d - var slot_pos := 0 - for i in range(INVENTORY_PAGE_SIZE * 2): - var d = by_cell.get(i, null) - if d == null: - continue - var vnum := int(d["vnum"]) - if vnum == 0 or not _is_metin(vnum): - continue - if _item_grade(vnum) > 2: - continue - _slot_to_inv[slot_pos] = i - var cell: Panel = _cells.get(slot_pos, null) - if cell: - _fill_cell(cell, vnum, int(d.get("count", 1))) - slot_pos += 1 - if slot_pos > MAX_ITEMS: - break - -func _fill_cell(cell: Panel, vnum: int, count: int) -> void: - _clear_cell(cell) - cell.set_meta("vnum", vnum) - var nm := "item %d" % vnum - if proto: - var pd: Dictionary = proto.item(vnum) - if not pd.is_empty(): - nm = String(pd.get("locale_name", pd.get("name", nm))) - cell.tooltip_text = "%s\n#%d" % [nm, vnum] - var tex := _icon(vnum) - if tex: - var tr := TextureRect.new() - tr.name = "icon" - tr.texture = tex - tr.set_anchors_preset(Control.PRESET_FULL_RECT) - tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - tr.stretch_mode = TextureRect.STRETCH_SCALE - tr.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(tr) - else: - var lb := Label.new() - lb.name = "icon" - lb.text = nm.substr(0, 6) - lb.add_theme_font_size_override("font_size", 9) - lb.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(lb) - if count > 1: - var cnt := Label.new() - cnt.name = "count" - cnt.text = str(count) - cnt.position = Vector2(2, 16) - cnt.add_theme_font_size_override("font_size", 9) - cnt.mouse_filter = Control.MOUSE_FILTER_IGNORE - cell.add_child(cnt) - -func _clear_cell(cell: Panel) -> void: - cell.set_meta("vnum", 0) - cell.tooltip_text = "" - for c in cell.get_children(): - c.queue_free() - -func _icon(vnum: int) -> Texture2D: - var rel := "" - if item_list and item_list.has(vnum): - rel = item_list.icon(vnum) - if rel == "": - rel = "icon/item/%05d.tga" % ((vnum / 10) * 10) - return UiAssets.load_tex(assets_root, rel) - -# --- select (uiselectitem.SelectItemSlot) -------------------------- - -func _on_cell_input(slot_pos: int, e: InputEvent) -> void: - if not (e is InputEventMouseButton) or e.button_index != MOUSE_BUTTON_LEFT or not e.pressed: - return - if not _slot_to_inv.has(slot_pos): - return - var inv_cell := int(_slot_to_inv[slot_pos]) - if client and client.has_method("script_select_item"): - client.script_select_item(inv_cell) # = net.SendSelectItemPacket(inventorySlotPos) - _sent = true - close() diff --git a/project/ui/select_item_ui.gd.uid b/project/ui/select_item_ui.gd.uid deleted file mode 100644 index ddf1af5f..00000000 --- a/project/ui/select_item_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://lro8chcsmtmk diff --git a/project/ui/shop_ui.gd b/project/ui/shop_ui.gd deleted file mode 100644 index 4448a67d..00000000 --- a/project/ui/shop_ui.gd +++ /dev/null @@ -1,685 +0,0 @@ -# ShopUI (P8) —— NPC 商店窗(1:1 严格对齐 40250 `root/uishop.py` 与 `uiscript/shopdialog.py`)。 -# -# var su := preload("res://ui/shop_ui.gd").new() -# add_child(su) -# su.setup(m2client, canvas_parent, proto, item_list) # proto / item_list 可空 -# -# 窗口尺寸严格对齐 40250: 184 × 328。 -# 边框: 经典羊皮纸金属 Board 边框(UiKit.board)。 -# 顶部: 40250 官方 TitleBar (8, 8, width=169),标题“商店”,带右上角关闭按钮。 -# 货架: 5 列 × 8 行(40 格),位置 (12, 34),单格 32×32,使用 Slot_Base.sub 底图。 -# 底部: 购买/出售中按钮(middle_button_01.sub,宽 61 高 21),或多货架 Tab。 -# 交互: 右键商品直接购买;左键在购买模式下弹窗确认;背包中右键或拖拽道具到商店出售。 -extends Node - -const CursorManager = preload("res://ui/cursor_manager.gd") -const ItemTooltip = preload("res://ui/item_tooltip.gd") -const ItemTooltipView = preload("res://ui/item_tooltip_view.gd") -const QuietButton = preload("res://ui/quiet_button.gd") -const UiAssets = preload("res://ui/ui_assets.gd") -const UiKit = preload("res://ui_kit.gd") -const UiBuild = preload("res://ui/ui_build.gd") - -const SHOP_SLOT_COUNT := 40 # shop.SHOP_SLOT_COUNT (== SHOP_HOST_ITEM_MAX_NUM) -const ITEM_ANTIFLAG_SELL := 1 << 8 -const ITEM_FLAG_COUNT_PER_1GOLD := 1 << 3 -const USE_SHOP_LIMIT_RANGE_M := 10.0 # 40250 USE_SHOP_LIMIT_RANGE = 1000cm (uishop.py:483) - -var client: Node -var proto: Node -var item_list # ItemListDB (RefCounted) -var _assets_root := "" -var _tooltip_builder: RefCounted -var _tooltip_view: Control = null -var _root: Control -var _tabbar: HBoxContainer -var _grid: GridContainer -var _err: Label -var _sell_quantity: SpinBox -var _active_tab := 0 -var _open_char_pos := Vector3.ZERO -var _has_open_pos := false -var item_mouse: Node -var cursor_manager: Node -var audio: Node -var _mode := 1 # 1=BUY, 2=SELL -var _buy_mode_button: Button -var _sell_mode_button: Button -var _sell_drop_target: Button -var _sell_confirm: ConfirmationDialog -var _buy_confirm: ConfirmationDialog -var _mobile_mode := false -var _mobile_sell_scroll: ScrollContainer -var _mobile_sell_list: VBoxContainer - -func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCounted = null) -> void: - client = m2client - proto = proto_node - item_list = ilist - _assets_root = AssetRoot.path() - _tooltip_builder = ItemTooltip.new() - _tooltip_builder.setup(_assets_root, "en", proto) - _build(parent) - if client.has_signal("shop_opened"): - client.shop_opened.connect(func(_v): open()) - if client.has_signal("shop_closed"): - client.shop_closed.connect(_close) - if client.has_signal("shop_updated"): - client.shop_updated.connect(_on_shop_slot_updated) - if client.has_signal("shop_error"): - client.shop_error.connect(_on_error) - -func is_open() -> bool: - return _root != null and _root.visible - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - if _root and _mobile_mode: - _set_mode(_mode) - -func get_mobile_window() -> Control: - return _root - -func close() -> void: - if client and client.has_method("shop_close") and is_open(): - client.shop_close() - _close() - -func _get_main_char_pos() -> Variant: - if client == null: - return null - var vid := 0 - if client.has_method("get_main_vid"): - vid = int(client.get_main_vid()) - if vid != 0 and client.has_method("get_entity"): - var e: Dictionary = client.get_entity(vid) - if not e.is_empty() and e.has("pos"): - return e["pos"] - return null - -func _process(_delta: float) -> void: - if not is_open() or not _has_open_pos: - return - var cur = _get_main_char_pos() - if cur is Vector3: - if _open_char_pos.distance_to(cur) > USE_SHOP_LIMIT_RANGE_M: - close() - -func _icon(vnum: int) -> Texture2D: - if vnum <= 0: - return null - var rel := "" - if item_list and item_list.has_method("has") and item_list.has(vnum): - rel = item_list.icon(vnum) - elif item_list and item_list.has_method("icon"): - rel = item_list.icon(vnum) - if rel != "": - return UiAssets.load_tex(_assets_root, rel) - var exact_rel := "icon/item/%05d.tga" % vnum - var tex := UiAssets.load_tex(_assets_root, exact_rel) - if tex != null: - return tex - var fallback_rel := "icon/item/%05d.tga" % ((vnum / 10) * 10) - return UiAssets.load_tex(_assets_root, fallback_rel) - -func open() -> void: - _root.visible = true - _err.text = "" - _sell_quantity.value = 1 - _active_tab = 0 - _set_mode(1) - var p = _get_main_char_pos() - if p is Vector3: - _open_char_pos = p - _has_open_pos = true - else: - _has_open_pos = false - refresh() - -func _close() -> void: - _hide_slot_tooltip() - _has_open_pos = false - if item_mouse and item_mouse.has_method("unregister_owner"): - item_mouse.unregister_owner(self) - if item_mouse.has_method("is_attached") and item_mouse.is_attached(): - item_mouse.cancel() - _root.visible = false - _err.text = "" - if _sell_quantity: - _sell_quantity.value = 1 - if is_instance_valid(_sell_confirm): - _sell_confirm.queue_free() - _sell_confirm = null - if is_instance_valid(_buy_confirm): - _buy_confirm.hide() - _buy_confirm.queue_free() - _buy_confirm = null - _set_mode(1) - -func close_and_leave() -> void: - client.shop_close() - _close() - -func sell(inv_cell: int, count: int = -1) -> void: - if not is_open() or inv_cell < 0: - return - var item := _inventory_item(inv_cell) - var amount := count if count > 0 else int(_sell_quantity.value) - if not item.is_empty(): - amount = clampi(amount, 1, int(item.get("count", amount))) - else: - amount = clampi(amount, 1, 200) - var payload := item.duplicate(true) - payload["window"] = 1 - payload["cell"] = inv_cell - payload["vnum"] = int(item.get("vnum", 0)) - payload["count"] = int(item.get("count", amount)) - _request_sell(payload, amount) - -func _drop_to_sell(payload: Dictionary) -> bool: - # uishop.py only exposes the sell target after switching to sell mode. A - # drag during buy mode must be rejected without sending CG_SHOP SELL/SELL2. - if _mode != 2: - return false - if payload.is_empty() or int(payload.get("window", -1)) != 1: - return false - var cell := int(payload.get("cell", -1)) - if cell < 0 or not client.has_method("shop_sell"): - return false - var source_count := maxi(1, int(payload.get("count", 1))) - var amount := clampi(int(_sell_quantity.value), 1, mini(200, source_count)) - return _request_sell(payload, amount) - -func _inventory_item(cell: int) -> Dictionary: - if client == null or not client.has_method("get_inventory"): - return {} - for item in client.get_inventory(): - if int(item.get("cell", -1)) == cell: - return item - return {} - -func _proto_item(vnum: int) -> Dictionary: - if vnum > 0 and proto and proto.has_method("item"): - return proto.item(vnum) - return {} - -func _sell_price(vnum: int, count: int, item_data: Dictionary = {}) -> int: - var d := item_data if not item_data.is_empty() else _proto_item(vnum) - var unit := maxi(0, int(d.get("sell_price", 0))) - var flags := int(d.get("flags", 0)) - var raw := (count / maxi(1, unit)) if (flags & ITEM_FLAG_COUNT_PER_1GOLD) != 0 else unit * count - return maxi(0, int(raw) / 5) - -func _is_valuable(payload: Dictionary, item_data: Dictionary) -> bool: - if int(item_data.get("sell_price", 0)) > 5000: - return true - var sockets: Array = payload.get("sockets", []) - for value in sockets: - if int(value) != 0: - return true - return false - -func _request_sell(payload: Dictionary, amount: int) -> bool: - var vnum := int(payload.get("vnum", 0)) - var d := _proto_item(vnum) - var anti_flags := int(payload.get("anti_flags", 0)) | int(d.get("anti_flags", 0)) - if (anti_flags & ITEM_ANTIFLAG_SELL) != 0: - _on_error("CANNOT_SELL") - _play_ui("loginfail.wav") - return false - if _is_valuable(payload, d): - _ask_sell_confirmation(payload, amount, _sell_price(vnum, amount, d)) - return true - return _send_sell(int(payload.get("cell", -1)), amount) - -func _send_sell(cell: int, amount: int) -> bool: - if cell < 0 or not client.has_method("shop_sell"): - return false - if not client.shop_sell(cell, amount): - _on_error("SEND_FAILED") - return false - _play_ui("money.wav") - return true - -func _ask_sell_confirmation(payload: Dictionary, amount: int, price: int) -> void: - if is_instance_valid(_sell_confirm): - _sell_confirm.queue_free() - var vnum := int(payload.get("vnum", 0)) - _sell_confirm = ConfirmationDialog.new() - _sell_confirm.title = "确认出售" - _sell_confirm.dialog_text = "确定以 %d 金币出售 %s ×%d?" % [ - price, - _name_of(vnum), - amount, - ] - _sell_confirm.ok_button_text = "出售" - _sell_confirm.cancel_button_text = "取消" - var dialog := _sell_confirm - dialog.confirmed.connect(func() -> void: - _sell_confirm = null - dialog.hide() - dialog.queue_free() - _send_sell(int(payload.get("cell", -1)), amount)) - dialog.canceled.connect(func() -> void: - _sell_confirm = null - dialog.hide() - dialog.queue_free()) - _prepare_mobile_confirmation(dialog, "确认出售") - _root.add_child(dialog) - if dialog.is_inside_tree(): - _popup_confirmation(dialog) - else: - if _mobile_mode: - dialog.call_deferred("popup_centered", Vector2i(480, 220)) - else: - dialog.call_deferred("popup_centered") - -func _play_ui(name: String) -> void: - if audio and audio.has_method("play_ui"): - audio.play_ui(name) - -func _set_mode(mode: int) -> void: - _mode = 2 if mode == 2 else 1 - if _buy_mode_button: - _buy_mode_button.button_pressed = _mode == 1 - if _sell_mode_button: - _sell_mode_button.button_pressed = _mode == 2 - if _sell_drop_target: - _sell_drop_target.visible = _mode == 2 - if cursor_manager and cursor_manager.has_method("set_cursor"): - cursor_manager.set_cursor(CursorManager.SELL if _mode == 2 else CursorManager.BUY) - if _mobile_sell_scroll: - _mobile_sell_scroll.visible = _mobile_mode and _mode == 2 - _grid.visible = not (_mobile_mode and _mode == 2) - _refresh_mobile_sell() - -func _name_of(vnum: int) -> String: - if proto and proto.has_method("item"): - var d: Dictionary = proto.item(vnum) - var n := String(d.get("locale_name", d.get("name", ""))) - if n != "": - return n - return "#%d" % vnum - -func refresh() -> void: - if not is_open() or client == null: - return - for c in _tabbar.get_children(): - _tabbar.remove_child(c) - c.queue_free() - for c in _grid.get_children(): - _grid.remove_child(c) - c.queue_free() - - # START_EX 商店有多个货架(shop.GetTabCount);普通 START 商店 tabs 只 1 个。 - var tabs: Array = [] - if client.has_method("get_shop"): - tabs = client.get_shop().get("tabs", []) - if tabs.is_empty(): - tabs = [{"name": "", "items": client.get_shop_items()}] - _active_tab = clampi(_active_tab, 0, tabs.size() - 1) - - if tabs.size() > 1: - _tabbar.visible = true - _buy_mode_button.visible = false - _sell_mode_button.visible = false - var tab_w := 61 if tabs.size() <= 2 else 43 - var tex_prefix := "d:/ymir work/ui/public/middle_button_" if tabs.size() <= 2 else "d:/ymir work/ui/public/small_button_" - for i in tabs.size(): - var tn := String(tabs[i].get("name", "")) - var tb_text := tn if tn != "" else "货架 %d" % (i + 1) - var tb := _create_toggle_button(tb_text, Vector2.ZERO, Vector2(tab_w, 21), tex_prefix) - tb.button_pressed = (i == _active_tab) - var idx := i - tb.pressed.connect(func() -> void: - _active_tab = idx - refresh()) - _tabbar.add_child(tb) - else: - _tabbar.visible = false - _buy_mode_button.visible = true - _sell_mode_button.visible = true - - var items: Array = tabs[_active_tab].get("items", []) - # uishop.py: 买位置 = tabIdx * SHOP_SLOT_COUNT + slotPos - var base := _active_tab * SHOP_SLOT_COUNT - var by_pos := {} - for it in items: - var slot := int(it.get("pos", 0)) - if slot >= 0 and slot < SHOP_SLOT_COUNT: - by_pos[slot] = it - - for slot in SHOP_SLOT_COUNT: - var it: Dictionary = by_pos.get(slot, {}) - _grid.add_child(_slot(it, base + slot)) - _refresh_mobile_sell() - -func _refresh_mobile_sell() -> void: - if not _mobile_mode or _mobile_sell_list == null: - return - for child in _mobile_sell_list.get_children(): - child.queue_free() - var inv: Array = client.get_inventory() if client and client.has_method("get_inventory") else [] - if inv.is_empty(): - var empty := Label.new() - empty.text = "(背包为空)" - _mobile_sell_list.add_child(empty) - return - for item in inv: - var cell := int(item.get("cell", -1)) - var vnum := int(item.get("vnum", 0)) - if cell < 0 or vnum <= 0: - continue - var button := Button.new() - button.alignment = HORIZONTAL_ALIGNMENT_LEFT - button.text = "%s ×%d · 点击出售" % [_name_of(vnum), int(item.get("count", 1))] - button.custom_minimum_size = Vector2(160, 30) - button.pressed.connect(func(): sell(cell, int(_sell_quantity.value))) - _mobile_sell_list.add_child(button) - -# 40250 ItemSlot 单元格:32×32,带 Slot_Base.sub 底图,图标居中,右下角堆叠数量 -func _slot(it: Dictionary, pos: int) -> Button: - var slot := QuietButton.new() - slot.custom_minimum_size = Vector2(32, 32) - slot.size = Vector2(32, 32) - slot.clip_text = true - - var slot_base_tex := UiAssets.load_tex(_assets_root, "d:/ymir work/ui/public/Slot_Base.sub") - if slot_base_tex: - slot.add_theme_stylebox_override("normal", UiBuild._sb_tex(slot_base_tex)) - slot.add_theme_stylebox_override("hover", UiBuild._sb_tex(slot_base_tex)) - slot.add_theme_stylebox_override("pressed", UiBuild._sb_tex(slot_base_tex)) - slot.add_theme_stylebox_override("disabled", UiBuild._sb_tex(slot_base_tex)) - slot.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) - - var vnum := int(it.get("vnum", 0)) - if vnum > 0: - var count := int(it.get("count", 0)) - slot.text = _name_of(vnum) # 保持 text 属性满足原有测试检索 - var tex := _icon(vnum) - if tex: - slot.icon = tex - slot.expand_icon = true - slot.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER - slot.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER - - # 堆叠数量标签(右下角) - if count > 1: - var count_lbl := Label.new() - count_lbl.name = "Count" - count_lbl.position = Vector2(0, 16) - count_lbl.size = Vector2(30, 14) - count_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - count_lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM - count_lbl.add_theme_font_size_override("font_size", 10) - count_lbl.add_theme_color_override("font_color", Color(1, 1, 1)) - count_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0)) - count_lbl.add_theme_constant_override("outline_size", 2) - count_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - count_lbl.text = str(count) - slot.add_child(count_lbl) - - if _tooltip_builder: - # The shared ItemTooltipView is the only item-attribute panel owner. - # Keeping a native Control tooltip here creates a second popup on hover. - slot.tooltip_text = "" - - slot.mouse_entered.connect(func(): _show_slot_tooltip(vnum, maxi(1, count), it)) - slot.mouse_exited.connect(func(): _hide_slot_tooltip()) - - slot.pressed.connect(func() -> void: _buy_slot(pos, it)) - slot.gui_input.connect(func(event: InputEvent) -> void: - if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: - _direct_buy(pos, it) - ) - else: - slot.disabled = true - return slot - -func _get_tooltip_view() -> Control: - if _root and _root.get_parent() and _root.get_parent().has_method("get_item_tooltip_view"): - return _root.get_parent().get_item_tooltip_view(_assets_root, proto, item_list) - if _tooltip_view == null or not is_instance_valid(_tooltip_view): - _tooltip_view = ItemTooltipView.new() - _tooltip_view.setup(_assets_root, proto, item_list, _tooltip_builder) - _tooltip_view.visible = false - _tooltip_view.z_index = 200 - if _root and is_instance_valid(_root): - _root.add_child(_tooltip_view) - return _tooltip_view - -func _show_slot_tooltip(vnum: int, count: int, item_data: Dictionary) -> void: - if vnum <= 0: - return - var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} - var tv := _get_tooltip_view() - if tv: - tv.show_item(vnum, count, pd, item_data) - -func _hide_slot_tooltip() -> void: - var tv := _get_tooltip_view() - if tv: - tv.hide_tooltip() - -func _direct_buy(pos: int, item: Dictionary) -> void: - if _mode != 1 or item.is_empty() or pos < 0: - return - if client and client.has_method("shop_buy"): - if not client.shop_buy(pos, 1): - _on_error("SEND_FAILED") - -func _on_shop_slot_updated(_pos: int) -> void: - if not is_open(): - return - refresh() - -func _buy_slot(pos: int, item: Dictionary) -> void: - if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached(): - var payload: Dictionary = item_mouse.call("get_payload") if item_mouse.has_method("get_payload") else {} - if not payload.is_empty(): - _drop_to_sell(payload) - item_mouse.cancel() - return - - if _mode != 1: - _set_mode(1) - return - if item.is_empty() or pos < 0: - return - if is_instance_valid(_buy_confirm): - _buy_confirm.hide() - _buy_confirm.queue_free() - _buy_confirm = ConfirmationDialog.new() - _buy_confirm.title = "确认购买" - _buy_confirm.dialog_text = "购买 %s?\n价格:%d 金币" % [_name_of(int(item.get("vnum", 0))), int(item.get("price", 0))] - _buy_confirm.ok_button_text = "购买" - _buy_confirm.cancel_button_text = "取消" - var dialog := _buy_confirm - dialog.confirmed.connect(func() -> void: - _buy_confirm = null - dialog.hide() - dialog.queue_free() - if not client.shop_buy(pos, 1): - _on_error("SEND_FAILED")) - dialog.canceled.connect(func() -> void: - _buy_confirm = null - dialog.hide() - dialog.queue_free()) - _prepare_mobile_confirmation(dialog, "确认购买") - _root.add_child(dialog) - if dialog.is_inside_tree(): - _popup_confirmation(dialog) - else: - if _mobile_mode: - dialog.call_deferred("popup_centered", Vector2i(480, 220)) - else: - dialog.call_deferred("popup_centered") - -func _prepare_mobile_confirmation(dialog: ConfirmationDialog, title: String) -> void: - if not _mobile_mode: - return - dialog.set_meta("mobile_modal", true) - dialog.set_meta("mobile_title", title) - dialog.min_size = Vector2i(480, 220) - dialog.size = Vector2i(480, 220) - -func _popup_confirmation(dialog: ConfirmationDialog) -> void: - if _mobile_mode: - dialog.popup_centered(Vector2i(480, 220)) - else: - dialog.popup_centered() - -func _on_error(kind: String) -> void: - var tbl := { - "NOT_ENOUGH_MONEY": "金币不足", - "SOLDOUT": "已售罄", - "INVENTORY_FULL": "背包已满", - "INVALID_POS": "位置无效", - "NOT_ENOUGH_ITEM": "物品数量不足", - "CANNOT_BUY": "当前物品不可购买", - "CANNOT_SELL": "当前物品不可出售", - "SHOP_BUSY": "商店正在处理上一笔交易", - "SEND_FAILED": "交易请求发送失败", - } - _err.text = str(tbl.get(kind, kind)) - -func _create_toggle_button(btn_text: String, pos: Vector2, sz: Vector2, tex_prefix: String) -> Button: - var btn := Button.new() - btn.text = btn_text - btn.toggle_mode = true - btn.position = pos - btn.size = sz - btn.custom_minimum_size = sz - btn.clip_text = true - btn.add_theme_font_size_override("font_size", 12) - btn.add_theme_color_override("font_color", Color(1.0, 0.9, 0.7)) - btn.add_theme_color_override("font_pressed_color", Color(1.0, 1.0, 1.0)) - btn.add_theme_color_override("font_hover_color", Color(1.0, 1.0, 0.8)) - - var nrm := UiAssets.load_tex(_assets_root, tex_prefix + "01.sub") - var ovr := UiAssets.load_tex(_assets_root, tex_prefix + "02.sub") - var dwn := UiAssets.load_tex(_assets_root, tex_prefix + "03.sub") - if nrm: - btn.add_theme_stylebox_override("normal", UiBuild._sb_tex(nrm)) - btn.add_theme_stylebox_override("hover", UiBuild._sb_tex(ovr if ovr else nrm)) - btn.add_theme_stylebox_override("pressed", UiBuild._sb_tex(dwn if dwn else nrm)) - btn.add_theme_stylebox_override("disabled", UiBuild._sb_tex(nrm)) - btn.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) - btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - return btn - -# 40250 shopdialog.py 严格 184 × 328 官方 UI 布局构建 -func _build(parent: Node) -> void: - _root = Control.new() - _root.name = "ShopWindow" - _root.size = Vector2(184, 328) - _root.custom_minimum_size = Vector2(184, 328) - _root.visible = false - _root.set_meta("is_titlebar", true) - parent.add_child(_root) - - var vp_size := Vector2(800.0, 600.0) - if _root.is_inside_tree() and _root.get_viewport(): - vp_size = _root.get_viewport_rect().size - # 40250 uiscript/shopdialog.py: x = SCREEN_WIDTH - 400, y = 10 - _root.position = Vector2(maxf(10.0, vp_size.x - 400.0), 10.0) - - # 1. 经典羊皮纸金属 Board 边框(184 × 328) - var board_np := UiKit.board(_assets_root, "board", 32, 128) - if board_np and board_np.texture: - board_np.set_anchors_preset(Control.PRESET_FULL_RECT) - board_np.mouse_filter = Control.MOUSE_FILTER_IGNORE - _root.add_child(board_np) - else: - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.08, 0.07, 0.06, 0.97) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - - # 2. 官方 TitleBar (8, 8, width=169) - var titlebar := UiBuild._titlebar(_assets_root, 169, {}) - titlebar.name = "TitleBar" - titlebar.position = Vector2(8, 8) - _root.add_child(titlebar) - - var title_lbl := Label.new() - title_lbl.name = "TitleName" - title_lbl.text = "商店" - title_lbl.size = Vector2(169, 23) - title_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - title_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - title_lbl.add_theme_color_override("font_color", Color(1.0, 0.9, 0.6)) - title_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - titlebar.add_child(title_lbl) - - var close_btn: Button = titlebar.find_child("CloseButton", true, false) - if close_btn: - close_btn.pressed.connect(close_and_leave) - - # 错误提示悬浮字(红字) - _err = Label.new() - _err.position = Vector2(12, 30) - _err.size = Vector2(160, 16) - _err.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _err.modulate = Color(1, 0.3, 0.3) - _err.add_theme_font_size_override("font_size", 11) - _err.mouse_filter = Control.MOUSE_FILTER_IGNORE - _root.add_child(_err) - - # 3. 40250 5×8 货架 ItemSlot (12, 34) - # 40 个格子,每格 32×32,使用 Slot_Base.sub 底图 - _grid = GridContainer.new() - _grid.name = "ItemSlot" - _grid.columns = 5 - _grid.position = Vector2(12, 34) - _grid.size = Vector2(160, 256) - _grid.add_theme_constant_override("h_separation", 0) - _grid.add_theme_constant_override("v_separation", 0) - _root.add_child(_grid) - - # 4. 底部按钮(y=295) - # BuyButton (21, 295, 61x21) - _buy_mode_button = _create_toggle_button("购买", Vector2(21, 295), Vector2(61, 21), "d:/ymir work/ui/public/middle_button_") - _buy_mode_button.name = "BuyButton" - _buy_mode_button.pressed.connect(func() -> void: _set_mode(1)) - _root.add_child(_buy_mode_button) - - # SellButton (104, 295, 61x21) - _sell_mode_button = _create_toggle_button("出售", Vector2(104, 295), Vector2(61, 21), "d:/ymir work/ui/public/middle_button_") - _sell_mode_button.name = "SellButton" - _sell_mode_button.pressed.connect(func() -> void: _set_mode(2)) - _root.add_child(_sell_mode_button) - - # 多货架 Tabbar(y=295 处覆盖显示) - _tabbar = HBoxContainer.new() - _tabbar.name = "TabBar" - _tabbar.position = Vector2(21, 295) - _tabbar.size = Vector2(144, 21) - _tabbar.add_theme_constant_override("separation", 4) - _tabbar.visible = false - _root.add_child(_tabbar) - - # 保持 API 兼容性字段(无视界面渲染) - _sell_quantity = SpinBox.new() - _sell_quantity.name = "SellQuantity" - _sell_quantity.value = 1 - _sell_quantity.visible = false - _root.add_child(_sell_quantity) - - _sell_drop_target = Button.new() - _sell_drop_target.name = "SellDropTarget" - _sell_drop_target.visible = false - _root.add_child(_sell_drop_target) - - # 移动端兼容 - _mobile_sell_scroll = ScrollContainer.new() - _mobile_sell_scroll.position = Vector2(12, 34) - _mobile_sell_scroll.size = Vector2(160, 256) - _mobile_sell_scroll.visible = false - _root.add_child(_mobile_sell_scroll) - _mobile_sell_list = VBoxContainer.new() - _mobile_sell_list.add_theme_constant_override("separation", 4) - _mobile_sell_scroll.add_child(_mobile_sell_list) diff --git a/project/ui/shop_ui.gd.uid b/project/ui/shop_ui.gd.uid deleted file mode 100644 index 1ee07666..00000000 --- a/project/ui/shop_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cojt0r4bhsq5p diff --git a/project/ui/skill_table.gd b/project/ui/skill_table.gd deleted file mode 100644 index c72c64b1..00000000 --- a/project/ui/skill_table.gd +++ /dev/null @@ -1,600 +0,0 @@ -# SkillTable (P6) —— 读 `locale/locale//skilldesc.txt`:skill id → 名/职业/动作。 -# -# 列(TSV,1-indexed,PythonSkill.cpp __LoadSkillDesc): -# 1 id · 2 JOB(WARRIOR/ASSASSIN/SURA/SHAMAN/SUPPORT/HORSE/GUILD) · 3-5 name/name2/name3 -# 6-9 desc1-4 · 10 (空) · 11 attributes(ATTACK_SKILL|PASSIVE|TOGGLE|STANDING_SKILL|…) -# 12 weapon_limit(SWORD|BOW|…) · 13 motion_name · 14 motion_idx · 15 grade_count · 16+ 公式 -# -# var st := preload("res://ui/skill_table.gd").new() -# st.load_file("/locale/locale/en/skilldesc.txt") -# st.entry(1) # { id, job, name, …, attrs, motion, motion_idx, grades, cooltime } -# st.for_job("WARRIOR") # 主动职业技能 [id, …] -# st.for_category("SUPPORT") # 辅助 / 被动树 -# st.is_passive(121) # true -# -# §3.8 TSkillData 判定位:第 11 列 attributes / 第 12 列 weapon_limit 是 `|` 分隔的 -# 记号串,原客户端 RegisterSkillDesc(PythonSkill.cpp:280)把它们经 -# m_SkillAttributeIndexMap / m_SkillNeedWeaponIndexMap 折成 dwSkillAttribute / -# dwNeedWeapon 两个位掩码,CPythonSkill 的一众 IsXxx() 谓词就是对这两个掩码取位。 -# 这里在 load_file 时做同样的折叠,谓词逐条对齐 PythonSkill.cpp。 -extends RefCounted - -var _by_id := {} -var count := 0 - -# PythonSkill.h: SKILL_EFFECT_COUNT / SKILL_GRADEGAP;当前 assets/root/constinfo.py -# 的 USE_SKILL_EFFECT_UPGRADE_ENABLE=1,故本地与远端动作都必须保留等级偏移。 -const SKILL_EFFECT_COUNT := 4 -const SKILL_GRADEGAP := 25 -var skill_effect_upgrade_enabled := true - -const JOB_DIR := { - "WARRIOR": "warrior", - "ASSASSIN": "assassin", - "SURA": "sura", - "SHAMAN": "shaman", -} - -# --- SKILL_ATTRIBUTE_*(PythonSkill.h:92-115,记号名同 m_SkillAttributeIndexMap)--- -const ATTR := { - "NEED_TARGET": 1 << 0, - "TOGGLE": 1 << 1, - "WEAPON_LIMITATION": 1 << 2, - "MELEE_ATTACK": 1 << 3, - "USE_HP": 1 << 4, - "CAN_CHANGE_DIRECTION": 1 << 5, - "STANDING_SKILL": 1 << 6, - "ONLY_FOR_ALLIANCE": 1 << 7, - "CAN_USE_FOR_ME": 1 << 8, - "NEED_CORPSE": 1 << 9, - "FAN_RANGE": 1 << 10, - "CAN_USE_IF_NOT_ENOUGH": 1 << 11, - "NEED_EMPTY_BOTTLE": 1 << 12, - "NEED_POISON_BOTTLE": 1 << 13, - "ATTACK_SKILL": 1 << 14, - "TIME_INCREASE_SKILL": 1 << 15, - "CHARGE_ATTACK": 1 << 16, - "PASSIVE": 1 << 17, - "CANNOT_LEVEL_UP": 1 << 18, - "ONLY_FOR_GUILD_WAR": 1 << 19, - "MOVING_SKILL": 1 << 20, - "HORSE_SKILL": 1 << 21, - "CIRCLE_RANGE": 1 << 22, - "SEARCH_TARGET": 1 << 23, -} - -# --- CItemData::EWeaponSubTypes(GameLib/ItemData.h:63-75)--- -const WEAPON_SWORD := 0 -const WEAPON_DAGGER := 1 -const WEAPON_BOW := 2 -const WEAPON_TWO_HANDED := 3 -const WEAPON_BELL := 4 -const WEAPON_FAN := 5 -const WEAPON_ARROW := 6 -const WEAPON_NONE := 8 # WEAPON_NUM_TYPES(7) + 1 - -# --- SKILL_NEED_WEAPON_*(PythonSkill.h:120-128,位 = 1 << CItemData::WEAPON_*)--- -# 记号名同 m_SkillNeedWeaponIndexMap;DOUBLE_SWORD 复用 DAGGER 位。 -const NEED_WEAPON := { - "SWORD": 1 << WEAPON_SWORD, - "DAGGER": 1 << WEAPON_DAGGER, - "BOW": 1 << WEAPON_BOW, - "TWO_HANDED": 1 << WEAPON_TWO_HANDED, - "DOUBLE_SWORD": 1 << WEAPON_DAGGER, - "BELL": 1 << WEAPON_BELL, - "FAN": 1 << WEAPON_FAN, - "ARROW": 1 << WEAPON_ARROW, - "EMPTY_HAND": 1 << WEAPON_NONE, -} - -# --- CPythonSkill::ESkillType(PythonSkill.h)+ m_SkillTypeIndexMap(PythonSkill.cpp:909-918)--- -const SKILL_TYPE_NONE := 0 -const SKILL_TYPE_ACTIVE := 1 -const SKILL_TYPE_SUPPORT := 2 -const SKILL_TYPE_GUILD := 3 -const SKILL_TYPE_HORSE := 4 -const _SKILL_TYPE_MAP := { - "none": SKILL_TYPE_NONE, - "active": SKILL_TYPE_ACTIVE, - "support": SKILL_TYPE_SUPPORT, - "guild": SKILL_TYPE_GUILD, - "horse": SKILL_TYPE_HORSE, - "warrior": SKILL_TYPE_ACTIVE, - "assassin": SKILL_TYPE_ACTIVE, - "sura": SKILL_TYPE_ACTIVE, - "shaman": SKILL_TYPE_ACTIVE, - "passive": SKILL_TYPE_ACTIVE, -} - -# 把 `|`(或旧数据里的空格 / 逗号)分隔的记号串折成位掩码。未知记号忽略, -# 对齐原版“map 里查不到就跳过该记号”的行为(PythonSkill.cpp:374-378 / 393-397)。 -static func _fold_bits(tokens: String, name_to_bit: Dictionary) -> int: - var bits := 0 - for raw in tokens.replace(",", "|").replace(" ", "|").split("|", false): - var key := raw.strip_edges().to_upper() - if name_to_bit.has(key): - bits |= int(name_to_bit[key]) - return bits - -func load_file(path: String) -> bool: - _by_id.clear() - count = 0 - if not FileAccess.file_exists(path): - return false - var f := FileAccess.open(path, FileAccess.READ) - if f == null: - return false - while not f.eof_reached(): - var line := f.get_line() - if line.strip_edges() == "": - continue - var t := line.split("\t") - if t.size() < 14: - continue - var id := int(t[0]) - if id == 0: - continue - # 0-indexed: id=0 job=1 name=2..4 desc=5..8 (9 空) attrs=10 weapon=11 - # motion_name=12 motion_idx=13 grades=14 - var job := t[1].strip_edges() - var attrs := t[10].strip_edges() if t.size() > 10 else "" - var weapon := t[11].strip_edges() if t.size() > 11 else "" - var entry := { - "id": id, - "job": job, - "name": t[2].strip_edges(), - "name2": t[3].strip_edges(), - "name3": t[4].strip_edges(), - "attrs": attrs, - "weapon": weapon, - "motion": t[12].strip_edges() if t.size() > 12 else "", - "motion_idx": int(t[13]) if t.size() > 13 else 0, - "grades": int(t[14]) if t.size() > 14 else 1, - # §3.8:预折叠的 TSkillData 判定位(对齐 dwSkillAttribute / dwNeedWeapon) - "attr_bits": _fold_bits(attrs, ATTR), - "need_weapon_bits": _fold_bits(weapon, NEED_WEAPON), - "skill_type": int(_SKILL_TYPE_MAP.get(job.to_lower(), SKILL_TYPE_NONE)), - } - _load_msk(entry) - # RegisterSkillDesc(PythonSkill.cpp:280):DESC_TOKEN_TYPE LEVEL_LIMIT=26 / - # MAX_LEVEL=27 只在 TSV 列数够时解析。本 40250 shipped skilldesc.txt 只有 22 列, - # 这两列都缺,逻辑保留以对齐参考端、并兼容带这两列的第三方数据。 - # .msk 的 maxlevel(_load_msk 已处理)优先于 skilldesc MAX_LEVEL。 - if not entry.has("max_level") and t.size() > 27 and int(t[27]) > 0: - entry["max_level"] = max(1, int(t[27])) - if t.size() > 26 and int(t[26]) > 0: - entry["level_limit"] = int(t[26]) - _by_id[id] = entry - count = _by_id.size() - return count > 0 - -# RegisterSkillTable(PythonSkill.cpp:90):SkillTable.txt 按 TAB 切分,列数不等于 -# TABLE_TOKEN_TYPE_MAX_NUM 的行、skilldesc 里没有的 vnum 都跳过。参考端 RegisterSkill(.msk) -# 没有调用点,TSkillData 构造时 dwTargetRange = 0,只由这里非空的 TARGET_RANGE 列覆盖; -# 所以表生效后不再沿用 .msk 的 Range。第 2 列是 CP949 技能名,按字节切分避免解码告警。 -const TABLE_TOKEN_TYPE_VNUM := 0 -const TABLE_TOKEN_TYPE_TARGET_RANGE := 25 -const TABLE_TOKEN_TYPE_MAX_NUM := 27 - -func load_table(path: String) -> bool: - if _by_id.is_empty() or not FileAccess.file_exists(path): - return false - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - return false - var bytes := file.get_buffer(file.get_length()) - var ranges := {} - var start := 0 - while start < bytes.size(): - var stop := bytes.find(10, start) - if stop < 0: - stop = bytes.size() - var fields := _tab_fields(bytes.slice(start, stop)) - start = stop + 1 - if fields.size() != TABLE_TOKEN_TYPE_MAX_NUM: - continue - var vnum := int(_ascii_token(fields[TABLE_TOKEN_TYPE_VNUM])) - if not _by_id.has(vnum): - continue - ranges[vnum] = _ascii_token(fields[TABLE_TOKEN_TYPE_TARGET_RANGE]) - if ranges.is_empty(): - return false - for id in _by_id: - _by_id[id]["target_range"] = 0 - for vnum in ranges: - if String(ranges[vnum]) != "": - _by_id[vnum]["target_range"] = int(ranges[vnum]) - return true - -static func _tab_fields(line: PackedByteArray) -> Array: - var fields := [] - var begin := 0 - for i in line.size() + 1: - if i == line.size() or line[i] == 9: - fields.append(line.slice(begin, i)) - begin = i + 1 - return fields - -static func _ascii_token(field: PackedByteArray) -> String: - var out := PackedByteArray() - for b in field: - if b >= 32 and b <= 126: - out.append(b) - return out.get_string_from_ascii().strip_edges() - -func has(id: int) -> bool: - return _by_id.has(id) - -func entry(id: int) -> Dictionary: - return _by_id.get(id, {}) - -func name_of(id: int) -> String: - return String(_by_id.get(id, {}).get("name", "skill %d" % id)) - -func motion_idx_of(id: int) -> int: - return int(_by_id.get(id, {}).get("motion_idx", 0)) - -func set_skill_effect_upgrade_enabled(enabled: bool) -> void: - skill_effect_upgrade_enabled = enabled - -# CPythonSkill::SSkillData::GetSkillMotionIndex(iGrade)。grade 超出该技能实际 -# 注册的 grade 数量时回退基础 motion index,和 RegisterSkillDesc 的空槽填 base 一致。 -func motion_idx_for_grade(id: int, grade: int = -1) -> int: - var base := motion_idx_of(id) - if not skill_effect_upgrade_enabled or grade < 0 or grade >= SKILL_EFFECT_COUNT: - return base - var grade_count := clampi(int(_by_id.get(id, {}).get("grades", 1)), 1, SKILL_EFFECT_COUNT) - return base + grade * SKILL_GRADEGAP if grade < grade_count else base - -func motion_name_of(id: int) -> String: - return String(_by_id.get(id, {}).get("motion", "")) - -func motion_name_by_idx(motion_idx: int) -> String: - # 先保留基础索引的精确匹配;没有基础项时,再解析等级偏移索引。 - for id in _by_id: - if int(_by_id[id].get("motion_idx", 0)) == motion_idx: - return String(_by_id[id].get("motion", "")) - for id in _by_id: - var base := int(_by_id[id].get("motion_idx", 0)) - var grade_count := clampi(int(_by_id[id].get("grades", 1)), 1, SKILL_EFFECT_COUNT) - for grade in range(1, grade_count): - if skill_effect_upgrade_enabled and base + grade * SKILL_GRADEGAP == motion_idx: - return String(_by_id[id].get("motion", "")) - return "" - -func motion_grade_by_idx(motion_idx: int) -> int: - for id in _by_id: - if int(_by_id[id].get("motion_idx", 0)) == motion_idx: - return 0 - for id in _by_id: - var base := int(_by_id[id].get("motion_idx", 0)) - var grade_count := clampi(int(_by_id[id].get("grades", 1)), 1, SKILL_EFFECT_COUNT) - for grade in range(1, grade_count): - if skill_effect_upgrade_enabled and base + grade * SKILL_GRADEGAP == motion_idx: - return grade - return 0 - -func _attrs(id: int) -> String: - return String(_by_id.get(id, {}).get("attrs", "")) - -# §3.8:折叠后的判定位掩码。取位即原版一众 IsXxx() 谓词(PythonSkill.cpp)。 -func attr_bits(id: int) -> int: - return int(_by_id.get(id, {}).get("attr_bits", 0)) - -func need_weapon_bits(id: int) -> int: - return int(_by_id.get(id, {}).get("need_weapon_bits", 0)) - -func _has_attr(id: int, name: String) -> bool: - return (attr_bits(id) & int(ATTR[name])) != 0 - -# --- 逐条对齐 CPythonSkill 谓词(PythonSkill.cpp)--- -func is_attack(id: int) -> bool: # IsAttackSkill - return _has_attr(id, "ATTACK_SKILL") - -func is_passive(id: int) -> bool: # SKILL_ATTRIBUTE_PASSIVE - return _has_attr(id, "PASSIVE") - -func is_toggle(id: int) -> bool: # IsToggleSkill - return _has_attr(id, "TOGGLE") - -func is_standing(id: int) -> bool: # IsStandingSkill - return _has_attr(id, "STANDING_SKILL") - -func is_need_target(id: int) -> bool: # IsNeedTarget - return _has_attr(id, "NEED_TARGET") - -func is_need_corpse(id: int) -> bool: # IsNeedCorpse - return _has_attr(id, "NEED_CORPSE") - -func is_auto_search_target(id: int) -> bool: # IsAutoSearchTarget - return _has_attr(id, "SEARCH_TARGET") - -func can_change_direction(id: int) -> bool: # CanChangeDirection - return _has_attr(id, "CAN_CHANGE_DIRECTION") - -func is_horse_skill(id: int) -> bool: # IsHorseSkill - return _has_attr(id, "HORSE_SKILL") - -func is_moving_skill(id: int) -> bool: # IsMovingSkill - return _has_attr(id, "MOVING_SKILL") - -# CPythonSkill::GetMotionLoopCount(fSkillPoint): the value is calculated from -# the same efficiency percentage as cooldown/SP and packed into OnUseSkill's -# uArg low nibble by CActorInstance::__OnUseSkill. -func motion_loop_count_of(id: int, level: int) -> int: - var formula := String(_by_id.get(id, {}).get("motion_loop_count_formula", "")) - if formula == "": - return 0 - var expression := Expression.new() - if expression.parse(formula, ["SkillPoint"]) != OK: - return 0 - var value: Variant = expression.execute([_skill_point(level)]) - if expression.has_execute_failed() or not (value is float or value is int): - return 0 - return maxi(0, int(value)) - -# CActorInstance::__OnUseSkill: uArg = loop count | (isMovingSkill ? 1<<4 : 0). -func motion_state_arg(id: int, level: int) -> int: - var arg := motion_loop_count_of(id, level) & 0x0f - if is_moving_skill(id): - arg |= 1 << 4 - return arg - -func is_melee(id: int) -> bool: # IsMeleeSkill - return _has_attr(id, "MELEE_ATTACK") - -func is_charge_skill(id: int) -> bool: # IsChargeSkill - return _has_attr(id, "CHARGE_ATTACK") - -func is_use_hp(id: int) -> bool: # IsUseHPSkill - return _has_attr(id, "USE_HP") - -func is_time_increase(id: int) -> bool: # IsTimeIncreaseSkill - return _has_attr(id, "TIME_INCREASE_SKILL") - -func is_need_empty_bottle(id: int) -> bool: # IsNeedEmptyBottle - return _has_attr(id, "NEED_EMPTY_BOTTLE") - -func is_need_poison_bottle(id: int) -> bool: # IsNeedPoisonBottle - return _has_attr(id, "NEED_POISON_BOTTLE") - -func can_use_for_me(id: int) -> bool: # CanUseForMe - return _has_attr(id, "CAN_USE_FOR_ME") - -func is_only_for_alliance(id: int) -> bool: # IsOnlyForAlliance - return _has_attr(id, "ONLY_FOR_ALLIANCE") - -func can_use_if_not_enough(id: int) -> bool: # CanUseIfNotEnough - return _has_attr(id, "CAN_USE_IF_NOT_ENOUGH") - -func is_only_for_guild_war(id: int) -> bool: # IsOnlyForGuildWar - return _has_attr(id, "ONLY_FOR_GUILD_WAR") - -func is_fan_range(id: int) -> bool: # IsFanRange - return _has_attr(id, "FAN_RANGE") - -func is_circle_range(id: int) -> bool: # IsCircleRange - return _has_attr(id, "CIRCLE_RANGE") - -# GetTargetCount(fSkillPoint)(PythonSkill.cpp:1361):strTargetCountFormula 为空 → 0, -# 否则把 SkillPoint(= LocaleService_GetSkillPower/100)代入公式后向下取整。 -# 扇形/圆形补目标(PythonPlayerSkill.cpp:684 `if (dwTargetMaxCount>0 …)`)就靠这个 > 0。 -func target_count(id: int, level: int) -> int: - var formula := String(_by_id.get(id, {}).get("target_count_formula", "")) - if formula == "": - return 0 - var expression := Expression.new() - if expression.parse(formula, ["SkillPoint"]) != OK: - return 0 - var value: Variant = expression.execute([_skill_point(level)]) - if expression.has_execute_failed() or not (value is float or value is int): - return 0 - return int(floor(float(value))) - -# __GetSkillTargetRange(PythonPlayerSkill.cpp:391)= rkSkillData.GetTargetRange() -# + GetStatus(POINT_BOW_DISTANCE)*100。这里是 GetTargetRange(PythonSkill.cpp:1086): -# MELEE_ATTACK / CHARGE_ATTACK 固定 MELEE_SKILL_TARGET_RANGE,否则 dwTargetRange -# (load_table 之后取 SkillTable.txt TARGET_RANGE);主角弓距加成由 player_skill 叠加。 -const MELEE_SKILL_TARGET_RANGE := 170 - -func target_range(id: int) -> int: - if is_melee(id) or is_charge_skill(id): - return MELEE_SKILL_TARGET_RANGE - return int(_by_id.get(id, {}).get("target_range", 0)) - -# skilldesc / .msk 的 FAN_RANGE / CIRCLE_RANGE → NetPlay.FlyShape(SINGLE/FAN/CIRCLE)。 -func fly_shape(id: int) -> int: - if is_fan_range(id): - return 1 - if is_circle_range(id): - return 2 - return 0 - -func has_weapon_limitation(id: int) -> bool: # SKILL_ATTRIBUTE_WEAPON_LIMITATION - return _has_attr(id, "WEAPON_LIMITATION") - -# IsCanUseSkill()(PythonSkill.cpp:1407):被动技一律不可主动施放,其余可。 -func is_can_use_skill(id: int) -> bool: - return not _has_attr(id, "PASSIVE") - -# CanUseWeaponType(dwWeaponType)(PythonSkill.cpp:1143):无武器限制恒真, -# 否则 dwNeedWeapon & (1 << dwWeaponType)。 -func can_use_weapon_type(id: int, weapon_type: int) -> bool: - if not has_weapon_limitation(id): - return true - return (need_weapon_bits(id) & (1 << weapon_type)) != 0 - -# IsNeedBow():dwNeedWeapon & SKILL_NEED_WEAPON_BOW。 -func is_need_bow(id: int) -> bool: - return (need_weapon_bits(id) & int(NEED_WEAPON["BOW"])) != 0 - -# 保留旧名:原本靠 weapon 列字符串判远程,现改为查 need-weapon 位(等价,含 ARROW)。 -func is_ranged(id: int) -> bool: - return (need_weapon_bits(id) & (int(NEED_WEAPON["BOW"]) | int(NEED_WEAPON["ARROW"]))) != 0 - -# CPythonSkill::ESkillType:3=SKILL_TYPE_GUILD,4=SKILL_TYPE_HORSE,1=主动,2=辅助。 -func skill_type_of(id: int) -> int: - return int(_by_id.get(id, {}).get("skill_type", SKILL_TYPE_NONE)) - -func is_guild_skill(id: int) -> bool: - return skill_type_of(id) == SKILL_TYPE_GUILD - -func can_level_up(id: int) -> bool: # !CANNOT_LEVEL_UP - return _by_id.has(id) and not _has_attr(id, "CANNOT_LEVEL_UP") - -# SSkillData::GetMaxLevel()(PythonSkill.cpp:1392)= byMaxLevel。优先 .msk 的 -# maxlevel(_load_msk),其次 skilldesc MAX_LEVEL 列,都没有则构造默认 20 -# (SSkillData 构造,PythonSkill.cpp:1462)。 -func max_level_of(id: int) -> int: - return int(_by_id.get(id, {}).get("max_level", 20)) - -# skillGetSkillLevelLimit(PythonSkill.cpp:1741)= byLevelLimit。本 40250 数据里 -# skilldesc 无 LEVEL_LIMIT 列、.msk 也无 levellimit 记号,恒 0;参考端马术页门 -# `GetStatus(LEVEL) >= GetSkillLevelLimit()` 因而恒真。 -func skill_level_limit(id: int) -> int: - return int(_by_id.get(id, {}).get("level_limit", 0)) - -# skillCanLevelUpSkill(PythonSkill.cpp:1833)。本 build 里 isRequirement / -# strRequireSkillName / byRequireSkillLevel / RequireStatDataVector 从不被填充 -# (构造默认 isRequirement=FALSE、空 vector,PythonSkill.cpp:1471), -# .msk / skilldesc 也没有任何前置技能 / 前置属性字段,故 1:1 化简为: -# 未满级(level < GetMaxLevel())且 非 SKILL_ATTRIBUTE_CANNOT_LEVEL_UP。 -func can_level_up_skill(id: int, level: int) -> bool: - return _by_id.has(id) and level < max_level_of(id) and not _has_attr(id, "CANNOT_LEVEL_UP") - -# 读取对应 .msk 中的 CoolTimeFormula / MaxLevel / NeedSPFormula。原客户端用同一份公式 -# 按技能效率百分比计算本地冷却与消耗;这里在发送前 / 后做同样的本地预测, -# GC_SKILL_COOLTIME_END 仍是服务器的提前解锁信号。 -# NeedSPFormula 供 §3.8 修改 3 的 __CheckShortMana / __CheckShortLife -# (参考 GetNeedSP,PythonSkill.cpp:1426;__CheckShortLife 同样取 GetNeedSP, -# PythonPlayerSkill.cpp:359)。 -func _load_msk(entry: Dictionary) -> void: - var job_dir := String(JOB_DIR.get(String(entry.get("job", "")), "")) - var motion := String(entry.get("motion", "")) - if job_dir == "" or motion == "": - return - var path := AssetRoot.path().path_join("PC/ymir work/pc/%s/skill/%s.msk" % [job_dir, motion]) - if not FileAccess.file_exists(path): - return - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - return - # .msk 常含 CP949/CP1252 本地化文本;只扫描 ASCII 配置行,不能把整份 - # 文件当 UTF-8 逐行读,否则 Godot 会为无关文案输出解码警告。 - var bytes := file.get_buffer(file.get_length()) - var formula := _ascii_field(bytes, "CoolTimeFormula") - if formula != "": - entry["cooltime_formula"] = formula.trim_prefix("\"").trim_suffix("\"") - var max_level := _ascii_field(bytes, "MaxLevel") - if max_level != "": - entry["max_level"] = max(1, int(max_level.trim_prefix("\"").trim_suffix("\""))) - var need_sp := _ascii_field(bytes, "NeedSPFormula") - if need_sp != "": - entry["need_sp_formula"] = need_sp.trim_prefix("\"").trim_suffix("\"") - # GetTargetCount / __GetSkillTargetRange 的数据源(.msk 的 TargetCountFormula / Range, - # CMotionSkillData 里对应 strTargetCountFormula / dwTargetRange)。 - var tcf := _ascii_field(bytes, "TargetCountFormula") - if tcf != "": - entry["target_count_formula"] = tcf.trim_prefix("\"").trim_suffix("\"") - var mlcf := _ascii_field(bytes, "MotionLoopCountFormula") - if mlcf != "": - entry["motion_loop_count_formula"] = mlcf.trim_prefix("\"").trim_suffix("\"") - var trange := _ascii_field(bytes, "Range") - if trange != "": - entry["target_range"] = max(0, int(trange.trim_prefix("\"").trim_suffix("\""))) - -# LocaleService_GetSkillPower(Locale.cpp:126,INTERNATIONAL 表)—— -# 原客户端 fcurEfficientPercentage = GetSkillPower(level) / 100,公式里的 -# SkillPoint / k 变量取这个值(ProcessFormula,PythonSkill.cpp:1258)。 -const _SKILL_POWER := [ - 0, - 5, 6, 8, 10, 12, - 14, 16, 18, 20, 22, - 24, 26, 28, 30, 32, - 34, 36, 38, 40, 50, # 20 = master - 52, 54, 56, 58, 60, - 63, 66, 69, 72, 82, # 30 = grand master - 85, 88, 91, 94, 98, - 102, 106, 110, 115, 125, # 40 = perfect master - 125, -] - -# fSkillPoint(= fcurEfficientPercentage):level 越界(>= 50,参考 SKILL_POWER_NUM)→ 0。 -func _skill_point(level: int) -> float: - if level < 0 or level >= 50: - return 0.0 - var idx: int = min(level, _SKILL_POWER.size() - 1) - return float(_SKILL_POWER[idx]) / 100.0 - -# GetNeedSP(fSkillPoint)(PythonSkill.cpp:1426):无公式 → 返回 -1(POC 约定: -# 「未知」,调用方按参考里的 `if (-1 != iNeedSP)` 分支不拦截;参考端此处返回 0, -# 但 POC 的 skilldesc/.msk 里不少主动技确实没有 NeedSPFormula,硬套 0 会误挡, -# 故这里显式区分「解析不出」= -1)。公式引用了非 SkillPoint 的状态变量时同样退化 -1。 -func need_sp(id: int, level: int) -> int: - var data := entry(id) - var formula := String(data.get("need_sp_formula", "")) - if formula == "": - return -1 - var expression := Expression.new() - if expression.parse(formula, ["SkillPoint"]) != OK: - return -1 - var value: Variant = expression.execute([_skill_point(level)]) - if expression.has_execute_failed() or not (value is float or value is int): - return -1 - return int(value) - -# __CheckShortLife(PythonPlayerSkill.cpp:359)里 dwNeedHP 也是取 GetNeedSP, -# 不是单独的 HP 公式——照抄这个怪癖。 -func need_hp(id: int, level: int) -> int: - return need_sp(id, level) - -func _ascii_field(bytes: PackedByteArray, field: String) -> String: - var needle := field.to_ascii_buffer() - if needle.is_empty(): - return "" - for start in range(bytes.size() - needle.size() + 1): - var found := true - for offset in needle.size(): - if bytes[start + offset] != needle[offset]: - found = false - break - if not found: - continue - var out := PackedByteArray() - var i := start + needle.size() - while i < bytes.size() and bytes[i] != 10 and bytes[i] != 13: - if bytes[i] >= 32 and bytes[i] <= 126: - out.append(bytes[i]) - i += 1 - return out.get_string_from_ascii().strip_edges() - return "" - -func cooldown_of(id: int, level: int) -> float: - var data := entry(id) - var formula := String(data.get("cooltime_formula", "")) - if formula == "": - return 0.0 - # §3.8 修改 3:SkillPoint 统一走 LocaleService_GetSkillPower(与 GetNeedSP 同一份 - # fcurEfficientPercentage),不再用 level/max_level 近似。 - var expression := Expression.new() - if expression.parse(formula, ["SkillPoint"]) != OK: - return 0.0 - var value: Variant = expression.execute([_skill_point(level)]) - if expression.has_execute_failed() or not (value is float or value is int): - return 0.0 - return maxf(0.0, float(value)) - -# JOB 列的分类:主动树是 WARRIOR/ASSASSIN/SURA/SHAMAN;辅助/被动树是 SUPPORT;还有 HORSE / GUILD -func category_of(id: int) -> String: - return String(_by_id.get(id, {}).get("job", "")) - -func for_job(job: String) -> Array: - return for_category(job) - -func for_category(cat: String) -> Array: - var out := [] - for id in _by_id: - if _by_id[id].get("job", "") == cat: - out.append(id) - out.sort() - return out diff --git a/project/ui/skill_table.gd.uid b/project/ui/skill_table.gd.uid deleted file mode 100644 index 34f38da2..00000000 --- a/project/ui/skill_table.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c5exefafglr65 diff --git a/project/ui/skill_ui.gd b/project/ui/skill_ui.gd deleted file mode 100644 index 0444a6f2..00000000 --- a/project/ui/skill_ui.gd +++ /dev/null @@ -1,438 +0,0 @@ -# SkillUI (P6) —— 技能窗:主动 / 辅助(被动·支援) / 坐骑 三个分类页 + 等级 + 加点。 -# -# var sk := preload("res://ui/skill_ui.gd").new() -# add_child(sk) -# sk.setup(m2client, skill_table, ui_manager) -# sk.set_job("WARRIOR") -# sk.toggle() # K 键 -# -# 每行:名字 + Lv X(/M/G/P) + [+](被动 / CANNOT_LEVEL_UP 无加点)+ [被动]/[切换] 标签。 -# 点名字 -> drag_skill_id(quickbar 落点读)。`skills_changed` 刷新。 -# §3.10:[+] 的显隐逐行对齐 uicharacter.py RefreshSkillPlusButtonList / -# __RefreshSkillPlusButton / CanShowPlusButton —— 技能组门(get_skill_group()==0)、 -# 三个技能点来源分开取、skillGrade==0、马术页 skillLevel<20、主动页 CanLevelUpSkill、 -# 辅助页 HIDE_SUPPORT_SKILL_POINT 整页隐藏。升级结果仍由 GC_SKILL_LEVEL 回包驱动。 -extends Node - -const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd") -const TABS := ["主动", "辅助", "坐骑"] -const MASTER_SUFFIX := ["", " M", " G", " P"] # master_type 0..3 - -# uicharacter.py:21 HIDE_SUPPORT_SKILL_POINT —— 本 migration 资源为 True,辅助页整页不出加号。 -const HIDE_SUPPORT_SKILL_POINT := true -# uicharacter.py __RefreshSkillPlusButton 的 HORSE 分支硬编码 `if skillLevel < 20`。 -const RIDING_SKILL_MAX_LEVEL := 20 - -signal skill_use_requested(skill_id: int) - -var client: Node -var table: RefCounted # SkillTable -var ui: CanvasLayer # UiManager -var job := "WARRIOR" -var drag_skill_id := 0 -var _tab := 0 -var _mobile_mode := false -var _mobile_quickbar: Node -var _mobile_hint: Label -var _mobile_drop_slots: Array[MobileTouchButton] = [] -var _mobile_drag_quickbar_slot := -1 - -var _win: Control -var _rows := {} # skill_id -> {lv, up} - -func setup(m2client: Node, skill_table: RefCounted, ui_manager: CanvasLayer) -> void: - client = m2client - table = skill_table - ui = ui_manager - if client.has_signal("skills_changed"): - client.skills_changed.connect(refresh) - if client.has_signal("point_changed"): - client.point_changed.connect(_on_point_changed) - -func _on_point_changed(_vid: int, point_type: int, _value: int, _amount: int) -> void: - # 40250 RecvPointChange refreshes the skill window when level/four-stat, - # active/support/horse points change. Keep the window's rows and plus - # buttons live even when no GC_SKILL_LEVEL packet follows the point update. - if point_type in [1, 12, 13, 14, 15, 27, 28, 112, 113] and is_open(): - refresh() - -func set_job(j: String) -> void: - job = j - if is_open(): - _rebuild() - -func set_mobile_mode(enabled: bool) -> void: - _mobile_mode = enabled - if _mobile_mode and _win: - close() - -func set_mobile_quickbar(quickbar: Node) -> void: - _mobile_quickbar = quickbar - _refresh_mobile_quickbar_targets() - -func is_open() -> bool: - return _win != null and is_instance_valid(_win) - -func toggle() -> void: - if is_open(): close() - else: open() - -func close() -> void: - if is_open(): - ui.close(_win) - _win = null - _rows.clear() - _mobile_drop_slots.clear() - _mobile_drag_quickbar_slot = -1 - -func _cat_key() -> String: - match _tab: - 1: return "SUPPORT" - 2: return "HORSE" - _: return job - -func open() -> void: - if is_open() or table == null: - return - _win = Control.new() - _win.name = "MobileSkillWindow" if _mobile_mode else "SkillWindow" - if _mobile_mode: - _win.set_anchors_preset(Control.PRESET_TOP_LEFT) - _win.position = Vector2.ZERO - _win.size = Vector2(760, 340) - _win.set_meta("mobile_title", "技能") - else: - _win.set_anchors_preset(Control.PRESET_CENTER_RIGHT) - _win.position = Vector2(-300, -240) - _win.size = Vector2(290, 470) - _win.set_meta("is_titlebar", true) - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.08, 0.09, 0.12, 0.95) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _win.add_child(panel) - var title := Label.new() - title.text = "技能" - title.position = Vector2(12, 8) if not _mobile_mode else Vector2(14, 48) - title.visible = not _mobile_mode - if _mobile_mode: - title.add_theme_font_size_override("font_size", 15) - _win.add_child(title) - # 分类页签 - var tabrow := HBoxContainer.new() - tabrow.name = "tabs" - tabrow.position = Vector2(10, 30) if not _mobile_mode else Vector2(14, 48) - if _mobile_mode: - tabrow.size = Vector2(360, 40) - tabrow.add_theme_constant_override("separation", 6) - _win.add_child(tabrow) - for i in TABS.size(): - var b := Button.new() - b.text = TABS[i] - b.toggle_mode = true - b.button_pressed = (i == _tab) - if _mobile_mode: - b.custom_minimum_size = Vector2(110, 38) - b.add_theme_font_size_override("font_size", 12) - var ti: int = i - b.pressed.connect(func(): _switch_tab(ti)) - tabrow.add_child(b) - var vb := VBoxContainer.new() - vb.name = "list" - vb.position = Vector2(10, 60) if not _mobile_mode else Vector2(0, 0) - vb.custom_minimum_size = Vector2(270, 0) if not _mobile_mode else Vector2(720, 0) - vb.add_theme_constant_override("separation", 2) - if _mobile_mode: - var scroll := ScrollContainer.new() - scroll.name = "SkillScroll" - scroll.position = Vector2(14, 92) - scroll.size = Vector2(732, 214) - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - scroll.mouse_filter = Control.MOUSE_FILTER_STOP - scroll.add_child(vb) - _win.add_child(scroll) - _mobile_hint = Label.new() - _mobile_hint.position = Vector2(14, 310) - _mobile_hint.size = Vector2(720, 22) - _mobile_hint.add_theme_font_size_override("font_size", 10) - _mobile_hint.add_theme_color_override("font_color", Color(0.62, 0.72, 0.84)) - _mobile_hint.text = "可用点数与技能等级由服务器实时确认" - _win.add_child(_mobile_hint) - _build_mobile_quickbar_targets() - else: - _win.add_child(vb) - ui.open(_win) - _rebuild() - -func _switch_tab(i: int) -> void: - _tab = i - if is_open(): - var tabrow: HBoxContainer = _win.get_node("tabs") - for j in tabrow.get_child_count(): - (tabrow.get_child(j) as Button).button_pressed = (j == i) - _rebuild() - -func _rebuild() -> void: - var vb: VBoxContainer = _win.get_node_or_null("list") as VBoxContainer - if vb == null and _mobile_mode: - vb = _win.get_node_or_null("SkillScroll/list") as VBoxContainer - if vb == null: - return - for c in vb.get_children(): - c.queue_free() - _rows.clear() - var ids: Array = table.for_category(_cat_key()) - if ids.is_empty(): - var e := Label.new() - e.text = "(无)" - vb.add_child(e) - return - for id in ids: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(720, 46) if _mobile_mode else Vector2(268, 26) - var nm: Control - if _mobile_mode: - var skill_name := MobileTouchButton.new() - skill_name.setup(table.name_of(id), Color(0.34, 0.58, 0.88, 0.88)) - skill_name.set_rect_style() - skill_name.set_gesture_enabled(true) - skill_name.custom_minimum_size = Vector2(300, 42) - skill_name.size = Vector2(300, 42) - var skill_button := skill_name - skill_button.long_pressed.connect(func(): _mobile_begin_skill_drag(id, skill_button)) - skill_button.drag_changed.connect(func(_delta: Vector2): - _mobile_update_skill_drag(id, skill_button)) - skill_button.drag_released.connect(func(_delta: Vector2): - _mobile_end_skill_drag(id, skill_button)) - nm = skill_name - else: - var skill_label := Label.new() - skill_label.text = table.name_of(id) - skill_label.custom_minimum_size = Vector2(140, 0) - skill_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - skill_label.add_theme_font_size_override("font_size", 12) - nm = skill_label - row.add_child(nm) - var lv := Label.new() - lv.name = "lv" - lv.custom_minimum_size = Vector2(100, 42) if _mobile_mode else Vector2(58, 0) - lv.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - lv.add_theme_font_size_override("font_size", 13 if _mobile_mode else 12) - row.add_child(lv) - var tag := Label.new() - tag.custom_minimum_size = Vector2(76, 42) if _mobile_mode else Vector2(40, 0) - tag.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - tag.add_theme_font_size_override("font_size", 10) - if table.is_passive(id): - tag.text = "被动" - tag.modulate = Color(0.6, 0.8, 1.0) - elif table.is_toggle(id): - tag.text = "切换" - tag.modulate = Color(0.9, 0.8, 0.5) - row.add_child(tag) - var sid: int = id - if table.can_level_up(id) and not table.is_passive(id): - var up := Button.new() - up.text = "+" - up.custom_minimum_size = Vector2(48, 42) if _mobile_mode else Vector2(28, 22) - if _mobile_mode: - up.add_theme_font_size_override("font_size", 18) - up.pressed.connect(func(): client.skill_up(sid)) - row.add_child(up) - _rows[id] = {"lv": lv, "up": up} - else: - _rows[id] = {"lv": lv, "up": null} - if not _mobile_mode: - nm.gui_input.connect(func(e: InputEvent): - if e is InputEventMouseButton and e.pressed: - if e.button_index == MOUSE_BUTTON_LEFT: - drag_skill_id = sid - if _mobile_hint: _mobile_hint.text = "已选择:%s;点击快捷栏按钮自动放入空位" % table.name_of(sid) - elif e.button_index == MOUSE_BUTTON_RIGHT: - skill_use_requested.emit(sid) - nm.accept_event()) - if _mobile_mode: - var assign := Button.new() - assign.text = "快捷栏" - assign.custom_minimum_size = Vector2(92, 42) - assign.add_theme_font_size_override("font_size", 11) - assign.pressed.connect(func(): _mobile_assign_skill(sid)) - row.add_child(assign) - vb.add_child(row) - refresh() - _refresh_mobile_quickbar_targets() - -func _build_mobile_quickbar_targets() -> void: - var title := Label.new() - title.text = "快捷栏" - title.position = Vector2(390, 48) - title.size = Vector2(60, 38) - title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - title.add_theme_font_size_override("font_size", 12) - title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54)) - title.mouse_filter = Control.MOUSE_FILTER_IGNORE - _win.add_child(title) - for i in 8: - var target := MobileTouchButton.new() - target.setup("—", Color(0.42, 0.65, 0.92, 0.9)) - target.set_rect_style() - target.set_gesture_enabled(true) - target.position = Vector2(448 + i * 38, 48) - target.size = Vector2(34, 38) - target.custom_minimum_size = Vector2(34, 38) - var slot := i - target.pressed.connect(func(): _mobile_drop_on_quickbar(slot)) - target.long_pressed.connect(func(): _mobile_begin_quickbar_drag(slot)) - target.drag_released.connect(func(_delta: Vector2): _mobile_end_quickbar_drag(slot, target)) - _win.add_child(target) - _mobile_drop_slots.append(target) - -func _mobile_begin_skill_drag(skill_id: int, _button: MobileTouchButton) -> void: - drag_skill_id = skill_id - if _mobile_hint: - _mobile_hint.text = "拖到上方快捷栏槽位,松手放入" - -func _mobile_update_skill_drag(skill_id: int, _button: MobileTouchButton) -> void: - if drag_skill_id == skill_id and _mobile_hint: - _mobile_hint.text = "拖到上方快捷栏槽位,松手放入" - -func _mobile_end_skill_drag(skill_id: int, button: MobileTouchButton) -> void: - if drag_skill_id != skill_id: - return - var target := _mobile_drop_slot_at(button.drag_end_position()) - if target >= 0 and _mobile_quickbar and _mobile_quickbar.has_method("assign"): - if bool(_mobile_quickbar.assign(target, "skill", skill_id)): - drag_skill_id = 0 - _refresh_mobile_quickbar_targets() - if _mobile_hint: _mobile_hint.text = "已放入快捷栏第 %d 格" % (target + 1) - return - if _mobile_hint: - _mobile_hint.text = "已选择:%s;可拖到快捷栏" % table.name_of(skill_id) - -func _mobile_drop_on_quickbar(slot: int) -> void: - if _mobile_quickbar == null or not _mobile_quickbar.has_method("assign"): - return - if drag_skill_id != 0: - if bool(_mobile_quickbar.assign(slot, "skill", drag_skill_id)): - drag_skill_id = 0 - _refresh_mobile_quickbar_targets() - if _mobile_hint: _mobile_hint.text = "已放入快捷栏第 %d 格" % (slot + 1) - -func _mobile_begin_quickbar_drag(slot: int) -> void: - _mobile_drag_quickbar_slot = slot - if _mobile_hint: - _mobile_hint.text = "拖到另一个快捷栏槽位以交换" - -func _mobile_end_quickbar_drag(slot: int, button: MobileTouchButton) -> void: - if _mobile_drag_quickbar_slot != slot: - return - var target := _mobile_drop_slot_at(button.drag_end_position()) - if target >= 0 and target != slot and _mobile_quickbar \ - and _mobile_quickbar.has_method("mobile_swap"): - _mobile_quickbar.mobile_swap(slot, target) - _refresh_mobile_quickbar_targets() - if _mobile_hint: _mobile_hint.text = "已交换快捷栏位置" - _mobile_drag_quickbar_slot = -1 - -func _mobile_drop_slot_at(position: Vector2) -> int: - for i in _mobile_drop_slots.size(): - var slot := _mobile_drop_slots[i] - if slot and slot.get_global_rect().has_point(position): - return i - return -1 - -func _refresh_mobile_quickbar_targets() -> void: - if not _mobile_mode: - return - for i in _mobile_drop_slots.size(): - var slot := _mobile_drop_slots[i] - if slot == null or _mobile_quickbar == null: - continue - var state: Dictionary = _mobile_quickbar.mobile_slot_state(i) \ - if _mobile_quickbar.has_method("mobile_slot_state") else {} - var label := "—" - if state.get("kind", "") == "skill" and table: - label = table.name_of(int(state.get("id", 0))).substr(0, 4) - elif state.get("kind", "") == "item": - label = "物" - elif state.get("kind", "") == "emote": - label = "表" - slot.set_caption(label) - -func _mobile_assign_skill(skill_id: int) -> void: - if _mobile_quickbar == null or not _mobile_quickbar.has_method("assign_mobile"): - if _mobile_hint: _mobile_hint.text = "快捷栏暂不可用" - return - if bool(_mobile_quickbar.assign_mobile("skill", skill_id)): - drag_skill_id = 0 - _refresh_mobile_quickbar_targets() - if _mobile_hint: _mobile_hint.text = "已放入快捷栏" - else: - if _mobile_hint: _mobile_hint.text = "快捷栏已满,请先移除一个技能" - -func refresh() -> void: - if not is_open(): - return - var lv := {} - var ms := {} - for s in client.get_skills(): - lv[int(s["id"])] = int(s["level"]) - ms[int(s["id"])] = int(s.get("master", 0)) - # RefreshSkillPlusButtonList(uicharacter.py:984)→ __RefreshSkillPlusButton(:937): - # 每页的技能点来源分开取,技能组未选时整列隐藏加号。 - var page := _cat_key() - var can_use_now := _can_use_skill_now() - var stat_point := _stat_point_for(page) - var plv := _player_level() - for id in _rows: - var lvl: int = lv.get(id, 0) - var m: int = clampi(ms.get(id, 0), 0, 3) - _rows[id]["lv"].text = "Lv %d%s" % [lvl, MASTER_SUFFIX[m]] - var up: Button = _rows[id]["up"] - if up != null: - up.visible = _can_show_plus(id, lvl, m, page, can_use_now, stat_point, plv) - -# __CanUseSkillNow(uicharacter.py:1270):net.GetMainActorSkillGroup()==0 -> False。 -func _can_use_skill_now() -> bool: - if client.has_method("get_skill_group"): - return int(client.get_skill_group()) != 0 - return true - -# skillPageStatDict(uicharacter.py:225):SUPPORT=POINT_SUB_SKILL(27) / -# ACTIVE=POINT_SKILL(28) / HORSE=POINT_HORSE_SKILL(113)。三个来源分开取,不合并成一个。 -func _stat_point_for(page: String) -> int: - if not client.has_method("get_points"): - return 1 - var p: Dictionary = client.get_points() - match page: - "SUPPORT": return int(p.get("skill_support", 0)) - "HORSE": return int(p.get("skill_horse", 0)) - _: return int(p.get("skill_active", 0)) - -func _player_level() -> int: - if not client.has_method("get_points"): - return 1 - return int(client.get_points().get("level", 1)) - -# __RefreshSkillPlusButton 每槽体(uicharacter.py:937)+ CanShowPlusButton(:925): -# 技能组未选 -> 全隐藏;辅助页 HIDE_SUPPORT_SKILL_POINT -> 整页隐藏; -# statPoint <= 0 -> 不显示;skillGrade != 0(已过 0 级,走大师书)-> 跳过; -# HORSE 页:GetStatus(LEVEL) >= GetSkillLevelLimit(idx) 时,skillLevel < 20 才显示; -# 其余页:skill.CanLevelUpSkill(idx, level)。 -func _can_show_plus(id: int, level: int, grade: int, page: String, - can_use_now: bool, stat_point: int, plv: int) -> bool: - if not can_use_now: - return false - if page == "SUPPORT" and HIDE_SUPPORT_SKILL_POINT: - return false - if stat_point <= 0: - return false - if grade != 0: - return false - if page == "HORSE": - if plv >= table.skill_level_limit(id): - return level < RIDING_SKILL_MAX_LEVEL - return false - return table.can_level_up_skill(id, level) diff --git a/project/ui/skill_ui.gd.uid b/project/ui/skill_ui.gd.uid deleted file mode 100644 index f5959cdd..00000000 --- a/project/ui/skill_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://db3diqdsl7isa diff --git a/project/ui/system_menu_ui.gd b/project/ui/system_menu_ui.gd deleted file mode 100644 index 81d55995..00000000 --- a/project/ui/system_menu_ui.gd +++ /dev/null @@ -1,257 +0,0 @@ -# SystemMenuUI (P11) —— ESC 系统菜单(1:1 迁移 `assets/root/uisystem.py` `SystemDialog` -# 的 `__LoadSystemMenu_Default`;布局走真 `assets/uiscript/uiscript/systemdialog.py`)。 -# -# var sm := preload("res://ui/system_menu_ui.gd").new() -# add_child(sm) -# sm.setup(ui_manager, m2client, assets_root, system_option_ui, game_option_ui) -# sm.toggle() # ESC(无其它窗打开时) -# -# 按钮绑定逐字对照 uisystem.py: -# help_button → interface.OpenHelpWindow(help_ui.gd,§8.3) -# mall_button → net.SendChatPacket("/in_game_mall") -# system_option_button → uiSystemOption.OptionDialog(system_option_ui) -# game_option_button → uiGameOption.OptionDialog(game_option_ui) -# change_button → net.ExitGame() = SendChatPacket("/phase_select") -# logout_button → net.LogOutGame() = SendChatPacket("/logout") -# exit_button → net.ExitApplication() = 退出进程 -# cancel_button / 标题栏 → 关闭 -extends Node - -const HelpUI = preload("res://ui/help_ui.gd") - -const LABELS := { - "help_button": "帮助", "mall_button": "商城", "system_option_button": "系统设置", - "game_option_button": "游戏设置", "change_button": "选择角色", "logout_button": "登出", - "exit_button": "退出游戏", "cancel_button": "取消", -} - -var ui: CanvasLayer -var client: Node -var assets_root := "" -var uiscript_dir := "" -var system_option_ui: Node -var game_option_ui: Node -var help_ui: Node # HelpUI(§8.3)—— 未注入时 _ensure_help() 懒建 -var toast: Callable = Callable() # 可选:func(msg: String) 显示提示 - -var _win: Dictionary = {} -var _mobile_mode := false -var _mobile_root: Control -var _mobile_buttons: Dictionary = {} - -const MOBILE_SIZE := Vector2(700, 330) - -func setup(ui_manager: CanvasLayer, m2client: Node, assets := "", - sys_opt: Node = null, game_opt: Node = null, help_win: Node = null) -> void: - ui = ui_manager - client = m2client - system_option_ui = sys_opt - game_option_ui = game_opt - help_ui = help_win - assets_root = assets - if assets_root == "" and ui and "assets_root" in ui: - assets_root = ui.assets_root - uiscript_dir = assets_root.path_join("uiscript/uiscript") - if not DirAccess.dir_exists_absolute(uiscript_dir): - uiscript_dir = assets_root.path_join("uiscript") - if ui and ui.has_signal("window_closed"): - ui.window_closed.connect(func(w): - if not _win.is_empty() and _win.get("root") == w: - _win = {} - ) - -func is_open() -> bool: - if _mobile_mode: - return _mobile_root != null and is_instance_valid(_mobile_root) - return not _win.is_empty() and is_instance_valid(_win.get("root")) - -func set_mobile_mode(enabled: bool) -> void: - if enabled != _mobile_mode and is_open(): - close() - _mobile_mode = enabled - if is_instance_valid(help_ui) and help_ui.has_method("set_mobile_mode"): - help_ui.set_mobile_mode(enabled) - -func toggle() -> void: - if is_open(): close() - else: open() - -func close() -> void: - if _mobile_mode: - var root := _mobile_root - _mobile_root = null - _mobile_buttons.clear() - if root and is_instance_valid(root) and ui: - ui.close(root) - return - var r: Control = _win.get("root", null) - _win = {} - if r and is_instance_valid(r) and ui: - ui.close(r) - -func open() -> void: - if _mobile_mode: - _open_mobile() - return - if is_open(): - return - var path := uiscript_dir.path_join("systemdialog.py") - if not FileAccess.file_exists(path): - push_warning("SystemMenuUI: no systemdialog.py at " + path) - return - _win = ui.open_script(path, assets_root, true) # modal - if not is_open(): - return - _relabel() - _wire() - -func _open_mobile() -> void: - if is_open(): - return - _mobile_root = Control.new() - _mobile_root.name = "MobileSystemMenuWindow" - _mobile_root.size = MOBILE_SIZE - _mobile_root.set_meta("mobile_title", "系统菜单") - _mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - panel.add_theme_stylebox_override("panel", _mobile_panel_style()) - _mobile_root.add_child(panel) - var hint := Label.new() - hint.text = "账户、设置和帮助" - hint.position = Vector2(22, 52) - hint.size = Vector2(640, 24) - hint.add_theme_font_size_override("font_size", 13) - hint.add_theme_color_override("font_color", Color(0.68, 0.78, 0.92)) - _mobile_root.add_child(hint) - var grid := GridContainer.new() - grid.name = "MobileSystemActions" - grid.columns = 2 - grid.position = Vector2(22, 82) - grid.size = Vector2(656, 174) - grid.add_theme_constant_override("h_separation", 12) - grid.add_theme_constant_override("v_separation", 8) - _mobile_root.add_child(grid) - for action in ["帮助", "商城", "系统设置", "游戏设置", "选择角色", "登出"]: - var button := Button.new() - button.name = "MobileAction_%s" % action - button.text = String(action) - button.custom_minimum_size = Vector2(322, 48) - button.add_theme_font_size_override("font_size", 14) - var selected := String(action) - button.pressed.connect(func(): _mobile_action(selected)) - _mobile_buttons[selected] = button - grid.add_child(button) - var exit := Button.new() - exit.name = "MobileExitButton" - exit.text = "退出游戏" - exit.position = Vector2(22, 272) - exit.size = Vector2(154, 42) - exit.add_theme_font_size_override("font_size", 13) - exit.pressed.connect(func(): _mobile_action("退出游戏")) - _mobile_buttons["退出游戏"] = exit - _mobile_root.add_child(exit) - var cancel := Button.new() - cancel.name = "MobileCancelButton" - cancel.text = "取消" - cancel.position = Vector2(524, 272) - cancel.size = Vector2(154, 42) - cancel.add_theme_font_size_override("font_size", 13) - cancel.pressed.connect(close) - _mobile_buttons["取消"] = cancel - _mobile_root.add_child(cancel) - ui.open(_mobile_root, true) - -func _mobile_action(action: String) -> void: - match action: - "帮助": - close() - open_help() - "商城": - _send_and_close("/in_game_mall") - "系统设置": - close() - if system_option_ui and system_option_ui.has_method("open"): - system_option_ui.open() - "游戏设置": - close() - if game_option_ui and game_option_ui.has_method("open"): - game_option_ui.open() - "选择角色": _send_and_close("/phase_select") - "登出": _send_and_close("/logout") - "退出游戏": get_tree().quit() - -func _send_and_close(command: String) -> void: - if client and client.has_method("say"): - client.say(0, command) - close() - -func _mobile_panel_style() -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = Color(0.04, 0.065, 0.1, 0.98) - style.border_color = Color(0.52, 0.68, 0.9, 0.86) - style.set_border_width_all(1) - style.set_corner_radius_all(10) - style.shadow_color = Color(0, 0, 0, 0.45) - style.shadow_size = 12 - return style - -func _node(nm: String) -> Control: - if _win.is_empty(): - return null - var n = _win.get("nodes", {}).get(nm, null) - return n if n is Control else null - -func _relabel() -> void: - for nm in LABELS: - var n := _node(nm) - if n and n.has_method("set_text"): - n.set_text(LABELS[nm]) - -func _btn(nm: String, cb: Callable) -> void: - var b := _node(nm) - if b is BaseButton: - b.pressed.connect(cb) - -# uisystem.py: self.interface.OpenHelpWindow()。未注入 help_ui 时懒建一个, -# 复用本菜单的 ui / client / assets_root。 -func _ensure_help() -> Node: - if not is_instance_valid(help_ui): - help_ui = HelpUI.new() - add_child(help_ui) - help_ui.setup(ui, client, assets_root) - if help_ui.has_method("set_mobile_mode"): - help_ui.set_mobile_mode(_mobile_mode) - return help_ui - -func open_help() -> void: - _ensure_help().open() - -func toggle_help() -> void: - _ensure_help().toggle() - -func _wire() -> void: - _btn("help_button", func(): - close() - open_help()) - _btn("mall_button", func(): - _send_and_close("/in_game_mall")) - _btn("system_option_button", func(): - close() - if system_option_ui and system_option_ui.has_method("open"): - system_option_ui.open()) - _btn("game_option_button", func(): - close() - if game_option_ui and game_option_ui.has_method("open"): - game_option_ui.open()) - _btn("change_button", func(): - _send_and_close("/phase_select")) - _btn("logout_button", func(): - _send_and_close("/logout")) - _btn("exit_button", func(): get_tree().quit()) - _btn("cancel_button", close) - var board := _node("board") - if board: - for x in board.find_children("*", "BaseButton", true, false): - if x.get_meta("is_titlebar", false): - x.pressed.connect(close) diff --git a/project/ui/system_menu_ui.gd.uid b/project/ui/system_menu_ui.gd.uid deleted file mode 100644 index 709aa0d5..00000000 --- a/project/ui/system_menu_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b2djf1pu8ad6n diff --git a/project/ui/system_option_ui.gd b/project/ui/system_option_ui.gd deleted file mode 100644 index a0f7e25e..00000000 --- a/project/ui/system_option_ui.gd +++ /dev/null @@ -1,396 +0,0 @@ -# SystemOptionUI (P11) —— 系统设置窗(1:1 迁移 `assets/root/uisystemoption.py` `OptionDialog`)。 -# -# var so := preload("res://ui/system_option_ui.gd").new() -# add_child(so) -# so.setup(ui_manager, assets_root, audio_node, func(): return cam, func(): return env) -# so.toggle() # ESC(无其它窗打开时) -# -# 布局走真 `assets/uiscript/uiscript/systemoptiondialog.py`(UiScript → UiBuild)。 -# 控件绑定逐字对照 uisystemoption.py: -# music_volume_controller → snd.SetMusicVolume + systemSetting.SetMusicVolume -# sound_volume_controller → snd.SetSoundVolume + systemSetting.SetSoundVolume -# camera_short / camera_long → systemSetting 镜头最大距离(近/远) -# fog_level0 / 1 / 2 (浓/中/淡) → systemSetting.SetFogLevel(0..2) -# tiling_cpu / tiling_gpu / apply → 原为 CPU/GPU 分块渲染,Godot 渲染器无对应,保留为占位 -# 设置持久化到 `user://system_option.cfg`(= 原 systemSetting 的配置文件)。 -extends Node - -const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd") -const CFG_PATH := "user://system_option.cfg" -const CAMERA_MAX := [11.0, 20.0] # 近 / 远(game_camera.max_dist) -const FOG_DENSITY := [0.055, 0.018, 0.004] # 浓 / 中 / 淡(Environment.fog_density) - -# uiScriptLocale 未接全局 Locale 时的中文兜底 -const LABELS := { - "titlename": "系统设置", "music_name": "背景音乐", "sound_name": "音效", - "bgm_button": "更换", "bgm_file": "默认主题", "camera_mode": "镜头距离", - "camera_short": "近", "camera_long": "远", "fog_mode": "雾", - "fog_level0": "浓", "fog_level1": "中", "fog_level2": "淡", - "tiling_mode": "图形内存", "tiling_cpu": "CPU", "tiling_gpu": "GPU", "tiling_apply": "应用", -} - -var ui: CanvasLayer -var assets_root := "" -var uiscript_dir := "" -var audio: Node -var _cam_get: Callable = Callable() -var _env_get: Callable = Callable() - -var _win: Dictionary = {} -var _cfg := ConfigFile.new() -var _mobile_mode := false -var _mobile_root: Control - -# --- 状态(= systemSetting 值)--- -var music_volume := 0.6 -var sound_volume := 0.9 -var camera_mode := 1 # 0 近, 1 远 -var fog_level := 1 # 0 浓, 1 中, 2 淡 -var tiling_mode := 1 # 0 CPU, 1 GPU(占位) - -static func config_path() -> String: - var override_dir := OS.get_environment("MT_CONFIG_DIR") - if override_dir != "": - return override_dir.path_join("system_option.cfg") - # Godot headless 在受限执行环境里可能无法写 user://;测试配置放在系统 - # 临时目录,正常图形客户端仍使用 user://。 - return OS.get_temp_dir().path_join("mtgodot_system_option.cfg") \ - if DisplayServer.get_name() == "headless" else CFG_PATH - -func setup(ui_manager: CanvasLayer, assets: String, audio_node: Node = null, - cam_getter := Callable(), env_getter := Callable()) -> void: - ui = ui_manager - assets_root = assets - if assets_root == "" and ui and "assets_root" in ui: - assets_root = ui.assets_root - uiscript_dir = assets_root.path_join("uiscript/uiscript") - if not DirAccess.dir_exists_absolute(uiscript_dir): - uiscript_dir = assets_root.path_join("uiscript") - if ui and ui.has_signal("window_closed"): - ui.window_closed.connect(func(w): - if not _win.is_empty() and _win.get("root") == w: - _win = {} - ) - audio = audio_node - _cam_get = cam_getter - _env_get = env_getter - _load_cfg() - _apply_all() # 持久化的设置在开窗前就生效 - -# --- open / close --------------------------------------------------- - -func is_open() -> bool: - if _mobile_mode: - return _mobile_root != null and is_instance_valid(_mobile_root) - return not _win.is_empty() and is_instance_valid(_win.get("root")) - -func set_mobile_mode(enabled: bool) -> void: - if enabled == _mobile_mode: - return - if is_open(): - var old_mode := _mobile_mode - _mobile_mode = true - close() - _mobile_mode = old_mode - _mobile_mode = enabled - -func toggle() -> void: - if is_open(): close() - else: open() - -func close() -> void: - if _mobile_mode: - if _mobile_root and is_instance_valid(_mobile_root) and ui: - ui.close(_mobile_root) - _mobile_root = null - return - var r: Control = _win.get("root", null) - _win = {} - if r and is_instance_valid(r) and ui: - ui.close(r) - -func open() -> void: - if _mobile_mode: - _open_mobile() - return - if is_open(): - return - var path := uiscript_dir.path_join("systemoptiondialog.py") - if not FileAccess.file_exists(path): - push_warning("SystemOptionUI: no systemoptiondialog.py at " + path) - return - _win = ui.open_script(path, assets_root, false, true) - if not is_open(): - return - set_center_position() - _relabel() - _wire() - _sync_controls() - -## 1:1 对齐 40250 uisystemoption.py: self.SetCenterPosition() -func set_center_position(offset := Vector2.ZERO) -> void: - var r: Control = _win.get("root", null) - if r == null or not is_instance_valid(r): - return - if ui and ui.has_method("center_window"): - ui.center_window(r, offset) - else: - var vp_sz := Vector2(1920, 1080) - var vp := r.get_viewport() - if vp: - var vrect := vp.get_visible_rect().size - if vrect.x > 300 and vrect.y > 300: - vp_sz = vrect - elif ui and "screen" in ui and ui.screen.x > 0: - vp_sz = Vector2(ui.screen) - var sz := r.size - if sz.x <= 0 or sz.y <= 0: - sz = r.custom_minimum_size - r.position = ((vp_sz - sz) / 2.0 + offset).round() - -func _open_mobile() -> void: - if is_open(): - return - _mobile_root = Control.new() - _mobile_root.name = "MobileSystemOptionWindow" - _mobile_root.size = Vector2(680, 340) - _mobile_root.set_meta("mobile_title", "系统设置") - _mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - panel.add_theme_stylebox_override("panel", _mobile_panel_style()) - _mobile_root.add_child(panel) - var content := VBoxContainer.new() - content.position = Vector2(18, 50) - content.size = Vector2(644, 270) - content.add_theme_constant_override("separation", 8) - _mobile_root.add_child(content) - _add_mobile_slider(content, "背景音乐", music_volume, func(v: float): _on_music_volume(v)) - _add_mobile_slider(content, "音效", sound_volume, func(v: float): _on_sound_volume(v)) - _add_mobile_radio(content, "镜头距离", ["近", "远"], camera_mode, - func(i: int): camera_mode = i; _apply_camera(); _save()) - _add_mobile_radio(content, "雾", ["浓", "中", "淡"], fog_level, - func(i: int): fog_level = i; _apply_fog(); _save()) - _add_mobile_radio(content, "图形内存", ["CPU", "GPU"], tiling_mode, - func(i: int): tiling_mode = i; _save()) - var hint := Label.new() - hint.text = "设置会自动保存;图形内存选项将在下次启动时使用" - hint.add_theme_font_size_override("font_size", 11) - hint.add_theme_color_override("font_color", Color(0.62, 0.72, 0.84)) - content.add_child(hint) - ui.open(_mobile_root) - -func _add_mobile_slider(parent: VBoxContainer, caption: String, initial: float, changed: Callable) -> void: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(640, 42) - var label := Label.new() - label.text = caption - label.custom_minimum_size = Vector2(92, 38) - label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - label.add_theme_font_size_override("font_size", 13) - row.add_child(label) - var slider := HSlider.new() - slider.min_value = 0.0 - slider.max_value = 1.0 - slider.step = 0.01 - slider.value = initial - slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL - slider.custom_minimum_size = Vector2(500, 38) - slider.value_changed.connect(changed) - row.add_child(slider) - parent.add_child(row) - -func _add_mobile_radio(parent: VBoxContainer, caption: String, choices: Array, active: int, picked: Callable) -> void: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(640, 44) - var label := Label.new() - label.text = caption - label.custom_minimum_size = Vector2(92, 40) - label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - label.add_theme_font_size_override("font_size", 13) - row.add_child(label) - var buttons: Array[Button] = [] - for i in choices.size(): - var button := Button.new() - button.text = String(choices[i]) - button.toggle_mode = true - button.button_pressed = i == active - button.custom_minimum_size = Vector2(100, 40) - var idx := i - button.pressed.connect(func(): - for other in buttons.size(): - buttons[other].set_pressed_no_signal(other == idx) - picked.call(idx)) - buttons.append(button) - row.add_child(button) - parent.add_child(row) - -func _mobile_panel_style() -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = Color(0.04, 0.065, 0.1, 0.98) - style.border_color = Color(0.52, 0.68, 0.9, 0.86) - style.set_border_width_all(1) - style.set_corner_radius_all(8) - return style - -func _node(nm: String) -> Control: - if _win.is_empty(): - return null - var n = _win.get("nodes", {}).get(nm, null) - return n if n is Control else null - -# uiScriptLocale 兜底:把 `` 文本换成中文 -func _relabel() -> void: - for nm in LABELS: - var n := _node(nm) - if n and n.has_method("set_text"): - n.set_text(LABELS[nm]) - -# --- wiring (uisystemoption.__Load) --------------------------------- - -func _wire() -> void: - var mv := _node("music_volume_controller") - if mv is Range: - mv.min_value = 0.0 - mv.max_value = 1.0 - mv.step = 0.01 - mv.value = music_volume - mv.value_changed.connect(_on_music_volume) - var sv := _node("sound_volume_controller") - if sv is Range: - sv.min_value = 0.0 - sv.max_value = 1.0 - sv.step = 0.01 - sv.value = sound_volume - sv.value_changed.connect(_on_sound_volume) - - _bind_radio(["camera_short", "camera_long"], func(i): - camera_mode = i - _apply_camera() - _save()) - _bind_radio(["fog_level0", "fog_level1", "fog_level2"], func(i): - fog_level = i - _apply_fog() - _save()) - _bind_radio(["tiling_cpu", "tiling_gpu"], func(i): - tiling_mode = i - _save()) - - var apply_btn := _node("tiling_apply") - if apply_btn is BaseButton: - apply_btn.pressed.connect(func(): - # 原 __OnClickTilingApplyButton 会重启客户端应用 CPU/GPU 分块; - # Godot 渲染器无此项,仅记录选择。 - _save()) - var bgm_btn := _node("bgm_button") - if bgm_btn is BaseButton: - bgm_btn.disabled = true # 换 BGM 需音乐文件选择器,暂缺 - var tb := _node("titlebar") - if tb: - for x in tb.find_children("*", "BaseButton", true, false): - x.pressed.connect(close) - -func _bind_radio(names: Array, on_pick: Callable) -> void: - var boxes: Array = [] - for nm in names: - boxes.append(_node(nm)) - for i in boxes.size(): - var b = boxes[i] - if not (b is BaseButton): - continue - b.toggle_mode = true - var idx := i - b.pressed.connect(func(): - for j in boxes.size(): - if boxes[j] is BaseButton: - boxes[j].set_pressed_no_signal(j == idx) - on_pick.call(idx)) - -# 把当前状态回填到控件(开窗时) -func _sync_controls() -> void: - _set_radio(["camera_short", "camera_long"], camera_mode) - _set_radio(["fog_level0", "fog_level1", "fog_level2"], fog_level) - _set_radio(["tiling_cpu", "tiling_gpu"], tiling_mode) - -func _set_radio(names: Array, active: int) -> void: - for i in names.size(): - var b = _node(names[i]) - if b is BaseButton: - b.set_pressed_no_signal(i == active) - -# --- change handlers ---------------------------------------------------- - -func _on_music_volume(v: float) -> void: - music_volume = clampf(v, 0.0, 1.0) - _apply_music() - _save() - -func _on_sound_volume(v: float) -> void: - sound_volume = clampf(v, 0.0, 1.0) - _apply_sound() - _save() - -# --- apply to engine -------------------------------------------------- - -func _apply_all() -> void: - _apply_music() - _apply_sound() - _apply_camera() - _apply_fog() - -func _apply_music() -> void: - if audio == null: - return - if "master_bgm" in audio: - audio.master_bgm = music_volume - # 已在播放的 BGM 声道即时跟随 - if "_bgm" in audio and "_bgm_cur" in audio: - var pl = audio._bgm[audio._bgm_cur] - if is_instance_valid(pl): - pl.volume_db = linear_to_db(maxf(music_volume, 0.0001)) - -func _apply_sound() -> void: - if audio and "master_sfx" in audio: - audio.master_sfx = sound_volume # play_ui / play_at 每次读取 - -func _apply_camera() -> void: - if not _cam_get.is_valid(): - return - var c = _cam_get.call() - if c == null: - return - var m: float = CAMERA_MAX[clampi(camera_mode, 0, 1)] - if "max_dist" in c: - c.max_dist = m - if "dist" in c: - c.dist = minf(c.dist, m) - -func _apply_fog() -> void: - if not _env_get.is_valid(): - return - var e = _env_get.call() - if not (e is Environment): - return - e.fog_enabled = true - e.fog_density = FOG_DENSITY[clampi(fog_level, 0, 2)] - -# --- persistence (= systemSetting 配置文件) -------------------------- - -func _load_cfg() -> void: - if _cfg.load(config_path()) != OK: - return - music_volume = float(_cfg.get_value("audio", "music_volume", music_volume)) - sound_volume = float(_cfg.get_value("audio", "sound_volume", sound_volume)) - camera_mode = int(_cfg.get_value("video", "camera_mode", camera_mode)) - fog_level = int(_cfg.get_value("video", "fog_level", fog_level)) - tiling_mode = int(_cfg.get_value("video", "tiling_mode", tiling_mode)) - -func _save() -> void: - _cfg.set_value("audio", "music_volume", music_volume) - _cfg.set_value("audio", "sound_volume", sound_volume) - _cfg.set_value("video", "camera_mode", camera_mode) - _cfg.set_value("video", "fog_level", fog_level) - _cfg.set_value("video", "tiling_mode", tiling_mode) - var err := _cfg.save(config_path()) - if err != OK: - push_warning("SystemOptionUI: save failed (%s): %s" % [err, config_path()]) diff --git a/project/ui/system_option_ui.gd.uid b/project/ui/system_option_ui.gd.uid deleted file mode 100644 index 703573c1..00000000 --- a/project/ui/system_option_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cs2tl51xyym8q diff --git a/project/ui/target_ui.gd b/project/ui/target_ui.gd deleted file mode 100644 index fa71ec3a..00000000 --- a/project/ui/target_ui.gd +++ /dev/null @@ -1,215 +0,0 @@ -# target_ui.gd —— 40250 目标信息板(TargetBoard)1:1 实现 -# 严格对照: -# root/uitarget.py:14-340 (TargetBoard) -# UserInterface/InstanceBase.cpp (CanViewTargetHP) -# root/localeinfo.py (TARGET_LEVEL_PAWN..KING) -extends Node - -const UiAssets = preload("res://ui/ui_assets.gd") - -const GRADE_NAMES := { - 0: "1阶", # nonplayer.PAWN: TARGET_LEVEL_PAWN - 1: "2阶", # nonplayer.S_PAWN: TARGET_LEVEL_S_PAWN - 2: "3阶", # nonplayer.KNIGHT: TARGET_LEVEL_KNIGHT - 3: "4阶", # nonplayer.S_KNIGHT: TARGET_LEVEL_S_KNIGHT - 4: "5阶首领", # nonplayer.BOSS: TARGET_LEVEL_BOSS - 5: "6阶王", # nonplayer.KING: TARGET_LEVEL_KING -} - -const DEFAULT_BOARD_WIDTH := 250.0 -const BOARD_HEIGHT := 40.0 -const GAUGE_WIDTH := 130.0 - -signal whisper_requested(name: String) -signal exchange_requested(vid: int) -signal fight_requested(vid: int) -signal view_equipment_requested(vid: int) - -var client: Node -var _root: Control -var _board: Panel -var _name_label: Label -var _hp_gauge_bg: ColorRect -var _hp_gauge_fill: ColorRect -var _hp_text: Label -var _close_button: Button -var _button_container: HBoxContainer -var _action_buttons: Dictionary = {} - -var _current_vid := 0 -var _current_name := "" -var _current_hp_pct := 100 -var _is_pc := false -var _shown := false -var _assets_root := "" - -func setup(m2client: Node, parent: Node, assets := "") -> void: - client = m2client - _assets_root = assets - _build(parent) - -func is_open() -> bool: - return _shown and _root != null and _root.visible - -func get_target_vid() -> int: - return _current_vid - -func get_target_name() -> String: - return _current_name - -func get_hp_percentage() -> int: - return _current_hp_pct - -func open(vid: int, target_name: String, level := -1, grade := -1, is_pc := false) -> void: - _current_vid = vid - _is_pc = is_pc - - var display_name := target_name - var prefix := "" - if level > 0: - prefix += "[Lv. %d] " % level - if grade in GRADE_NAMES: - prefix += "(%s) " % GRADE_NAMES[grade] - - _current_name = prefix + display_name - _name_label.text = _current_name - - # 40250 uitarget.py:301 板面自适应宽度:200 + 7 * nameLength - var calculated_width := maxf(DEFAULT_BOARD_WIDTH, 200.0 + 7.0 * float(_current_name.length())) - _update_layout(calculated_width, is_pc) - - _shown = true - _root.visible = true - _update_screen_position() - -func set_hp(hp_pct: int) -> void: - _current_hp_pct = clampi(hp_pct, 0, 100) - if _hp_gauge_fill: - _hp_gauge_fill.size.x = GAUGE_WIDTH * (_current_hp_pct / 100.0) - if _hp_text: - _hp_text.text = "%d%%" % _current_hp_pct - if _hp_gauge_bg and not _is_pc: - _hp_gauge_bg.visible = true - -func close() -> void: - _shown = false - _current_vid = 0 - _current_name = "" - if _root: - _root.visible = false - -func _on_close_button_pressed() -> void: - if client and client.has_method("clear_target"): - client.clear_target() - close() - -func _update_layout(width: float, is_pc: bool) -> void: - _board.size = Vector2(width, BOARD_HEIGHT) - if is_pc: - _hp_gauge_bg.visible = false - _button_container.visible = true - _button_container.position = Vector2(width - 200, 8) - else: - _hp_gauge_bg.visible = true - _button_container.visible = false - _hp_gauge_bg.position = Vector2(width - 175, 14) - _close_button.position = Vector2(width - 28, 10) - -func _update_screen_position() -> void: - if _root == null or not is_open(): - return - var screen_w := 800.0 - var vp := _root.get_viewport() - if vp: - screen_w = vp.get_visible_rect().size.x - # 40250 uitarget.py:254 UpdatePosition: ScreenWidth // 2 - Width // 2, 10 - _root.position = Vector2(screen_w / 2.0 - _board.size.x / 2.0, 10.0) - -func _build(parent: Node) -> void: - _root = Control.new() - _root.name = "TargetBoard" - _root.set_anchors_preset(Control.PRESET_TOP_LEFT) - _root.size = Vector2(DEFAULT_BOARD_WIDTH, BOARD_HEIGHT) - _root.visible = false - parent.add_child(_root) - - # 经典 ThinBoard 风格 - _board = Panel.new() - _board.name = "Board" - _board.size = Vector2(DEFAULT_BOARD_WIDTH, BOARD_HEIGHT) - var bg := StyleBoxFlat.new() - bg.bg_color = Color(0.04, 0.05, 0.07, 0.88) - bg.border_color = Color(0.45, 0.38, 0.22, 0.95) - bg.set_border_width_all(1) - bg.set_corner_radius_all(2) - _board.add_theme_stylebox_override("panel", bg) - _root.add_child(_board) - - # 目标名称标签 - _name_label = Label.new() - _name_label.name = "Name" - _name_label.position = Vector2(23, 11) - _name_label.size = Vector2(200, 20) - _name_label.add_theme_font_size_override("font_size", 11) - _name_label.add_theme_color_override("font_color", Color(1.0, 0.92, 0.75)) - _name_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9)) - _name_label.add_theme_constant_override("outline_size", 2) - _board.add_child(_name_label) - - # 红色 HP Gauge(40250 130px 经典红槽) - _hp_gauge_bg = ColorRect.new() - _hp_gauge_bg.name = "HPGaugeBG" - _hp_gauge_bg.position = Vector2(DEFAULT_BOARD_WIDTH - 175, 14) - _hp_gauge_bg.size = Vector2(GAUGE_WIDTH, 12) - _hp_gauge_bg.color = Color(0.12, 0.02, 0.02, 0.95) - _board.add_child(_hp_gauge_bg) - - _hp_gauge_fill = ColorRect.new() - _hp_gauge_fill.name = "HPGaugeFill" - _hp_gauge_fill.position = Vector2.ZERO - _hp_gauge_fill.size = Vector2(GAUGE_WIDTH, 12) - _hp_gauge_fill.color = Color(0.85, 0.12, 0.12, 1.0) - _hp_gauge_bg.add_child(_hp_gauge_fill) - - _hp_text = Label.new() - _hp_text.name = "HPText" - _hp_text.set_anchors_preset(Control.PRESET_FULL_RECT) - _hp_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _hp_text.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _hp_text.add_theme_font_size_override("font_size", 9) - _hp_text.add_theme_color_override("font_color", Color(1, 1, 1, 0.95)) - _hp_text.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8)) - _hp_text.add_theme_constant_override("outline_size", 1) - _hp_text.text = "100%" - _hp_gauge_bg.add_child(_hp_text) - - # 关闭按钮 (X) - _close_button = Button.new() - _close_button.name = "CloseButton" - _close_button.position = Vector2(DEFAULT_BOARD_WIDTH - 28, 10) - _close_button.size = Vector2(18, 18) - _close_button.text = "×" - _close_button.add_theme_font_size_override("font_size", 12) - _close_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - _close_button.pressed.connect(_on_close_button_pressed) - _board.add_child(_close_button) - - # PC 交互按钮条 - _button_container = HBoxContainer.new() - _button_container.name = "ButtonContainer" - _button_container.position = Vector2(DEFAULT_BOARD_WIDTH - 200, 8) - _button_container.visible = false - _board.add_child(_button_container) - - _add_action_btn("私聊", func(): whisper_requested.emit(_current_name)) - _add_action_btn("交易", func(): exchange_requested.emit(_current_vid)) - _add_action_btn("装备", func(): view_equipment_requested.emit(_current_vid)) - -func _add_action_btn(text: String, callback: Callable) -> void: - var btn := Button.new() - btn.text = text - btn.custom_minimum_size = Vector2(42, 22) - btn.add_theme_font_size_override("font_size", 10) - btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - btn.pressed.connect(callback) - _button_container.add_child(btn) diff --git a/project/ui/target_ui.gd.uid b/project/ui/target_ui.gd.uid deleted file mode 100644 index caf4bcdb..00000000 --- a/project/ui/target_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dh05do42dtgef diff --git a/project/ui/ui_build.gd b/project/ui/ui_build.gd deleted file mode 100644 index ffa4da38..00000000 --- a/project/ui/ui_build.gd +++ /dev/null @@ -1,507 +0,0 @@ -# UiBuild (P1) —— uiscript 嵌套 Dictionary → Godot Control 树。 -# -# var spec := UiScript.new().parse_file(path) -# var r := UiBuild.build(spec, assets_root) -# add_child(r.root) -# r.nodes["accept"].pressed.connect(...) # 按 uiscript 里的 "name" 取节点 -# -# 覆盖 uiscript 最常用的 ~18 种 type;其它降级为 Control / Button / TextureRect。 -# board/thinboard 的九宫格用 ui_kit(真实 ymir 贴图,缺失则 StyleBoxFlat 兜底)。 -extends RefCounted - -const UiKit = preload("res://ui_kit.gd") -const UiAssets = preload("res://ui/ui_assets.gd") - -# spec: 顶层 window dict。返回 { root: Control, nodes: {name: Control} } -static func build(spec: Dictionary, assets_root: String) -> Dictionary: - var ctx := {"assets": assets_root, "nodes": {}} - var root := _make(spec, ctx, Vector2i.ZERO) - if root == null: - root = Control.new() - return {"root": root, "nodes": ctx["nodes"]} - -# --- recursion --------------------------------------------------------- - -static func _i(spec: Dictionary, key: String, def := 0) -> int: - var v = spec.get(key, def) - return int(v) if (v is int or v is float or v is bool or v is String) else def - -static func _str(v) -> String: - if v == null: - return "" - return v if v is String else str(v) - -static func _make(spec: Dictionary, ctx: Dictionary, parent_size: Vector2i) -> Control: - var type := _str(spec.get("type", "window")) - var w := _i(spec, "width", 0) - var h := _i(spec, "height", 0) - if type == "titlebar" and h == 0: - h = 23 - elif type == "horizontalbar" and h == 0: - h = 17 - elif (type == "grid_table" or type == "slot") and (w == 0 or h == 0): - if w == 0 and spec.has("x_count"): - var xc := maxi(1, _i(spec, "x_count", 1)) - var xs := _i(spec, "x_step", 32) - var xb := _i(spec, "x_blank", 0) - w = xc * (xs + xb) - if h == 0 and spec.has("y_count"): - var yc := maxi(1, _i(spec, "y_count", 1)) - var ys := _i(spec, "y_step", 32) - var yb := _i(spec, "y_blank", 0) - h = yc * (ys + yb) - var node := _make_by_type(type, spec, ctx, Vector2i(w, h)) - if node == null: - node = Control.new() - - node.name = _safe_name(_str(spec.get("name", type))) - var halign := _str(spec.get("text_horizontal_align", "")) - var valign := _str(spec.get("text_vertical_align", "")) - var allalign := _str(spec.get("all_align", "")) - if type == "text": - if allalign == "center": - if w == 0: w = parent_size.x - if h == 0: h = parent_size.y - else: - if halign == "center" and w == 0: - w = parent_size.x if parent_size.x > 0 else 100 - if (valign == "center" or _str(spec.get("vertical_align", "")) == "center") and h == 0: - h = parent_size.y if parent_size.y > 0 else 17 - if (w == 0 or h == 0) and node is TextureRect and (node as TextureRect).texture != null: - var tex: Texture2D = (node as TextureRect).texture - if w == 0: w = tex.get_width() - if h == 0: h = tex.get_height() - elif (w == 0 or h == 0) and node is Button: - var btn := node as Button - var nrm_sb = btn.get_theme_stylebox("normal") - if nrm_sb is StyleBoxTexture and (nrm_sb as StyleBoxTexture).texture != null: - var tex: Texture2D = (nrm_sb as StyleBoxTexture).texture - if w == 0: w = tex.get_width() - if h == 0: h = tex.get_height() - - if w > 0: - node.custom_minimum_size = Vector2(w, h) - node.size = Vector2(w, h) - - # 位置:x/y + horizontal_align / vertical_align(相对父) - var x := _i(spec, "x", 0) - var y := _i(spec, "y", 0) - if type == "text" and halign == "center" and _i(spec, "width", 0) == 0: - x = 0 - match _str(spec.get("horizontal_align", "left")): - "center": x = (parent_size.x - w) / 2 + x - "right": x = parent_size.x - w - x - match _str(spec.get("vertical_align", "top")): - "center": - if type == "text" and (valign == "center" or _str(spec.get("vertical_align", "")) == "center") and h == parent_size.y: - pass - else: - y = (parent_size.y - h) / 2 + y - "bottom": y = parent_size.y - h - y - node.position = Vector2(x, y) - - var nm := _str(spec.get("name", "")) - if nm != "": - ctx["nodes"][nm] = node - - var self_size := Vector2i(w, h) if (w > 0 and h > 0) else parent_size - for child in spec.get("children", []): - if child is Dictionary: - var c := _make(child, ctx, self_size) - if c: - node.add_child(c) - return node - -static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: Vector2i) -> Control: - var assets: String = ctx["assets"] - match type: - "window", "box": - var c := Control.new() - c.mouse_filter = Control.MOUSE_FILTER_PASS - return c - "board", "thinboard", "board_with_titlebar": - var p := _board(assets, "thinboard" if type == "thinboard" else "board", sz) - if type == "board_with_titlebar" and spec.has("title"): - var tl := Label.new() - tl.name = "TitleText" - tl.text = _str(spec.get("title", "")) - tl.position = Vector2(12, 6) - p.add_child(tl) - p.set_meta("titlebar_h", 24) - return p - "titlebar": - return _titlebar(assets, sz.x, spec) - "text": - var lbl := Label.new() - lbl.text = _str(spec.get("text", "")) - if spec.has("text_color"): - lbl.add_theme_color_override("font_color", _argb(int(spec["text_color"]))) - elif spec.has("color"): - lbl.add_theme_color_override("font_color", _argb(int(spec["color"]))) - elif spec.has("r") and spec.has("g") and spec.has("b"): - var r := float(spec["r"]) - var g := float(spec["g"]) - var b := float(spec["b"]) - var a := float(spec.get("a", 1.0)) - lbl.add_theme_color_override("font_color", Color(r, g, b, a)) - else: - lbl.add_theme_color_override("font_color", Color(0.8549, 0.8549, 0.8549)) - - var font_size := 12 - var fs_spec := _str(spec.get("fontsize", "")).to_upper() - var fn_spec := _str(spec.get("fontname", "")).to_upper() - if fs_spec == "LARGE" or "LARGE" in fn_spec: - font_size = 14 - elif fs_spec == "SMALL" or "SMALL" in fn_spec: - font_size = 9 - lbl.add_theme_font_size_override("font_size", font_size) - - var halign := _str(spec.get("text_horizontal_align", "")) - var valign := _str(spec.get("text_vertical_align", "")) - if _str(spec.get("all_align", "")) == "center": - halign = "center" - valign = "center" - match halign: - "center": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - "right": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - _: lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT - match valign: - "center": lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - "bottom": lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM - _: lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - return lbl - "button", "toggle_button", "radio_button": - var b := Button.new() - b.text = _str(spec.get("text", "")) - b.toggle_mode = (type == "toggle_button" or type == "radio_button") - if spec.has("text_color"): - var col := _argb(int(spec["text_color"])) - b.add_theme_color_override("font_color", col) - b.add_theme_color_override("font_pressed_color", col) - b.add_theme_color_override("font_hover_color", col) - b.add_theme_color_override("font_focus_color", col) - elif spec.has("color"): - var col := _argb(int(spec["color"])) - b.add_theme_color_override("font_color", col) - b.add_theme_color_override("font_pressed_color", col) - b.add_theme_color_override("font_hover_color", col) - b.add_theme_color_override("font_focus_color", col) - b.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER - b.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER - b.alignment = HORIZONTAL_ALIGNMENT_CENTER - b.clip_text = true - - var btn_fsize := 12 - var fs_spec := _str(spec.get("fontsize", "")).to_upper() - var fn_spec := _str(spec.get("fontname", "")).to_upper() - if fs_spec == "LARGE" or "LARGE" in fn_spec: - btn_fsize = 14 - elif fs_spec == "SMALL" or "SMALL" in fn_spec: - btn_fsize = 9 - b.add_theme_font_size_override("font_size", btn_fsize) - - var nrm := UiAssets.load_tex(assets, _str(spec.get("default_image", ""))) - var ovr := UiAssets.load_tex(assets, _str(spec.get("over_image", ""))) - var dwn := UiAssets.load_tex(assets, _str(spec.get("down_image", ""))) - var empty := StyleBoxEmpty.new() - empty.content_margin_left = 0 - empty.content_margin_right = 0 - empty.content_margin_top = 0 - empty.content_margin_bottom = 0 - if nrm: - b.add_theme_stylebox_override("normal", _sb_tex(nrm)) - b.add_theme_stylebox_override("hover", _sb_tex(ovr if ovr else nrm)) - b.add_theme_stylebox_override("pressed", _sb_tex(dwn if dwn else nrm)) - b.add_theme_stylebox_override("disabled", _sb_tex(nrm)) - b.add_theme_stylebox_override("focus", empty) - var tex_w := nrm.get_width() - var tex_h := nrm.get_height() - b.custom_minimum_size = Vector2(tex_w, tex_h) - b.size = Vector2(tex_w, tex_h) - # 40250 对齐:进入节点树时重设尺寸,防止被 Godot 默认最小高度撑大到 31px - b.tree_entered.connect(func(): - b.size = b.custom_minimum_size - ) - if btn_fsize + 6 > tex_h: - btn_fsize = maxi(8, tex_h - 6) - b.add_theme_font_size_override("font_size", btn_fsize) - else: - b.add_theme_stylebox_override("normal", empty) - b.add_theme_stylebox_override("hover", empty) - b.add_theme_stylebox_override("pressed", empty) - b.add_theme_stylebox_override("focus", empty) - b.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - return b - "image", "expanded_image", "ani_image", "mark": - var ir := TextureRect.new() - ir.texture = UiAssets.load_tex(assets, _str(spec.get("image", ""))) - ir.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - ir.stretch_mode = TextureRect.STRETCH_SCALE if type == "expanded_image" \ - else TextureRect.STRETCH_KEEP - ir.mouse_filter = Control.MOUSE_FILTER_IGNORE - return ir - "editline": - var e := LineEdit.new() - e.text = _str(spec.get("text", "")) - e.secret = bool(spec.get("secret", false)) - if spec.has("input_limit"): - e.max_length = int(spec["input_limit"]) - return e - "line": - var ln := ColorRect.new() - ln.color = Color(1, 1, 1, 0.15) - if _i(spec, "height", 0) == 0: - ln.custom_minimum_size = Vector2(_i(spec, "width", 100), 1) - return ln - "listbox", "candidate_list": - return ItemList.new() - "gauge", "bar": - var pb := ProgressBar.new() - pb.show_percentage = false - pb.value = 100.0 - return pb - "horizontalbar": - return _horizontalbar(assets, _i(spec, "width", 100)) - "grid_table", "slotbar", "slot": - # Metin2 物品格容器:有 start_index/x_count 或显式 "slot" 元组 -> 建子格; - # 否则 grid_table 退化成普通 GridContainer。 - var cells := _slot_cells(spec) - if cells.is_empty() and type == "grid_table": - var g := GridContainer.new() - g.columns = maxi(1, _i(spec, "x_count", _i(spec, "column", 1))) - return g - var s := Control.new() - s.set_meta("is_slot_container", true) - var base_img := _str(spec.get("image", "")) - var base_tex: Texture2D = UiAssets.load_tex(assets, base_img) if base_img != "" else null - for cd in cells: - var cell := Panel.new() - cell.name = "slot_%d" % int(cd["index"]) - cell.position = Vector2(cd["x"], cd["y"]) - cell.custom_minimum_size = Vector2(cd["w"], cd["h"]) - cell.size = Vector2(cd["w"], cd["h"]) - cell.set_meta("slot_index", int(cd["index"])) - cell.mouse_filter = Control.MOUSE_FILTER_STOP - if base_tex != null: - cell.add_theme_stylebox_override("panel", _sb_tex(base_tex)) - s.add_child(cell) - return s - "sliderbar": - var slider := HSlider.new() - # 40250 sliderbar.sub (175×12), sliderbar_cursor.sub (53×12) - slider.custom_minimum_size = Vector2(175, 12) - slider.size = Vector2(175, 12) - slider.min_value = 0.0 - slider.max_value = 1.0 - slider.step = 0.01 - var track_tex := UiAssets.load_tex(assets, "d:/ymir work/ui/game/windows/sliderbar.sub") - var grab_tex := UiAssets.load_tex(assets, "d:/ymir work/ui/game/windows/sliderbar_cursor.sub") - if track_tex: - var sb_track := StyleBoxTexture.new() - sb_track.texture = track_tex - sb_track.content_margin_left = 0 - sb_track.content_margin_right = 0 - sb_track.content_margin_top = 0 - sb_track.content_margin_bottom = 0 - slider.add_theme_stylebox_override("slider", sb_track) - if grab_tex: - slider.add_theme_icon_override("grabber", grab_tex) - slider.add_theme_icon_override("grabber_highlight", grab_tex) - slider.add_theme_icon_override("grabber_disabled", grab_tex) - return slider - "scrollbar", "thin_scrollbar": - return VScrollBar.new() - _: - return Control.new() - -# --- helpers --------------------------------------------------------- - -static func _board(assets: String, prefix: String, sz: Vector2i) -> Panel: - var p := Panel.new() - if sz.x > 0 and sz.y > 0: - p.custom_minimum_size = Vector2(sz) - p.size = Vector2(sz) - var np: NinePatchRect = null - if assets != "": - var m := 16 if prefix == "thinboard" else 32 - var base_sz := 32 if prefix == "thinboard" else 128 - np = UiKit.board(assets, prefix, m, base_sz) - if np and np.texture: - np.mouse_filter = Control.MOUSE_FILTER_IGNORE - p.add_child(np) - np.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - p.get_child(0).show_behind_parent = true - p.add_theme_stylebox_override("panel", StyleBoxEmpty.new()) - else: - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.10, 0.11, 0.14, 0.92) - sb.border_color = Color(0.35, 0.32, 0.26) - sb.set_border_width_all(1) - sb.set_corner_radius_all(3) - p.add_theme_stylebox_override("panel", sb) - return p - -static func _titlebar(assets: String, width: int, spec: Dictionary) -> Control: - var t := Control.new() - var w := maxi(64, width) - t.custom_minimum_size = Vector2(w, 23) - t.size = Vector2(w, 23) - t.set_meta("is_titlebar", true) - - # Background textures (left, center, right) - var left_tex := UiKit.ui_tex(assets, "titlebar_left") - var center_tex := UiKit.ui_tex(assets, "titlebar_center") - var right_tex := UiKit.ui_tex(assets, "titlebar_right") - if left_tex and center_tex and right_tex: - var img_left := TextureRect.new() - img_left.name = "TitleBarLeft" - img_left.texture = left_tex - img_left.position = Vector2.ZERO - img_left.size = Vector2(32, 23) - img_left.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - img_left.mouse_filter = Control.MOUSE_FILTER_IGNORE - t.add_child(img_left) - - var img_center := TextureRect.new() - img_center.name = "TitleBarCenter" - img_center.texture = center_tex - img_center.position = Vector2(32, 0) - img_center.size = Vector2(maxi(0, w - 64), 23) - img_center.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - img_center.stretch_mode = TextureRect.STRETCH_TILE - img_center.mouse_filter = Control.MOUSE_FILTER_IGNORE - t.add_child(img_center) - - var img_right := TextureRect.new() - img_right.name = "TitleBarRight" - img_right.texture = right_tex - img_right.position = Vector2(w - 32, 0) - img_right.size = Vector2(32, 23) - img_right.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - img_right.mouse_filter = Control.MOUSE_FILTER_IGNORE - t.add_child(img_right) - else: - var p := Panel.new() - p.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.2, 0.2, 0.25, 0.9) - p.add_theme_stylebox_override("panel", sb) - p.mouse_filter = Control.MOUSE_FILTER_IGNORE - t.add_child(p) - - # Close Button - var btn_close := Button.new() - btn_close.name = "CloseButton" - var nrm := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_01.sub") - var ovr := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_02.sub") - var dwn := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_03.sub") - if nrm: - btn_close.add_theme_stylebox_override("normal", _sb_tex(nrm)) - if ovr: btn_close.add_theme_stylebox_override("hover", _sb_tex(ovr)) - if dwn: btn_close.add_theme_stylebox_override("pressed", _sb_tex(dwn)) - var bw := nrm.get_width() - var bh := nrm.get_height() - btn_close.size = Vector2(bw, bh) - btn_close.position = Vector2(w - bw - 3, 3) - else: - btn_close.text = "×" - btn_close.size = Vector2(16, 16) - btn_close.position = Vector2(w - 19, 3) - btn_close.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - t.add_child(btn_close) - t.set_meta("close_button", btn_close) - return t - -static func _horizontalbar(assets: String, width: int) -> Control: - var w := maxi(96, width) - var bar := Control.new() - bar.custom_minimum_size = Vector2(w, 17) - bar.size = Vector2(w, 17) - bar.mouse_filter = Control.MOUSE_FILTER_PASS - - var left_tex := UiKit.ui_tex(assets, "horizontalbar_left") - var center_tex := UiKit.ui_tex(assets, "horizontalbar_center") - var right_tex := UiKit.ui_tex(assets, "horizontalbar_right") - - if left_tex and center_tex and right_tex: - var img_left := TextureRect.new() - img_left.name = "HorizontalBarLeft" - img_left.texture = left_tex - img_left.position = Vector2.ZERO - img_left.size = Vector2(32, 17) - img_left.mouse_filter = Control.MOUSE_FILTER_IGNORE - bar.add_child(img_left) - - var img_center := TextureRect.new() - img_center.name = "HorizontalBarCenter" - img_center.texture = center_tex - img_center.position = Vector2(32, 0) - img_center.size = Vector2(maxi(0, w - 64), 17) - img_center.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - img_center.stretch_mode = TextureRect.STRETCH_TILE - img_center.mouse_filter = Control.MOUSE_FILTER_IGNORE - bar.add_child(img_center) - - var img_right := TextureRect.new() - img_right.name = "HorizontalBarRight" - img_right.texture = right_tex - img_right.position = Vector2(w - 32, 0) - img_right.size = Vector2(32, 17) - img_right.mouse_filter = Control.MOUSE_FILTER_IGNORE - bar.add_child(img_right) - else: - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.2, 0.22, 0.25, 0.85) - var p := Panel.new() - p.set_anchors_preset(Control.PRESET_FULL_RECT) - p.add_theme_stylebox_override("panel", sb) - bar.add_child(p) - return bar - -# slot 容器的子格:显式 "slot" 元组,或 start_index/x_count/y_count/x_step/y_step 网格。 -static func _slot_cells(spec: Dictionary) -> Array: - var cells := [] - if spec.get("slot", null) is Array: - for sd in spec["slot"]: - if sd is Dictionary: - cells.append({"index": _i(sd, "index"), "x": _i(sd, "x"), "y": _i(sd, "y"), - "w": _i(sd, "width", 32), "h": _i(sd, "height", 32)}) - elif spec.has("x_count"): - var si := _i(spec, "start_index", 0) - var xc := maxi(1, _i(spec, "x_count", 1)) - var yc := maxi(1, _i(spec, "y_count", 1)) - var xs := _i(spec, "x_step", 32) - var ys := _i(spec, "y_step", 32) - var xb := _i(spec, "x_blank", 0) - var yb := _i(spec, "y_blank", 0) - for row in yc: - for col in xc: - cells.append({"index": si + row * xc + col, "x": col * (xs + xb), "y": row * (ys + yb), - "w": xs, "h": ys}) - return cells - -static func _sb_tex(t: Texture2D) -> StyleBoxTexture: - var sb := StyleBoxTexture.new() - sb.texture = t - sb.content_margin_left = 0 - sb.content_margin_top = 0 - sb.content_margin_right = 0 - sb.content_margin_bottom = 0 - return sb - -static func _argb(v: int) -> Color: - # 0xAARRGGBB - var a := (v >> 24) & 0xff - var r := (v >> 16) & 0xff - var g := (v >> 8) & 0xff - var b := v & 0xff - if a == 0: - a = 255 - return Color8(r, g, b, a) - -static func _safe_name(s: String) -> String: - var out := s - for bad: String in [".", ":", "@", "/", "%", " "]: - out = out.replace(bad, "_") - return out if out != "" else "node" diff --git a/project/ui/ui_build.gd.uid b/project/ui/ui_build.gd.uid deleted file mode 100644 index 6fe2d0ec..00000000 --- a/project/ui/ui_build.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dxoqp78836s5p diff --git a/project/ui/ui_manager.gd b/project/ui/ui_manager.gd deleted file mode 100644 index eb851f63..00000000 --- a/project/ui/ui_manager.gd +++ /dev/null @@ -1,479 +0,0 @@ -# UiManager (P1) —— 窗口栈:打开 / 关闭 / 置顶 / ESC 关顶层 / modal 变暗 / 标题栏拖动。 -# -# var ui := preload("res://ui/ui_manager.gd").new() -# add_child(ui) -# ui.locale = func(k): return Locale.t(k) -# var w := ui.open_script("res://../assets/uiscript/uiscript/systemdialog.py", assets_root) -# w.nodes["close_button"].pressed.connect(func(): ui.close(w.root)) -# -# open_script 返回 { root: Control, nodes: {name: Control} }(同 UiBuild.build)。 -extends CanvasLayer - -const UiScript = preload("res://ui/uiscript.gd") -const UiBuild = preload("res://ui/ui_build.gd") -const MobileWindowHost = preload("res://ui/mobile/mobile_window_host.gd") - -signal window_opened(root: Control) -signal window_closed(root: Control) - -var locale: Callable -var screen := Vector2i(1920, 1080) -var mobile_mode := false -var mobile_screen := Vector2i(844, 390) - -var _root: Control # 全屏容器 -var _dim: ColorRect # modal 变暗层 -var _mobile_blocker: ColorRect -var _stack: Array[Control] = [] -var _mobile_external: Array[Dictionary] = [] -var _mobile_modals: Array[Node] = [] -var _mobile_saved: Dictionary = {} -var _mobile_close_callbacks: Dictionary = {} -var _last_mobile_area := Rect2(-1, -1, 0, 0) -var _drag_win: Control = null -var _drag_from := Vector2.ZERO - -func _init() -> void: - layer = 10 - _ensure_root() - -func _ensure_root() -> void: - if _root != null: - return - layer = 10 - _root = Control.new() - _root.name = "UiRoot" - _root.set_anchors_preset(Control.PRESET_FULL_RECT) - _root.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_root) - _dim = ColorRect.new() - _dim.color = Color(0, 0, 0, 0.45) - _dim.set_anchors_preset(Control.PRESET_FULL_RECT) - _dim.visible = false - _root.add_child(_dim) - _mobile_blocker = ColorRect.new() - _mobile_blocker.name = "MobileWindowBlocker" - _mobile_blocker.color = Color(0, 0, 0, 0) - _mobile_blocker.set_anchors_preset(Control.PRESET_FULL_RECT) - _mobile_blocker.mouse_filter = Control.MOUSE_FILTER_STOP - _mobile_blocker.visible = false - _root.add_child(_mobile_blocker) - -func _ready() -> void: - _ensure_root() - set_process_input(true) - set_process_unhandled_input(true) - set_process(true) - -## Select the presentation policy without changing any feature controller. -## Mobile windows are rebuilt with the landscape design resolution and hosted -## inside the safe area; desktop keeps the historical 1920x1080 parser space. -func set_mobile_mode(enabled: bool) -> void: - mobile_mode = enabled - if enabled: - screen = mobile_screen - layer = 30 - _last_mobile_area = Rect2(-1, -1, 0, 0) - else: - screen = Vector2i(1920, 1080) - layer = 10 - _last_mobile_area = Rect2(-1, -1, 0, 0) - if not enabled: - for record in _mobile_external.duplicate(): - var root: Control = record.get("root") - if root and is_instance_valid(root): - _dismiss_external(root) - _restack() - -func track_mobile_window(root: Control, title := "", close_cb := Callable()) -> void: - if root == null: - return - for record in _mobile_external: - if record.get("root") == root: - record["title"] = title - record["close_cb"] = close_cb - return - _mobile_external.append({"root": root, "title": title, "close_cb": close_cb}) - if mobile_mode and root.visible: - _sync_mobile_windows() - -## Register a native Window/ConfirmationDialog that is created outside a -## feature's hosted Control. It remains owned by the feature, but the mobile -## manager can block world input and make Android back press its cancel action -## first. This avoids moving native dialogs into a Control tree. -func track_mobile_modal(popup: Node) -> void: - if popup == null: - return - if not _mobile_modals.has(popup): - _mobile_modals.append(popup) - popup.set_meta("mobile_modal", true) - MobileWindowHost.wire_buttons(popup) - -func reflow_mobile_window(root: Control) -> void: - if not mobile_mode or root == null or not is_instance_valid(root): - return - if not _stack.has(root): - return - MobileWindowHost.install(root, _mobile_area(), _window_title(root), - Callable(self, "_close_hosted_window").bind(root)) - _restack() - -# --- open / close -------------------------------------------------------- - -func build_script(path: String, assets_root: String) -> Dictionary: - var us := UiScript.new() - us.screen = screen - if locale.is_valid(): - us.locale = locale - var spec := us.parse_file(path) - if spec.is_empty(): - push_warning("UiManager: parse failed (%s): %s" % [path, us.last_error]) - return {"root": Control.new(), "nodes": {}} - return UiBuild.build(spec, assets_root) - -## 1:1 对齐 40250 ui.py: SetCenterPosition(x = 0, y = 0) -## 将窗口居中于当前屏幕 / 视口 -func center_window(win: Control, offset := Vector2.ZERO) -> void: - if win == null or not is_instance_valid(win): - return - var vp_sz := Vector2(1920, 1080) - var vp := win.get_viewport() - if vp: - var vrect := vp.get_visible_rect().size - if vrect.x > 300 and vrect.y > 300: - vp_sz = vrect - elif _root and _root.size.x > 300 and _root.size.y > 300: - vp_sz = _root.size - elif screen.x > 0 and screen.y > 0: - vp_sz = Vector2(screen) - var win_sz := win.size - if win_sz.x <= 0 or win_sz.y <= 0: - win_sz = win.custom_minimum_size - win.position = ((vp_sz - win_sz) / 2.0 + offset).round() - -# 解析 + 构建 + 打开,返回 { root, nodes } -func open_script(path: String, assets_root: String, modal := false, center := false) -> Dictionary: - var r := build_script(path, assets_root) - open(r.root, modal) - if center and r.root is Control: - center_window(r.root) - return r - -func open(win: Control, modal := false) -> void: - _ensure_root() - if win.get_parent() == null: - _root.add_child(win) - win.set_meta("modal", modal) - _wire_close_buttons(win) - if mobile_mode: - MobileWindowHost.install(win, _mobile_area(), _window_title(win), - Callable(self, "_close_hosted_window").bind(win)) - else: - _wire_drag(win) - _stack.erase(win) - _stack.append(win) - _restack() - window_opened.emit(win) - -func _wire_close_buttons(win: Control) -> void: - for btn in win.find_children("CloseButton", "BaseButton", true, false): - var b := btn as BaseButton - if not b.has_meta("_close_wired"): - b.set_meta("_close_wired", true) - b.pressed.connect(func(): close(win)) - -func close(win: Control) -> void: - if win == null: - return - if bool(win.get_meta("mobile_external", false)): - var cb: Callable = _mobile_close_callbacks.get(win, Callable()) - if cb.is_valid() and win.visible: - cb.call() - else: - _dismiss_external(win) - return - _stack.erase(win) - if win.get_parent(): - win.get_parent().remove_child(win) - win.queue_free() - _restack() - window_closed.emit(win) - -func close_top() -> bool: - if _close_mobile_popup(): - return true - if _stack.is_empty(): - return false - var win := _stack[-1] - if bool(win.get_meta("mobile_external", false)): - var cb: Callable = _mobile_close_callbacks.get(win, Callable()) - if cb.is_valid(): - cb.call() - else: - _dismiss_external(win) - else: - close(win) - return true - -func top() -> Control: - return _stack[-1] if not _stack.is_empty() else null - -# wndMgr 的输入边界:鼠标不能从打开的窗口下方穿透到世界;模态确认框(LockWindow) -# 只允许 ESC 回到窗口栈。键盘按 CWindowManager::RunKeyDown:非模态窗口不截键。 -func blocks_game_input(event: InputEvent) -> bool: - var popup := _mobile_modal_popup() - if popup != null: - return not (event is InputEventKey and event.keycode == KEY_ESCAPE) - if _stack.is_empty(): - return false - var top_window: Control = _stack[-1] - if top_window == null or not is_instance_valid(top_window) or not top_window.visible: - return false - if bool(top_window.get_meta("modal", false)): - return not (event is InputEventKey and event.keycode == KEY_ESCAPE) - if mobile_mode and event is InputEventScreenTouch: - return true - if mobile_mode and event is InputEventScreenDrag: - return true - if event is InputEventMouse: - return top_window.get_global_rect().has_point(event.position) - # 键盘:没有 LockWindow 时键先给 ActiveWindow(输入框——Godot 里由获得焦点的控件在 - # gui_input 中自行吃掉),其余落到 game.py OnKeyDown,所以技能窗 / 背包开着时 - # 快捷栏 1-4 / F1-F4、Space 普攻和开关窗键都照常生效。 - return false - -func _input(event: InputEvent) -> void: - # Keyboard events do not automatically enter Control.gui_input. Consume - # blocked keys here, before PlayerController/GameCamera receive them through - # _unhandled_input; mouse events remain available to child controls. - if event is InputEventKey and blocks_game_input(event): - get_viewport().set_input_as_handled() - -func _process(_delta: float) -> void: - if mobile_mode: - var area := _mobile_area() - if not area.is_equal_approx(_last_mobile_area): - _last_mobile_area = area - _reflow_mobile_windows(area) - _sync_mobile_windows() - # Password/confirmation dialogs are created by feature controllers after - # their window is hosted. Wire their buttons too, including dialogs that - # appear as siblings under UiManager's root. - MobileWindowHost.wire_buttons(_root) - for win in _stack: - if win and is_instance_valid(win): - MobileWindowHost.wire_buttons(win) - for popup in _mobile_modals.duplicate(): - if popup == null or not is_instance_valid(popup): - _mobile_modals.erase(popup) - else: - MobileWindowHost.wire_buttons(popup) - -func _reflow_mobile_windows(area: Rect2) -> void: - # Safe areas can change while the logical viewport stays the same (for - # example when a keyboard, call bar or system gesture inset appears). Keep - # every already-open feature inside the new area instead of waiting for a - # full window resize. - for win in _stack.duplicate(): - if win == null or not is_instance_valid(win) or not win.visible: - continue - MobileWindowHost.install(win, area, _window_title(win), - Callable(self, "_close_hosted_window").bind(win)) - -func _sync_mobile_windows() -> void: - for record in _mobile_external: - var root: Control = record.get("root") - if root == null or not is_instance_valid(root): - continue - if root.visible and not _stack.has(root): - _present_external(root, String(record.get("title", "")), - record.get("close_cb", Callable())) - elif not root.visible and _stack.has(root): - _dismiss_external(root) - -func _mobile_area() -> Rect2: - return MobileWindowHost.safe_rect(get_viewport()) - -func _window_title(win: Control) -> String: - if win == null: - return "" - var title := String(win.get_meta("mobile_title", "")) - if title != "": - return title - return String(win.name) if win.name != "Control" else "功能" - -func _close_hosted_window(win: Control) -> void: - close(win) - -func _present_external(root: Control, title: String, close_cb: Callable) -> void: - if root == null or not is_instance_valid(root) or _stack.has(root): - return - var parent := root.get_parent() - if parent == null: - return - var saved := { - "parent": parent, - "position": root.position, - "size": root.size, - "scale": root.scale, - "pivot": root.pivot_offset, - "anchor_left": root.anchor_left, - "anchor_top": root.anchor_top, - "anchor_right": root.anchor_right, - "anchor_bottom": root.anchor_bottom, - } - _mobile_saved[root] = saved - parent.remove_child(root) - _root.add_child(root) - root.set_meta("mobile_external", true) - root.set_meta("modal", false) - _mobile_close_callbacks[root] = close_cb - MobileWindowHost.install(root, _mobile_area(), title, close_cb) - _stack.append(root) - _restack() - -func _dismiss_external(root: Control) -> void: - if root == null: - return - _stack.erase(root) - _mobile_close_callbacks.erase(root) - MobileWindowHost.uninstall(root) - var saved: Dictionary = _mobile_saved.get(root, {}) - if root.get_parent() == _root: - _root.remove_child(root) - var parent: Node = saved.get("parent") - if parent and is_instance_valid(parent): - parent.add_child(root) - if not saved.is_empty(): - root.position = saved.get("position", root.position) - root.size = saved.get("size", root.size) - root.scale = saved.get("scale", Vector2.ONE) - root.pivot_offset = saved.get("pivot", Vector2.ZERO) - root.anchor_left = float(saved.get("anchor_left", root.anchor_left)) - root.anchor_top = float(saved.get("anchor_top", root.anchor_top)) - root.anchor_right = float(saved.get("anchor_right", root.anchor_right)) - root.anchor_bottom = float(saved.get("anchor_bottom", root.anchor_bottom)) - _mobile_saved.erase(root) - root.set_meta("mobile_external", false) - _restack() - window_closed.emit(root) - -func _restack() -> void: - var any_modal := false - for i in _stack.size(): - var w := _stack[i] - w.move_to_front() - if bool(w.get_meta("modal", false)): - any_modal = true - _dim.visible = any_modal - _mobile_blocker.visible = mobile_mode and not _stack.is_empty() - if _mobile_blocker: - _root.move_child(_mobile_blocker, 0) - if any_modal: - # 变暗层紧贴在最顶层 modal 之下 - _dim.move_to_front() - _stack[-1].move_to_front() - -# --- ESC --------------------------------------------------------------- - -func _unhandled_input(e: InputEvent) -> void: - if e is InputEventKey and e.pressed and e.keycode == KEY_ESCAPE: - if _close_mobile_popup(): - get_viewport().set_input_as_handled() - return - if close_top(): - get_viewport().set_input_as_handled() - -func _mobile_modal_popup() -> Node: - if not mobile_mode or _root == null: - return null - for popup in _mobile_modals: - if popup and is_instance_valid(popup) and _node_visible(popup): - return popup - for node in _root.find_children("*", "", true, false): - if (node is Window or bool(node.get_meta("mobile_modal", false))) and _node_visible(node): - return node - return null - -func _node_visible(node: Node) -> bool: - if node is CanvasItem: - return (node as CanvasItem).visible - if node is Window: - return (node as Window).visible - return false - -func _close_mobile_popup() -> bool: - var popup := _mobile_modal_popup() - if popup == null: - return false - var close_cb: Variant = popup.get_meta("mobile_modal_close", Callable()) - if close_cb is Callable and close_cb.is_valid(): - close_cb.call() - return true - # ConfirmationDialog owners generally use the canceled signal to clear - # pending state. Drive its cancel button when available; plain dialogs can - # safely be hidden on back. - if popup is ConfirmationDialog and popup.has_method("get_cancel_button"): - var cancel = popup.call("get_cancel_button") - if cancel is BaseButton and not cancel.disabled: - cancel.pressed.emit() - return true - popup.hide() - return true - -# --- 标题栏拖动 -------------------------------------------------------- - -func _wire_drag(win: Control) -> void: - var handles := _find_drag_handles(win) - for handle in handles: - if handle == null or handle.has_meta("_drag_wired"): - continue - handle.set_meta("_drag_wired", true) - handle.mouse_filter = Control.MOUSE_FILTER_STOP - handle.gui_input.connect(func(ev: InputEvent): _on_handle_input(win, ev)) - -func _find_drag_handles(win: Control) -> Array[Control]: - var list: Array[Control] = [] - for n in win.find_children("*", "", true, false): - if n is Control and (n.get_meta("is_titlebar", false) or n.get_meta("titlebar_h", 0) > 0): - list.append(n as Control) - if list.is_empty(): - list.append(win) - return list - -func _on_handle_input(win: Control, ev: InputEvent) -> void: - if ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT: - if ev.pressed: - _drag_win = win - _drag_from = win.get_global_mouse_position() - win.position - open(win) # 置顶 - else: - _drag_win = null - elif ev is InputEventMouseMotion and _drag_win == win: - win.position = win.get_global_mouse_position() - _drag_from - -# --- 40250 ItemToolTip 统一悬浮窗 --- -var _item_tooltip_view: Control = null - -func get_item_tooltip_view(assets_root := "", proto: Node = null, ilist: RefCounted = null) -> Control: - if _item_tooltip_view == null or not is_instance_valid(_item_tooltip_view): - var view_cls = load("res://ui/item_tooltip_view.gd") - _item_tooltip_view = view_cls.new() - _item_tooltip_view.z_index = 200 - _item_tooltip_view.visible = false - _ensure_root() - _root.add_child(_item_tooltip_view) - if not assets_root.is_empty(): - _item_tooltip_view.setup(assets_root, proto, ilist) - return _item_tooltip_view - -func show_item_tooltip(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {}, - player_ctx: Dictionary = {}, assets_root := "", proto: Node = null, ilist: RefCounted = null) -> void: - var view = get_item_tooltip_view(assets_root, proto, ilist) - if view: - view.show_item(vnum, count, proto_data, instance_data, player_ctx) - -func hide_item_tooltip() -> void: - if _item_tooltip_view and is_instance_valid(_item_tooltip_view): - _item_tooltip_view.hide_tooltip() - diff --git a/project/ui/ui_manager.gd.uid b/project/ui/ui_manager.gd.uid deleted file mode 100644 index b5a2e67c..00000000 --- a/project/ui/ui_manager.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cyw533ww50yve diff --git a/project/ui/ui_profile.gd b/project/ui/ui_profile.gd deleted file mode 100644 index a176ba11..00000000 --- a/project/ui/ui_profile.gd +++ /dev/null @@ -1,48 +0,0 @@ -# UiProfile —— desktop / mobile presentation selection. -# -# The profile only selects presentation and input surfaces. It must never -# change the M2 protocol or gameplay rules. Desktop developers can force the -# mobile HUD with `--mobile-ui` (or the project setting below) without needing -# an Android build. -extends RefCounted - -enum Mode { AUTO, DESKTOP, MOBILE } - -const SETTING := "mt/ui/profile" -const MOBILE_ARG := "--mobile-ui" -const DESKTOP_ARG := "--desktop-ui" - -static func parse(value: String) -> int: - match value.strip_edges().to_lower(): - "mobile": - return Mode.MOBILE - "desktop": - return Mode.DESKTOP - _: - return Mode.AUTO - -static func resolve(value: String = "") -> int: - var requested := value - if requested == "": - requested = String(ProjectSettings.get_setting(SETTING, "auto")) - var parsed := parse(requested) - if parsed != Mode.AUTO: - return parsed - for arg in OS.get_cmdline_args(): - if arg == MOBILE_ARG: - return Mode.MOBILE - if arg == DESKTOP_ARG: - return Mode.DESKTOP - return Mode.MOBILE if OS.has_feature("mobile") else Mode.DESKTOP - -static func is_mobile(value: String = "") -> bool: - return resolve(value) == Mode.MOBILE - -static func name(mode: int) -> String: - match mode: - Mode.MOBILE: - return "mobile" - Mode.DESKTOP: - return "desktop" - _: - return "auto" diff --git a/project/ui/ui_profile.gd.uid b/project/ui/ui_profile.gd.uid deleted file mode 100644 index 679f2cc4..00000000 --- a/project/ui/ui_profile.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://hvyi07nm3dbg diff --git a/project/ui/uiscript.gd b/project/ui/uiscript.gd deleted file mode 100644 index 1568e038..00000000 --- a/project/ui/uiscript.gd +++ /dev/null @@ -1,357 +0,0 @@ -# UiScript (P1) —— 解析原始客户端的 `assets/uiscript/*.py` 布局脚本为嵌套 Dictionary。 -# -# 这些文件是 Python **dict 字面量**,用到的子集很小: -# 语句: import X(忽略)· NAME = (文件常量)· window = {...} -# 表达式:str/int/float · NAME · NAME.ATTR · (e) · 元组 (e,e) · 列表 [e,e] · -# dict {k:v} · 一元 - · 二元 + - * / -# 注释: # 和 ## 到行尾 -# -# 名字解析顺序:文件常量 → {SCREEN_WIDTH, SCREEN_HEIGHT} → uiScriptLocale. -# (交给 locale 回调)→ 未知则 0 / ""。除法按浮点算(调用方按需取整)。 -# -# 用法: -# var us := preload("res://ui/uiscript.gd").new() -# us.screen = Vector2i(1920, 1080) -# us.locale = func(key): return Locale.t(key) # 可选 -# var tree: Dictionary = us.parse_file("res://../assets/uiscript/uiscript/popupdialog.py") -# # tree = { name, type?, x, y, width, height, children:[...], ... } -extends RefCounted - -var screen := Vector2i(1920, 1080) -var locale: Callable # func(key: String) -> String -var last_error := "" - -var _consts := {} # 文件顶层 NAME = expr -var _s := "" # 源文本 -var _p := 0 # 游标 - -# --- public --------------------------------------------------------------- - -func parse_file(path: String) -> Dictionary: - if not FileAccess.file_exists(path): - last_error = "no such file: " + path - return {} - return parse_text(FileAccess.get_file_as_string(path)) - -func parse_text(src: String) -> Dictionary: - last_error = "" - _consts = {"SCREEN_WIDTH": screen.x, "SCREEN_HEIGHT": screen.y} - _s = _strip_comments(src) - _p = 0 - var window := {} - while true: - _skip_ws() - if _p >= _s.length(): - break - if _match_kw("import"): - _skip_to_newline() - continue - var name := _read_ident() - if name == "": - # 可能是散落的括号/逗号;跳一个字符防死循环 - _p += 1 - continue - _skip_ws() - if _p < _s.length() and _s[_p] == "=": - _p += 1 - var val = _parse_expr() - if name == "window": - window = val if val is Dictionary else {} - else: - _consts[name] = val - else: - _skip_to_newline() - if window.is_empty() and last_error == "": - last_error = "no `window = {...}` found" - return window - -# --- lexing helpers ----------------------------------------------------- - -func _strip_comments(src: String) -> String: - var out := "" - var in_str := false - var q := "" - var i := 0 - while i < src.length(): - var c := src[i] - if in_str: - out += c - if c == "\\" and i + 1 < src.length(): - out += src[i + 1] - i += 2 - continue - if c == q: - in_str = false - i += 1 - continue - if c == '"' or c == "'": - in_str = true - q = c - out += c - elif c == "#": - while i < src.length() and src[i] != "\n": - i += 1 - continue - else: - out += c - i += 1 - return out - -func _skip_ws() -> void: - while _p < _s.length() and _s[_p] in [" ", "\t", "\r", "\n", ","]: - _p += 1 - -func _skip_to_newline() -> void: - while _p < _s.length() and _s[_p] != "\n": - _p += 1 - -func _match_kw(kw: String) -> bool: - _skip_ws() - if _s.substr(_p, kw.length()) == kw: - var after := _p + kw.length() - if after >= _s.length() or not _is_ident_char(_s[after]): - _p = after - return true - return false - -func _is_ident_char(c: String) -> bool: - return c == "_" or (c >= "a" and c <= "z") or (c >= "A" and c <= "Z") or (c >= "0" and c <= "9") - -func _read_ident() -> String: - _skip_ws() - var start := _p - while _p < _s.length() and _is_ident_char(_s[_p]): - _p += 1 - return _s.substr(start, _p - start) - -# --- expression parser (precedence: + - then * /) ---------------------- - -func _parse_expr(): - var lhs = _parse_term() - while _p < _s.length(): - _skip_ws_no_comma() - if _p >= _s.length(): - break - var c := _s[_p] - if c == "+" or c == "-": - _p += 1 - var rhs = _parse_term() - if c == "+" and (lhs is String or rhs is String): - lhs = _str_or(lhs) + _str_or(rhs) - elif c == "+": - lhs = _num(lhs) + _num(rhs) - else: - lhs = _num(lhs) - _num(rhs) - elif c == "%": - # Python 字符串格式化 "fmt" % (args):取 fmt,丢掉格式参数 - _p += 1 - _parse_term() - elif c == '"' or c == "'": - # Python 相邻字符串字面量隐式拼接: "a" "b" - lhs = _str_or(lhs) + _str_or(_parse_atom()) - else: - break - return lhs - -func _parse_term(): - var lhs = _parse_atom() - while true: - _skip_ws_no_comma() - if _p < _s.length() and (_s[_p] == "*" or _s[_p] == "/"): - var op := _s[_p] - _p += 1 - var rhs = _parse_atom() - if op == "*": - lhs = _num(lhs) * _num(rhs) - else: - lhs = _num(lhs) / _num(rhs) if _num(rhs) != 0.0 else 0.0 - else: - break - return lhs - -func _skip_ws_no_comma() -> void: - while _p < _s.length() and _s[_p] in [" ", "\t", "\r", "\n"]: - _p += 1 - -func _parse_atom(): - _skip_ws_no_comma() - if _p >= _s.length(): - return 0 - var c := _s[_p] - if c == "-": - _p += 1 - return -_num(_parse_atom()) - if c == '"' or c == "'": - return _parse_string() - if c == "{": - return _parse_dict() - if c == "[": - return _parse_seq("[", "]") - if c == "(": - return _parse_seq("(", ")") # 元组或加括号的表达式,_parse_seq 统一处理 - if c == "_" or (c >= "A" and c <= "Z") or (c >= "a" and c <= "z"): - return _parse_name() - if (c >= "0" and c <= "9") or c == ".": - return _parse_number() - _p += 1 - return 0 - -func _parse_string(): - var q := _s[_p] - _p += 1 - var out := "" - while _p < _s.length() and _s[_p] != q: - if _s[_p] == "\\" and _p + 1 < _s.length(): - var n := _s[_p + 1] - out += "\n" if n == "n" else ("\t" if n == "t" else n) - _p += 2 - continue - out += _s[_p] - _p += 1 - _p += 1 # closing quote - return out - -func _parse_number(): - var start := _p - var is_float := false - while _p < _s.length() and ((_s[_p] >= "0" and _s[_p] <= "9") or _s[_p] == "." or _s[_p] == "x" \ - or (_s[_p] >= "a" and _s[_p] <= "f") or (_s[_p] >= "A" and _s[_p] <= "F")): - if _s[_p] == ".": - is_float = true - _p += 1 - var tok := _s.substr(start, _p - start) - if tok.begins_with("0x") or tok.begins_with("0X"): - return tok.hex_to_int() - return float(tok) if is_float else int(tok) - -func _parse_name(): - var name := _read_ident() - # 属性链:uiScriptLocale.KEY / X.Y - while _p < _s.length() and _s[_p] == ".": - _p += 1 - var attr := _read_ident() - if name == "uiScriptLocale": - return _resolve_locale(attr) - name += "." + attr - if _consts.has(name): - return _consts[name] - if name == "True": - return true - if name == "False": - return false - if name == "None": - return null - # 未知名字:数值上下文当 0,字符串上下文当 "";这里返回 0,_str_or 会兜 - return 0 - -static var _locale_dict := {} - -static func _ensure_locale_dict() -> void: - if not _locale_dict.is_empty(): - return - _locale_dict["WINDOWS_PATH"] = "locale/en/ui/windows/" - _locale_dict["LOCALE_UISCRIPT_PATH"] = "locale/en/ui/" - _locale_dict["LOGIN_PATH"] = "locale/en/ui/login/" - _locale_dict["EMPIRE_PATH"] = "locale/en/ui/empire/" - _locale_dict["GUILD_PATH"] = "locale/en/ui/guild/" - _locale_dict["SELECT_PATH"] = "locale/en/ui/select/" - _locale_dict["MAPNAME_PATH"] = "locale/en/ui/mapname/" - var cands := [ - "assets/locale/locale/en/locale_interface.txt", - "assets/locale/en/locale_interface.txt", - "../assets/locale/locale/en/locale_interface.txt", - "../assets/locale/en/locale_interface.txt", - ] - for p in cands: - if FileAccess.file_exists(p): - var txt := FileAccess.get_file_as_string(p) - for line in txt.split("\n"): - var parts := line.strip_edges().split("\t", false) - if parts.size() >= 2: - _locale_dict[parts[0].strip_edges()] = parts[1].strip_edges() - break - -func _resolve_locale(key: String): - if locale.is_valid(): - var v = locale.call(key) - if v != null and String(v) != "": - return String(v) - _ensure_locale_dict() - if _locale_dict.has(key): - return _locale_dict[key] - return "<" + key + ">" - -func _parse_dict() -> Dictionary: - var d := {} - _p += 1 # { - var guard := 0 - while _p < _s.length(): - guard += 1 - if guard > 4000: - last_error = "dict parse runaway" - break - _skip_ws() - if _p >= _s.length() or _s[_p] == "}": - _p += 1 - break - var before := _p - var key = _parse_atom() - _skip_ws() - if _p < _s.length() and _s[_p] == ":": - _p += 1 - var val = _parse_expr() - d[_key(key)] = val - elif _p == before: - _p += 1 # 没进展也没 ':',强制前进防死循环 - # 否则:孤立 token(相邻字符串续行残留等)——丢弃,继续 - _skip_ws() - return d - -func _key(k) -> String: - if k is String: - return k - if k is int or k is float: - return str(k) - return "_" - -# 统一处理 ( ) 和 [ ]:单元素且无尾逗号的 (x) 退化成 x,否则是数组。 -func _parse_seq(open: String, close: String): - _p += 1 # opening - var items := [] - var had_comma := false - var guard := 0 - while _p < _s.length(): - guard += 1 - if guard > 4000: - break - _skip_ws_no_comma() - if _p >= _s.length() or _s[_p] == close: - _p += 1 - break - if _s[_p] == ",": - had_comma = true - _p += 1 - continue - var before := _p - items.append(_parse_expr()) - if _p == before: - _p += 1 - if open == "(" and items.size() == 1 and not had_comma: - return items[0] - return items - -# --- coercion ---------------------------------------------------------- - -func _num(v) -> float: - if v is float: - return v - if v is int: - return float(v) - if v is bool: - return 1.0 if v else 0.0 - return 0.0 - -func _str_or(v) -> String: - if v is String: - return v - return str(v) diff --git a/project/ui/uiscript.gd.uid b/project/ui/uiscript.gd.uid deleted file mode 100644 index 87490e07..00000000 --- a/project/ui/uiscript.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://e2wkbja3qvh7 diff --git a/project/ui/view_equipment_ui.gd b/project/ui/view_equipment_ui.gd deleted file mode 100644 index a4f001b2..00000000 --- a/project/ui/view_equipment_ui.gd +++ /dev/null @@ -1,158 +0,0 @@ -# ViewEquipmentUI — GC_VIEW_EQUIP 的 40250 EquipmentDialog 复刻。 -# -# 参考:40250/root/uiequipmentdialog.py + uiscript/equipmentdialog.py。 -# 原版不是文字列表:固定 180x230 的装备槽底图承载 11 个装备位;鼠标进入 -# 一个非空槽位时,由同一 ItemToolTip.AddItemData 显示属性面板,离开立即隐藏。 -extends Node - -const UiAssets = preload("res://ui/ui_assets.gd") - -# 40250 EquipmentSlot:index, x, y, width, height(相对 equipment_bg)。 -const SLOT_RECTS := { - 0: Rect2(39, 37, 32, 64), 1: Rect2(39, 2, 32, 32), - 2: Rect2(39, 145, 32, 32), 3: Rect2(75, 67, 32, 32), - 4: Rect2(3, 3, 32, 96), 5: Rect2(114, 67, 32, 32), - 6: Rect2(114, 35, 32, 32), 7: Rect2(2, 145, 32, 32), - 8: Rect2(75, 145, 32, 32), 9: Rect2(114, 2, 32, 32), - 10: Rect2(75, 35, 32, 32), -} -const EQUIPMENT_BG := "d:/ymir work/ui/equipment_bg_without_ring.tga" - -var client: Node -var ui: CanvasLayer -var proto: Node -var item_list: RefCounted -var _window: Control -var _slots: Dictionary = {} # wear -> Panel - -func setup(m2client: Node, manager: CanvasLayer, proto_node: Node = null, il: RefCounted = null) -> void: - client = m2client - ui = manager - proto = proto_node - item_list = il - if client.has_signal("view_equipment"): - client.view_equipment.connect(show_for) - -func show_for(vid: int) -> void: - if client == null or not client.has_method("get_view_equipment"): - return - var equipment: Array = client.get_view_equipment(vid) - if equipment.is_empty(): - return - if is_instance_valid(_window): - _hide_item_tooltip() - ui.close(_window) - _window = _build_window(vid, equipment) - ui.open(_window) - -func _build_window(vid: int, equipment: Array) -> Control: - var win := Panel.new() - win.name = "ViewEquipment" - win.position = Vector2(870, 300) - win.size = Vector2(180, 230) - var bg := StyleBoxFlat.new() - bg.bg_color = Color(0.05, 0.06, 0.09, 0.94) - bg.border_color = Color(0.65, 0.5, 0.2, 0.9) - bg.set_border_width_all(1) - win.add_theme_stylebox_override("panel", bg) - - var title := Label.new() - title.position = Vector2(8, 4) - title.size = Vector2(142, 26) - title.text = "%s 的装备" % _character_name(vid) - title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - title.add_theme_font_size_override("font_size", 13) - win.add_child(title) - var close := Button.new() - close.text = "×" - close.position = Vector2(150, 3) - close.size = Vector2(25, 25) - close.pressed.connect(func(): - if is_instance_valid(_window): - _hide_item_tooltip() - ui.close(_window) - _window = null) - win.add_child(close) - - var equipment_bg := TextureRect.new() - equipment_bg.name = "EquipmentBaseImage" - equipment_bg.position = Vector2(15, 35) - equipment_bg.size = Vector2(150, 182) - equipment_bg.texture = UiAssets.load_tex(AssetRoot.path(), EQUIPMENT_BG) - equipment_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - equipment_bg.stretch_mode = TextureRect.STRETCH_SCALE - equipment_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE - win.add_child(equipment_bg) - - _slots.clear() - var by_wear := {} - for d: Dictionary in equipment: - by_wear[int(d.get("wear", -1))] = d - for wear in SLOT_RECTS: - var slot := _make_slot(int(wear), SLOT_RECTS[wear], by_wear.get(wear, {})) - equipment_bg.add_child(slot) - _slots[int(wear)] = slot - return win - -func _make_slot(wear: int, rect: Rect2, data: Dictionary) -> Panel: - var slot := Panel.new() - slot.name = "slot_%d" % wear - slot.position = rect.position + Vector2(3, 3) # 40250 EquipmentSlot 的偏移。 - slot.size = rect.size - slot.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - slot.set_meta("item", data.duplicate(true)) - var style := StyleBoxFlat.new() - style.bg_color = Color(0.03, 0.04, 0.06, 0.35) - style.border_color = Color(0.52, 0.44, 0.25, 0.70) - style.set_border_width_all(1) - slot.add_theme_stylebox_override("panel", style) - var vnum := int(data.get("vnum", 0)) - if vnum > 0: - var icon := _item_icon(vnum) - if icon: - var image := TextureRect.new() - image.texture = icon - image.set_anchors_preset(Control.PRESET_FULL_RECT) - image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - image.mouse_filter = Control.MOUSE_FILTER_IGNORE - slot.add_child(image) - var count := int(data.get("count", 1)) - if count > 1: - var count_label := Label.new() - count_label.text = str(count) - count_label.position = Vector2(1, rect.size.y - 15) - count_label.add_theme_font_size_override("font_size", 10) - count_label.mouse_filter = Control.MOUSE_FILTER_IGNORE - slot.add_child(count_label) - slot.mouse_entered.connect(func(): _on_slot_over_in(wear)) - slot.mouse_exited.connect(_hide_item_tooltip) - return slot - -func _on_slot_over_in(wear: int) -> void: - var slot: Panel = _slots.get(wear, null) - if slot == null: - return - var data: Dictionary = slot.get_meta("item", {}) - var vnum := int(data.get("vnum", 0)) - if vnum <= 0: - return - # 对应 EquipmentDialog.OverInItem:一个共享 ItemToolTip,先清旧内容再 AddItemData。 - var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} - if ui and ui.has_method("show_item_tooltip"): - ui.show_item_tooltip(vnum, int(data.get("count", 1)), proto_data, data, {}, AssetRoot.path(), proto, item_list) - -func _hide_item_tooltip() -> void: - if ui and ui.has_method("hide_item_tooltip"): - ui.hide_item_tooltip() - -func _item_icon(vnum: int) -> Texture2D: - var path: String = item_list.icon(vnum) if item_list and item_list.has(vnum) else "icon/item/%05d.tga" % ((vnum / 10) * 10) - return UiAssets.load_tex(AssetRoot.path(), path) - -func _character_name(vid: int) -> String: - if client.has_method("get_entity"): - var entity: Dictionary = client.get_entity(vid) - if not entity.is_empty() and not String(entity.get("name", "")).is_empty(): - return String(entity["name"]) - return "角色 #%d" % vid diff --git a/project/ui/view_equipment_ui.gd.uid b/project/ui/view_equipment_ui.gd.uid deleted file mode 100644 index 02fbb721..00000000 --- a/project/ui/view_equipment_ui.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b05l7vag5x0hd diff --git a/project/ui/weapon_trace.gd b/project/ui/weapon_trace.gd deleted file mode 100644 index 35fc96da..00000000 --- a/project/ui/weapon_trace.gd +++ /dev/null @@ -1,311 +0,0 @@ -# WeaponTrace —— 40250 GameLib/WeaponTrace.cpp 的 Godot 适配层。 -# -# 40250 的刀光不是一次性 MSE 特效:AttachWeapon 为可追踪武器创建对象, -# ActorInstance 每帧采样武器实例的刀根/刀尖世界坐标,RenderTrace 再把 -# short/long 两条时间轨迹用 cubic spline 拼成 triangle strip。这里保留同一 -# 状态边界和参数;渲染坐标在世界空间采样,在当前 PlayerView 局部渲染, -# 因此角色移动时已经生成的尾迹不会被父节点位移二次带走。 -extends Node3D - -const DEFAULT_LIFETIME := 0.18 -const DEFAULT_SAMPLING_TIME := 0.003 -const TRACE_COLOR := Color(0.3, 0.8, 1.0, 1.0) - -var lifetime := DEFAULT_LIFETIME -var sampling_time := DEFAULT_SAMPLING_TIME -var reach_scale := 1.0 # CActorInstance::__GetReachScale() -var use_texture := false -var texture_path := "" - -var _weapon: Node3D -var _playing := false -var _has_geometry := false -var _tip_local := Vector3.ZERO -var _short_points: Array = [] # newest first:刀根 -var _long_points: Array = [] # newest first:刀尖 -var _vertex_count := 0 - -var _mesh := ImmediateMesh.new() -var _mesh_instance := MeshInstance3D.new() -var _material := StandardMaterial3D.new() - -func _ready() -> void: - _ensure_render_node() - -func _ensure_render_node() -> void: - if _mesh_instance.get_parent() == null: - _mesh_instance.name = "WeaponTraceMesh" - _mesh_instance.mesh = _mesh - _mesh_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - add_child(_mesh_instance) - _material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - _material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - _material.blend_mode = BaseMaterial3D.BLEND_MODE_MIX - _material.cull_mode = BaseMaterial3D.CULL_DISABLED - _material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED - _material.vertex_color_use_as_albedo = true - _mesh_instance.material_override = _material - _mesh_instance.visible = false - -func attach_to_weapon(weapon_node: Node3D) -> bool: - if _weapon == weapon_node and is_instance_valid(_weapon): - return _has_geometry - turn_off() - clear() - _weapon = weapon_node - _has_geometry = false - _tip_local = Vector3.ZERO - if not is_instance_valid(_weapon): - return false - _has_geometry = _resolve_tip_local() - return _has_geometry - -func detach_weapon() -> void: - turn_off() - clear() - _weapon = null - _has_geometry = false - _tip_local = Vector3.ZERO - -func turn_on() -> void: - # 40250 TurnOn only changes the playing flag; the existing tail remains and - # naturally fades, so combo segments do not pop the trace between swings. - _playing = true - -func turn_off() -> void: - # 40250 TurnOff deliberately does not Clear(): old samples remain visible - # until lifetime expiry. Equipment replacement uses detach_weapon(), which - # is the DestroyWeaponTrace/Clear path instead. - _playing = false - -func set_lifetime(value: float) -> void: - lifetime = maxf(value, 0.001) - _trim_expired() - -func set_sampling_time(value: float) -> void: - sampling_time = maxf(value, 0.0001) - -func set_reach_scale(value: float) -> void: - # 40250 applies this scale when extending the trace from the bone matrix; - # recompute the cached local endpoint so an actor-scale change takes effect - # before the next sample. - reach_scale = maxf(value, 0.0) - if is_instance_valid(_weapon): - _has_geometry = _resolve_tip_local() - -func set_texture(path: String) -> void: - texture_path = path - use_texture = path != "" - _material.albedo_texture = _load_texture(path) if use_texture else null - -func use_alpha() -> void: - use_texture = false - _material.albedo_texture = null - -func use_texture_mode() -> void: - use_texture = true - _material.albedo_texture = _load_texture(texture_path) if texture_path != "" else null - -func is_playing() -> bool: - return _playing - -func debug_state() -> Dictionary: - return { - "playing": _playing, - "has_geometry": _has_geometry, - "short_points": _short_points.size(), - "long_points": _long_points.size(), - "vertex_count": _vertex_count, - "lifetime": lifetime, - "sampling_time": sampling_time, - "reach_scale": reach_scale, - "tip_local": _tip_local, - "tip_length": _tip_local.length(), - "use_texture": use_texture, - } - -func _process(delta: float) -> void: - _ensure_render_node() - var elapsed := maxf(delta, 0.0) - _age_points(elapsed) - if _playing and _has_geometry and is_instance_valid(_weapon): - _sample_weapon() - _render_trace() - -func _sample_weapon() -> void: - var root_world := _weapon.global_transform.origin - var tip_world := _weapon.global_transform * (_tip_local) - _short_points.push_front({"age": 0.0, "pos": root_world}) - _long_points.push_front({"age": 0.0, "pos": tip_world}) - _trim_expired() - -func _age_points(delta: float) -> void: - for sample in _short_points: - sample["age"] = float(sample.get("age", 0.0)) + delta - for sample in _long_points: - sample["age"] = float(sample.get("age", 0.0)) + delta - _trim_expired() - -func _trim_expired() -> void: - while not _short_points.is_empty() and float(_short_points.back().get("age", 0.0)) > lifetime: - _short_points.pop_back() - while not _long_points.is_empty() and float(_long_points.back().get("age", 0.0)) > lifetime: - _long_points.pop_back() - -func clear() -> void: - _short_points.clear() - _long_points.clear() - _vertex_count = 0 - if is_instance_valid(_mesh): - _mesh.clear_surfaces() - if is_instance_valid(_mesh_instance): - _mesh_instance.visible = false - -func _resolve_tip_local() -> bool: - # CWeaponTrace::SetWeaponInstance computes m_fLength from the maximum - # distance between the weapon bone origin and GetBoundBox() corners. It does - # not select that corner as the direction of the blade. Update() then moves - # along the composite bone's local Z axis by m_fLength * reach_scale. - var inverse := _weapon.global_transform.affine_inverse() - var farthest := 0.0 - var meshes: Array[MeshInstance3D] = [] - if _weapon is MeshInstance3D: - meshes.append(_weapon as MeshInstance3D) - for child in _weapon.find_children("*", "MeshInstance3D", true, false): - var mi := child as MeshInstance3D - if mi != null and not meshes.has(mi): - meshes.append(mi) - for mi in meshes: - if mi.mesh == null: - continue - var box := mi.get_aabb() - for corner in 8: - var local_point := box.position + Vector3( - box.size.x if (corner & 1) != 0 else 0.0, - box.size.y if (corner & 2) != 0 else 0.0, - box.size.z if (corner & 4) != 0 else 0.0) - var world_point: Vector3 = mi.global_transform * local_point - var weapon_local: Vector3 = inverse * world_point - var distance := weapon_local.length_squared() - if distance > farthest: - farthest = distance - if farthest <= 0.000001 or is_zero_approx(reach_scale): - return false - _tip_local = Vector3(0.0, 0.0, sqrt(farthest) * reach_scale) - return _tip_local.length_squared() > 0.000001 - -func _render_trace() -> void: - if _short_points.size() <= 1 or _long_points.size() <= 1: - _vertex_count = 0 - _mesh.clear_surfaces() - _mesh_instance.visible = false - return - var vertices := _build_vertices() - _vertex_count = vertices.size() - _mesh.clear_surfaces() - if vertices.size() < 4: - _mesh_instance.visible = false - return - _mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLE_STRIP, _material) - for vertex in vertices: - _mesh.surface_set_color(Color(TRACE_COLOR.r, TRACE_COLOR.g, TRACE_COLOR.b, - float(vertex.get("alpha", 0.0)))) - _mesh.surface_set_uv(vertex.get("uv", Vector2.ZERO)) - _mesh.surface_add_vertex(to_local(vertex.get("pos", Vector3.ZERO))) - _mesh.surface_end() - _mesh_instance.visible = true - -func _build_vertices() -> Array: - var length := minf(lifetime, float(_long_points.back().get("age", 0.0))) - var out: Array = [] - # Reference BuildVertex renders the long track first (UV y=0), then the - # short track (UV y=1), interleaving the pair into a triangle strip. - var long_vertices := _build_spline(_long_points, true, length) - var short_vertices := _build_spline(_short_points, false, length) - var count := mini(long_vertices.size(), short_vertices.size()) - for i in count: - out.append(long_vertices[i]) - out.append(short_vertices[i]) - return out - -func _build_spline(input: Array, is_long: bool, length: float) -> Array: - var out: Array = [] - var n := input.size() - 1 - if n <= 0: - return out - var h: Array = [] - var r: Array = [] - h.resize(n + 1) - r.resize(n + 1) - for i in n: - var dt := float(input[i + 1].get("age", 0.0)) - float(input[i].get("age", 0.0)) - if dt <= 0.000001: - return out - h[i] = dt - var p0: Vector3 = input[i].get("pos", Vector3.ZERO) - var p1: Vector3 = input[i + 1].get("pos", Vector3.ZERO) - r[i] = (p1 - p0) * (3.0 / dt) - r[n] = Vector3.ZERO - for i in range(n, 0, -1): - r[i] = r[i] + r[i - 1] - var stack: Array = [] - var rate := 0.5 - r[0] = r[0] * 0.5 - stack.append(rate) - for i in range(1, n): - r[i] = r[i] - r[i - 1] - rate = 1.0 / (4.0 - rate) - r[i] = r[i] * rate - stack.append(rate) - r[n] = r[n] - r[n - 1] - rate = 1.0 / (2.0 - rate) - r[n] = r[n] * rate - for i in range(n - 1, -1, -1): - r[i] = r[i] - float(stack.pop_back()) * r[i + 1] - - var base := 0 - var timebase := 0.0 - var timenext := float(h[0]) - var delta := maxf(sampling_time, 0.0001) - var a: Vector3 = input[0].get("pos", Vector3.ZERO) - var b: Vector3 = r[0] - var first_delta: Vector3 = input[1].get("pos", Vector3.ZERO) - a - var c: Vector3 = (3.0 * first_delta - r[1] * float(h[0]) - 2.0 * float(h[0]) * r[0]) \ - / (float(h[0]) * float(h[0])) - var d: Vector3 = (-2.0 * first_delta + (r[1] + r[0]) * float(h[0])) \ - / (float(h[0]) * float(h[0]) * float(h[0])) - var t := 0.0 - var life := maxf(lifetime, 0.001) - while t <= length + 0.000001: - while t > timenext and base < n: - timebase = timenext - base += 1 - if base >= n: - break - var seg_delta: Vector3 = input[base + 1].get("pos", Vector3.ZERO) - input[base].get("pos", Vector3.ZERO) - a = input[base].get("pos", Vector3.ZERO) - b = r[base] - c = (3.0 * seg_delta - r[base + 1] * float(h[base]) - - 2.0 * float(h[base]) * r[base]) / (float(h[base]) * float(h[base])) - d = (-2.0 * seg_delta + (r[base + 1] + r[base]) * float(h[base])) / \ - (float(h[base]) * float(h[base]) * float(h[base])) - timenext += float(h[base]) - if base >= n: - break - var cc := t - timebase - var position: Vector3 = a + cc * (b + cc * (c + cc * d)) - var ttt := clampf((t + float(input[0].get("age", 0.0))) / life, 0.0, 1.0) - var alpha := clampf((1.0 - ttt) * (1.0 - ttt) / 2.5 - 0.1, 0.0, 1.0) if is_long else 0.0 - out.append({"pos": position, "uv": Vector2(t / life, 0.0 if is_long else 1.0), "alpha": alpha}) - t += delta - return out - -func _load_texture(path: String) -> Texture2D: - if path == "": - return null - if ResourceLoader.exists(path): - return ResourceLoader.load(path) as Texture2D - if not FileAccess.file_exists(path): - return null - var image := Image.load_from_file(path) - return ImageTexture.create_from_image(image) if image != null and not image.is_empty() else null diff --git a/project/ui/weapon_trace.gd.uid b/project/ui/weapon_trace.gd.uid deleted file mode 100644 index bbddf81f..00000000 --- a/project/ui/weapon_trace.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cdnxbuud626a3 diff --git a/project/ui_kit.gd b/project/ui_kit.gd deleted file mode 100644 index 2021c55c..00000000 --- a/project/ui_kit.gd +++ /dev/null @@ -1,78 +0,0 @@ -# UiKit —— 复用 `ETC/ymir work/ui/pattern/` 九宫格贴图构造控件。 -# 从 hud.gd 抽出,charselect.gd / login.gd 共用(用 preload 引入)。PARITY §6.1。 -extends RefCounted - -const PAT := "ETC/ymir work/ui/pattern/" -static var _cache := {} - -static func ui_tex(assets: String, name: String) -> Texture2D: - var key := assets + "|" + name - if _cache.has(key): - return _cache[key] - var tex: Texture2D = null - var cand := assets.path_join(PAT + name + ".tga") - if not FileAccess.file_exists(cand): - var da := DirAccess.open(assets) - if da: - for sub in da.get_directories(): - var p := assets.path_join(sub).path_join("ymir work/ui/pattern/" + name + ".tga") - if FileAccess.file_exists(p): - cand = p - break - if FileAccess.file_exists(cand): - var img := Image.new() - if img.load(cand) == OK: - tex = ImageTexture.create_from_image(img) - _cache[key] = tex - return tex - -# 4 角 + 4 边 + *_base 合成 atlas -> NinePatchRect。prefix = board | thinboard -static func board(assets: String, prefix := "board", m := 32, base := 128) -> NinePatchRect: - if prefix == "thinboard": - m = 16 - base = 32 - var total := m + base + m - var atlas := Image.create_empty(total, total, false, Image.FORMAT_RGBA8) - atlas.fill(Color(0, 0, 0, 0)) - var put := func(nm: String, x: int, y: int) -> void: - var t := ui_tex(assets, nm) - if t == null: - return - var im := t.get_image() - im.convert(Image.FORMAT_RGBA8) - atlas.blit_rect(im, Rect2i(Vector2i.ZERO, im.get_size()), Vector2i(x, y)) - put.call(prefix + "_corner_lefttop", 0, 0) - put.call(prefix + "_corner_righttop", m + base, 0) - put.call(prefix + "_corner_leftbottom", 0, m + base) - put.call(prefix + "_corner_rightbottom", m + base, m + base) - var edge := func(nm: String, r: Rect2i) -> void: - var t := ui_tex(assets, nm) - if t == null: - return - var im := t.get_image() - im.convert(Image.FORMAT_RGBA8) - im.resize(r.size.x, r.size.y, Image.INTERPOLATE_BILINEAR) - atlas.blit_rect(im, Rect2i(Vector2i.ZERO, r.size), r.position) - edge.call(prefix + "_line_top", Rect2i(m, 0, base, m)) - edge.call(prefix + "_line_bottom", Rect2i(m, m + base, base, m)) - edge.call(prefix + "_line_left", Rect2i(0, m, m, base)) - edge.call(prefix + "_line_right", Rect2i(m + base, m, m, base)) - var b := ui_tex(assets, prefix + "_base") - if b: - var bi := b.get_image() - bi.convert(Image.FORMAT_RGBA8) - bi.resize(base, base, Image.INTERPOLATE_BILINEAR) - atlas.blit_rect(bi, Rect2i(Vector2i.ZERO, Vector2i(base, base)), Vector2i(m, m)) - elif prefix == "thinboard": - # 40250 ui.py: ThinBoard.BOARD_COLOR = grp.GenerateColor(0.0, 0.0, 0.0, 0.51) - # 官方 ThinBoard 中间无独立贴图,由 Base = Bar() 填充半透明黑色 (0, 0, 0, 0.51) - for by in range(m, m + base): - for bx in range(m, m + base): - atlas.set_pixel(bx, by, Color(0.0, 0.0, 0.0, 0.51)) - var np := NinePatchRect.new() - np.texture = ImageTexture.create_from_image(atlas) - np.patch_margin_left = m - np.patch_margin_right = m - np.patch_margin_top = m - np.patch_margin_bottom = m - return np diff --git a/project/ui_kit.gd.uid b/project/ui_kit.gd.uid deleted file mode 100644 index 5d045c02..00000000 --- a/project/ui_kit.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b2agrl1oqbb3n diff --git a/project/ui_test.gd b/project/ui_test.gd deleted file mode 100644 index 62e6a266..00000000 --- a/project/ui_test.gd +++ /dev/null @@ -1,174 +0,0 @@ -# ui_test —— P1 UI 工具层 headless 自检(解析器 + 构建器 + 窗口管理器)。 -# godot --headless --path project --script ui_test.gd -extends SceneTree - -const UiScript = preload("res://ui/uiscript.gd") -const UiBuild = preload("res://ui/ui_build.gd") -const UiManager = preload("res://ui/ui_manager.gd") -const Dialogs = preload("res://ui/dialogs.gd") - -const FIXTURE := """ -import uiScriptLocale -ROOT = "d:/ymir work/ui/public/" -PAD = 10 - -window = { - "name" : "TestWin", - "style" : ("float",), - "x" : SCREEN_WIDTH/2 - 250, - "y" : SCREEN_HEIGHT/2 - 40, - "width" : 280, - "height" : 105, - "children" : ( - { - "name" : "board", - "type" : "board_with_titlebar", - "x" : 0, "y" : 0, - "width" : 280, "height" : 105, - "title" : uiScriptLocale.HELLO, - "children" : ( - { - "name" : "message", - "type" : "text", - "x" : PAD, "y" : 38, - "text" : uiScriptLocale.MESSAGE, - "text_horizontal_align" : "center", - "text_color" : 0xffF8BF24, - }, - { - "name" : "accept", - "type" : "button", - "x" : 0, "y" : 63, - "width" : 61, "height" : 21, - "horizontal_align" : "center", - "text" : uiScriptLocale.OK, - "default_image" : ROOT + "middle_button_01.sub", - }, - ), - }, - ), -} -""" - -var _fail := 0 -func _ck(c: bool, m: String) -> void: - if not c: - _fail += 1 - printerr("FAIL: " + m) - -func _init() -> void: - _test_parser() - _test_builder() - _test_manager() - _test_dialogs() - _test_real_file() - if _fail == 0: - print("PASS: ui_test (parser + builder + manager)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _test_parser() -> void: - var us := UiScript.new() - us.screen = Vector2i(1920, 1080) - us.locale = func(k: String) -> String: - return {"HELLO": "Hi", "MESSAGE": "Body", "OK": "OK"}.get(k, "") - var w := us.parse_text(FIXTURE) - _ck(us.last_error == "", "parser: no error (%s)" % us.last_error) - _ck(w.get("name") == "TestWin", "parser: window name") - _ck(int(w.get("x")) == 710, "parser: arithmetic SCREEN_WIDTH/2-250 = 710, got %s" % w.get("x")) - _ck(int(w.get("width")) == 280, "parser: width") - var kids: Array = w.get("children", []) - _ck(kids.size() == 1 and kids[0].get("type") == "board_with_titlebar", "parser: child type") - _ck(kids[0].get("title") == "Hi", "parser: locale HELLO -> 'Hi'") - var inner: Array = kids[0].get("children", []) - _ck(inner.size() == 2, "parser: 2 grandchildren") - _ck(inner[0].get("text") == "Body", "parser: locale MESSAGE") - _ck(int(inner[0].get("text_color")) == 0xffF8BF24, "parser: hex color") - _ck(int(inner[1].get("x")) == 0 and inner[1].get("horizontal_align") == "center", - "parser: const PAD + align kept") - # 空输入 / 坏输入 - _ck(UiScript.new().parse_text("import x\n").is_empty(), "parser: no window -> {}") - -func _test_builder() -> void: - var us := UiScript.new() - us.screen = Vector2i(1920, 1080) - us.locale = func(k: String) -> String: return k - var spec := us.parse_text(FIXTURE) - var r := UiBuild.build(spec, "") # 无 assets -> 主题兜底 - var root: Control = r.root - _ck(root is Control, "builder: root is Control") - _ck(root.size == Vector2(280, 105), "builder: root size") - _ck(root.position == Vector2(710, 500), "builder: root pos, got %s" % root.position) - var nodes: Dictionary = r.nodes - _ck(nodes.has("accept") and nodes["accept"] is Button, "builder: accept is Button") - _ck(nodes["accept"].text == "OK", "builder: button text") - # horizontal_align center:x = (280 - 61)/2 + 0 = 109(父 board 宽 280) - _ck(nodes["accept"].position.x == 109, "builder: center align x = 109, got %s" % nodes["accept"].position.x) - _ck(nodes.has("message") and nodes["message"] is Label, "builder: message is Label") - _ck(nodes["message"].horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER, "builder: text align") - _ck(nodes["board"] is Panel, "builder: board is Panel") - _ck(nodes["board"].has_node("TitleText"), "builder: board_with_titlebar has title label") - -func _test_manager() -> void: - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() # ensure built (add_child 后一帧才 _ready,这里直接调) - var w1 := Control.new() - w1.custom_minimum_size = Vector2(100, 100) - var w2 := Control.new() - ui.open(w1) - ui.open(w2, true) # modal - _ck(ui.top() == w2, "manager: w2 on top") - _ck(ui._dim.visible, "manager: modal dim shown") - _ck(ui.close_top(), "manager: close_top ok") - _ck(ui.top() == w1, "manager: w1 now top") - _ck(not ui._dim.visible, "manager: dim hidden after modal closed") - _ck(not ui.close_top() == false, "manager: close last") - ui.close_top() - _ck(ui.top() == null, "manager: stack empty") - ui.queue_free() - -func _test_dialogs() -> void: - var ui: CanvasLayer = UiManager.new() - get_root().add_child(ui) - ui._ready() - var dlg := Dialogs.new(ui) - var got := [0] - dlg.confirm("delete?", func(): got[0] = 1, func(): got[0] = -1) - _ck(ui.top() != null, "dialogs: confirm opened") - _ck(ui._dim.visible, "dialogs: confirm is modal") - # 找到「确定」按钮点一下 - var ok_btn: Button = null - for n in ui.top().get_children(): - if n is Button and n.text == "确定": - ok_btn = n - _ck(ok_btn != null, "dialogs: 确定 button present") - if ok_btn: - ok_btn.pressed.emit() - _ck(got[0] == 1, "dialogs: on_ok fired") - _ck(ui.top() == null, "dialogs: closed after confirm") - ui.queue_free() - -func _test_real_file() -> void: - var p := AssetRoot.sub("uiscript/uiscript/popupdialog.py") - if not FileAccess.file_exists(p): - print(" (skip real-file test: %s not present)" % p) - return - var us := UiScript.new() - us.locale = func(k: String) -> String: return "L_" + k - var w := us.parse_file(p) - _ck(us.last_error == "", "real: popupdialog parsed (%s)" % us.last_error) - _ck(w.get("name") == "PopupDialog", "real: name == PopupDialog, got %s" % w.get("name")) - var kids: Array = w.get("children", []) - _ck(kids.size() >= 1 and kids[0].get("type") == "board", "real: first child is board") - var found_btn := false - for c in kids[0].get("children", []): - if c.get("type") == "button" and c.get("name") == "accept": - found_btn = true - _ck(String(c.get("default_image", "")).ends_with(".sub"), "real: button default_image is .sub") - _ck(found_btn, "real: found accept button") - # 构建也不炸 - var r := UiBuild.build(w, "") - _ck(r.root is Control and r.nodes.has("accept"), "real: build ok") diff --git a/project/ui_test.gd.uid b/project/ui_test.gd.uid deleted file mode 100644 index 9efe660f..00000000 --- a/project/ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://drcuav4wqayst diff --git a/project/view_equipment_ui_test.gd b/project/view_equipment_ui_test.gd deleted file mode 100644 index 3b34fad2..00000000 --- a/project/view_equipment_ui_test.gd +++ /dev/null @@ -1,65 +0,0 @@ -# 40250 EquipmentDialog:槽位布局与统一属性面板回归测试。 -extends SceneTree - -const ViewEquipmentUI = preload("res://ui/view_equipment_ui.gd") -const UiManager = preload("res://ui/ui_manager.gd") - -class MockClient extends Node: - var equipment := [] - func get_view_equipment(_vid: int) -> Array: return equipment - -class MockProto extends Node: - func item(vnum: int) -> Dictionary: - if vnum != 19: - return {} - return { - "vnum": 19, "locale_name": "战斗剑 +9", "type": 1, - "values": [0, 0, 0, 10, 20, 0], - } - -var failures := 0 - -func _check(ok: bool, message: String) -> void: - if ok: - print("PASS: " + message) - else: - failures += 1 - printerr("FAIL: " + message) - -func _init() -> void: - call_deferred("_run") - -func _run() -> void: - var ui := UiManager.new() - get_root().add_child(ui) - var client := MockClient.new() - client.equipment.resize(11) - for wear in 11: - client.equipment[wear] = {"wear": wear, "vnum": 0} - client.equipment[4] = {"wear": 4, "vnum": 19, "count": 1, - "sockets": [0, 0, 0], "attrs": [{"type": 5, "value": 12}]} - var view := ViewEquipmentUI.new() - get_root().add_child(view) - view.setup(client, ui, MockProto.new()) - view.show_for(1001) - await process_frame - - var dialog: Control = ui.find_child("ViewEquipment", true, false) - _check(dialog != null and dialog.size == Vector2(180, 230), "uses the 40250 180x230 dialog") - _check(view._slots.size() == 11, "creates all 11 legacy EquipmentSlot positions") - var weapon: Panel = view._slots.get(4) - _check(weapon != null and weapon.position == Vector2(6, 6) and weapon.size == Vector2(32, 96), - "weapon slot matches 40250 index 4 rectangle") - _check(weapon.tooltip_text.is_empty(), "slot has no native Godot tooltip panel") - view._on_slot_over_in(4) - await process_frame - var tooltip: Control = ui.get_item_tooltip_view(AssetRoot.path(), view.proto, null) - _check(tooltip.visible, "hover opens the shared 40250 ItemTooltipView") - _check(ui.find_children("ItemTooltipView", "", true, false).size() == 1, - "only one item attribute panel exists") - view._hide_item_tooltip() - _check(not tooltip.visible, "leaving the slot hides the shared attribute panel") - - if failures == 0: - print("ALL TESTS PASSED") - quit(failures) diff --git a/project/view_equipment_ui_test.gd.uid b/project/view_equipment_ui_test.gd.uid deleted file mode 100644 index 157be9aa..00000000 --- a/project/view_equipment_ui_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dnrccypslhl1i diff --git a/project/water_reference_test.gd b/project/water_reference_test.gd deleted file mode 100644 index 32ecb13f..00000000 --- a/project/water_reference_test.gd +++ /dev/null @@ -1,51 +0,0 @@ -extends SceneTree -var failures := 0 -func check(ok: bool, label: String) -> void: - if not ok: - failures += 1 - if failures < 6: printerr("FAIL: " + label) -func _init() -> void: - call_deferred("run") -func run() -> void: - var world = ClassDB.instantiate("Metin2World") - world.auto_load = false - world.objects_enabled = false - world.env_enabled = false - world.assets_root = AssetRoot.path() - world.map_path = "OutdoorA1/metin2_map_a1" - root.add_child(world) - check(world.load_map(), "real A1 loads") - var pieces := 0 - var vertices_checked := 0 - var near_shore := 0 - var boundary_vertices := 0 - for mesh in world.find_children("*", "MeshInstance3D", true, false): - if not String(mesh.name).begins_with("Water"): - continue - pieces += 1 - var material: ShaderMaterial = mesh.mesh.surface_get_material(0) - check(material.shader.code.contains("unshaded") and material.shader.code.contains("ALBEDO = tex;") - and material.shader.code.contains("ALPHA = COLOR.a;"), "water selects texture RGB / vertex alpha") - var arrays: Array = mesh.mesh.surface_get_arrays(0) - var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] - var colors: PackedColorArray = arrays[Mesh.ARRAY_COLOR] - for i in vertices.size(): - var v := vertices[i] - # sample_height selects a neighbouring chunk on the outer border; - # no such chunk exists there. The mesh still has its padded edge data. - if v.x >= 1024.0 or v.z >= 1280.0: - boundary_vertices += 1 - continue - var depth := (v.y - float(world.sample_height(v.x, v.z))) * 100.0 - var expected := clampf(depth / 200.0, 0.0, 0.8) # A1 HeightScale=0.5 - # ArrayMesh stores vertex colors as RGBA8, like legacy diffuse DWORD. - check(absf(colors[i].a - expected) < 1.0 / 255.0 + 0.0001, "real shoreline depth formula pos=%s actual=%s expected=%s" % [v, colors[i].a, expected]) - if colors[i].a < 0.12: near_shore += 1 - vertices_checked += 1 - for i in range(0, vertices.size(), 4): - check(absf(vertices[i + 1].x - vertices[i].x - 2.0) < 0.001, "retain every water cell's depth samples") - check(pieces > 0 and vertices_checked > 0 and near_shore > 0, "water and shallow shoreline exercised") - world.free() - await process_frame - print("water_reference_test: failures=%d pieces=%d vertices=%d shallow=%d boundary_skipped=%d (geometry/material, not 40250 pixels)" % [failures, pieces, vertices_checked, near_shore, boundary_vertices]) - quit(1 if failures else 0) diff --git a/project/water_reference_test.gd.uid b/project/water_reference_test.gd.uid deleted file mode 100644 index e4372a5b..00000000 --- a/project/water_reference_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://hpjr6orgucph diff --git a/project/weapon_attach_test.gd b/project/weapon_attach_test.gd deleted file mode 100644 index 4972ea0e..00000000 --- a/project/weapon_attach_test.gd +++ /dev/null @@ -1,165 +0,0 @@ -# weapon_attach_test —— 武器挂点按种族 + 左右手(真 PlayerView + 真 gr2)。 -# godot --headless --path project --script weapon_attach_test.gd -# -# 参考端 playersettingmodule.py RegisterAttachingBoneName: -# warrior PART_WEAPON=equip_right_hand (661) -# assassin PART_WEAPON=equip_right PART_WEAPON_LEFT=equip_left (873-874) -# sura PART_WEAPON=equip_right (999) -# shaman PART_WEAPON=equip_right PART_WEAPON_LEFT=equip_left (1190-1191) -# ActorInstanceAttach.cpp AttachWeapon:GetAttachingBoneName 失败(该职业没登记左手)就不挂。 -# PlayerView 的 shield_gr2 槽 = PART_WEAPON_LEFT(参考端没有盾牌挂件)。 -extends SceneTree - -const PlayerView = preload("res://ui/player_view.gd") - -const RIGHT_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"] -const LEFT_BONE := ["", "equip_left", "", "equip_left"] -const MAX_ORIGIN_M := 5.0 -const MAX_MESH_TO_HAND_M := 0.12 -# The hand can sit inside the empty volume between pommel/guard vertices; 03150 -# measures ~0.278 m to the nearest authored vertex while its transformed AABB -# crosses the hand. This still rejects the former metre-scale displacement. -const MAX_VERTEX_TO_HAND_M := 0.30 - -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: weapon_attach_test (race attaching bones + left/right hand)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - if not ClassDB.class_exists("Metin2Model"): - print(" (skip: no Metin2Model extension)") - return - var assets := AssetRoot.path() - var weapon_dir := assets.path_join("item/ymir work/item/weapon") - var sword := weapon_dir.path_join("00010.gr2") - var dagger := weapon_dir.path_join("04000.gr2") - var offset_sword := weapon_dir.path_join("03150.gr2") - if not FileAccess.file_exists(sword) or not FileAccess.file_exists(dagger) or not FileAccess.file_exists(offset_sword): - print(" (skip: weapon gr2 assets missing)") - return - await process_frame - for race in 8: - var job := race & 3 - var pv = PlayerView.new() - get_root().add_child(pv) - if not pv.build(assets, race): - print(" (skip race %d: body assets missing)" % race) - pv.free() - continue - pv.set("weapon_gr2", sword) - pv.set("shield_gr2", dagger) - for i in 3: - await process_frame - var right := pv.model.get_node_or_null("Weapon") as Node3D - _ck(right != null, "race %d: right-hand weapon attached (%s)" % [race, RIGHT_BONE[job]]) - if right: - _ck(_origin_bounded(pv, right, RIGHT_BONE[job]), "race %d: weapon transform remains bounded at %s" % [race, RIGHT_BONE[job]]) - _ck(_mesh_reaches_hand(pv, right as MeshInstance3D, RIGHT_BONE[job]), "race %d: weapon mesh grip reaches %s" % [race, RIGHT_BONE[job]]) - _ck(_mesh_vertex_near_hand(pv, right as MeshInstance3D, RIGHT_BONE[job]), "race %d: weapon geometry reaches %s" % [race, RIGHT_BONE[job]]) - var left := pv.model.get_node_or_null("Shield") as Node3D - if LEFT_BONE[job] == "": - _ck(left == null, "race %d: no PART_WEAPON_LEFT bone registered -> nothing on left hand" % race) - else: - _ck(left != null, "race %d: left-hand weapon attached (%s)" % [race, LEFT_BONE[job]]) - if left: - _ck(_origin_bounded(pv, left, LEFT_BONE[job]), "race %d: left weapon transform remains bounded at %s" % [race, LEFT_BONE[job]]) - _ck(_mesh_reaches_hand(pv, left as MeshInstance3D, LEFT_BONE[job]), "race %d: left weapon mesh grip reaches %s" % [race, LEFT_BONE[job]]) - _ck(_mesh_vertex_near_hand(pv, left as MeshInstance3D, LEFT_BONE[job]), "race %d: left weapon geometry reaches %s" % [race, LEFT_BONE[job]]) - - # 03150 is a warrior two-handed weapon. It remains attached to PART_WEAPON - # (right hand); MODE_TWOHAND_SWORD moves the left hand onto its grip. - if job == 0: - pv.set("shield_gr2", "") - pv.set("weapon_gr2", offset_sword) - pv.set_motion_mode(3) - for state in ["wait", "walk", "run"]: - pv.set_anim_state(state) - for i in 3: - await process_frame - var attached := pv.model.get_node_or_null("Weapon") as MeshInstance3D - _ck(attached != null, "race %d %s: 03150 attached" % [race, state]) - if attached: - _ck(_mesh_reaches_hand(pv, attached, RIGHT_BONE[job]), - "race %d %s: 03150 visible mesh reaches right hand" % [race, state]) - _ck(_mesh_vertex_near_hand(pv, attached, RIGHT_BONE[job]), - "race %d %s: 03150 grip geometry reaches right hand" % [race, state]) - var left_grip_tolerance := 0.40 if race == 4 else MAX_MESH_TO_HAND_M - _ck(_mesh_reaches_hand(pv, attached, "Bip01 L Hand", left_grip_tolerance), - "race %d %s: two-hand pose places left hand on 03150" % [race, state]) - pv.play_attack_motion(3, 14, 1.0) - for i in 3: - await process_frame - _ck(String(pv.anim.get("anim_path")).contains("/twohand_sword/combo_01.msa"), - "race %d: 03150 attack uses twohand_sword combo_01" % race) - pv.queue_free() - await process_frame - -func _hand_position(pv: Node, bone: String) -> Variant: - var pose: Dictionary = pv.anim.call("get_effect_bone_pose", bone) - if pose.is_empty(): - printerr(" bone %s has no pose" % bone) - return null - return (pv.model as Node3D).global_transform * (pose["transform"] as Transform3D).origin - -func _origin_bounded(pv: Node, node: Node3D, bone: String) -> bool: - var hand_v: Variant = _hand_position(pv, bone) - if hand_v == null: - return false - var hand: Vector3 = hand_v - var d := node.global_position.distance_to(hand) - if d > MAX_ORIGIN_M: - printerr(" %s is %.3f m from %s" % [node.name, d, bone]) - return d <= MAX_ORIGIN_M - -func _mesh_reaches_hand(pv: Node, node: MeshInstance3D, bone: String, - max_distance: float = MAX_MESH_TO_HAND_M) -> bool: - var hand_v: Variant = _hand_position(pv, bone) - if hand_v == null: - return false - var hand: Vector3 = hand_v - var local := node.get_aabb() - var world_box := AABB() - var have := false - for corner in 8: - var p := local.position + Vector3( - local.size.x if (corner & 1) != 0 else 0.0, - local.size.y if (corner & 2) != 0 else 0.0, - local.size.z if (corner & 4) != 0 else 0.0) - p = node.global_transform * p - world_box = world_box.expand(p) if have else AABB(p, Vector3.ZERO) - have = true - var nearest := Vector3( - clampf(hand.x, world_box.position.x, world_box.end.x), - clampf(hand.y, world_box.position.y, world_box.end.y), - clampf(hand.z, world_box.position.z, world_box.end.z)) - var d := hand.distance_to(nearest) - if d > max_distance: - printerr(" %s visible AABB is %.3f m from %s (box=%s)" % [node.name, d, bone, world_box]) - return d <= max_distance - -func _mesh_vertex_near_hand(pv: Node, node: MeshInstance3D, bone: String) -> bool: - var hand_v: Variant = _hand_position(pv, bone) - if hand_v == null or node.mesh == null: - return false - var hand: Vector3 = hand_v - var nearest := INF - for surface in node.mesh.get_surface_count(): - var arrays := node.mesh.surface_get_arrays(surface) - if arrays.size() <= Mesh.ARRAY_VERTEX or not arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array: - continue - for local_point in arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array: - nearest = minf(nearest, hand.distance_to(node.global_transform * local_point)) - if nearest > MAX_VERTEX_TO_HAND_M: - printerr(" %s nearest visible vertex is %.3f m from %s" % [node.name, nearest, bone]) - return nearest <= MAX_VERTEX_TO_HAND_M diff --git a/project/weapon_attach_test.gd.uid b/project/weapon_attach_test.gd.uid deleted file mode 100644 index 1aa16e58..00000000 --- a/project/weapon_attach_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://l4xxmduo82sb diff --git a/project/weapon_reach_scale_attack_parity_test.gd b/project/weapon_reach_scale_attack_parity_test.gd deleted file mode 100644 index 4b4276a0..00000000 --- a/project/weapon_reach_scale_attack_parity_test.gd +++ /dev/null @@ -1,50 +0,0 @@ -# weapon_reach_scale_attack_parity_test —— 40250 __NormalAttackProcess。 -# -# ActorInstanceCollisionDetection.cpp 将刀尖球从 last 到 current 的位移向量 -# 乘以 __GetReachScale();AFFECT_GEOMGYEONG=1.5 不只影响视觉刀光,也影响 -# 攻击命中位置。 -extends SceneTree - -const NetPlay = preload("res://net_play.gd") - -var _fail := 0 - -func _ck(condition: bool, message: String) -> void: - if not condition: - _fail += 1 - printerr("FAIL: " + message) - -func _run() -> void: - var np := NetPlay.new() - root.add_child(np) - var view := Node.new() - var script := GDScript.new() - script.source_code = """ -extends Node -func weapon_reach_scale() -> float: - return 1.5 -""" - script.reload() - view.set_script(script) - root.add_child(view) - np.player_view = view - - if not np.has_method("_attack_sample_positions"): - _ck(false, "attack sample path exposes the reach-scaled endpoint helper") - else: - var out: Dictionary = np._attack_sample_positions( - Vector3(0.0, 0.0, 0.0), Vector3(10.0, 0.0, 0.0), 0.0, Vector3.ZERO) - _ck(Vector3(out.get("last", Vector3.ZERO)).is_equal_approx(Vector3.ZERO), - "attack sweep keeps the last sample unchanged") - _ck(Vector3(out.get("pos", Vector3.ZERO)).is_equal_approx(Vector3(15.0, 0.0, 0.0)), - "AFFECT_GEOMGYEONG reach scale extends the attack sweep by 1.5") - - if _fail == 0: - print("PASS: weapon_reach_scale_attack_parity_test (attack sweep reach scale)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _init() -> void: - call_deferred("_run") diff --git a/project/weapon_reach_scale_attack_parity_test.gd.uid b/project/weapon_reach_scale_attack_parity_test.gd.uid deleted file mode 100644 index 648092ee..00000000 --- a/project/weapon_reach_scale_attack_parity_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://w0utni15x52s diff --git a/project/weapon_trace_endpoint_parity_test.gd b/project/weapon_trace_endpoint_parity_test.gd deleted file mode 100644 index b8ce587f..00000000 --- a/project/weapon_trace_endpoint_parity_test.gd +++ /dev/null @@ -1,52 +0,0 @@ -# weapon_trace_endpoint_parity_test —— 40250 CWeaponTrace::SetWeaponInstance/Update。 -# -# 参考端先按武器包围盒到骨骼原点的最大距离计算 m_fLength,Update 再沿 -# 武器局部 Z 轴追加 m_fLength * __GetReachScale();不是取某个 AABB 角点 -# 作为任意方向的刀尖。 -extends SceneTree - -const WeaponTrace = preload("res://ui/weapon_trace.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 host := Node3D.new() - root.add_child(host) - var weapon := MeshInstance3D.new() - var box := BoxMesh.new() - box.size = Vector3(0.2, 0.2, 1.6) - weapon.mesh = box - host.add_child(weapon) - - var trace: Node = WeaponTrace.new() - host.add_child(trace) - await process_frame - _ck(trace.attach_to_weapon(weapon), "weapon geometry resolves") - - var expected := sqrt(0.1 * 0.1 + 0.1 * 0.1 + 0.8 * 0.8) - _ck(is_zero_approx(float(trace._tip_local.x)), - "reference trace endpoint stays on the local Z axis") - _ck(is_zero_approx(float(trace._tip_local.y)), - "reference trace endpoint does not use an arbitrary AABB corner direction") - _ck(is_equal_approx(float(trace._tip_local.z), expected), - "reference endpoint uses max bound distance as m_fLength") - - trace.set_reach_scale(1.5) - var scaled_expected := expected * 1.5 - _ck(is_equal_approx(float(trace._tip_local.z), scaled_expected), - "__GetReachScale scales the local endpoint length") - - if _fail == 0: - print("PASS: weapon_trace_endpoint_parity_test (bone-axis length + reach scale)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/weapon_trace_endpoint_parity_test.gd.uid b/project/weapon_trace_endpoint_parity_test.gd.uid deleted file mode 100644 index 5d5c8870..00000000 --- a/project/weapon_trace_endpoint_parity_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dgb7d0cil801a diff --git a/project/weapon_trace_multi_part_parity_test.gd b/project/weapon_trace_multi_part_parity_test.gd deleted file mode 100644 index a0f6a487..00000000 --- a/project/weapon_trace_multi_part_parity_test.gd +++ /dev/null @@ -1,59 +0,0 @@ -# weapon_trace_multi_part_parity_test —— 40250 AttachWeapon 的多部件语义。 -# -# CActorInstance::AttachWeapon 会分别对 PART_WEAPON / PART_WEAPON_LEFT -# 创建 CWeaponTrace;匕首(以及骑马扇)可能同时有右手与左手部件。 -# 当前端不能只查找单个 Weapon 节点,否则第二条刀光会永久缺失。 -extends SceneTree - -const PlayerView = preload("res://ui/player_view.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 pv := PlayerView.new() - root.add_child(pv) - await process_frame - - # 用两个带几何体的挂点模拟 Metin2Model 的 Weapon / Shield 部件, - # 不依赖 native GR2 资产即可验证 AttachWeapon 的部件枚举。 - var model := Node3D.new() - model.name = "Metin2Model" - pv.add_child(model) - var weapon := MeshInstance3D.new() - weapon.name = "Weapon" - var weapon_box := BoxMesh.new() - weapon_box.size = Vector3(0.1, 0.1, 1.0) - weapon.mesh = weapon_box - model.add_child(weapon) - var shield := MeshInstance3D.new() - shield.name = "Shield" - var shield_box := BoxMesh.new() - shield_box.size = Vector3(0.1, 0.1, 1.0) - shield.mesh = shield_box - model.add_child(shield) - pv.model = model - pv._refresh_weapon_trace() - - var state: Dictionary = pv.weapon_trace_state() - _ck(int(state.get("count", 0)) == 2, - "Weapon and Shield parts each create a WeaponTrace") - - pv.set_weapon_trace_enabled(false) - await process_frame - _ck(int(pv.weapon_trace_state().get("count", 0)) == 0, - "disabling traces destroys every weapon-part trace") - - if _fail == 0: - print("PASS: weapon_trace_multi_part_parity_test (Weapon + Shield traces)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/weapon_trace_multi_part_parity_test.gd.uid b/project/weapon_trace_multi_part_parity_test.gd.uid deleted file mode 100644 index 3c2dc8a0..00000000 --- a/project/weapon_trace_multi_part_parity_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ck1owxw8wpcee diff --git a/project/weapon_trace_reach_affect_parity_test.gd b/project/weapon_trace_reach_affect_parity_test.gd deleted file mode 100644 index e64f5b0a..00000000 --- a/project/weapon_trace_reach_affect_parity_test.gd +++ /dev/null @@ -1,51 +0,0 @@ -# weapon_trace_reach_affect_parity_test —— 40250 AFFECT_GEOMGYEONG。 -# -# InstanceBaseEffect.cpp::__Warrior_SetGeomgyeongAffect 在开启铁剑气时把 -# CActorInstance::SetReachScale 设为 1.5,关闭时恢复 1.0;该值同时被 -# WeaponTrace::Update 与攻击碰撞使用。 -extends SceneTree - -const PlayerView = preload("res://ui/player_view.gd") - -var _fail := 0 - -func _ck(condition: bool, message: String) -> void: - if not condition: - _fail += 1 - printerr("FAIL: " + message) - -func _run() -> void: - var pv := PlayerView.new() - root.add_child(pv) - await process_frame - - var model := Node3D.new() - var weapon := MeshInstance3D.new() - weapon.name = "Weapon" - var box := BoxMesh.new() - box.size = Vector3(0.1, 0.1, 1.0) - weapon.mesh = box - model.add_child(weapon) - pv.add_child(model) - pv.model = model - pv._refresh_weapon_trace() - - pv.set_affect(15, true, null) - var enabled: Dictionary = pv.weapon_trace_state() - _ck(is_equal_approx(float(enabled.get("reach_scale", 0.0)), 1.5), - "AFFECT_GEOMGYEONG sets WeaponTrace reach scale to 1.5") - - pv.set_affect(15, false, null) - var disabled: Dictionary = pv.weapon_trace_state() - _ck(is_equal_approx(float(disabled.get("reach_scale", 0.0)), 1.0), - "removing AFFECT_GEOMGYEONG restores WeaponTrace reach scale to 1.0") - - if _fail == 0: - print("PASS: weapon_trace_reach_affect_parity_test (Geomgyeong reach scale)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _init() -> void: - call_deferred("_run") diff --git a/project/weapon_trace_reach_affect_parity_test.gd.uid b/project/weapon_trace_reach_affect_parity_test.gd.uid deleted file mode 100644 index da521fdb..00000000 --- a/project/weapon_trace_reach_affect_parity_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://3ixj6ewp0g3x diff --git a/project/weapon_trace_test.gd b/project/weapon_trace_test.gd deleted file mode 100644 index b699eafe..00000000 --- a/project/weapon_trace_test.gd +++ /dev/null @@ -1,103 +0,0 @@ -# weapon_trace_test —— 40250 WeaponTrace 的轨迹/生命周期/接入回归。 -# godot --headless --path project --script weapon_trace_test.gd -extends SceneTree - -const WeaponTrace = preload("res://ui/weapon_trace.gd") -const PlayerView = preload("res://ui/player_view.gd") - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - await _run() - if _fail == 0: - print("PASS: weapon_trace_test") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) - -func _run() -> void: - await _test_trace_lifecycle() - await _test_player_view_integration() - -func _test_trace_lifecycle() -> void: - var host := Node3D.new() - get_root().add_child(host) - var weapon := MeshInstance3D.new() - var box := BoxMesh.new() - box.size = Vector3(0.12, 0.12, 1.6) - weapon.mesh = box - host.add_child(weapon) - var trace: Node = WeaponTrace.new() - host.add_child(trace) - await process_frame - _ck(trace.attach_to_weapon(weapon), "box weapon resolves a trace tip") - trace.turn_on() - for i in 6: - weapon.position = Vector3(float(i) * 0.05, 0.0, 0.0) - trace._process(0.016) - var active: Dictionary = trace.debug_state() - _ck(active.get("playing", false), "TurnOn keeps trace active") - _ck(int(active.get("short_points", 0)) >= 2 and int(active.get("long_points", 0)) >= 2, - "Update samples both short and long world tracks") - _ck(int(active.get("vertex_count", 0)) >= 4, "BuildVertex produces a triangle-strip vertex band") - trace.turn_off() - var before_fade: Dictionary = trace.debug_state() - trace._process(0.05) - var during_fade: Dictionary = trace.debug_state() - _ck(not during_fade.get("playing", true), "TurnOff stops sampling") - _ck(int(during_fade.get("long_points", 0)) > 0 and - int(during_fade.get("long_points", 0)) <= int(before_fade.get("long_points", 0)), - "TurnOff preserves the tail for lifetime fade") - trace._process(0.20) - var expired: Dictionary = trace.debug_state() - _ck(int(expired.get("short_points", 0)) == 0 and int(expired.get("long_points", 0)) == 0, - "expired trace samples are removed") - trace.attach_to_weapon(weapon) - var replaced: Dictionary = trace.debug_state() - _ck(int(replaced.get("short_points", 0)) == 0 and int(replaced.get("long_points", 0)) == 0, - "re-attaching a weapon clears the old actor trace") - trace.free() - host.free() - await process_frame - -func _test_player_view_integration() -> void: - if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2AnimPlayer"): - print(" (skip PlayerView integration: animation extension unavailable)") - return - var assets := AssetRoot.path() - var weapon_path := assets.path_join("item/ymir work/item/weapon/00010.gr2") - if not FileAccess.file_exists(weapon_path): - print(" (skip PlayerView integration: weapon asset unavailable)") - return - var pv: Node = PlayerView.new() - get_root().add_child(pv) - _ck(pv.build(assets, 0), "PlayerView builds for WeaponTrace integration") - pv.set("weapon_gr2", weapon_path) - for i in 4: - await process_frame - var attached: Dictionary = pv.weapon_trace_state() - _ck(attached.get("present", false), "ordinary sword creates PlayerView WeaponTrace") - pv.play_attack_motion(2, 14, 1.0) - var attacking: Dictionary = pv.weapon_trace_state() - _ck(attacking.get("playing", false), "attack motion turns WeaponTrace on") - # playback_finished is the 40250 motion-queue boundary; invoke the same - # callback deterministically instead of trying to interrupt an active once - # motion with a loop request (PlayerView correctly defers that request). - pv._on_playback_finished() - var hidden: Dictionary = pv.weapon_trace_state() - _ck(not hidden.get("playing", true), "new non-attack motion turns WeaponTrace off") - pv.set_weapon_trace_enabled(false) - await process_frame - _ck(not pv.weapon_trace_state().get("present", true), "weapon type gate can destroy the trace") - pv.free() - if ClassDB.class_exists("Metin2Model"): - Metin2Model.clear_texture_cache() - if ClassDB.class_exists("Metin2AnimPlayer"): - Metin2AnimPlayer.clear_clip_cache() - await process_frame diff --git a/project/weapon_trace_test.gd.uid b/project/weapon_trace_test.gd.uid deleted file mode 100644 index acd3665f..00000000 --- a/project/weapon_trace_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bv2nvs8jg4bxl diff --git a/project/whisper_chat_system.gd b/project/whisper_chat_system.gd deleted file mode 100644 index 90ea2909..00000000 --- a/project/whisper_chat_system.gd +++ /dev/null @@ -1,170 +0,0 @@ -# whisper_chat_system.gd —— 40250 独立多标签私聊与任务栏闪烁通知系统 1:1 -# 严格对照: -# Client/Eternexus/root/uiwhisper.py (WhisperDialog, WhisperButton) -# ClientVS22/source/UserInterface/PythonChat.cpp (RenderWhisper, WhisperMode) -# ClientVS22/source/UserInterface/PythonPlayer.cpp (ToggleIgnoreTarget, IsIgnoreTarget) -extends RefCounted - -const COLOR_NORMAL := "#FFFFFF" -const COLOR_GM := "#FFE700" # 官方 GM 专属亮金黄色 -const COLOR_SYSTEM := "#00FFFF" - -enum DialogState { - VISIBLE, # 展开在前台 - MINIMIZED, # 最小化到底部任务栏 - CLOSED # 完全关闭 -} - -class WhisperMessage: - var sender: String - var content: String - var timestamp: int - var is_gm: bool = false - - func _init(p_sender: String, p_content: String, p_time: int, p_is_gm: bool = false) -> void: - sender = p_sender - content = p_content - timestamp = p_time - is_gm = p_is_gm - -class WhisperConversation: - var target_name: String - var is_gamemaster: bool = false - var state: int = DialogState.VISIBLE - var is_blinking: bool = false # 任务栏闪烁标识 - var unread_count: int = 0 - var history: Array = [] # Array of WhisperMessage - - func _init(p_target: String, p_is_gm: bool = false) -> void: - target_name = p_target - is_gamemaster = p_is_gm - state = DialogState.VISIBLE - is_blinking = false - unread_count = 0 - history = [] - -var main_player_name: String = "Hero" -var conversations: Dictionary = {} # target_name -> WhisperConversation -var ignored_targets: Dictionary = {} # target_name -> bool -var current_time: int = 1000 - -func _init(player_name: String = "Hero") -> void: - main_player_name = player_name - conversations.clear() - ignored_targets.clear() - current_time = 1000 - -# 1. 打开或激活与某人的私聊窗口 (uiwhisper.py: OpenWhisperDialog) -func open_conversation(target_name: String) -> WhisperConversation: - if target_name.is_empty(): - return null - - var conv: WhisperConversation = null - if conversations.has(target_name): - conv = conversations[target_name] - conv.state = DialogState.VISIBLE - conv.is_blinking = false - conv.unread_count = 0 - else: - var is_gm: bool = target_name.begins_with("[GM]") - conv = WhisperConversation.new(target_name, is_gm) - conversations[target_name] = conv - - return conv - -# 2. 最小化私聊窗口到任务栏 (uiwhisper.py: Minimize) -func minimize_conversation(target_name: String) -> bool: - if not conversations.has(target_name): - return false - conversations[target_name].state = DialogState.MINIMIZED - return true - -# 3. 关闭私聊窗口 (uiwhisper.py: Close) -func close_conversation(target_name: String) -> bool: - if not conversations.has(target_name): - return false - conversations[target_name].state = DialogState.CLOSED - return true - -# 4. 收到私聊消息 (1:1 PythonChat::AppendWhisper) -func receive_whisper(sender_name: String, text: String, is_gm_override: bool = false) -> Dictionary: - var res := { - "delivered": false, - "blocked_by_ignore": false, - "triggered_blink": false, - "conversation": null, - "error_code": "OK" - } - - # 检查黑名单免打扰 (ToggleIgnoreTarget / IsIgnoreTarget) - if is_ignored(sender_name): - res["blocked_by_ignore"] = true - res["error_code"] = "BLOCKED_BY_IGNORE" - return res - - var is_gm: bool = is_gm_override or sender_name.begins_with("[GM]") - - # 获取或创建会话 - var conv: WhisperConversation = null - if conversations.has(sender_name): - conv = conversations[sender_name] - else: - conv = WhisperConversation.new(sender_name, is_gm) - conv.state = DialogState.MINIMIZED # 新收到的私聊若未预先开启则最小化并在任务栏闪烁 - conversations[sender_name] = conv - - var msg = WhisperMessage.new(sender_name, text, current_time, is_gm) - conv.history.append(msg) - - # 若处于最小化或后台,激活任务栏闪烁与未读计数 - if conv.state == DialogState.MINIMIZED or conv.state == DialogState.CLOSED: - conv.is_blinking = true - conv.unread_count += 1 - res["triggered_blink"] = true - - res["delivered"] = true - res["conversation"] = conv - return res - -# 5. 发送私聊消息 -func send_whisper(target_name: String, text: String) -> Dictionary: - var res := { "success": false, "error_code": "OK", "message": "" } - - if target_name.is_empty(): - res["error_code"] = "EMPTY_TARGET" - return res - - if text.strip_edges().is_empty(): - res["error_code"] = "EMPTY_MESSAGE" - return res - - var conv = open_conversation(target_name) - var msg = WhisperMessage.new(main_player_name, text, current_time, false) - conv.history.append(msg) - - res["success"] = true - return res - -# 6. 拉黑/解除拉黑目标 (1:1 player.ToggleIgnoreTarget) -func toggle_ignore_target(target_name: String) -> bool: - if target_name.is_empty(): - return false - - if ignored_targets.has(target_name): - ignored_targets.erase(target_name) - return false # 现在未拉黑 - else: - ignored_targets[target_name] = true - return true # 现在已拉黑 - -func is_ignored(target_name: String) -> bool: - return ignored_targets.get(target_name, false) - -# 7. 获取当前所有在任务栏闪烁的会话列表 -func get_blinking_conversations() -> Array: - var list: Array = [] - for k in conversations: - var c: WhisperConversation = conversations[k] - if c.is_blinking: - list.append(c) - return list diff --git a/project/whisper_chat_system.gd.uid b/project/whisper_chat_system.gd.uid deleted file mode 100644 index 998b0234..00000000 --- a/project/whisper_chat_system.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cajspl0p0g56c diff --git a/project/world/world_time.gd b/project/world/world_time.gd deleted file mode 100644 index 7fe3c971..00000000 --- a/project/world/world_time.gd +++ /dev/null @@ -1,77 +0,0 @@ -# WorldTime (P9) —— 昼夜循环:GC_TIME 的服务器时钟 → 太阳角度 + 光色 + 环境色。 -# -# var wt := preload("res://world/world_time.gd").new() -# add_child(wt) -# wt.setup(m2client, sun_light, environment) # environment 可空 -# -# GC_TIME 给的是 unix 秒。本地按 delta 累加,得到「日内秒」(0..86400)。 -# 06:00 日出、12:00 正午、18:00 日落、0:00 午夜。太阳 pitch 随之走。 -extends Node - -const DAY := 86400.0 -const CYCLE_SCALE := 1.0 # >1 加速昼夜(调试用) - -var client: Node -var sun: DirectionalLight3D -var env: Environment -var _epoch := 0.0 # 最近一次 GC_TIME 的服务器 unix 秒 -var _since := 0.0 # 收到后本地经过的秒 -var _have_time := false - -func setup(m2client: Node, sun_light: DirectionalLight3D, environment: Environment = null) -> void: - client = m2client - sun = sun_light - env = environment - if client and client.has_signal("time_changed"): - client.time_changed.connect(_on_time) - if client and client.has_method("get_server_time"): - var t: int = client.get_server_time() - if t > 0: - _on_time(t) - -func set_lighting(sun_light: DirectionalLight3D, environment: Environment = null) -> void: - sun = sun_light - env = environment - -func _on_time(server_epoch: int) -> void: - _epoch = float(server_epoch) - _since = 0.0 - _have_time = true - -# 当前日内秒(0..86400)。没有 GC_TIME 时固定用「正午」——不要跟着玩家的系统钟 -# 走到半夜把整个场景变黑(那不是 m2dev-client-main 的行为)。 -const DEFAULT_NOON := 43200.0 - -func seconds_of_day() -> float: - if not _have_time: - return DEFAULT_NOON - return fmod((_epoch + _since) * CYCLE_SCALE, DAY) - -# 0 = 午夜, 0.25 = 日出, 0.5 = 正午, 0.75 = 日落 -func day_fraction() -> float: - return seconds_of_day() / DAY - -func _process(delta: float) -> void: - _since += delta - if sun == null: - return - var f := day_fraction() - # 太阳高度角:正午(0.5)最高 ~ -85°(朝下),午夜最低 ~ +85°(朝上,被地平线挡) - var elevation := sin((f - 0.25) * TAU) * 80.0 # -80..80 - var azimuth := lerpf(40.0, 220.0, f) - sun.rotation = Vector3(deg_to_rad(-elevation), deg_to_rad(azimuth), 0.0) - # 光照强度 & 颜色:白天暖白,黄昏偏橙,夜里近黑冷蓝 - var daylight := clampf(sin((f - 0.25) * TAU) * 1.4 + 0.3, 0.0, 1.0) - sun.light_energy = lerpf(0.03, 1.15, daylight) - var warm := Color(1.0, 0.55, 0.30) - var noon := Color(1.0, 0.97, 0.90) - var night := Color(0.35, 0.42, 0.65) - if daylight < 0.35: - sun.light_color = night.lerp(warm, daylight / 0.35) - else: - sun.light_color = warm.lerp(noon, (daylight - 0.35) / 0.65) - if env: - env.ambient_light_energy = lerpf(0.12, 0.6, daylight) - env.ambient_light_color = Color(0.16, 0.19, 0.30).lerp(Color(0.5, 0.55, 0.62), daylight) - if env.background_mode == Environment.BG_COLOR: - env.background_color = Color(0.03, 0.04, 0.09).lerp(Color(0.55, 0.62, 0.72), daylight) diff --git a/project/world/world_time.gd.uid b/project/world/world_time.gd.uid deleted file mode 100644 index 464ee7c4..00000000 --- a/project/world/world_time.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bgp1ppw42yo7 diff --git a/project/world_demo.gd b/project/world_demo.gd deleted file mode 100644 index 0960f9f3..00000000 --- a/project/world_demo.gd +++ /dev/null @@ -1,169 +0,0 @@ -# W6 —— 玩家 / NPC / 第三人称相机。SHINSOO §9-W6。 -# 交互:godot --path project --script world_demo.gd -- --play -# 左键点地走过去 / 左键点 NPC 选中 · WASD 直接走 · 右键拖拽转视角 · 滚轮缩放 · Shift 跑 -# 截图:默认(无 --play)跑脚本化「前进」并存 test/golden/world-a1-r2.png -extends SceneTree - -const GameCamera = preload("res://game_camera.gd") -const PlayerCtl = preload("res://player_controller.gd") - -var _world: Node -var _player: Node3D -var _model: Node3D -var _anim: Node -var _npc_anim: Node -var _npc: Node3D -var _cam: Camera3D -var _pc: Node # PlayerController -var _target: Node3D # 当前选中实体 -var _anim_state := "" -var _audio: Node -var _assets := "" -var _t := 0.0 -var _shot_mode := false -var _shot_done := false - -func _init() -> void: - _assets = AssetRoot.path() - _shot_mode = "--play" not in OS.get_cmdline_user_args() # 默认脚本化截图;`-- --play` 进交互 - - _world = ClassDB.instantiate("Metin2World") - _world.set("assets_root", _assets) - _world.set("map_path", "OutdoorA1/metin2_map_a1") - _world.set("auto_load", false) - _world.set("load_radius_tiles", 1) # W8: 3×3 流式 - _world.set("stream_budget", 3) - get_root().add_child(_world) - var ok: bool = _world.call("load_map") - print("load ok=", ok, " ", _world.call("get_load_report")) - - # 出生点:村庄一带(全局 cm) - var spawn_cm := Vector3(45000.0, -38000.0, 17800.0) - var gx := spawn_cm.x * 0.01 - var gz := -spawn_cm.y * 0.01 - var gy: float = _world.call("sample_height", gx, gz) - _world.call("set_focus_position", gx, gz) # 流式对准出生点 - - _player = Node3D.new() - _player.name = "Player" - _player.position = Vector3(gx, gy, gz) - get_root().add_child(_player) - - _model = ClassDB.instantiate("Metin2Model") - _model.name = "Metin2Model" - _model.set("texture_dir", _assets.path_join("PC/ymir work/pc/warrior")) - _model.set("gr2_path", _assets.path_join("PC/ymir work/pc/warrior/warrior_cheongrin.gr2")) - _model.set("hair_gr2", _assets.path_join("PC/ymir work/pc/warrior/hair/hair_1_1.gr2")) - # SourceSkin(hair_1_1.dds) -> TargetSkin 换色(PARITY §2.4) - _model.set("hair_skin", _assets.path_join("PC/ymir work/pc/warrior/warrior_hair_01_red.dds")) - # 一手剑挂 equip_right_hand(PARITY §2.1;无物品系统,demo 写死) - _model.set("weapon_gr2", _assets.path_join("item/ymir work/item/weapon/00040.gr2")) - _player.add_child(_model) - - _audio = preload("res://audio.gd").new() - get_root().add_child(_audio) - _audio.setup(_assets) - - if ClassDB.class_exists("Metin2AnimPlayer"): - _anim = ClassDB.instantiate("Metin2AnimPlayer") - _anim.set("model_path", NodePath("../Metin2Model")) - _anim.set("loop", true) - _player.add_child(_anim) - # .msa 动作事件里的声音(脚步 / 挥击等)-> 3D 定位播放 - _anim.connect("motion_event", func(_type, _effect, sound, _pos): - if _audio and sound != "": - _audio.play_at(sound, _model.global_position, _player)) - _set_anim("wait") - - _spawn_npc(Vector3(gx + 6.0, 0.0, gz + 4.0)) - - var hud := preload("res://hud.gd").new() - get_root().add_child(hud) - hud.setup(_world, _player) - - _cam = GameCamera.new() - _cam.target = _player - _cam.world = _world - get_root().add_child(_cam) - _cam.make_current() - - _pc = PlayerCtl.new() - _pc.player = _player - _pc.camera = _cam - _pc.world = _world - _pc.force_run = _shot_mode - get_root().add_child(_pc) - _pc.anim_state.connect(_set_anim) - _pc.moved.connect(func(p): _world.call("set_focus_position", p.x, p.z)) - - # F5:后台暂停游戏树 + 停 BGM + 降帧;前台还原(截图模式下不挂,免打断脚本) - if not _shot_mode: - var life := preload("res://app_lifecycle.gd").new() - get_root().add_child(life) - life.bind(null, _audio) - _pc.target_selected.connect(func(n): - _target = n - print("[target] ", n.name, " @ ", n.global_position)) - if _npc: - _pc.pickables.append(_npc) - -func _spawn_npc(pos: Vector3) -> void: - var npc := Node3D.new() - npc.name = "NPC" - npc.position = Vector3(pos.x, _world.call("sample_height", pos.x, pos.z), pos.z) - npc.rotation.y = PI - get_root().add_child(npc) - _npc = npc - var m: Node3D = ClassDB.instantiate("Metin2Model") - m.name = "Metin2Model" - m.set("texture_dir", _assets.path_join("PC/ymir work/pc/shaman")) - m.set("gr2_path", _assets.path_join("PC/ymir work/pc/shaman/shaman_lord.gr2")) - npc.add_child(m) - var tag := Label3D.new() - tag.text = "村民" - tag.position = Vector3(0, 2.2, 0) - tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED - tag.no_depth_test = false - tag.pixel_size = 0.012 - npc.add_child(tag) - if ClassDB.class_exists("Metin2AnimPlayer"): - _npc_anim = ClassDB.instantiate("Metin2AnimPlayer") - _npc_anim.set("model_path", NodePath("../Metin2Model")) - _npc_anim.set("loop", true) - _npc_anim.set("anim_path", _assets.path_join("PC/ymir work/pc/shaman/general/wait.msa")) - npc.add_child(_npc_anim) - -func _set_anim(state: String) -> void: - if state == _anim_state or _anim == null: - return - _anim_state = state - var map := {"wait": "general/wait.msa", "walk": "general/walk.msa", "run": "general/run.msa"} - var f: String = map[state] - _anim.set("anim_path", _assets.path_join("PC/ymir work/pc/warrior/" + f)) - -func _process(dt: float) -> bool: - _t += dt - if int(_t * 2.0) != int((_t - dt) * 2.0): - print("t=%.1f player=%s anim=%s perf=%s" % [_t, _player.position, _anim_state, _world.call("get_perf")]) - - # 脚本化截图:走向一个前方点(展示点地移动 + 相机跟随),再选中 NPC - if _shot_mode: - if _t > 2.4 and _t < 2.5: - var ahead := _player.position - _player.global_transform.basis.z * 60.0 - _pc.call("walk_to", Vector3(ahead.x, 0, ahead.z)) - if _t > 6.0 and _t < 6.1 and _npc: - _pc.target_selected.emit(_npc) - - if _shot_mode and _t > 7.0 and not _shot_done: - _shot_done = true - var img := get_root().get_texture().get_image() - var p := ProjectSettings.globalize_path("res://../test/golden/world-a1-r2.png") - DirAccess.make_dir_recursive_absolute(p.get_base_dir()) - img.save_png(p) - print("saved world-a1-r2.png ", img.get_size(), " player=", _player.position, - " anim=", _anim_state, " target=", (_target.name if _target else "none")) - quit(0) - return false - -func _finalize() -> void: - pass diff --git a/project/world_demo.gd.uid b/project/world_demo.gd.uid deleted file mode 100644 index d92b5009..00000000 --- a/project/world_demo.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://3lfvqnomofcj diff --git a/project/world_probe.gd b/project/world_probe.gd deleted file mode 100644 index e5aa30bf..00000000 --- a/project/world_probe.gd +++ /dev/null @@ -1,76 +0,0 @@ -# W1/W2 headless 冒烟:加载 A1,打印 load report + sample_height + 顶视截图。 -# godot --headless --path project --script world_probe.gd -extends SceneTree - -func _init() -> void: - var assets: String = AssetRoot.path() - var w: Node = ClassDB.instantiate("Metin2World") - if w == null: - push_error("Metin2World not registered") - quit(1) - return - w.set("assets_root", assets) - w.set("map_path", "OutdoorA1/metin2_map_a1") - w.set("auto_load", false) - get_root().add_child(w) - - var ok: bool = w.call("load_map") - var rep: Dictionary = w.call("get_load_report") - print("load_map ok=", ok) - for k in rep: - print(" ", k, " = ", rep[k]) - - var terr: int = 0 - # Terrain_* now lives below Chunk_* roots so unloading can free a whole - # streamed chunk. Count recursively; direct-child counting reports a false 0. - for c in w.find_children("Terrain_*", "MeshInstance3D", true, false): - terr += 1 - print("terrain MeshInstance3D children: ", terr) - - var h1: float = w.call("sample_height", 128.0, 128.0) - var h_out: float = w.call("sample_height", -50.0, -50.0) - print("sample_height(128,128) = ", h1, " off-map = ", h_out) - - # 顶视正交截图(W2 与 minimap 对齐用) - _shot(w, rep) - - var expected_chunks: int = int(rep["map_size_x"]) * int(rep["map_size_y"]) - var patch_side := maxi(1, int(w.get("terrain_patches"))) - var expect: int = expected_chunks * patch_side * patch_side - var pass_ok: bool = ok and terr == expect and int(rep["chunks_failed"]) == 0 \ - and h1 != 0.0 and h_out == 0.0 - print("RESULT: ", "PASS" if pass_ok else "FAIL") - quit(0 if pass_ok else 1) - -func _shot(w: Node, rep: Dictionary) -> void: - var msx: float = float(rep["map_size_x"]) * 256.0 - var msy: float = float(rep["map_size_y"]) * 256.0 - var cam := Camera3D.new() - cam.projection = Camera3D.PROJECTION_ORTHOGONAL - cam.size = max(msx, msy) * 1.05 - cam.position = Vector3(msx * 0.5, 2000.0, msy * 0.5) - cam.rotation_degrees = Vector3(-90.0, 0.0, 0.0) - cam.far = 5000.0 - get_root().add_child(cam) - cam.make_current() - var sun := DirectionalLight3D.new() - sun.rotation_degrees = Vector3(-55.0, 35.0, 0.0) - get_root().add_child(sun) - var env := WorldEnvironment.new() - var e := Environment.new() - e.background_mode = Environment.BG_COLOR - e.background_color = Color(0.1, 0.1, 0.12) - e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - e.ambient_light_color = Color(0.5, 0.5, 0.5) - env.environment = e - get_root().add_child(env) - - await process_frame - await process_frame - await process_frame - var img := get_root().get_texture().get_image() - if img != null: - var p := ProjectSettings.globalize_path("res://../test/golden/world-a1-terrain-top.png") - DirAccess.make_dir_recursive_absolute(p.get_base_dir()) - img.save_png(p) - print("screenshot -> ", p) diff --git a/project/world_probe.gd.uid b/project/world_probe.gd.uid deleted file mode 100644 index 07d7d863..00000000 --- a/project/world_probe.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cfppfe2w0oo1x diff --git a/project/world_shot.gd b/project/world_shot.gd deleted file mode 100644 index 71ed6bc3..00000000 --- a/project/world_shot.gd +++ /dev/null @@ -1,58 +0,0 @@ -# 窗口渲染 + 顶视/斜视截图(W2-W4 视觉核对)。非 headless。 -# godot --path project --script world_shot.gd --quit-after 90 -extends SceneTree - -var _w: Node -var _frames := 0 - -func _init() -> void: - var assets: String = AssetRoot.path() - _w = ClassDB.instantiate("Metin2World") - _w.set("assets_root", assets) - _w.set("map_path", "OutdoorA1/metin2_map_a1") - _w.set("auto_load", false) - get_root().add_child(_w) - var ok: bool = _w.call("load_map") - var rep: Dictionary = _w.call("get_load_report") - print("ok=", ok, " report=", rep) - - var msx: float = float(rep["map_size_x"]) * 256.0 - var msy: float = float(rep["map_size_y"]) * 256.0 - - # 光照 / 天空 / 雾 / 色调由 Metin2World 从 A1.msenv 建(env_ok=%s) - print("env_ok=", rep.get("env_ok"), " fog_level=", rep.get("fog_level")) - - var top := Camera3D.new() - top.name = "TopCam" - top.projection = Camera3D.PROJECTION_ORTHOGONAL - top.size = max(msx, msy) * 1.05 - top.position = Vector3(msx * 0.5, 3000.0, msy * 0.5) - top.rotation_degrees = Vector3(-90.0, 0.0, 0.0) - top.far = 8000.0 - get_root().add_child(top) - - var persp := Camera3D.new() - persp.name = "PerspCam" - persp.fov = 55.0 - persp.position = Vector3(msx * 0.35, 260.0, msy * 0.62) - persp.look_at(Vector3(msx * 0.5, 150.0, msy * 0.45)) - persp.far = 6000.0 - get_root().add_child(persp) - - top.make_current() - -func _process(_dt: float) -> bool: - _frames += 1 - var outdir: String = ProjectSettings.globalize_path("res://../test/golden") - DirAccess.make_dir_recursive_absolute(outdir) - if _frames == 6: - var img := get_root().get_texture().get_image() - img.save_png(outdir + "/world-a1-terrain-top.png") - print("saved terrain-top.png ", img.get_size()) - get_root().get_node("PerspCam").make_current() - elif _frames == 12: - var img2 := get_root().get_texture().get_image() - img2.save_png(outdir + "/world-a1-persp.png") - print("saved world-a1-persp.png ", img2.get_size()) - quit(0) - return false diff --git a/project/world_shot.gd.uid b/project/world_shot.gd.uid deleted file mode 100644 index 05604afd..00000000 --- a/project/world_shot.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bijsdyxq6elx5