Files
mtgodot-poc/project/game_scene.gd
T
shenleiandClaude Opus 5 e0c1853909 fix(net): 主角 VID 归参考端所有,GC_CHARACTER_DEL 不再把玩家「去主角化」
实机故障:进入游戏后整屏灰紫,主角还是蓝色胶囊占位体,2D UI 与小地图正常。

根因(已实证,非推测)。`EntityStore::mut_despawn()` 在 despawn 的 vid 等于
主角 vid 时把 `m_main_vid` 清零。40250 服务器在 spawn-in 头几秒会对玩家自己的
vid 反复下发 GC_CHARACTER_DEL + GC_CHARACTER_ADD(视野重算,实测约 10 次)。
第一次 DEL 就把玩家永久去主角化:`get_main_vid()` 返回 0 →
`_sync_main_character()` 直接 return → 建模代码从未跑过(main_attempts=0)→
占位胶囊留在 y=0 → 相机跟着它钻到 C1 地形底下 → 满屏灰紫。地图其实一直是好的
(chunks_built 20/20、objects_placed 565)。

参考端怎么做(40250 ClientVS22)。主角身份属于 `CNetworkActorManager::m_dwMainVID`,
只由 `SetMainActorVID()`(GC_MAIN_CHARACTER)写,只在管理器析构时清;
`RemoveActor()` 删 actor 行和角色实例,但绝不碰它;重新加入时
`__AppendCharacterManagerActor()` 用 `kCreateData.m_isMain = __IsMainActorVID(dwVID)`
重新推导,并再跑一次 `SetMainCharacterIndex(dwVID)`。

修法(entity_store.cpp):
- `mut_despawn()` 只删行、只发 Despawn,不动 m_main_vid;m2dev 分支的
  GC_CHARACTER_DEL 同规则。
- `mut_spawn_full()` 在 `e.vid == m_main_vid` 时把 `is_main` 置回 true,
  并重新广播 MainSet,对齐参考端的重新推导。

顺带落地的渲染修复(同一次实机排查中定位):
- libgr2 `sample_pose()` 增加 `root_offset` 形参;`Metin2AnimPlayer::apply_pose()`
  显式传单位阵而不是模型的 InitialPlacement。Metin2 动画 gr2 的根骨轨道自带骨盆
  高度,再乘一次 IP 会把整个人抬高一个骨盆高(武 99.4cm / 刺 92.6 / 巫 103.7 /
  萨 95.5,正好各自的 IP.z)——即此前的「人物悬空」。`bind_pose()` 语义不变。
- `Metin2Model::_load_hair()` 改用与身体/武器同一条 `make_material()`
  (specular_disabled)而不是 roughness=1.0 的 StandardMaterial3D,头发不再被
  PBR 高光洗成奶白色块。

取证与回归工具:
- `project/net_trace.gd`(新):主角 vid 生命周期取证。挂在 AppFlow 之后逐帧读
  `get_main_vid()`,归零时打印 `NETTRACE: MAIN_LOST was=.. now=0 reason=..` 加最近
  24 条环形事件;`reason` 区分 despawn 与 silent(reset_for_map_change 清空
  m_changes,是静默清零)。`MT_NETTRACE=1` 打开逐事件日志。
- `game_scene.gd`:诊断 tick 支持重复采样(分辨主角装配是「没跑」还是「没跑完」),
  新增 main_sync_ready / main_loading / main_view_key / main_map_vid /
  main_retry_in_ms 字段;`MT_DIAG_SHOT=<png>` 让游戏自己存一帧实机画面
  (screencapture 拍到的是终端窗口,没用)。
- `project/model_render_test.gd` + `test/rendering/model_baseline.json`(新):
  八个种族的模型渲染基线(--bless 重新落盘)。
- `project/char_bench_test.gd`(新):无网络无地图的单角色实验台,五变体 ×
  六机位出证据图,只产图不做阈值判定。
- `project/package_render_test.gd`(新):导出包内的渲染自检入口
  (导出模板不接受 --script,只能走 MT_TEST_MODE)。
- `/project/build/` 加入 .gitignore(渲染用例的落图输出)。

实机复验(真实服务器,非模拟):
- 修前 `LIVE_SMOKE FAIL: 主角色 VID 有效`;
  `NETTRACE: MAIN_LOST was=25910 now=0 reason=despawn t=+7737ms`,
  环里紧跟 `spawn vid=25910 is_main=false`。
- 修后 `LIVE_SMOKE RESULT: PASS`,main_vid=25933、entities=19;
  40s 实机跑 16 次诊断采样全部 model_built=True / main_attempts=1 /
  main_sync_ready=True,player_pos 落在 C1 地形上而非 y=0;
  MT_DIAG_SHOT 存下的帧里地形、建筑、NPC、树木、真实角色模型、HUD、小地图俱全。

回归:gamescene_test / equip_model_test / gamescene_visual_test(feet_gap
0.001373)/ model_render_test(8 races)/ char_select_visual_test / 包内
MT_TEST_MODE=render 全绿;ctest 22/23,唯一失败 net.classic_session 已用
「还原本次改动 → 重跑 → diff 失败集合」确认与本次修改无关(失败集合完全相同)。

注:画面整体偏暗属于光照/色调差距,受 PARITY-GAP.md §0 门禁约束(对照帧未落盘前
不做相机/光照/色调/材质调参),本次不碰。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
2026-09-08 17:15:55 +09:00

1580 lines
63 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 地图 BGMM2Client.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 :722strOwnership = 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_worldCPythonSystem::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_worldCPythonSystem::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_worlduigameoption.__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_playuitarget.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
# 一次弓挥击 / 弓技能起手 → 压一个待发 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_type0 普通 / 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