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
+14 -15
View File
@@ -1,19 +1,18 @@
# AppFlow (P10) —— 登录 → 选人 → 进游戏 的串场控制器。
#
# 把它做场景根:一个 M2Client 贯穿全程,LoadingScreen / ReconnectUI 常驻。
# 把它做场景根:一个 M2Client 贯穿全程,LoadingScreen 常驻。
# var af := preload("res://app_flow.gd").new()
# add_child(af)
# af.start(assets_root) # assets_root 传给 game_scene
#
# 状态机:LOGIN → SELECT → GAME。M2Client 信号驱动切屏。
# 断线:在 GAME 里保留场景交给 ReconnectUI 自动重连;在 LOGIN/SELECT 回登录
# 断线:任何阶段都回到登录阶段,和 ClientVS22 的 SetLoginPhase 一致
extends Node
const ServerInfoRes = preload("res://net/serverinfo.gd")
const ChannelStatus = preload("res://net/channel_status.gd")
const LoadingScreen = preload("res://ui/loading_screen.gd")
const CharSelectScreen = preload("res://ui/char_select_screen.gd")
const ReconnectUI = preload("res://ui/reconnect_ui.gd")
const GameScene = preload("res://game_scene.gd")
enum { LOGIN, SELECT, GAME }
@@ -24,7 +23,6 @@ var _state := LOGIN
var _assets := ""
var _ui: CanvasLayer # 登录 / 选人的临时界面层
var _loading: CanvasLayer
var _reconnect: CanvasLayer
var _game: Node
var build_game_scene := true # 测试里置 false,只跑状态机不建重场景
var _sel_server := 0
@@ -53,9 +51,6 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
_loading = LoadingScreen.new()
add_child(_loading)
_loading.setup(client)
_reconnect = ReconnectUI.new()
add_child(_reconnect)
_reconnect.setup(client)
_goto_login()
if auto_login:
call_deferred("_do_connect")
@@ -83,8 +78,12 @@ func _on_char_list(list: Array) -> void:
sc.set_chars(list)
return
_goto_select()
if auto_login and not _chars.is_empty() and client.has_method("select_character"):
client.select_character(int(_chars[0].get("index", 0)))
if auto_login and not _chars.is_empty():
var idx := int(_chars[0].get("index", 0))
if client.has_method("enter_game"):
client.enter_game(idx)
elif client.has_method("select_character"):
client.select_character(idx)
func _on_char_name_changed(pid: int, name: String) -> void:
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
@@ -121,11 +120,9 @@ func _on_login_failed(reason: String) -> void:
_goto_login()
_set_status("登录失败:%s" % reason)
func _on_disconnected(_reason: String) -> void:
# GAME 里:保留 game_sceneReconnectUI 负责重连遮罩
# LOGIN/SELECT 里:回登录
if _state != GAME:
_goto_login()
func _on_disconnected(reason: String) -> void:
_goto_login()
_set_status("连接断开:%s" % reason)
# --- 切屏 ------------------------------------------------------------------
@@ -323,7 +320,9 @@ func _build_char_list() -> void:
_ui.add_child(screen)
screen.setup(client, _assets, _chars)
screen.select_requested.connect(func(idx: int):
if client.has_method("select_character"):
if client.has_method("enter_game"):
client.enter_game(idx)
elif client.has_method("select_character"):
client.select_character(idx))
screen.back_requested.connect(func():
if client.has_method("disconnect_from_server"):
+39
View File
@@ -0,0 +1,39 @@
# BgmDirector —— 把 M2Client.bgm_changed 信号翻译成 Audio 的 BGM 播放。
#
# 存在的理由:§9.1 的地图 BGM 消费点,从 net_world.gd 里拆出来,避免 W2(可见性)
# 和 W4(音频)同时改 net_world.gd。真正的「地图 → 曲目」解析和音量应用由 W4 在
# 这里补全(见 docs/CLIENT-GAP-FIX.md §9.1)。
#
# 用法:
# var bd := preload("res://bgm_director.gd").new()
# add_child(bd)
# bd.bind(client, audio)
extends Node
var _client: Node
var _audio: Node
# 服务器最近一次下发的 BGM(曲目 id 或路径)和音量(<0 表示未指定)。
var current_track := ""
var current_volume := -1.0
func bind(client: Node, audio: Node) -> void:
_client = client
_audio = audio
if _client and _client.has_signal("bgm_changed"):
_client.bgm_changed.connect(_on_bgm_changed)
func _on_bgm_changed(name: String, volume: float) -> void:
current_track = name
if volume >= 0.0:
current_volume = volume
if _audio and "master_bgm" in _audio:
_audio.master_bgm = clampf(volume, 0.0, 1.0)
if _audio == null:
return
if name == "":
if _audio.has_method("stop_bgm"):
_audio.stop_bgm()
return
if _audio.has_method("play_bgm"):
_audio.play_bgm(name)
+8 -3
View File
@@ -1,6 +1,6 @@
# 选人界面 —— 3 个旋转台角色 + 九宫格信息面板 + 选择/创建按钮。
# 数据来自 M2Client.char_list(联机)或 --mock 合成列表(离线开发)。
# 选中 -> M2Client.select_character(index) -> entered_game。
# 选中 -> M2Client.enter_game(index) / DirectEnter -> entered_game。
# 用法(离线预览):godot --path project --script charselect.gd --mock --quit-after 120
extends SceneTree
@@ -209,8 +209,13 @@ func _enter() -> void:
_set_status("空槽不能进入(创建角色未实现)")
return
if _client:
_client.call("select_character", int(s.get("index", _sel)))
_set_status("select_character(%d) sent" % int(s.get("index", _sel)))
var idx := int(s.get("index", _sel))
if _client.has_method("enter_game"):
_client.call("enter_game", idx)
_set_status("enter_game(%d) sent" % idx)
else:
_client.call("select_character", idx)
_set_status("select_character(%d) sent" % idx)
else:
_set_status("[mock] 进入游戏:%s" % s.get("name"))
+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:
+17
View File
@@ -7,6 +7,7 @@ extends CanvasLayer
var _world: Node
var _player: Node3D
var _minimap_rect: TextureRect
var _minimap_frame: Control
var _dot: ColorRect
var _map_w_m := 1024.0
var _map_h_m := 1280.0
@@ -43,6 +44,21 @@ func setup(world: Node, player: Node3D) -> void:
_build_hotbar()
_build_inventory()
func reload_world(new_world: Node) -> void:
_world = new_world
if _world == null:
return
var rep: Dictionary = _world.call("get_load_report")
_map_w_m = float(rep.get("map_size_x", 1)) * 256.0
_map_h_m = float(rep.get("map_size_y", 1)) * 256.0
if _minimap_frame and is_instance_valid(_minimap_frame):
_minimap_frame.queue_free()
_minimap_frame = null
_minimap_rect = null
_dot = null
if int(rep.get("map_size_x", 0)) > 0 and int(rep.get("map_size_y", 0)) > 0:
_build_minimap(int(rep["map_size_x"]), int(rep["map_size_y"]))
# --- real UI texture loading (§6.1) -------------------------------------------
func _ui_tex(name: String) -> Texture2D:
@@ -149,6 +165,7 @@ func _build_minimap(nx: int, ny: int) -> void:
big.blit_rect(img, Rect2i(0, 0, cell, cell), Vector2i(tx * cell, ty * cell))
var mm_h := 192 * ny / nx if nx > 0 else 192
var frame := _nine("board", 32, 128)
_minimap_frame = frame
frame.set_anchors_preset(Control.PRESET_TOP_RIGHT)
frame.position = Vector2(-192 - 24 - 12, 12)
frame.size = Vector2(192 + 24, mm_h + 24)
+4 -1
View File
@@ -134,7 +134,10 @@ func _on_chars(list: Array) -> void:
if list.size() > 0 and (_auto_select or "--auto" in OS.get_cmdline_args()):
var idx: int = int(list[0].get("index", 0))
_msg("自动选择槽位 %d ..." % idx)
_client.call("select_character", idx)
if _client.has_method("enter_game"):
_client.call("enter_game", idx)
else:
_client.call("select_character", idx)
func _on_entered_game() -> void:
_msg("已进入游戏 —— 加载场景")
+15
View File
@@ -117,6 +117,21 @@ func set_local_vid(vid: int) -> void:
func set_local_node(node: Node3D) -> void:
_local_node = node
# ClientVS22 destroys the network actor manager when Loading starts. This is
# called from M2Client.world_reset before the replacement connection sends its
# first GC_MAIN_CHARACTER/GC_CHARACTER_ADD burst.
func clear_for_map_change() -> void:
for vid in _by_vid.keys():
var node: Node3D = _by_vid[vid]
if is_instance_valid(node):
node.queue_free()
entity_removed.emit(int(vid))
_by_vid.clear()
_main_vid = 0
_pvp_relations.clear()
_duel_opponents.clear()
_duel_cannot_attack = false
func node_for(vid: int) -> Node3D:
return _by_vid.get(vid, null)
+1 -1
View File
@@ -74,7 +74,7 @@ func _test_m2client_surface() -> void:
var c: Object = ClassDB.instantiate("M2Client")
get_root().add_child(c)
for m in ["move", "character_position", "sync_positions", "request_warp", "fishing", "request_dungeon", "attack", "set_target", "say", "shoot", "add_fly_targeting", "change_name", "select_empire", "download_guild_symbol", "get_guild_symbol", "script_select_item", "quest_cancel", "party_use_skill", "get_skill_group", "get_entity", "get_entities",
"get_main_vid", "get_main_pid", "get_points", "get_target", "select_character", "reconnect",
"get_main_vid", "get_main_pid", "get_points", "get_target", "select_character", "enter_game", "reconnect",
"suspend", "resume", "move_item", "use_item", "drop_item", "drop_item_count", "use_item_to_item", "give_item", "pickup_item",
"get_inventory", "get_equipment", "get_belt_inventory", "get_view_equipment", "get_item", "get_ground_items", "get_pvp_relations", "get_duel", "get_lover", "get_land_areas", "get_observers", "is_observer_mode", "get_observer_count", "has_mobile_flag", "combo_skill_enabled", "get_stamina_state", "whisper",
"party_invite", "party_answer", "party_leave", "party_set_distribute", "party_set_state", "get_party", "get_party_distribute_mode",
+4 -1
View File
@@ -119,7 +119,10 @@ func _run() -> void:
_ck(picked and fc2.selects == [0], "flow: pick char -> select_character(0)")
fc2.entered_game.emit()
_ck(af.state() == AppFlow.GAME, "flow: entered_game -> GAME")
# 在 SELECT 时断线 -> 回 LOGIN
# 在 GAME 时断线 -> 回 LOGINClientVS22 SetLoginPhase 语义)
fc2.disconnected.emit("game dropped")
_ck(af.state() == AppFlow.LOGIN, "flow: disconnect in GAME -> LOGIN")
# 再次收到角色列表时允许回到 SELECT
fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}])
_ck(af.state() == AppFlow.SELECT, "flow: back to SELECT")
fc2.disconnected.emit("dropped")
+8
View File
@@ -64,6 +64,14 @@ func close() -> void:
ui.close(_win)
_win = null
func reload_map(metin_world: Node, name := "") -> void:
world = metin_world
if name != "":
map_name = name
if is_open():
close()
open()
func _build_window() -> void:
_win = Control.new()
_win.name = "AtlasWindow"
+7
View File
@@ -45,6 +45,13 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
_sel = -1
_cube_slots.clear()
_craft_in_flight = false
func _on_open(npc_vnum: int) -> void:
if npc_vnum <= 0:
return
+8
View File
@@ -32,6 +32,14 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
_next_display = 0
_offered_cells.clear()
if _gold_input:
_gold_input.text = ""
func offer(inv_window: int, inv_cell: int) -> void:
if not is_open() or inv_window < 0 or inv_cell < 0:
return
+4
View File
@@ -29,6 +29,10 @@ func setup(m2client: Node, parent: Node) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
func toggle() -> void:
_root.visible = not _root.visible
if _root.visible:
+7
View File
@@ -77,6 +77,13 @@ func _on_removed(vid: int) -> void:
_by_vid.erase(vid)
n.queue_free()
func clear_for_map_change() -> void:
for vid in _by_vid.keys():
var node: Node3D = _by_vid[vid]
if is_instance_valid(node):
node.queue_free()
_by_vid.clear()
func _process(dt: float) -> void:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null:
+7
View File
@@ -29,6 +29,13 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
if is_instance_valid(_password_dialog):
_password_dialog.queue_free()
_password_dialog = null
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
+4
View File
@@ -62,6 +62,10 @@ func setup(m2client: Node, parent: Node, dlg: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
# --- 로컬 플레이어가 파티장인가 ---
func _local_is_leader() -> bool:
if client == null or not client.has_method("get_main_vid"):
+4
View File
@@ -22,6 +22,10 @@ func setup(m2client: Node, parent: Node) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
func toggle() -> void:
_root.visible = not _root.visible
if _root.visible:
+5
View File
@@ -26,6 +26,11 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
_cur = {}
func _name_of(vnum: int) -> String:
if vnum == 0:
return "-"
+7
View File
@@ -34,6 +34,13 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
if is_instance_valid(_password_dialog):
_password_dialog.queue_free()
_password_dialog = null
func deposit(inv_window: int, inv_cell: int) -> void:
if not is_open() or inv_window < 0 or inv_cell < 0:
return
+3
View File
@@ -36,6 +36,9 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCoun
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
_close()
func open() -> void:
_root.visible = true
_err.text = ""
+4
View File
@@ -29,6 +29,10 @@ func setup(m2client: Node, sun_light: DirectionalLight3D, environment: Environme
if t > 0:
_on_time(t)
func set_lighting(sun_light: DirectionalLight3D, environment: Environment = null) -> void:
sun = sun_light
env = environment
func _on_time(server_epoch: int) -> void:
_epoch = float(server_epoch)
_since = 0.0