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
This commit is contained in:
shenlei
2026-09-02 15:13:01 +09:00
co-authored by Claude Sonnet 5
parent 70f200e885
commit f32064c74a
36 changed files with 4473 additions and 202 deletions
+102 -20
View File
@@ -19,6 +19,7 @@ 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")
@@ -107,8 +108,10 @@ 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
@@ -118,14 +121,14 @@ var _guild_invite_dialog: ConfirmationDialog
var _gift_dialog: AcceptDialog
func setup(m2client: Node, assets_root: String,
map_path: String = "OutdoorA1/metin2_map_a1") -> void:
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(map_path)
map_path = _resolve_map_path(initial_map_path)
_build_lighting()
# 关键:setup 是协程。每个重活之间让出一帧,好让 M2Client._process 抽 socket
# (否则整段 ~5s 同步阻塞会漏 PONG → 服务器 10s 后 "peer closed")。
@@ -179,6 +182,11 @@ func setup(m2client: Node, assets_root: String,
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)
@@ -194,7 +202,7 @@ func setup(m2client: Node, assets_root: String,
fx = EffectRegistry.new()
fx.setup(_assets)
# 技能(P6)—— K 键技能窗,数字键 1-9 快捷栏F1-F4 切页+ 技能特效表
# 技能(P6)—— K 键技能窗,数字键 1-4 → 快捷栏 0-3 / F1-F4 → 4-7 / Ctrl+数字 = 表情+ 技能特效表
if assets_root != "":
skill_table = SkillTable.new()
for lang in ["en", "common"]:
@@ -378,6 +386,8 @@ func setup(m2client: Node, assets_root: String,
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))
@@ -495,12 +505,62 @@ func _skill_master(sid: int) -> int:
return int(s.get("master", 0))
return 0
# GC_WARP —— 同服传送:直接把玩家挪到新坐标(Y 交给地形贴地)。
# 跨服(same_server=false)留给 P10 的重连串场
# GC_WARP —— 40250 的参考端无论是否“同服”都会重连到 lAddr:wPort
# world_reset 先清旧实体,随后这里重载地图并把玩家放到新坐标
func _on_warp(pos: Vector3, same_server: bool) -> void:
if not same_server:
push_warning("GC_WARP 到别的服务器 %s —— 跨服重连归 P10" % pos)
_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"):
@@ -556,13 +616,26 @@ func _unhandled_input(e: InputEvent) -> void:
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)
# 参考端 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]
@@ -642,6 +715,7 @@ 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)
@@ -667,6 +741,10 @@ func _build_world(assets_root: String, map_path: String) -> void:
_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()
@@ -677,16 +755,20 @@ func _map_loaded() -> bool:
var rep: Dictionary = world.call("get_load_report")
return int(rep.get("map_size_x", 0)) > 0
func _resolve_map_path(requested: String) -> String:
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)
var pos: Variant = entity.get("pos_cm", null)
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):
return requested
if not (pos is Vector2):
return requested
var map_pos := Vector2(pos.x, pos.y)
var settings: Array[String] = []
_collect_map_settings(_assets, settings)
@@ -700,7 +782,7 @@ func _resolve_map_path(requested: String) -> String:
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 pos.x < base.x or pos.x >= max_x or pos.y < base.y or pos.y >= max_y:
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)
@@ -720,7 +802,7 @@ func _resolve_map_path(requested: String) -> String:
best = candidate
if best != requested:
print("[GameScene] map resolved by server position ", pos, " -> ", best)
print("[GameScene] map resolved by server position ", map_pos, " -> ", best)
return best
func _collect_map_settings(dir_path: String, out: Array[String]) -> void: