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:
co-authored by
Claude Sonnet 5
parent
70f200e885
commit
f32064c74a
+102
-20
@@ -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 地图 BGM:M2Client.bgm_changed -> Audio。放在独立节点里,W4 补全曲目解析。
|
||||
_bgm_director = BgmDirector.new()
|
||||
add_child(_bgm_director)
|
||||
_bgm_director.bind(client, _audio)
|
||||
|
||||
# UI 层 + 物品 proto + 背包窗(I 键开关)
|
||||
ui = UiManager.new()
|
||||
add_child(ui)
|
||||
@@ -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:
|
||||
# 参考端 __PressNumKey(game.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:
|
||||
# ESC:ui_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:
|
||||
|
||||
Reference in New Issue
Block a user