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,242 @@
|
||||
# EffectPlayer (P5) —— 解析后的 `.mse` spec → Godot 节点树(GPUParticles3D 为主)。
|
||||
#
|
||||
# var fx := preload("res://fx/effect_player.gd").new()
|
||||
# fx.build(spec, assets_root) # spec 来自 fx/mse.gd
|
||||
# add_child(fx) # 挂到要出特效的位置(可 reparent 到骨骼)
|
||||
# fx.play() # 或 fx.play(true) 一次性
|
||||
#
|
||||
# 覆盖 Particle 组:发射形状 / 速率 / 寿命 / 方向 / 重力 / 缩放曲线 / 颜色渐变 /
|
||||
# billboard / 加法混合 / 旋转。纹理(.dds)暂用程序化径向渐变代替(GDScript 不解 DDS)。
|
||||
# Mesh 组:占位(MeshFileName 是 .mde,另需解码器)。SimpleLight:本资产集未用。
|
||||
extends Node3D
|
||||
|
||||
var spec := {}
|
||||
var assets_root := ""
|
||||
var one_shot := false
|
||||
var _emitters: Array[GPUParticles3D] = []
|
||||
var _mesh_nodes: Array[Node3D] = []
|
||||
var _bsphere_r := 0.0
|
||||
|
||||
static var _glow_tex: Texture2D
|
||||
|
||||
func build(mse_spec: Dictionary, assets := "") -> void:
|
||||
spec = mse_spec
|
||||
assets_root = assets
|
||||
_bsphere_r = float(spec.get("bsphere_r", 0.0))
|
||||
for p in spec.get("particles", []):
|
||||
var e := _build_particle(p)
|
||||
if e:
|
||||
add_child(e)
|
||||
_emitters.append(e)
|
||||
for m in spec.get("meshes", []):
|
||||
var mn := _build_mesh(m)
|
||||
if mn:
|
||||
add_child(mn)
|
||||
_mesh_nodes.append(mn)
|
||||
|
||||
func play(force_one_shot := false) -> void:
|
||||
for i in _emitters.size():
|
||||
var e := _emitters[i]
|
||||
var st := float(_emitters[i].get_meta("start_time", 0.0))
|
||||
if force_one_shot:
|
||||
e.one_shot = true
|
||||
var tree := get_tree()
|
||||
if st <= 0.0 or tree == null:
|
||||
e.restart()
|
||||
e.emitting = true
|
||||
else:
|
||||
tree.create_timer(st).timeout.connect(func():
|
||||
if is_instance_valid(e):
|
||||
e.restart()
|
||||
e.emitting = true)
|
||||
var tr := get_tree()
|
||||
if (force_one_shot or one_shot) and tr:
|
||||
var total := _longest_life() + 0.5
|
||||
tr.create_timer(maxf(total, 1.5)).timeout.connect(queue_free)
|
||||
|
||||
func stop() -> void:
|
||||
for e in _emitters:
|
||||
e.emitting = false
|
||||
|
||||
func _longest_life() -> float:
|
||||
var m := 1.0
|
||||
for e in _emitters:
|
||||
m = maxf(m, e.lifetime + float(e.get_meta("start_time", 0.0)))
|
||||
return m
|
||||
|
||||
# --- particle ---------------------------------------------------------
|
||||
|
||||
func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
var emit: Dictionary = p.get("emitter", {})
|
||||
var prop: Dictionary = p.get("particle", {})
|
||||
var g := GPUParticles3D.new()
|
||||
g.set_meta("start_time", float(p.get("start_time", 0.0)))
|
||||
g.amount = maxi(1, int(_n(emit.get("MaxEmissionCount", 16))))
|
||||
g.explosiveness = 0.0
|
||||
|
||||
var life := _last_val(emit.get("TimeEventLifeTime", []), 1.0)
|
||||
g.lifetime = clampf(life, 0.05, 12.0)
|
||||
var cycle := _n(emit.get("CycleLength", 0.0))
|
||||
var loop := int(_n(emit.get("CycleLoopEnable", 0)))
|
||||
g.one_shot = (loop == 0)
|
||||
if cycle > 0.0 and loop != 0:
|
||||
g.lifetime = maxf(g.lifetime, cycle)
|
||||
|
||||
var pm := ParticleProcessMaterial.new()
|
||||
# 发射形状
|
||||
var shape := int(_n(emit.get("EmitterShape", 0)))
|
||||
if shape == 3:
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
||||
pm.emission_sphere_radius = maxf(0.01, _n(emit.get("EmittingRadius", 10.0)) * 0.01)
|
||||
else:
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT
|
||||
# 方向 / 速度
|
||||
var vel := _last_val(emit.get("TimeEventEmittingVelocity", []), 0.0) * 0.01
|
||||
pm.direction = Vector3(0, 1, 0)
|
||||
pm.spread = 25.0
|
||||
pm.initial_velocity_min = vel * 0.6
|
||||
pm.initial_velocity_max = maxf(vel, 0.05)
|
||||
# 重力
|
||||
var grav := _last_val(p.get("particle", {}).get("TimeEventGravity", []), 0.0)
|
||||
pm.gravity = Vector3(0, -grav * 0.01, 0)
|
||||
# 大小
|
||||
var sx := _last_val(emit.get("TimeEventSizeX", []), 32.0) * 0.01
|
||||
pm.scale_min = maxf(0.02, sx * 0.7)
|
||||
pm.scale_max = maxf(0.03, sx)
|
||||
pm.scale_curve = _curve_tex(prop.get("TimeEventScaleX", [[0, 1], [1, 0]]))
|
||||
# 颜色渐变(RGB 曲线各取末值 + Alpha 曲线)
|
||||
pm.color = _rgb(prop)
|
||||
var ramp := _alpha_ramp(prop.get("TimeEventAlpha", []), pm.color)
|
||||
if ramp:
|
||||
pm.color_ramp = ramp
|
||||
# 旋转
|
||||
var rspeed := _n(prop.get("RotationSpeed", 0.0))
|
||||
if rspeed != 0.0:
|
||||
pm.angular_velocity_min = deg_to_rad(rspeed) * 0.3
|
||||
pm.angular_velocity_max = deg_to_rad(rspeed)
|
||||
g.process_material = pm
|
||||
|
||||
# 绘制网格 + 材质(billboard + 混合)
|
||||
var qm := QuadMesh.new()
|
||||
qm.size = Vector2(1, 1)
|
||||
g.draw_pass_1 = qm
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.billboard_mode = _billboard(int(_n(prop.get("BillboardType", 1))))
|
||||
mat.billboard_keep_scale = true
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.blend_mode = _blend(int(_n(prop.get("SrcBlendType", 5))), int(_n(prop.get("DestBlendType", 2))))
|
||||
mat.albedo_texture = _glow()
|
||||
mat.vertex_color_use_as_albedo = true
|
||||
g.material_override = mat
|
||||
return g
|
||||
|
||||
func _build_mesh(m: Dictionary) -> Node3D:
|
||||
# .mde 需要专门解码器;先占位一个小 box,位置对
|
||||
var n := Node3D.new()
|
||||
n.name = "mesh_" + String(m.get("mesh_file", "?")).get_basename()
|
||||
var mi := MeshInstance3D.new()
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = Vector3(0.1, 0.1, 0.4)
|
||||
mi.mesh = bm
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(1, 0.9, 0.5, 0.6)
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mi.material_override = mat
|
||||
n.add_child(mi)
|
||||
return n
|
||||
|
||||
# --- helpers ---------------------------------------------------------
|
||||
|
||||
func _n(v) -> float:
|
||||
if v is Array:
|
||||
return float(v[0]) if v.size() > 0 else 0.0
|
||||
if v is float or v is int:
|
||||
return float(v)
|
||||
return 0.0
|
||||
|
||||
# List 表末行的值(列 idx,默认 col 1 = 时间后第一个数)
|
||||
func _last_val(rows, def: float, col := 1) -> float:
|
||||
if rows is Array and rows.size() > 0:
|
||||
var r = rows[-1]
|
||||
if r is Array and r.size() > col and (r[col] is float or r[col] is int):
|
||||
return float(r[col])
|
||||
return def
|
||||
|
||||
func _rgb(prop: Dictionary) -> Color:
|
||||
return Color(
|
||||
_last_val(prop.get("TimeEventColorRed", []), 1.0),
|
||||
_last_val(prop.get("TimeEventColorGreen", []), 1.0),
|
||||
_last_val(prop.get("TimeEventColorBlue", []), 1.0),
|
||||
1.0)
|
||||
|
||||
# TimeEventScaleX rows [[t,v],...] -> CurveTexture
|
||||
func _curve_tex(rows) -> CurveTexture:
|
||||
var c := Curve.new()
|
||||
c.min_value = 0.0
|
||||
c.max_value = 2.0
|
||||
if rows is Array and rows.size() > 0:
|
||||
for r in rows:
|
||||
if r is Array and r.size() >= 2:
|
||||
c.add_point(Vector2(clampf(float(r[0]), 0, 1), float(r[1])))
|
||||
else:
|
||||
c.add_point(Vector2(0, 1))
|
||||
c.add_point(Vector2(1, 0))
|
||||
var t := CurveTexture.new()
|
||||
t.curve = c
|
||||
return t
|
||||
|
||||
func _alpha_ramp(rows, base: Color) -> GradientTexture1D:
|
||||
if not (rows is Array) or rows.size() < 1:
|
||||
return null
|
||||
var grad := Gradient.new()
|
||||
grad.offsets = PackedFloat32Array()
|
||||
grad.colors = PackedColorArray()
|
||||
var pts := []
|
||||
for r in rows:
|
||||
if r is Array and r.size() >= 2:
|
||||
pts.append([clampf(float(r[0]), 0, 1), float(r[1])])
|
||||
if pts.is_empty():
|
||||
return null
|
||||
pts.sort_custom(func(a, b): return a[0] < b[0])
|
||||
if pts[0][0] > 0.0:
|
||||
pts.push_front([0.0, pts[0][1]])
|
||||
if pts[-1][0] < 1.0:
|
||||
pts.append([1.0, pts[-1][1]])
|
||||
for pt in pts:
|
||||
grad.add_point(pt[0], Color(base.r, base.g, base.b, clampf(pt[1], 0, 1)))
|
||||
grad.remove_point(0)
|
||||
grad.remove_point(0)
|
||||
var gt := GradientTexture1D.new()
|
||||
gt.gradient = grad
|
||||
return gt
|
||||
|
||||
func _billboard(bt: int) -> int:
|
||||
# 0 = none/local, 1 = 面向相机, 4 = Y 轴
|
||||
if bt == 4:
|
||||
return BaseMaterial3D.BILLBOARD_FIXED_Y
|
||||
if bt == 0:
|
||||
return BaseMaterial3D.BILLBOARD_DISABLED
|
||||
return BaseMaterial3D.BILLBOARD_ENABLED
|
||||
|
||||
func _blend(src: int, dst: int) -> int:
|
||||
# D3D blend: 5=SRCALPHA 2=ONE 4=INVSRCALPHA 3=SRCCOLOR
|
||||
if dst == 2:
|
||||
return BaseMaterial3D.BLEND_MODE_ADD
|
||||
if dst == 4:
|
||||
return BaseMaterial3D.BLEND_MODE_MIX
|
||||
return BaseMaterial3D.BLEND_MODE_ADD
|
||||
|
||||
# 程序化径向渐变(代替 .dds 粒子纹理)
|
||||
static func _glow() -> Texture2D:
|
||||
if _glow_tex == null:
|
||||
var s := 48
|
||||
var img := Image.create(s, s, false, Image.FORMAT_RGBA8)
|
||||
for y in s:
|
||||
for x in s:
|
||||
var d := Vector2(x - s / 2.0, y - s / 2.0).length() / (s / 2.0)
|
||||
var a := clampf(1.0 - d, 0.0, 1.0)
|
||||
a = a * a
|
||||
img.set_pixel(x, y, Color(1, 1, 1, a))
|
||||
_glow_tex = ImageTexture.create_from_image(img)
|
||||
return _glow_tex
|
||||
@@ -0,0 +1 @@
|
||||
uid://dusxpcvp74to5
|
||||
@@ -0,0 +1,99 @@
|
||||
# EffectRegistry (P5) —— 按名字 / 路径找 `.mse`,缓存解析结果,生成 EffectPlayer。
|
||||
#
|
||||
# var fxr := preload("res://fx/effect_registry.gd").new()
|
||||
# fxr.setup(assets_root)
|
||||
# fxr.spawn("geompung_3_sword", weapon_bone_node) # 挂到某节点下播一次性
|
||||
# var pv := fxr.spawn_at("hit_spark", world, global_pos)
|
||||
extends RefCounted
|
||||
|
||||
const Mse = preload("res://fx/mse.gd")
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
|
||||
var assets_root := ""
|
||||
var _path_cache := {} # name -> abs .mse path ("" = not found)
|
||||
var _spec_cache := {} # abs path -> parsed spec
|
||||
|
||||
func setup(assets: String) -> void:
|
||||
assets_root = assets
|
||||
|
||||
# name 可为: 裸名 "xxx"(扫 **/effect/ 下 xxx.mse)· 相对 "effect/xxx.mse" · "d:/ymir work/.../xxx.mse"
|
||||
func resolve(name: String) -> String:
|
||||
if _path_cache.has(name):
|
||||
return _path_cache[name]
|
||||
var p := _resolve_uncached(name)
|
||||
_path_cache[name] = p
|
||||
return p
|
||||
|
||||
func _resolve_uncached(name: String) -> String:
|
||||
if assets_root == "":
|
||||
return ""
|
||||
var rel := name.replace("\\", "/")
|
||||
if rel.length() >= 2 and rel[1] == ":":
|
||||
rel = rel.substr(2)
|
||||
rel = rel.lstrip("/")
|
||||
if not rel.ends_with(".mse"):
|
||||
rel += ".mse"
|
||||
# 直接 / 散包相对
|
||||
for cand in [assets_root.path_join(rel)]:
|
||||
if FileAccess.file_exists(cand):
|
||||
return cand
|
||||
var da := DirAccess.open(assets_root)
|
||||
if da:
|
||||
for sub in da.get_directories():
|
||||
var c := assets_root.path_join(sub).path_join(rel)
|
||||
if FileAccess.file_exists(c):
|
||||
return c
|
||||
# 裸名:按 basename 递归找(限定 effect 目录,限量)
|
||||
if not name.contains("/"):
|
||||
var bn := rel.get_file()
|
||||
var hit := _scan_for(assets_root, bn, 7)
|
||||
if hit != "":
|
||||
return hit
|
||||
return ""
|
||||
|
||||
func _scan_for(dir: String, basename: String, depth: int) -> String:
|
||||
if depth < 0:
|
||||
return ""
|
||||
var da := DirAccess.open(dir)
|
||||
if da == null:
|
||||
return ""
|
||||
for f in da.get_files():
|
||||
if f == basename:
|
||||
return dir.path_join(f)
|
||||
for sub in da.get_directories():
|
||||
if sub.begins_with("."):
|
||||
continue
|
||||
var r := _scan_for(dir.path_join(sub), basename, depth - 1)
|
||||
if r != "":
|
||||
return r
|
||||
return ""
|
||||
|
||||
func spec_for(name: String) -> Dictionary:
|
||||
var p := resolve(name)
|
||||
if p == "":
|
||||
return {}
|
||||
if _spec_cache.has(p):
|
||||
return _spec_cache[p]
|
||||
var s := Mse.new().parse_file(p)
|
||||
_spec_cache[p] = s
|
||||
return s
|
||||
|
||||
# 挂到 parent 下(跟随 parent 变换),播一次性
|
||||
func spawn(name: String, parent: Node3D, one_shot := true) -> Node3D:
|
||||
var s := spec_for(name)
|
||||
if s.is_empty() or parent == null:
|
||||
return null
|
||||
var fx: Node3D = EffectPlayer.new()
|
||||
fx.name = "fx_" + name.get_file().get_basename()
|
||||
fx.one_shot = one_shot
|
||||
fx.build(s, assets_root)
|
||||
parent.add_child(fx)
|
||||
fx.play(one_shot)
|
||||
return fx
|
||||
|
||||
# 在世界某点播一次性
|
||||
func spawn_at(name: String, world_parent: Node3D, global_pos: Vector3, one_shot := true) -> Node3D:
|
||||
var fx := spawn(name, world_parent, one_shot)
|
||||
if fx:
|
||||
fx.global_position = global_pos
|
||||
return fx
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyu4mphxwm5ti
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c728y7nra8uhh
|
||||
@@ -0,0 +1,74 @@
|
||||
# SkillFx (P6 收尾) —— 技能 id / 内建特效 id → .mse 文件,接 P5 EffectRegistry。
|
||||
#
|
||||
# var sfx := preload("res://fx/skill_fx.gd").new()
|
||||
# sfx.setup(effect_registry, skill_table)
|
||||
# sfx.spawn_skill(1, 3, caster_node) # 技能 1(samyeon) grade→_4 挂 caster 播
|
||||
# sfx.spawn_special(19, node) # GC_SPECIAL_EFFECT type 19 = SE_AUTO_HPUP -> drugup_red
|
||||
#
|
||||
# 技能特效表:skilldesc 的 motion_name(col13)就是特效基名;等级后缀 = 2 + master_type(夹 2..4)。
|
||||
# effect/pc/<class>/effect/<motion>_<N>.mse(EffectRegistry 的 basename 扫描能找到散包 / PC / season1)
|
||||
# 少数技能有 _blow / _head / _foot / _yong 子特效;这里只放主特效,缺就跳过。
|
||||
extends RefCounted
|
||||
|
||||
# GC_SPECIAL_EFFECT 的 type(ItemData.h / Packet.h 的 SE_* enum,值 = 下标)-> etc 特效基名。
|
||||
# 不全 —— 只填常见的(真机 GM 号狂刷 19/20 自动喝药)。
|
||||
const SPECIAL_FX := {
|
||||
1: "drugup_red", # SE_HPUP_RED
|
||||
2: "drugup_blue", # SE_SPUP_BLUE
|
||||
3: "drugup_green", # SE_SPEEDUP_GREEN
|
||||
4: "drugup_purple", # SE_DXUP_PURPLE
|
||||
9: "firecracker_1", # SE_CHINA_FIREWORK
|
||||
11: "firecracker_2", # SE_SUCCESS(凑合)
|
||||
12: "fail", # SE_FAIL
|
||||
19: "drugup_red", # SE_AUTO_HPUP
|
||||
20: "drugup_blue", # SE_AUTO_SPUP
|
||||
}
|
||||
|
||||
var fxr: RefCounted # EffectRegistry
|
||||
var table: RefCounted # SkillTable
|
||||
# 一个 motion 常见的子特效后缀(有就一起播)
|
||||
const SUB_SUFFIX := ["", "_blow", "_hand", "_head", "_foot", "_yong"]
|
||||
|
||||
func setup(effect_registry: RefCounted, skill_table: RefCounted) -> void:
|
||||
fxr = effect_registry
|
||||
table = skill_table
|
||||
|
||||
# skill_id + master_type(0..3) -> 主特效基名(<motion>_<N>)。查不到 motion 返回 ""。
|
||||
func skill_effect_name(skill_id: int, master: int = 0) -> String:
|
||||
if table == null:
|
||||
return ""
|
||||
var mn := String(table.entry(skill_id).get("motion", ""))
|
||||
if mn == "":
|
||||
return ""
|
||||
var n := clampi(2 + master, 2, 4)
|
||||
return "%s_%d" % [mn, n]
|
||||
|
||||
# 在 host 下播技能特效(主 + 已存在的子特效各一份)。返回播了几个。
|
||||
func spawn_skill(skill_id: int, master: int, host: Node3D) -> int:
|
||||
if fxr == null or host == null:
|
||||
return 0
|
||||
var base := skill_effect_name(skill_id, master)
|
||||
if base == "":
|
||||
return 0
|
||||
var parts := base.rsplit("_", false, 1) # "samyeon_4" -> ["samyeon", "4"]
|
||||
var stem: String = parts[0]
|
||||
var num: String = parts[1] if parts.size() > 1 else ""
|
||||
var played := 0
|
||||
for suf in SUB_SUFFIX:
|
||||
var name: String = base
|
||||
if suf != "":
|
||||
name = "%s%s_%s" % [stem, suf, num] if num != "" else stem + suf
|
||||
if fxr.resolve(name) != "":
|
||||
fxr.spawn(name, host, true)
|
||||
played += 1
|
||||
return played
|
||||
|
||||
# GC_SPECIAL_EFFECT type -> 内建特效。返回是否播了。
|
||||
func spawn_special(special_id: int, host: Node3D) -> bool:
|
||||
if fxr == null or host == null or not SPECIAL_FX.has(special_id):
|
||||
return false
|
||||
var name: String = SPECIAL_FX[special_id]
|
||||
if fxr.resolve(name) == "":
|
||||
return false
|
||||
fxr.spawn(name, host, true)
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://dit70gssglg2b
|
||||
@@ -0,0 +1,83 @@
|
||||
# Weather (P9) —— 雪 / 雨 环境粒子。跟随相机,在其上方一片区域内下落。
|
||||
#
|
||||
# var w := preload("res://fx/weather.gd").new()
|
||||
# add_child(w)
|
||||
# w.setup(camera) # camera: Camera3D / Node3D
|
||||
# w.set_weather("snow") # "snow" | "rain" | "none"
|
||||
#
|
||||
# 用 GPUParticles3D + ParticleProcessMaterial 的盒子发射体,粒子朝下。
|
||||
# 由地图属性或调试驱动;没有专门封包。
|
||||
extends Node3D
|
||||
|
||||
var _cam: Node3D
|
||||
var _p: GPUParticles3D
|
||||
var _kind := "none"
|
||||
|
||||
func setup(camera: Node3D) -> void:
|
||||
_cam = camera
|
||||
_p = GPUParticles3D.new()
|
||||
_p.amount = 800
|
||||
_p.lifetime = 3.0
|
||||
_p.visibility_aabb = AABB(Vector3(-30, -30, -30), Vector3(60, 60, 60))
|
||||
_p.local_coords = false
|
||||
_p.emitting = false
|
||||
add_child(_p)
|
||||
_build_material()
|
||||
set_weather("none")
|
||||
|
||||
func kind() -> String:
|
||||
return _kind
|
||||
|
||||
func set_weather(k: String) -> void:
|
||||
_kind = k
|
||||
if _p == null:
|
||||
return
|
||||
if k == "none":
|
||||
_p.emitting = false
|
||||
return
|
||||
_p.emitting = true
|
||||
var m: ParticleProcessMaterial = _p.process_material
|
||||
if k == "snow":
|
||||
_p.amount = 700
|
||||
_p.lifetime = 6.0
|
||||
m.gravity = Vector3(0, -2.0, 0)
|
||||
m.initial_velocity_min = 0.5
|
||||
m.initial_velocity_max = 1.5
|
||||
m.scale_min = 0.03
|
||||
m.scale_max = 0.08
|
||||
m.color = Color(1, 1, 1, 0.9)
|
||||
m.turbulence_enabled = true
|
||||
m.turbulence_noise_strength = 0.6
|
||||
elif k == "rain":
|
||||
_p.amount = 1200
|
||||
_p.lifetime = 1.2
|
||||
m.gravity = Vector3(0, -30.0, 0)
|
||||
m.initial_velocity_min = 12.0
|
||||
m.initial_velocity_max = 18.0
|
||||
m.scale_min = 0.015
|
||||
m.scale_max = 0.03
|
||||
m.color = Color(0.7, 0.8, 0.95, 0.5)
|
||||
m.turbulence_enabled = false
|
||||
|
||||
func _build_material() -> void:
|
||||
var m := ParticleProcessMaterial.new()
|
||||
m.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
m.emission_box_extents = Vector3(25, 1, 25)
|
||||
m.direction = Vector3(0, -1, 0)
|
||||
m.spread = 8.0
|
||||
m.gravity = Vector3(0, -2.0, 0)
|
||||
_p.process_material = m
|
||||
var mesh := QuadMesh.new()
|
||||
mesh.size = Vector2(0.1, 0.1)
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
mat.albedo_color = Color(1, 1, 1, 0.85)
|
||||
mesh.material = mat
|
||||
_p.draw_pass_1 = mesh
|
||||
|
||||
func _process(_dt: float) -> void:
|
||||
if _cam and _p:
|
||||
# 发射体盒子锚在相机上方 20m
|
||||
_p.global_position = _cam.global_position + Vector3(0, 20, 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj5x183tcq55n
|
||||
Reference in New Issue
Block a user