1622 lines
65 KiB
GDScript
1622 lines
65 KiB
GDScript
# 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
|
||
|
||
# 实机「灰紫虚空 + 蓝胶囊」的取证钩子。离线自检(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
|
||
# 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))
|
||
quickbar.skill_cast_started.connect(_on_skill_cast_started)
|
||
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<grade>
|
||
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()
|
||
_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=<png>:最后一次 tick 存一张实机帧,用于人工核对渲染结果。
|
||
var shot := OS.get_environment("MT_DIAG_SHOT")
|
||
if not shot.is_empty() and _diag_done:
|
||
await RenderingServer.frame_post_draw
|
||
var img := get_viewport().get_texture().get_image()
|
||
if img != null:
|
||
img.save_png(shot)
|
||
|
||
# 会徽上传图源:优先 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
|
||
|
||
var _motion_effect_target: WeakRef
|
||
|
||
func _on_skill_cast_started(_skill: int, target_vid: int) -> void:
|
||
_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)
|
||
|
||
func _on_local_motion_event_detailed(event: Dictionary) -> void:
|
||
# Keep sound/projectile dispatch exactly once, independent of visual routing.
|
||
if int(event.type) == 1 and String(event.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 int(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)
|
||
else:
|
||
_on_local_motion_event(int(event.type), String(event.effect), String(event.sound), event.pos)
|
||
|
||
# 一次弓挥击 / 弓技能起手 → 压一个待发 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:
|
||
_motion_effect_target = null
|
||
_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)
|
||
|
||
# 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}
|
||
|
||
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_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
|
||
|
||
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
|