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,164 @@
|
||||
# skill_test —— P6:skilldesc 解析 + skill_ui 行 + quickbar 施放/冷却 headless 自检。
|
||||
# godot --headless --path project --script skill_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const SkillTable = preload("res://ui/skill_table.gd")
|
||||
const SkillUI = preload("res://ui/skill_ui.gd")
|
||||
const Quickbar = preload("res://ui/quickbar.gd")
|
||||
const UiManager = preload("res://ui/ui_manager.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal skills_changed()
|
||||
signal skill_cooldown_end(skill: int)
|
||||
signal quickslots_changed()
|
||||
var skills := [{"id": 1, "level": 5, "master": 0}, {"id": 16, "level": 3, "master": 2}]
|
||||
var quickslots := []
|
||||
var casts := []
|
||||
var skill_uses := []
|
||||
var quickslot_ops := []
|
||||
var saids := []
|
||||
var main := 1000
|
||||
func get_main_vid() -> int: return main
|
||||
func get_entity(_v) -> Dictionary: return {"race": 0}
|
||||
func get_skills() -> Array: return skills
|
||||
func get_quickslots() -> Array: return quickslots
|
||||
func get_target() -> Dictionary: return {"vid": 2000}
|
||||
func cast_skill(mi, rot, x, y) -> bool: casts.append([mi, rot, x, y]); return true
|
||||
func use_skill(id, target_vid) -> bool: skill_uses.append([id, target_vid]); return true
|
||||
func quickslot_add(pos, type, ref) -> bool: quickslot_ops.append(["add", pos, type, ref]); return true
|
||||
func quickslot_del(pos) -> bool: quickslot_ops.append(["del", pos]); return true
|
||||
func quickslot_swap(pos, change_pos) -> bool: quickslot_ops.append(["swap", pos, change_pos]); return true
|
||||
func skill_up(id) -> bool: saids.append(id); return true
|
||||
func use_item(w, c) -> bool: return true
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
if _fail == 0:
|
||||
print("PASS: skill_test (table + skill_ui + quickbar)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var assets := AssetRoot.path()
|
||||
var st := SkillTable.new()
|
||||
var loaded := false
|
||||
for lang in ["en", "common"]:
|
||||
if st.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)):
|
||||
loaded = true
|
||||
break
|
||||
if not loaded:
|
||||
print(" (skip: no skilldesc.txt)")
|
||||
return
|
||||
_ck(st.count > 30, "skilldesc parsed (%d skills)" % st.count)
|
||||
var w := st.for_job("WARRIOR")
|
||||
_ck(w.size() > 3, "WARRIOR has skills (%d)" % w.size())
|
||||
var s1: Dictionary = st.entry(1)
|
||||
_ck(s1.get("job", "") == "WARRIOR", "skill 1 job = WARRIOR")
|
||||
_ck(String(s1.get("name", "")) != "", "skill 1 has a name (%s)" % s1.get("name"))
|
||||
# 列偏移修正后:skill 1 'Three-Way Cut' motion_name=samyeon motion_idx=1, ATTACK_SKILL
|
||||
_ck(st.motion_idx_of(1) == 1, "skill 1 motion_idx == 1 (列偏移已修)")
|
||||
_ck(String(s1.get("motion", "")) == "samyeon", "skill 1 motion_name == samyeon")
|
||||
_ck(st.is_attack(1), "skill 1 是 ATTACK_SKILL")
|
||||
_ck(st.is_ranged(46), "skill 46 bow skill is ranged")
|
||||
_ck(not st.is_ranged(1), "skill 1 melee skill is not ranged")
|
||||
_ck(is_equal_approx(st.cooldown_of(1, 5), 15.0), "skill 1 .msk CoolTimeFormula = 15s")
|
||||
_ck(st.is_attack(19) == false and String(st.entry(19).get("name", "")) != "",
|
||||
"skill 19 'Strong Body' 非攻击")
|
||||
# 辅助 / 被动树
|
||||
var sup := st.for_category("SUPPORT")
|
||||
_ck(sup.size() > 3, "SUPPORT 分类有技能 (%d)" % sup.size())
|
||||
_ck(st.is_passive(121), "skill 121 'Leadership' is_passive")
|
||||
_ck(st.can_level_up(125) == false, "skill 125 'Item Creation' CANNOT_LEVEL_UP")
|
||||
_ck(st.for_category("HORSE").size() >= 3, "HORSE 分类有技能")
|
||||
|
||||
var fc := FakeClient.new()
|
||||
get_root().add_child(fc)
|
||||
var ui: CanvasLayer = UiManager.new()
|
||||
get_root().add_child(ui)
|
||||
ui._ready()
|
||||
|
||||
# skill_ui
|
||||
var sk: Node = SkillUI.new()
|
||||
get_root().add_child(sk)
|
||||
sk.setup(fc, st, ui)
|
||||
sk.set_job("WARRIOR")
|
||||
sk.open()
|
||||
_ck(sk.is_open(), "skill window opened")
|
||||
_ck(sk._rows.size() == w.size(), "skill window rows = WARRIOR skill count")
|
||||
# 找 skill 1 的行,点加号
|
||||
if sk._rows.has(1):
|
||||
sk._rows[1]["up"].pressed.emit()
|
||||
_ck(fc.saids == [1], "+ -> client.skill_up(1)")
|
||||
_ck(String(sk._rows[1]["lv"].text) == "Lv 5", "skill 1 shows Lv 5 (from get_skills)")
|
||||
# skill 16 master 2 -> "Lv 3 G"
|
||||
if sk._rows.has(16):
|
||||
_ck(String(sk._rows[16]["lv"].text) == "Lv 3 G", "skill 16 master 2 -> 'Lv 3 G'")
|
||||
|
||||
# 切到「辅助」页 -> 列 SUPPORT,被动技能无 [+]
|
||||
sk._switch_tab(1)
|
||||
_ck(sk._cat_key() == "SUPPORT", "tab 1 -> SUPPORT")
|
||||
_ck(sk._rows.has(121) and sk._rows[121]["up"] == null, "被动技能 121 无 [+] 按钮")
|
||||
# 有能加点的辅助技能带 [+]
|
||||
var any_up := false
|
||||
for rid in sk._rows:
|
||||
if sk._rows[rid]["up"] != null:
|
||||
any_up = true
|
||||
_ck(any_up or sup.all(func(i): return st.is_passive(i) or not st.can_level_up(i)),
|
||||
"辅助页有可加点项 或 全是被动")
|
||||
sk._switch_tab(2)
|
||||
_ck(sk._cat_key() == "HORSE", "tab 2 -> HORSE")
|
||||
sk._switch_tab(0) # 切回主动,后面的 quickbar 断言不受影响
|
||||
|
||||
# quickbar
|
||||
var pl := Node3D.new()
|
||||
get_root().add_child(pl)
|
||||
var qb: Node = Quickbar.new()
|
||||
get_root().add_child(qb)
|
||||
qb.setup(fc, st, ui, func() -> Node: return pl)
|
||||
qb.assign(0, "skill", 1)
|
||||
_ck(fc.quickslot_ops == [["add", 0, 2, 1]], "assign -> CG_QUICKSLOT_ADD slot 0")
|
||||
qb.activate(0)
|
||||
_ck(fc.skill_uses.size() == 1 and fc.skill_uses[0] == [1, 2000],
|
||||
"quickbar slot 0 -> use_skill(skill 1, selected target)")
|
||||
_ck(fc.casts.size() == 1, "quickbar slot 0 -> cast_skill once")
|
||||
if fc.casts.size() == 1:
|
||||
_ck(int(fc.casts[0][0]) == st.motion_idx_of(1), "cast used skill 1's motion_idx")
|
||||
# 冷却中不再施放
|
||||
qb.activate(0)
|
||||
_ck(fc.skill_uses.size() == 1, "on cooldown -> no second use_skill")
|
||||
_ck(fc.casts.size() == 1, "on cooldown -> no second cast")
|
||||
# GC_SKILL_COOLTIME_END 提前解锁
|
||||
fc.skill_cooldown_end.emit(1)
|
||||
qb.activate(0)
|
||||
_ck(fc.skill_uses.size() == 2, "cooldown_end -> can use_skill again")
|
||||
_ck(fc.casts.size() == 2, "cooldown_end -> can cast again")
|
||||
# 36 槽 = 4 页 × 9 格;第四页的第 9 格正好是全局槽 35。
|
||||
qb.set_page(3)
|
||||
qb.assign(8, "item", 9)
|
||||
_ck(qb._state.size() == 36 and qb._state[35].kind == "item" and qb._state[35].id == 9,
|
||||
"quickbar has 36 slots; page 4 slot 9 = global slot 35")
|
||||
_ck(fc.quickslot_ops.back() == ["add", 35, 1, 9], "page 4 assign -> CG_QUICKSLOT_ADD{35,item,9}")
|
||||
qb.set_page(0)
|
||||
qb._swap(0, 1) # UI 的两次点击最终调用同一个交换操作
|
||||
_ck(fc.quickslot_ops.back() == ["swap", 0, 1], "quickslot move -> CG_QUICKSLOT_SWAP")
|
||||
qb._clear(1)
|
||||
_ck(fc.quickslot_ops.back() == ["del", 1], "quickslot clear -> CG_QUICKSLOT_DEL")
|
||||
|
||||
# 服务器 GC_QUICKSLOT_* 恢复:type 2 技能 / type 1 道具 -> 填格
|
||||
var qb2: Node = Quickbar.new()
|
||||
get_root().add_child(qb2)
|
||||
fc.quickslots = [{"pos": 2, "type": 2, "ref": 16}, {"pos": 3, "type": 1, "ref": 9}]
|
||||
qb2.setup(fc, st, ui, func() -> Node: return pl) # setup 里自动 restore_from_server
|
||||
_ck(qb2._state[2].kind == "skill" and int(qb2._state[2].id) == 16, "quickslot 恢复:格2=技能16")
|
||||
_ck(qb2._state[3].kind == "item" and int(qb2._state[3].id) == 9, "quickslot 恢复:格3=道具9")
|
||||
fc.quickslots = [{"pos": 0, "type": 2, "ref": 1}]
|
||||
fc.quickslots_changed.emit()
|
||||
_ck(qb2._state[0].kind == "skill" and int(qb2._state[0].id) == 1, "quickslots_changed -> 重填")
|
||||
Reference in New Issue
Block a user