完善客户端功能并同步差距文档

This commit is contained in:
shenlei
2026-09-03 18:40:01 +09:00
parent f93a172296
commit 13ccb8d02d
135 changed files with 21881 additions and 1259 deletions
+345 -63
View File
@@ -2,7 +2,7 @@
#
# 把已有的件拼成一个能玩的场景:
# Metin2World + 本地玩家 + GameCamera + PlayerController + NetWorld + HUD
# + NetPlay(胶水)+ Audio + AppLifecycle
# + NetPlay(胶水)+ Audio
#
# 由 login.gd 在 entered_game 时实例化:
# var gs := preload("res://game_scene.gd").new()
@@ -17,14 +17,16 @@ 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 CursorManager = preload("res://ui/cursor_manager.gd")
const MouseController = preload("res://ui/mouse_controller.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 RemotePlayerView = preload("res://ui/remote_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")
@@ -56,6 +58,7 @@ 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 DungeonState = preload("res://dungeon_state.gd")
const WorldTime = preload("res://world/world_time.gd")
const Weather = preload("res://fx/weather.gd")
@@ -72,6 +75,8 @@ var net_world: Node
var net_play: Node
var hud: Node
var ui: CanvasLayer # UiManager
var cursor_manager: Node
var mouse_controller: Node
var proto: Node # Metin2Proto
var inventory: Node # InventoryUI
var item_list: RefCounted # ItemList
@@ -105,6 +110,7 @@ var refine_ui: Node # RefineUI
var dragon_soul_ui: Node # DragonSoulUI
var minimap: Node # Minimap
var atlas_ui: Node # AtlasUI
var dungeon_state: Node # GC_DUNGEON destination / compass state
var world_time: Node # WorldTime
var weather: Node3D # Weather
var _assets := ""
@@ -116,9 +122,16 @@ var _mount: Node3D
var _sun: DirectionalLight3D
var _env: Environment
var _env_from_msenv := false # true = 用 Metin2World 从 .msenv 建的 Sun/WorldEnv
var _pending_shoot_skill := 0
# CNormalBowAttack_FlyEventHandler_AutoClear 的等价:每次弓挥击 / 弓技能起手压一个
# uSkill`.msa` MOTION_EVENT_TYPE_FLY 帧(_on_local_motion_event 的 type 6)按 FIFO 弹一个
# 发 CG_SHOOT —— pop 即自清(AutoClear),队列天然支持连射多箭在途。§3.6
# 上限兜底:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时丢最旧的,避免无界增长。
var _pending_shots: Array[int] = []
const MAX_PENDING_SHOTS := 8
var _guild_invite_dialog: ConfirmationDialog
var _gift_dialog: AcceptDialog
var _quick_page_mode := false
var _show_names := false
func setup(m2client: Node, assets_root: String,
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
@@ -159,6 +172,14 @@ func setup(m2client: Node, assets_root: String,
net_world.world = world
net_world.setup(client, _mount)
net_world.set_local_node(player)
net_world.entity_added.connect(func(node: Node3D, _vid: int):
if node not in pc.pickables:
pc.pickables.append(node))
net_world.entity_removed.connect(func(vid: int):
for pickable in pc.pickables.duplicate():
if pickable == null or not is_instance_valid(pickable) \
or int(pickable.get_meta("vid", -1)) == vid:
pc.pickables.erase(pickable))
hud = Hud.new()
if _map_loaded():
@@ -171,6 +192,11 @@ func setup(m2client: Node, assets_root: String,
add_child(net_play)
net_play.setup(client, pc, net_world, hud)
net_play.camera = cam
# §8.6 targetBoard.GetTargetVID() -> net_world:选中目标始终强显名字。
if net_play.has_signal("target_changed") and net_world and net_world.has_method("set_target_vid"):
net_play.target_changed.connect(func(vid: int): net_world.set_target_vid(int(vid)))
if _assets != "":
net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表
await _yield()
_audio = Audio.new()
@@ -178,9 +204,6 @@ func setup(m2client: Node, assets_root: String,
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()
@@ -190,11 +213,18 @@ func setup(m2client: Node, assets_root: String,
# UI 层 + 物品 proto + 背包窗(I 键开关)
ui = UiManager.new()
add_child(ui)
cursor_manager = CursorManager.new()
add_child(cursor_manager)
mouse_controller = MouseController.new()
add_child(mouse_controller)
mouse_controller.setup(ui, cursor_manager, _audio)
# 聊天窗(Enter 聚焦输入)
chat = ChatUI.new()
add_child(chat)
chat.setup(client, ui)
chat.setup(client, ui, _assets)
if net_play and net_play.has_signal("fishing_feedback") and chat.has_method("on_fishing_feedback"):
net_play.fishing_feedback.connect(chat.on_fishing_feedback)
if net_world and net_world.has_signal("main_bubble"):
net_world.main_bubble.connect(_player_bubble)
@@ -218,20 +248,35 @@ func setup(m2client: Node, assets_root: String,
char_status_ui.setup(ui, client, assets_root)
quickbar = Quickbar.new()
add_child(quickbar)
quickbar.item_mouse = mouse_controller
quickbar.net_play = net_play # §3.8 修改 1:技能三层校验的运行期上下文
quickbar.setup(client, skill_table, ui, func() -> Node: return player)
# §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。
if net_play:
net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot)
if quickbar.has_signal("skill_rejected"):
quickbar.skill_rejected.connect(func(_sid: int, code: String):
if net_play and net_play.has_signal("cannot_act"):
net_play.cannot_act.emit(code) # HUD 走同一条 OnCannotUseSkill 文案通道
)
if net_play and net_play.has_signal("fishing_feedback") and net_play.has_signal("cannot_act"):
net_play.fishing_feedback.connect(func(code: String): net_play.cannot_act.emit(code))
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.
# Bow skills send CG_SHOOT from the .msa FLY frame, matching
# CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot —— 压队列,
# 不再用 1.5s 定时器兜底(AutoClear 由 FLY 帧 pop 自清)。§3.6
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)
_queue_shot(sid)
)
# 弓普攻起手(net_play._emit_swing):OnSetFlyTarget 已在那里发过 CG_FLY_TARGETING
# 这里只把 uSkill 压进 FLY 帧待发队列(普攻恒 0)。§3.6
if net_play and net_play.has_signal("bow_shot_fired"):
net_play.bow_shot_fired.connect(_queue_shot)
if client.has_signal("effect_cue"):
client.effect_cue.connect(func(vid: int, name: String, special: int):
var host: Node3D = _fx_host(vid)
@@ -241,16 +286,31 @@ func setup(m2client: Node, assets_root: String,
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("emoticon_requested"):
client.emoticon_requested.connect(_play_emoticon)
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_dialog.setup(client, ui, proto, assets_root)
quest_log = QuestLog.new()
add_child(quest_log)
quest_log.setup(client, ui)
if quest_dialog.has_signal("quest_button_received"):
quest_dialog.quest_button_received.connect(func(index: int, title: String, icon_type: String, icon_name: String):
if quest_log and quest_log.has_method("recv_quest"):
quest_log.recv_quest(index, title, icon_type, icon_name))
quest_dialog.quest_button_cleared.connect(func(index: int):
if quest_log and quest_log.has_method("clear_quest"):
quest_log.clear_quest(index))
quest_dialog.opened.connect(func():
if quest_log and quest_log.has_method("set_buttons_suppressed"):
quest_log.set_buttons_suppressed(true))
quest_dialog.closed.connect(func():
if quest_log and quest_log.has_method("set_buttons_suppressed"):
quest_log.set_buttons_suppressed(false))
# 死亡窗 + 状态图标条(P4
var death := DeathUI.new()
@@ -268,6 +328,8 @@ func setup(m2client: Node, assets_root: String,
if FileAccess.file_exists(ip):
proto.call("load_item_proto", ip)
await _yield()
if chat and chat.has_method("set_proto"):
chat.set_proto(proto)
var mp := assets_root.path_join("locale/locale/en/mob_proto")
if FileAccess.file_exists(mp):
proto.call("load_mob_proto", mp)
@@ -275,14 +337,26 @@ func setup(m2client: Node, assets_root: String,
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
if net_world and "kind_resolver" in net_world:
net_world.kind_resolver = net_play._entity_kind # §8.8 名字色分类:怪 / NPC 走 mob_proto bType
if net_world and "title_name_resolver" in net_world and assets_root != "":
# §8.8 称号名:introloading.__RegisterTitleName -> localeInfo.PVP_LEVEL<grade>
var _tt_loc: RefCounted = load("res://locale.gd").new()
_tt_loc.setup(assets_root, "en")
net_world.title_name_resolver = func(g: int) -> String:
var k := "PVP_LEVEL%d" % g
return _tt_loc.t(k) if _tt_loc.has(k) else ""
# 怪 / 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()
if quest_log and quest_log.has_method("set_item_sources"):
quest_log.set_item_sources(proto, item_list, assets_root)
inventory = InventoryUI.new()
add_child(inventory)
inventory.item_mouse = mouse_controller
inventory.setup(ui, client, proto, assets_root, item_list)
# 装备 → 模型部件:武器 / 盾 / 身体 / 头盔 / 头发
equip_model = EquipModel.new()
@@ -294,6 +368,8 @@ func setup(m2client: Node, assets_root: String,
ground_items = GroundItems.new()
add_child(ground_items)
ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list)
pc.set_input_surfaces(cursor_manager, ui, ground_items,
Callable(net_play, "cancel_fishing"), Callable(net_play, "on_ground_click"))
view_equipment_ui = ViewEquipmentUI.new()
add_child(view_equipment_ui)
view_equipment_ui.setup(client, ui, proto, item_list)
@@ -309,6 +385,10 @@ func setup(m2client: Node, assets_root: String,
party_ui = PartyUI.new()
add_child(party_ui)
party_ui.setup(client, ui)
# __ArrangeQuestButton 的 xPos 随组队窗可见性移位;party_ui 无信号,用探针轮询。
if quest_log and quest_log.has_method("set_party_probe"):
quest_log.set_party_probe(func() -> bool:
return party_ui != null and party_ui.has_method("is_open") and party_ui.is_open())
friend_ui = FriendUI.new()
add_child(friend_ui)
friend_ui.setup(client, ui)
@@ -321,21 +401,27 @@ func setup(m2client: Node, assets_root: String,
chat.start_whisper(nm))
shop_ui = ShopUI.new()
add_child(shop_ui)
shop_ui.item_mouse = mouse_controller
shop_ui.audio = _audio
shop_ui.setup(client, ui, proto, item_list)
exchange_ui = ExchangeUI.new()
add_child(exchange_ui)
exchange_ui.item_mouse = mouse_controller
exchange_ui.setup(client, ui, proto)
safebox_ui = SafeboxUI.new()
add_child(safebox_ui)
safebox_ui.item_mouse = mouse_controller
safebox_ui.setup(client, ui, proto)
mall_ui = MallUI.new()
add_child(mall_ui)
mall_ui.item_mouse = mouse_controller
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.item_mouse = mouse_controller
private_shop_ui.setup(client, ui, proto, assets_root)
guild_ui = GuildUI.new()
add_child(guild_ui)
@@ -360,9 +446,26 @@ func setup(m2client: Node, assets_root: String,
minimap = Minimap.new()
add_child(minimap)
minimap.setup(client, ui, func() -> Node3D: return player)
dungeon_state = DungeonState.new()
add_child(dungeon_state)
dungeon_state.setup(client, func() -> Node3D: return player)
if dungeon_state.has_signal("destination_changed") and hud and hud.has_method("set_dungeon_destination"):
dungeon_state.destination_changed.connect(hud.set_dungeon_destination)
atlas_ui = AtlasUI.new()
add_child(atlas_ui)
atlas_ui.setup(world, ui, func() -> Node3D: return player, map_path.get_file(), client)
if quest_dialog:
quest_dialog.map_signal_added.connect(func(x: float, y: float):
if minimap and minimap.has_method("add_signal_point"):
minimap.add_signal_point(x, y)
if atlas_ui and atlas_ui.has_method("open"):
atlas_ui.open())
quest_dialog.map_signals_cleared.connect(func():
if minimap and minimap.has_method("clear_signal_points"):
minimap.clear_signal_points())
quest_dialog.atlas_center_requested.connect(func(x: int, y: int):
if atlas_ui and atlas_ui.has_method("set_center_position_adjust"):
atlas_ui.set_center_position_adjust(x, y))
world_time = WorldTime.new()
add_child(world_time)
# .msenv 已经给了固定的白天关照(A1 没有昼夜预设)——别让 world_time 每帧覆盖它。
@@ -381,6 +484,11 @@ func setup(m2client: Node, assets_root: String,
game_option_ui = GameOptionUI.new()
add_child(game_option_ui)
game_option_ui.setup(ui, client, assets_root)
# §8.4「总显示名字」radio -> net_worldCPythonSystem::SetAlwaysShowNameFlag)。
if game_option_ui.has_signal("display_option_changed"):
game_option_ui.display_option_changed.connect(_on_display_option)
if net_world and net_world.has_method("set_always_show_name") and game_option_ui.has_method("display_value"):
net_world.set_always_show_name(game_option_ui.display_value("always_show_name") == 1)
system_menu_ui = SystemMenuUI.new()
add_child(system_menu_ui)
system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui)
@@ -408,6 +516,17 @@ func setup(m2client: Node, assets_root: String,
net_world.catch_up()
set_process_unhandled_input(true)
set_process(true)
func _process(_dt: float) -> void:
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
# node keeps its listener at the origin and rebases active 3D instances.
if _audio == null or player == null or not is_instance_valid(player):
return
var basis := player.global_transform.basis
_audio.set_listener(player.global_position, -basis.z, basis.y)
if world and world.has_method("get_ambience_sources") and _audio.has_method("update_ambience_sources"):
_audio.update_ambience_sources(world.call("get_ambience_sources"))
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
func _guild_mark_upload_image() -> Image:
@@ -465,7 +584,10 @@ func _on_gift_available() -> void:
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)
# Motion sounds are character sounds in ClientVS22 and use the optional
# 0.3s / 5000cm frequency-distance guard.
_audio.play_character_sound_3d(anchor.global_position.x, anchor.global_position.y,
anchor.global_position.z, sound, true)
if effect != "" and fx:
# EffectPosition 是骨骼本地锚点(cm);简版直接挂角色根 + y 偏移
var gp := anchor.global_position + Vector3(pos.x, pos.y, pos.z) * 0.01
@@ -476,13 +598,21 @@ func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vecto
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
6: # FLY —— `.msa` MOTION_EVENT_TYPE_FLY 帧:CActorInstance::ProcessMotionEventFly
# → m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill)ActorInstanceMotionEvent.cpp:290)。
# FIFO pop = AutoClear + 连射多箭在途各自一发。
if client and client.has_method("shoot") and not _pending_shots.is_empty():
client.shoot(_pending_shots.pop_front())
9: # WARP —— 传送起点,不是投射物
pass
# 一次弓挥击 / 弓技能起手 → 压一个待发 uSkill(对齐 AutoClear handler 的 Set())。
# 满了丢最旧的:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时不至无界增长。§3.6
func _queue_shot(skill: int) -> void:
_pending_shots.append(skill)
while _pending_shots.size() > MAX_PENDING_SHOTS:
_pending_shots.pop_front()
var _flash_rect: ColorRect
func _screen_flash(secs: float) -> void:
if hud == null:
@@ -591,47 +721,168 @@ func _on_inv_context(window: int, cell: int) -> bool:
return false
func _unhandled_input(e: InputEvent) -> void:
if not (e is InputEventKey and e.pressed and not e.echo):
if not (e is InputEventKey) or 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_H and not e.ctrl_pressed and not e.meta_pressed and system_menu_ui:
# 参考端 game.py __PressHKey:非 Ctrl → interface.OpenHelpWindow()Ctrl+H 是 /user_horse_ride
system_menu_ui.toggle_help()
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()
var key: Key = e.keycode
if ui and ui.has_method("blocks_game_input") and ui.blocks_game_input(e):
return
# The reference keeps a different callback for LSHIFT press/release. The
# flag is also useful on macOS where Command is the modifier for emotes.
if key == KEY_SHIFT:
_quick_page_mode = e.pressed
return
if not e.pressed:
match key:
KEY_SPACE:
if net_play and net_play.has_method("set_attack_key"):
net_play.set_attack_key(false)
KEY_ALT:
_set_names(false)
KEY_Q, KEY_E:
if cam and cam.has_method("set_key_orbit"):
cam.set_key_orbit(0)
KEY_R, KEY_F:
if cam and cam.has_method("set_key_zoom"):
cam.set_key_zoom(0)
KEY_T, KEY_G:
if cam and cam.has_method("set_key_pitch"):
cam.set_key_pitch(0)
return
var command: bool = e.ctrl_pressed or e.meta_pressed
match key:
KEY_ENTER, KEY_KP_ENTER:
if chat and not chat.is_typing():
chat.focus_input()
get_viewport().set_input_as_handled()
KEY_ALT:
_set_names(true)
KEY_PRINT:
_save_screen()
KEY_SPACE:
if net_play and net_play.has_method("set_attack_key"):
net_play.set_attack_key(true)
KEY_I:
if inventory: inventory.toggle()
KEY_K:
if skills:
skills.set_job(_job_name())
skills.toggle()
KEY_V:
if char_status_ui: char_status_ui.open("SKILL")
KEY_C:
if not command and char_status_ui: char_status_ui.open("STATUS")
KEY_B:
if command:
_send_command("/user_horse_back")
elif char_status_ui:
char_status_ui.open("EMOTICON")
KEY_H:
if command:
_send_command("/user_horse_ride")
elif system_menu_ui:
# game.py __PressHKey: normal H opens the help window.
system_menu_ui.toggle_help()
KEY_J:
_toggle_horse()
KEY_N:
if not command and quest_log: quest_log.toggle()
KEY_F:
if command:
_send_command("/user_horse_feed")
elif cam and cam.has_method("set_key_zoom"):
cam.set_key_zoom(1)
KEY_G:
if command:
_send_command("/ride")
elif _show_names and guild_ui:
guild_ui.toggle()
elif cam and cam.has_method("set_key_pitch"):
cam.set_key_pitch(1)
KEY_Q:
if command:
if quest_log and quest_log.has_method("toggle_buttons"): quest_log.toggle_buttons()
elif cam and cam.has_method("set_key_orbit"):
cam.set_key_orbit(-1)
KEY_E:
if not command and cam and cam.has_method("set_key_orbit"):
cam.set_key_orbit(1)
KEY_R:
if not command and cam and cam.has_method("set_key_zoom"):
cam.set_key_zoom(-1)
KEY_T:
if not command and cam and cam.has_method("set_key_pitch"):
cam.set_key_pitch(-1)
KEY_L:
if not command and chat and chat.has_method("toggle_log"): chat.toggle_log()
KEY_M:
if not command and atlas_ui: atlas_ui.toggle()
KEY_KP_ADD:
if minimap and minimap.has_method("get_scale"):
minimap.set_scale(float(minimap.get_scale()) * 1.15)
KEY_KP_SUBTRACT:
if minimap and minimap.has_method("get_scale"):
minimap.set_scale(float(minimap.get_scale()) / 1.15)
KEY_Z:
if not command and ground_items: ground_items.try_pickup()
KEY_O:
if not command and ENABLE_DRAGON_SOUL and dragon_soul_ui: dragon_soul_ui.toggle()
KEY_ESCAPE:
if system_menu_ui:
# ui_manager consumes topmost windows before this reaches ESC.
system_menu_ui.toggle()
_:
if int(key) == 96 or int(e.physical_keycode) == 96:
if not command and ground_items: ground_items.try_pickup()
elif key >= KEY_F1 and key <= KEY_F4 and quickbar:
# game.py __PressQuickSlot(4..7), independent of page selection.
quickbar.activate(4 + (key - KEY_F1))
elif key >= KEY_1 and key <= KEY_9:
var n: int = key - KEY_1
if command:
_emote(n)
elif _quick_page_mode and n < 4 and quickbar:
quickbar.set_page(n)
elif n < 4 and quickbar:
quickbar.activate(n)
func _set_names(visible: bool) -> void:
_show_names = visible
if net_world and net_world.has_method("set_names_visible"):
net_world.set_names_visible(visible)
# GameOptionUI 显示开关 radio 选中(uigameoption RefreshAlwaysShowName 系)。
func _on_display_option(key: String, value: int) -> void:
if key == "always_show_name" and net_world and net_world.has_method("set_always_show_name"):
net_world.set_always_show_name(value == 1)
func _send_command(text: String) -> void:
if client and client.has_method("say"):
client.say(0, text)
func _toggle_horse() -> void:
if client == null:
return
var main: Dictionary = client.get_entity(client.get_main_vid()) if client.has_method("get_entity") else {}
if int(main.get("mount_vnum", 0)) != 0:
_send_command("/unmount")
return
if private_shop_ui and private_shop_ui.has_method("is_open") and private_shop_ui.is_open():
return
if not client.has_method("get_inventory") or not client.has_method("use_item"):
return
for item in client.get_inventory():
if int(item.get("vnum", 0)) in [71114, 71116, 71118, 71120]:
client.use_item(1, int(item.get("cell", 0)))
return
func _save_screen() -> void:
var texture := get_viewport().get_texture()
if texture == null:
return
var dir := OS.get_user_data_dir().path_join("screenshots")
DirAccess.make_dir_recursive_absolute(dir)
texture.get_image().save_png(dir.path_join("screen_%d.png" % Time.get_ticks_msec()))
# 表情快捷键(§8.6 / §4.8)—— 参考端 Ctrl+1..9 = chrmgr.SetEmoticon(-1, n) + net.SendEmoticon(n)。
# W3 在 §8.6 里补全:本地立即播表情动作 + 发 classic 表情包。这里先留统一入口。
@@ -639,6 +890,13 @@ func _emote(index: int) -> void:
if client and client.has_method("send_emoticon"):
client.send_emoticon(index)
func _play_emoticon(index: int) -> void:
# playersettingmodule.py registers these in this exact order (0..11).
const EFFECTS := ["sweat", "money", "happy", "like", "love_s", "angry",
"aha", "gloom", "sorry", "!_mix_back", "question", "fish"]
if fx and player and index >= 0 and index < EFFECTS.size():
fx.spawn(EFFECTS[index], player, true)
func _job_name() -> String:
var r := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3
return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][r]
@@ -663,8 +921,9 @@ func _player_bubble(text: String) -> void:
lbl.pixel_size = 0.0055
lbl.outline_size = 6
player.add_child(lbl)
# §8.8:本地玩家气泡寿命对齐 CPythonTextTail gs_TextTail_LivingTime(默认 5s
var tw := create_tween()
tw.tween_interval(3.0)
tw.tween_interval(4.0)
tw.tween_property(lbl, "modulate:a", 0.0, 1.0)
tw.tween_callback(lbl.queue_free)
@@ -676,17 +935,34 @@ func set_entity_model_factory(factory: Callable) -> void:
net_world.set_model_factory(factory)
var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试)
var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性)
# 默认工厂:怪 / NPC -> MobViewrace = mob_proto vnum。失败返回 null 用占位胶囊。
# 默认工厂:远端 PC -> RemotePlayerView怪 / NPC -> MobView。失败返回 null 用占位胶囊。
func _make_entity_model(d: Dictionary) -> Node3D:
if bool(d.get("is_main", false)) or proto == null or _assets == "":
if bool(d.get("is_main", false)) or _assets == "":
return null
var ch_type := int(d.get("ch_type", -1))
var race := int(d.get("race", 0))
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
if ch_type == 0:
if race < 0 or _remote_player_view_cache.get(race, true) == false:
return null
var pv := RemotePlayerView.new()
var parts: Array = d.get("parts", []) if d.get("parts", []) is Array else []
if pv.build_remote(_assets, race, parts, item_list, proto, pump):
pv.set_audio(_audio)
_remote_player_view_cache[race] = true
return pv
_remote_player_view_cache[race] = false
pv.free()
return null
if proto == null:
return null
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):
mv.set_audio(_audio)
_mob_view_cache[race] = true
return mv
_mob_view_cache[race] = false
@@ -871,6 +1147,7 @@ func _on_main_set(vid: int) -> void:
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):
pv.set_audio(_audio)
set_player_model(pv)
_model_built = true
if net_play:
@@ -893,6 +1170,11 @@ func _on_main_set(vid: int) -> void:
p.y = float(world.call("sample_height", p.x, p.z))
player.position = p
# AppFlow 的唯一生命周期协调者在 GameScene 装配完成后调用,绑定当前场景的音频对象。
# GameScene 不创建 AppLifecycle,避免切屏时重复监听暂停 / 恢复通知。
func lifecycle_audio() -> Node:
return _audio
func _make_placeholder_player() -> Node3D:
var root := Node3D.new()
root.name = "LocalPlayer"