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
315 lines
11 KiB
GDScript
315 lines
11 KiB
GDScript
# PartyUI (P8/P11) —— 组队成员信息板(1:1 迁移 `assets/root/uiparty.py` 的
|
||
# `PartyMemberInfoBoard` + `PartyMemberInfoWindow`;布局照 `partymemberinfoboard.py` 的
|
||
# 每员 106×36 条:StateButton(角色图标) + NameSlot + Gauge(HP) + 附加图标行)。
|
||
#
|
||
# var pu := preload("res://ui/party_ui.gd").new()
|
||
# add_child(pu)
|
||
# pu.setup(m2client, canvas_parent, dialogs) # dialogs 可空
|
||
#
|
||
# 每员一条 strip:角色状态按钮(队长可点 → 分配角色 / 踢人)、名字(+★队长)、HP 条、
|
||
# 附加效果格(affects[7] 非零 → 小图标 + tooltip)。整条点击 → 选中该员 vid。
|
||
# 顶部:EXP 分配开关(均分 / 不均分 → party_set_distribute)+ 组队治疗(party_use_skill)。
|
||
# `party_changed` 刷新;`party_invite_ask(leader_pid)` → 确认框 → party_answer。
|
||
extends Node
|
||
|
||
# PythonPlayer.h EPartyRole
|
||
const ROLE_NORMAL := 0
|
||
const ROLE_ATTACKER := 2
|
||
const ROLE_TANKER := 3
|
||
const ROLE_BUFFER := 4
|
||
const ROLE_SKILL_MASTER := 5
|
||
const ROLE_HASTE := 6
|
||
const ROLE_DEFENDER := 7
|
||
const ROLE_LABEL := {
|
||
0: "普", 1: "队", 2: "攻", 3: "坦", 4: "辅", 5: "宗", 6: "速", 7: "防",
|
||
}
|
||
const ROLE_MENU := [
|
||
[ROLE_NORMAL, "普通"], [ROLE_ATTACKER, "攻击"], [ROLE_TANKER, "坦克"],
|
||
[ROLE_HASTE, "迅捷"], [ROLE_BUFFER, "辅助"], [ROLE_SKILL_MASTER, "宗师"],
|
||
[ROLE_DEFENDER, "防御"],
|
||
]
|
||
# Packet.h EPartyExpDistributionType
|
||
const EXP_NON_PARITY := 0 # 不均分(按贡献)
|
||
const EXP_PARITY := 1 # 均分
|
||
# uiparty.PartyMemberInfoBoard.PARTY_SKILL_*
|
||
const PARTY_SKILL_HEAL := 1
|
||
# affects[7] 槽位含义(暂定,按 partymemberinfoboard.py 图标顺序)
|
||
const AFFECT_LABEL := ["经验", "攻击", "坦克", "辅助", "宗师", "迅捷", "防御"]
|
||
|
||
var client: Node
|
||
var dialogs: Node
|
||
var _root: Control
|
||
var _list: VBoxContainer
|
||
var _dist_btn: Button
|
||
var _heal_btn: Button
|
||
var _notice: Label
|
||
var _role_popup: Control = null
|
||
|
||
func setup(m2client: Node, parent: Node, dlg: Node = null) -> void:
|
||
client = m2client
|
||
dialogs = dlg
|
||
_build(parent)
|
||
if client.has_signal("party_changed"):
|
||
client.party_changed.connect(refresh)
|
||
if client.has_signal("party_invite_ask"):
|
||
client.party_invite_ask.connect(_on_invite)
|
||
if client.has_signal("party_request_denied"):
|
||
client.party_request_denied.connect(_on_request_denied)
|
||
if client.has_signal("vitals_changed"):
|
||
client.vitals_changed.connect(func(_v): if is_open(): refresh())
|
||
refresh()
|
||
|
||
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"):
|
||
return false
|
||
var my_vid := int(client.get_main_vid())
|
||
for m in client.get_party():
|
||
if int(m.get("vid", 0)) == my_vid:
|
||
return bool(m.get("leader", false))
|
||
return false
|
||
|
||
# --- 새로고침 ------------------------------------------------------------
|
||
|
||
func refresh() -> void:
|
||
if client == null or not client.has_method("get_party"):
|
||
return
|
||
_dismiss_role_popup()
|
||
for c in _list.get_children():
|
||
_list.remove_child(c)
|
||
c.queue_free()
|
||
var members: Array = client.get_party()
|
||
_root.visible = not members.is_empty()
|
||
if members.is_empty():
|
||
return
|
||
_refresh_dist_btn()
|
||
for m in members:
|
||
_list.add_child(_strip(m))
|
||
|
||
func _refresh_dist_btn() -> void:
|
||
if not is_instance_valid(_dist_btn):
|
||
return
|
||
var mode := int(client.get_party_distribute_mode()) if client.has_method("get_party_distribute_mode") else 0
|
||
_dist_btn.text = "EXP:均分" if mode == EXP_PARITY else "EXP:不均分"
|
||
_dist_btn.disabled = not _local_is_leader()
|
||
if is_instance_valid(_heal_btn):
|
||
var leadership := _leadership_level()
|
||
_heal_btn.disabled = not _local_is_leader() or (leadership >= 0 and leadership < 18)
|
||
_heal_btn.tooltip_text = "队长 Leadership 达到 18 级后可用" if _heal_btn.disabled and leadership >= 0 and leadership < 18 else "组队治疗"
|
||
|
||
func _leadership_level() -> int:
|
||
# The server's CParty::Update gates party roles/heal using the leader's
|
||
# Leadership skill. -1 means that the snapshot has not arrived yet; in
|
||
# that case the server remains authoritative and the controls stay usable.
|
||
if client == null or not client.has_method("get_skills"):
|
||
return -1
|
||
var skills: Array = client.get_skills()
|
||
for skill in skills:
|
||
if int(skill.get("id", -1)) == 121: # SKILL_LEADERSHIP in 40250
|
||
return int(skill.get("level", 0))
|
||
return -1
|
||
|
||
func _role_unlock_level(role: int) -> int:
|
||
match role:
|
||
ROLE_ATTACKER: return 10
|
||
ROLE_TANKER, ROLE_HASTE: return 20
|
||
ROLE_BUFFER: return 25
|
||
ROLE_SKILL_MASTER: return 35
|
||
ROLE_DEFENDER: return 40
|
||
_: return 0
|
||
|
||
func _role_allowed(role: int, current_role: int = -1) -> bool:
|
||
if role == ROLE_NORMAL or role == current_role:
|
||
return true
|
||
var leadership := _leadership_level()
|
||
return leadership < 0 or leadership >= _role_unlock_level(role)
|
||
|
||
# --- 한 명 strip ------------------------------------------------------
|
||
|
||
func _strip(m: Dictionary) -> Control:
|
||
var pid := int(m.get("pid", 0))
|
||
var vid := int(m.get("vid", 0))
|
||
var is_leader := bool(m.get("leader", false))
|
||
var role := int(m.get("state", 0)) & 0x7F
|
||
|
||
var strip := PanelContainer.new()
|
||
strip.custom_minimum_size = Vector2(206, 40)
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 4)
|
||
strip.add_child(row)
|
||
|
||
# StateButton —— 角色图标(队长可点)
|
||
var state_btn := Button.new()
|
||
state_btn.custom_minimum_size = Vector2(22, 34)
|
||
state_btn.text = "队" if is_leader else ROLE_LABEL.get(role, "普")
|
||
state_btn.tooltip_text = "队长" if is_leader else ("角色:%s" % ROLE_LABEL.get(role, "普"))
|
||
state_btn.disabled = not _local_is_leader()
|
||
state_btn.pressed.connect(func() -> void: _open_role_popup(state_btn, pid, role))
|
||
row.add_child(state_btn)
|
||
|
||
var col := VBoxContainer.new()
|
||
col.add_theme_constant_override("separation", 1)
|
||
col.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.add_child(col)
|
||
|
||
# NameSlot / NamePrint
|
||
var name_lbl := Button.new()
|
||
name_lbl.flat = true
|
||
name_lbl.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
name_lbl.text = ("★ " if is_leader else "") + String(m.get("name", "?"))
|
||
name_lbl.add_theme_font_size_override("font_size", 12)
|
||
name_lbl.pressed.connect(func() -> void: _select_member(vid))
|
||
col.add_child(name_lbl)
|
||
|
||
# Gauge (HP)
|
||
var bar := ProgressBar.new()
|
||
bar.min_value = 0
|
||
bar.max_value = 100
|
||
bar.value = int(m.get("hp_pct", 0))
|
||
bar.show_percentage = false
|
||
bar.custom_minimum_size = Vector2(180, 8)
|
||
col.add_child(bar)
|
||
|
||
# 附가효과 아이콘 행
|
||
var affects: Array = m.get("affects", [])
|
||
var chips := HBoxContainer.new()
|
||
chips.add_theme_constant_override("separation", 2)
|
||
col.add_child(chips)
|
||
for i in affects.size():
|
||
var v := int(affects[i])
|
||
if v == 0:
|
||
continue
|
||
var chip := Label.new()
|
||
chip.text = AFFECT_LABEL[i] if i < AFFECT_LABEL.size() else "?%d" % i
|
||
chip.add_theme_font_size_override("font_size", 9)
|
||
chip.add_theme_color_override("font_color", Color(0.7, 0.9, 1.0))
|
||
chip.tooltip_text = "%s +%d" % [chip.text, v]
|
||
chips.add_child(chip)
|
||
return strip
|
||
|
||
func _select_member(vid: int) -> void:
|
||
if vid <= 0 or client == null:
|
||
return
|
||
if client.has_method("set_target"):
|
||
client.set_target(vid)
|
||
elif client.has_method("target"):
|
||
client.target(vid)
|
||
|
||
# --- 角色分配 팝업 (uiparty.__ShowStateButton + OnSelectState / OnExpel) ---
|
||
|
||
func _open_role_popup(anchor: Control, pid: int, cur_role: int) -> void:
|
||
_dismiss_role_popup()
|
||
if not _local_is_leader():
|
||
return
|
||
var pop := VBoxContainer.new()
|
||
pop.add_theme_constant_override("separation", 2)
|
||
var bg := PanelContainer.new()
|
||
bg.add_child(pop)
|
||
for entry in ROLE_MENU:
|
||
var r: int = entry[0]
|
||
var b := Button.new()
|
||
b.text = entry[1] + (" ✓" if r == cur_role else "")
|
||
b.custom_minimum_size = Vector2(84, 22)
|
||
b.disabled = not _role_allowed(r, cur_role)
|
||
if b.disabled:
|
||
b.tooltip_text = "Leadership %d 级解锁" % _role_unlock_level(r)
|
||
b.pressed.connect(func() -> void:
|
||
if r == ROLE_NORMAL:
|
||
# OnSelectState(-1): 清掉当前角色
|
||
client.party_set_state(pid, cur_role, false)
|
||
else:
|
||
client.party_set_state(pid, r, true)
|
||
_dismiss_role_popup())
|
||
pop.add_child(b)
|
||
var expel := Button.new()
|
||
expel.text = "踢出队伍"
|
||
expel.custom_minimum_size = Vector2(84, 22)
|
||
expel.add_theme_color_override("font_color", Color(1, 0.5, 0.5))
|
||
expel.pressed.connect(func() -> void:
|
||
client.party_leave(pid) # OnExpel -> SendPartyRemovePacket
|
||
_dismiss_role_popup())
|
||
pop.add_child(expel)
|
||
bg.position = anchor.get_global_position() + Vector2(24, 0)
|
||
_root.get_parent().add_child(bg)
|
||
_role_popup = bg
|
||
|
||
func _dismiss_role_popup() -> void:
|
||
if is_instance_valid(_role_popup):
|
||
_role_popup.queue_free()
|
||
_role_popup = null
|
||
|
||
# --- 邀请确认 ---------------------------------------------------------
|
||
|
||
func _on_invite(leader_pid: int) -> void:
|
||
var accept := func() -> void: client.party_answer(leader_pid, true)
|
||
var decline := func() -> void: client.party_answer(leader_pid, false)
|
||
if dialogs and dialogs.has_method("confirm"):
|
||
dialogs.confirm("有人邀请你加入队伍,接受?", accept, decline)
|
||
else:
|
||
accept.call()
|
||
|
||
func _on_request_denied() -> void:
|
||
if is_instance_valid(_notice):
|
||
_notice.text = "组队请求被拒绝"
|
||
|
||
# --- EXP 분배 / 组队治疗 --------------------------------------------
|
||
|
||
func _toggle_distribute() -> void:
|
||
if client == null or not _local_is_leader():
|
||
return
|
||
var mode := int(client.get_party_distribute_mode()) if client.has_method("get_party_distribute_mode") else 0
|
||
client.party_set_distribute(EXP_NON_PARITY if mode == EXP_PARITY else EXP_PARITY)
|
||
|
||
func _party_heal() -> void:
|
||
if client and client.has_method("party_use_skill"):
|
||
client.party_use_skill(PARTY_SKILL_HEAL, 0) # OnPartyUseSkill
|
||
|
||
# --- 레이아웃 ------------------------------------------------------------
|
||
|
||
func _build(parent: Node) -> void:
|
||
_root = Control.new()
|
||
_root.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||
_root.position = Vector2(16, 150)
|
||
_root.size = Vector2(226, 320)
|
||
_root.visible = false
|
||
parent.add_child(_root)
|
||
var panel := Panel.new()
|
||
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
var sb := StyleBoxFlat.new()
|
||
sb.bg_color = Color(0.06, 0.07, 0.09, 0.85)
|
||
sb.set_corner_radius_all(4)
|
||
panel.add_theme_stylebox_override("panel", sb)
|
||
_root.add_child(panel)
|
||
|
||
var header := HBoxContainer.new()
|
||
header.position = Vector2(8, 6)
|
||
header.add_theme_constant_override("separation", 6)
|
||
_root.add_child(header)
|
||
_dist_btn = Button.new()
|
||
_dist_btn.text = "EXP:均分"
|
||
_dist_btn.add_theme_font_size_override("font_size", 11)
|
||
_dist_btn.pressed.connect(_toggle_distribute)
|
||
header.add_child(_dist_btn)
|
||
var heal := Button.new()
|
||
heal.text = "组队治疗"
|
||
heal.add_theme_font_size_override("font_size", 11)
|
||
heal.pressed.connect(_party_heal)
|
||
_heal_btn = heal
|
||
header.add_child(heal)
|
||
_notice = Label.new()
|
||
_notice.add_theme_font_size_override("font_size", 10)
|
||
_notice.add_theme_color_override("font_color", Color(1, 0.65, 0.55))
|
||
header.add_child(_notice)
|
||
|
||
_list = VBoxContainer.new()
|
||
_list.position = Vector2(8, 32)
|
||
_list.add_theme_constant_override("separation", 4)
|
||
_root.add_child(_list)
|