Handle late main-character data, guarded model construction, bounded retries and map correction. Validate player model sources and add lifecycle and real-asset visual regressions. Include pending material and character-selection changes, updated A1 screenshot, and the detailed rendering repair plan. Hair occlusion, full UI parity and final macOS package acceptance remain unfinished.
1516 lines
60 KiB
GDScript
1516 lines
60 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
|
||
const MAIN_MODEL_RETRY_MS := 1000
|
||
var _audio: Node
|
||
var _bgm_director: Node
|
||
var _mount: Node3D
|
||
var _sun: DirectionalLight3D
|
||
var _env: Environment
|
||
var _env_from_msenv := false # true = 用 Metin2World 从 .msenv 建的 Sun/WorldEnv
|
||
# CNormalBowAttack_FlyEventHandler_AutoClear 的等价:每次弓挥击 / 弓技能起手压一个
|
||
# uSkill,`.msa` MOTION_EVENT_TYPE_FLY 帧(_on_local_motion_event 的 type 6)按 FIFO 弹一个
|
||
# 发 CG_SHOOT —— pop 即自清(AutoClear),队列天然支持连射多箭在途。§3.6
|
||
# 上限兜底:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时丢最旧的,避免无界增长。
|
||
var _pending_shots: Array[int] = []
|
||
const MAX_PENDING_SHOTS := 8
|
||
var _guild_invite_dialog: ConfirmationDialog
|
||
var _gift_dialog: AcceptDialog
|
||
var _quick_page_mode := false
|
||
var _show_names := false
|
||
var _ui_mobile := false
|
||
|
||
func setup(m2client: Node, assets_root: String,
|
||
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
|
||
client = m2client
|
||
_assets = assets_root
|
||
_ui_mobile = UiProfile.resolve() == UiProfile.Mode.MOBILE
|
||
# GC_MAIN_CHARACTER only carries the server-space coordinates, not the map
|
||
# folder. The old fixed A1 fallback puts characters from A2/A3 outside the
|
||
# rendered terrain, which looks like an empty map. Resolve the map from the
|
||
# character position before constructing Metin2World.
|
||
map_path = _resolve_map_path(initial_map_path)
|
||
_build_lighting()
|
||
# 关键:setup 是协程。每个重活之间让出一帧,好让 M2Client._process 抽 socket
|
||
# (否则整段 ~5s 同步阻塞会漏 PONG → 服务器 10s 后 "peer closed")。
|
||
await _yield()
|
||
_build_world(assets_root, map_path)
|
||
await _yield()
|
||
|
||
player = _make_placeholder_player()
|
||
add_child(player)
|
||
|
||
cam = GameCamera.new()
|
||
cam.target = player
|
||
cam.world = world
|
||
add_child(cam)
|
||
cam.make_current()
|
||
|
||
pc = PlayerCtl.new()
|
||
pc.player = player
|
||
pc.camera = cam
|
||
pc.world = world
|
||
add_child(pc)
|
||
|
||
_mount = Node3D.new()
|
||
_mount.name = "Entities"
|
||
add_child(_mount)
|
||
net_world = NetWorld.new()
|
||
add_child(net_world)
|
||
net_world.world = world
|
||
net_world.setup(client, _mount)
|
||
# §8.8 屏幕空间实体血条:明确绑定轨道相机,避免依赖 viewport 当前相机,
|
||
# 也让地图切换 / 多视口时投影来源稳定。
|
||
if net_world.has_method("set_camera"):
|
||
net_world.set_camera(cam)
|
||
net_world.set_local_node(player)
|
||
net_world.entity_added.connect(_on_pickable_added)
|
||
net_world.entity_removed.connect(_on_pickable_removed)
|
||
|
||
if _ui_mobile:
|
||
hud = MobileUiRoot.new()
|
||
add_child(hud)
|
||
hud.setup(world, player, client)
|
||
else:
|
||
hud = Hud.new()
|
||
add_child(hud)
|
||
if _map_loaded():
|
||
hud.setup(world, player) # 含小地图 / 快捷栏 / 背包
|
||
else:
|
||
hud.call("_build_status") # 无地图兜底:只搭状态栏(血 / 蓝 / 经验 / 等级)
|
||
|
||
net_play = NetPlay.new()
|
||
add_child(net_play)
|
||
net_play.setup(client, pc, net_world, hud)
|
||
net_play.camera = cam
|
||
# §8.6 targetBoard.GetTargetVID() -> net_world:选中目标始终强显名字。
|
||
if net_play.has_signal("target_changed") and net_world and net_world.has_method("set_target_vid"):
|
||
net_play.target_changed.connect(func(vid: int): net_world.set_target_vid(int(vid)))
|
||
# §8.8 game.py OnRender chr.Pick() -> ShowCharacterTextTail:悬停角色也强显名字(pick_show.gd)。
|
||
if pc.has_signal("hover_entity_changed") and net_world and net_world.has_method("set_hover_vid"):
|
||
pc.hover_entity_changed.connect(func(vid: int): net_world.set_hover_vid(int(vid)))
|
||
if _assets != "":
|
||
net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表
|
||
|
||
await _yield()
|
||
_audio = Audio.new()
|
||
add_child(_audio)
|
||
if assets_root != "":
|
||
_audio.setup(assets_root)
|
||
await _yield()
|
||
|
||
# §9.1 地图 BGM:M2Client.bgm_changed -> Audio。放在独立节点里,W4 补全曲目解析。
|
||
_bgm_director = BgmDirector.new()
|
||
add_child(_bgm_director)
|
||
_bgm_director.bind(client, _audio)
|
||
|
||
# UI 层 + 物品 proto + 背包窗(I 键开关)
|
||
ui = UiManager.new()
|
||
add_child(ui)
|
||
cursor_manager = CursorManager.new()
|
||
add_child(cursor_manager)
|
||
mouse_controller = MouseController.new()
|
||
add_child(mouse_controller)
|
||
mouse_controller.setup(ui, cursor_manager, _audio)
|
||
|
||
# 聊天窗(Enter 聚焦输入)
|
||
chat = ChatUI.new()
|
||
add_child(chat)
|
||
chat.setup(client, ui, _assets)
|
||
if net_play and net_play.has_signal("fishing_feedback") and chat.has_method("on_fishing_feedback"):
|
||
net_play.fishing_feedback.connect(chat.on_fishing_feedback)
|
||
# §8.8 / §5.4 game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时
|
||
# self.interface.OpenWhisperDialog(name)。chat.start_whisper 打开独立私聊窗口。
|
||
if net_play and net_play.has_signal("whisper_requested") and chat.has_method("start_whisper"):
|
||
net_play.whisper_requested.connect(chat.start_whisper)
|
||
if net_world and net_world.has_signal("main_bubble"):
|
||
net_world.main_bubble.connect(_player_bubble)
|
||
|
||
# 特效注册表(P5)—— GC_SPECIFIC_EFFECT(.mse 路径) / GC_SPECIAL_EFFECT(内建 id) 在实体上播
|
||
fx = EffectRegistry.new()
|
||
fx.setup(_assets)
|
||
|
||
# 技能(P6)—— K 键技能窗,数字键 1-4 → 快捷栏 0-3 / F1-F4 → 4-7 / Ctrl+数字 = 表情,+ 技能特效表
|
||
if assets_root != "":
|
||
skill_table = SkillTable.new()
|
||
for lang in ["en", "common"]:
|
||
if skill_table.load_file(assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang)):
|
||
break
|
||
skill_fx = SkillFx.new()
|
||
skill_fx.setup(fx, skill_table)
|
||
skills = SkillUI.new()
|
||
add_child(skills)
|
||
skills.setup(client, skill_table, ui)
|
||
char_status_ui = CharStatusUI.new()
|
||
add_child(char_status_ui)
|
||
char_status_ui.setup(ui, client, assets_root)
|
||
quickbar = Quickbar.new()
|
||
add_child(quickbar)
|
||
quickbar.item_mouse = mouse_controller
|
||
quickbar.net_play = net_play # §3.8 修改 1:技能三层校验的运行期上下文
|
||
quickbar.setup(client, skill_table, ui, func() -> Node: return player, assets_root)
|
||
# §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。
|
||
if net_play:
|
||
net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot)
|
||
if quickbar.has_signal("skill_rejected"):
|
||
quickbar.skill_rejected.connect(func(_sid: int, code: String):
|
||
if net_play and net_play.has_signal("cannot_act"):
|
||
net_play.cannot_act.emit(code) # HUD 走同一条 OnCannotUseSkill 文案通道
|
||
)
|
||
if net_play and net_play.has_signal("fishing_feedback") and net_play.has_signal("cannot_act"):
|
||
net_play.fishing_feedback.connect(func(code: String): net_play.cannot_act.emit(code))
|
||
if quickbar.has_signal("skill_activated"):
|
||
quickbar.skill_activated.connect(func(sid: int):
|
||
if skill_fx and player:
|
||
skill_fx.spawn_skill(sid, _skill_master(sid), player)
|
||
# Bow skills send CG_SHOOT from the .msa FLY frame, matching
|
||
# CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot —— 压队列,
|
||
# 不再用 1.5s 定时器兜底(AutoClear 由 FLY 帧 pop 自清)。§3.6
|
||
if skill_table and skill_table.has_method("is_ranged") and skill_table.is_ranged(sid):
|
||
_queue_shot(sid)
|
||
)
|
||
|
||
# 弓普攻起手(net_play._emit_swing):OnSetFlyTarget 已在那里发过 CG_FLY_TARGETING,
|
||
# 这里只把 uSkill 压进 FLY 帧待发队列(普攻恒 0)。§3.6
|
||
if net_play and net_play.has_signal("bow_shot_fired"):
|
||
net_play.bow_shot_fired.connect(_queue_shot)
|
||
|
||
if client.has_signal("effect_cue"):
|
||
client.effect_cue.connect(func(vid: int, name: String, special: int):
|
||
var host: Node3D = _fx_host(vid)
|
||
if host == null:
|
||
return
|
||
if name != "":
|
||
fx.spawn(name, host, true) # GC_SPECIFIC_EFFECT
|
||
elif special >= 0 and skill_fx:
|
||
skill_fx.spawn_special(special, host)) # GC_SPECIAL_EFFECT 内建 id
|
||
if client.has_signal("emoticon_requested"):
|
||
client.emoticon_requested.connect(_play_emoticon)
|
||
if client.has_signal("gift_available"):
|
||
client.gift_available.connect(_on_gift_available)
|
||
|
||
# 任务 / NPC(P7)—— 对话窗自动弹,J 键任务日志
|
||
quest_dialog = QuestDialog.new()
|
||
add_child(quest_dialog)
|
||
quest_dialog.setup(client, ui, proto, assets_root)
|
||
if quest_dialog.has_signal("camera_event_requested_full"):
|
||
quest_dialog.camera_event_requested_full.connect(_on_quest_camera_event)
|
||
if quest_dialog.has_signal("fade_event_requested_full"):
|
||
quest_dialog.fade_event_requested_full.connect(_on_quest_fade_event)
|
||
if quest_dialog.has_signal("dungeon_result_requested"):
|
||
quest_dialog.dungeon_result_requested.connect(_on_dungeon_result)
|
||
dungeon_result_ui = DungeonResultUI.new()
|
||
add_child(dungeon_result_ui)
|
||
dungeon_result_ui.setup(ui)
|
||
quest_log = QuestLog.new()
|
||
add_child(quest_log)
|
||
quest_log.setup(client, ui)
|
||
if quest_dialog.has_signal("quest_button_received"):
|
||
quest_dialog.quest_button_received.connect(func(index: int, title: String, icon_type: String, icon_name: String):
|
||
if quest_log and quest_log.has_method("recv_quest"):
|
||
quest_log.recv_quest(index, title, icon_type, icon_name))
|
||
quest_dialog.quest_button_cleared.connect(func(index: int):
|
||
if quest_log and quest_log.has_method("clear_quest"):
|
||
quest_log.clear_quest(index))
|
||
quest_dialog.opened.connect(func():
|
||
if quest_log and quest_log.has_method("set_buttons_suppressed"):
|
||
quest_log.set_buttons_suppressed(true))
|
||
quest_dialog.closed.connect(func():
|
||
if quest_log and quest_log.has_method("set_buttons_suppressed"):
|
||
quest_log.set_buttons_suppressed(false))
|
||
|
||
# 死亡窗 + 状态图标条(P4)
|
||
var death := DeathUI.new()
|
||
add_child(death)
|
||
death.setup(client, ui)
|
||
if client.has_signal("affect_added"):
|
||
var refresh_affects := func(_a = null): if hud and hud.has_method("set_affects"): hud.set_affects(client.get_affects())
|
||
client.affect_added.connect(refresh_affects)
|
||
client.affect_removed.connect(refresh_affects)
|
||
await _yield()
|
||
if ClassDB.class_exists("Metin2Proto") and assets_root != "":
|
||
proto = ClassDB.instantiate("Metin2Proto")
|
||
add_child(proto)
|
||
var ip := assets_root.path_join("locale/locale/en/item_proto")
|
||
if FileAccess.file_exists(ip):
|
||
proto.call("load_item_proto", ip)
|
||
await _yield()
|
||
if chat and chat.has_method("set_proto"):
|
||
chat.set_proto(proto)
|
||
if quest_dialog and quest_dialog.has_method("set_proto"):
|
||
quest_dialog.set_proto(proto)
|
||
var mp := assets_root.path_join("locale/locale/en/mob_proto")
|
||
if FileAccess.file_exists(mp):
|
||
proto.call("load_mob_proto", mp)
|
||
await _yield()
|
||
net_play.proto = proto # 按 race 分类 NPC/怪(bType 在本 fork 不可靠)
|
||
if net_world and "name_resolver" in net_world:
|
||
net_world.name_resolver = net_play._entity_name # 头顶名字:怪走 mob_proto
|
||
if net_world and "kind_resolver" in net_world:
|
||
net_world.kind_resolver = net_play._entity_kind # §8.8 名字色分类:怪 / NPC 走 mob_proto bType
|
||
if net_world and "title_name_resolver" in net_world and assets_root != "":
|
||
# §8.8 称号名:introloading.__RegisterTitleName -> localeInfo.PVP_LEVEL<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()
|
||
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
|
||
# node keeps its listener at the origin and rebases active 3D instances.
|
||
if _audio == null or player == null or not is_instance_valid(player):
|
||
return
|
||
var basis := player.global_transform.basis
|
||
_audio.set_listener(player.global_position, -basis.z, basis.y)
|
||
if world and world.has_method("get_ambience_sources") and _audio.has_method("update_ambience_sources"):
|
||
_audio.update_ambience_sources(world.call("get_ambience_sources"))
|
||
|
||
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
|
||
func _guild_mark_upload_image() -> Image:
|
||
var p := "res://ui/default_guild_mark.png"
|
||
if ResourceLoader.exists(p):
|
||
var tex: Texture2D = load(p)
|
||
if tex:
|
||
return tex.get_image()
|
||
var img := Image.create(16, 12, false, Image.FORMAT_RGBA8)
|
||
for y in 12:
|
||
for x in 16:
|
||
img.set_pixel(x, y, Color(0.8, 0.2, 0.2) if (x + y) % 2 == 0 else Color(0.9, 0.85, 0.3))
|
||
return img
|
||
|
||
func _on_guild_invite(guild_id: int, guild_name: String) -> void:
|
||
if _guild_invite_dialog and is_instance_valid(_guild_invite_dialog):
|
||
_guild_invite_dialog.queue_free()
|
||
_guild_invite_dialog = ConfirmationDialog.new()
|
||
_guild_invite_dialog.title = "公会邀请"
|
||
_guild_invite_dialog.dialog_text = "加入公会“%s”?" % guild_name
|
||
_guild_invite_dialog.ok_button_text = "加入"
|
||
_guild_invite_dialog.cancel_button_text = "拒绝"
|
||
_guild_invite_dialog.set_meta("mobile_title", "公会邀请")
|
||
ui.add_child(_guild_invite_dialog)
|
||
if ui.has_method("track_mobile_modal"):
|
||
ui.track_mobile_modal(_guild_invite_dialog)
|
||
_guild_invite_dialog.confirmed.connect(func():
|
||
client.guild_answer_invite(guild_id, true)
|
||
_guild_invite_dialog.queue_free())
|
||
_guild_invite_dialog.canceled.connect(func():
|
||
client.guild_answer_invite(guild_id, false)
|
||
_guild_invite_dialog.queue_free())
|
||
_popup_guild_invite()
|
||
|
||
func _on_gift_available() -> void:
|
||
# 40250's Gift_Show has no corresponding claim packet in the shipped
|
||
# PythonNetworkStream. Keep the server notification visible instead of
|
||
# silently consuming it; any reward data remains owned by the gift UI/backend.
|
||
if ui == null:
|
||
return
|
||
if _gift_dialog and is_instance_valid(_gift_dialog):
|
||
_popup_gift()
|
||
return
|
||
_gift_dialog = AcceptDialog.new()
|
||
_gift_dialog.title = "礼物"
|
||
_gift_dialog.dialog_text = "有新的礼物可查看。"
|
||
_gift_dialog.ok_button_text = "知道了"
|
||
_gift_dialog.set_meta("mobile_title", "礼物")
|
||
ui.add_child(_gift_dialog)
|
||
if ui.has_method("track_mobile_modal"):
|
||
ui.track_mobile_modal(_gift_dialog)
|
||
_gift_dialog.confirmed.connect(func():
|
||
if is_instance_valid(_gift_dialog):
|
||
_gift_dialog.queue_free())
|
||
_popup_gift()
|
||
|
||
func _popup_guild_invite() -> void:
|
||
if not is_instance_valid(_guild_invite_dialog):
|
||
return
|
||
if _ui_mobile:
|
||
_guild_invite_dialog.min_size = Vector2i(500, 230)
|
||
_guild_invite_dialog.size = Vector2i(500, 230)
|
||
_guild_invite_dialog.popup_centered(Vector2i(500, 230))
|
||
else:
|
||
_guild_invite_dialog.popup_centered()
|
||
|
||
func _popup_gift() -> void:
|
||
if not is_instance_valid(_gift_dialog):
|
||
return
|
||
if _ui_mobile:
|
||
_gift_dialog.min_size = Vector2i(460, 210)
|
||
_gift_dialog.size = Vector2i(460, 210)
|
||
_gift_dialog.popup_centered(Vector2i(460, 210))
|
||
else:
|
||
_gift_dialog.popup_centered()
|
||
|
||
# .msa MotionEventData 分派(对齐 GameLib/RaceMotionData EMotionEventType):
|
||
# 1 EFFECT / 10 EFFECT_TO_TARGET → 挂特效;2 SCREEN_WAVING → 震屏;
|
||
# 3 SCREEN_FLASHING → 屏闪;5 SOUND / 有 sound 字段 → 3D 音;6 FLY / 9 WARP → 钩子。
|
||
# 数字在 .msa 文本里与运行时枚举有历史偏差,故先按「字段是否有值」再按 type 兜底。
|
||
func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vector3) -> void:
|
||
var anchor: Node3D = player
|
||
if sound != "" and _audio:
|
||
# Motion sounds are character sounds in ClientVS22 and use the optional
|
||
# 0.3s / 5000cm frequency-distance guard.
|
||
_audio.play_character_sound_3d(anchor.global_position.x, anchor.global_position.y,
|
||
anchor.global_position.z, sound, true)
|
||
if effect != "" and fx:
|
||
# EffectPosition 是骨骼本地锚点(cm);简版直接挂角色根 + y 偏移
|
||
var gp := anchor.global_position + Vector3(pos.x, pos.y, pos.z) * 0.01
|
||
fx.spawn_at(effect.get_file().get_basename(), _mount, gp, true)
|
||
match type:
|
||
2: # SCREEN_WAVING
|
||
if cam and cam.has_method("shake"):
|
||
cam.shake(0.06, 9.0)
|
||
3: # SCREEN_FLASHING
|
||
_screen_flash(0.12)
|
||
6: # FLY —— `.msa` MOTION_EVENT_TYPE_FLY 帧:CActorInstance::ProcessMotionEventFly
|
||
# → m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill)(ActorInstanceMotionEvent.cpp:290)。
|
||
# FIFO pop = AutoClear + 连射多箭在途各自一发。
|
||
if client and client.has_method("shoot") and not _pending_shots.is_empty():
|
||
client.shoot(_pending_shots.pop_front())
|
||
9: # WARP —— 传送起点,不是投射物
|
||
pass
|
||
|
||
# 一次弓挥击 / 弓技能起手 → 压一个待发 uSkill(对齐 AutoClear handler 的 Set())。
|
||
# 满了丢最旧的:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时不至无界增长。§3.6
|
||
func _queue_shot(skill: int) -> void:
|
||
_pending_shots.append(skill)
|
||
while _pending_shots.size() > MAX_PENDING_SHOTS:
|
||
_pending_shots.pop_front()
|
||
|
||
var _flash_rect: ColorRect
|
||
var _quest_fade_rect: ColorRect
|
||
var _quest_fade_tween: Tween
|
||
|
||
func _on_quest_camera_event(kind: String, setting: Dictionary, blendtime: float) -> void:
|
||
if cam == null or not is_instance_valid(cam):
|
||
return
|
||
match kind:
|
||
"SET_CAMERA":
|
||
if cam.has_method("set_event_camera"):
|
||
cam.set_event_camera(setting)
|
||
"BLEND_CAMERA":
|
||
if cam.has_method("blend_event_camera"):
|
||
cam.blend_event_camera(setting, blendtime)
|
||
"RESTORE_CAMERA":
|
||
if cam.has_method("set_default_camera"):
|
||
cam.set_default_camera()
|
||
|
||
func _on_quest_fade_event(kind: String, speed: float) -> void:
|
||
if ui == null:
|
||
if quest_dialog and quest_dialog.has_method("end_event_process"):
|
||
quest_dialog.end_event_process()
|
||
return
|
||
if _quest_fade_rect == null or not is_instance_valid(_quest_fade_rect):
|
||
_quest_fade_rect = ColorRect.new()
|
||
_quest_fade_rect.name = "QuestFade"
|
||
_quest_fade_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
_quest_fade_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_quest_fade_rect.z_index = 100
|
||
ui.add_child(_quest_fade_rect)
|
||
var white := kind.begins_with("WHITE_")
|
||
_quest_fade_rect.color = Color(1, 1, 1, _quest_fade_rect.color.a) if white \
|
||
else Color(0, 0, 0, _quest_fade_rect.color.a)
|
||
var fade_in := kind.ends_with("_IN")
|
||
var target_alpha := 0.0 if fade_in else 1.0
|
||
_quest_fade_rect.visible = target_alpha > 0.0 or _quest_fade_rect.color.a > 0.001
|
||
if _quest_fade_tween and _quest_fade_tween.is_valid():
|
||
_quest_fade_tween.kill()
|
||
# uiquest.py advances alpha by speed once per frame. At 60 FPS this is
|
||
# equivalent to 1 / (speed * 60) seconds; malformed zero speeds use the
|
||
# reference default to avoid permanently blocking the event set.
|
||
var frame_speed := maxf(absf(speed), 0.035)
|
||
var duration := absf(target_alpha - _quest_fade_rect.color.a) / (frame_speed * 60.0)
|
||
_quest_fade_tween = create_tween()
|
||
_quest_fade_tween.tween_property(_quest_fade_rect, "color:a", target_alpha, duration)
|
||
_quest_fade_tween.tween_callback(func():
|
||
_quest_fade_rect.visible = target_alpha > 0.0
|
||
if quest_dialog and quest_dialog.has_method("end_event_process"):
|
||
quest_dialog.end_event_process())
|
||
|
||
func _on_dungeon_result(result: Dictionary) -> void:
|
||
if dungeon_result_ui and dungeon_result_ui.has_method("show_result"):
|
||
dungeon_result_ui.show_result(result)
|
||
|
||
func _clear_quest_presentation() -> void:
|
||
if _quest_fade_tween and _quest_fade_tween.is_valid():
|
||
_quest_fade_tween.kill()
|
||
_quest_fade_tween = null
|
||
if _quest_fade_rect and is_instance_valid(_quest_fade_rect):
|
||
_quest_fade_rect.color.a = 0.0
|
||
_quest_fade_rect.visible = false
|
||
if cam and cam.has_method("set_default_camera"):
|
||
cam.set_default_camera()
|
||
|
||
func _screen_flash(secs: float) -> void:
|
||
if hud == null:
|
||
return
|
||
if _flash_rect == null or not is_instance_valid(_flash_rect):
|
||
_flash_rect = ColorRect.new()
|
||
_flash_rect.color = Color(1, 1, 1, 0)
|
||
_flash_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
ui.add_child(_flash_rect)
|
||
_flash_rect.color.a = 0.5
|
||
var tw := create_tween()
|
||
tw.tween_property(_flash_rect, "color:a", 0.0, secs)
|
||
|
||
# skill id -> master_type(0 普通 / 1 M / 2 G / 3 P),给 skill_fx 选特效档。
|
||
func _skill_master(sid: int) -> int:
|
||
if client and client.has_method("get_skills"):
|
||
for s in client.get_skills():
|
||
if int(s.get("id", -1)) == sid:
|
||
return int(s.get("master", 0))
|
||
return 0
|
||
|
||
# GC_WARP —— 40250 的参考端无论是否“同服”都会重连到 lAddr:wPort;
|
||
# world_reset 先清旧实体,随后这里重载地图并把玩家放到新坐标。
|
||
func _on_warp(pos: Vector3, same_server: bool) -> void:
|
||
if not same_server:
|
||
_reload_map_for_warp(pos)
|
||
return
|
||
_place_player_at_net_pos(pos)
|
||
|
||
func _on_world_reset() -> void:
|
||
_main_generation += 1
|
||
_main_view_key = ""
|
||
_main_attempts = 0
|
||
_main_retry_at = 0
|
||
_main_placed_vid = 0
|
||
_main_map_vid = 0
|
||
_model_built = false
|
||
_main_model_attempted = false
|
||
if net_world and net_world.has_method("clear_for_map_change"):
|
||
net_world.clear_for_map_change()
|
||
if ground_items and ground_items.has_method("clear_for_map_change"):
|
||
ground_items.clear_for_map_change()
|
||
# The reference loading phase closes transient interaction state. Keep
|
||
# persistent account data (inventory, quests, friends) intact.
|
||
for widget in [shop_ui, exchange_ui, safebox_ui, mall_ui, cube_ui,
|
||
private_shop_ui, select_item_ui, quest_dialog, quest_log, party_ui,
|
||
friend_ui, refine_ui, dragon_soul_ui]:
|
||
if widget == null or not is_instance_valid(widget):
|
||
continue
|
||
if widget.has_method("close"):
|
||
widget.call("close")
|
||
elif widget.has_method("_close"):
|
||
widget.call("_close")
|
||
if atlas_ui and atlas_ui.has_method("close"):
|
||
atlas_ui.close()
|
||
if dungeon_result_ui and dungeon_result_ui.has_method("close"):
|
||
dungeon_result_ui.close()
|
||
_clear_quest_presentation()
|
||
if ui and ui.has_method("close_top"):
|
||
while ui.close_top():
|
||
pass
|
||
if _audio and _audio.has_method("stop_bgm"):
|
||
_audio.stop_bgm(0.15)
|
||
|
||
func _reload_map_for_warp(pos: Vector3) -> void:
|
||
var server_pos := Vector2(pos.x * 100.0, -pos.z * 100.0)
|
||
var next_map := _resolve_map_path(map_path, server_pos)
|
||
var old_world := world
|
||
map_path = next_map
|
||
_build_world(_assets, map_path)
|
||
if old_world and is_instance_valid(old_world) and old_world != world:
|
||
old_world.queue_free()
|
||
if cam:
|
||
cam.world = world
|
||
if pc:
|
||
pc.world = world
|
||
if net_world:
|
||
net_world.world = world
|
||
if hud and hud.has_method("reload_world") and _map_loaded():
|
||
hud.reload_world(world)
|
||
if atlas_ui and atlas_ui.has_method("reload_map"):
|
||
atlas_ui.reload_map(world, map_path.get_file())
|
||
if world_time and world_time.has_method("set_lighting"):
|
||
world_time.set_lighting(_sun if not _env_from_msenv else null,
|
||
_env if not _env_from_msenv else null)
|
||
_place_player_at_net_pos(pos)
|
||
|
||
func _place_player_at_net_pos(pos: Vector3) -> void:
|
||
if player:
|
||
var wp := MapCoord.to_world(pos)
|
||
if world and world.has_method("set_focus_position"):
|
||
world.call("set_focus_position", wp.x, wp.z)
|
||
if world and world.has_method("sample_height"):
|
||
wp.y = float(world.call("sample_height", wp.x, wp.z))
|
||
player.global_position = wp
|
||
if net_play and "_last_sent_pos" in net_play:
|
||
net_play._last_sent_pos = player.global_position
|
||
if cam and cam.has_method("snap_to_target"):
|
||
cam.snap_to_target()
|
||
|
||
# 背包道具右键:按当前打开的窗决定动作(商店卖 / 交易放 / 仓库存)。
|
||
# 返回 true = 已消费,inventory_ui 不再 use_item。
|
||
func _on_inv_context(window: int, cell: int) -> bool:
|
||
if shop_ui and shop_ui.is_open():
|
||
shop_ui.sell(cell)
|
||
return true
|
||
if exchange_ui and exchange_ui.is_open():
|
||
exchange_ui.offer(window, cell)
|
||
return true
|
||
if safebox_ui and safebox_ui.is_open():
|
||
safebox_ui.deposit(window, cell)
|
||
return true
|
||
if ENABLE_DRAGON_SOUL and dragon_soul_ui and dragon_soul_ui.is_open():
|
||
dragon_soul_ui.add_cell(window, cell)
|
||
return true
|
||
return false
|
||
|
||
func _unhandled_input(e: InputEvent) -> void:
|
||
if not (e is InputEventKey) or e.echo:
|
||
return
|
||
var key: Key = e.keycode
|
||
if ui and ui.has_method("blocks_game_input") and ui.blocks_game_input(e):
|
||
return
|
||
# The reference keeps a different callback for LSHIFT press/release. The
|
||
# flag is also useful on macOS where Command is the modifier for emotes.
|
||
if key == KEY_SHIFT:
|
||
_quick_page_mode = e.pressed
|
||
return
|
||
# game.py SetPCTargetBoard :807 —— app.IsPressed(app.DIK_LCONTROL)。参考端在点 PC 的
|
||
# 那一刻轮询 LCONTROL 按住态;poc 把按下 / 松开喂给 net_play,点 PC 时再查(seam ⑫)。
|
||
if key == KEY_CTRL:
|
||
if net_play and net_play.has_method("set_lcontrol_down"):
|
||
net_play.set_lcontrol_down(e.pressed)
|
||
return
|
||
if not e.pressed:
|
||
match key:
|
||
KEY_SPACE:
|
||
if net_play and net_play.has_method("set_attack_key"):
|
||
net_play.set_attack_key(false)
|
||
KEY_ALT:
|
||
_set_names(false)
|
||
KEY_Q, KEY_E:
|
||
if cam and cam.has_method("set_key_orbit"):
|
||
cam.set_key_orbit(0)
|
||
KEY_R, KEY_F:
|
||
if cam and cam.has_method("set_key_zoom"):
|
||
cam.set_key_zoom(0)
|
||
KEY_T, KEY_G:
|
||
if cam and cam.has_method("set_key_pitch"):
|
||
cam.set_key_pitch(0)
|
||
return
|
||
|
||
var command: bool = e.ctrl_pressed or e.meta_pressed
|
||
match key:
|
||
KEY_ENTER, KEY_KP_ENTER:
|
||
if chat and not chat.is_typing():
|
||
chat.focus_input()
|
||
get_viewport().set_input_as_handled()
|
||
KEY_ALT:
|
||
_set_names(true)
|
||
KEY_PRINT:
|
||
_save_screen()
|
||
KEY_SPACE:
|
||
if net_play and net_play.has_method("set_attack_key"):
|
||
net_play.set_attack_key(true)
|
||
KEY_I:
|
||
if inventory: inventory.toggle()
|
||
KEY_K:
|
||
if skills:
|
||
skills.set_job(_job_name())
|
||
skills.toggle()
|
||
KEY_V:
|
||
if char_status_ui: char_status_ui.open("SKILL")
|
||
KEY_C:
|
||
if not command and char_status_ui: char_status_ui.open("STATUS")
|
||
KEY_B:
|
||
if command:
|
||
_send_command("/user_horse_back")
|
||
elif char_status_ui:
|
||
char_status_ui.open("EMOTICON")
|
||
KEY_H:
|
||
if command:
|
||
_send_command("/user_horse_ride")
|
||
elif system_menu_ui:
|
||
# game.py __PressHKey: normal H opens the help window.
|
||
system_menu_ui.toggle_help()
|
||
KEY_J:
|
||
_toggle_horse()
|
||
KEY_N:
|
||
if not command and quest_log: quest_log.toggle()
|
||
KEY_F:
|
||
if command:
|
||
_send_command("/user_horse_feed")
|
||
elif cam and cam.has_method("set_key_zoom"):
|
||
cam.set_key_zoom(1)
|
||
KEY_G:
|
||
if command:
|
||
_send_command("/ride")
|
||
elif _show_names and guild_ui:
|
||
guild_ui.toggle()
|
||
elif cam and cam.has_method("set_key_pitch"):
|
||
cam.set_key_pitch(1)
|
||
KEY_Q:
|
||
if command:
|
||
if quest_log and quest_log.has_method("toggle_buttons"): quest_log.toggle_buttons()
|
||
elif cam and cam.has_method("set_key_orbit"):
|
||
cam.set_key_orbit(-1)
|
||
KEY_E:
|
||
if not command and cam and cam.has_method("set_key_orbit"):
|
||
cam.set_key_orbit(1)
|
||
KEY_R:
|
||
if not command and cam and cam.has_method("set_key_zoom"):
|
||
cam.set_key_zoom(-1)
|
||
KEY_T:
|
||
if not command and cam and cam.has_method("set_key_pitch"):
|
||
cam.set_key_pitch(-1)
|
||
KEY_L:
|
||
if not command and chat and chat.has_method("toggle_log"): chat.toggle_log()
|
||
KEY_M:
|
||
if not command and atlas_ui: atlas_ui.toggle()
|
||
KEY_KP_ADD:
|
||
if minimap and minimap.has_method("get_scale"):
|
||
minimap.set_scale(float(minimap.get_scale()) * 1.15)
|
||
KEY_KP_SUBTRACT:
|
||
if minimap and minimap.has_method("get_scale"):
|
||
minimap.set_scale(float(minimap.get_scale()) / 1.15)
|
||
KEY_Z:
|
||
if not command and ground_items: ground_items.try_pickup()
|
||
KEY_O:
|
||
if not command and ENABLE_DRAGON_SOUL and dragon_soul_ui: dragon_soul_ui.toggle()
|
||
KEY_ESCAPE:
|
||
if system_menu_ui:
|
||
# ui_manager consumes topmost windows before this reaches ESC.
|
||
system_menu_ui.toggle()
|
||
_:
|
||
if int(key) == 96 or int(e.physical_keycode) == 96:
|
||
if not command and ground_items: ground_items.try_pickup()
|
||
elif key >= KEY_F1 and key <= KEY_F4 and quickbar:
|
||
# game.py __PressQuickSlot(4..7), independent of page selection.
|
||
quickbar.activate(4 + (key - KEY_F1))
|
||
elif key >= KEY_1 and key <= KEY_9:
|
||
var n: int = key - KEY_1
|
||
if command:
|
||
_emote(n)
|
||
elif _quick_page_mode and n < 4 and quickbar:
|
||
quickbar.set_page(n)
|
||
elif n < 4 and quickbar:
|
||
quickbar.activate(n)
|
||
|
||
func _set_names(visible: bool) -> void:
|
||
_show_names = visible
|
||
if net_world and net_world.has_method("set_names_visible"):
|
||
net_world.set_names_visible(visible)
|
||
|
||
# GameOptionUI 显示开关 radio 选中(uigameoption RefreshAlwaysShowName 系)。
|
||
func _on_display_option(key: String, value: int) -> void:
|
||
if key == "always_show_name" and net_world and net_world.has_method("set_always_show_name"):
|
||
net_world.set_always_show_name(value == 1)
|
||
elif key == "show_damage" and net_world and net_world.has_method("set_show_damage"):
|
||
net_world.set_show_damage(value == 1)
|
||
elif key == "name_color" and net_world and net_world.has_method("set_empire_name_mode"):
|
||
net_world.set_empire_name_mode(value == 1)
|
||
elif key == "target_board" and net_play and net_play.has_method("set_view_other_empire_target"):
|
||
net_play.set_view_other_empire_target(value == 1)
|
||
|
||
func _send_command(text: String) -> void:
|
||
if client and client.has_method("say"):
|
||
client.say(0, text)
|
||
|
||
func _toggle_horse() -> void:
|
||
if client == null:
|
||
return
|
||
var main: Dictionary = client.get_entity(client.get_main_vid()) if client.has_method("get_entity") else {}
|
||
if int(main.get("mount_vnum", 0)) != 0:
|
||
_send_command("/unmount")
|
||
return
|
||
if private_shop_ui and private_shop_ui.has_method("is_open") and private_shop_ui.is_open():
|
||
return
|
||
if not client.has_method("get_inventory") or not client.has_method("use_item"):
|
||
return
|
||
for item in client.get_inventory():
|
||
if int(item.get("vnum", 0)) in [71114, 71116, 71118, 71120]:
|
||
client.use_item(1, int(item.get("cell", 0)))
|
||
return
|
||
|
||
func _save_screen() -> void:
|
||
var texture := get_viewport().get_texture()
|
||
if texture == null:
|
||
return
|
||
var dir := OS.get_user_data_dir().path_join("screenshots")
|
||
DirAccess.make_dir_recursive_absolute(dir)
|
||
texture.get_image().save_png(dir.path_join("screen_%d.png" % Time.get_ticks_msec()))
|
||
|
||
# 表情快捷键(§8.6 / §4.8)—— 参考端 Ctrl+1..9 = chrmgr.SetEmoticon(-1, n) + net.SendEmoticon(n)。
|
||
# W3 在 §8.6 里补全:本地立即播表情动作 + 发 classic 表情包。这里先留统一入口。
|
||
func _emote(index: int) -> void:
|
||
if client and client.has_method("send_emoticon"):
|
||
client.send_emoticon(index)
|
||
|
||
func _play_emoticon(index: int) -> void:
|
||
# playersettingmodule.py registers these in this exact order (0..11).
|
||
const EFFECTS := ["sweat", "money", "happy", "like", "love_s", "angry",
|
||
"aha", "gloom", "sorry", "!_mix_back", "question", "fish"]
|
||
if fx and player and index >= 0 and index < EFFECTS.size():
|
||
fx.spawn(EFFECTS[index], player, true)
|
||
|
||
func _job_name() -> String:
|
||
var r := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3
|
||
return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][r]
|
||
|
||
func _fx_host(vid: int) -> Node3D:
|
||
if vid == client.get_main_vid():
|
||
return player
|
||
return net_world.node_for(vid) if net_world else null
|
||
|
||
func _player_bubble(text: String) -> void:
|
||
if player == null:
|
||
return
|
||
var old := player.get_node_or_null("bubble")
|
||
if old:
|
||
old.queue_free()
|
||
var lbl := Label3D.new()
|
||
lbl.name = "bubble"
|
||
lbl.text = text
|
||
lbl.position.y = 2.35
|
||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||
lbl.no_depth_test = true
|
||
lbl.pixel_size = 0.0055
|
||
lbl.outline_size = 6
|
||
player.add_child(lbl)
|
||
# §8.8:本地玩家气泡寿命对齐 CPythonTextTail gs_TextTail_LivingTime(默认 5s)
|
||
var tw := create_tween()
|
||
tw.tween_interval(4.0)
|
||
tw.tween_property(lbl, "modulate:a", 0.0, 1.0)
|
||
tw.tween_callback(lbl.queue_free)
|
||
|
||
# --- 允许 P2 之后注入真模型 --------------------------------------------------
|
||
|
||
# factory: func(entity_dict) -> Node3D(有 set_anim_state(String) 就被驱动)
|
||
func set_entity_model_factory(factory: Callable) -> void:
|
||
if net_world:
|
||
net_world.set_model_factory(factory)
|
||
|
||
var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试)
|
||
var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性)
|
||
|
||
# 默认工厂:远端 PC -> RemotePlayerView;怪 / NPC -> MobView。失败返回 null 用占位胶囊。
|
||
func _make_entity_model(d: Dictionary) -> Node3D:
|
||
if bool(d.get("is_main", false)) or _assets == "":
|
||
return null
|
||
var ch_type := int(d.get("ch_type", -1))
|
||
var race := int(d.get("race", 0))
|
||
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
|
||
if ch_type == 0:
|
||
if race < 0 or _remote_player_view_cache.get(race, true) == false:
|
||
return null
|
||
var pv := RemotePlayerView.new()
|
||
var parts: Array = d.get("parts", []) if d.get("parts", []) is Array else []
|
||
if pv.build_remote(_assets, race, parts, item_list, proto, pump):
|
||
pv.set_audio(_audio)
|
||
_remote_player_view_cache[race] = true
|
||
return pv
|
||
_remote_player_view_cache[race] = false
|
||
pv.free()
|
||
return null
|
||
if proto == null:
|
||
return null
|
||
if race < 1 or _mob_view_cache.get(race, true) == false:
|
||
return null
|
||
var mv := MobView.new()
|
||
if mv.build(_assets, proto, race, pump):
|
||
mv.set_audio(_audio)
|
||
_mob_view_cache[race] = true
|
||
return mv
|
||
_mob_view_cache[race] = false
|
||
mv.free()
|
||
return null
|
||
|
||
func set_player_model(node: Node3D) -> void:
|
||
if node == null or player == null:
|
||
return
|
||
var old := player
|
||
node.position = old.position
|
||
node.name = "LocalPlayer"
|
||
add_child(node)
|
||
player = node
|
||
cam.target = node
|
||
pc.player = node
|
||
if net_world and net_world.has_method("set_local_node"):
|
||
net_world.set_local_node(node)
|
||
old.queue_free()
|
||
|
||
# --- internals -------------------------------------------------------------
|
||
|
||
# setup() 协程用:让出一帧(在树里才 await,测试里直接返回)。
|
||
func _yield() -> void:
|
||
if is_inside_tree() and get_tree() != null:
|
||
await get_tree().process_frame
|
||
|
||
func _build_world(assets_root: String, map_path: String) -> void:
|
||
if not ClassDB.class_exists("Metin2World"):
|
||
push_warning("GameScene: Metin2World 未注册(扩展没编?)")
|
||
return
|
||
_env_from_msenv = false
|
||
world = ClassDB.instantiate("Metin2World")
|
||
world.set("auto_load", false) # 别让 _ready() 自动加载一遍,下面显式调一次拿返回值
|
||
world.set("assets_root", assets_root)
|
||
world.set("map_path", map_path)
|
||
add_child(world)
|
||
var ok: bool = world.call("load_map")
|
||
if not ok:
|
||
push_warning("GameScene: load_map 失败:" + map_path + "(在平地上继续)")
|
||
# 网络实体坐标要对齐地图本地帧(否则角色飘在几公里外的黑 void)
|
||
if world.has_method("get_map_base_cm"):
|
||
MapCoord.set_base(world.get_map_base_cm())
|
||
# Metin2World 会从 .msenv 建自己的 Sun / WorldEnv(真实关照参数)。有就用它,
|
||
# 丢掉 _build_lighting() 的兜底 —— 两套光 + 两个 WorldEnvironment 会互相打架、
|
||
# 也让 world_time 每帧把 .msenv 的色调冲掉。
|
||
var msenv_sun := world.get_node_or_null("Sun") as DirectionalLight3D
|
||
var msenv_we := world.get_node_or_null("WorldEnv")
|
||
if msenv_sun != null and msenv_we != null and msenv_we.environment != null:
|
||
if is_instance_valid(_sun):
|
||
_sun.queue_free()
|
||
for c in get_children():
|
||
if c is WorldEnvironment: # 兜底那个(.msenv 的挂在 world 下,不是这里的直接子)
|
||
c.queue_free()
|
||
_sun = msenv_sun
|
||
_env = msenv_we.environment
|
||
_env_from_msenv = true
|
||
elif _sun == null or not is_instance_valid(_sun) or _sun.get_parent() != self:
|
||
# A map without .msenv still needs the fallback lighting. This branch is
|
||
# mainly for a cross-map reload from an outdoor .msenv map.
|
||
_build_lighting()
|
||
# .msenv 换过 Environment 后,把持久化的雾等设置重新贴上去
|
||
if system_option_ui and system_option_ui.has_method("_apply_all"):
|
||
system_option_ui._apply_all()
|
||
|
||
func _map_loaded() -> bool:
|
||
if world == null:
|
||
return false
|
||
var rep: Dictionary = world.call("get_load_report")
|
||
return int(rep.get("map_size_x", 0)) > 0
|
||
|
||
func _resolve_map_path(requested: String, forced_pos: Variant = null) -> String:
|
||
var pos: Variant = forced_pos
|
||
if pos == null:
|
||
if client == null or _assets == "" or not client.has_method("get_main_vid"):
|
||
return requested
|
||
var vid := int(client.get_main_vid())
|
||
if vid == 0 or not client.has_method("get_entity"):
|
||
return requested
|
||
var entity: Dictionary = client.get_entity(vid)
|
||
pos = entity.get("pos_cm", null)
|
||
if not (pos is Vector3):
|
||
if not (pos is Vector2):
|
||
return requested
|
||
var map_pos := Vector2(pos.x, pos.y)
|
||
|
||
var settings: Array[String] = []
|
||
_collect_map_settings(_assets, settings)
|
||
var best := requested
|
||
var best_score := 1000000
|
||
for setting_path: String in settings:
|
||
var bounds := _map_bounds(setting_path)
|
||
if bounds.is_empty():
|
||
continue
|
||
var base: Vector2 = bounds["base"]
|
||
var size: Vector2i = bounds["size"]
|
||
var max_x := base.x + float(size.x) * 25600.0
|
||
var max_y := base.y + float(size.y) * 25600.0
|
||
if map_pos.x < base.x or map_pos.x >= max_x or map_pos.y < base.y or map_pos.y >= max_y:
|
||
continue
|
||
var root := _assets.trim_suffix("/")
|
||
var rel_setting := setting_path.substr(root.length() + 1)
|
||
var candidate := rel_setting.get_base_dir()
|
||
var lower := candidate.to_lower()
|
||
# Prefer the normal outdoor map over duplicate patch/season copies when
|
||
# several map packages advertise the same world-space rectangle.
|
||
var score := candidate.split("/").size()
|
||
if lower.begins_with("outdoor"):
|
||
score -= 20
|
||
if lower.begins_with("season") or lower.begins_with("metin2_patch"):
|
||
score += 20
|
||
if candidate == requested:
|
||
score -= 1000
|
||
if score < best_score:
|
||
best_score = score
|
||
best = candidate
|
||
|
||
if best != requested:
|
||
print("[GameScene] map resolved by server position ", map_pos, " -> ", best)
|
||
return best
|
||
|
||
func _collect_map_settings(dir_path: String, out: Array[String]) -> void:
|
||
var dir := DirAccess.open(dir_path)
|
||
if dir == null:
|
||
return
|
||
for file_name: String in dir.get_files():
|
||
if file_name.to_lower() == "setting.txt":
|
||
out.append(dir_path.path_join(file_name))
|
||
for child: String in dir.get_directories():
|
||
if child in [".godot", ".git", "build", "export"]:
|
||
continue
|
||
_collect_map_settings(dir_path.path_join(child), out)
|
||
|
||
func _map_bounds(setting_path: String) -> Dictionary:
|
||
var f := FileAccess.open(setting_path, FileAccess.READ)
|
||
if f == null:
|
||
return {}
|
||
var base := Vector2.ZERO
|
||
var size := Vector2i.ZERO
|
||
var have_base := false
|
||
var have_size := false
|
||
while not f.eof_reached():
|
||
var fields := f.get_line().replace("\t", " ").strip_edges().split(" ", false)
|
||
if fields.size() < 3:
|
||
continue
|
||
match String(fields[0]).to_lower():
|
||
"baseposition":
|
||
base = Vector2(float(fields[1]), float(fields[2]))
|
||
have_base = true
|
||
"mapsize":
|
||
size = Vector2i(int(fields[1]), int(fields[2]))
|
||
have_size = size.x > 0 and size.y > 0
|
||
if not have_base or not have_size:
|
||
return {}
|
||
return {"base": base, "size": size}
|
||
|
||
func _build_lighting() -> void:
|
||
var sun := DirectionalLight3D.new()
|
||
sun.name = "FallbackSun"
|
||
sun.rotation = Vector3(deg_to_rad(-50), deg_to_rad(40), 0)
|
||
sun.light_energy = 1.1
|
||
sun.shadow_enabled = true
|
||
add_child(sun)
|
||
_sun = sun
|
||
var we := WorldEnvironment.new()
|
||
var e := Environment.new()
|
||
e.background_mode = Environment.BG_COLOR
|
||
e.background_color = Color(0.55, 0.62, 0.72)
|
||
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||
e.ambient_light_color = Color(0.5, 0.55, 0.62)
|
||
e.ambient_light_energy = 0.42
|
||
e.tonemap_mode = Environment.TONE_MAPPER_LINEAR
|
||
e.tonemap_exposure = 0.82
|
||
e.adjustment_enabled = true
|
||
e.adjustment_saturation = 1.05
|
||
e.adjustment_contrast = 1.08
|
||
e.adjustment_brightness = 0.88
|
||
we.environment = e
|
||
add_child(we)
|
||
_env = e
|
||
|
||
func _on_main_set(vid: int) -> void:
|
||
if not _main_sync_ready or _main_loading or vid <= 0 or client == null:
|
||
return
|
||
if vid != int(client.get_main_vid()):
|
||
return # A queued event may belong to the previous character.
|
||
# GC_MAIN_CHARACTER can arrive after GameScene has subscribed. Mark this VID
|
||
# as local before building its view so NetWorld removes (and never recreates)
|
||
# the generic remote-player capsule for our own character.
|
||
if net_world and net_world.has_method("set_local_vid"):
|
||
net_world.set_local_vid(vid)
|
||
if net_world and net_world.has_method("set_local_node") and player:
|
||
net_world.set_local_node(player)
|
||
var e: Dictionary = client.get_entity(vid)
|
||
if e.is_empty() or player == null:
|
||
return
|
||
var net_pos: Variant = e.get("pos", null)
|
||
if not net_pos is Vector3 or not net_pos.is_finite():
|
||
return
|
||
# setup can only choose a map from data already received. Resolve again
|
||
# once the late authoritative position is available, before placing/viewing.
|
||
if _main_map_vid != vid:
|
||
_main_map_vid = vid
|
||
if _assets != "":
|
||
var next_map := _resolve_map_path(map_path, Vector2(net_pos.x * 100.0, -net_pos.z * 100.0))
|
||
if next_map != map_path:
|
||
_main_loading = true
|
||
_reload_map_for_warp(net_pos)
|
||
_main_loading = false
|
||
var key := "%d:%d:%s" % [vid, int(e.get("race", -1)), _assets]
|
||
if key != _main_view_key:
|
||
_main_view_key = key
|
||
_main_attempts = 0
|
||
_main_retry_at = 0
|
||
_main_model_attempted = false
|
||
_model_built = false
|
||
# 首次拿到 race -> 换成真 Metin2Model
|
||
if not _model_built and _assets != "" and int(e.get("race", -1)) in range(8) \
|
||
and _main_attempts < MAIN_MODEL_MAX_ATTEMPTS and Time.get_ticks_msec() >= _main_retry_at:
|
||
_main_model_attempted = true
|
||
_main_attempts += 1
|
||
_main_loading = true
|
||
var generation := _main_generation
|
||
var pv = _create_main_view()
|
||
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
|
||
var built: bool = pv.build(_assets, int(e.get("race", 0)), pump)
|
||
_main_loading = false
|
||
# build() polls the network. World reset / new main data may arrive
|
||
# inside it; do not publish a view belonging to an obsolete generation.
|
||
if generation != _main_generation or is_queued_for_deletion() \
|
||
or vid != int(client.get_main_vid()) \
|
||
or int(client.get_entity(vid).get("race", -1)) != int(e.get("race", -1)):
|
||
pv.free()
|
||
return
|
||
if built:
|
||
pv.set_audio(_audio)
|
||
set_player_model(pv)
|
||
_model_built = true
|
||
if net_play:
|
||
net_play.player_view = pv
|
||
if pv.anim and pv.anim.has_signal("motion_event"):
|
||
pv.anim.motion_event.connect(_on_local_motion_event)
|
||
if pc and pc.has_signal("anim_state") and not pc.anim_state.is_connected(_on_main_anim_state):
|
||
pc.anim_state.connect(_on_main_anim_state)
|
||
if equip_model:
|
||
equip_model.set_race(int(e.get("race", 0)))
|
||
equip_model.refresh()
|
||
else:
|
||
_main_retry_at = Time.get_ticks_msec() + MAIN_MODEL_RETRY_MS
|
||
push_warning("GameScene: 主角模型加载失败(vid=%d race=%d attempt=%d/%d)" % [vid, int(e.get("race", 0)), _main_attempts, MAIN_MODEL_MAX_ATTEMPTS])
|
||
pv.free()
|
||
# Polling for model readiness must not snap a moving player to the initial
|
||
# server position on every frame or on duplicate main-character events.
|
||
if _main_placed_vid == vid:
|
||
return
|
||
_place_player_at_net_pos(net_pos)
|
||
_main_placed_vid = vid
|
||
|
||
func _create_main_view() -> Node3D:
|
||
return PlayerView.new()
|
||
|
||
func _on_main_anim_state(state: String) -> void:
|
||
if is_instance_valid(player) and player.has_method("set_anim_state"):
|
||
player.set_anim_state(state)
|
||
|
||
func _sync_main_character() -> void:
|
||
if not _main_sync_ready or _main_loading or player == null or not is_instance_valid(player):
|
||
return
|
||
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
|
||
return
|
||
var vid := int(client.get_main_vid())
|
||
if vid == 0:
|
||
return
|
||
var e: Dictionary = client.get_entity(vid)
|
||
# Do not mark the attempt until the authoritative character row exists. The
|
||
# phase packet can precede the row by several frames on a busy connection.
|
||
if e.is_empty():
|
||
return
|
||
_on_main_set(vid)
|
||
|
||
# AppFlow 的唯一生命周期协调者在 GameScene 装配完成后调用,绑定当前场景的音频对象。
|
||
# GameScene 不创建 AppLifecycle,避免切屏时重复监听暂停 / 恢复通知。
|
||
func lifecycle_audio() -> Node:
|
||
return _audio
|
||
|
||
func _make_placeholder_player() -> Node3D:
|
||
var root := Node3D.new()
|
||
root.name = "LocalPlayer"
|
||
var mesh := MeshInstance3D.new()
|
||
var cap := CapsuleMesh.new()
|
||
cap.radius = 0.35
|
||
cap.height = 1.8
|
||
mesh.mesh = cap
|
||
mesh.position.y = 0.9
|
||
var mat := StandardMaterial3D.new()
|
||
mat.albedo_color = Color(0.35, 0.6, 1.0)
|
||
mesh.material_override = mat
|
||
root.add_child(mesh)
|
||
return root
|