# GameScene (P0,简版) —— 进游戏后的场景装配。 # # 把已有的件拼成一个能玩的场景: # Metin2World + 本地玩家 + GameCamera + PlayerController + NetWorld + HUD # + NetPlay(胶水)+ Audio + AppLifecycle # # 由 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 AppLifecycle = preload("res://app_lifecycle.gd") const Audio = preload("res://audio.gd") const BgmDirector = preload("res://bgm_director.gd") const UiManager = preload("res://ui/ui_manager.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 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 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 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 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 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 world_time: Node # WorldTime var weather: Node3D # Weather var _assets := "" var map_path := "" var _model_built := false 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 var _pending_shoot_skill := 0 var _guild_invite_dialog: ConfirmationDialog var _gift_dialog: AcceptDialog func setup(m2client: Node, assets_root: String, initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void: client = m2client _assets = assets_root # 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) net_world.set_local_node(player) hud = Hud.new() if _map_loaded(): hud.setup(world, player) # 含小地图 / 快捷栏 / 背包 else: add_child(hud) hud.call("_build_status") # 无地图兜底:只搭状态栏(血 / 蓝 / 经验 / 等级) net_play = NetPlay.new() add_child(net_play) net_play.setup(client, pc, net_world, hud) net_play.camera = cam await _yield() _audio = Audio.new() add_child(_audio) if assets_root != "": _audio.setup(assets_root) await _yield() var life := AppLifecycle.new() add_child(life) life.bind(client, _audio) # §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) # 聊天窗(Enter 聚焦输入) chat = ChatUI.new() add_child(chat) chat.setup(client, ui) 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.setup(client, skill_table, ui, func() -> Node: return player) 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 motion event, matching # CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot. if skill_table and skill_table.has_method("is_ranged") and skill_table.is_ranged(sid): _pending_shoot_skill = sid get_tree().create_timer(1.5).timeout.connect(func(): if _pending_shoot_skill == sid: _pending_shoot_skill = 0) ) 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("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) quest_log = QuestLog.new() add_child(quest_log) quest_log.setup(client, ui) # 死亡窗 + 状态图标条(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() 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 # 怪 / 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() inventory = InventoryUI.new() add_child(inventory) 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) ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list) 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) 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.setup(client, ui, proto, item_list) exchange_ui = ExchangeUI.new() add_child(exchange_ui) exchange_ui.setup(client, ui, proto) safebox_ui = SafeboxUI.new() add_child(safebox_ui) safebox_ui.setup(client, ui, proto) mall_ui = MallUI.new() add_child(mall_ui) 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.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) atlas_ui = AtlasUI.new() add_child(atlas_ui) atlas_ui.setup(world, ui, func() -> Node3D: return player, map_path.get_file(), client) 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) 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)) client.entity_main_set.connect(_on_main_set) 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) # 会徽上传图源:优先 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 = "拒绝" ui.add_child(_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()) _guild_invite_dialog.popup_centered() 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): _gift_dialog.popup_centered() return _gift_dialog = AcceptDialog.new() _gift_dialog.title = "礼物" _gift_dialog.dialog_text = "有新的礼物可查看。" _gift_dialog.ok_button_text = "知道了" ui.add_child(_gift_dialog) _gift_dialog.confirmed.connect(func(): if is_instance_valid(_gift_dialog): _gift_dialog.queue_free()) _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: _audio.play_at(sound, anchor.global_position, anchor) 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 —— 弓技能在动画飞行点发送 CG_SHOOT if _pending_shoot_skill != 0 and client and client.has_method("shoot"): client.shoot(_pending_shoot_skill) _pending_shoot_skill = 0 9: # WARP —— 传送起点,不是投射物 pass var _flash_rect: ColorRect 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: 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 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 and e.pressed and not e.echo): return if e.keycode in [KEY_ENTER, KEY_KP_ENTER] and chat and not chat.is_typing(): chat.focus_input() get_viewport().set_input_as_handled() elif e.keycode == KEY_I and inventory: inventory.toggle() elif e.keycode == KEY_K and skills: skills.set_job(_job_name()) skills.toggle() elif e.keycode in [KEY_V, KEY_C] and char_status_ui: char_status_ui.toggle() elif e.keycode == KEY_J and quest_log: quest_log.toggle() elif e.keycode == KEY_O and friend_ui: friend_ui.toggle() elif e.keycode == KEY_G and guild_ui: guild_ui.toggle() elif e.keycode == KEY_L and ENABLE_DRAGON_SOUL and dragon_soul_ui: dragon_soul_ui.toggle() elif e.keycode == KEY_M and atlas_ui: atlas_ui.toggle() elif e.keycode == KEY_Z and ground_items: ground_items.try_pickup() elif e.keycode >= KEY_F1 and e.keycode <= KEY_F4 and quickbar: # 参考端 game.py:318 —— F1..F4 固定 __PressQuickSlot(4..7)。 quickbar.activate(4 + (e.keycode - KEY_F1)) elif e.keycode >= KEY_1 and e.keycode <= KEY_9: # 参考端 __PressNumKey(game.py:431): # Ctrl+1..9 → 表情;否则 1..4 → 快捷栏 0..3,5..9 无操作。 var n: int = e.keycode - KEY_1 if e.ctrl_pressed or e.meta_pressed: _emote(n) elif n < 4 and quickbar: quickbar.activate(n) elif e.keycode == KEY_ESCAPE and system_menu_ui: # ESC:ui_manager 先关最顶层窗口并吃掉事件;到这里说明没有窗口打开。 system_menu_ui.toggle() # 表情快捷键(§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 _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) var tw := create_tween() tw.tween_interval(3.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 是否有可用模型;失败就别再试) # 默认工厂:怪 / NPC -> MobView(race = mob_proto vnum)。失败返回 null 用占位胶囊。 func _make_entity_model(d: Dictionary) -> Node3D: if bool(d.get("is_main", false)) or proto == null or _assets == "": return null var race := int(d.get("race", 0)) if race < 1 or _mob_view_cache.get(race, true) == false: return null var mv := MobView.new() var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable() if mv.build(_assets, proto, race, pump): _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.6 we.environment = e add_child(we) _env = e func _on_main_set(vid: int) -> void: var e: Dictionary = client.get_entity(vid) if e.is_empty() or player == null: return # 首次拿到 race -> 换成真 Metin2Model if not _model_built and _assets != "" and int(e.get("race", 0)) >= 0: var pv := PlayerView.new() var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable() if pv.build(_assets, int(e.get("race", 0)), pump): 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"): pc.anim_state.connect(func(s: String): if is_instance_valid(player) and player.has_method("set_anim_state"): player.set_anim_state(s)) if equip_model: equip_model.set_race(int(e.get("race", 0))) equip_model.refresh() else: pv.queue_free() var p: Vector3 = MapCoord.to_world(e.get("pos", Vector3.ZERO)) if world and world.has_method("set_focus_position"): world.call("set_focus_position", p.x, p.z) # 先对准,才建得出脚下的区块 if world and world.has_method("sample_height"): p.y = float(world.call("sample_height", p.x, p.z)) player.position = p 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