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
+242
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dusxpcvp74to5
+99
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dyu4mphxwm5ti
+235
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://c728y7nra8uhh
+74
View File
@@ -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_namecol13)就是特效基名;等级后缀 = 2 + master_type(夹 2..4)。
# effect/pc/<class>/effect/<motion>_<N>.mseEffectRegistry 的 basename 扫描能找到散包 / PC / season1
# 少数技能有 _blow / _head / _foot / _yong 子特效;这里只放主特效,缺就跳过。
extends RefCounted
# GC_SPECIAL_EFFECT 的 typeItemData.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
+1
View File
@@ -0,0 +1 @@
uid://dit70gssglg2b
+83
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cj5x183tcq55n