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:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+138
View File
@@ -0,0 +1,138 @@
# fx_test —— P5.mse 解析 + EffectPlayer 构建 headless 自检。
# godot --headless --path project --script fx_test.gd
extends SceneTree
const Mse = preload("res://fx/mse.gd")
const EffectPlayer = preload("res://fx/effect_player.gd")
const EffectRegistry = preload("res://fx/effect_registry.gd")
const FIXTURE := """
BoundingSphereRadius 120.000000
BoundingSpherePosition 0.000000 0.000000 100.000000
Group Particle
{
StartTime 0.000000
List TimeEventPosition
{
0.000000 "MOVING_TYPE_DIRECT" 8.000000 0.000000 25.000000
}
Group EmitterProperty
{
MaxEmissionCount 5
CycleLength 0.400000
CycleLoopEnable 1
EmitterShape 3
EmittingRadius 10.000000
List TimeEventLifeTime
{
0.000000 0.077320
}
List TimeEventSizeX
{
0.000000 64.000000
}
}
Group ParticleProperty
{
SrcBlendType 5
DestBlendType 2
BillboardType 1
RotationSpeed 500.000000
List TimeEventScaleX
{
0.000000 1.000000
1.000000 0.000000
}
List TimeEventColorRed { 0.000000 0.886275 }
List TimeEventColorGreen { 0.000000 0.086275 }
List TimeEventColorBlue { 0.000000 0.239216 }
List TimeEventAlpha
{
0.000000 0.710000
0.864103 0.000000
}
List TextureFiles { "bottom_under.dds" }
}
}
Group Particle
{
StartTime 0.200000
Group EmitterProperty { MaxEmissionCount 10 EmitterShape 0 }
Group ParticleProperty { SrcBlendType 5 DestBlendType 4 BillboardType 4 }
}
"""
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_test_parser()
_test_builder()
_test_registry()
if _fail == 0:
print("PASS: fx_test (.mse parser + EffectPlayer + registry)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _test_parser() -> void:
var m := Mse.new()
var s := m.parse_text(FIXTURE)
_ck(m.last_error == "", "parse: no error (%s)" % m.last_error)
_ck(abs(float(s.bsphere_r) - 120.0) < 0.01, "bsphere radius")
_ck(s.bsphere_pos == Vector3(0, 0, 100), "bsphere pos = (0,0,100)")
_ck(s.particles.size() == 2, "2 Particle groups (%d)" % s.particles.size())
var p0: Dictionary = s.particles[0]
_ck(abs(p0.start_time) < 0.01, "p0 start_time 0")
_ck(p0.position.size() == 1 and String(p0.position[0][1]) == "MOVING_TYPE_DIRECT", "p0 TimeEventPosition row parsed with quoted enum")
_ck(int(p0.emitter.get("MaxEmissionCount")) == 5, "p0 emitter MaxEmissionCount 5")
_ck(int(p0.emitter.get("EmitterShape")) == 3, "p0 EmitterShape 3")
var scale_rows = p0.particle.get("TimeEventScaleX")
_ck(scale_rows is Array and scale_rows.size() == 2 and float(scale_rows[1][1]) == 0.0, "p0 ScaleX list = 2 rows, last v=0")
var tex = p0.particle.get("TextureFiles")
_ck(tex is Array and tex.size() == 1 and String(tex[0][0]) == "bottom_under.dds", "p0 TextureFiles")
_ck(abs(float(s.particles[1].start_time) - 0.2) < 0.01, "p1 start_time 0.2")
func _test_builder() -> void:
var m := Mse.new()
var s := m.parse_text(FIXTURE)
var fx: Node3D = EffectPlayer.new()
get_root().add_child(fx)
fx.build(s, "")
_ck(fx._emitters.size() == 2, "EffectPlayer built 2 GPUParticles3D")
var g0: GPUParticles3D = fx._emitters[0]
_ck(g0.amount == 5, "emitter 0 amount = 5")
_ck(g0.process_material is ParticleProcessMaterial, "emitter 0 has process material")
var pm: ParticleProcessMaterial = g0.process_material
_ck(pm.emission_shape == ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, "emitter 0 sphere emission (shape 3)")
_ck(g0.material_override is StandardMaterial3D, "emitter 0 draw material")
_ck((g0.material_override as StandardMaterial3D).blend_mode == BaseMaterial3D.BLEND_MODE_ADD, "src5/dst2 -> additive blend")
_ck(float(fx._emitters[1].get_meta("start_time")) == 0.2, "emitter 1 start_time meta = 0.2")
fx.play()
_ck(g0.emitting, "emitter 0 emitting after play() (start_time 0)")
fx.queue_free()
func _test_registry() -> void:
var assets := AssetRoot.path()
if not DirAccess.dir_exists_absolute(assets):
print(" (skip registry: no assets)")
return
var fxr := EffectRegistry.new()
fxr.setup(assets)
# 已知存在: PC/ymir work/pc/warrior/effect/geompung_3_sword.mse
var p := fxr.resolve("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse")
if p == "":
p = fxr.resolve("geompung_3_sword")
_ck(p != "" and FileAccess.file_exists(p), "registry resolved a real .mse (%s)" % p)
if p != "":
var s := fxr.spec_for("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse")
_ck(s.get("particles", []).size() > 0, "real .mse parsed to >=1 particle (%d)" % s.get("particles", []).size())
var parent := Node3D.new()
get_root().add_child(parent)
var fx := fxr.spawn("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse", parent, true)
_ck(fx != null and fx.get_parent() == parent, "spawn attached EffectPlayer")