Files
mtgodot-poc/project/charselect.gd
T
shenleiandClaude Sonnet 5 f32064c74a 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
2026-09-02 15:13:01 +09:00

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