Files
mtgodot-poc/project/game_scene.gd
T
shenleiandClaude Sonnet 5 f32064c74a 40250 classic: parallel-dev checkpoint through increment 49 (W0 interface freeze)
Captures the uncommitted parallel-development work (increments 46-49) on the
40250 classic client port, folded into docs/CLIENT-GAP.md + CLIENT-GAP-FIX.md.

Increment 49 (W0 interface-freeze batch, Phase 1 prerequisite) lands the
cross-workflow shared-file scaffolding so the 4 Phase 1 workflows can run in
isolated worktrees without contention:

- entity_store.{h,cpp}: Entity gains empire/affect_flags/owner_vid/state_flags;
  new mut_spawn_full() (single-shot two-packet merge, §2.3), mut_ownership()
  (§2.5), mut_map_bgm() + take_bgm_dirty()/bgm_name()/bgm_volume() (§9.1),
  drain_dirty() (§2.1/§2.5 bare-field-update queue); mut_char_info() now stores
  empire; reset_for_map_change() clears m_dirty.
- m2_client.cpp: new bgm_changed(name, volume) signal; classic + m2dev pump
  loops drain drain_dirty() -> entity_info and take_bgm_dirty() -> bgm_changed;
  entity_dict() exposes the 4 new keys.
- classic/classic_parser.{h,cpp}: GC_MAIN_CHARACTER3_BGM /
  GC_MAIN_CHARACTER4_BGM_VOL route bgm_name/bgm_volume to mut_map_bgm() (was
  discarded); new m_pending_actor staging map (W1 fills §2.2 merge logic).
- project/bgm_director.gd (new) + game_scene.gd: BGM consumption split out of
  net_world.gd so W2/W4 don't collide; §8.6/§4.8 keybind convergence (digits
  1-4 -> quickslots 0-3, F1-F4 -> quickslots 4-7, Ctrl+1..9 -> _emote()).

Tests: cmake --build build clean; ctest 16/16; 15 GDScript regressions green
(netbridge, gamescene, netplay, p9, p10, p2b, p8, system_menu_ui, skill,
combat_fx, skill_fx, player_motion, char_status_ui, chat, inventory), no skips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 15:13:01 +09:00

907 lines
34 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 + AppLifecycle
#
# 由 login.gd 在 entered_game 时实例化:
# var gs := preload("res://game_scene.gd").new()
# get_root().add_child(gs)
# gs.setup(m2client, assets_root) # map_path 可选,默认 a1
#
# P0 本地玩家用占位胶囊;P2 装备系统落地后换真 Metin2Model(见 set_player_model)。
extends Node3D
const GameCamera = preload("res://game_camera.gd")
const PlayerCtl = preload("res://player_controller.gd")
const NetWorld = preload("res://net_world.gd")
const NetPlay = preload("res://net_play.gd")
const Hud = preload("res://hud.gd")
const AppLifecycle = preload("res://app_lifecycle.gd")
const Audio = preload("res://audio.gd")
const BgmDirector = preload("res://bgm_director.gd")
const UiManager = preload("res://ui/ui_manager.gd")
const InventoryUI = preload("res://ui/inventory_ui.gd")
const ItemListDB = preload("res://ui/item_list.gd")
const EquipModel = preload("res://ui/equip_model.gd")
const PlayerView = preload("res://ui/player_view.gd")
const MobView = preload("res://ui/mob_view.gd")
const GroundItems = preload("res://ui/ground_items.gd")
const ViewEquipmentUI = preload("res://ui/view_equipment_ui.gd")
const ChatUI = preload("res://ui/chat_ui.gd")
const DeathUI = preload("res://ui/death_ui.gd")
const EffectRegistry = preload("res://fx/effect_registry.gd")
const SkillFx = preload("res://fx/skill_fx.gd")
const SkillTable = preload("res://ui/skill_table.gd")
const SkillUI = preload("res://ui/skill_ui.gd")
const CharStatusUI = preload("res://ui/char_status_ui.gd")
const SystemOptionUI = preload("res://ui/system_option_ui.gd")
const GameOptionUI = preload("res://ui/game_option_ui.gd")
const SystemMenuUI = preload("res://ui/system_menu_ui.gd")
const Quickbar = preload("res://ui/quickbar.gd")
const QuestDialog = preload("res://ui/quest_dialog.gd")
const QuestLog = preload("res://ui/quest_log.gd")
const SelectItemUI = preload("res://ui/select_item_ui.gd")
const PartyUI = preload("res://ui/party_ui.gd")
const FriendUI = preload("res://ui/friend_ui.gd")
const LoveUI = preload("res://ui/love_ui.gd")
const ShopUI = preload("res://ui/shop_ui.gd")
const ExchangeUI = preload("res://ui/exchange_ui.gd")
const SafeboxUI = preload("res://ui/safebox_ui.gd")
const MallUI = preload("res://ui/mall_ui.gd")
const CubeUI = preload("res://ui/cube_ui.gd")
const PrivateShopUI = preload("res://ui/private_shop_ui.gd")
const GuildUI = preload("res://ui/guild_ui.gd")
const RefineUI = preload("res://ui/refine_ui.gd")
const DragonSoulUI = preload("res://ui/dragon_soul_ui.gd")
const Minimap = preload("res://ui/minimap.gd")
const AtlasUI = preload("res://ui/atlas_ui.gd")
const WorldTime = preload("res://world/world_time.gd")
const Weather = preload("res://fx/weather.gd")
# 龙魂是协议兼容层的一部分,但龙魂 40250 服务器没有这个功能。
# 保留 DragonSoulUI / ds_refine 代码供后续兼容,当前客户端不创建入口、不消费背包右键。
const ENABLE_DRAGON_SOUL := false
var client: Node
var world: Node
var player: Node3D
var cam: Camera3D
var pc: Node
var net_world: Node
var net_play: Node
var hud: Node
var ui: CanvasLayer # UiManager
var proto: Node # Metin2Proto
var inventory: Node # InventoryUI
var item_list: RefCounted # ItemList
var equip_model: Node # EquipModel
var ground_items: Node # GroundItems
var view_equipment_ui: Node # ViewEquipmentUI
var chat: Node # ChatUI
var fx: RefCounted # EffectRegistry
var skill_fx: RefCounted # SkillFx
var skill_table: RefCounted # SkillTable
var skills: Node # SkillUI
var char_status_ui: Node # CharStatusUI
var system_option_ui: Node # SystemOptionUI
var game_option_ui: Node # GameOptionUI
var system_menu_ui: Node # SystemMenuUI
var quickbar: Node # Quickbar
var quest_dialog: Node # QuestDialog
var quest_log: Node # QuestLog
var select_item_ui: Node # SelectItemUI
var party_ui: Node # PartyUI
var friend_ui: Node # FriendUI
var love_ui: Node # LoveUI
var shop_ui: Node # ShopUI
var exchange_ui: Node # ExchangeUI
var safebox_ui: Node # SafeboxUI
var mall_ui: Node # MallUI
var cube_ui: Node # CubeUI
var private_shop_ui: Node # PrivateShopUI
var guild_ui: Node # GuildUI
var refine_ui: Node # RefineUI
var dragon_soul_ui: Node # DragonSoulUI
var minimap: Node # Minimap
var atlas_ui: Node # AtlasUI
var world_time: Node # WorldTime
var weather: Node3D # Weather
var _assets := ""
var map_path := ""
var _model_built := false
var _audio: Node
var _bgm_director: Node
var _mount: Node3D
var _sun: DirectionalLight3D
var _env: Environment
var _env_from_msenv := false # true = 用 Metin2World 从 .msenv 建的 Sun/WorldEnv
var _pending_shoot_skill := 0
var _guild_invite_dialog: ConfirmationDialog
var _gift_dialog: AcceptDialog
func setup(m2client: Node, assets_root: String,
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
client = m2client
_assets = assets_root
# GC_MAIN_CHARACTER only carries the server-space coordinates, not the map
# folder. The old fixed A1 fallback puts characters from A2/A3 outside the
# rendered terrain, which looks like an empty map. Resolve the map from the
# character position before constructing Metin2World.
map_path = _resolve_map_path(initial_map_path)
_build_lighting()
# 关键:setup 是协程。每个重活之间让出一帧,好让 M2Client._process 抽 socket
# (否则整段 ~5s 同步阻塞会漏 PONG → 服务器 10s 后 "peer closed")。
await _yield()
_build_world(assets_root, map_path)
await _yield()
player = _make_placeholder_player()
add_child(player)
cam = GameCamera.new()
cam.target = player
cam.world = world
add_child(cam)
cam.make_current()
pc = PlayerCtl.new()
pc.player = player
pc.camera = cam
pc.world = world
add_child(pc)
_mount = Node3D.new()
_mount.name = "Entities"
add_child(_mount)
net_world = NetWorld.new()
add_child(net_world)
net_world.world = world
net_world.setup(client, _mount)
net_world.set_local_node(player)
hud = Hud.new()
if _map_loaded():
hud.setup(world, player) # 含小地图 / 快捷栏 / 背包
else:
add_child(hud)
hud.call("_build_status") # 无地图兜底:只搭状态栏(血 / 蓝 / 经验 / 等级)
net_play = NetPlay.new()
add_child(net_play)
net_play.setup(client, pc, net_world, hud)
net_play.camera = cam
await _yield()
_audio = Audio.new()
add_child(_audio)
if assets_root != "":
_audio.setup(assets_root)
await _yield()
var life := AppLifecycle.new()
add_child(life)
life.bind(client, _audio)
# §9.1 地图 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)
# 聊天窗(Enter 聚焦输入)
chat = ChatUI.new()
add_child(chat)
chat.setup(client, ui)
if net_world and net_world.has_signal("main_bubble"):
net_world.main_bubble.connect(_player_bubble)
# 特效注册表(P5)—— GC_SPECIFIC_EFFECT(.mse 路径) / GC_SPECIAL_EFFECT(内建 id) 在实体上播
fx = EffectRegistry.new()
fx.setup(_assets)
# 技能(P6)—— K 键技能窗,数字键 1-4 → 快捷栏 0-3 / F1-F4 → 4-7 / Ctrl+数字 = 表情,+ 技能特效表
if assets_root != "":
skill_table = SkillTable.new()
for lang in ["en", "common"]:
if skill_table.load_file(assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang)):
break
skill_fx = SkillFx.new()
skill_fx.setup(fx, skill_table)
skills = SkillUI.new()
add_child(skills)
skills.setup(client, skill_table, ui)
char_status_ui = CharStatusUI.new()
add_child(char_status_ui)
char_status_ui.setup(ui, client, assets_root)
quickbar = Quickbar.new()
add_child(quickbar)
quickbar.setup(client, skill_table, ui, func() -> Node: return player)
if quickbar.has_signal("skill_activated"):
quickbar.skill_activated.connect(func(sid: int):
if skill_fx and player:
skill_fx.spawn_skill(sid, _skill_master(sid), player)
# Bow skills send CG_SHOOT from the motion event, matching
# CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot.
if skill_table and skill_table.has_method("is_ranged") and skill_table.is_ranged(sid):
_pending_shoot_skill = sid
get_tree().create_timer(1.5).timeout.connect(func():
if _pending_shoot_skill == sid:
_pending_shoot_skill = 0)
)
if client.has_signal("effect_cue"):
client.effect_cue.connect(func(vid: int, name: String, special: int):
var host: Node3D = _fx_host(vid)
if host == null:
return
if name != "":
fx.spawn(name, host, true) # GC_SPECIFIC_EFFECT
elif special >= 0 and skill_fx:
skill_fx.spawn_special(special, host)) # GC_SPECIAL_EFFECT 内建 id
if client.has_signal("gift_available"):
client.gift_available.connect(_on_gift_available)
# 任务 / NPC(P7)—— 对话窗自动弹,J 键任务日志
quest_dialog = QuestDialog.new()
add_child(quest_dialog)
quest_dialog.setup(client, ui)
quest_log = QuestLog.new()
add_child(quest_log)
quest_log.setup(client, ui)
# 死亡窗 + 状态图标条(P4
var death := DeathUI.new()
add_child(death)
death.setup(client, ui)
if client.has_signal("affect_added"):
var refresh_affects := func(_a = null): if hud and hud.has_method("set_affects"): hud.set_affects(client.get_affects())
client.affect_added.connect(refresh_affects)
client.affect_removed.connect(refresh_affects)
await _yield()
if ClassDB.class_exists("Metin2Proto") and assets_root != "":
proto = ClassDB.instantiate("Metin2Proto")
add_child(proto)
var ip := assets_root.path_join("locale/locale/en/item_proto")
if FileAccess.file_exists(ip):
proto.call("load_item_proto", ip)
await _yield()
var mp := assets_root.path_join("locale/locale/en/mob_proto")
if FileAccess.file_exists(mp):
proto.call("load_mob_proto", mp)
await _yield()
net_play.proto = proto # 按 race 分类 NPC/怪(bType 在本 fork 不可靠)
if net_world and "name_resolver" in net_world:
net_world.name_resolver = net_play._entity_name # 头顶名字:怪走 mob_proto
# 怪 / NPC 真模型:race -> mob_proto.name -> monster/npc 目录
net_world.set_model_factory(_make_entity_model)
if assets_root != "":
item_list = ItemListDB.new()
item_list.load_file(assets_root.path_join("locale/locale/common/item_list.txt"))
await _yield()
inventory = InventoryUI.new()
add_child(inventory)
inventory.setup(ui, client, proto, assets_root, item_list)
# 装备 → 模型部件:武器 / 盾 / 身体 / 头盔 / 头发
equip_model = EquipModel.new()
add_child(equip_model)
equip_model.setup(client, item_list, func() -> Node: return player, assets_root)
equip_model.main_getter = func() -> int: return client.get_main_vid()
equip_model.proto = proto # armor shape 走 item_proto values[3] + specular
# 地面掉落物
ground_items = GroundItems.new()
add_child(ground_items)
ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list)
view_equipment_ui = ViewEquipmentUI.new()
add_child(view_equipment_ui)
view_equipment_ui.setup(client, ui, proto, item_list)
# 选魔石窗(EventManager [SELECT_ITEM] -> interfacemodule.BINARY_OpenSelectItemWindow
select_item_ui = SelectItemUI.new()
add_child(select_item_ui)
select_item_ui.setup(ui, client, proto, assets_root, item_list)
if quest_dialog and quest_dialog.has_signal("select_item_requested"):
quest_dialog.select_item_requested.connect(select_item_ui.open)
# 社交 / 商店 / 仓库(P8
party_ui = PartyUI.new()
add_child(party_ui)
party_ui.setup(client, ui)
friend_ui = FriendUI.new()
add_child(friend_ui)
friend_ui.setup(client, ui)
love_ui = LoveUI.new()
add_child(love_ui)
love_ui.setup(client, ui)
if chat and friend_ui.has_signal("whisper_to"):
friend_ui.whisper_to.connect(func(nm: String):
if chat.has_method("start_whisper"):
chat.start_whisper(nm))
shop_ui = ShopUI.new()
add_child(shop_ui)
shop_ui.setup(client, ui, proto, item_list)
exchange_ui = ExchangeUI.new()
add_child(exchange_ui)
exchange_ui.setup(client, ui, proto)
safebox_ui = SafeboxUI.new()
add_child(safebox_ui)
safebox_ui.setup(client, ui, proto)
mall_ui = MallUI.new()
add_child(mall_ui)
mall_ui.setup(client, ui, proto)
cube_ui = CubeUI.new()
add_child(cube_ui)
cube_ui.setup(client, ui, proto)
private_shop_ui = PrivateShopUI.new()
add_child(private_shop_ui)
private_shop_ui.setup(client, ui, proto, assets_root)
guild_ui = GuildUI.new()
add_child(guild_ui)
guild_ui.setup(client, ui, skill_table)
guild_ui.mark_image_provider = _guild_mark_upload_image
if client.has_signal("guild_invite_ask"):
client.guild_invite_ask.connect(_on_guild_invite)
refine_ui = RefineUI.new()
add_child(refine_ui)
refine_ui.setup(client, ui, proto)
if ENABLE_DRAGON_SOUL:
dragon_soul_ui = DragonSoulUI.new()
add_child(dragon_soul_ui)
dragon_soul_ui.setup(client, ui, proto)
else:
dragon_soul_ui = null
await _yield()
if inventory and "context_consumer" in inventory:
inventory.context_consumer = _on_inv_context
# 世界系统(P9)—— 小地图、昼夜、天气、传送
minimap = Minimap.new()
add_child(minimap)
minimap.setup(client, ui, func() -> Node3D: return player)
atlas_ui = AtlasUI.new()
add_child(atlas_ui)
atlas_ui.setup(world, ui, func() -> Node3D: return player, map_path.get_file(), client)
world_time = WorldTime.new()
add_child(world_time)
# .msenv 已经给了固定的白天关照(A1 没有昼夜预设)——别让 world_time 每帧覆盖它。
# 只在走兜底关照时驱动昼夜。
world_time.setup(client, _sun if not _env_from_msenv else null,
_env if not _env_from_msenv else null)
weather = Weather.new()
add_child(weather)
weather.setup(cam)
# 系统设置 / 游戏设置 / ESC 系统菜单(uisystem.SystemDialog + uiSystemOption + uiGameOption
system_option_ui = SystemOptionUI.new()
add_child(system_option_ui)
system_option_ui.setup(ui, assets_root, _audio,
func() -> Camera3D: return cam,
func() -> Environment: return _env)
game_option_ui = GameOptionUI.new()
add_child(game_option_ui)
game_option_ui.setup(ui, client, assets_root)
system_menu_ui = SystemMenuUI.new()
add_child(system_menu_ui)
system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui)
if client.has_signal("warp"):
client.warp.connect(_on_warp)
if client.has_signal("world_reset"):
client.world_reset.connect(_on_world_reset)
if client.has_signal("channel_changed") and hud and hud.has_method("set_channel"):
client.channel_changed.connect(func(c): hud.set_channel(c))
client.entity_main_set.connect(_on_main_set)
if client.get_main_vid() != 0: # 重连 / 已在局内
# net_play._on_main_set 的信号早在它连上前就发过了 —— 手动补上关键那步:
# 告诉 net_world 主角 vid 由本地 player 代表,别给它生成节点(否则 catch_up 会
# 在玩家身上叠一个大蓝胶囊)。
if net_world and net_world.has_method("set_local_vid"):
net_world.set_local_vid(client.get_main_vid())
if net_play and net_play.has_method("_on_main_set"):
net_play._on_main_set(client.get_main_vid())
_on_main_set(client.get_main_vid())
# setup() 是协程 —— 进来时进游戏的 spawn burst 早被 M2Client.pump_game 抽干、
# net_world 还没连上信号。把当前所有实体(怪 / NPC / 别的玩家)补建一遍。
if net_world and net_world.has_method("catch_up"):
net_world.catch_up()
set_process_unhandled_input(true)
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
func _guild_mark_upload_image() -> Image:
var p := "res://ui/default_guild_mark.png"
if ResourceLoader.exists(p):
var tex: Texture2D = load(p)
if tex:
return tex.get_image()
var img := Image.create(16, 12, false, Image.FORMAT_RGBA8)
for y in 12:
for x in 16:
img.set_pixel(x, y, Color(0.8, 0.2, 0.2) if (x + y) % 2 == 0 else Color(0.9, 0.85, 0.3))
return img
func _on_guild_invite(guild_id: int, guild_name: String) -> void:
if _guild_invite_dialog and is_instance_valid(_guild_invite_dialog):
_guild_invite_dialog.queue_free()
_guild_invite_dialog = ConfirmationDialog.new()
_guild_invite_dialog.title = "公会邀请"
_guild_invite_dialog.dialog_text = "加入公会“%s”?" % guild_name
_guild_invite_dialog.ok_button_text = "加入"
_guild_invite_dialog.cancel_button_text = "拒绝"
ui.add_child(_guild_invite_dialog)
_guild_invite_dialog.confirmed.connect(func():
client.guild_answer_invite(guild_id, true)
_guild_invite_dialog.queue_free())
_guild_invite_dialog.canceled.connect(func():
client.guild_answer_invite(guild_id, false)
_guild_invite_dialog.queue_free())
_guild_invite_dialog.popup_centered()
func _on_gift_available() -> void:
# 40250's Gift_Show has no corresponding claim packet in the shipped
# PythonNetworkStream. Keep the server notification visible instead of
# silently consuming it; any reward data remains owned by the gift UI/backend.
if ui == null:
return
if _gift_dialog and is_instance_valid(_gift_dialog):
_gift_dialog.popup_centered()
return
_gift_dialog = AcceptDialog.new()
_gift_dialog.title = "礼物"
_gift_dialog.dialog_text = "有新的礼物可查看。"
_gift_dialog.ok_button_text = "知道了"
ui.add_child(_gift_dialog)
_gift_dialog.confirmed.connect(func():
if is_instance_valid(_gift_dialog):
_gift_dialog.queue_free())
_gift_dialog.popup_centered()
# .msa MotionEventData 分派(对齐 GameLib/RaceMotionData EMotionEventType):
# 1 EFFECT / 10 EFFECT_TO_TARGET → 挂特效;2 SCREEN_WAVING → 震屏;
# 3 SCREEN_FLASHING → 屏闪;5 SOUND / 有 sound 字段 → 3D 音;6 FLY / 9 WARP → 钩子。
# 数字在 .msa 文本里与运行时枚举有历史偏差,故先按「字段是否有值」再按 type 兜底。
func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vector3) -> void:
var anchor: Node3D = player
if sound != "" and _audio:
_audio.play_at(sound, anchor.global_position, anchor)
if effect != "" and fx:
# EffectPosition 是骨骼本地锚点(cm);简版直接挂角色根 + y 偏移
var gp := anchor.global_position + Vector3(pos.x, pos.y, pos.z) * 0.01
fx.spawn_at(effect.get_file().get_basename(), _mount, gp, true)
match type:
2: # SCREEN_WAVING
if cam and cam.has_method("shake"):
cam.shake(0.06, 9.0)
3: # SCREEN_FLASHING
_screen_flash(0.12)
6: # FLY —— 弓技能在动画飞行点发送 CG_SHOOT
if _pending_shoot_skill != 0 and client and client.has_method("shoot"):
client.shoot(_pending_shoot_skill)
_pending_shoot_skill = 0
9: # WARP —— 传送起点,不是投射物
pass
var _flash_rect: ColorRect
func _screen_flash(secs: float) -> void:
if hud == null:
return
if _flash_rect == null or not is_instance_valid(_flash_rect):
_flash_rect = ColorRect.new()
_flash_rect.color = Color(1, 1, 1, 0)
_flash_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
ui.add_child(_flash_rect)
_flash_rect.color.a = 0.5
var tw := create_tween()
tw.tween_property(_flash_rect, "color:a", 0.0, secs)
# skill id -> master_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:
if net_world and net_world.has_method("clear_for_map_change"):
net_world.clear_for_map_change()
if ground_items and ground_items.has_method("clear_for_map_change"):
ground_items.clear_for_map_change()
# The reference loading phase closes transient interaction state. Keep
# persistent account data (inventory, quests, friends) intact.
for widget in [shop_ui, exchange_ui, safebox_ui, mall_ui, cube_ui,
private_shop_ui, select_item_ui, quest_dialog, quest_log, party_ui,
friend_ui, refine_ui, dragon_soul_ui]:
if widget == null or not is_instance_valid(widget):
continue
if widget.has_method("close"):
widget.call("close")
elif widget.has_method("_close"):
widget.call("_close")
if atlas_ui and atlas_ui.has_method("close"):
atlas_ui.close()
if ui and ui.has_method("close_top"):
while ui.close_top():
pass
if _audio and _audio.has_method("stop_bgm"):
_audio.stop_bgm(0.15)
func _reload_map_for_warp(pos: Vector3) -> void:
var server_pos := Vector2(pos.x * 100.0, -pos.z * 100.0)
var next_map := _resolve_map_path(map_path, server_pos)
var old_world := world
map_path = next_map
_build_world(_assets, map_path)
if old_world and is_instance_valid(old_world) and old_world != world:
old_world.queue_free()
if cam:
cam.world = world
if pc:
pc.world = world
if net_world:
net_world.world = world
if hud and hud.has_method("reload_world") and _map_loaded():
hud.reload_world(world)
if atlas_ui and atlas_ui.has_method("reload_map"):
atlas_ui.reload_map(world, map_path.get_file())
if world_time and world_time.has_method("set_lighting"):
world_time.set_lighting(_sun if not _env_from_msenv else null,
_env if not _env_from_msenv else null)
_place_player_at_net_pos(pos)
func _place_player_at_net_pos(pos: Vector3) -> void:
if player:
var wp := MapCoord.to_world(pos)
if world and world.has_method("set_focus_position"):
world.call("set_focus_position", wp.x, wp.z)
if world and world.has_method("sample_height"):
wp.y = float(world.call("sample_height", wp.x, wp.z))
player.global_position = wp
if net_play and "_last_sent_pos" in net_play:
net_play._last_sent_pos = player.global_position
if cam and cam.has_method("snap_to_target"):
cam.snap_to_target()
# 背包道具右键:按当前打开的窗决定动作(商店卖 / 交易放 / 仓库存)。
# 返回 true = 已消费,inventory_ui 不再 use_item。
func _on_inv_context(window: int, cell: int) -> bool:
if shop_ui and shop_ui.is_open():
shop_ui.sell(cell)
return true
if exchange_ui and exchange_ui.is_open():
exchange_ui.offer(window, cell)
return true
if safebox_ui and safebox_ui.is_open():
safebox_ui.deposit(window, cell)
return true
if ENABLE_DRAGON_SOUL and dragon_soul_ui and dragon_soul_ui.is_open():
dragon_soul_ui.add_cell(window, cell)
return true
return false
func _unhandled_input(e: InputEvent) -> void:
if not (e is InputEventKey and e.pressed and not e.echo):
return
if e.keycode in [KEY_ENTER, KEY_KP_ENTER] and chat and not chat.is_typing():
chat.focus_input()
get_viewport().set_input_as_handled()
elif e.keycode == KEY_I and inventory:
inventory.toggle()
elif e.keycode == KEY_K and skills:
skills.set_job(_job_name())
skills.toggle()
elif e.keycode in [KEY_V, KEY_C] and char_status_ui:
char_status_ui.toggle()
elif e.keycode == KEY_J and quest_log:
quest_log.toggle()
elif e.keycode == KEY_O and friend_ui:
friend_ui.toggle()
elif e.keycode == KEY_G and guild_ui:
guild_ui.toggle()
elif e.keycode == KEY_L and ENABLE_DRAGON_SOUL and dragon_soul_ui:
dragon_soul_ui.toggle()
elif e.keycode == KEY_M and atlas_ui:
atlas_ui.toggle()
elif e.keycode == KEY_Z and ground_items:
ground_items.try_pickup()
elif e.keycode >= KEY_F1 and e.keycode <= KEY_F4 and quickbar:
# 参考端 game.py:318 —— F1..F4 固定 __PressQuickSlot(4..7)。
quickbar.activate(4 + (e.keycode - KEY_F1))
elif e.keycode >= KEY_1 and e.keycode <= KEY_9:
# 参考端 __PressNumKeygame.py:431):
# Ctrl+1..9 → 表情;否则 1..4 → 快捷栏 0..3,5..9 无操作。
var n: int = e.keycode - KEY_1
if e.ctrl_pressed or e.meta_pressed:
_emote(n)
elif n < 4 and quickbar:
quickbar.activate(n)
elif e.keycode == KEY_ESCAPE and system_menu_ui:
# ESCui_manager 先关最顶层窗口并吃掉事件;到这里说明没有窗口打开。
system_menu_ui.toggle()
# 表情快捷键(§8.6 / §4.8)—— 参考端 Ctrl+1..9 = chrmgr.SetEmoticon(-1, n) + net.SendEmoticon(n)。
# W3 在 §8.6 里补全:本地立即播表情动作 + 发 classic 表情包。这里先留统一入口。
func _emote(index: int) -> void:
if client and client.has_method("send_emoticon"):
client.send_emoticon(index)
func _job_name() -> String:
var r := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3
return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][r]
func _fx_host(vid: int) -> Node3D:
if vid == client.get_main_vid():
return player
return net_world.node_for(vid) if net_world else null
func _player_bubble(text: String) -> void:
if player == null:
return
var old := player.get_node_or_null("bubble")
if old:
old.queue_free()
var lbl := Label3D.new()
lbl.name = "bubble"
lbl.text = text
lbl.position.y = 2.35
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.pixel_size = 0.0055
lbl.outline_size = 6
player.add_child(lbl)
var tw := create_tween()
tw.tween_interval(3.0)
tw.tween_property(lbl, "modulate:a", 0.0, 1.0)
tw.tween_callback(lbl.queue_free)
# --- 允许 P2 之后注入真模型 --------------------------------------------------
# factory: func(entity_dict) -> Node3D(有 set_anim_state(String) 就被驱动)
func set_entity_model_factory(factory: Callable) -> void:
if net_world:
net_world.set_model_factory(factory)
var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试)
# 默认工厂:怪 / NPC -> MobViewrace = mob_proto vnum)。失败返回 null 用占位胶囊。
func _make_entity_model(d: Dictionary) -> Node3D:
if bool(d.get("is_main", false)) or proto == null or _assets == "":
return null
var race := int(d.get("race", 0))
if race < 1 or _mob_view_cache.get(race, true) == false:
return null
var mv := MobView.new()
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
if mv.build(_assets, proto, race, pump):
_mob_view_cache[race] = true
return mv
_mob_view_cache[race] = false
mv.free()
return null
func set_player_model(node: Node3D) -> void:
if node == null or player == null:
return
var old := player
node.position = old.position
node.name = "LocalPlayer"
add_child(node)
player = node
cam.target = node
pc.player = node
if net_world and net_world.has_method("set_local_node"):
net_world.set_local_node(node)
old.queue_free()
# --- internals -------------------------------------------------------------
# setup() 协程用:让出一帧(在树里才 await,测试里直接返回)。
func _yield() -> void:
if is_inside_tree() and get_tree() != null:
await get_tree().process_frame
func _build_world(assets_root: String, map_path: String) -> void:
if not ClassDB.class_exists("Metin2World"):
push_warning("GameScene: Metin2World 未注册(扩展没编?)")
return
_env_from_msenv = false
world = ClassDB.instantiate("Metin2World")
world.set("auto_load", false) # 别让 _ready() 自动加载一遍,下面显式调一次拿返回值
world.set("assets_root", assets_root)
world.set("map_path", map_path)
add_child(world)
var ok: bool = world.call("load_map")
if not ok:
push_warning("GameScene: load_map 失败:" + map_path + "(在平地上继续)")
# 网络实体坐标要对齐地图本地帧(否则角色飘在几公里外的黑 void)
if world.has_method("get_map_base_cm"):
MapCoord.set_base(world.get_map_base_cm())
# Metin2World 会从 .msenv 建自己的 Sun / WorldEnv(真实关照参数)。有就用它,
# 丢掉 _build_lighting() 的兜底 —— 两套光 + 两个 WorldEnvironment 会互相打架、
# 也让 world_time 每帧把 .msenv 的色调冲掉。
var msenv_sun := world.get_node_or_null("Sun") as DirectionalLight3D
var msenv_we := world.get_node_or_null("WorldEnv")
if msenv_sun != null and msenv_we != null and msenv_we.environment != null:
if is_instance_valid(_sun):
_sun.queue_free()
for c in get_children():
if c is WorldEnvironment: # 兜底那个(.msenv 的挂在 world 下,不是这里的直接子)
c.queue_free()
_sun = msenv_sun
_env = msenv_we.environment
_env_from_msenv = true
elif _sun == null or not is_instance_valid(_sun) or _sun.get_parent() != self:
# A map without .msenv still needs the fallback lighting. This branch is
# mainly for a cross-map reload from an outdoor .msenv map.
_build_lighting()
# .msenv 换过 Environment 后,把持久化的雾等设置重新贴上去
if system_option_ui and system_option_ui.has_method("_apply_all"):
system_option_ui._apply_all()
func _map_loaded() -> bool:
if world == null:
return false
var rep: Dictionary = world.call("get_load_report")
return int(rep.get("map_size_x", 0)) > 0
func _resolve_map_path(requested: String, forced_pos: Variant = null) -> String:
var pos: Variant = forced_pos
if pos == null:
if client == null or _assets == "" or not client.has_method("get_main_vid"):
return requested
var vid := int(client.get_main_vid())
if vid == 0 or not client.has_method("get_entity"):
return requested
var entity: Dictionary = client.get_entity(vid)
pos = entity.get("pos_cm", null)
if not (pos is Vector3):
if not (pos is Vector2):
return requested
var map_pos := Vector2(pos.x, pos.y)
var settings: Array[String] = []
_collect_map_settings(_assets, settings)
var best := requested
var best_score := 1000000
for setting_path: String in settings:
var bounds := _map_bounds(setting_path)
if bounds.is_empty():
continue
var base: Vector2 = bounds["base"]
var size: Vector2i = bounds["size"]
var max_x := base.x + float(size.x) * 25600.0
var max_y := base.y + float(size.y) * 25600.0
if map_pos.x < base.x or map_pos.x >= max_x or map_pos.y < base.y or map_pos.y >= max_y:
continue
var root := _assets.trim_suffix("/")
var rel_setting := setting_path.substr(root.length() + 1)
var candidate := rel_setting.get_base_dir()
var lower := candidate.to_lower()
# Prefer the normal outdoor map over duplicate patch/season copies when
# several map packages advertise the same world-space rectangle.
var score := candidate.split("/").size()
if lower.begins_with("outdoor"):
score -= 20
if lower.begins_with("season") or lower.begins_with("metin2_patch"):
score += 20
if candidate == requested:
score -= 1000
if score < best_score:
best_score = score
best = candidate
if best != requested:
print("[GameScene] map resolved by server position ", map_pos, " -> ", best)
return best
func _collect_map_settings(dir_path: String, out: Array[String]) -> void:
var dir := DirAccess.open(dir_path)
if dir == null:
return
for file_name: String in dir.get_files():
if file_name.to_lower() == "setting.txt":
out.append(dir_path.path_join(file_name))
for child: String in dir.get_directories():
if child in [".godot", ".git", "build", "export"]:
continue
_collect_map_settings(dir_path.path_join(child), out)
func _map_bounds(setting_path: String) -> Dictionary:
var f := FileAccess.open(setting_path, FileAccess.READ)
if f == null:
return {}
var base := Vector2.ZERO
var size := Vector2i.ZERO
var have_base := false
var have_size := false
while not f.eof_reached():
var fields := f.get_line().replace("\t", " ").strip_edges().split(" ", false)
if fields.size() < 3:
continue
match String(fields[0]).to_lower():
"baseposition":
base = Vector2(float(fields[1]), float(fields[2]))
have_base = true
"mapsize":
size = Vector2i(int(fields[1]), int(fields[2]))
have_size = size.x > 0 and size.y > 0
if not have_base or not have_size:
return {}
return {"base": base, "size": size}
func _build_lighting() -> void:
var sun := DirectionalLight3D.new()
sun.name = "FallbackSun"
sun.rotation = Vector3(deg_to_rad(-50), deg_to_rad(40), 0)
sun.light_energy = 1.1
sun.shadow_enabled = true
add_child(sun)
_sun = sun
var we := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0.55, 0.62, 0.72)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.5, 0.55, 0.62)
e.ambient_light_energy = 0.6
we.environment = e
add_child(we)
_env = e
func _on_main_set(vid: int) -> void:
var e: Dictionary = client.get_entity(vid)
if e.is_empty() or player == null:
return
# 首次拿到 race -> 换成真 Metin2Model
if not _model_built and _assets != "" and int(e.get("race", 0)) >= 0:
var pv := PlayerView.new()
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
if pv.build(_assets, int(e.get("race", 0)), pump):
set_player_model(pv)
_model_built = true
if net_play:
net_play.player_view = pv
if pv.anim and pv.anim.has_signal("motion_event"):
pv.anim.motion_event.connect(_on_local_motion_event)
if pc and pc.has_signal("anim_state"):
pc.anim_state.connect(func(s: String):
if is_instance_valid(player) and player.has_method("set_anim_state"):
player.set_anim_state(s))
if equip_model:
equip_model.set_race(int(e.get("race", 0)))
equip_model.refresh()
else:
pv.queue_free()
var p: Vector3 = MapCoord.to_world(e.get("pos", Vector3.ZERO))
if world and world.has_method("set_focus_position"):
world.call("set_focus_position", p.x, p.z) # 先对准,才建得出脚下的区块
if world and world.has_method("sample_height"):
p.y = float(world.call("sample_height", p.x, p.z))
player.position = p
func _make_placeholder_player() -> Node3D:
var root := Node3D.new()
root.name = "LocalPlayer"
var mesh := MeshInstance3D.new()
var cap := CapsuleMesh.new()
cap.radius = 0.35
cap.height = 1.8
mesh.mesh = cap
mesh.position.y = 0.9
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.35, 0.6, 1.0)
mesh.material_override = mat
root.add_child(mesh)
return root