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
246 lines
7.8 KiB
GDScript
246 lines
7.8 KiB
GDScript
# 选人界面 —— 3 个旋转台角色 + 九宫格信息面板 + 选择/创建按钮。
|
|
# 数据来自 M2Client.char_list(联机)或 --mock 合成列表(离线开发)。
|
|
# 选中 -> M2Client.enter_game(index) / DirectEnter -> entered_game。
|
|
# 用法(离线预览):godot --path project --script charselect.gd --mock --quit-after 120
|
|
extends SceneTree
|
|
|
|
const UiKit = preload("res://ui_kit.gd")
|
|
const Audio = preload("res://audio.gd")
|
|
const Locale = preload("res://locale.gd")
|
|
const CLASS_DIR := ["warrior", "assassin", "sura", "shaman"]
|
|
const CLASS_KEY := ["JOB_WARRIOR", "JOB_ASSASSIN", "JOB_SURA", "JOB_SHAMAN"]
|
|
|
|
var _audio: Node
|
|
var _loc: RefCounted
|
|
|
|
var _assets: String
|
|
var _client: Node # M2Client(联机时)
|
|
var _slots: Array[Dictionary] = [] # {index,name,job,level,x,y}
|
|
var _pedestals: Array[Node3D] = []
|
|
var _models: Array[Node3D] = []
|
|
var _sel := 0
|
|
var _t := 0.0
|
|
var _panel: Control
|
|
var _status: Label
|
|
var _mock := false
|
|
|
|
func _init() -> void:
|
|
_assets = AssetRoot.path()
|
|
_mock = "--mock" in OS.get_cmdline_user_args() or "--mock" in OS.get_cmdline_args()
|
|
|
|
_loc = Locale.new()
|
|
_loc.setup(_assets, "en")
|
|
|
|
_audio = Audio.new()
|
|
get_root().add_child(_audio)
|
|
_audio.setup(_assets)
|
|
_audio.play_bgm("characterselect")
|
|
|
|
_build_stage()
|
|
_build_ui()
|
|
|
|
if _mock:
|
|
_slots = [
|
|
{"index": 0, "name": "Baalthazar", "job": 0, "level": 42, "x": 0, "y": 0},
|
|
{"index": 1, "name": "Nyx", "job": 1, "level": 7, "x": 0, "y": 0},
|
|
{"index": 2, "name": "", "job": 0, "level": 0, "x": 0, "y": 0}, # 空槽
|
|
]
|
|
_populate()
|
|
else:
|
|
if ClassDB.class_exists("M2Client"):
|
|
_client = ClassDB.instantiate("M2Client")
|
|
get_root().add_child(_client)
|
|
_client.connect("char_list", _on_char_list)
|
|
_client.connect("stage_changed", func(s): _set_status("stage: " + s))
|
|
_client.connect("login_failed", func(r): _set_status("login failed: " + r))
|
|
_client.connect("entered_game", func(): _set_status("entered game"))
|
|
var host := "192.168.21.203"
|
|
_client.call("connect_to_server", host, 11000, host, 13002, "admin", "123456789")
|
|
_set_status("connecting to " + host + " ...")
|
|
else:
|
|
_set_status("M2Client 未注册 —— 用 --mock")
|
|
|
|
# --- 3D 舞台 ---------------------------------------------------------------
|
|
|
|
func _build_stage() -> void:
|
|
var root := Node3D.new()
|
|
root.name = "Stage"
|
|
get_root().add_child(root)
|
|
|
|
var sun := DirectionalLight3D.new()
|
|
sun.rotation_degrees = Vector3(-35, 20, 0)
|
|
sun.light_energy = 2.4
|
|
root.add_child(sun)
|
|
var fill := DirectionalLight3D.new() # 正面补光
|
|
fill.rotation_degrees = Vector3(-10, 180, 0)
|
|
fill.light_energy = 0.9
|
|
fill.light_color = Color(0.8, 0.85, 1.0)
|
|
root.add_child(fill)
|
|
var we := WorldEnvironment.new()
|
|
var e := Environment.new()
|
|
e.background_mode = Environment.BG_COLOR
|
|
e.background_color = Color(0.10, 0.11, 0.14)
|
|
e.ambient_light_color = Color(0.5, 0.52, 0.62)
|
|
e.ambient_light_energy = 1.1
|
|
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
|
we.environment = e
|
|
root.add_child(we)
|
|
|
|
# 三个台座,绕中心排成弧
|
|
for i in 3:
|
|
var a := deg_to_rad(-28.0 + i * 28.0)
|
|
var ped := Node3D.new()
|
|
ped.name = "Pedestal%d" % i
|
|
ped.position = Vector3(sin(a) * 2.3, 0.0, -cos(a) * 2.3 + 2.3)
|
|
root.add_child(ped)
|
|
var disc := MeshInstance3D.new()
|
|
var cyl := CylinderMesh.new()
|
|
cyl.top_radius = 0.62
|
|
cyl.bottom_radius = 0.7
|
|
cyl.height = 0.14
|
|
disc.mesh = cyl
|
|
var m := StandardMaterial3D.new()
|
|
m.albedo_color = Color(0.18, 0.19, 0.24)
|
|
m.metallic = 0.5
|
|
m.roughness = 0.4
|
|
disc.material_override = m
|
|
disc.position.y = 0.07
|
|
ped.add_child(disc)
|
|
_pedestals.append(ped)
|
|
_models.append(null)
|
|
|
|
var cam := Camera3D.new()
|
|
cam.name = "Cam"
|
|
root.add_child(cam)
|
|
cam.look_at_from_position(Vector3(0.0, 1.5, 5.4), Vector3(0.0, 1.15, 1.8), Vector3.UP)
|
|
cam.fov = 44.0
|
|
cam.make_current()
|
|
|
|
func _slot_model(job: int) -> Node3D:
|
|
var cls: String = CLASS_DIR[clampi(job, 0, 3)]
|
|
var m: Node3D = ClassDB.instantiate("Metin2Model")
|
|
m.name = "Model"
|
|
m.set("texture_dir", _assets.path_join("PC/ymir work/pc/" + cls))
|
|
m.set("gr2_path", _assets.path_join("PC/ymir work/pc/" + cls + "/" + cls + "_novice.gr2"))
|
|
if ClassDB.class_exists("Metin2AnimPlayer"):
|
|
var anim: Node = ClassDB.instantiate("Metin2AnimPlayer")
|
|
anim.set("model_path", NodePath("../Model"))
|
|
anim.set("loop", true)
|
|
anim.set("anim_path", _assets.path_join("PC/ymir work/pc/" + cls + "/general/wait.msa"))
|
|
m.add_child.call_deferred(anim)
|
|
return m
|
|
|
|
func _populate() -> void:
|
|
for i in range(min(_slots.size(), _pedestals.size())):
|
|
var s := _slots[i]
|
|
if _models[i] != null:
|
|
_models[i].queue_free()
|
|
_models[i] = null
|
|
if String(s.get("name", "")).is_empty():
|
|
continue
|
|
var mdl := _slot_model(int(s.get("job", 0)))
|
|
_pedestals[i].add_child(mdl)
|
|
_models[i] = mdl
|
|
_refresh_panel()
|
|
|
|
# --- 2D 信息面板 --------------------------------------------------------
|
|
|
|
func _build_ui() -> void:
|
|
var layer := CanvasLayer.new()
|
|
get_root().add_child(layer)
|
|
|
|
_panel = UiKit.board(_assets, "board", 32, 128)
|
|
_panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
|
_panel.position = Vector2(-280, -172)
|
|
_panel.size = Vector2(560, 160)
|
|
layer.add_child(_panel)
|
|
|
|
_status = Label.new()
|
|
_status.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
|
_status.position = Vector2(20, 16)
|
|
_status.add_theme_font_size_override("font_size", 16)
|
|
layer.add_child(_status)
|
|
|
|
func _set_status(t: String) -> void:
|
|
if _status:
|
|
_status.text = t
|
|
print("[charselect] ", t)
|
|
|
|
func _refresh_panel() -> void:
|
|
for c in _panel.get_children():
|
|
c.queue_free()
|
|
var row := HBoxContainer.new()
|
|
row.position = Vector2(24, 20)
|
|
row.add_theme_constant_override("separation", 10)
|
|
_panel.add_child(row)
|
|
for i in range(_slots.size()):
|
|
var s := _slots[i]
|
|
var b := Button.new()
|
|
var nm := String(s.get("name", ""))
|
|
var job := clampi(int(s.get("job", 0)), 0, 3)
|
|
b.text = ("[ %s ]" % _loc.t("SELECT_CREATE") if nm.is_empty()
|
|
else "%s\n%s %s %d" % [nm, _loc.t(CLASS_KEY[job]),
|
|
_loc.t("SELECT_LEVEL"), int(s.get("level", 0))])
|
|
b.custom_minimum_size = Vector2(150, 70)
|
|
b.toggle_mode = true
|
|
b.button_pressed = (i == _sel)
|
|
var idx := i
|
|
b.pressed.connect(func(): _select_slot(idx))
|
|
row.add_child(b)
|
|
|
|
var enter := Button.new()
|
|
enter.text = _loc.t("SELECT_SELECT") # "Start"
|
|
enter.position = Vector2(24, 118)
|
|
enter.custom_minimum_size = Vector2(150, 40)
|
|
enter.pressed.connect(_enter)
|
|
_panel.add_child(enter)
|
|
|
|
func _select_slot(i: int) -> void:
|
|
_sel = i
|
|
_set_status("selected slot %d" % i)
|
|
if _audio and i < _slots.size():
|
|
var job := clampi(int(_slots[i].get("job", 0)), 0, 3)
|
|
_audio.play_ui(CLASS_DIR[job] + "_select")
|
|
_refresh_panel()
|
|
|
|
func _enter() -> void:
|
|
var s := _slots[_sel] if _sel < _slots.size() else {}
|
|
if String(s.get("name", "")).is_empty():
|
|
_set_status("空槽不能进入(创建角色未实现)")
|
|
return
|
|
if _client:
|
|
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"))
|
|
|
|
func _on_char_list(list: Array) -> void:
|
|
_slots.clear()
|
|
for d in list:
|
|
_slots.append(d)
|
|
while _slots.size() < 3:
|
|
_slots.append({"index": _slots.size(), "name": "", "job": 0, "level": 0})
|
|
_set_status("char_list: %d 槽" % list.size())
|
|
_populate()
|
|
|
|
# --- 循环:旋转选中角色,截图 -------------------------------------------
|
|
|
|
func _process(dt: float) -> bool:
|
|
_t += dt
|
|
for i in range(_pedestals.size()):
|
|
var spd := 0.6 if i == _sel else 0.15
|
|
_pedestals[i].rotate_y(spd * dt)
|
|
if _mock and _t > 3.0:
|
|
var img := get_root().get_texture().get_image()
|
|
var out := ProjectSettings.globalize_path("res://../test/golden/charselect.png")
|
|
DirAccess.make_dir_recursive_absolute(out.get_base_dir())
|
|
img.save_png(out)
|
|
print("[charselect] saved ", out)
|
|
quit(0)
|
|
return false
|