# 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") # 龙魂是协议兼容层的一部分,但龙魂 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 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 _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 const MAIN_MODEL_MAX_ATTEMPTS := 3 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 _ui_mobile = UiProfile.resolve() == UiProfile.Mode.MOBILE # 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 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) # §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 _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) 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))) if _assets != "": net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表 await _yield() _audio = Audio.new() add_child(_audio) if assets_root != "": _audio.setup(assets_root) 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) cursor_manager = CursorManager.new() add_child(cursor_manager) mouse_controller = MouseController.new() add_child(mouse_controller) mouse_controller.setup(ui, cursor_manager, _audio) # 聊天窗(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) # 技能(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 skill_fx = SkillFx.new() skill_fx.setup(fx, skill_table) skills = SkillUI.new() add_child(skills) skills.setup(client, skill_table, ui) char_status_ui = CharStatusUI.new() add_child(char_status_ui) char_status_ui.setup(ui, client, assets_root) 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) # §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)) if quickbar.has_signal("skill_activated"): quickbar.skill_activated.connect(func(sid: int): if skill_fx and player: skill_fx.spawn_skill(sid, _skill_master(sid), player) # 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) ) # 弓普攻起手(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) # 任务 / 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)) quest_dialog.closed.connect(func(): if quest_log and quest_log.has_method("set_buttons_suppressed"): quest_log.set_buttons_suppressed(false)) # 死亡窗 + 状态图标条(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) await _yield() if ClassDB.class_exists("Metin2Proto") and assets_root != "": proto = ClassDB.instantiate("Metin2Proto") add_child(proto) var ip := assets_root.path_join("locale/locale/en/item_proto") if FileAccess.file_exists(ip): proto.call("load_item_proto", ip) 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 := assets_root.path_join("locale/locale/en/mob_proto") if FileAccess.file_exists(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 = load("res://locale.gd").new() _tt_loc.setup(assets_root, "en") 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 "" # 怪 / NPC 真模型:race -> mob_proto.name -> monster/npc 目录 net_world.set_model_factory(_make_entity_model) 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.main_getter = func() -> int: return client.get_main_vid() equip_model.proto = proto # armor shape 走 item_proto values[3] + specular # 地面掉落物 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, "en") 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) pc.set_input_surfaces(cursor_manager, ui, ground_items, Callable(net_play, "cancel_fishing"), 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) # 选魔石窗(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("open"): atlas_ui.open()) quest_dialog.map_signals_cleared.connect(func(): if minimap and minimap.has_method("clear_signal_points"): minimap.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) # 系统设置 / 游戏设置 / 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) 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() client.entity_main_set.connect(_on_main_set) _main_sync_ready = true if client.get_main_vid() != 0: # 重连 / 已在局内 # net_play._on_main_set 的信号早在它连上前就发过了 —— 手动补上关键那步: # 告诉 net_world 主角 vid 由本地 player 代表,别给它生成节点(否则 catch_up 会 # 在玩家身上叠一个大蓝胶囊)。 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()) # setup() 是协程 —— 进来时进游戏的 spawn burst 早被 M2Client.pump_game 抽干、 # net_world 还没连上信号。把当前所有实体(怪 / NPC / 别的玩家)补建一遍。 if net_world and net_world.has_method("catch_up"): net_world.catch_up() set_process_unhandled_input(true) 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 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) 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() # 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")) # 会徽上传图源:优先 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 —— 传送起点,不是投射物 pass # 一次弓挥击 / 弓技能起手 → 压一个待发 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 cam.has_method("set_event_camera"): cam.set_event_camera(setting) "BLEND_CAMERA": if cam.has_method("blend_event_camera"): cam.blend_event_camera(setting, blendtime) "RESTORE_CAMERA": 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 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 # 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_world_reset() -> void: _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 map_path = next_map _build_world(_assets, map_path) if old_world and is_instance_valid(old_world) and old_world != world: old_world.queue_free() 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)) player.global_position = wp if net_play and "_last_sent_pos" in net_play: net_play._last_sent_pos = player.global_position if cam and cam.has_method("snap_to_target"): 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 < 4 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) func _send_command(text: String) -> void: if client and client.has_method("say"): client.say(0, text) 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) 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): 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.name = "LocalPlayer" add_child(node) player = node cam.target = node pc.player = node 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) set_player_model(pv) _model_built = true if net_play: net_play.player_view = pv if 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 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 _make_placeholder_player() -> Node3D: var root := Node3D.new() root.name = "LocalPlayer" 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() mat.albedo_color = Color(0.35, 0.6, 1.0) mesh.material_override = mat root.add_child(mesh) return root