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,683 @@
|
||||
# CharSelectScreen —— Metin2 风格的选人界面。
|
||||
#
|
||||
# var s := preload("res://ui/char_select_screen.gd").new()
|
||||
# parent.add_child(s)
|
||||
# s.setup(m2client, assets_root, chars) # chars = M2Client.char_list 数组
|
||||
# s.select_requested.connect(func(idx): client.select_character(idx))
|
||||
# s.back_requested.connect(_goto_login)
|
||||
#
|
||||
# 组成:全屏背景(select.jpg)+ 右侧 3D 角色(SubViewport 里的 PlayerView,缓转 + wait)
|
||||
# + 左侧信息板(thinboard:帮会徽 / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷)
|
||||
# + 左上职业名(locale name_<class>.dds,解不出就用中文描边字)+ 底部 开始/创建/删除/退出。
|
||||
# 所有素材都是可选的:缺哪个降级哪个,绝不崩。
|
||||
extends Control
|
||||
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
const UiKit = preload("res://ui_kit.gd")
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
|
||||
signal select_requested(index: int)
|
||||
signal delete_requested(index: int, private_code: String)
|
||||
signal create_requested(spec: Dictionary) # {slot, name, job, shape, con, int, str, dex}
|
||||
signal back_requested()
|
||||
|
||||
const CLASS_CN := ["猛将", "刺客", "术士", "巫女"]
|
||||
const CLASS_DIR := ["warrior", "assassin", "sura", "shaman"]
|
||||
# 建号默认四维(CON, INT, STR, DEX)—— 对齐经典 Metin2 各职业起始属性。
|
||||
const JOB_BASE_STATS := {
|
||||
0: {"con": 4, "int": 3, "str": 6, "dex": 3}, # warrior
|
||||
1: {"con": 3, "int": 3, "str": 4, "dex": 6}, # assassin
|
||||
2: {"con": 3, "int": 5, "str": 5, "dex": 3}, # sura
|
||||
3: {"con": 4, "int": 6, "str": 3, "dex": 3}, # shaman
|
||||
}
|
||||
const EMPIRE_KEY := {1: "EMPIRE_A", 2: "EMPIRE_B", 3: "EMPIRE_C"}
|
||||
const EMPIRE_FALLBACK := {0: "—", 1: "神獸王國", 2: "天朝王國", 3: "眞魔王國"}
|
||||
const EMPIRE_FLAG := {1: "a", 2: "b", 3: "c"}
|
||||
const STAT_ROWS := [
|
||||
{"key": "ht", "label": "体力", "color": Color(0.90, 0.30, 0.30)},
|
||||
{"key": "iq", "label": "智力", "color": Color(0.85, 0.45, 0.95)},
|
||||
{"key": "st", "label": "力量", "color": Color(0.70, 0.55, 1.00)},
|
||||
{"key": "dx", "label": "敏捷", "color": Color(0.35, 0.75, 1.00)},
|
||||
]
|
||||
|
||||
var _client: Node
|
||||
var _assets := ""
|
||||
var _chars: Array = []
|
||||
var _sel := 0
|
||||
|
||||
var _viewport: SubViewport
|
||||
var _pivot: Node3D
|
||||
var _cam: Camera3D
|
||||
var _pv: Node # PlayerView
|
||||
var _panel_box: VBoxContainer
|
||||
var _class_img: TextureRect
|
||||
var _class_lbl: Label
|
||||
var _status: Label
|
||||
var _slot_lbl: Label
|
||||
var _spin := 0.0
|
||||
|
||||
func setup(client: Node, assets_root: String, chars: Array) -> void:
|
||||
_client = client
|
||||
_assets = assets_root
|
||||
_chars = _pad_slots(chars)
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_build_background()
|
||||
_build_stage()
|
||||
_build_class_name()
|
||||
_build_panel()
|
||||
_build_status()
|
||||
set_process(true)
|
||||
_select(_first_occupied())
|
||||
|
||||
# char_list 只带占位了角色的槽;补齐到 slot_count 个(空槽 name=""),◀▶ 能走到空槽建号。
|
||||
func _pad_slots(chars: Array) -> Array:
|
||||
var n := 3
|
||||
if _client and _client.has_method("get_slot_count"):
|
||||
n = maxi(int(_client.get_slot_count()), chars.size())
|
||||
n = maxi(n, chars.size())
|
||||
var by_idx := {}
|
||||
for c in chars:
|
||||
by_idx[int(c.get("index", 0))] = c
|
||||
var out: Array = []
|
||||
for i in n:
|
||||
out.append(by_idx.get(i, {"index": i, "name": "", "job": 0, "level": 0}))
|
||||
return out
|
||||
|
||||
func _first_occupied() -> int:
|
||||
for i in _chars.size():
|
||||
if String(_chars[i].get("name", "")) != "":
|
||||
return i
|
||||
return 0
|
||||
|
||||
# 由 app_flow 在收到新的 char_list(建/删号后 M2Client 重发)时调用,保持当前选中槽。
|
||||
func set_chars(chars: Array) -> void:
|
||||
var keep := _slot_index()
|
||||
_chars = _pad_slots(chars)
|
||||
for i in _chars.size():
|
||||
if int(_chars[i].get("index", -1)) == keep:
|
||||
_sel = i
|
||||
break
|
||||
_sel = clampi(_sel, 0, maxi(_chars.size() - 1, 0))
|
||||
_refresh_class_name()
|
||||
_refresh_panel()
|
||||
_rebuild_model()
|
||||
|
||||
func on_char_created(slot: int) -> void:
|
||||
_close_dialog()
|
||||
_set_status("角色已创建(槽位 %d)" % slot)
|
||||
|
||||
func on_char_create_failed(reason_type: int) -> void:
|
||||
const R := {1: "该名称已被使用", 2: "名称不合法", 3: "该槽位已有角色", 4: "权限不足"}
|
||||
_set_status("创建失败:%s" % R.get(reason_type, "错误码 %d" % reason_type))
|
||||
|
||||
func on_char_deleted(slot: int) -> void:
|
||||
_close_dialog()
|
||||
_set_status("角色已删除(槽位 %d)" % slot)
|
||||
|
||||
func on_char_delete_failed() -> void:
|
||||
var e: Node = _dialog_node()
|
||||
if e:
|
||||
var code := e.find_child("Code", true, false)
|
||||
if code is LineEdit:
|
||||
(code as LineEdit).text = ""
|
||||
_set_status("删除失败:删除码(社交号)不正确")
|
||||
|
||||
# --- 背景 ---------------------------------------------------------------------
|
||||
|
||||
func _build_background() -> void:
|
||||
var tex := UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/select/select.sub")
|
||||
var cr := ColorRect.new()
|
||||
cr.color = Color(0.05, 0.06, 0.07)
|
||||
cr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
cr.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(cr)
|
||||
if tex != null:
|
||||
var tr := TextureRect.new()
|
||||
tr.texture = tex
|
||||
tr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||||
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(tr)
|
||||
|
||||
# --- 右侧 3D 角色 -----------------------------------------------------------
|
||||
|
||||
func _build_stage() -> void:
|
||||
var vpc := SubViewportContainer.new()
|
||||
vpc.stretch = true
|
||||
vpc.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
vpc.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(vpc)
|
||||
|
||||
_viewport = SubViewport.new()
|
||||
_viewport.transparent_bg = true
|
||||
_viewport.own_world_3d = true
|
||||
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
_viewport.msaa_3d = Viewport.MSAA_4X
|
||||
vpc.add_child(_viewport)
|
||||
|
||||
var root := Node3D.new()
|
||||
_viewport.add_child(root)
|
||||
|
||||
var key := DirectionalLight3D.new()
|
||||
key.rotation_degrees = Vector3(-32, 28, 0)
|
||||
key.light_energy = 1.7
|
||||
key.light_color = Color(1.0, 0.96, 0.90)
|
||||
root.add_child(key)
|
||||
var rim := DirectionalLight3D.new()
|
||||
rim.rotation_degrees = Vector3(-6, 200, 0)
|
||||
rim.light_energy = 0.7
|
||||
rim.light_color = Color(0.75, 0.83, 1.0)
|
||||
root.add_child(rim)
|
||||
|
||||
var we := WorldEnvironment.new()
|
||||
var e := Environment.new()
|
||||
e.background_mode = Environment.BG_COLOR
|
||||
e.background_color = Color(0, 0, 0, 0)
|
||||
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
e.ambient_light_color = Color(0.42, 0.45, 0.55)
|
||||
e.ambient_light_energy = 0.9
|
||||
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||
we.environment = e
|
||||
root.add_child(we)
|
||||
|
||||
_pivot = Node3D.new()
|
||||
root.add_child(_pivot)
|
||||
|
||||
_cam = Camera3D.new()
|
||||
_cam.fov = 30.0
|
||||
_cam.position = Vector3(0.0, 1.0, 4.0)
|
||||
root.add_child(_cam)
|
||||
_cam.make_current()
|
||||
|
||||
# 相机按模型包围球自适应:球心 + 半径/sin(fovV/2) 保证整模无论姿势都不出框,
|
||||
# 再沿 -X 平移“半径的一小截”把人物推到右半屏(横向视锥比纵向宽,不会切到远侧手)。
|
||||
func _frame_model() -> void:
|
||||
if _cam == null or _pv == null or not is_instance_valid(_pv):
|
||||
return
|
||||
var aabb := AABB()
|
||||
var first := true
|
||||
for mi in (_pv as Node).find_children("*", "MeshInstance3D", true, false):
|
||||
var m := mi as MeshInstance3D
|
||||
if m.mesh == null:
|
||||
continue
|
||||
var wb: AABB = m.get_global_transform() * m.get_aabb()
|
||||
aabb = wb if first else aabb.merge(wb)
|
||||
first = false
|
||||
if first:
|
||||
return
|
||||
var c := aabb.get_center()
|
||||
var r: float = maxf(aabb.size.length() * 0.5, 0.9)
|
||||
var dist: float = r / sin(deg_to_rad(_cam.fov * 0.5)) * 1.12 # 12% 余量
|
||||
var off := Vector3(-r * 0.5, 0.0, 0.0)
|
||||
_cam.position = c + off + Vector3(0.0, r * 0.04, dist)
|
||||
_cam.look_at(c + off, Vector3.UP)
|
||||
|
||||
func _rebuild_model() -> void:
|
||||
if _pivot == null:
|
||||
return
|
||||
if _pv != null and is_instance_valid(_pv):
|
||||
_pv.queue_free()
|
||||
_pv = null
|
||||
var c := _cur()
|
||||
if c.is_empty() or String(c.get("name", "")) == "":
|
||||
return
|
||||
var job := clampi(int(c.get("job", 0)), 0, 3)
|
||||
var pump: Callable = _client.net_poll if _client and _client.has_method("net_poll") else Callable()
|
||||
var pv := PlayerView.new()
|
||||
_pivot.add_child(pv)
|
||||
if pv.build(_assets, job, pump):
|
||||
_pv = pv
|
||||
pv.set_anim_state("wait")
|
||||
# 蒙皮 / LOD 定型要几帧,多 fit 两次取稳定包围盒
|
||||
for i in 4:
|
||||
await get_tree().process_frame
|
||||
if pv != _pv:
|
||||
return
|
||||
_frame_model()
|
||||
else:
|
||||
pv.queue_free()
|
||||
|
||||
# --- 左上职业名 -----------------------------------------------------------
|
||||
|
||||
func _build_class_name() -> void:
|
||||
_class_img = TextureRect.new()
|
||||
_class_img.position = Vector2(48, 60)
|
||||
_class_img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
|
||||
_class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_class_img)
|
||||
|
||||
_class_lbl = Label.new()
|
||||
_class_lbl.position = Vector2(50, 54)
|
||||
_class_lbl.add_theme_font_size_override("font_size", 84)
|
||||
_class_lbl.add_theme_color_override("font_color", Color(0.86, 0.11, 0.09))
|
||||
_class_lbl.add_theme_color_override("font_outline_color", Color(0.15, 0.05, 0.02))
|
||||
_class_lbl.add_theme_constant_override("outline_size", 10)
|
||||
_class_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_class_lbl)
|
||||
|
||||
func _refresh_class_name() -> void:
|
||||
var job := clampi(int(_cur().get("job", 0)), 0, 3)
|
||||
var has_char := not _cur().is_empty() and String(_cur().get("name", "")) != ""
|
||||
var tex: Texture2D = null
|
||||
if has_char:
|
||||
var cn: String = CLASS_DIR[job]
|
||||
for loc in ["en", "de", "tr", "gr", "ru"]:
|
||||
tex = UiAssets.load_tex(_assets, "locale/locale/%s/ui/select/name_%s.sub" % [loc, cn])
|
||||
if tex != null:
|
||||
break
|
||||
_class_img.texture = tex
|
||||
_class_img.visible = tex != null
|
||||
_class_lbl.visible = tex == null and has_char
|
||||
_class_lbl.text = CLASS_CN[job] if has_char else ""
|
||||
|
||||
# --- 左侧信息板 --------------------------------------------------------------
|
||||
|
||||
func _build_panel() -> void:
|
||||
var board := UiKit.board(_assets, "board", 24, 96)
|
||||
board.position = Vector2(56, 168)
|
||||
board.size = Vector2(300, 396)
|
||||
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(board)
|
||||
# 板底纹太透就垫一层
|
||||
var tint := ColorRect.new()
|
||||
tint.color = Color(0.03, 0.05, 0.06, 0.62)
|
||||
tint.position = board.position + Vector2(10, 10)
|
||||
tint.size = board.size - Vector2(20, 20)
|
||||
tint.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(tint)
|
||||
|
||||
_panel_box = VBoxContainer.new()
|
||||
_panel_box.position = board.position + Vector2(22, 22)
|
||||
_panel_box.custom_minimum_size = Vector2(256, 0)
|
||||
_panel_box.add_theme_constant_override("separation", 9)
|
||||
add_child(_panel_box)
|
||||
|
||||
func _refresh_panel() -> void:
|
||||
for c in _panel_box.get_children():
|
||||
c.queue_free()
|
||||
var d := _cur()
|
||||
var has_char := not d.is_empty() and String(d.get("name", "")) != ""
|
||||
|
||||
# 帮会徽 + 帮会名
|
||||
var head := HBoxContainer.new()
|
||||
head.add_theme_constant_override("separation", 10)
|
||||
_panel_box.add_child(head)
|
||||
var crest := TextureRect.new()
|
||||
crest.custom_minimum_size = Vector2(56, 56)
|
||||
crest.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
|
||||
var gid := int(d.get("guild_id", 0))
|
||||
if has_char and gid != 0 and _client and _client.has_method("get_guild_mark_image"):
|
||||
var gimg = _client.call("get_guild_mark_image", gid)
|
||||
if gimg is Image and gimg.get_width() > 0:
|
||||
crest.texture = ImageTexture.create_from_image(gimg)
|
||||
if crest.texture == null and has_char:
|
||||
crest.texture = _empire_flag_tex() # 无会徽时退国旗
|
||||
if crest.texture == null:
|
||||
var ph := Image.create_empty(48, 48, false, Image.FORMAT_RGBA8)
|
||||
ph.fill(Color(0.16, 0.20, 0.28, 0.9))
|
||||
crest.texture = ImageTexture.create_from_image(ph)
|
||||
head.add_child(crest)
|
||||
var gbox := VBoxContainer.new()
|
||||
head.add_child(gbox)
|
||||
gbox.add_child(_plain(_empire_name() if has_char else "—", 14, Color(0.95, 0.9, 0.7)))
|
||||
var gname := String(d.get("guild_name", "")).strip_edges()
|
||||
gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 13, Color(0.8, 0.82, 0.86)))
|
||||
|
||||
_panel_box.add_child(_sep())
|
||||
|
||||
# 名称 / 等级 / 游戏时间
|
||||
_panel_box.add_child(_kv("名称", String(d.get("name", "")) if has_char else "空槽位"))
|
||||
_panel_box.add_child(_kv("等级", str(int(d.get("level", 0))) if has_char else "-"))
|
||||
var mins := int(d.get("play_minutes", 0))
|
||||
_panel_box.add_child(_kv("游戏时间", ("%d 时 %d 分" % [mins / 60, mins % 60]) if has_char else "-"))
|
||||
|
||||
_panel_box.add_child(_sep())
|
||||
|
||||
# 四维
|
||||
for row in STAT_ROWS:
|
||||
var rd: Dictionary = row
|
||||
var v := int(d.get(rd["key"], 0)) if has_char else 0
|
||||
_panel_box.add_child(_stat(String(rd["label"]), v, Color(rd["color"])))
|
||||
|
||||
_panel_box.add_child(_sep())
|
||||
|
||||
# 槽位切换
|
||||
if _chars.size() > 1:
|
||||
var nav := HBoxContainer.new()
|
||||
nav.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
nav.add_theme_constant_override("separation", 12)
|
||||
_panel_box.add_child(nav)
|
||||
var prev := Button.new(); prev.text = "◀"; prev.custom_minimum_size = Vector2(38, 28)
|
||||
prev.pressed.connect(func(): _select(wrapi(_sel - 1, 0, _chars.size())))
|
||||
nav.add_child(prev)
|
||||
_slot_lbl = _plain("%d / %d" % [_sel + 1, _chars.size()], 14, Color(1, 1, 1))
|
||||
nav.add_child(_slot_lbl)
|
||||
var nxt := Button.new(); nxt.text = "▶"; nxt.custom_minimum_size = Vector2(38, 28)
|
||||
nxt.pressed.connect(func(): _select(wrapi(_sel + 1, 0, _chars.size())))
|
||||
nav.add_child(nxt)
|
||||
|
||||
# 按钮
|
||||
var brow := GridContainer.new()
|
||||
brow.columns = 2
|
||||
brow.add_theme_constant_override("h_separation", 8)
|
||||
brow.add_theme_constant_override("v_separation", 8)
|
||||
_panel_box.add_child(brow)
|
||||
var start := Button.new()
|
||||
start.text = "开始"
|
||||
start.custom_minimum_size = Vector2(122, 34)
|
||||
start.disabled = not has_char
|
||||
start.pressed.connect(_do_start)
|
||||
brow.add_child(start)
|
||||
var create := Button.new()
|
||||
create.text = "创建"
|
||||
create.custom_minimum_size = Vector2(122, 34)
|
||||
create.disabled = has_char
|
||||
create.pressed.connect(_open_create_dialog)
|
||||
brow.add_child(create)
|
||||
var del := Button.new()
|
||||
del.text = "删除"
|
||||
del.custom_minimum_size = Vector2(122, 34)
|
||||
del.disabled = not has_char
|
||||
del.pressed.connect(_open_delete_dialog)
|
||||
brow.add_child(del)
|
||||
var quit := Button.new()
|
||||
quit.text = "退出"
|
||||
quit.custom_minimum_size = Vector2(122, 34)
|
||||
quit.pressed.connect(func(): back_requested.emit())
|
||||
brow.add_child(quit)
|
||||
|
||||
# --- 底部状态 --------------------------------------------------------------
|
||||
|
||||
func _build_status() -> void:
|
||||
_status = Label.new()
|
||||
_status.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
||||
_status.position = Vector2(24, -32)
|
||||
_status.add_theme_font_size_override("font_size", 13)
|
||||
_status.add_theme_color_override("font_color", Color(0.9, 0.92, 0.95))
|
||||
_status.add_theme_color_override("font_outline_color", Color(0, 0, 0))
|
||||
_status.add_theme_constant_override("outline_size", 4)
|
||||
add_child(_status)
|
||||
|
||||
func set_status(t: String) -> void:
|
||||
_set_status(t)
|
||||
|
||||
# 会徽下载完成后由 app_flow 调用,重绘信息板即可拿到真徽记。
|
||||
func refresh_crest() -> void:
|
||||
_refresh_panel()
|
||||
|
||||
func _set_status(t: String) -> void:
|
||||
if _status:
|
||||
_status.text = t
|
||||
|
||||
# --- 选择 / 循环 ----------------------------------------------------------
|
||||
|
||||
func _select(i: int) -> void:
|
||||
if _chars.is_empty():
|
||||
_sel = 0
|
||||
_refresh_class_name()
|
||||
_refresh_panel()
|
||||
return
|
||||
_sel = clampi(i, 0, _chars.size() - 1)
|
||||
if _slot_lbl:
|
||||
_slot_lbl.text = "%d / %d" % [_sel + 1, _chars.size()]
|
||||
_refresh_class_name()
|
||||
_refresh_panel()
|
||||
_rebuild_model()
|
||||
var nm := String(_cur().get("name", ""))
|
||||
_set_status("已选:%s" % nm if nm != "" else "空槽位(点“创建”建号)")
|
||||
|
||||
func _do_start() -> void:
|
||||
var d := _cur()
|
||||
if d.is_empty() or String(d.get("name", "")) == "":
|
||||
_set_status("空槽位不能进入")
|
||||
return
|
||||
select_requested.emit(_slot_index())
|
||||
_set_status("进入游戏:%s" % d.get("name"))
|
||||
|
||||
# --- 建号 / 删号弹窗 ------------------------------------------------------
|
||||
|
||||
var _dialog: Control
|
||||
|
||||
func _close_dialog() -> void:
|
||||
if _dialog and is_instance_valid(_dialog):
|
||||
_dialog.queue_free()
|
||||
_dialog = null
|
||||
|
||||
func _dialog_node() -> Node:
|
||||
return _dialog if _dialog and is_instance_valid(_dialog) else null
|
||||
|
||||
func _modal_root(title: String, w: int, h: int) -> VBoxContainer:
|
||||
_close_dialog()
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0, 0, 0, 0.55)
|
||||
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(dim)
|
||||
_dialog = dim
|
||||
var board := UiKit.board(_assets, "board", 24, 96)
|
||||
board.set_anchors_preset(Control.PRESET_CENTER)
|
||||
board.position = Vector2(-w / 2.0, -h / 2.0)
|
||||
board.size = Vector2(w, h)
|
||||
dim.add_child(board)
|
||||
var bg := ColorRect.new()
|
||||
bg.color = Color(0.04, 0.06, 0.07, 0.92)
|
||||
bg.position = Vector2(10, 10)
|
||||
bg.size = Vector2(w - 20, h - 20)
|
||||
board.add_child(bg)
|
||||
var box := VBoxContainer.new()
|
||||
box.position = Vector2(24, 22)
|
||||
box.custom_minimum_size = Vector2(w - 48, 0)
|
||||
box.add_theme_constant_override("separation", 10)
|
||||
board.add_child(box)
|
||||
var t := _plain(title, 18, Color(0.95, 0.85, 0.55))
|
||||
box.add_child(t)
|
||||
box.add_child(_sep())
|
||||
return box
|
||||
|
||||
func _open_create_dialog() -> void:
|
||||
var d := _cur()
|
||||
if String(d.get("name", "")) != "":
|
||||
_set_status("该槽位已有角色")
|
||||
return
|
||||
var slot := _slot_index()
|
||||
# 空槽默认按 slot 序轮职业,让四个槽能各建一职
|
||||
var job := clampi(slot, 0, 3)
|
||||
var box := _modal_root("创建角色 · 槽位 %d" % slot, 420, 360)
|
||||
|
||||
var jrow := HBoxContainer.new()
|
||||
jrow.add_theme_constant_override("separation", 8)
|
||||
box.add_child(jrow)
|
||||
jrow.add_child(_plain("职业", 13, Color(0.72, 0.78, 0.85)))
|
||||
var jopt := OptionButton.new()
|
||||
jopt.name = "Job"
|
||||
for i in 4:
|
||||
jopt.add_item("%s / %s" % [CLASS_CN[i], CLASS_DIR[i]], i)
|
||||
jopt.selected = job
|
||||
jrow.add_child(jopt)
|
||||
|
||||
var nrow := HBoxContainer.new()
|
||||
nrow.add_theme_constant_override("separation", 8)
|
||||
box.add_child(nrow)
|
||||
nrow.add_child(_plain("名称", 13, Color(0.72, 0.78, 0.85)))
|
||||
var name_edit := LineEdit.new()
|
||||
name_edit.name = "Name"
|
||||
name_edit.max_length = 12
|
||||
name_edit.custom_minimum_size = Vector2(240, 0)
|
||||
name_edit.placeholder_text = "角色名(最多 12)"
|
||||
nrow.add_child(name_edit)
|
||||
|
||||
# 四维(起始属性,只读,随职业变)
|
||||
var stat_box := VBoxContainer.new()
|
||||
stat_box.name = "Stats"
|
||||
stat_box.add_theme_constant_override("separation", 5)
|
||||
box.add_child(stat_box)
|
||||
var render_stats := func(j: int) -> void:
|
||||
for c in stat_box.get_children():
|
||||
c.queue_free()
|
||||
var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0])
|
||||
stat_box.add_child(_stat("体力", int(bs["con"]), Color(0.90, 0.30, 0.30)))
|
||||
stat_box.add_child(_stat("智力", int(bs["int"]), Color(0.85, 0.45, 0.95)))
|
||||
stat_box.add_child(_stat("力量", int(bs["str"]), Color(0.70, 0.55, 1.00)))
|
||||
stat_box.add_child(_stat("敏捷", int(bs["dex"]), Color(0.35, 0.75, 1.00)))
|
||||
render_stats.call(job)
|
||||
jopt.item_selected.connect(func(j: int): render_stats.call(j))
|
||||
|
||||
box.add_child(_sep())
|
||||
var brow := HBoxContainer.new()
|
||||
brow.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
brow.add_theme_constant_override("separation", 16)
|
||||
box.add_child(brow)
|
||||
var ok := Button.new(); ok.text = "创建"; ok.custom_minimum_size = Vector2(120, 34)
|
||||
ok.pressed.connect(func():
|
||||
var nm := name_edit.text.strip_edges()
|
||||
if nm.length() < 2:
|
||||
_set_status("名称太短")
|
||||
return
|
||||
var j := jopt.get_selected_id()
|
||||
var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0])
|
||||
create_requested.emit({
|
||||
"slot": slot, "name": nm, "job": j, "shape": 0,
|
||||
"con": int(bs["con"]), "int": int(bs["int"]),
|
||||
"str": int(bs["str"]), "dex": int(bs["dex"]),
|
||||
})
|
||||
_set_status("创建中…"))
|
||||
brow.add_child(ok)
|
||||
var cancel := Button.new(); cancel.text = "取消"; cancel.custom_minimum_size = Vector2(120, 34)
|
||||
cancel.pressed.connect(_close_dialog)
|
||||
brow.add_child(cancel)
|
||||
|
||||
func _open_delete_dialog() -> void:
|
||||
var d := _cur()
|
||||
if String(d.get("name", "")) == "":
|
||||
return
|
||||
var slot := _slot_index()
|
||||
var box := _modal_root("删除角色", 400, 240)
|
||||
box.add_child(_plain("将永久删除 “%s” (Lv.%d)。" % [d.get("name", ""), int(d.get("level", 0))],
|
||||
13, Color(0.95, 0.75, 0.7)))
|
||||
var crow := HBoxContainer.new()
|
||||
crow.add_theme_constant_override("separation", 8)
|
||||
box.add_child(crow)
|
||||
crow.add_child(_plain("删除码", 13, Color(0.72, 0.78, 0.85)))
|
||||
var code := LineEdit.new()
|
||||
code.name = "Code"
|
||||
code.secret = true
|
||||
code.max_length = 7
|
||||
code.custom_minimum_size = Vector2(200, 0)
|
||||
code.placeholder_text = "社交号 / 删除码"
|
||||
crow.add_child(code)
|
||||
box.add_child(_sep())
|
||||
var brow := HBoxContainer.new()
|
||||
brow.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
brow.add_theme_constant_override("separation", 16)
|
||||
box.add_child(brow)
|
||||
var ok := Button.new(); ok.text = "删除"; ok.custom_minimum_size = Vector2(120, 34)
|
||||
ok.pressed.connect(func():
|
||||
delete_requested.emit(slot, code.text)
|
||||
_set_status("删除中…"))
|
||||
brow.add_child(ok)
|
||||
var cancel := Button.new(); cancel.text = "取消"; cancel.custom_minimum_size = Vector2(120, 34)
|
||||
cancel.pressed.connect(_close_dialog)
|
||||
brow.add_child(cancel)
|
||||
|
||||
# --- 国家 --------------------------------------------------------------
|
||||
|
||||
func _empire_id() -> int:
|
||||
if _client and _client.has_method("get_empire"):
|
||||
return int(_client.get_empire())
|
||||
return 0
|
||||
|
||||
func _empire_name() -> String:
|
||||
var e := _empire_id()
|
||||
# 优先 locale_game.txt 的 EMPIRE_A/B/C
|
||||
var key: String = EMPIRE_KEY.get(e, "")
|
||||
if key != "":
|
||||
var loc := _locale_line(key)
|
||||
if loc != "":
|
||||
return loc
|
||||
return EMPIRE_FALLBACK.get(e, "—")
|
||||
|
||||
static var _loc_cache: Dictionary = {}
|
||||
|
||||
func _locale_line(key: String) -> String:
|
||||
if _loc_cache.has(key):
|
||||
return _loc_cache[key]
|
||||
var val := ""
|
||||
for rel in ["locale/locale/en/locale_game.txt", "locale/locale/de/locale_game.txt"]:
|
||||
var p := _assets.path_join(rel)
|
||||
if not FileAccess.file_exists(p):
|
||||
continue
|
||||
var f := FileAccess.open(p, FileAccess.READ)
|
||||
while f and not f.eof_reached():
|
||||
var parts := f.get_line().split("\t", false)
|
||||
if parts.size() >= 2 and String(parts[0]) == key:
|
||||
val = String(parts[1]).strip_edges()
|
||||
break
|
||||
if val != "":
|
||||
break
|
||||
_loc_cache[key] = val
|
||||
return val
|
||||
|
||||
func _empire_flag_tex() -> Texture2D:
|
||||
var f: String = EMPIRE_FLAG.get(_empire_id(), "")
|
||||
if f == "":
|
||||
return null
|
||||
return UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/empire/empireflag_%s.sub" % f)
|
||||
|
||||
func _process(dt: float) -> void:
|
||||
_spin += dt
|
||||
if _pivot:
|
||||
# 正面朝相机 + 轻微来回摆
|
||||
_pivot.rotation.y = PI + sin(_spin * 0.3) * 0.35
|
||||
|
||||
# --- 小工具 --------------------------------------------------------------
|
||||
|
||||
func _cur() -> Dictionary:
|
||||
return _chars[_sel] if _sel >= 0 and _sel < _chars.size() else {}
|
||||
|
||||
func _slot_index() -> int:
|
||||
return int(_cur().get("index", _sel))
|
||||
|
||||
func _plain(text: String, size: int, col: Color) -> Label:
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.add_theme_font_size_override("font_size", size)
|
||||
l.add_theme_color_override("font_color", col)
|
||||
return l
|
||||
|
||||
func _sep() -> Control:
|
||||
var c := ColorRect.new()
|
||||
c.color = Color(1, 1, 1, 0.12)
|
||||
c.custom_minimum_size = Vector2(0, 2)
|
||||
return c
|
||||
|
||||
func _kv(k: String, v: String) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
var kl := _plain(k, 13, Color(0.72, 0.78, 0.85))
|
||||
kl.custom_minimum_size = Vector2(64, 0)
|
||||
row.add_child(kl)
|
||||
row.add_child(_plain(v, 14, Color(1, 1, 1)))
|
||||
return row
|
||||
|
||||
func _stat(label: String, value: int, col: Color) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
var kl := _plain(label, 13, Color(0.72, 0.78, 0.85))
|
||||
kl.custom_minimum_size = Vector2(44, 0)
|
||||
row.add_child(kl)
|
||||
var track := ColorRect.new()
|
||||
track.color = Color(0, 0, 0, 0.5)
|
||||
track.custom_minimum_size = Vector2(150, 14)
|
||||
row.add_child(track)
|
||||
var fill := ColorRect.new()
|
||||
fill.color = col
|
||||
fill.anchor_left = 0.0
|
||||
fill.anchor_top = 0.0
|
||||
fill.anchor_bottom = 1.0
|
||||
fill.offset_left = 1
|
||||
fill.offset_top = 1
|
||||
fill.offset_bottom = -1
|
||||
fill.offset_right = 1 + int(clampf(value / 25.0, 0.0, 1.0) * 148.0)
|
||||
track.add_child(fill)
|
||||
row.add_child(_plain(str(value), 13, Color(1, 1, 1)))
|
||||
return row
|
||||
Reference in New Issue
Block a user