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
243 lines
7.8 KiB
GDScript
243 lines
7.8 KiB
GDScript
# 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
|