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,235 @@
|
||||
# Mse (P5) —— 解析 Metin2 `.mse` 特效脚本(EffectLib,非逆向,有完整源码)。
|
||||
#
|
||||
# `.mse` 是 CTextFileLoader 花括号文本树:
|
||||
# BoundingSphereRadius 120.0
|
||||
# Group Particle {
|
||||
# StartTime 0.0
|
||||
# List TimeEventPosition { 0.0 "MOVING_TYPE_DIRECT" 8.0 0.0 25.0 }
|
||||
# Group EmitterProperty { MaxEmissionCount 5 CycleLength 0.4 List TimeEventSizeX { 0.0 64.0 } ... }
|
||||
# Group ParticleProperty { SrcBlendType 5 BillboardType 1 List TimeEventAlpha { 0.0 0.71 0.86 0.0 } List TextureFiles { "x.dds" } }
|
||||
# }
|
||||
# Group Mesh { MeshFileName "a.mde" MeshElementCount 1 Group MeshElement00 { ... } }
|
||||
#
|
||||
# var m := preload("res://fx/mse.gd").new()
|
||||
# var spec := m.parse_file(path)
|
||||
# # spec = { bsphere_r, bsphere_pos:Vector3, particles:[Dict], meshes:[Dict], dir }
|
||||
#
|
||||
# 每个 particle dict 保留原始键(大小写不变):
|
||||
# start_time:float, position: [ [t, "MOVING_TYPE", x,y,z], ... ],
|
||||
# emitter: Dict, particle: Dict (List 键 -> Array[Array],标量键 -> float/String/Array)
|
||||
extends RefCounted
|
||||
|
||||
var last_error := ""
|
||||
|
||||
var _toks: Array = []
|
||||
var _p := 0
|
||||
|
||||
func parse_file(path: String) -> Dictionary:
|
||||
last_error = ""
|
||||
if not FileAccess.file_exists(path):
|
||||
last_error = "no such file: " + path
|
||||
return {}
|
||||
var spec := parse_text(FileAccess.get_file_as_string(path))
|
||||
spec["dir"] = path.get_base_dir()
|
||||
return spec
|
||||
|
||||
func parse_text(src: String) -> Dictionary:
|
||||
_toks = _tokenize(src)
|
||||
_p = 0
|
||||
var top := _parse_block(true)
|
||||
var out := {
|
||||
"bsphere_r": float(_scalar(top.get("BoundingSphereRadius", 0.0))),
|
||||
"bsphere_pos": _vec3(top.get("BoundingSpherePosition", [0, 0, 0])),
|
||||
"particles": [],
|
||||
"meshes": [],
|
||||
"dir": "",
|
||||
}
|
||||
for g in top.get("__group_Particle", []):
|
||||
out.particles.append(_norm_particle(g))
|
||||
for g in top.get("__group_Mesh", []):
|
||||
out.meshes.append(_norm_mesh(g))
|
||||
return out
|
||||
|
||||
# --- 归一化 -----------------------------------------------------------
|
||||
|
||||
func _norm_particle(g: Dictionary) -> Dictionary:
|
||||
return {
|
||||
"start_time": float(_scalar(g.get("StartTime", 0.0))),
|
||||
"position": g.get("TimeEventPosition", []),
|
||||
"emitter": _first(g.get("__group_EmitterProperty", [])),
|
||||
"particle": _first(g.get("__group_ParticleProperty", [])),
|
||||
}
|
||||
|
||||
func _norm_mesh(g: Dictionary) -> Dictionary:
|
||||
var elems := []
|
||||
for k in g:
|
||||
if String(k).begins_with("__group_MeshElement"):
|
||||
for e in g[k]:
|
||||
elems.append(e)
|
||||
return {
|
||||
"start_time": float(_scalar(g.get("StartTime", 0.0))),
|
||||
"position": g.get("TimeEventPosition", []),
|
||||
"mesh_file": String(_scalar(g.get("MeshFileName", ""))),
|
||||
"frame_delay": float(_scalar(g.get("MeshAnimationFrameDelay", 0.0))),
|
||||
"elements": elems,
|
||||
}
|
||||
|
||||
func _first(a) -> Dictionary:
|
||||
return a[0] if a is Array and a.size() > 0 else {}
|
||||
|
||||
func _scalar(v):
|
||||
if v is Array:
|
||||
return v[0] if v.size() > 0 else 0.0
|
||||
return v
|
||||
|
||||
func _vec3(v) -> Vector3:
|
||||
if v is Array and v.size() >= 3:
|
||||
return Vector3(float(v[0]), float(v[1]), float(v[2]))
|
||||
return Vector3.ZERO
|
||||
|
||||
# --- 词法 -----------------------------------------------------------
|
||||
# tokens: "{" "}" "\n" · {"s": <quoted string>} · 裸词/数字(String)
|
||||
|
||||
func _tokenize(src: String) -> Array:
|
||||
var out := []
|
||||
var i := 0
|
||||
var n := src.length()
|
||||
while i < n:
|
||||
var c := src[i]
|
||||
if c == "\n":
|
||||
out.append("\n")
|
||||
i += 1
|
||||
elif c in [" ", "\t", "\r"]:
|
||||
i += 1
|
||||
elif c == '"':
|
||||
var j := i + 1
|
||||
var s := ""
|
||||
while j < n and src[j] != '"':
|
||||
s += src[j]
|
||||
j += 1
|
||||
out.append({"q": s}) # 带引号的字符串(q 键区分裸词)
|
||||
i = j + 1
|
||||
elif c == "{" or c == "}":
|
||||
out.append(c)
|
||||
i += 1
|
||||
elif c == "#" or (c == "/" and i + 1 < n and src[i + 1] == "/"):
|
||||
while i < n and src[i] != "\n":
|
||||
i += 1
|
||||
else:
|
||||
var j := i
|
||||
while j < n and not (src[j] in [" ", "\t", "\r", "\n", "{", "}", '"']):
|
||||
j += 1
|
||||
out.append(src.substr(i, j - i))
|
||||
i = j
|
||||
return out
|
||||
|
||||
func _eq(v, ch) -> bool:
|
||||
return v is String and v == ch
|
||||
|
||||
func _val(t):
|
||||
if t is Dictionary:
|
||||
return t.get("q", "")
|
||||
var s := String(t)
|
||||
# 数字?
|
||||
if s.is_valid_float():
|
||||
return float(s)
|
||||
if s.is_valid_int():
|
||||
return int(s)
|
||||
return s
|
||||
|
||||
# 解析一个 { } 块(top=true 时是隐式顶层)。
|
||||
func _parse_block(top := false) -> Dictionary:
|
||||
var d := {}
|
||||
var guard := 0
|
||||
while _p < _toks.size():
|
||||
guard += 1
|
||||
if guard > 500000:
|
||||
last_error = "runaway"
|
||||
break
|
||||
var t = _toks[_p]
|
||||
if _eq(t, "}"):
|
||||
_p += 1
|
||||
return d
|
||||
if _eq(t, "\n"):
|
||||
_p += 1
|
||||
continue
|
||||
if _eq(t, "{"):
|
||||
_p += 1
|
||||
continue
|
||||
var word := "" if (t is Dictionary) else String(t)
|
||||
if word == "Group":
|
||||
_p += 1
|
||||
var name := _next_word()
|
||||
_skip_newlines()
|
||||
if _eq(_cur(), "{"):
|
||||
_p += 1
|
||||
var sub := _parse_block()
|
||||
var key := "__group_" + name
|
||||
if not d.has(key):
|
||||
d[key] = []
|
||||
d[key].append(sub)
|
||||
continue
|
||||
if word == "List":
|
||||
_p += 1
|
||||
var lname := _next_word()
|
||||
_skip_newlines()
|
||||
var rows := []
|
||||
if _eq(_cur(), "{"):
|
||||
_p += 1
|
||||
rows = _parse_list_rows()
|
||||
d[lname] = rows
|
||||
continue
|
||||
# KEY v1 v2 ... (到行尾)
|
||||
var key := word
|
||||
_p += 1
|
||||
var vals := []
|
||||
while _p < _toks.size() and not _eq(_toks[_p], "\n") and not _eq(_toks[_p], "{") and not _eq(_toks[_p], "}"):
|
||||
vals.append(_val(_toks[_p]))
|
||||
_p += 1
|
||||
d[key] = vals[0] if vals.size() == 1 else vals
|
||||
if top and _p >= _toks.size():
|
||||
break
|
||||
return d
|
||||
|
||||
func _parse_list_rows() -> Array:
|
||||
var rows := []
|
||||
var row := []
|
||||
var guard := 0
|
||||
while _p < _toks.size():
|
||||
guard += 1
|
||||
if guard > 200000:
|
||||
break
|
||||
var t = _toks[_p]
|
||||
if _eq(t, "}"):
|
||||
_p += 1
|
||||
if not row.is_empty():
|
||||
rows.append(row)
|
||||
break
|
||||
if _eq(t, "\n"):
|
||||
_p += 1
|
||||
if not row.is_empty():
|
||||
rows.append(row)
|
||||
row = []
|
||||
continue
|
||||
if _eq(t, "{"):
|
||||
_p += 1
|
||||
continue
|
||||
row.append(_val(t))
|
||||
_p += 1
|
||||
return rows
|
||||
|
||||
func _cur():
|
||||
return _toks[_p] if _p < _toks.size() else null
|
||||
|
||||
func _next_word() -> String:
|
||||
while _p < _toks.size() and _eq(_toks[_p], "\n"):
|
||||
_p += 1
|
||||
if _p < _toks.size() and not (_toks[_p] is Dictionary):
|
||||
var w := String(_toks[_p])
|
||||
_p += 1
|
||||
return w
|
||||
return "?"
|
||||
|
||||
func _skip_newlines() -> void:
|
||||
while _p < _toks.size() and _eq(_toks[_p], "\n"):
|
||||
_p += 1
|
||||
Reference in New Issue
Block a user