Metin2 game client (P0–P11) + mobile asset pipeline

Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+686
View File
@@ -0,0 +1,686 @@
# 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 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")
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 _model_built := false
var _audio: 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
func setup(m2client: Node, assets_root: String,
map_path: String = "OutdoorA1/metin2_map_a1") -> void:
client = m2client
_assets = assets_root
_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)
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)
# 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-9 快捷栏,F1-F4 切页,+ 技能特效表
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
# 任务 / 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
refine_ui = RefineUI.new()
add_child(refine_ui)
refine_ui.setup(client, ui, proto)
dragon_soul_ui = DragonSoulUI.new()
add_child(dragon_soul_ui)
dragon_soul_ui.setup(client, ui, proto)
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("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
# .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 —— 同服传送:直接把玩家挪到新坐标(Y 交给地形贴地)。
# 跨服(same_server=false)留给 P10 的重连串场。
func _on_warp(pos: Vector3, same_server: bool) -> void:
if not same_server:
push_warning("GC_WARP 到别的服务器 %s —— 跨服重连归 P10" % pos)
return
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 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 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:
quickbar.set_page(e.keycode - KEY_F1)
elif e.keycode >= KEY_1 and e.keycode <= KEY_9 and quickbar:
quickbar.activate(e.keycode - KEY_1)
elif e.keycode == KEY_ESCAPE and system_menu_ui:
# ESCui_manager 先关最顶层窗口并吃掉事件;到这里说明没有窗口打开。
system_menu_ui.toggle()
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
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
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
# .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 _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