Metin2 game client (P0–P11) + mobile asset pipeline
Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
# 选人界面 —— 3 个旋转台角色 + 九宫格信息面板 + 选择/创建按钮。
|
||||
# 数据来自 M2Client.char_list(联机)或 --mock 合成列表(离线开发)。
|
||||
# 选中 -> M2Client.select_character(index) -> 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, 11011, "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:
|
||||
_client.call("select_character", int(s.get("index", _sel)))
|
||||
_set_status("select_character(%d) sent" % int(s.get("index", _sel)))
|
||||
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
|
||||
Reference in New Issue
Block a user