Implement playable Mac client and rendering validation
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
extends SceneTree
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + message)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
Metin2AnimPlayer.clear_clip_cache()
|
||||
var a := PlayerView.new()
|
||||
var b := PlayerView.new()
|
||||
for view in [a, b]:
|
||||
check(view.build(AssetRoot.path(), 0), "warrior builds")
|
||||
root.add_child(view)
|
||||
view.anim.set_process(false)
|
||||
check(Metin2AnimPlayer.get_clip_cache_stats().misses == 1, "two actors decode idle only once")
|
||||
a.anim.set_time(0.3)
|
||||
b.anim.set_time(0.9)
|
||||
check(not is_equal_approx(a.anim.get_time(), b.anim.get_time()), "playback clocks remain independent")
|
||||
for i in 3:
|
||||
for state in ["run", "attack", "wait"]:
|
||||
for view in [a, b]:
|
||||
view.set_anim_state(state)
|
||||
view.anim.set_time(0.2)
|
||||
var stats: Dictionary = Metin2AnimPlayer.get_clip_cache_stats()
|
||||
check(stats.misses == 3 and stats.hits >= 17, "hot motion switches reuse decoded clips")
|
||||
check(stats.entries <= 32 and stats.section_bytes <= 64 * 1024 * 1024, "cache has entry and decoded-section budget")
|
||||
check(stats.msa_misses == 3 and stats.msa_hits >= 17, "hot switches reuse parsed MSA metadata")
|
||||
check(stats.msa_entries <= 128, "MSA cache has an entry limit")
|
||||
Metin2AnimPlayer.clear_clip_cache()
|
||||
check(Metin2AnimPlayer.get_clip_cache_stats().msa_entries == 0, "MSA cache clears with clips")
|
||||
a.anim.set_time(0.5)
|
||||
check(a.model.get_visual_aabb().size.length() > 0, "clearing cache does not invalidate active clip")
|
||||
a.free()
|
||||
b.free()
|
||||
await process_frame
|
||||
print("animation_cache_test: failures=%d stats=%s" % [failures, stats])
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmwgj04n10rly
|
||||
@@ -84,6 +84,36 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
|
||||
func state() -> int:
|
||||
return _state
|
||||
|
||||
# Read-only adapter for the packaged playable test. It deliberately returns
|
||||
# summaries rather than the private UI/game nodes so a report cannot retain a
|
||||
# stale scene reference across reconnect or map reload.
|
||||
func get_playable_snapshot() -> Dictionary:
|
||||
var stage := "LOGIN"
|
||||
match _state:
|
||||
SELECT: stage = "SELECT"
|
||||
GAME: stage = "GAME"
|
||||
var selected: Dictionary = {}
|
||||
for character in _chars:
|
||||
if int(character.get("index", -1)) == _selected_char_slot:
|
||||
selected = {"index": _selected_char_slot, "name_present": not String(character.get("name", "")).is_empty()}
|
||||
break
|
||||
var scene_ready := false
|
||||
var map_key := ""
|
||||
if _game != null and is_instance_valid(_game) and _game.has_method("get_playable_context"):
|
||||
var context: Dictionary = _game.get_playable_context()
|
||||
scene_ready = bool(context.get("scene_ready", false))
|
||||
map_key = String(context.get("map_path", ""))
|
||||
return {"stage": stage, "selected_character": selected,
|
||||
"character_count": _chars.size(), "scene_ready": scene_ready,
|
||||
"map_key": map_key, "reconnecting": _reconnecting_game}
|
||||
|
||||
# Internal test adapter: callers must treat returned Node references as
|
||||
# borrowed and reacquire them after every scene/reconnect transition.
|
||||
func get_playable_context() -> Dictionary:
|
||||
if _game == null or not is_instance_valid(_game) or not _game.has_method("get_playable_context"):
|
||||
return {}
|
||||
return _game.get_playable_context()
|
||||
|
||||
# --- client 信号 -------------------------------------------------------------
|
||||
|
||||
func _wire_client() -> void:
|
||||
|
||||
@@ -48,6 +48,9 @@ func _init() -> void:
|
||||
"AppLifecycle survives login screen construction")
|
||||
_ck(client.get_parent() == flow,
|
||||
"AppFlow keeps the same client beside lifecycle coordinator")
|
||||
var login_snapshot: Dictionary = flow.get_playable_snapshot()
|
||||
_ck(login_snapshot.get("stage", "") == "LOGIN" and not login_snapshot.get("scene_ready", true),
|
||||
"playable snapshot is a read-only LOGIN summary")
|
||||
|
||||
# A live game keeps its scene during a transient disconnect. ReconnectUI
|
||||
# owns the retry while the next character list silently re-enters the last
|
||||
@@ -67,6 +70,9 @@ func _init() -> void:
|
||||
_ck(flow.state() == AppFlow.GAME and not flow._reconnecting_game
|
||||
and not flow._reconnect.is_showing(),
|
||||
"successful reconnect restores GAME and hides overlay")
|
||||
var game_snapshot: Dictionary = flow.get_playable_snapshot()
|
||||
_ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true),
|
||||
"playable snapshot reports GAME after reconnect")
|
||||
|
||||
flow.queue_free()
|
||||
await process_frame
|
||||
|
||||
@@ -56,6 +56,33 @@ func run() -> void:
|
||||
if indices(models[0]).size() != 4:
|
||||
failures += 1
|
||||
printerr("FAIL: body and hair surfaces must both be exercised")
|
||||
# Changes must update the visible mesh immediately, even while animation is paused.
|
||||
for model in models:
|
||||
model.set("hair_gr2", "")
|
||||
if indices(model).size() != 3:
|
||||
failures += 1
|
||||
printerr("FAIL: detached hair remains in rendered mesh")
|
||||
model.set("hair_gr2", base.path_join("hair/hair_2_1.gr2"))
|
||||
if indices(model).size() != 4:
|
||||
failures += 1
|
||||
printerr("FAIL: replacement hair missing")
|
||||
model.set("hair_skin", base.path_join("warrior_hair_01.dds"))
|
||||
var mi: MeshInstance3D = model.find_children("*", "MeshInstance3D", true, false)[0]
|
||||
var hair: ShaderMaterial = mi.get_active_material(3)
|
||||
var expected: Image = preload("res://ui/ui_assets.gd").load_dds_image(base.path_join("warrior_hair_01.dds"))
|
||||
var actual: Image = hair.get_shader_parameter("albedo_tex").get_image()
|
||||
actual.convert(Image.FORMAT_RGBA8)
|
||||
expected.convert(Image.FORMAT_RGBA8)
|
||||
actual.clear_mipmaps()
|
||||
expected.clear_mipmaps()
|
||||
if actual.get_data() != expected.get_data() or not hair.shader.code.contains("cull_disabled"):
|
||||
failures += 1
|
||||
printerr("FAIL: replacement hair texture or two-sided material not applied")
|
||||
if OS.get_environment("MTGODOT_GPUSKIN") == "1":
|
||||
var body: ShaderMaterial = mi.get_active_material(0)
|
||||
if int(body.get_shader_parameter("mode")) != 0:
|
||||
failures += 1
|
||||
printerr("FAIL: body incorrectly reclassified as hair")
|
||||
print("character_winding_test: failures=", failures)
|
||||
for node in animations + models:
|
||||
node.queue_free()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dg8ihlbqw6jss
|
||||
@@ -8,9 +8,14 @@ extends Node
|
||||
const AppFlow = preload("res://app_flow.gd")
|
||||
const LiveSmokeTest = preload("res://live_smoke_test.gd")
|
||||
const PackageRenderTest = preload("res://package_render_test.gd")
|
||||
const PlayableLiveTest = preload("res://playable_live_test.gd")
|
||||
const ForestMapRenderTest = preload("res://forest_map_render_test.gd")
|
||||
const NetTrace = preload("res://net_trace.gd")
|
||||
|
||||
func _ready() -> void:
|
||||
if OS.get_environment("MT_TEST_MODE") == "particle_exit":
|
||||
add_child(preload("res://particle_exit_test.gd").new())
|
||||
return
|
||||
# MT_TEST_MODE=render:包内离线渲染自检,不起 AppFlow、不连服务器。
|
||||
# 导出模板不接受 --script,这是导出包里唯一能跑离线渲染回归的入口。
|
||||
if OS.get_environment("MT_TEST_MODE") == "render":
|
||||
@@ -19,6 +24,11 @@ func _ready() -> void:
|
||||
t.name = "PackageRenderTest"
|
||||
add_child(t)
|
||||
return
|
||||
if OS.get_environment("MT_TEST_MODE") == "forest_render":
|
||||
var forest := ForestMapRenderTest.new()
|
||||
forest.name = "ForestMapRenderTest"
|
||||
add_child(forest)
|
||||
return
|
||||
# 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic
|
||||
# DH2/CTR 协议,因此默认固定到 classic,同时保留 MT_PROTOCOL 覆盖能力。
|
||||
if OS.get_environment("MT_PROTOCOL").is_empty():
|
||||
@@ -44,3 +54,8 @@ func _ready() -> void:
|
||||
smoke.name = "LiveSmokeTest"
|
||||
add_child(smoke)
|
||||
smoke.setup(af, af.client)
|
||||
if OS.get_environment("MT_TEST_MODE") == "playable":
|
||||
var playable := PlayableLiveTest.new()
|
||||
playable.name = "PlayableLiveTest"
|
||||
add_child(playable)
|
||||
playable.setup(af, af.client)
|
||||
|
||||
@@ -63,6 +63,8 @@ class FakeAnim extends Node:
|
||||
class FakeView extends Node3D:
|
||||
var states := []
|
||||
var anim := FakeAnim.new()
|
||||
func _init() -> void:
|
||||
add_child(anim)
|
||||
func set_anim_state(s): states.append(s)
|
||||
|
||||
class FakeHud extends Node:
|
||||
@@ -300,6 +302,7 @@ func _run() -> void:
|
||||
|
||||
# 端到端:proto 桩 + parts[WEAPON] -> _refresh_motion_mode() -> combo_motion_mode 变,且清连击
|
||||
var fp := FakeProto.new()
|
||||
get_root().add_child(fp)
|
||||
fp.items[19] = {"type": np.ITEM_TYPE_WEAPON, "sub_type": np.WEAPON_SUB_DAGGER}
|
||||
np.proto = fp
|
||||
np._main_vid = 1000
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
extends SceneTree
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
func _init() -> void:
|
||||
var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/shamanblackcore.dds")
|
||||
var before := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||||
var images: Array[Image] = []
|
||||
var failed := false
|
||||
for i in range(20):
|
||||
var decoded := UiAssets.load_dds_image(path)
|
||||
if decoded == null or decoded.is_empty():
|
||||
failed = true
|
||||
else:
|
||||
images.append(decoded)
|
||||
var after := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||||
failed = failed or before != after or images.size() != 20
|
||||
for decoded in images:
|
||||
failed = failed or decoded.get_data() != images[0].get_data()
|
||||
if failed:
|
||||
printerr("FAIL: DDS helper ownership/images before=%d after=%d images=%d" % [before, after, images.size()])
|
||||
else:
|
||||
print("PASS: dds_lifecycle_test (20 decodes, node count unchanged, images survive helper release)")
|
||||
quit(1 if failed else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://buf6xm7130vgl
|
||||
@@ -0,0 +1,116 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
const OPERATIONS := [2, 3, 4, 5, 6, 8]
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
var materials: Array = []
|
||||
var white := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||
white.fill(Color.WHITE)
|
||||
var texture := ImageTexture.create_from_image(white)
|
||||
for destination in [6, 2]:
|
||||
for operation in OPERATIONS:
|
||||
fx.build({"particles": [{"particle": {"BillboardType": 1, "ColorOperationType": operation,
|
||||
"SrcBlendType": 5, "DestBlendType": destination}}]})
|
||||
var material: Material = fx._emitters[-1].material_override
|
||||
if operation == 4:
|
||||
check(material is StandardMaterial3D, "ordinary modulation unchanged")
|
||||
material.albedo_texture = texture
|
||||
else:
|
||||
check(material is ShaderMaterial, "operation %d has dedicated material" % operation)
|
||||
if material is ShaderMaterial:
|
||||
check(material.get_shader_parameter("color_operation") == operation, "texture-stage operation")
|
||||
check(material.get_shader_parameter("color_gain") == (2.0 if operation == 5 else (4.0 if operation == 6 else 1.0)), "RGB gain")
|
||||
check(material.get_shader_parameter("billboard_mode") == BaseMaterial3D.BILLBOARD_PARTICLES, "particle rotation preserved")
|
||||
material.set_shader_parameter("albedo_texture", texture)
|
||||
materials.append(material)
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await pixels(materials)
|
||||
else:
|
||||
print("SKIP: color-operation pixel checks require rendering")
|
||||
fx.free()
|
||||
var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/noejeon_4_blow.mse")
|
||||
var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path)
|
||||
var count := 0
|
||||
for particle in spec.get("particles", []):
|
||||
if int(particle.particle.get("ColorOperationType", 4)) != 5:
|
||||
continue
|
||||
var prop: Dictionary = particle.particle.duplicate(true)
|
||||
prop.erase("TextureFiles") # real properties, without texture-loader coupling
|
||||
var real_fx := EffectPlayer.new()
|
||||
real_fx.build({"particles": [{"particle": prop}]})
|
||||
var material: Material = real_fx._emitters[0].material_override
|
||||
check(material is ShaderMaterial, "real MODULATE2X material")
|
||||
if material is ShaderMaterial:
|
||||
check(material.get_shader_parameter("color_gain") == 2.0, "real MODULATE2X gain")
|
||||
check(material.get_shader_parameter("billboard_mode") == BaseMaterial3D.BILLBOARD_FIXED_Y, "real Y billboard preserved")
|
||||
count += 1
|
||||
real_fx.free()
|
||||
check(count > 0, "real MODULATE2X coverage")
|
||||
print("real color-operation particles=%d" % count)
|
||||
print("effect_color_operation_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
func pixels(materials: Array) -> void:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(96, 96)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var env := WorldEnvironment.new()
|
||||
env.environment = Environment.new()
|
||||
env.environment.background_mode = Environment.BG_COLOR
|
||||
env.environment.background_color = Color.BLACK
|
||||
viewport.add_child(env)
|
||||
var camera := Camera3D.new()
|
||||
viewport.add_child(camera)
|
||||
camera.position = Vector3(0, 0, 3)
|
||||
camera.look_at(Vector3.ZERO)
|
||||
camera.current = true
|
||||
var quad := MeshInstance3D.new()
|
||||
var source := QuadMesh.new()
|
||||
source.size = Vector2(2, 2)
|
||||
var arrays := source.get_mesh_arrays()
|
||||
var colors := PackedColorArray()
|
||||
colors.resize(arrays[Mesh.ARRAY_VERTEX].size())
|
||||
colors.fill(Color(0.6, 0.25, 0.125, 0.5))
|
||||
arrays[Mesh.ARRAY_COLOR] = colors
|
||||
quad.mesh = ArrayMesh.new()
|
||||
quad.mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||
viewport.add_child(quad)
|
||||
for tint in [Color.WHITE, Color(0.5, 0.75, 0.25, 0.8)]:
|
||||
var texture_image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||
texture_image.fill(tint)
|
||||
var decoded := texture_image.get_pixel(0, 0).srgb_to_linear()
|
||||
var texture := ImageTexture.create_from_image(texture_image)
|
||||
for i in materials.size():
|
||||
if materials[i] is ShaderMaterial:
|
||||
materials[i].set_shader_parameter("albedo_texture", texture)
|
||||
else:
|
||||
materials[i].albedo_texture = texture
|
||||
quad.material_override = materials[i]
|
||||
await process_frame
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var pixel := viewport.get_texture().get_image().get_pixel(48, 48).srgb_to_linear()
|
||||
var operation: int = OPERATIONS[i % OPERATIONS.size()]
|
||||
var vertex := Vector3(0.6, 0.25, 0.125)
|
||||
var texel := Vector3(decoded.r, decoded.g, decoded.b)
|
||||
var expected := vertex * texel
|
||||
match operation:
|
||||
2: expected = vertex
|
||||
3: expected = texel
|
||||
5: expected *= 2.0
|
||||
6: expected *= 4.0
|
||||
8: expected = vertex + texel - Vector3.ONE * 0.5
|
||||
expected = expected.clamp(Vector3.ZERO, Vector3.ONE) * (0.5 * decoded.a)
|
||||
var actual := Vector3(pixel.r, pixel.g, pixel.b)
|
||||
check(actual.distance_to(expected) < 0.025, "op=%d tint=%s RGB/alpha got=%s expected=%s" % [operation, tint, actual, expected])
|
||||
print("color-operation pixel cases=24")
|
||||
viewport.queue_free()
|
||||
await process_frame
|
||||
@@ -0,0 +1 @@
|
||||
uid://dds0frgva7yxj
|
||||
@@ -0,0 +1,83 @@
|
||||
extends SceneTree
|
||||
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
|
||||
func effective(pm: ParticleProcessMaterial, age: float) -> Color:
|
||||
return pm.color * pm.color_ramp.gradient.sample(age) if pm.color_ramp else pm.color
|
||||
|
||||
func _init() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": {
|
||||
"TimeEventColorRed": [[0.0, 0.2], [0.5, 0.8], [1.0, 0.4]],
|
||||
"TimeEventColorGreen": [[0.25, 0.4], [0.75, 0.8]],
|
||||
"TimeEventColorBlue": [[0.0, 0.5]],
|
||||
"TimeEventAlpha": [[0.0, 1.0], [1.0, 0.0]]}},
|
||||
{"particle": {"TimeEventColorRed": [[0.0, 0.5]], "TimeEventAlpha": [[0.0, 1.0]]}},
|
||||
{"particle": {"TimeEventColorBlue": [[0.0, 0.2], [1.0, 0.8]]}},
|
||||
{"particle": {}}]})
|
||||
var pm: ParticleProcessMaterial = fx._emitters[0].process_material
|
||||
check(pm.color == Color.WHITE, "neutral base prevents double tint")
|
||||
check(effective(pm, 0.0).is_equal_approx(Color(0.2, 0.4, 0.5, 1.0)), "birth RGBA")
|
||||
check(effective(pm, 0.25).is_equal_approx(Color(0.5, 0.4, 0.5, 0.75)), "independent green knot")
|
||||
check(effective(pm, 0.5).is_equal_approx(Color(0.8, 0.6, 0.5, 0.5)), "middle RGBA")
|
||||
check(effective(pm, 0.75).is_equal_approx(Color(0.6, 0.8, 0.5, 0.25)), "late RGBA")
|
||||
check(effective(pm, 1.0).is_equal_approx(Color(0.4, 0.8, 0.5, 0.0)), "end RGBA")
|
||||
check(effective(fx._emitters[1].process_material, 0.5).is_equal_approx(Color(0.5, 1.0, 1.0)),
|
||||
"constant 0.5 tint remains 0.5, not 0.25")
|
||||
check(effective(fx._emitters[2].process_material, 0.5).is_equal_approx(Color(1.0, 1.0, 0.5)),
|
||||
"RGB animates without alpha track")
|
||||
check(effective(fx._emitters[3].process_material, 0.5) == Color.WHITE, "missing tracks default white")
|
||||
fx.free()
|
||||
var malformed := EffectPlayer.new()
|
||||
var cleaned: GradientTexture1D = malformed._color_ramp({"TimeEventColorRed": [
|
||||
[1.0, 0.8], [], [0.0, 0.1], [0.0, 0.2], [NAN, 0.5], [0.5, INF], ["bad", 0.4]]})
|
||||
check(cleaned.gradient.sample(0.5).is_equal_approx(Color(0.5, 1, 1)),
|
||||
"unordered/duplicate keys normalized and invalid values ignored")
|
||||
malformed.free()
|
||||
real_resources()
|
||||
print("effect_color_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
|
||||
# Independent scalar interpolation oracle for the sorted, valid asset tracks.
|
||||
func reference(rows: Array, age: float) -> float:
|
||||
if rows.is_empty():
|
||||
return 1.0
|
||||
if age <= float(rows[0][0]):
|
||||
return float(rows[0][1])
|
||||
for i in range(1, rows.size()):
|
||||
if age <= float(rows[i][0]):
|
||||
var fraction := (age - float(rows[i - 1][0])) / (float(rows[i][0]) - float(rows[i - 1][0]))
|
||||
return float(rows[i - 1][1]) * (1.0 - fraction) + float(rows[i][1]) * fraction
|
||||
return float(rows[-1][1])
|
||||
|
||||
func real_resources() -> void:
|
||||
var count := 0
|
||||
for name in ["warrior/effect/palbang_4_sword.mse", "assassin/effect/beam_blow.mse",
|
||||
"sura/effect/pabeopsul_4_blow.mse", "shaman/effect/noejeon_making_1.mse"]:
|
||||
var path := AssetRoot.path().path_join("PC/ymir work/pc/" + name)
|
||||
var parsed: Dictionary = preload("res://fx/mse.gd").new().parse_file(path)
|
||||
check(not parsed.get("particles", []).is_empty(), "real resource loaded: " + name)
|
||||
for particle in parsed.get("particles", []):
|
||||
# Exercise real RGBA through material construction; texture loading and
|
||||
# emission geometry are deliberately outside this color-only regression.
|
||||
var prop: Dictionary = {}
|
||||
for key in ["TimeEventColorRed", "TimeEventColorGreen", "TimeEventColorBlue", "TimeEventAlpha"]:
|
||||
prop[key] = particle.particle.get(key, [])
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": prop}]})
|
||||
var pm: ParticleProcessMaterial = fx._emitters[0].process_material
|
||||
for step in range(41):
|
||||
var age := float(step) / 40.0
|
||||
var expected := Color(reference(prop.TimeEventColorRed, age),
|
||||
reference(prop.TimeEventColorGreen, age), reference(prop.TimeEventColorBlue, age),
|
||||
reference(prop.TimeEventAlpha, age))
|
||||
check(effective(pm, age).is_equal_approx(expected), "real RGBA %s particle %d t=%f" % [name, count, age])
|
||||
count += 1
|
||||
fx.free()
|
||||
print("real color tracks: particles=%d samples=%d" % [count, count * 41])
|
||||
@@ -0,0 +1 @@
|
||||
uid://cl2sjnmvtvr3q
|
||||
@@ -0,0 +1,28 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
for attached in [0, 1]:
|
||||
fx.build({"particles": [{"emitter": {"CycleLength": 0.8, "CycleLoopEnable": 0,
|
||||
"TimeEventLifeTime": [[0, 0.05]]}, "particle": {"AttachEnable": attached}}]})
|
||||
root.add_child(fx)
|
||||
fx.play(true)
|
||||
for i in range(2):
|
||||
check(fx._emitters[i].local_coords == bool(i), "AttachEnable determines local/world simulation")
|
||||
check(not fx._emitters[i].one_shot, "effect cleanup must not impose single GPU lifetime")
|
||||
await create_timer(0.3).timeout
|
||||
for emitter in fx._emitters:
|
||||
check(emitter.emitting, "short-lived particles still emit inside source cycle")
|
||||
await create_timer(0.6).timeout
|
||||
for emitter in fx._emitters:
|
||||
check(not emitter.emitting, "source cycle still stops emission")
|
||||
fx.free()
|
||||
print("effect_emission_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6olq0ioo7c0f
|
||||
@@ -0,0 +1,64 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
for kind in [4, 5]:
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": {"BillboardType": kind, "ColorOperationType": 4}}]})
|
||||
var emitter: GPUParticles3D = fx._emitters[0]
|
||||
var mesh := emitter.draw_pass_1
|
||||
var arrays := mesh.surface_get_arrays(0)
|
||||
var angles := [-30.0, 30.0] if kind == 4 else [0.0, -60.0, 60.0]
|
||||
check(arrays[Mesh.ARRAY_VERTEX].size() == angles.size() * 6, "one quad per reference face")
|
||||
for i in angles.size():
|
||||
for v in 6:
|
||||
check(is_equal_approx(arrays[Mesh.ARRAY_TEX_UV2][i * 6 + v].x, deg_to_rad(angles[i])), "reference face angle")
|
||||
check(emitter.material_override is ShaderMaterial, "custom face shader")
|
||||
check(emitter.material_override.get_shader_parameter("billboard_mode") == 5, "multiface mode")
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await pixels(mesh, emitter.material_override, kind)
|
||||
fx.free()
|
||||
print("effect_faces_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
func pixels(mesh: Mesh, material: ShaderMaterial, kind: int) -> void:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(128, 128)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var world := WorldEnvironment.new()
|
||||
world.environment = Environment.new()
|
||||
world.environment.background_mode = Environment.BG_COLOR
|
||||
world.environment.background_color = Color.BLACK
|
||||
viewport.add_child(world)
|
||||
var image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color.WHITE)
|
||||
material.set_shader_parameter("albedo_texture", ImageTexture.create_from_image(image))
|
||||
var instance := MeshInstance3D.new()
|
||||
instance.mesh = mesh
|
||||
instance.material_override = material
|
||||
viewport.add_child(instance)
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 1.28
|
||||
viewport.add_child(camera)
|
||||
for angle in [0.0, 0.7]:
|
||||
camera.position = Vector3(sin(angle) * 3, 0, cos(angle) * 3)
|
||||
camera.look_at(Vector3.ZERO)
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var pixels := viewport.get_texture().get_image()
|
||||
var width := 0
|
||||
for x in 128:
|
||||
if pixels.get_pixel(x, 64).r > 0.5: width += 1
|
||||
# Default width .64m: 2FACE projects cos(30deg), 3FACE includes a full front face.
|
||||
var expected := 64.0 * (cos(PI / 6) if kind == 4 else 1.0)
|
||||
check(absf(width - expected) <= 3.0, "Metal projection kind=%d camera=%f width=%d expected=%f" % [kind, angle, width, expected])
|
||||
viewport.free()
|
||||
await process_frame
|
||||
@@ -0,0 +1 @@
|
||||
uid://1mm2fl01mhtn
|
||||
@@ -0,0 +1,57 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": {"BillboardType": 3, "ColorOperationType": 4}}]})
|
||||
var material: Material = fx._emitters[0].material_override
|
||||
check(material is ShaderMaterial, "LIE no longer uses camera-facing StandardMaterial")
|
||||
if material is ShaderMaterial:
|
||||
check(material.get_shader_parameter("billboard_mode") == 4, "horizontal custom billboard selected")
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await pixels(material)
|
||||
fx.free()
|
||||
print("effect_lie_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
func pixels(material: ShaderMaterial) -> void:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(96, 96)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var environment := WorldEnvironment.new()
|
||||
environment.environment = Environment.new()
|
||||
environment.environment.background_mode = Environment.BG_COLOR
|
||||
environment.environment.background_color = Color.BLACK
|
||||
viewport.add_child(environment)
|
||||
var image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color.WHITE)
|
||||
material.set_shader_parameter("albedo_texture", ImageTexture.create_from_image(image))
|
||||
var mesh := MeshInstance3D.new()
|
||||
mesh.mesh = QuadMesh.new()
|
||||
mesh.mesh.size = Vector2(1.5, 1.5)
|
||||
mesh.material_override = material
|
||||
viewport.add_child(mesh)
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 3
|
||||
viewport.add_child(camera)
|
||||
for top in [true, false]:
|
||||
camera.position = Vector3(0, 3, 0) if top else Vector3(0, 0, 3)
|
||||
camera.look_at(Vector3.ZERO, Vector3.FORWARD if top else Vector3.UP)
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var pixels := viewport.get_texture().get_image()
|
||||
var lit := 0
|
||||
for y in 96:
|
||||
for x in 96:
|
||||
if pixels.get_pixel(x, y).r > 0.5: lit += 1
|
||||
check(lit > 1500 if top else lit < 100, "horizontal quad visibility top=%s pixels=%d" % [top, lit])
|
||||
viewport.free()
|
||||
await process_frame
|
||||
@@ -0,0 +1 @@
|
||||
uid://2do65c7c5cj3
|
||||
@@ -0,0 +1,88 @@
|
||||
extends SceneTree
|
||||
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func fixture():
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"start_time": 0.2,
|
||||
"emitter": {"CycleLength": 1.0, "CycleLoopEnable": 1}}],
|
||||
"lights": [{"duration": 0.8, "diffuse": [1.0, 0.5, 0.2, 1.0]}]})
|
||||
# A synthetic two-frame mesh isolates playback from MDE asset decoding.
|
||||
var host := Node3D.new()
|
||||
var mesh := MeshInstance3D.new()
|
||||
mesh.material_override = StandardMaterial3D.new()
|
||||
host.add_child(mesh)
|
||||
fx.add_child(host)
|
||||
fx._mesh_nodes.append(host)
|
||||
fx._mesh_states.append({"node": host, "clock": 0.0, "start_time": 0.0,
|
||||
"frame_delay": 0.5, "duration": 1.0, "children": [{"node": mesh,
|
||||
"frames": [{"visibility": 1.0}, {"visibility": 0.5}],
|
||||
"meshes": [ArrayMesh.new(), ArrayMesh.new()], "frame": -1}]})
|
||||
root.add_child(fx)
|
||||
fx.set_process(false) # Advance deterministically; SceneTree timers still run.
|
||||
return fx
|
||||
|
||||
func run() -> void:
|
||||
var fx = fixture()
|
||||
var emitter: GPUParticles3D = fx._emitters[0]
|
||||
check(not emitter.emitting, "build does not emit before play")
|
||||
fx.play(true)
|
||||
check(not emitter.emitting, "play respects delay immediately")
|
||||
fx._process(0.25)
|
||||
check(emitter.emitting, "particle starts on playback clock")
|
||||
fx._process(0.35)
|
||||
check(fx._mesh_states[0].children[0].frame == 1, "mesh reached second frame")
|
||||
fx.stop()
|
||||
fx._process(0.01)
|
||||
check(not fx._lights[0].visible, "stopped light stays hidden")
|
||||
check(not fx._mesh_nodes[0].visible, "stopped mesh stays hidden")
|
||||
check(is_equal_approx(fx._light_states[0].clock, 0.6), "stop freezes CPU clock")
|
||||
await create_timer(0.3).timeout
|
||||
check(not emitter.emitting, "old delayed start cannot revive stopped emitter")
|
||||
fx.play(false)
|
||||
check(not emitter.one_shot, "normal replay restores source loop mode")
|
||||
check(is_zero_approx(fx._mesh_states[0].clock), "mesh replay resets clock")
|
||||
check(is_zero_approx(fx._light_states[0].clock), "light replay resets clock")
|
||||
check(fx._mesh_states[0].children[0].frame == 0, "mesh replay restores first frame")
|
||||
check(fx._lights[0].visible, "light replay restores visibility")
|
||||
fx._process(0.1)
|
||||
check(not emitter.emitting, "replay uses new start delay")
|
||||
fx._process(0.1)
|
||||
check(emitter.emitting, "replay starts at delay boundary")
|
||||
# Old forced-one-shot cleanup must not delete a later persistent playback.
|
||||
await create_timer(2.5).timeout
|
||||
check(is_instance_valid(fx), "old auto-free cannot delete replay")
|
||||
if is_instance_valid(fx):
|
||||
fx.free()
|
||||
var once = fixture()
|
||||
once.play(true)
|
||||
once._process(once._longest_life() + 0.6)
|
||||
check(once.is_queued_for_deletion(), "current one-shot still cleans up")
|
||||
if not once.is_queued_for_deletion():
|
||||
once.free()
|
||||
await process_frame
|
||||
# Exercise the actual SceneTree process path, not just manual clock updates.
|
||||
var live = fixture()
|
||||
live.set_process(true)
|
||||
live.play()
|
||||
await create_timer(0.35).timeout
|
||||
check(live._emitters[0].emitting, "SceneTree starts delayed emission")
|
||||
live.stop()
|
||||
var stopped_clock: float = live._particle_states[0].clock
|
||||
await create_timer(0.1).timeout
|
||||
check(not live._emitters[0].emitting and not live._lights[0].visible,
|
||||
"SceneTree does not reactivate stopped effect")
|
||||
check(is_equal_approx(live._particle_states[0].clock, stopped_clock),
|
||||
"SceneTree leaves stopped clock unchanged")
|
||||
live.free()
|
||||
print("effect_playback_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://48lu45ir8yo5
|
||||
@@ -0,0 +1,77 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void: call_deferred("run")
|
||||
func run() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"emitter": {"TimeEventLifeTime": [[0, 1.0]]}, "particle": {
|
||||
"BillboardType": 1, "RotationType": 4, "RotationSpeed": 90, "ColorOperationType": 4}}]})
|
||||
var emitter: GPUParticles3D = fx._emitters[0]
|
||||
var pm: ParticleProcessMaterial = emitter.process_material
|
||||
var material: ShaderMaterial = emitter.material_override
|
||||
check(pm.angular_velocity_min == 0 and pm.angular_velocity_max == 0, "no second angular integration")
|
||||
check(pm.anim_offset_min == 0 and pm.anim_offset_max == 1 and pm.anim_speed_max == 0, "stable per-birth sign seed")
|
||||
check(material.get_shader_parameter("random_rotation") == true, "random rotation enabled")
|
||||
check(is_equal_approx(material.get_shader_parameter("rotation_speed"), PI / 2), "full magnitude in radians")
|
||||
if DisplayServer.get_name() != "headless": await pixels(material)
|
||||
fx.free()
|
||||
print("effect_random_rotation_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
func pixels(material: ShaderMaterial) -> void:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(128, 128)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var environment := WorldEnvironment.new()
|
||||
environment.environment = Environment.new()
|
||||
environment.environment.background_mode = Environment.BG_COLOR
|
||||
environment.environment.background_color = Color.BLACK
|
||||
viewport.add_child(environment)
|
||||
var image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color.WHITE)
|
||||
material.set_shader_parameter("albedo_texture", ImageTexture.create_from_image(image))
|
||||
material.set_shader_parameter("rotation_lifetime", 1.0)
|
||||
var node := MultiMeshInstance3D.new()
|
||||
node.multimesh = MultiMesh.new()
|
||||
node.multimesh.transform_format = MultiMesh.TRANSFORM_3D
|
||||
node.multimesh.use_custom_data = true
|
||||
node.multimesh.mesh = QuadMesh.new()
|
||||
node.multimesh.mesh.size = Vector2(1.2, 0.16)
|
||||
node.multimesh.instance_count = 1
|
||||
node.multimesh.set_instance_transform(0, Transform3D.IDENTITY)
|
||||
node.material_override = material
|
||||
viewport.add_child(node)
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 2.0
|
||||
camera.position.z = 3
|
||||
viewport.add_child(camera)
|
||||
var results: Array[Image] = []
|
||||
for seed in [0.25, 0.75]:
|
||||
node.multimesh.set_instance_custom_data(0, Color(0, 0.5, seed, 1))
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
results.append(viewport.get_texture().get_image())
|
||||
var positive := 0
|
||||
var negative := 0
|
||||
var mismatch := 0
|
||||
var lit := 0
|
||||
for y in 128:
|
||||
for x in 128:
|
||||
var a := results[0].get_pixel(x, y).r > 0.5
|
||||
var b := results[1].get_pixel(x, y).r > 0.5
|
||||
if a:
|
||||
lit += 1
|
||||
positive += (2 * x - 127) * (2 * y - 127)
|
||||
if b: negative += (2 * x - 127) * (2 * y - 127)
|
||||
if a != (results[1].get_pixel(127 - x, y).r > 0.5): mismatch += 1
|
||||
check(lit > 300, "rendered visible particles")
|
||||
check(positive * float(negative) < 0, "opposite rotation directions")
|
||||
check(mismatch < 60, "equal magnitude produces mirrored GPU images")
|
||||
viewport.free()
|
||||
await process_frame
|
||||
@@ -0,0 +1 @@
|
||||
uid://7if0yf3v2fuw
|
||||
@@ -0,0 +1,63 @@
|
||||
extends SceneTree
|
||||
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
|
||||
func _init() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
# D3D8: SRCALPHA=5, INVSRCALPHA=6; both mesh and particle use this helper.
|
||||
check(fx._blend(5, 6) == BaseMaterial3D.BLEND_MODE_MIX, "source-alpha transparency")
|
||||
check(fx._blend(5, 2) == BaseMaterial3D.BLEND_MODE_ADD, "additive glow preserved")
|
||||
fx.build({"particles": [{"start_time": 0.5,
|
||||
"emitter": {"CycleLength": 1.0, "CycleLoopEnable": 1, "LoopCount": 2},
|
||||
"particle": {"SrcBlendType": 5, "DestBlendType": 6}}]})
|
||||
var state: Dictionary = fx._particle_states[0]
|
||||
var emitter: GPUParticles3D = state.node
|
||||
check(emitter.material_override.blend_mode == BaseMaterial3D.BLEND_MODE_MIX,
|
||||
"built particle uses alpha material")
|
||||
fx._advance_particle(state, 0.25)
|
||||
check(is_equal_approx(state.clock, 0.25) and not emitter.emitting,
|
||||
"stationary emitter advances clock and respects start delay")
|
||||
# Simulate the play timer starting emission; isolate cycle gating from timers.
|
||||
emitter.emitting = true
|
||||
fx._advance_particle(state, 2.0)
|
||||
check(emitter.emitting and not state.emission_stopped, "finite loop before end")
|
||||
fx._advance_particle(state, 0.25)
|
||||
check(not emitter.emitting and state.emission_stopped, "stationary finite loop ends")
|
||||
state.clock = 0.0
|
||||
state.start_time = 0.0
|
||||
state.cycle_loop = false
|
||||
state.emission_stopped = false
|
||||
emitter.emitting = true
|
||||
fx._advance_particle(state, 1.0)
|
||||
check(not emitter.emitting and state.emission_stopped, "stationary non-loop ends")
|
||||
state.clock = 0.0
|
||||
state.cycle_loop = true
|
||||
state.loop_count = 0
|
||||
state.emission_stopped = false
|
||||
emitter.emitting = true
|
||||
fx._advance_particle(state, 10.0)
|
||||
check(emitter.emitting and not state.emission_stopped, "unlimited loop preserved")
|
||||
check(emitter.position == Vector3.ZERO, "missing position stays at origin")
|
||||
fx.free()
|
||||
var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/7budongbakbu_throw.mse")
|
||||
var parsed: Dictionary = preload("res://fx/mse.gd").new().parse_file(path)
|
||||
check(not parsed.get("particles", []).is_empty(), "real shaman effect loaded")
|
||||
var real_fx := EffectPlayer.new()
|
||||
real_fx.build(parsed, AssetRoot.path())
|
||||
var alpha_count := 0
|
||||
for i in parsed.get("particles", []).size():
|
||||
var prop: Dictionary = parsed.particles[i].particle
|
||||
if int(prop.get("SrcBlendType", 0)) == 5 and int(prop.get("DestBlendType", 0)) == 6:
|
||||
alpha_count += 1
|
||||
check(real_fx._emitters[i].material_override.blend_mode == BaseMaterial3D.BLEND_MODE_MIX,
|
||||
"real shaman alpha particle %d" % i)
|
||||
check(alpha_count > 0, "real fixture exercises alpha blending")
|
||||
real_fx.free()
|
||||
print("effect_render_regression_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://sxn37poee1rf
|
||||
@@ -0,0 +1,103 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
for kind in [0, 2, 3]:
|
||||
fx.build({"particles": [{"particle": {"RotationType": kind, "RotationSpeed": 180,
|
||||
"RotationRandomStartingBegin": 30, "RotationRandomStartingEnd": 60, "BillboardType": 1}}]})
|
||||
var g: GPUParticles3D = fx._emitters[-1]
|
||||
var pm: ParticleProcessMaterial = g.process_material
|
||||
var speed := 0.0 if kind == 0 else (180.0 if kind == 2 else -180.0)
|
||||
check(pm.angular_velocity_min == speed and pm.angular_velocity_max == speed,
|
||||
"constant rotation type %d uses degrees without random slowing" % kind)
|
||||
check(pm.angle_min == 30.0 and pm.angle_max == 60.0, "initial angle range type %d" % kind)
|
||||
check(g.material_override.billboard_mode == BaseMaterial3D.BILLBOARD_PARTICLES,
|
||||
"camera billboard consumes particle rotation type %d" % kind)
|
||||
check(fx._billboard(0) == BaseMaterial3D.BILLBOARD_DISABLED, "non-billboard unchanged")
|
||||
check(fx._billboard(1) == BaseMaterial3D.BILLBOARD_ENABLED, "mesh camera billboard unchanged")
|
||||
check(fx._billboard(2) == BaseMaterial3D.BILLBOARD_FIXED_Y, "source Y enum is two")
|
||||
check(fx._billboard(4) != BaseMaterial3D.BILLBOARD_FIXED_Y, "two-face is not misclassified as Y")
|
||||
fx.free()
|
||||
var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/7budongbakbu_throw.mse")
|
||||
var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path)
|
||||
check(not spec.get("particles", []).is_empty(), "real shaman rotation resource loaded")
|
||||
var count := 0
|
||||
for particle in spec.get("particles", []):
|
||||
var prop: Dictionary = particle.particle.duplicate(true)
|
||||
var kind := int(prop.get("RotationType", 0))
|
||||
if kind != 2 and kind != 3:
|
||||
continue
|
||||
# Isolate rotation/material configuration without loading original textures.
|
||||
prop.erase("TextureFiles")
|
||||
var real_fx := EffectPlayer.new()
|
||||
real_fx.build({"particles": [{"particle": prop}]})
|
||||
var pm: ParticleProcessMaterial = real_fx._emitters[0].process_material
|
||||
var expected := float(prop.get("RotationSpeed", 0)) * (1.0 if kind == 2 else -1.0)
|
||||
check(pm.angular_velocity_min == expected and pm.angular_velocity_max == expected, "real constant speed")
|
||||
check(pm.angle_min == float(prop.get("RotationRandomStartingBegin", 0)) and
|
||||
pm.angle_max == float(prop.get("RotationRandomStartingEnd", 0)), "real initial angle range")
|
||||
count += 1
|
||||
real_fx.free()
|
||||
check(count > 0, "real constant-rotation coverage")
|
||||
print("real rotation tracks: particles=%d" % count)
|
||||
time_event_rotation()
|
||||
real_time_event_rotation()
|
||||
print("effect_rotation_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
|
||||
func time_event_rotation() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": {"RotationType": 1, "RotationSpeed": 999,
|
||||
"TimeEventRotation": [[0.0, 180.0], [0.5, -180.0], [1.0, 0.0]],
|
||||
"RotationRandomStartingBegin": 30, "RotationRandomStartingEnd": 30}},
|
||||
{"particle": {"RotationType": 1, "RotationSpeed": 999}}]})
|
||||
var pm: ParticleProcessMaterial = fx._emitters[0].process_material
|
||||
check(pm.angular_velocity_curve is CurveTexture, "TIME_EVENT has a speed curve")
|
||||
if pm.angular_velocity_curve is CurveTexture:
|
||||
var curve: Curve = pm.angular_velocity_curve.curve
|
||||
# Godot 4.7 curve branch integrates sampled value * base rate directly
|
||||
# into CUSTOM.x radians; unlike the constant branch it does not convert.
|
||||
check(is_equal_approx(curve.sample(0.0) * pm.angular_velocity_min, PI), "birth rate is pi radians/sec")
|
||||
check(is_zero_approx(curve.sample(0.25)), "speed passes through zero")
|
||||
check(is_equal_approx(curve.sample(0.75) * pm.angular_velocity_min, -PI / 2.0), "negative rate preserved")
|
||||
check(is_zero_approx(curve.sample(1.0)), "end stops without erasing earlier motion")
|
||||
var angle := deg_to_rad(pm.angle_min)
|
||||
for i in range(2000):
|
||||
angle += curve.sample((float(i) + 0.5) / 2000.0) * pm.angular_velocity_min * 2.0 / 2000.0
|
||||
check(absf(angle - deg_to_rad(-60.0)) < 0.0001, "two-second lifetime integrates speed plus initial angle")
|
||||
var empty: ParticleProcessMaterial = fx._emitters[1].process_material
|
||||
check(empty.angular_velocity_curve is CurveTexture, "empty TIME_EVENT remains an explicit zero curve")
|
||||
if empty.angular_velocity_curve is CurveTexture:
|
||||
check(is_zero_approx(empty.angular_velocity_curve.curve.sample(0.5)), "empty track ignores RotationSpeed fallback")
|
||||
fx.free()
|
||||
|
||||
func real_time_event_rotation() -> void:
|
||||
var count := 0
|
||||
var samples := 0
|
||||
for name in ["jeungsok_blow", "8chojeonbu", "jeungryeok_hand"]:
|
||||
var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/" + name + ".mse")
|
||||
var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path)
|
||||
check(not spec.get("particles", []).is_empty(), "real TIME_EVENT file " + name)
|
||||
for particle in spec.get("particles", []):
|
||||
if int(particle.particle.get("RotationType", 0)) != 1:
|
||||
continue
|
||||
var rows: Array = particle.particle.get("TimeEventRotation", [])
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": {"RotationType": 1, "TimeEventRotation": rows}}]})
|
||||
var pm: ParticleProcessMaterial = fx._emitters[0].process_material
|
||||
check(pm.angular_velocity_curve is CurveTexture, "real TIME_EVENT material")
|
||||
if pm.angular_velocity_curve is CurveTexture:
|
||||
for row in rows:
|
||||
if float(row[0]) >= 0.0 and float(row[0]) <= 1.0:
|
||||
var actual: float = pm.angular_velocity_curve.curve.sample(float(row[0])) * pm.angular_velocity_min
|
||||
check(is_equal_approx(actual, deg_to_rad(float(row[1]))), "real TIME_EVENT radians " + name)
|
||||
samples += 1
|
||||
count += 1
|
||||
fx.free()
|
||||
check(count > 0 and samples > 0, "real time-dependent speed coverage")
|
||||
print("real time-event tracks: particles=%d keys=%d" % [count, samples])
|
||||
@@ -0,0 +1 @@
|
||||
uid://cb3kfnretl1a5
|
||||
@@ -0,0 +1,81 @@
|
||||
extends SceneTree
|
||||
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
|
||||
func _init() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"emitter": {"TimeEventSizeX": [[0, 10]], "TimeEventSizeY": [[0, 30]]},
|
||||
"particle": {"TimeEventScaleX": [[0, 0], [1, 4]], "TimeEventScaleY": [[0, 3], [1, 1]]}},
|
||||
{"emitter": {"TimeEventSizeX": [[0, 0]], "TimeEventSizeY": [[0, 0]]}, "particle": {}}]})
|
||||
var g: GPUParticles3D = fx._emitters[0]
|
||||
var pm: ParticleProcessMaterial = g.process_material
|
||||
check(g.draw_pass_1.size.is_equal_approx(Vector2(0.2, 0.6)), "independent half-sizes become full metre dimensions")
|
||||
check(pm.scale_min == 1.0 and pm.scale_max == 1.0, "no invented random size factor")
|
||||
check(pm.scale_curve is CurveXYZTexture, "independent XYZ scale texture")
|
||||
if pm.scale_curve is CurveXYZTexture:
|
||||
var tex: CurveXYZTexture = pm.scale_curve
|
||||
check(is_equal_approx(tex.curve_x.sample(0.25), 1.0), "X interpolates linearly")
|
||||
check(is_equal_approx(tex.curve_y.sample(0.25), 2.5), "Y has independent interpolation")
|
||||
check(is_equal_approx(tex.curve_x.sample(1.0), 4.0), "scale above two is retained")
|
||||
check(is_equal_approx(tex.curve_z.sample(0.5), 1.0), "depth scale remains neutral")
|
||||
var empty: GPUParticles3D = fx._emitters[1]
|
||||
check(empty.draw_pass_1.size == Vector2.ZERO, "zero source size is not made visible")
|
||||
var defaults: ParticleProcessMaterial = empty.process_material
|
||||
if defaults.scale_curve is CurveXYZTexture:
|
||||
check(defaults.scale_curve.curve_x.sample(1.0) == 1.0 and defaults.scale_curve.curve_y.sample(1.0) == 1.0,
|
||||
"absent scale tracks stay constant instead of shrinking")
|
||||
else:
|
||||
check(false, "default independent curves")
|
||||
fx.free()
|
||||
real_resources()
|
||||
print("effect_scale_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
|
||||
func real_resources() -> void:
|
||||
var count := 0
|
||||
var samples := 0
|
||||
for name in ["warrior/effect/palbang_4_sword.mse", "assassin/effect/beam_blow.mse",
|
||||
"sura/effect/pabeopsul_4_blow.mse", "shaman/effect/noejeon_making_1.mse"]:
|
||||
var path := AssetRoot.path().path_join("PC/ymir work/pc/" + name)
|
||||
var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path)
|
||||
check(not spec.get("particles", []).is_empty(), "real size resource loaded: " + name)
|
||||
for particle in spec.get("particles", []):
|
||||
var fx := EffectPlayer.new()
|
||||
# Only source size/scale tracks: not a full texture or emission test.
|
||||
fx.build({"particles": [{"emitter": {
|
||||
"TimeEventSizeX": particle.emitter.get("TimeEventSizeX", []),
|
||||
"TimeEventSizeY": particle.emitter.get("TimeEventSizeY", [])}, "particle": {
|
||||
"TimeEventScaleX": particle.particle.get("TimeEventScaleX", []),
|
||||
"TimeEventScaleY": particle.particle.get("TimeEventScaleY", [])}}]})
|
||||
var texture: Texture2D = fx._emitters[0].process_material.scale_curve
|
||||
check(texture is CurveXYZTexture, "real XY texture " + name)
|
||||
if texture is CurveXYZTexture:
|
||||
for axis in range(2):
|
||||
var key := "TimeEventScaleX" if axis == 0 else "TimeEventScaleY"
|
||||
var curve: Curve = texture.curve_x if axis == 0 else texture.curve_y
|
||||
var rows: Array = particle.particle.get(key, [])
|
||||
for i in rows.size():
|
||||
var time := float(rows[i][0])
|
||||
var duplicate := i > 0 and float(rows[i - 1][0]) == time
|
||||
if time >= 0.0 and time <= 1.0 and not duplicate:
|
||||
check(is_equal_approx(curve.sample(time), float(rows[i][1])), "real scale key %s %s t=%f got=%f expected=%f" % [name, key, time, curve.sample(time), float(rows[i][1])])
|
||||
samples += 1
|
||||
if i > 0 and not duplicate:
|
||||
var mid := (float(rows[i - 1][0]) + time) * 0.5
|
||||
var value := (float(rows[i - 1][1]) + float(rows[i][1])) * 0.5
|
||||
if mid >= 0.0 and mid <= 1.0:
|
||||
# Duplicate-key jumps occupy 1e-4 of normalized age;
|
||||
# permit the resulting small interpolation deviation.
|
||||
var after_jump := i > 1 and float(rows[i - 2][0]) == float(rows[i - 1][0])
|
||||
var matches := absf(curve.sample(mid) - value) < 0.001 if after_jump else is_equal_approx(curve.sample(mid), value)
|
||||
check(matches, "real linear midpoint %s %s t=%f got=%f expected=%f" % [name, key, mid, curve.sample(mid), value])
|
||||
samples += 1
|
||||
count += 1
|
||||
fx.free()
|
||||
print("real scale tracks: particles=%d samples=%d" % [count, samples])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5703nwexgqtl
|
||||
@@ -0,0 +1,22 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
var effect := EffectPlayer.new()
|
||||
var rows := [[0, "MOVING_TYPE_DIRECT", 100, 200, 300], [1, "MOVING_TYPE_DIRECT", 200, 400, 600]]
|
||||
check(effect._position_at(rows, 0).is_equal_approx(Vector3(1, 3, -2)), "source Z goes up and Y goes south")
|
||||
check(effect._position_at(rows, 0.5).is_equal_approx(Vector3(1.5, 4.5, -3)), "movement interpolation uses converted vectors")
|
||||
effect.build({"particles": [{"position": rows, "emitter": {"EmittingDirection": [0, 0, 1], "EmitterShape": 1}}],
|
||||
"lights": [{"position": rows}]})
|
||||
var emitter: GPUParticles3D = effect._emitters[0]
|
||||
check(emitter.position.is_equal_approx(Vector3(1, 3, -2)), "particle spawn shares movement coordinates")
|
||||
check(effect._lights[0].position.is_equal_approx(emitter.position), "light and particle coordinates agree")
|
||||
check(emitter.process_material.direction.is_equal_approx(Vector3.UP), "source upwards emission is upwards")
|
||||
check(emitter.process_material.emission_box_extents.y < 0.01, "ellipse lies on ground plane")
|
||||
effect.free()
|
||||
print("effect_space_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfm5yxb2yufon
|
||||
@@ -0,0 +1,77 @@
|
||||
extends SceneTree
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color.WHITE)
|
||||
var texture := ImageTexture.create_from_image(image)
|
||||
var fx := EffectPlayer.new()
|
||||
fx.build({"particles": [{"particle": {"BillboardType": 0, "SrcBlendType": 5, "DestBlendType": 6}}]})
|
||||
var particle_mat: StandardMaterial3D = fx._emitters[0].material_override
|
||||
# A plain white texture and a static quad isolate material face visibility.
|
||||
particle_mat.albedo_texture = texture
|
||||
var materials := [particle_mat, fx._mesh_material({"BlendingSrcType": 5, "BlendingDestType": 6}, [texture])]
|
||||
for i in materials.size():
|
||||
check(materials[i].cull_mode == BaseMaterial3D.CULL_DISABLED, "effect material %d is two-sided" % i)
|
||||
check(materials[i].depth_draw_mode == BaseMaterial3D.DEPTH_DRAW_DISABLED, "effect material %d does not write depth" % i)
|
||||
check(not materials[i].no_depth_test, "effect material %d still tests world depth" % i)
|
||||
if DisplayServer.get_name() != "headless":
|
||||
materials.append(preload("res://fx/particle_color_material.gd").build(particle_mat, 5))
|
||||
materials.append(preload("res://fx/particle_color_material.gd").build(particle_mat, 6))
|
||||
await visual(materials)
|
||||
else:
|
||||
print("SKIP: pixel checks require a rendering backend")
|
||||
fx.free()
|
||||
print("effect_surface_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
func visual(materials: Array) -> void:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(96, 96)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var environment := WorldEnvironment.new()
|
||||
environment.environment = Environment.new()
|
||||
environment.environment.background_mode = Environment.BG_COLOR
|
||||
environment.environment.background_color = Color.BLACK
|
||||
viewport.add_child(environment)
|
||||
var quad := MeshInstance3D.new()
|
||||
quad.mesh = QuadMesh.new()
|
||||
quad.mesh.size = Vector2(2, 2)
|
||||
viewport.add_child(quad)
|
||||
var camera := Camera3D.new()
|
||||
viewport.add_child(camera)
|
||||
camera.current = true
|
||||
var blocker := MeshInstance3D.new()
|
||||
blocker.mesh = BoxMesh.new()
|
||||
blocker.mesh.size = Vector3(2, 2, 0.1)
|
||||
var black := StandardMaterial3D.new()
|
||||
black.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
black.albedo_color = Color.BLACK
|
||||
blocker.material_override = black
|
||||
viewport.add_child(blocker)
|
||||
for i in materials.size():
|
||||
quad.material_override = materials[i]
|
||||
for side in [1.0, -1.0]:
|
||||
camera.position = Vector3(0, 0, side * 3.0)
|
||||
camera.look_at(Vector3.ZERO)
|
||||
blocker.visible = false
|
||||
var color: Color = await center(viewport)
|
||||
check(color.r > 0.8, "material %d side %d visible pixel=%s" % [i, int(side), color])
|
||||
blocker.position = Vector3(0, 0, side)
|
||||
blocker.visible = true
|
||||
color = await center(viewport)
|
||||
check(color.r < 0.05, "material %d side %d occluded by world" % [i, int(side)])
|
||||
viewport.queue_free()
|
||||
await process_frame
|
||||
func center(viewport: SubViewport) -> Color:
|
||||
await process_frame
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
return viewport.get_texture().get_image().get_pixel(48, 48)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cqj76kscctrvh
|
||||
@@ -0,0 +1,83 @@
|
||||
extends SceneTree
|
||||
const Materials = preload("res://fx/particle_color_material.gd")
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var fx := EffectPlayer.new()
|
||||
var textures: Array[Texture2D] = []
|
||||
for color in [Color.RED, Color.GREEN, Color.BLUE]:
|
||||
var img := Image.create(4, 4, false, Image.FORMAT_RGBA8)
|
||||
img.fill(color)
|
||||
textures.append(ImageTexture.create_from_image(img))
|
||||
var frames := fx._texture_array(textures)
|
||||
check(frames != null and frames.get_layers() == 3, "independent texture-array slices")
|
||||
var base := StandardMaterial3D.new()
|
||||
base.blend_mode = BaseMaterial3D.BLEND_MODE_MIX
|
||||
base.albedo_texture = textures[0]
|
||||
var mat := Materials.build(base, 4, frames, {"TexAniType": 1, "TexAniDelay": 0.1}, 1.0)
|
||||
check(mat is ShaderMaterial, "ordinary modulation supports per-particle frames")
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await pixels(mat)
|
||||
else:
|
||||
print("SKIP: texture animation pixels require a renderer")
|
||||
fx.free()
|
||||
print("effect_texture_animation_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
func pixels(material: ShaderMaterial) -> void:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(96, 96)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var environment := WorldEnvironment.new()
|
||||
environment.environment = Environment.new()
|
||||
environment.environment.background_mode = Environment.BG_COLOR
|
||||
environment.environment.background_color = Color.BLACK
|
||||
viewport.add_child(environment)
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 3.0
|
||||
camera.position.z = 3.0
|
||||
viewport.add_child(camera)
|
||||
var instance := MultiMeshInstance3D.new()
|
||||
var mesh := QuadMesh.new()
|
||||
mesh.size = Vector2(0.8, 0.8)
|
||||
var multi := MultiMesh.new()
|
||||
multi.transform_format = MultiMesh.TRANSFORM_3D
|
||||
multi.use_custom_data = true
|
||||
multi.use_colors = true
|
||||
multi.mesh = mesh
|
||||
multi.instance_count = 2
|
||||
for i in 2:
|
||||
multi.set_instance_transform(i, Transform3D(Basis.IDENTITY, Vector3(-0.6 + i * 1.2, 0, 0)))
|
||||
multi.set_instance_color(i, Color.WHITE)
|
||||
instance.multimesh = multi
|
||||
instance.material_override = material
|
||||
viewport.add_child(instance)
|
||||
# Same material, different ages: old shared AnimatedTexture fails this.
|
||||
for type in [0, 1, 2, 3, 4]:
|
||||
material.set_shader_parameter("animation_type", type)
|
||||
for age in [0.15, 0.25, 0.35]:
|
||||
multi.set_instance_custom_data(0, Color(0, 0, 0.2, 0))
|
||||
multi.set_instance_custom_data(1, Color(0, age, 0.2, 0))
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var img := viewport.get_texture().get_image()
|
||||
var newborn := img.get_pixel(29, 48)
|
||||
check(newborn.r > 0.9 and newborn.g < 0.05, "newborn always starts at frame zero type=%d" % type)
|
||||
var aged := img.get_pixel(67, 48)
|
||||
if type in [0, 1, 2, 4]:
|
||||
var step := int(floor(age / 0.1))
|
||||
var index := 0 if type == 0 else posmod(-step if type == 2 else step, 3)
|
||||
var channels := [aged.r, aged.g, aged.b]
|
||||
check(channels[index] > 0.9, "age selects expected frame type=%d age=%s" % [type, age])
|
||||
else:
|
||||
check(maxf(aged.r, maxf(aged.g, aged.b)) > 0.9, "random frame selects one valid texture")
|
||||
viewport.free()
|
||||
await process_frame
|
||||
@@ -0,0 +1 @@
|
||||
uid://cedkk28y0ohf7
|
||||
@@ -0,0 +1,42 @@
|
||||
extends SceneTree
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
const Anchor = preload("res://fx/target_effect_anchor.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void: call_deferred("run")
|
||||
func run() -> void:
|
||||
var old_mode := OS.get_environment("MTGODOT_GPUSKIN")
|
||||
var centers := {}
|
||||
for mode in ["0", "1"]:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", mode)
|
||||
for race in 8:
|
||||
var view := PlayerView.new()
|
||||
check(view.build(AssetRoot.path(), race), "race builds")
|
||||
root.add_child(view)
|
||||
view.anim.set_process(false)
|
||||
view.anim.set_time(0)
|
||||
var result: Dictionary = view.model.get_fly_target_bounds()
|
||||
check(result.valid, "source bone OBBs parsed race=%d mode=%s" % [race, mode])
|
||||
var bounds: AABB = result.bounds
|
||||
check(bounds.size.is_finite() and bounds.size.length() > 50, "nonempty cm bounds")
|
||||
var center: Vector3 = Anchor.fly_target_position(view)
|
||||
check(center.distance_to(view.model.to_global(bounds.get_center())) < 0.00001, "source centre transforms once")
|
||||
if mode == "0": centers[race] = center
|
||||
else: check(center.distance_to(centers[race]) < 0.0001, "CPU/GPU target centres match")
|
||||
var extra := MeshInstance3D.new()
|
||||
extra.mesh = BoxMesh.new()
|
||||
extra.position = Vector3(1000, 1000, 1000)
|
||||
view.add_child(extra)
|
||||
view.set_anim_state("attack")
|
||||
view.anim.set_time(0.3)
|
||||
check(Anchor.fly_target_position(view).distance_to(center) < 0.00001, "cached body sphere excludes attachments and later poses")
|
||||
view.position = Vector3(5, 2, -3)
|
||||
check(Anchor.fly_target_position(view).distance_to(center + view.position) < 0.00001, "cached local sphere follows actor translation")
|
||||
view.free()
|
||||
OS.set_environment("MTGODOT_GPUSKIN", old_mode)
|
||||
await process_frame
|
||||
print("fly_target_bounds_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cayswx15kvish
|
||||
@@ -0,0 +1,96 @@
|
||||
extends Node
|
||||
|
||||
## 离线加载实际鬼木林/赤鬼木林地图,并把树怪放进生产 GameScene 的实体挂载点。
|
||||
## 服务器刷新、选中、战斗和掉落仍由 playable_live_test 负责。
|
||||
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
const Fixtures = preload("res://gamescene_test.gd")
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
const Config = preload("res://testing/playable_config.gd")
|
||||
const PlayableReport = preload("res://testing/playable_report.gd")
|
||||
|
||||
var failures := 0
|
||||
|
||||
func _ready() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures += 1
|
||||
printerr("FOREST MAP FAIL: " + message)
|
||||
|
||||
func run() -> void:
|
||||
var map_key := OS.get_environment("MT_FOREST_MAP")
|
||||
var config_path := OS.get_environment("MT_PLAYABLE_CONFIG")
|
||||
var loaded_config := Config.load_file(config_path) if not config_path.is_empty() else {"ok": false, "config": {}}
|
||||
if map_key.is_empty() and loaded_config.ok:
|
||||
map_key = String(loaded_config.config.get("map_key", ""))
|
||||
if map_key.is_empty():
|
||||
map_key = "outdoortrent/metin2_map_trent"
|
||||
var race := int(OS.get_environment("MT_FOREST_MOB_RACE")) if not OS.get_environment("MT_FOREST_MOB_RACE").is_empty() else 2301
|
||||
var output := OS.get_environment("MT_TEST_OUTPUT")
|
||||
if output.is_empty():
|
||||
output = ProjectSettings.globalize_path("res://../build/rendering/forest-map-%d" % Time.get_unix_time_from_system())
|
||||
DirAccess.make_dir_recursive_absolute(output)
|
||||
var playable_report := PlayableReport.new()
|
||||
playable_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded_config.config if loaded_config.ok else {})
|
||||
get_tree().root.size = Vector2i(1280, 720)
|
||||
print("FOREST MAP: loading %s" % map_key)
|
||||
var client := Fixtures.FakeClient.new()
|
||||
# Server-space values chosen inside the corresponding local map rectangle.
|
||||
# GameScene/MapCoord performs the actual base-position conversion.
|
||||
var base_x := 0.0 if map_key == "outdoortrent/metin2_map_trent" else 1049600.0
|
||||
var server_x := base_x + 12800.0
|
||||
var server_y := 12800.0
|
||||
client.spawn(1000, "ForestRender", Vector3(server_x * 0.01, 0.0, -server_y * 0.01), true)
|
||||
var scene := GameScene.new()
|
||||
get_tree().root.add_child(scene)
|
||||
await scene.setup(client, AssetRoot.path(), map_key)
|
||||
print("FOREST MAP: GameScene setup complete")
|
||||
if not scene._map_loaded():
|
||||
_fail("map did not load: %s" % map_key)
|
||||
if not scene._model_built or scene.player == null:
|
||||
_fail("local player model is not ready")
|
||||
var entities := scene.get_node_or_null("Entities")
|
||||
if entities == null:
|
||||
_fail("GameScene entity mount is missing")
|
||||
var positions := [Vector3(12.0, 0.0, 18.0), Vector3(15.0, 0.0, 21.0)]
|
||||
var monster := MobView.new()
|
||||
monster.name = "ForestTreeMonster_%d" % race
|
||||
if not monster.build(AssetRoot.path(), null, race):
|
||||
_fail("tree monster build failed race=%d" % race)
|
||||
else:
|
||||
entities.add_child(monster)
|
||||
monster.position = positions[0]
|
||||
monster.set_anim_state("wait")
|
||||
client.spawn(23001, "TreeMonster", Vector3(positions[0].x * 100.0, 0.0, -positions[0].z * 100.0))
|
||||
await get_tree().process_frame
|
||||
if monster.model == null or monster.model.call("get_visual_aabb").size.length() <= 0.0:
|
||||
_fail("tree monster has no visible geometry")
|
||||
if scene.cam and scene.cam.has_method("snap_to_target"):
|
||||
scene.cam.snap_to_target()
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var image_name := "forest-map-%s.png" % map_key.get_file()
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await RenderingServer.frame_post_draw
|
||||
if get_tree().root.get_texture().get_image().save_png(output.path_join(image_name)) != OK:
|
||||
_fail("map screenshot could not be saved")
|
||||
var report := {"passed": failures == 0, "failures": failures, "map": map_key,
|
||||
"tree_monster_race": race, "tree_monster_model": monster.model != null,
|
||||
"map_loaded": scene._map_loaded(), "screenshot": image_name if DisplayServer.get_name() != "headless" else "",
|
||||
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
|
||||
"scope": "production GameScene map load plus offline tree-monster placement; no server spawn"}
|
||||
var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(JSON.stringify(report, "\t"))
|
||||
file.close()
|
||||
var client_report_path := OS.get_environment("MT_TEST_REPORT")
|
||||
if not client_report_path.is_empty():
|
||||
playable_report.add_case("MAP-FOREST-01", "PASS" if failures == 0 else "FAIL",
|
||||
"map=%s tree_monster_race=%d" % [map_key, race])
|
||||
playable_report.write(client_report_path)
|
||||
print("FOREST MAP RENDER: ", JSON.stringify(report))
|
||||
scene.queue_free()
|
||||
client.queue_free()
|
||||
await get_tree().process_frame
|
||||
get_tree().quit(1 if failures else 0)
|
||||
@@ -0,0 +1,124 @@
|
||||
extends SceneTree
|
||||
|
||||
## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收。
|
||||
## 这不是服务器刷新证明;真实地图中的选中、战斗和掉落由 playable_live_test 承担。
|
||||
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
const RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]
|
||||
const STATES := ["wait", "run", "attack", "damage", "dead"]
|
||||
const SIZE := 640
|
||||
|
||||
var _output := ""
|
||||
var _failures: Array[String] = []
|
||||
var _pose_bounds := {}
|
||||
var _results: Array[Dictionary] = []
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
_failures.append(message)
|
||||
printerr("FOREST MOB FAIL: " + message)
|
||||
|
||||
func run() -> void:
|
||||
_output = OS.get_environment("MT_RENDER_OUTPUT")
|
||||
if _output.is_empty():
|
||||
_output = ProjectSettings.globalize_path("res://../build/rendering/forest-mobs-%d" % Time.get_unix_time_from_system())
|
||||
DirAccess.make_dir_recursive_absolute(_output)
|
||||
root.size = Vector2i(SIZE, SIZE)
|
||||
_build_lighting()
|
||||
var cam := Camera3D.new()
|
||||
cam.fov = 38.0
|
||||
cam.near = 0.02
|
||||
root.add_child(cam)
|
||||
cam.make_current()
|
||||
for gpu in ["0", "1"]:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", gpu)
|
||||
for race in RACES:
|
||||
await _check_race(race, gpu, cam)
|
||||
OS.set_environment("MTGODOT_GPUSKIN", "0")
|
||||
var report := {"passed": _failures.is_empty(), "failures": _failures, "races": RACES,
|
||||
"states": STATES, "gpu_modes": ["0", "1"], "results": _results,
|
||||
"assets": AssetRoot.path(), "viewport": str(root.size),
|
||||
"headless": DisplayServer.get_name() == "headless",
|
||||
"scope": "offline tree-monster assembly; no server spawn or 40250 pixel parity"}
|
||||
var file := FileAccess.open(_output.path_join("report.json"), FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(JSON.stringify(report, "\t"))
|
||||
file.close()
|
||||
print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(), "results": _results.size(), "output": _output}))
|
||||
quit(0 if _failures.is_empty() else 1)
|
||||
|
||||
func _check_race(race: int, gpu: String, cam: Camera3D) -> void:
|
||||
var view := MobView.new()
|
||||
view.name = "ForestMob_%d_%s" % [race, gpu]
|
||||
root.add_child(view)
|
||||
if not view.build(AssetRoot.path(), null, race):
|
||||
_fail("race=%d GPU=%s build returned false" % [race, gpu])
|
||||
view.queue_free()
|
||||
await process_frame
|
||||
return
|
||||
var row := {"race": race, "gpu_skin": gpu == "1", "states": {}}
|
||||
for state in STATES:
|
||||
var requested := view.get_motion_path(state)
|
||||
if requested.is_empty() or not FileAccess.file_exists(requested):
|
||||
_fail("race=%d GPU=%s state=%s has no resolved .msa" % [race, gpu, state])
|
||||
continue
|
||||
view.set_anim_state(state)
|
||||
if view.anim == null:
|
||||
_fail("race=%d GPU=%s state=%s has no animation player" % [race, gpu, state])
|
||||
continue
|
||||
view.anim.set_process(true)
|
||||
await process_frame
|
||||
var actual := String(view.anim.get("anim_path"))
|
||||
if actual != requested:
|
||||
_fail("race=%d GPU=%s state=%s resolved %s but played %s" % [race, gpu, state, requested, actual])
|
||||
var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 1.0
|
||||
var fraction := 0.5
|
||||
if state == "dead":
|
||||
fraction = 0.95
|
||||
view.anim.call("set_time", maxf(0.0, duration * fraction))
|
||||
view.anim.set_process(false)
|
||||
await process_frame
|
||||
var bounds: AABB = view.model.call("get_visual_aabb")
|
||||
if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0:
|
||||
_fail("race=%d GPU=%s state=%s has invalid visual AABB" % [race, gpu, state])
|
||||
_pose_bounds["%d/%s" % [race, state]] = bounds if gpu == "0" else _pose_bounds.get("%d/%s" % [race, state], AABB())
|
||||
if gpu == "1":
|
||||
var cpu_bounds: AABB = _pose_bounds.get("%d/%s" % [race, state], AABB())
|
||||
var tolerance := maxf(0.01, maxf(cpu_bounds.size.length(), bounds.size.length()) * 0.005)
|
||||
if cpu_bounds.size.length() <= 0.0 or cpu_bounds.position.distance_to(bounds.position) > tolerance or cpu_bounds.size.distance_to(bounds.size) > tolerance:
|
||||
_fail("race=%d state=%s CPU/GPU AABB differs beyond %.4fm" % [race, state, tolerance])
|
||||
var image_name := ""
|
||||
if DisplayServer.get_name() != "headless":
|
||||
var centre: Vector3 = view.model.global_transform * bounds.get_center()
|
||||
var visual_size: float = bounds.size.length() * view.model.scale.length() / sqrt(3.0)
|
||||
var distance: float = maxf(3.0, visual_size * 1.8)
|
||||
cam.position = centre + Vector3(distance * 0.35, distance * 0.22, distance)
|
||||
cam.look_at(centre, Vector3.UP)
|
||||
await RenderingServer.frame_post_draw
|
||||
image_name = "mob-%d-%s.png" % [race, state] if gpu == "1" else "mob-%d-%s-cpu.png" % [race, state]
|
||||
if root.get_texture().get_image().save_png(_output.path_join(image_name)) != OK:
|
||||
_fail("race=%d state=%s screenshot save failed" % [race, state])
|
||||
row.states[state] = {"requested_motion": requested, "resolved_motion": actual,
|
||||
"duration": duration, "sample_fraction": fraction,
|
||||
"aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z],
|
||||
"aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z], "screenshot": image_name}
|
||||
_results.append(row)
|
||||
view.queue_free()
|
||||
await process_frame
|
||||
|
||||
func _build_lighting() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-42, 155, 0)
|
||||
sun.light_energy = 1.1
|
||||
root.add_child(sun)
|
||||
var env := WorldEnvironment.new()
|
||||
var settings := Environment.new()
|
||||
settings.background_mode = Environment.BG_COLOR
|
||||
settings.background_color = Color(0.08, 0.1, 0.14)
|
||||
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
settings.ambient_light_color = Color(0.55, 0.58, 0.65)
|
||||
settings.ambient_light_energy = 0.8
|
||||
env.environment = settings
|
||||
root.add_child(env)
|
||||
+227
-121
@@ -13,6 +13,9 @@ extends Node3D
|
||||
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
const Mde = preload("res://fx/mde.gd")
|
||||
const EffectSpace = preload("res://fx/effect_space.gd")
|
||||
const ParticleColorMaterial = preload("res://fx/particle_color_material.gd")
|
||||
const ParticleFaces = preload("res://fx/particle_faces.gd")
|
||||
|
||||
var spec := {}
|
||||
var assets_root := ""
|
||||
@@ -24,6 +27,8 @@ var _mesh_states: Array[Dictionary] = []
|
||||
var _lights: Array[OmniLight3D] = []
|
||||
var _light_states: Array[Dictionary] = []
|
||||
var _bsphere_r := 0.0
|
||||
var _playing := false
|
||||
var _cleanup_remaining := -1.0
|
||||
|
||||
static var _glow_tex: Texture2D
|
||||
|
||||
@@ -48,41 +53,54 @@ func build(mse_spec: Dictionary, assets := "") -> void:
|
||||
_lights.append(light)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _playing:
|
||||
return
|
||||
for state in _particle_states:
|
||||
_advance_particle(state, delta)
|
||||
for state in _mesh_states:
|
||||
_advance_mesh(state, delta)
|
||||
for state in _light_states:
|
||||
_advance_light(state, delta)
|
||||
if _cleanup_remaining >= 0.0:
|
||||
_cleanup_remaining -= maxf(delta, 0.0)
|
||||
if _cleanup_remaining <= 0.0:
|
||||
queue_free()
|
||||
|
||||
func play(force_one_shot := false) -> void:
|
||||
# EffectPlayer can be reused by the registry. Reset the CPU-side clocks as
|
||||
# well as the GPU emitters so cycle gates do not inherit the previous play.
|
||||
# One playback clock owns starts and cleanup. No detached SceneTree timers
|
||||
# may start or delete an effect after stop/replay, or while it is paused.
|
||||
_playing = true
|
||||
_cleanup_remaining = maxf(_longest_life() + 0.5, 1.5) if (force_one_shot or one_shot) else -1.0
|
||||
for state in _particle_states:
|
||||
state["clock"] = 0.0
|
||||
state["emission_stopped"] = false
|
||||
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: SceneTree = get_tree() if is_inside_tree() else null
|
||||
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: SceneTree = get_tree() if is_inside_tree() else null
|
||||
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)
|
||||
state["emission_started"] = false
|
||||
var e: GPUParticles3D = state["node"]
|
||||
# Effect one_shot controls node cleanup, not a GPU emission cycle.
|
||||
# Individual lifetime may be much shorter than the MSE emission window.
|
||||
e.one_shot = false
|
||||
e.restart()
|
||||
e.emitting = false
|
||||
e.position = _position_at(state.get("rows", []), 0.0)
|
||||
_advance_particle(state, 0.0)
|
||||
for state in _mesh_states:
|
||||
state["clock"] = 0.0
|
||||
for child in state.get("children", []):
|
||||
child["frame"] = -1
|
||||
child["texture_frame"] = -1
|
||||
_advance_mesh(state, 0.0)
|
||||
for state in _light_states:
|
||||
state["clock"] = 0.0
|
||||
_advance_light(state, 0.0)
|
||||
|
||||
func stop() -> void:
|
||||
# Existing particles may finish their lifetime; mesh/light emission stops now.
|
||||
_playing = false
|
||||
_cleanup_remaining = -1.0
|
||||
for e in _emitters:
|
||||
e.emitting = false
|
||||
for mesh in _mesh_nodes:
|
||||
mesh.visible = false
|
||||
for light in _lights:
|
||||
light.visible = false
|
||||
|
||||
@@ -109,6 +127,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
var emit: Dictionary = p.get("emitter", {})
|
||||
var prop: Dictionary = p.get("particle", {})
|
||||
var g := GPUParticles3D.new()
|
||||
g.emitting = false
|
||||
g.set_meta("start_time", float(p.get("start_time", 0.0)))
|
||||
g.set_meta("emitter_advanced_type", int(_n(emit.get("EmitterAdvancedType", 0))))
|
||||
g.set_meta("emitter_emit_from_edge", int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0)
|
||||
@@ -116,8 +135,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5:
|
||||
# EffectLib stores effect coordinates in centimetres; the Godot scene is
|
||||
# metres. The position curve is evaluated again as the effect advances.
|
||||
g.position = Vector3(float(positions[0][2]), float(positions[0][3]),
|
||||
float(positions[0][4])) * 0.01
|
||||
g.position = _position_at(positions, 0.0)
|
||||
g.amount = maxi(1, int(_n(emit.get("MaxEmissionCount", 16))))
|
||||
g.explosiveness = 0.0
|
||||
|
||||
@@ -128,7 +146,8 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
var cycle := _n(emit.get("CycleLength", 0.0)) if emit.has("CycleLength") else 0.05
|
||||
var loop := int(_n(emit.get("CycleLoopEnable", 0)))
|
||||
var loop_count := int(_n(emit.get("LoopCount", 0)))
|
||||
g.one_shot = (loop == 0)
|
||||
g.one_shot = false # emission ends at the MSE clock gate, not one particle lifetime
|
||||
g.local_coords = int(_n(prop.get("AttachEnable", 0))) != 0
|
||||
g.set_meta("cycle_length", maxf(cycle, 0.0))
|
||||
g.set_meta("cycle_loop", loop != 0)
|
||||
g.set_meta("loop_count", maxi(loop_count, 0))
|
||||
@@ -140,11 +159,11 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
var shape := int(_n(emit.get("EmitterShape", 0)))
|
||||
var emit_from_edge := int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0
|
||||
var radius := maxf(0.0, _n(emit.get("EmittingRadius", 10.0))) * 0.01
|
||||
var emitting_size := _vec3(emit.get("EmittingSize", [0.0, 0.0, 0.0])) * 0.01
|
||||
var emitting_size := EffectSpace.position(_vec3(emit.get("EmittingSize", [0.0, 0.0, 0.0])))
|
||||
match shape:
|
||||
1: # CEmitterProperty::EMITTER_SHAPE_ELLIPSE (flat box approximation)
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
pm.emission_box_extents = Vector3(maxf(radius, 0.01), maxf(radius, 0.01), 0.005)
|
||||
pm.emission_box_extents = Vector3(maxf(radius, 0.01), 0.005, maxf(radius, 0.01))
|
||||
2: # CEmitterProperty::EMITTER_SHAPE_SQUARE
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
pm.emission_box_extents = Vector3(maxf(absf(emitting_size.x) * 0.5, 0.005),
|
||||
@@ -160,6 +179,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
direction += Vector3(_last_val(emit.get("TimeEventEmittingDirectionX", []), 0.0),
|
||||
_last_val(emit.get("TimeEventEmittingDirectionY", []), 0.0),
|
||||
_last_val(emit.get("TimeEventEmittingDirectionZ", []), 0.0))
|
||||
direction = EffectSpace.direction(direction)
|
||||
pm.direction = direction.normalized() if direction.length_squared() > 0.000001 else Vector3(0, 1, 0)
|
||||
pm.spread = 25.0 if direction.length_squared() <= 0.000001 else 0.0
|
||||
# The reference computes OUTER/INNER velocity from each particle's radial
|
||||
@@ -175,44 +195,84 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
# 重力
|
||||
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
|
||||
# EffectLib stores independent half-width/height. Lifetime scale is XY,
|
||||
# not a uniform random multiplier. Birth-time size tracks remain approximated
|
||||
# by their last value until per-particle emitter-clock sampling is supported.
|
||||
var half_width := _last_val(emit.get("TimeEventSizeX", []), 32.0)
|
||||
var half_height := _last_val(emit.get("TimeEventSizeY", []), 32.0)
|
||||
pm.scale_min = 1.0
|
||||
pm.scale_max = 1.0
|
||||
var scale_texture := CurveXYZTexture.new()
|
||||
scale_texture.curve_x = _scale_curve(prop.get("TimeEventScaleX", []))
|
||||
scale_texture.curve_y = _scale_curve(prop.get("TimeEventScaleY", []))
|
||||
scale_texture.curve_z = _scale_curve([])
|
||||
pm.scale_curve = scale_texture
|
||||
# Godot multiplies color by color_ramp. Store RGBA only in the ramp so
|
||||
# channels are not squared, and sample all four tracks over particle age.
|
||||
pm.color = Color.WHITE
|
||||
pm.color_ramp = _color_ramp(prop)
|
||||
# 旋转:ParticleProperty 的 0=NONE、1=TIME_EVENT、2=CW、3=CCW、
|
||||
# 4=RANDOM_DIRECTION。Godot 没有完全相同的每粒子时间表,先保留方向和
|
||||
# 时间表末值,避免把 NONE 误当成恒速旋转。
|
||||
# 4=RANDOM_DIRECTION。TIME_EVENT 按粒子寿命采样转速再积分。
|
||||
var rotation_type := int(_n(prop.get("RotationType", 0)))
|
||||
var angle_begin := _n(prop.get("RotationRandomStartingBegin", 0.0))
|
||||
var angle_end := _n(prop.get("RotationRandomStartingEnd", 0.0))
|
||||
pm.angle_min = minf(angle_begin, angle_end)
|
||||
pm.angle_max = maxf(angle_begin, angle_end)
|
||||
var rspeed := _n(prop.get("RotationSpeed", 0.0))
|
||||
if rotation_type == 1:
|
||||
rspeed = _last_val(prop.get("TimeEventRotation", []), rspeed)
|
||||
var rotation_texture := CurveTexture.new()
|
||||
rotation_texture.curve = _scale_curve(prop.get("TimeEventRotation", []), 0.0)
|
||||
pm.angular_velocity_curve = rotation_texture
|
||||
# Godot 4.7's curve branch accumulates directly in radians (CUSTOM.x),
|
||||
# whereas the constant branch converts degrees internally. The curve
|
||||
# retains source degrees/sec; this multiplier supplies the conversion.
|
||||
pm.angular_velocity_min = deg_to_rad(1.0)
|
||||
pm.angular_velocity_max = deg_to_rad(1.0)
|
||||
if rotation_type == 3:
|
||||
rspeed = -rspeed
|
||||
if rotation_type != 0 and rspeed != 0.0:
|
||||
pm.angular_velocity_min = deg_to_rad(rspeed) * 0.3
|
||||
pm.angular_velocity_max = deg_to_rad(rspeed)
|
||||
if rotation_type in [2, 3] and rspeed != 0.0:
|
||||
# Both EffectLib and Godot's constant angular velocity use degrees/sec.
|
||||
pm.angular_velocity_min = rspeed
|
||||
pm.angular_velocity_max = rspeed
|
||||
if rotation_type == 4:
|
||||
# Choose a stable per-birth sign in the drawing shader, retaining full
|
||||
# magnitude in both directions and the initial angle in CUSTOM.x.
|
||||
pm.anim_speed_min = 0.0
|
||||
pm.anim_speed_max = 0.0
|
||||
pm.anim_offset_min = 0.0
|
||||
pm.anim_offset_max = 1.0
|
||||
g.process_material = pm
|
||||
|
||||
# 绘制网格 + 材质(billboard + 混合)
|
||||
var qm := QuadMesh.new()
|
||||
qm.size = Vector2(1, 1)
|
||||
g.draw_pass_1 = qm
|
||||
g.draw_pass_1 = ParticleFaces.build(Vector2(maxf(half_width, 0.0), maxf(half_height, 0.0)) * 0.02,
|
||||
int(_n(prop.get("BillboardType", 1))))
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
# EffectInstance::OnRender disables culling and depth writes for FX, but
|
||||
# keeps depth testing so terrain/buildings can still occlude the effect.
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||||
mat.billboard_mode = _billboard(int(_n(prop.get("BillboardType", 1))))
|
||||
# Only particle billboards consume INSTANCE_CUSTOM.x (rotation). Keep the
|
||||
# shared mesh-material mapping separate: meshes have no particle custom data.
|
||||
if int(_n(prop.get("BillboardType", 1))) == 1:
|
||||
mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
|
||||
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))))
|
||||
var tex := _particle_texture(prop)
|
||||
mat.albedo_texture = tex if tex else _glow()
|
||||
var textures := _particle_textures(prop)
|
||||
mat.albedo_texture = textures[0] if not textures.is_empty() else _glow()
|
||||
var frames: Texture2DArray
|
||||
if textures.size() > 1:
|
||||
frames = _texture_array(textures)
|
||||
# CUSTOM.z is a stable per-birth seed; CUSTOM.y is this particle's age.
|
||||
# Do not use a shared AnimatedTexture wall clock for all particles.
|
||||
pm.anim_speed_min = 0.0
|
||||
pm.anim_speed_max = 0.0
|
||||
pm.anim_offset_min = 0.0
|
||||
pm.anim_offset_max = 1.0
|
||||
mat.vertex_color_use_as_albedo = true
|
||||
g.material_override = mat
|
||||
g.material_override = ParticleColorMaterial.build(mat, int(_n(prop.get("ColorOperationType", 4))), frames, prop, g.lifetime)
|
||||
_particle_states.append({
|
||||
"node": g,
|
||||
"rows": positions,
|
||||
@@ -222,6 +282,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
"cycle_loop": loop != 0,
|
||||
"loop_count": maxi(loop_count, 0),
|
||||
"emission_stopped": false,
|
||||
"emission_started": false,
|
||||
})
|
||||
return g
|
||||
|
||||
@@ -230,8 +291,7 @@ func _advance_particle(state: Dictionary, delta: float) -> void:
|
||||
if g == null or not is_instance_valid(g):
|
||||
return
|
||||
var rows: Array = state.get("rows", [])
|
||||
if rows.is_empty():
|
||||
return
|
||||
# Position tracks are optional; stationary emitters still obey lifecycle gates.
|
||||
var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0)
|
||||
state["clock"] = clock
|
||||
var start := float(state.get("start_time", 0.0))
|
||||
@@ -248,7 +308,11 @@ func _advance_particle(state: Dictionary, delta: float) -> void:
|
||||
g.emitting = false
|
||||
state["emission_stopped"] = true
|
||||
return
|
||||
if not bool(state.get("emission_stopped", false)):
|
||||
if _playing and not bool(state.get("emission_started", false)) and not bool(state.get("emission_stopped", false)):
|
||||
g.restart()
|
||||
g.emitting = true
|
||||
state["emission_started"] = true
|
||||
if not rows.is_empty() and not bool(state.get("emission_stopped", false)):
|
||||
g.position = _position_at(rows, local)
|
||||
|
||||
func _build_mesh(m: Dictionary) -> Node3D:
|
||||
@@ -262,7 +326,7 @@ func _build_mesh(m: Dictionary) -> Node3D:
|
||||
n.name = "mesh_" + String(m.get("mesh_file", "?")).get_basename()
|
||||
var positions: Array = m.get("position", [])
|
||||
if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5:
|
||||
n.position = Vector3(float(positions[0][2]), float(positions[0][3]), float(positions[0][4])) * 0.01
|
||||
n.position = _position_at(positions, 0.0)
|
||||
|
||||
var geometries: Array = mesh_data.get("geometries", [])
|
||||
var elements: Array = m.get("elements", [])
|
||||
@@ -328,7 +392,7 @@ func _build_light(l: Dictionary) -> OmniLight3D:
|
||||
light.name = "simple_light"
|
||||
var positions: Array = l.get("position", [])
|
||||
if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5:
|
||||
light.position = Vector3(float(positions[0][2]), float(positions[0][3]), float(positions[0][4])) * 0.01
|
||||
light.position = _position_at(positions, 0.0)
|
||||
var diffuse: Array = l.get("diffuse", [0.0, 0.0, 0.0, 1.0])
|
||||
if diffuse.size() >= 4:
|
||||
light.light_color = Color(float(diffuse[0]), float(diffuse[1]), float(diffuse[2]), float(diffuse[3]))
|
||||
@@ -477,6 +541,8 @@ func _array_mesh(frame: Dictionary) -> ArrayMesh:
|
||||
func _mesh_material(element: Dictionary, textures: Array) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.billboard_mode = _billboard(int(_n(element.get("BillboardType", 0))))
|
||||
mat.blend_mode = _blend(int(_n(element.get("BlendingSrcType", 5))), int(_n(element.get("BlendingDestType", 2))))
|
||||
@@ -578,7 +644,7 @@ func _position_at(rows, time: float) -> Vector3:
|
||||
var p0 := _position_vector(previous)
|
||||
var p1 := _position_vector(current)
|
||||
if String(previous[1]) == "MOVING_TYPE_BEZIER_CURVE" and previous.size() >= 8:
|
||||
var control := Vector3(float(previous[5]), float(previous[6]), float(previous[7]))
|
||||
var control := EffectSpace.direction(Vector3(float(previous[5]), float(previous[6]), float(previous[7])))
|
||||
var middle := p0 + control
|
||||
var inv := 1.0 - t
|
||||
return (p0 * inv * inv + middle * 2.0 * inv * t + p1 * t * t) * 0.01
|
||||
@@ -588,7 +654,7 @@ func _position_at(rows, time: float) -> Vector3:
|
||||
|
||||
func _position_vector(row) -> Vector3:
|
||||
if row is Array and row.size() >= 5:
|
||||
return Vector3(float(row[2]), float(row[3]), float(row[4]))
|
||||
return EffectSpace.direction(Vector3(float(row[2]), float(row[3]), float(row[4])))
|
||||
return Vector3.ZERO
|
||||
|
||||
func _event_value(rows, time: float, fallback: float) -> float:
|
||||
@@ -624,10 +690,10 @@ func _vec3(v) -> Vector3:
|
||||
return Vector3(float(v[0]), float(v[1]), float(v[2]))
|
||||
return Vector3.ZERO
|
||||
|
||||
func _particle_texture(prop: Dictionary) -> Texture2D:
|
||||
func _particle_textures(prop: Dictionary) -> Array[Texture2D]:
|
||||
var rows: Variant = prop.get("TextureFiles", [])
|
||||
if not rows is Array or rows.is_empty():
|
||||
return null
|
||||
return []
|
||||
# ParticleSystemData resolves relative texture names against the .mse
|
||||
# directory. UiAssets keeps the same behavior and uses the native DDS path.
|
||||
var mse_dir := String(spec.get("dir", ""))
|
||||
@@ -641,23 +707,29 @@ func _particle_texture(prop: Dictionary) -> Texture2D:
|
||||
tex = UiAssets.load_tex(assets_root, file)
|
||||
if tex:
|
||||
textures.append(tex)
|
||||
if textures.is_empty():
|
||||
return null
|
||||
var animation_type := int(_n(prop.get("TexAniType", 0)))
|
||||
if textures.size() == 1 or animation_type == 0:
|
||||
return textures[0]
|
||||
# AnimatedTexture is shared by the draw pass, unlike the original per-particle
|
||||
# frame index. This preserves the real frame order and delay for the common
|
||||
# multi-file case; random-start variants remain deterministic until the GPU
|
||||
# particle custom-data path is added.
|
||||
if animation_type == 2: # TEXTURE_ANIMATION_TYPE_CCW
|
||||
textures.reverse()
|
||||
var animated := AnimatedTexture.new()
|
||||
animated.fps = 1.0 / maxf(_n(prop.get("TexAniDelay", 0.05)), 0.001)
|
||||
animated.frames = mini(textures.size(), 256)
|
||||
for i in animated.frames:
|
||||
animated.set_frame_texture(i, textures[i])
|
||||
return animated
|
||||
return textures
|
||||
|
||||
func _texture_array(textures: Array[Texture2D]) -> Texture2DArray:
|
||||
var images: Array[Image] = []
|
||||
var width := 1
|
||||
var height := 1
|
||||
for texture in textures:
|
||||
width = maxi(width, texture.get_width())
|
||||
height = maxi(height, texture.get_height())
|
||||
for texture in textures:
|
||||
var img := texture.get_image()
|
||||
if img == null or img.is_empty():
|
||||
return null
|
||||
if img.is_compressed():
|
||||
if img.decompress() != OK:
|
||||
return null
|
||||
img.convert(Image.FORMAT_RGBA8)
|
||||
img.clear_mipmaps()
|
||||
if img.get_width() != width or img.get_height() != height:
|
||||
img.resize(width, height, Image.INTERPOLATE_BILINEAR)
|
||||
images.append(img)
|
||||
var frames := Texture2DArray.new()
|
||||
return frames if frames.create_from_images(images) == OK else null
|
||||
|
||||
# List 表末行的值(列 idx,默认 col 1 = 时间后第一个数)
|
||||
func _last_val(rows, def: float, col := 1) -> float:
|
||||
@@ -667,68 +739,102 @@ func _last_val(rows, def: float, col := 1) -> float:
|
||||
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:
|
||||
# Independent lifetime channel, with linear tangents matching EffectLib.
|
||||
func _scale_curve(rows, fallback := 1.0) -> Curve:
|
||||
var values := {}
|
||||
if rows is Array:
|
||||
for row in rows:
|
||||
if row is Array and row.size() >= 2 and (row[0] is float or row[0] is int) and (row[1] is float or row[1] is int):
|
||||
var time := float(row[0])
|
||||
var value := float(row[1])
|
||||
if is_finite(time) and is_finite(value):
|
||||
if not values.has(time):
|
||||
values[time] = []
|
||||
values[time].append(value)
|
||||
var times := values.keys()
|
||||
times.sort()
|
||||
var track: Array = []
|
||||
for time in times:
|
||||
for value in values[time]:
|
||||
track.append([time, value])
|
||||
var points := {0.0: _event_value(track, 0.0, fallback), 1.0: _event_value(track, 1.0, fallback)}
|
||||
for i in times.size():
|
||||
var time: float = times[i]
|
||||
if time > 0.0 and time < 1.0:
|
||||
points[time] = values[time][0]
|
||||
if time >= 0.0 and time < 1.0 and values[time].size() > 1:
|
||||
# EffectLib takes the first value exactly at a duplicate key, then
|
||||
# departs from the last. A texture cannot encode a zero-width jump;
|
||||
# preserve both sides using a narrow transition, not a long ramp.
|
||||
var next_time: float = times[i + 1] if i + 1 < times.size() else 1.0
|
||||
# Keep wider than Curve's approximate-equality epsilon.
|
||||
var after := time + minf(0.0001, (next_time - time) * 0.5)
|
||||
points[after] = values[time][-1]
|
||||
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
|
||||
c.min_value = minf(0.0, points.values().min())
|
||||
c.max_value = maxf(1.0, points.values().max())
|
||||
times = points.keys()
|
||||
times.sort()
|
||||
for time in times:
|
||||
c.add_point(Vector2(time, points[time]), 0.0, 0.0, Curve.TANGENT_LINEAR, Curve.TANGENT_LINEAR)
|
||||
return c
|
||||
|
||||
func _alpha_ramp(rows, base: Color) -> GradientTexture1D:
|
||||
if not (rows is Array) or rows.size() < 1:
|
||||
return null
|
||||
func _color_ramp(prop: Dictionary) -> GradientTexture1D:
|
||||
# Match EffectLib ParticleSystemData: union the RGBA key times, then
|
||||
# linearly evaluate each channel there. Preserve independent channel knots.
|
||||
var times := {0.0: true, 1.0: true}
|
||||
var tracks: Array = []
|
||||
for key in ["TimeEventColorRed", "TimeEventColorGreen", "TimeEventColorBlue", "TimeEventAlpha"]:
|
||||
var points := {}
|
||||
var rows = prop.get(key, [])
|
||||
if rows is Array:
|
||||
for row in rows:
|
||||
if row is Array and row.size() >= 2 and (row[0] is float or row[0] is int) and (row[1] is float or row[1] is int):
|
||||
var time := float(row[0])
|
||||
var value := float(row[1])
|
||||
if is_finite(time) and is_finite(value):
|
||||
points[time] = value
|
||||
times[clampf(time, 0.0, 1.0)] = true
|
||||
var ordered := points.keys()
|
||||
ordered.sort()
|
||||
var track: Array = []
|
||||
for time in ordered:
|
||||
track.append([time, points[time]])
|
||||
tracks.append(track)
|
||||
var ordered_times := times.keys()
|
||||
ordered_times.sort()
|
||||
var offsets := PackedFloat32Array()
|
||||
var colors := PackedColorArray()
|
||||
for time in ordered_times:
|
||||
offsets.append(time)
|
||||
colors.append(Color(
|
||||
clampf(_event_value(tracks[0], time, 1.0), 0.0, 1.0),
|
||||
clampf(_event_value(tracks[1], time, 1.0), 0.0, 1.0),
|
||||
clampf(_event_value(tracks[2], time, 1.0), 0.0, 1.0),
|
||||
clampf(_event_value(tracks[3], time, 1.0), 0.0, 1.0)))
|
||||
var grad := Gradient.new()
|
||||
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]])
|
||||
if pts.size() == 1:
|
||||
pts.append([minf(1.0, pts[0][0] + 0.001), pts[0][1]])
|
||||
grad.offsets = PackedFloat32Array([float(pts[0][0]), float(pts[-1][0])])
|
||||
grad.colors = PackedColorArray([
|
||||
Color(base.r, base.g, base.b, clampf(float(pts[0][1]), 0, 1)),
|
||||
Color(base.r, base.g, base.b, clampf(float(pts[-1][1]), 0, 1)),
|
||||
])
|
||||
for i in range(1, pts.size() - 1):
|
||||
var pt = pts[i]
|
||||
grad.add_point(float(pt[0]), Color(base.r, base.g, base.b, clampf(float(pt[1]), 0, 1)))
|
||||
grad.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_LINEAR
|
||||
grad.offsets = offsets
|
||||
grad.colors = colors
|
||||
var gt := GradientTexture1D.new()
|
||||
gt.gradient = grad
|
||||
return gt
|
||||
|
||||
func _billboard(bt: int) -> int:
|
||||
# 0 = none/local, 1 = 面向相机, 4 = Y 轴
|
||||
if bt == 4:
|
||||
# EffectLib Type.h: 0=NONE, 1=ALL, 2=Y. Particle 3=LIE,
|
||||
# 4=2FACE and 5=3FACE still use a camera-facing approximation below.
|
||||
if bt == 2:
|
||||
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
|
||||
# D3DBLEND: 5=SRCALPHA, 6=INVSRCALPHA, 2=ONE, 4=INVSRCCOLOR.
|
||||
# Ordinary transparency must not fall through to additive glow.
|
||||
if src == 5 and dst == 6:
|
||||
return BaseMaterial3D.BLEND_MODE_MIX
|
||||
# Remaining pairs retain the existing approximations, not exact D3D parity.
|
||||
if dst == 2:
|
||||
return BaseMaterial3D.BLEND_MODE_ADD
|
||||
if dst == 4:
|
||||
|
||||
@@ -97,3 +97,26 @@ func spawn_at(name: String, world_parent: Node3D, global_pos: Vector3, one_shot
|
||||
if fx:
|
||||
fx.global_position = global_pos
|
||||
return fx
|
||||
|
||||
func spawn_motion(event: Dictionary, view: Node3D, world_parent: Node3D) -> Node3D:
|
||||
var effect := spawn(String(event.get("effect", "")), world_parent, true)
|
||||
if effect == null:
|
||||
return null
|
||||
var anchor := preload("res://fx/motion_effect_anchor.gd").new()
|
||||
effect.add_child(anchor)
|
||||
if not anchor.configure(view, event):
|
||||
effect.free()
|
||||
return null
|
||||
return effect
|
||||
|
||||
func spawn_target(event: Dictionary, target: Node3D, world_parent: Node3D) -> Node3D:
|
||||
if not is_instance_valid(target) or bool(event.get("fishing_effect", false)):
|
||||
return null # Fishing requires its separate water landing position.
|
||||
var effect := spawn(String(event.get("effect", "")), world_parent, true)
|
||||
if effect == null: return null
|
||||
var anchor := preload("res://fx/target_effect_anchor.gd").new()
|
||||
effect.add_child(anchor)
|
||||
if not anchor.configure(target, event.get("pos", Vector3.ZERO), bool(event.get("following", false))):
|
||||
effect.free()
|
||||
return null
|
||||
return effect
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
extends RefCounted
|
||||
# All MSE/MDE vectors are Z-up. Convert orientation before the cm->m scale.
|
||||
static func direction(source: Vector3) -> Vector3:
|
||||
return Vector3(source.x, source.z, -source.y)
|
||||
static func position(source: Vector3) -> Vector3:
|
||||
return direction(source) * 0.01
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3htid787d4uy
|
||||
+2
-2
@@ -115,10 +115,10 @@ func parse_bytes(data: PackedByteArray) -> Dictionary:
|
||||
positions.resize(vertex_count)
|
||||
for i in vertex_count:
|
||||
var base := offset + i * 12
|
||||
positions[i] = Vector3(
|
||||
positions[i] = preload("res://fx/effect_space.gd").position(Vector3(
|
||||
float(data.decode_float(base)),
|
||||
float(data.decode_float(base + 4)),
|
||||
float(data.decode_float(base + 8))) * 0.01
|
||||
float(data.decode_float(base + 8))))
|
||||
offset += vertex_count * 12
|
||||
|
||||
var indices := PackedInt32Array()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 40250 ActorInstanceMotionEvent / ActorInstanceAttach: bone * offset * actor
|
||||
# in row-vector order. The Godot equivalent is actor * offset * bone.
|
||||
extends Node
|
||||
|
||||
const SOURCE_TO_METRES := Transform3D(Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0)), Vector3.ZERO)
|
||||
var model: Node3D
|
||||
var animator: Node
|
||||
var event := {}
|
||||
|
||||
func configure(view: Node3D, data: Dictionary) -> bool:
|
||||
model = view.get("model")
|
||||
animator = view.get("anim")
|
||||
event = data.duplicate()
|
||||
# Sample immediately, then after animation (default priority 0) every frame.
|
||||
process_priority = 100
|
||||
if not update_anchor():
|
||||
return false
|
||||
var attached := bool(event.get("attaching", false))
|
||||
set_process(not bool(event.get("independent", false)) and
|
||||
(not attached or bool(event.get("following", false))))
|
||||
return true
|
||||
|
||||
func update_anchor() -> bool:
|
||||
if not is_instance_valid(model) or not model.is_inside_tree():
|
||||
return false
|
||||
var bone := Transform3D.IDENTITY
|
||||
if bool(event.get("attaching", false)) and not bool(event.get("independent", false)):
|
||||
if not is_instance_valid(animator):
|
||||
return false
|
||||
var pose: Dictionary = animator.get_effect_bone_pose(String(event.get("bone", "")))
|
||||
if pose.is_empty():
|
||||
return false # No silent substitution of the actor origin for a missing bone.
|
||||
bone = pose.transform
|
||||
var offset := Transform3D(Basis.IDENTITY, event.get("pos", Vector3.ZERO))
|
||||
get_parent().global_transform = model.global_transform * offset * bone * SOURCE_TO_METRES.affine_inverse()
|
||||
return true
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not update_anchor():
|
||||
get_parent().queue_free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvib4rwg2v55o
|
||||
@@ -0,0 +1,139 @@
|
||||
# Particle-only legacy texture-stage operations. Static operation 4 keeps the
|
||||
# StandardMaterial path; animated textures use per-particle array sampling.
|
||||
# RGB is saturated before framebuffer blending;
|
||||
# alpha remains texture alpha * particle alpha, never multiplied by RGB gain.
|
||||
extends RefCounted
|
||||
|
||||
static var _shaders := {}
|
||||
const SOURCE := """
|
||||
shader_type spatial;
|
||||
render_mode unshaded, cull_disabled, depth_draw_never, __BLEND__;
|
||||
uniform sampler2D albedo_texture : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform float color_gain = 2.0;
|
||||
uniform int color_operation = 5;
|
||||
uniform int billboard_mode = 0;
|
||||
uniform bool random_rotation = false;
|
||||
uniform float rotation_speed = 0.0;
|
||||
uniform float rotation_lifetime = 1.0;
|
||||
void vertex() {
|
||||
float particle_angle = INSTANCE_CUSTOM.x;
|
||||
if (random_rotation) {
|
||||
float direction = INSTANCE_CUSTOM.z < 0.5 ? -1.0 : 1.0;
|
||||
particle_angle += direction * rotation_speed * INSTANCE_CUSTOM.y * rotation_lifetime;
|
||||
}
|
||||
if (billboard_mode != 0) {
|
||||
mat4 facing = mat4(normalize(INV_VIEW_MATRIX[0]), normalize(INV_VIEW_MATRIX[1]),
|
||||
normalize(INV_VIEW_MATRIX[2]), MODEL_MATRIX[3]);
|
||||
if (billboard_mode == 2) {
|
||||
facing = mat4(vec4(normalize(cross(vec3(0.0, 1.0, 0.0), MAIN_CAM_INV_VIEW_MATRIX[2].xyz)), 0.0),
|
||||
vec4(0.0, 1.0, 0.0, 0.0),
|
||||
vec4(normalize(cross(MAIN_CAM_INV_VIEW_MATRIX[0].xyz, vec3(0.0, 1.0, 0.0))), 0.0),
|
||||
MODEL_MATRIX[3]);
|
||||
} else if (billboard_mode == 5) {
|
||||
vec3 up = vec3(0.0, 1.0, 0.0);
|
||||
vec3 right = cross(up, MAIN_CAM_INV_VIEW_MATRIX[2].xyz);
|
||||
right = length(right) > 0.000001 ? normalize(right) : vec3(1.0, 0.0, 0.0);
|
||||
// Source Y billboard's non-zero rotation branch (including its 90 degree offset).
|
||||
float a = particle_angle;
|
||||
if (a != 0.0) {
|
||||
vec3 source_cross = -right;
|
||||
up = up * -sin(a) - source_cross * cos(a);
|
||||
right = -(source_cross * -sin(a) + vec3(0.0, 1.0, 0.0) * cos(a));
|
||||
}
|
||||
// Source Z rotation becomes world Y rotation after coordinate conversion.
|
||||
float c = cos(UV2.x), s = sin(UV2.x);
|
||||
mat3 turn = mat3(vec3(c, 0.0, -s), vec3(0.0, 1.0, 0.0), vec3(s, 0.0, c));
|
||||
right = turn * right;
|
||||
up = turn * up;
|
||||
facing = mat4(vec4(right, 0.0), vec4(up, 0.0), vec4(cross(right, up), 0.0), MODEL_MATRIX[3]);
|
||||
} else if (billboard_mode == 4) {
|
||||
// EffectLib LIE: source Up=(cos(a),-sin(a),0),
|
||||
// Cross=(sin(a),cos(a),0), converted from Z-up to Y-up.
|
||||
float a = particle_angle;
|
||||
facing = mat4(vec4(sin(a), 0.0, -cos(a), 0.0),
|
||||
vec4(cos(a), 0.0, sin(a), 0.0),
|
||||
vec4(0.0, 1.0, 0.0, 0.0), MODEL_MATRIX[3]);
|
||||
} else if (billboard_mode == 3) {
|
||||
float a = particle_angle;
|
||||
facing *= mat4(vec4(cos(a), -sin(a), 0.0, 0.0), vec4(sin(a), cos(a), 0.0, 0.0),
|
||||
vec4(0.0, 0.0, 1.0, 0.0), vec4(0.0, 0.0, 0.0, 1.0));
|
||||
}
|
||||
mat4 scale_matrix = mat4(vec4(length(MODEL_MATRIX[0].xyz), 0.0, 0.0, 0.0),
|
||||
vec4(0.0, length(MODEL_MATRIX[1].xyz), 0.0, 0.0),
|
||||
vec4(0.0, 0.0, length(MODEL_MATRIX[2].xyz), 0.0),
|
||||
vec4(0.0, 0.0, 0.0, 1.0));
|
||||
MODELVIEW_MATRIX = VIEW_MATRIX * facing * scale_matrix;
|
||||
MODELVIEW_NORMAL_MATRIX = mat3(MODELVIEW_MATRIX);
|
||||
}
|
||||
}
|
||||
void fragment() {
|
||||
vec4 texel = texture(albedo_texture, UV);
|
||||
vec3 result = texel.rgb * COLOR.rgb * color_gain;
|
||||
if (color_operation == 2) { result = COLOR.rgb; }
|
||||
else if (color_operation == 3) { result = texel.rgb; }
|
||||
else if (color_operation == 8) { result = COLOR.rgb + texel.rgb - vec3(0.5); }
|
||||
ALBEDO = clamp(result, vec3(0.0), vec3(1.0));
|
||||
ALPHA = texel.a * COLOR.a;
|
||||
}
|
||||
"""
|
||||
|
||||
const ANIMATION_UNIFORMS := """
|
||||
uniform sampler2DArray frame_textures : source_color, filter_linear, repeat_enable;
|
||||
uniform int frame_count = 1;
|
||||
uniform int animation_type = 0;
|
||||
uniform bool random_start = false;
|
||||
uniform float frame_delay = 0.05;
|
||||
uniform float particle_lifetime = 1.0;
|
||||
varying flat float texture_frame;
|
||||
float frame_random(float seed) { return fract(sin(seed * 127.1 + 311.7) * 43758.5453); }
|
||||
"""
|
||||
const ANIMATION_VERTEX := """
|
||||
float seed = INSTANCE_CUSTOM.z;
|
||||
float steps = frame_delay > 0.000001 ? max(ceil(INSTANCE_CUSTOM.y * particle_lifetime / frame_delay) - 1.0, 0.0) : 0.0;
|
||||
float count = float(frame_count);
|
||||
float direction = animation_type == 2 ? -1.0 : 1.0;
|
||||
if (animation_type == 4) { direction = seed < 0.5 ? 1.0 : -1.0; }
|
||||
float first = animation_type == 4 && direction < 0.0 ? count - 1.0 : 0.0;
|
||||
if (random_start) { first = floor(frame_random(seed + 1.0) * count); }
|
||||
texture_frame = first;
|
||||
if (animation_type != 0 && steps > 0.0) {
|
||||
texture_frame = animation_type == 3 ? floor(frame_random(seed + steps * 3.17) * count) : mod(first + direction * steps, count);
|
||||
}
|
||||
"""
|
||||
|
||||
static func build(base: StandardMaterial3D, operation: int, frames: Texture2DArray = null,
|
||||
properties: Dictionary = {}, lifetime := 1.0) -> Material:
|
||||
var lie := int(properties.get("BillboardType", -1)) == 3
|
||||
var multiface := int(properties.get("BillboardType", -1)) in [4, 5]
|
||||
var random_rotation := int(properties.get("RotationType", 0)) == 4
|
||||
if operation not in [2, 3, 5, 6, 8] and frames == null and not lie and not multiface and not random_rotation:
|
||||
return base
|
||||
var blend := "blend_add" if base.blend_mode == BaseMaterial3D.BLEND_MODE_ADD else "blend_mix"
|
||||
var key := blend + ("_animated" if frames else "")
|
||||
if not _shaders.has(key):
|
||||
var shader := Shader.new()
|
||||
var source := SOURCE.replace("__BLEND__", blend)
|
||||
if frames:
|
||||
source = source.replace("void vertex() {", ANIMATION_UNIFORMS + "\nvoid vertex() {" + ANIMATION_VERTEX)
|
||||
source = source.replace("texture(albedo_texture, UV)", "texture(frame_textures, vec3(UV, texture_frame))")
|
||||
shader.code = source
|
||||
_shaders[key] = shader
|
||||
var material := ShaderMaterial.new()
|
||||
material.shader = _shaders[key]
|
||||
material.set_shader_parameter("albedo_texture", base.albedo_texture)
|
||||
material.set_shader_parameter("color_operation", operation)
|
||||
material.set_shader_parameter("color_gain", 2.0 if operation == 5 else (4.0 if operation == 6 else 1.0))
|
||||
material.set_shader_parameter("billboard_mode", 5 if multiface else (4 if lie else int(base.billboard_mode)))
|
||||
if random_rotation and not lie and not multiface:
|
||||
material.set_shader_parameter("billboard_mode", 5 if int(properties.get("BillboardType", 1)) == 2 else 3)
|
||||
material.set_shader_parameter("random_rotation", random_rotation)
|
||||
material.set_shader_parameter("rotation_speed", deg_to_rad(float(properties.get("RotationSpeed", 0))))
|
||||
material.set_shader_parameter("rotation_lifetime", lifetime)
|
||||
if frames:
|
||||
material.set_shader_parameter("frame_textures", frames)
|
||||
material.set_shader_parameter("frame_count", frames.get_layers())
|
||||
material.set_shader_parameter("animation_type", int(properties.get("TexAniType", 0)))
|
||||
material.set_shader_parameter("random_start", bool(properties.get("TexAniRandomStartFrameFlag", 0)))
|
||||
material.set_shader_parameter("frame_delay", float(properties.get("TexAniDelay", 0.05)))
|
||||
material.set_shader_parameter("particle_lifetime", lifetime)
|
||||
return material
|
||||
@@ -0,0 +1 @@
|
||||
uid://cu4fhnm5bep65
|
||||
@@ -0,0 +1,31 @@
|
||||
# EffectLib TwoSideRenderer / ThreeSideRenderer angles, carried per face in UV2.
|
||||
# One particle simulation drives every face (no independent emitter randomness).
|
||||
extends RefCounted
|
||||
|
||||
static func build(size: Vector2, billboard: int) -> Mesh:
|
||||
if billboard not in [4, 5]:
|
||||
var quad := QuadMesh.new()
|
||||
quad.size = size
|
||||
return quad
|
||||
var angles := [-30.0, 30.0] if billboard == 4 else [0.0, -60.0, 60.0]
|
||||
var vertices := PackedVector3Array()
|
||||
var normals := PackedVector3Array()
|
||||
var uv := PackedVector2Array()
|
||||
var uv2 := PackedVector2Array()
|
||||
var corners := [Vector2(0, 0), Vector2(0, 1), Vector2(1, 1),
|
||||
Vector2(0, 0), Vector2(1, 1), Vector2(1, 0)]
|
||||
for angle in angles:
|
||||
for corner in corners:
|
||||
vertices.append(Vector3((corner.x - 0.5) * size.x, (0.5 - corner.y) * size.y, 0))
|
||||
normals.append(Vector3(0, 0, 1))
|
||||
uv.append(corner)
|
||||
uv2.append(Vector2(deg_to_rad(angle), 0))
|
||||
var arrays := []
|
||||
arrays.resize(Mesh.ARRAY_MAX)
|
||||
arrays[Mesh.ARRAY_VERTEX] = vertices
|
||||
arrays[Mesh.ARRAY_NORMAL] = normals
|
||||
arrays[Mesh.ARRAY_TEX_UV] = uv
|
||||
arrays[Mesh.ARRAY_TEX_UV2] = uv2
|
||||
var mesh := ArrayMesh.new()
|
||||
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||
return mesh
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2act25qwmh5q
|
||||
@@ -0,0 +1,43 @@
|
||||
# Target-following effects use actor-local offset. Snapshot effects use the
|
||||
# fly-target's world position and a world-aligned offset (40250 type 10).
|
||||
extends Node
|
||||
const CONVERSION := Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0))
|
||||
var target: WeakRef
|
||||
var offset := Vector3.ZERO
|
||||
func configure(node: Node3D, source_offset: Vector3, following: bool) -> bool:
|
||||
if not is_instance_valid(node) or not node.is_inside_tree(): return false
|
||||
target = weakref(node)
|
||||
offset = CONVERSION * source_offset
|
||||
process_priority = 100
|
||||
set_process(following)
|
||||
if following:
|
||||
_process(0.0)
|
||||
else:
|
||||
get_parent().global_position = fly_target_position(node) + offset
|
||||
return true
|
||||
static func fly_target_position(node: Node3D) -> Vector3:
|
||||
# The reference model-index 0 sphere is built from bone OBBs, not the
|
||||
# union of visible attachments or the rendered vertex AABB.
|
||||
var models := node.find_children("*", "Metin2Model", true, false)
|
||||
if node.has_method("get_fly_target_bounds"): models.push_front(node)
|
||||
for model in models:
|
||||
var result: Dictionary = model.get_fly_target_bounds()
|
||||
if result.get("valid", false):
|
||||
var body_bounds: AABB = result.bounds
|
||||
return model.to_global(body_bounds.get_center())
|
||||
# The reference returns the model bounding sphere centre, not its feet.
|
||||
var meshes := node.find_children("*", "MeshInstance3D", true, false)
|
||||
var bounds := AABB()
|
||||
var found := false
|
||||
for mesh in meshes:
|
||||
if mesh.mesh == null or not mesh.is_visible_in_tree(): continue
|
||||
var box: AABB = mesh.global_transform * mesh.get_aabb()
|
||||
bounds = bounds.merge(box) if found else box
|
||||
found = true
|
||||
return bounds.get_center() if found else node.global_position
|
||||
func _process(_delta: float) -> void:
|
||||
var node: Node3D = target.get_ref() if target else null
|
||||
if not is_instance_valid(node) or not node.is_inside_tree():
|
||||
get_parent().queue_free()
|
||||
return
|
||||
get_parent().global_transform = node.global_transform * Transform3D(Basis.IDENTITY, offset)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhnn4cqiuego2
|
||||
+2
-1
@@ -159,6 +159,7 @@ func _test_builder() -> void:
|
||||
"emitter 1 edge sphere uses surface emission")
|
||||
_ck(abs(pm1.spread - 180.0) < 0.01, "OUTER emitter uses radial-velocity fallback spread")
|
||||
_ck(fx._lights.size() == 1, "EffectPlayer built 1 OmniLight3D")
|
||||
fx.play()
|
||||
fx._process(0.11)
|
||||
_ck(fx._lights[0].visible, "SimpleLight becomes visible after start time")
|
||||
_ck(abs(fx._lights[0].omni_range - 0.06) < 0.02, "SimpleLight range follows TimeEventRange")
|
||||
@@ -171,7 +172,7 @@ func _test_builder() -> void:
|
||||
var bezier_mid: Vector3 = fx._position_at(moving_rows, 1.5)
|
||||
_ck(abs(direct_mid.x - 0.5) < 0.001 and abs(direct_mid.y) < 0.001,
|
||||
"MOVING_TYPE_DIRECT interpolates in metres")
|
||||
_ck(abs(bezier_mid.x - 1.25) < 0.001 and abs(bezier_mid.y - 0.5) < 0.001,
|
||||
_ck(abs(bezier_mid.x - 1.25) < 0.001 and abs(bezier_mid.z + 0.5) < 0.001 and abs(bezier_mid.y) < 0.001,
|
||||
"MOVING_TYPE_BEZIER_CURVE uses previous control point")
|
||||
fx.play()
|
||||
_ck(g0.emitting, "emitter 0 emitting after play() (start_time 0)")
|
||||
|
||||
+43
-1
@@ -303,6 +303,7 @@ func setup(m2client: Node, assets_root: String,
|
||||
)
|
||||
if net_play and net_play.has_signal("fishing_feedback") and net_play.has_signal("cannot_act"):
|
||||
net_play.fishing_feedback.connect(func(code: String): net_play.cannot_act.emit(code))
|
||||
quickbar.skill_cast_started.connect(_on_skill_cast_started)
|
||||
if quickbar.has_signal("skill_activated"):
|
||||
quickbar.skill_activated.connect(func(sid: int):
|
||||
if skill_fx and player:
|
||||
@@ -835,6 +836,32 @@ func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vecto
|
||||
9: # WARP —— 传送起点,不是投射物
|
||||
pass
|
||||
|
||||
var _motion_effect_target: WeakRef
|
||||
|
||||
func _on_skill_cast_started(_skill: int, target_vid: int) -> void:
|
||||
_motion_effect_target = null
|
||||
var target: Node3D = null
|
||||
if client and target_vid == int(client.get_main_vid()):
|
||||
target = player
|
||||
elif net_world and target_vid != 0:
|
||||
target = net_world.node_for(target_vid)
|
||||
if is_instance_valid(target):
|
||||
_motion_effect_target = weakref(target)
|
||||
|
||||
func _on_local_motion_event_detailed(event: Dictionary) -> void:
|
||||
# Keep sound/projectile dispatch exactly once, independent of visual routing.
|
||||
if int(event.type) == 1 and String(event.effect) != "":
|
||||
_on_local_motion_event(int(event.type), "", String(event.sound), event.pos)
|
||||
if fx and is_instance_valid(player):
|
||||
fx.spawn_motion(event, player, _mount)
|
||||
elif int(event.type) == 10:
|
||||
_on_local_motion_event(int(event.type), "", String(event.sound), event.pos)
|
||||
var target: Node3D = _motion_effect_target.get_ref() if _motion_effect_target else null
|
||||
if fx and is_instance_valid(target):
|
||||
fx.spawn_target(event, target, _mount)
|
||||
else:
|
||||
_on_local_motion_event(int(event.type), String(event.effect), String(event.sound), event.pos)
|
||||
|
||||
# 一次弓挥击 / 弓技能起手 → 压一个待发 uSkill(对齐 AutoClear handler 的 Set())。
|
||||
# 满了丢最旧的:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时不至无界增长。§3.6
|
||||
func _queue_shot(skill: int) -> void:
|
||||
@@ -936,6 +963,7 @@ func _on_warp(pos: Vector3, same_server: bool) -> void:
|
||||
_place_player_at_net_pos(pos)
|
||||
|
||||
func _on_world_reset() -> void:
|
||||
_motion_effect_target = null
|
||||
_main_generation += 1
|
||||
_main_view_key = ""
|
||||
_main_attempts = 0
|
||||
@@ -1249,6 +1277,18 @@ func set_entity_model_factory(factory: Callable) -> void:
|
||||
if net_world:
|
||||
net_world.set_model_factory(factory)
|
||||
|
||||
# Borrowed-node adapter for the packaged playable test. It is only exposed
|
||||
# from an explicit test mode; the returned nodes must never be serialized or
|
||||
# retained across _on_world_reset / AppFlow reconnect.
|
||||
func get_playable_context() -> Dictionary:
|
||||
var test_mode := OS.get_environment("MT_TEST_MODE")
|
||||
if test_mode != "playable" and test_mode != "forest_render":
|
||||
return {}
|
||||
return {"scene_ready": _model_built and _map_loaded(), "map_path": map_path,
|
||||
"net_play": net_play, "net_world": net_world, "world": world,
|
||||
"player": player, "pc": pc, "hud": hud, "quickbar": quickbar,
|
||||
"ground_items": ground_items, "skill_fx": skill_fx}
|
||||
|
||||
var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试)
|
||||
var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性)
|
||||
|
||||
@@ -1518,7 +1558,9 @@ func _on_main_set(vid: int) -> void:
|
||||
_model_built = true
|
||||
if net_play:
|
||||
net_play.player_view = pv
|
||||
if pv.anim and pv.anim.has_signal("motion_event"):
|
||||
if pv.anim and pv.anim.has_signal("motion_event_detailed"):
|
||||
pv.anim.motion_event_detailed.connect(_on_local_motion_event_detailed)
|
||||
elif pv.anim and pv.anim.has_signal("motion_event"):
|
||||
pv.anim.motion_event.connect(_on_local_motion_event)
|
||||
if pc and pc.has_signal("anim_state") and not pc.anim_state.is_connected(_on_main_anim_state):
|
||||
pc.anim_state.connect(_on_main_anim_state)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
extends SceneTree
|
||||
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
var failures: Array[String] = []
|
||||
var ghosts_checked := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failures.append(message)
|
||||
printerr("FAIL: ", message)
|
||||
|
||||
func run() -> void:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", "1")
|
||||
var cam := Camera3D.new()
|
||||
root.add_child(cam)
|
||||
cam.make_current()
|
||||
for race in 8:
|
||||
cam.position = Vector3(0, 1, 3)
|
||||
var pv := PlayerView.new()
|
||||
root.add_child(pv)
|
||||
if not pv.build(AssetRoot.path(), race):
|
||||
check(false, "race %d build failed" % race)
|
||||
pv.queue_free()
|
||||
continue
|
||||
pv.anim.set_process(false)
|
||||
pv.anim.set("blend_time", 0.0)
|
||||
pv.anim.set("time", 0.5)
|
||||
var mesh_node: MeshInstance3D = pv.model.get_node("MeshInstance3D")
|
||||
var reference_hair: PackedVector3Array = mesh_node.mesh.surface_get_arrays(mesh_node.mesh.get_surface_count() - 1)[Mesh.ARRAY_VERTEX]
|
||||
for level in [0, 1, 2, 3, 2, 1, 0]:
|
||||
cam.position = Vector3(0, 1, [3.0, 25.0, 60.0, 110.0][level])
|
||||
for frame in 3:
|
||||
await process_frame
|
||||
check(int(pv.model.call("get_lod_level")) == level, "race %d did not reach LOD %d" % [race, level])
|
||||
var mesh := mesh_node.mesh
|
||||
var last := mesh.get_surface_count() - 1
|
||||
check(String(mesh.surface_get_name(last)).begins_with("hair_"), "race %d LOD %d lost hair" % [race, level])
|
||||
var vertices: PackedVector3Array = mesh.surface_get_arrays(last)[Mesh.ARRAY_VERTEX]
|
||||
check(vertices == reference_hair, "race %d LOD %d changed hair geometry" % [race, level])
|
||||
var mat: ShaderMaterial = mesh_node.get_active_material(last)
|
||||
check(int(mat.get_shader_parameter("mode")) == 2, "race %d LOD %d lost hair cutout" % [race, level])
|
||||
check(mat.get_shader_parameter("bones_tex") != null, "race %d LOD %d lost skin matrices" % [race, level])
|
||||
var ghost: MeshInstance3D = pv.model.get_node_or_null("LodGhost")
|
||||
if ghost != null:
|
||||
ghosts_checked += 1
|
||||
var old_mat: ShaderMaterial = ghost.get_active_material(0)
|
||||
var frozen: Texture2D = old_mat.get_shader_parameter("bones_tex")
|
||||
check(frozen != mat.get_shader_parameter("bones_tex"), "LOD ghost shares live bone texture")
|
||||
var before := frozen.get_image().get_data()
|
||||
pv.anim.set("time", 1.0)
|
||||
check(before == frozen.get_image().get_data(), "LOD ghost pose changed after animation sample")
|
||||
check(ghost.custom_aabb.size.length_squared() > 0, "LOD ghost lost posed culling bounds")
|
||||
for surface in ghost.mesh.get_surface_count():
|
||||
check(ghost.get_active_material(surface) != null, "LOD ghost lost surface material")
|
||||
pv.anim.set("time", 0.5)
|
||||
pv.queue_free()
|
||||
await process_frame
|
||||
check(ghosts_checked > 0, "No LOD fade snapshots were exercised")
|
||||
print("gpu_lod_attachment_test: failures=", failures.size(), " ghosts=", ghosts_checked)
|
||||
quit(0 if failures.is_empty() else 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://huxe52dmjvo6
|
||||
@@ -0,0 +1,46 @@
|
||||
extends SceneTree
|
||||
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
var failures := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func run() -> void:
|
||||
for race in 8:
|
||||
var views: Array = []
|
||||
for mode in ["0", "1"]:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", mode)
|
||||
var pv := PlayerView.new()
|
||||
root.add_child(pv)
|
||||
if not pv.build(AssetRoot.path(), race):
|
||||
printerr("FAIL: build race ", race)
|
||||
quit(1)
|
||||
return
|
||||
pv.model.set("lod_enabled", false)
|
||||
pv.anim.set_process(false)
|
||||
pv.anim.set("blend_time", 0.0)
|
||||
views.append(pv)
|
||||
for motion in ["wait", "walk", "run", "attack", "damage", "dead"]:
|
||||
for i in 2:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", str(i))
|
||||
views[i].set_anim_state(motion)
|
||||
views[i].anim.set("loop", false)
|
||||
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
for i in 2:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", str(i))
|
||||
views[i].anim.set("time", float(views[i].anim.call("get_duration")) * fraction)
|
||||
var cpu: MeshInstance3D = views[0].model.get_node("MeshInstance3D")
|
||||
var gpu: MeshInstance3D = views[1].model.get_node("MeshInstance3D")
|
||||
# Read the actual culling box without invoking get_visual_aabb(),
|
||||
# which refreshes it on demand and would hide the stale-box bug.
|
||||
var expected: AABB = cpu.get_aabb()
|
||||
var actual: AABB = gpu.custom_aabb
|
||||
if not actual.position.is_finite() or not actual.size.is_finite() or not actual.grow(0.02).encloses(expected):
|
||||
failures += 1
|
||||
printerr("FAIL: GPU culling excludes CPU pose: race=%d motion=%s time=%s" % [race, motion, fraction])
|
||||
for pv in views:
|
||||
pv.queue_free()
|
||||
await process_frame
|
||||
print("gpu_pose_bounds_test: failures=", failures, " (8 races x 6 motions x 5 samples)")
|
||||
quit(0 if failures == 0 else 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d4asr8c7kwoep
|
||||
@@ -103,6 +103,12 @@ func _process(_delta: float) -> void:
|
||||
"settle_second":
|
||||
if _seconds_in_state(now) >= SETTLE_SECONDS:
|
||||
_check(_client != null and _client.is_in_game(), "重连后客户端仍处于游戏状态")
|
||||
var vid := int(_client.get_main_vid())
|
||||
_check(vid != 0 and not _client.get_entity(vid).is_empty(), "重连后主角重新建立")
|
||||
_check(not _client.get_points().is_empty() and _has_method_and_array("get_skills")
|
||||
and _has_method_and_array("get_quickslots"), "重连后属性/技能/快捷栏可读取")
|
||||
var scene: Node = _flow.get("_game")
|
||||
_check(is_instance_valid(scene) and bool(scene.get("_model_built")), "重连后真实角色模型建立")
|
||||
_finish_later()
|
||||
"waiting_login", "waiting_first_game":
|
||||
if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS:
|
||||
@@ -189,7 +195,7 @@ func _run_world_checks() -> void:
|
||||
_fail("主角色缺少 pos_cm")
|
||||
|
||||
if not _mutations:
|
||||
_finish_later()
|
||||
_start_reconnect()
|
||||
return
|
||||
_send_move()
|
||||
|
||||
@@ -215,6 +221,9 @@ func _send_stop() -> void:
|
||||
func _send_chat() -> void:
|
||||
var ok := bool(_client.say(CHAT_TALKING, "[smoke] client live smoke test"))
|
||||
_check(ok, "发送普通聊天包")
|
||||
_start_reconnect()
|
||||
|
||||
func _start_reconnect() -> void:
|
||||
if not _reconnect:
|
||||
_finish_later()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
extends SceneTree
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
var failures := 0
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
for mode in ["0", "1"]:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", mode)
|
||||
for race in [101, 110, 20001]:
|
||||
var view := MobView.new()
|
||||
if not view.build(AssetRoot.path(), null, race):
|
||||
failures += 1
|
||||
view.free()
|
||||
continue
|
||||
root.add_child(view)
|
||||
view.anim.set_process(false)
|
||||
view.anim.set_time(0.3)
|
||||
var signed_area := 0.0
|
||||
var total_area := 0.0
|
||||
for instance in view.model.find_children("*", "MeshInstance3D", true, false):
|
||||
for surface in instance.mesh.get_surface_count():
|
||||
var arrays: Array = instance.mesh.surface_get_arrays(surface)
|
||||
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var normals: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL]
|
||||
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
|
||||
for i in range(0, indices.size(), 3):
|
||||
var a := indices[i]
|
||||
var b := indices[i + 1]
|
||||
var c := indices[i + 2]
|
||||
var cross := (vertices[b] - vertices[a]).cross(vertices[c] - vertices[a])
|
||||
signed_area += cross.dot((normals[a] + normals[b] + normals[c]).normalized())
|
||||
total_area += cross.length()
|
||||
var ratio := signed_area / maxf(total_area, 0.00001)
|
||||
print("MOB winding race=%d GPU=%s normal_alignment=%f" % [race, mode, ratio])
|
||||
# Godot's outward front face is clockwise; geometric cross is -normal.
|
||||
if ratio >= -0.5:
|
||||
failures += 1
|
||||
printerr("FAIL: NPC/monster faces disagree with outward normals")
|
||||
view.free()
|
||||
await process_frame
|
||||
print("mob_winding_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dai84hb3pgfqd
|
||||
@@ -63,6 +63,10 @@ func _arg(name: String, dflt := "") -> String:
|
||||
func run() -> void:
|
||||
root.size = Vector2i(SIZE, SIZE)
|
||||
_bless = "--bless" in OS.get_cmdline_user_args()
|
||||
if _bless and ("--legacy-winding" in OS.get_cmdline_user_args() or not _arg("race").is_empty()):
|
||||
printerr("Baseline updates require all eight production races; remove diagnostic/race filters.")
|
||||
quit(1)
|
||||
return
|
||||
_out = OS.get_environment("MT_RENDER_OUTPUT")
|
||||
if _out.is_empty():
|
||||
_out = ProjectSettings.globalize_path("res://../build/rendering/modeltest-%d"
|
||||
@@ -116,7 +120,7 @@ func run() -> void:
|
||||
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify(report, "\t"))
|
||||
f.close()
|
||||
if _bless:
|
||||
if _bless and _fail.is_empty():
|
||||
_write_baseline(results)
|
||||
print("MODELRENDER: baseline written -> ", ProjectSettings.globalize_path(BASELINE))
|
||||
print("MODELRENDER: ", _out)
|
||||
@@ -135,9 +139,13 @@ func _measure(race: int, cam: Camera3D) -> Dictionary:
|
||||
pv.queue_free()
|
||||
return {}
|
||||
pv.set_anim_state("wait")
|
||||
# Reproduce the pre-merge CPU index order to audit historical baselines.
|
||||
if "--legacy-winding" in OS.get_cmdline_user_args():
|
||||
pv.model.set("flip_winding", false)
|
||||
await process_frame
|
||||
await process_frame
|
||||
if pv.anim:
|
||||
pv.anim.set("blend_time", 0.0)
|
||||
pv.anim.call("set_time", 0.5)
|
||||
pv.anim.set_process(false)
|
||||
pv.rotation = Vector3.ZERO
|
||||
@@ -163,6 +171,8 @@ func _measure(race: int, cam: Camera3D) -> Dictionary:
|
||||
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
|
||||
|
||||
var m := {
|
||||
"body_sha256": FileAccess.get_sha256(String(pv.model.get("gr2_path"))),
|
||||
"hair_sha256": FileAccess.get_sha256(String(pv.model.get("hair_gr2"))),
|
||||
"body_gr2": String(pv.model.get("gr2_path")),
|
||||
"hair_gr2": String(pv.model.get("hair_gr2")),
|
||||
"surfaces": total, "hair_surface_first": hair_first,
|
||||
@@ -237,6 +247,9 @@ func _check_baseline(race: int, m: Dictionary, baseline: Dictionary) -> void:
|
||||
_fail.append("race %d: no baseline entry (run with --bless after reviewing the render)" % race)
|
||||
return
|
||||
var b: Dictionary = baseline[key]
|
||||
for field in ["body_sha256", "hair_sha256"]:
|
||||
if b.has(field) and b[field] != m[field]:
|
||||
_fail.append("race %d: %s differs from reviewed asset baseline" % [race, field])
|
||||
var cover_delta: float = absf(float(m["head_cover"]) - float(b.get("head_cover", 0.0)))
|
||||
if cover_delta > TOL_HEAD_COVER:
|
||||
_fail.append("race %d: head_cover %.4f vs baseline %.4f (delta %.4f > %.3f)"
|
||||
@@ -408,7 +421,8 @@ func _write_baseline(results: Dictionary) -> void:
|
||||
var slim := {}
|
||||
for k in results:
|
||||
var m: Dictionary = results[k]
|
||||
slim[k] = {"body_gr2": String(m["body_gr2"]).get_file(),
|
||||
slim[k] = {"body_sha256": m["body_sha256"], "hair_sha256": m["hair_sha256"],
|
||||
"body_gr2": String(m["body_gr2"]).get_file(),
|
||||
"hair_gr2": String(m["hair_gr2"]).get_file(),
|
||||
"head_cover": m["head_cover"], "silhouette_px": m["silhouette_px"],
|
||||
"head_px": m["head_px"], "hair_mean_lum": m["hair_mean_lum"],
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
extends SceneTree
|
||||
const Anchor = preload("res://fx/motion_effect_anchor.gd")
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var view := PlayerView.new()
|
||||
check(view.build(AssetRoot.path(), 0), "real warrior builds")
|
||||
root.add_child(view)
|
||||
view.position = Vector3(7, 2, -4)
|
||||
view.rotation.y = 0.7
|
||||
view.anim.set_process(false)
|
||||
view.anim.set_time(0.2)
|
||||
var effect := Node3D.new()
|
||||
root.add_child(effect)
|
||||
var anchor := Anchor.new()
|
||||
effect.add_child(anchor)
|
||||
var event := {"attaching": true, "following": true, "independent": false,
|
||||
"bone": "equip_right_hand", "pos": Vector3(10, 20, 30)}
|
||||
check(anchor.configure(view, event), "real equip_right_hand resolves")
|
||||
check(anchor.is_processing(), "following bone updates")
|
||||
var pose: Dictionary = view.anim.get_effect_bone_pose(event.bone)
|
||||
if not pose.is_empty():
|
||||
var expected: Vector3 = view.model.global_transform * (pose.transform.origin + event.pos)
|
||||
check(effect.global_position.distance_to(expected) < 0.00001, "bone + actor-local cm offset, then actor rotation")
|
||||
var before := effect.global_transform
|
||||
view.anim.set_time(1.0)
|
||||
anchor.update_anchor()
|
||||
check(not effect.global_transform.is_equal_approx(before), "anchor uses sampled animation pose")
|
||||
event.following = false
|
||||
check(anchor.configure(view, event) and not anchor.is_processing(), "snapshot bone does not follow")
|
||||
before = effect.global_transform
|
||||
view.position.x += 3.0
|
||||
await process_frame
|
||||
check(effect.global_transform.is_equal_approx(before), "snapshot remains in world after actor moves")
|
||||
event.independent = true
|
||||
event.bone = "missing"
|
||||
check(anchor.configure(view, event), "independent ignores bone")
|
||||
check(effect.global_position.distance_to(view.model.global_transform * event.pos) < 0.00001,
|
||||
"independent offset converts source Z to world up")
|
||||
event.independent = false
|
||||
check(not anchor.configure(view, event), "missing bone rejected")
|
||||
event.attaching = false
|
||||
check(anchor.configure(view, event) and anchor.is_processing(), "root attachment follows regardless of FollowingEnable")
|
||||
# Metadata from the real file must survive the file-reader parser and native API.
|
||||
view.anim.set_anim_path(AssetRoot.path().path_join("PC/ymir work/pc/warrior/skill/gigongcham.msa"))
|
||||
var events: Array = view.anim.get_events()
|
||||
check(not events.is_empty(), "real skill events loaded")
|
||||
if not events.is_empty():
|
||||
check(events[0].attaching and events[0].bone == "Bip01 Spine1", "file parser retains bone metadata")
|
||||
check(String(events[0].effect).contains("/"), "full effect path retained")
|
||||
effect.free()
|
||||
view.free()
|
||||
await process_frame
|
||||
print("motion_effect_anchor_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmqy41vlghvjy
|
||||
@@ -0,0 +1,107 @@
|
||||
extends SceneTree
|
||||
var failures: Array[String] = []
|
||||
var report := {"species": [], "maps": [], "scope": "native static tree geometry/materials; not SpeedTree wind/LOD parity"}
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void: call_deferred("run")
|
||||
func run() -> void:
|
||||
var assets := AssetRoot.path()
|
||||
var directory := assets.path_join("TreeGeometry")
|
||||
var manifest: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(directory.path_join("manifest.json")))
|
||||
check(manifest.trees.size() == 87, "all 87 extracted species staged")
|
||||
var sample_mesh: ArrayMesh
|
||||
for hash in manifest.trees:
|
||||
var record: Dictionary = manifest.trees[hash]
|
||||
var path := directory.path_join(record.glb)
|
||||
check(FileAccess.get_sha256(assets.path_join(record.source)) == hash, "SPT provenance " + record.source)
|
||||
check(FileAccess.get_sha256(path) == record.glb_sha256, "GLB provenance " + record.source)
|
||||
var state := GLTFState.new()
|
||||
var doc := GLTFDocument.new()
|
||||
check(doc.append_from_file(path, state) == OK, "GLB loads " + record.source)
|
||||
if state.get_meshes().size() != 1:
|
||||
check(false, "one native combined mesh")
|
||||
continue
|
||||
var mesh: ArrayMesh = state.get_meshes()[0].get_mesh().get_mesh()
|
||||
check(mesh.get_surface_count() == int(record.primitives), "all branch/frond/leaf surfaces retained")
|
||||
var masked := 0
|
||||
var vertices := 0
|
||||
for surface in mesh.get_surface_count():
|
||||
var arrays := mesh.surface_get_arrays(surface)
|
||||
vertices += arrays[Mesh.ARRAY_VERTEX].size()
|
||||
var material: StandardMaterial3D = mesh.surface_get_material(surface)
|
||||
check(material != null and material.albedo_texture != null, "real texture retained")
|
||||
if material and material.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR:
|
||||
masked += 1
|
||||
check(material.cull_mode == BaseMaterial3D.CULL_DISABLED, "leaf cards double-sided")
|
||||
check(is_equal_approx(material.alpha_scissor_threshold, 0.3), "native alpha cutoff retained")
|
||||
check(vertices > 0 and mesh.get_aabb().size.is_finite(), "finite native geometry")
|
||||
report.species.append({"source": record.source, "vertices": vertices, "surfaces": mesh.get_surface_count(), "masked": masked, "height_m": mesh.get_aabb().size.y})
|
||||
if String(record.source).ends_with("b1_beech_rt2.spt"): sample_mesh = mesh
|
||||
for map in ["OutdoorA1/metin2_map_a1", "OutdoorA3/metin2_map_a3", "OutdoorB1/metin2_map_b1",
|
||||
"OutdoorB3/metin2_map_b3", "OutdoorC1/metin2_map_c1", "OutdoorC3/metin2_map_c3"]:
|
||||
var world := Metin2World.new()
|
||||
world.auto_load = false
|
||||
world.env_enabled = false
|
||||
world.assets_root = assets
|
||||
world.map_path = map
|
||||
root.add_child(world)
|
||||
check(world.load_map(), "map loads " + map)
|
||||
var stats: Dictionary = world.get_load_report()
|
||||
check(stats.native_trees_placed > 0 and stats.proxy_trees_placed == 0, "all placed trees native " + map)
|
||||
check(stats.native_trees_placed == stats.trees_placed, "tree placement count preserved")
|
||||
for node in world.find_children("*", "MultiMeshInstance3D", true, false):
|
||||
if node.has_meta("tree_geometry"):
|
||||
check(node.get_meta("tree_geometry") == "native_spt", "renderer uses native path")
|
||||
check(node.multimesh.mesh.get_meta("source_sha256", "") != "", "mesh provenance retained")
|
||||
report.maps.append({"map": map, "native": stats.native_trees_placed, "proxy": stats.proxy_trees_placed})
|
||||
world.free()
|
||||
await process_frame
|
||||
var output := OS.get_environment("MT_TREE_OUTPUT")
|
||||
if output == "": output = ProjectSettings.globalize_path("res://../build/rendering/native-trees")
|
||||
DirAccess.make_dir_recursive_absolute(output)
|
||||
if sample_mesh and DisplayServer.get_name() != "headless": await render_sample(sample_mesh, output)
|
||||
report["failures"] = failures
|
||||
FileAccess.open(output.path_join("report.json"), FileAccess.WRITE).store_string(JSON.stringify(report, "\t"))
|
||||
print("native_tree_test: failures=%d report=%s" % [failures.size(), output])
|
||||
quit(1 if not failures.is_empty() else 0)
|
||||
func render_sample(mesh: ArrayMesh, output: String) -> void:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(640, 640)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var environment := WorldEnvironment.new()
|
||||
environment.environment = Environment.new()
|
||||
environment.environment.background_mode = Environment.BG_COLOR
|
||||
environment.environment.background_color = Color(0.12, 0.16, 0.23)
|
||||
environment.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
environment.environment.ambient_light_color = Color.WHITE
|
||||
environment.environment.ambient_light_energy = 0.8
|
||||
viewport.add_child(environment)
|
||||
var instance := MeshInstance3D.new()
|
||||
instance.mesh = mesh
|
||||
viewport.add_child(instance)
|
||||
var light := DirectionalLight3D.new()
|
||||
light.rotation_degrees = Vector3(-40, -30, 0)
|
||||
viewport.add_child(light)
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 28
|
||||
viewport.add_child(camera)
|
||||
for angle in [0.0, PI / 2]:
|
||||
camera.position = Vector3(sin(angle) * 35, 12, cos(angle) * 35)
|
||||
camera.look_at(Vector3(0, 10, 0))
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var image := viewport.get_texture().get_image()
|
||||
var background := image.get_pixel(0, 0)
|
||||
var visible := 0
|
||||
for y in 640:
|
||||
for x in 640:
|
||||
if image.get_pixel(x, y) != background: visible += 1
|
||||
check(visible > 5000 and visible < 200000, "native tree silhouette is visible without opaque atlas rectangle")
|
||||
check(image.save_png(output.path_join("beech-%d.png" % int(rad_to_deg(angle)))) == OK, "native tree screenshot")
|
||||
viewport.free()
|
||||
await process_frame
|
||||
@@ -0,0 +1 @@
|
||||
uid://brr0ihehpik0y
|
||||
@@ -1268,6 +1268,16 @@ func _change_target_to_picked_instance(vid: int) -> bool:
|
||||
_refresh_target_hud()
|
||||
return true
|
||||
|
||||
# Public test/automation adapter for the same target path used by a real
|
||||
# pointer click. It keeps all combat eligibility checks and sends the normal
|
||||
# CG_TARGET through _change_target_to_picked_instance; it does not synthesize
|
||||
# a hit or alter EntityStore state.
|
||||
func select_target(vid: int) -> bool:
|
||||
if _observer_mode or client == null or not client.has_method("is_in_game") or not client.is_in_game():
|
||||
return false
|
||||
_attack_cd = 0.0
|
||||
return _change_target_to_picked_instance(vid)
|
||||
|
||||
# --- §3.4 扇形 / 圆形多目标 fly-targeting(PythonPlayerSkill.cpp:680-746)----------
|
||||
|
||||
# 主角 → 某点的水平方位角(度)。扇形过滤 / 随机点抖动只比较角差,与坐标系无关,
|
||||
|
||||
@@ -49,6 +49,7 @@ func _run() -> void:
|
||||
_fail.append("资源缺失:%s" % p)
|
||||
|
||||
if _fail.is_empty():
|
||||
report["effects"] = await _check_effect_resources(root)
|
||||
report["scene"] = await _build_and_measure()
|
||||
else:
|
||||
report["scene"] = {"skipped": "前置检查已失败,不再装场景"}
|
||||
@@ -65,6 +66,34 @@ func _run() -> void:
|
||||
print("PKGRENDER: ", "PASS" if _fail.is_empty() else "FAIL")
|
||||
get_tree().quit(0 if _fail.is_empty() else 1)
|
||||
|
||||
func _check_effect_resources(assets: String) -> Dictionary:
|
||||
var dds_path := assets.path_join("PC/ymir work/pc/shaman/effect/shamanblackcore.dds")
|
||||
var decoded := preload("res://ui/ui_assets.gd").load_dds_image(dds_path)
|
||||
var dds_ok := decoded != null and not decoded.is_empty()
|
||||
if not dds_ok:
|
||||
_fail.append("包内 16 位技能 DDS 解码失败")
|
||||
var mse_path := assets.path_join("PC/ymir work/pc/shaman/effect/noejeon_4_blow.mse")
|
||||
var spec := preload("res://fx/mse.gd").new().parse_file(mse_path)
|
||||
var effect := preload("res://fx/effect_player.gd").new()
|
||||
effect.build(spec, assets)
|
||||
add_child(effect)
|
||||
# ParticleProcessMaterial shader variants are compiled on a deferred frame.
|
||||
# Exercise a real lifecycle instead of freeing uninitialized GPU emitters.
|
||||
await get_tree().process_frame
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await RenderingServer.frame_post_draw
|
||||
var special_materials := 0
|
||||
for emitter in effect._emitters:
|
||||
if emitter.material_override is ShaderMaterial:
|
||||
special_materials += 1
|
||||
if special_materials == 0:
|
||||
_fail.append("包内真实技能未装配专用颜色材质")
|
||||
effect.free()
|
||||
await get_tree().process_frame
|
||||
return {"dds_decoded": dds_ok, "special_materials": special_materials,
|
||||
"dds_sha256": FileAccess.get_sha256(dds_path), "mse_sha256": FileAccess.get_sha256(mse_path),
|
||||
"scope": "bundled DDS decode and real MSE material assembly; not skill pixels"}
|
||||
|
||||
# 包内和开发树的差异几乎都藏在这几个值里,先无条件记下来。
|
||||
func _env() -> Dictionary:
|
||||
var cands := {}
|
||||
@@ -142,6 +171,10 @@ func _build_and_measure() -> Dictionary:
|
||||
_fail.append("地图没装出来(灰底虚空)")
|
||||
if surfaces == 0:
|
||||
_fail.append("玩家节点下 0 个 surface")
|
||||
if scene.world:
|
||||
var trees: Dictionary = scene.world.get_load_report()
|
||||
if int(trees.get("native_trees_placed", 0)) <= 0 or int(trees.get("proxy_trees_placed", -1)) != 0:
|
||||
_fail.append("真实树木缺失或仍有代理回退:%s" % trees)
|
||||
if not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX:
|
||||
_fail.append("feet_gap=%s 超出 %.3f" % [feet_gap, FEET_GAP_MAX])
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Export-template reproducer: no maps, models, assets or network required.
|
||||
extends Node
|
||||
var held: Array[ParticleProcessMaterial] = []
|
||||
func _ready() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var mode := OS.get_environment("MT_PARTICLE_EXIT_CASE")
|
||||
for i in 8:
|
||||
make_particle(mode, i)
|
||||
for i in 5: await get_tree().process_frame
|
||||
held.clear()
|
||||
for i in 5: await get_tree().process_frame
|
||||
print("PARTICLE_EXIT PASS case=", mode)
|
||||
get_tree().quit()
|
||||
func make_particle(mode: String, index: int) -> void:
|
||||
if mode == "empty": return
|
||||
var material := ParticleProcessMaterial.new()
|
||||
if mode in ["gravity", "scale", "shape", "held", "restored"]:
|
||||
if mode == "gravity": material.gravity = Vector3(0, -1, 0)
|
||||
if mode == "scale": material.scale_min = 0.5
|
||||
if mode in ["shape", "held", "restored"]: material.emission_shape = index % 3
|
||||
material.get_rid()
|
||||
if mode == "restored":
|
||||
material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT
|
||||
material.get_rid()
|
||||
if mode == "held": held.append(material)
|
||||
return
|
||||
if mode == "primed": material.get_rid()
|
||||
if mode != "default":
|
||||
material.gravity = Vector3(0, -1, 0)
|
||||
material.emission_shape = index % 3
|
||||
material.scale_min = 0.5
|
||||
material.get_rid()
|
||||
if mode == "emitter":
|
||||
var emitter := GPUParticles3D.new()
|
||||
emitter.emitting = false
|
||||
emitter.process_material = material
|
||||
add_child(emitter)
|
||||
emitter.free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://d16lqtrehlaet
|
||||
@@ -0,0 +1,56 @@
|
||||
extends SceneTree
|
||||
|
||||
const NetPlay = preload("res://net_play.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal entity_main_set(vid: int)
|
||||
signal entity_moved(vid: int)
|
||||
signal points_changed(points: Dictionary)
|
||||
signal vitals_changed(vid: int)
|
||||
signal target_info(vid: int, hp_percent: int)
|
||||
signal entity_despawned(vid: int)
|
||||
signal entity_dead(vid: int)
|
||||
var in_game := false
|
||||
var target := -1
|
||||
var entities := {}
|
||||
func is_in_game() -> bool: return in_game
|
||||
func get_entity(vid: int) -> Dictionary: return entities.get(vid, {})
|
||||
func set_target(vid: int) -> bool:
|
||||
target = vid
|
||||
return true
|
||||
|
||||
class FakePlayerController extends Node:
|
||||
signal target_selected(node: Node3D)
|
||||
signal moved(pos: Vector3)
|
||||
signal anim_state(state: String)
|
||||
var player: Node3D
|
||||
|
||||
var failures := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func run() -> void:
|
||||
var client := FakeClient.new()
|
||||
var pc := FakePlayerController.new()
|
||||
var nw := Node.new()
|
||||
root.add_child(client)
|
||||
root.add_child(pc)
|
||||
root.add_child(nw)
|
||||
var play := NetPlay.new()
|
||||
root.add_child(play)
|
||||
play.setup(client, pc, nw)
|
||||
client.entities[10] = {"vid": 10, "kind": 2, "race": 2301, "dead": false, "hp": 100, "max_hp": 100}
|
||||
check(not play.select_target(10), "target selection is blocked while disconnected")
|
||||
client.in_game = true
|
||||
check(play.select_target(10), "target selection uses production target path")
|
||||
check(client.target == 10, "target selection sends the selected VID")
|
||||
check(not play.select_target(999), "missing target is rejected")
|
||||
check(client.target == 0, "invalid target clears server target")
|
||||
print("playable_adapter_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1,14 @@
|
||||
extends SceneTree
|
||||
|
||||
const Config = preload("res://testing/playable_config.gd")
|
||||
|
||||
func _init() -> void:
|
||||
var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG")
|
||||
var result := Config.load_file(path)
|
||||
if not result.ok:
|
||||
for error in result.errors:
|
||||
printerr("CONFIG: " + String(error))
|
||||
quit(2)
|
||||
return
|
||||
print("PLAYABLE CONFIG: PASS " + path)
|
||||
quit(0)
|
||||
@@ -0,0 +1,50 @@
|
||||
extends SceneTree
|
||||
|
||||
const Config = preload("res://testing/playable_config.gd")
|
||||
const Report = preload("res://testing/playable_report.gd")
|
||||
|
||||
var failures := 0
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func run() -> void:
|
||||
var example_path := ProjectSettings.globalize_path("res://../test/playable/scenario.example.json")
|
||||
var example := Config.load_file(example_path)
|
||||
check(not example.ok, "empty example configuration is rejected")
|
||||
check(example.errors.size() > 0, "invalid configuration has reasons")
|
||||
var valid := {
|
||||
"schema_version": 1, "scenario_id": "harness", "protocol": "classic",
|
||||
"server": {"server_index": 0, "channel_index": 0}, "character_slot": 0,
|
||||
"map_key": "OutdoorA1/metin2_map_a1", "waypoints_cm": [[454600, 934000]],
|
||||
"allowed_mob_vnums": [101], "allowed_drop_vnums": [19],
|
||||
"skill_cases": [{"case_id": "skill-1", "skill_id": 1, "required_evidence": ["damage"]}],
|
||||
"resolution": [1280, 720], "loops": 1, "timeout_seconds": 120,
|
||||
}
|
||||
check(Config.validate(valid).ok, "complete configuration is accepted")
|
||||
var secret := valid.duplicate(true)
|
||||
secret["password"] = "must-be-rejected"
|
||||
check(not Config.validate(secret).ok, "credentials in configuration are rejected")
|
||||
|
||||
var report := Report.new()
|
||||
report.begin("harness-run", "playable", valid)
|
||||
report.add_case("CONFIG-01", "PASS", "validated")
|
||||
report.add_case("EVENT-01", "PASS", "received")
|
||||
var finished := report.finish()
|
||||
check(finished.status == "PASS", "all passing cases produce client PASS")
|
||||
check(finished.coverage.required == 2 and finished.coverage.passed == 2, "coverage is exact")
|
||||
var blocked := Report.new()
|
||||
blocked.begin("blocked-run", "playable", valid)
|
||||
blocked.add_case("FIXTURE-01", "BLOCKED", "fixture_not_ready")
|
||||
check(blocked.finish().status == "BLOCKED", "blocked fixture cannot become PASS")
|
||||
var out := "user://playable-harness-report.json"
|
||||
check(report.write(out), "report can be serialized")
|
||||
var json: Variant = JSON.parse_string(FileAccess.get_file_as_string(out))
|
||||
check(json is Dictionary and json.get("run_id", "") == "harness-run", "serialized report is readable")
|
||||
print("playable_harness_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1,373 @@
|
||||
extends Node
|
||||
|
||||
## 首个 Mac 内测的真实联网闭环。
|
||||
##
|
||||
## 这个脚本只负责调度生产入口并观察服务端结果:移动仍经 PlayerController,
|
||||
## 目标仍经 NetPlay,拾取仍经 NetPlay.pick_ground_item,技能入口由后续 CBT
|
||||
## 用例接入 Quickbar。没有 `MT_PLAYABLE_ALLOW_GAMEPLAY=1` 时只做登录/进场
|
||||
## 观察并把后续必测项标记为 BLOCKED,不会误发游戏操作。
|
||||
|
||||
const Config = preload("res://testing/playable_config.gd")
|
||||
const Report = preload("res://testing/playable_report.gd")
|
||||
const Probe = preload("res://testing/playable_probe.gd")
|
||||
|
||||
const WAIT_WORLD_SECONDS := 60.0
|
||||
const MOVE_SECONDS := 20.0
|
||||
const TARGET_SECONDS := 30.0
|
||||
const ATTACK_SECONDS := 90.0
|
||||
const DROP_SECONDS := 30.0
|
||||
const PICKUP_SECONDS := 15.0
|
||||
|
||||
var _flow: Node
|
||||
var _client: Node
|
||||
var _config: Dictionary
|
||||
var _report: RefCounted
|
||||
var _probe: Node
|
||||
var _report_path := ""
|
||||
var _events_path := ""
|
||||
var _state := "boot"
|
||||
var _state_since := 0.0
|
||||
var _started := 0.0
|
||||
var _finished := false
|
||||
var _allow_gameplay := false
|
||||
var _entered_count := 0
|
||||
var _main_vid := 0
|
||||
var _server_move_seen := false
|
||||
var _waypoint_index := 0
|
||||
var _expected_position := Vector3.ZERO
|
||||
var _target_vid := 0
|
||||
var _target_initial_hp := -1
|
||||
var _target_damaged := false
|
||||
var _target_dead := false
|
||||
var _drop_vid := 0
|
||||
var _drop_vnum := 0
|
||||
var _pickup_before := 0
|
||||
var _cases := {}
|
||||
|
||||
func setup(flow: Node, m2client: Node, config_path := "") -> void:
|
||||
_flow = flow
|
||||
_client = m2client
|
||||
_started = Time.get_ticks_msec() / 1000.0
|
||||
_state_since = _started
|
||||
_allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1"
|
||||
_report_path = OS.get_environment("MT_TEST_REPORT").strip_edges()
|
||||
if _report_path.is_empty():
|
||||
_report_path = "user://playable-client-report.json"
|
||||
_events_path = OS.get_environment("MT_TEST_EVENTS").strip_edges()
|
||||
if _events_path.is_empty():
|
||||
_events_path = _report_path.get_base_dir().path_join("events.jsonl")
|
||||
var path := config_path if not config_path.is_empty() else OS.get_environment("MT_PLAYABLE_CONFIG")
|
||||
var loaded := Config.load_file(path)
|
||||
_report = Report.new()
|
||||
_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded.config if loaded.ok else {})
|
||||
if not loaded.ok:
|
||||
_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(loaded.errors))
|
||||
_finish_later()
|
||||
return
|
||||
_config = loaded.config
|
||||
_case("CONFIG-01", "PASS", "validated")
|
||||
if _client == null:
|
||||
_case("NET-BOOT-01", "FAIL", "M2Client was not created")
|
||||
_finish_later()
|
||||
return
|
||||
_probe = Probe.new()
|
||||
_probe.name = "PlayableProbe"
|
||||
add_child(_probe)
|
||||
_probe.setup(_flow, _client)
|
||||
_probe.observed.connect(_on_observed)
|
||||
_bind("char_list", Callable(self, "_on_char_list"))
|
||||
_bind("entered_game", Callable(self, "_on_entered_game"))
|
||||
_bind("disconnected", Callable(self, "_on_disconnected"))
|
||||
_bind("entity_moved", Callable(self, "_on_entity_moved"))
|
||||
_bind("damage", Callable(self, "_on_damage"))
|
||||
_bind("entity_dead", Callable(self, "_on_entity_dead"))
|
||||
_bind("ground_item_added", Callable(self, "_on_ground_item_added"))
|
||||
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed"))
|
||||
_bind("item_picked_up", Callable(self, "_on_item_picked_up"))
|
||||
_bind("inventory_changed", Callable(self, "_on_inventory_changed"))
|
||||
_set_state("WAIT_LOGIN")
|
||||
_record("harness_started", {"allow_gameplay": _allow_gameplay})
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _finished:
|
||||
return
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
if now - _started > float(_config.get("timeout_seconds", 120)):
|
||||
_fail_current("overall timeout in state %s" % _state)
|
||||
_finish_later()
|
||||
return
|
||||
match _state:
|
||||
"WAIT_LOGIN":
|
||||
if _entered_count > 0:
|
||||
_set_state("WAIT_WORLD")
|
||||
elif now - _state_since > 30.0:
|
||||
_fail_case("NET-LOGIN-01", "login/character selection timed out")
|
||||
_finish_later()
|
||||
"WAIT_WORLD":
|
||||
var snap: Dictionary = _flow.get_playable_snapshot() if _flow and _flow.has_method("get_playable_snapshot") else {}
|
||||
if bool(snap.get("scene_ready", false)):
|
||||
_case("NET-LOGIN-01", "PASS", "character list received")
|
||||
_case("NET-SELECT-01", "PASS", "entered_game received")
|
||||
_case("NET-WORLD-01", "PASS", "game scene and map are ready")
|
||||
_main_vid = int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0
|
||||
if _main_vid <= 0:
|
||||
_fail_case("NET-WORLD-02", "main VID is invalid")
|
||||
_finish_later()
|
||||
elif not _allow_gameplay:
|
||||
_case("GAMEPLAY-ALLOW-01", "BLOCKED", "--allow-gameplay was not supplied")
|
||||
_finish_later()
|
||||
else:
|
||||
_begin_move()
|
||||
elif now - _state_since > WAIT_WORLD_SECONDS:
|
||||
_fail_case("NET-WORLD-01", "scene did not become ready")
|
||||
_finish_later()
|
||||
"MOVE":
|
||||
if _move_reached():
|
||||
if _server_move_seen:
|
||||
_case("NET-MOVE-%02d" % (_waypoint_index + 1), "PASS", "server entity_moved observed")
|
||||
_waypoint_index += 1
|
||||
if _waypoint_index >= _config.waypoints_cm.size():
|
||||
_begin_target()
|
||||
else:
|
||||
_send_waypoint()
|
||||
else:
|
||||
_block_current("local arrival without server movement evidence")
|
||||
_finish_later()
|
||||
elif now - _state_since > MOVE_SECONDS:
|
||||
_fail_current("waypoint not reached")
|
||||
_finish_later()
|
||||
"TARGET":
|
||||
if _target_vid > 0:
|
||||
_case("NET-TARGET-01", "PASS", "allowed live monster selected")
|
||||
_begin_attack()
|
||||
elif now - _state_since > TARGET_SECONDS:
|
||||
_fail_case("NET-TARGET-01", "no allowed live monster found")
|
||||
_finish_later()
|
||||
"ATTACK":
|
||||
if _target_dead or (_target_damaged and _target_hp_decreased()):
|
||||
if _client.has_method("get_entity") and not _client.get_entity(_target_vid).is_empty():
|
||||
_case("NET-ATTACK-01", "PASS", "server damage/death evidence observed")
|
||||
else:
|
||||
_block_current("target result arrived after entity removal")
|
||||
_stop_attack()
|
||||
_set_state("DROP")
|
||||
elif now - _state_since > ATTACK_SECONDS:
|
||||
_stop_attack()
|
||||
_fail_case("NET-ATTACK-01", "no server damage or death result")
|
||||
_finish_later()
|
||||
"DROP":
|
||||
if _drop_vid > 0:
|
||||
_case("NET-DROP-01", "PASS", "allowed ground item added by server")
|
||||
_begin_pickup()
|
||||
elif now - _state_since > DROP_SECONDS:
|
||||
_block_current("deterministic drop fixture was not observed")
|
||||
_finish_later()
|
||||
"PICKUP":
|
||||
if _pickup_succeeded():
|
||||
_case("NET-PICKUP-01", "PASS", "item_picked_up and inventory delta observed")
|
||||
_case("NET-EXIT-01", "PASS", "client test state completed")
|
||||
_finish_later()
|
||||
elif now - _state_since > PICKUP_SECONDS:
|
||||
_fail_case("NET-PICKUP-01", "pickup result did not include inventory delta")
|
||||
_finish_later()
|
||||
|
||||
func _bind(signal_name: String, callback: Callable) -> void:
|
||||
if _client.has_signal(signal_name) and not _client.is_connected(signal_name, callback):
|
||||
_client.connect(signal_name, callback)
|
||||
|
||||
func _set_state(next: String) -> void:
|
||||
_state = next
|
||||
_state_since = Time.get_ticks_msec() / 1000.0
|
||||
_record("state", {"name": next})
|
||||
|
||||
func _on_observed(kind: String, payload: Dictionary) -> void:
|
||||
_record(kind, payload)
|
||||
|
||||
func _record(kind: String, payload := {}) -> void:
|
||||
if _report == null:
|
||||
return
|
||||
var data: Dictionary = payload.duplicate(true)
|
||||
data["monotonic_us"] = Time.get_ticks_usec()
|
||||
data["run_id"] = OS.get_environment("MT_PLAYABLE_RUN_ID")
|
||||
data["case_id"] = _state
|
||||
_report.add_event(data, _events_path)
|
||||
|
||||
func _on_char_list(characters: Array) -> void:
|
||||
_record("char_list", {"count": characters.size()})
|
||||
var slot := int(_config.get("character_slot", -1))
|
||||
var found := false
|
||||
for character in characters:
|
||||
if int(character.get("index", -1)) == slot:
|
||||
found = true
|
||||
break
|
||||
if not found:
|
||||
_fail_case("NET-LOGIN-01", "configured character slot is absent")
|
||||
_finish_later()
|
||||
|
||||
func _on_entered_game() -> void:
|
||||
_entered_count += 1
|
||||
_record("entered_game", {"count": _entered_count})
|
||||
|
||||
func _on_disconnected(reason: String) -> void:
|
||||
_record("disconnected", {"reason": reason})
|
||||
if _state != "WAIT_LOGIN" and _state != "WAIT_WORLD":
|
||||
_fail_current("unexpected disconnect: %s" % reason)
|
||||
_finish_later()
|
||||
|
||||
func _on_entity_moved(vid: int) -> void:
|
||||
if vid == _main_vid:
|
||||
_server_move_seen = true
|
||||
_record("main_entity_moved", {"vid": vid})
|
||||
|
||||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
if vid == _target_vid and amount > 0:
|
||||
_target_damaged = true
|
||||
_record("target_damage", {"vid": vid, "amount": amount, "flag": flag})
|
||||
|
||||
func _on_entity_dead(vid: int) -> void:
|
||||
if vid == _target_vid:
|
||||
_target_dead = true
|
||||
_record("target_dead", {"vid": vid})
|
||||
|
||||
func _on_ground_item_added(item: Dictionary) -> void:
|
||||
var vnum := int(item.get("vnum", 0))
|
||||
if _state == "DROP" and vnum in _config.get("allowed_drop_vnums", []):
|
||||
_drop_vid = int(item.get("vid", 0))
|
||||
_drop_vnum = vnum
|
||||
_record("allowed_drop", {"vid": _drop_vid, "vnum": vnum})
|
||||
|
||||
func _on_ground_item_removed(vid: int) -> void:
|
||||
_record("ground_item_removed", {"vid": vid})
|
||||
|
||||
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
|
||||
if _state == "PICKUP" and vnum == _drop_vnum:
|
||||
_record("item_picked_up", {"vnum": vnum, "count": count, "source": source})
|
||||
|
||||
func _on_inventory_changed(window: int, cell: int) -> void:
|
||||
_record("inventory_changed", {"window": window, "cell": cell})
|
||||
|
||||
func _begin_move() -> void:
|
||||
_waypoint_index = 0
|
||||
_send_waypoint()
|
||||
|
||||
func _send_waypoint() -> void:
|
||||
var point: Array = _config.waypoints_cm[_waypoint_index]
|
||||
var net := Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01)
|
||||
_expected_position = MapCoord.to_world(net)
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var controller: Node = context.get("pc", null)
|
||||
_server_move_seen = false
|
||||
if controller == null or not controller.has_method("walk_to"):
|
||||
_fail_case("NET-MOVE-%02d" % (_waypoint_index + 1), "PlayerController.walk_to is unavailable")
|
||||
_finish_later()
|
||||
return
|
||||
controller.walk_to(_expected_position)
|
||||
_set_state("MOVE")
|
||||
_record("move_requested", {"waypoint_index": _waypoint_index, "x_cm": point[0], "y_cm": point[1]})
|
||||
|
||||
func _move_reached() -> bool:
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var player: Node3D = context.get("player", null)
|
||||
return is_instance_valid(player) and player.global_position.distance_to(_expected_position) <= 1.0
|
||||
|
||||
func _begin_target() -> void:
|
||||
_target_vid = 0
|
||||
var allowed: Array = _config.get("allowed_mob_vnums", [])
|
||||
var best_distance := INF
|
||||
var player_pos := Vector3.ZERO
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var player: Node3D = context.get("player", null)
|
||||
if is_instance_valid(player):
|
||||
player_pos = player.global_position
|
||||
for entity in _client.get_entities():
|
||||
var race := int(entity.get("race", 0))
|
||||
var kind := int(entity.get("kind", entity.get("ch_type", -1)))
|
||||
if race not in allowed or bool(entity.get("dead", false)) or kind != 2:
|
||||
continue
|
||||
var pos: Variant = entity.get("pos", null)
|
||||
var distance := 0.0
|
||||
if pos is Vector3:
|
||||
distance = player_pos.distance_to(MapCoord.to_world(pos))
|
||||
if distance < best_distance:
|
||||
best_distance = distance
|
||||
_target_vid = int(entity.get("vid", 0))
|
||||
if _target_vid > 0:
|
||||
var net_play: Node = context.get("net_play", null)
|
||||
if net_play == null or not net_play.has_method("select_target") or not net_play.select_target(_target_vid):
|
||||
_target_vid = 0
|
||||
_set_state("TARGET")
|
||||
|
||||
func _begin_attack() -> void:
|
||||
var entity: Dictionary = _client.get_entity(_target_vid)
|
||||
_target_initial_hp = int(entity.get("hp", -1))
|
||||
_target_damaged = false
|
||||
_target_dead = false
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var net_play: Node = context.get("net_play", null)
|
||||
if net_play == null or not net_play.has_method("set_attack_key"):
|
||||
_fail_case("NET-ATTACK-01", "NetPlay.set_attack_key is unavailable")
|
||||
_finish_later()
|
||||
return
|
||||
net_play.set_attack_key(true)
|
||||
_set_state("ATTACK")
|
||||
|
||||
func _stop_attack() -> void:
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var net_play: Node = context.get("net_play", null)
|
||||
if net_play and net_play.has_method("set_attack_key"):
|
||||
net_play.set_attack_key(false)
|
||||
|
||||
func _target_hp_decreased() -> bool:
|
||||
if not _client.has_method("get_entity"):
|
||||
return false
|
||||
var e: Dictionary = _client.get_entity(_target_vid)
|
||||
return not e.is_empty() and _target_initial_hp > 0 and int(e.get("hp", _target_initial_hp)) < _target_initial_hp
|
||||
|
||||
func _begin_pickup() -> void:
|
||||
_pickup_before = _inventory_count(_drop_vnum)
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var net_play: Node = context.get("net_play", null)
|
||||
if net_play == null or not net_play.has_method("pick_ground_item"):
|
||||
_fail_case("NET-PICKUP-01", "NetPlay.pick_ground_item is unavailable")
|
||||
_finish_later()
|
||||
return
|
||||
net_play.pick_ground_item(_drop_vid)
|
||||
_set_state("PICKUP")
|
||||
|
||||
func _inventory_count(vnum: int) -> int:
|
||||
if not _client.has_method("get_inventory"):
|
||||
return -1
|
||||
var total := 0
|
||||
for item in _client.get_inventory():
|
||||
if int(item.get("vnum", 0)) == vnum:
|
||||
total += int(item.get("count", 0))
|
||||
return total
|
||||
|
||||
func _pickup_succeeded() -> bool:
|
||||
return _drop_vnum > 0 and _inventory_count(_drop_vnum) > _pickup_before
|
||||
|
||||
func _case(case_id: String, status: String, reason: String) -> void:
|
||||
if _cases.has(case_id):
|
||||
return
|
||||
_cases[case_id] = true
|
||||
_report.add_case(case_id, status, reason)
|
||||
|
||||
func _fail_case(case_id: String, reason: String) -> void:
|
||||
_case(case_id, "FAIL", reason)
|
||||
|
||||
func _block_current(reason: String) -> void:
|
||||
_case("BLOCK-%s" % _state, "BLOCKED", reason)
|
||||
|
||||
func _fail_current(reason: String) -> void:
|
||||
_fail_case("FAIL-%s" % _state, reason)
|
||||
|
||||
func _finish_later() -> void:
|
||||
if _finished:
|
||||
return
|
||||
_finished = true
|
||||
_stop_attack()
|
||||
_report.write(_report_path)
|
||||
call_deferred("_quit")
|
||||
|
||||
func _quit() -> void:
|
||||
get_tree().quit(0)
|
||||
@@ -0,0 +1,47 @@
|
||||
extends SceneTree
|
||||
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
const EquipModel = preload("res://ui/equip_model.gd")
|
||||
var failures: Array[String] = []
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failures.append(message)
|
||||
printerr(message)
|
||||
|
||||
func run() -> void:
|
||||
for race in 8:
|
||||
var pv := PlayerView.new()
|
||||
root.add_child(pv)
|
||||
if not pv.build(AssetRoot.path(), race):
|
||||
check(false, "race %d failed to build" % race)
|
||||
pv.queue_free()
|
||||
continue
|
||||
pv.anim.set_process(false)
|
||||
pv.anim.set("blend_time", 0.0)
|
||||
var equip := EquipModel.new()
|
||||
pv.add_child(equip)
|
||||
equip.setup_remote(null, func() -> Node: return pv, AssetRoot.path(), race, [0, 0, 0, 0])
|
||||
# MSM is an independent asset authority for the equipped body's gender.
|
||||
var body_dir := String(pv.model.get("gr2_path")).get_base_dir().to_lower()
|
||||
check(body_dir == pv.motion_dir.get_base_dir().to_lower(),
|
||||
"race %d: MSM body and animation directories differ" % race)
|
||||
check(String(pv.model.get("hair_gr2")).to_lower().begins_with(body_dir + "/"),
|
||||
"race %d: hair is from a different resource family" % race)
|
||||
for motion in ["wait", "walk", "run", "attack", "damage", "dead"]:
|
||||
pv.set_anim_state(motion)
|
||||
check(String(pv.anim.get("anim_path")).get_file() == motion + ".msa",
|
||||
"race %d: missing %s motion" % [race, motion])
|
||||
for fraction in [0.0, 0.5, 1.0]:
|
||||
pv.anim.set("time", float(pv.anim.call("get_duration")) * fraction)
|
||||
for mi in pv.model.find_children("*", "MeshInstance3D", true, false):
|
||||
var bounds: AABB = mi.get_aabb()
|
||||
check(bounds.position.is_finite() and bounds.size.is_finite() and bounds.size.length() > 0,
|
||||
"race %d: invalid bounds in %s" % [race, motion])
|
||||
pv.queue_free()
|
||||
await process_frame
|
||||
print("race_motion_assembly_test: failures=", failures.size(), " (8 races, 6 motions, 3 samples)")
|
||||
quit(0 if failures.is_empty() else 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://tevl03ejjnln
|
||||
@@ -0,0 +1,129 @@
|
||||
# Offline real-resource stress/viewport report; NOT 40250 visual parity or
|
||||
# server reconnect acceptance. Run with a renderer, --quit-after 3600.
|
||||
extends SceneTree
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
const Fixtures = preload("res://gamescene_test.gd")
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
class ScenarioClient extends Fixtures.FakeClient:
|
||||
signal warp(pos: Vector3, same_server: bool)
|
||||
signal world_reset()
|
||||
var failures: Array[String] = []
|
||||
var output := ""
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var run_start := Time.get_ticks_msec()
|
||||
var sample_frames := 90
|
||||
if OS.has_environment("MT_RENDER_SAMPLE_FRAMES"):
|
||||
sample_frames = clampi(int(OS.get_environment("MT_RENDER_SAMPLE_FRAMES")), 90, 36000)
|
||||
output = OS.get_environment("MT_RENDER_OUTPUT")
|
||||
if output == "":
|
||||
output = ProjectSettings.globalize_path("res://../build/rendering/scenarios-followup")
|
||||
DirAccess.make_dir_recursive_absolute(output)
|
||||
root.size = Vector2i(1280, 720)
|
||||
var report := {"scope": "offline real maps, CPU/GPU animated crowd and viewport layout; no server writes or 40250 parity",
|
||||
"engine": Engine.get_version_info(), "backend": DisplayServer.get_name(), "samples": [],
|
||||
"sample_frames": sample_frames, "model_builds": []}
|
||||
var client := ScenarioClient.new()
|
||||
root.add_child(client)
|
||||
var scene := GameScene.new()
|
||||
root.add_child(scene)
|
||||
await scene.setup(client, AssetRoot.path(), "OutdoorA1/metin2_map_a1")
|
||||
client.main = 1000
|
||||
client.ents[1000] = {"vid": 1000, "name": "Scenario", "race": 0, "pos_cm": Vector2(454600, 934000),
|
||||
"pos": Vector3(4546, 0, -9340), "is_main": true, "func": 0, "moving": false,
|
||||
"angle_deg": 0.0, "hp": 100, "max_hp": 100, "dead": false}
|
||||
for i in 30: await process_frame
|
||||
check(scene._model_built and scene._map_loaded(), "A1 main character and world loaded")
|
||||
var centre: Vector3 = scene.player.position
|
||||
for mode in ["0", "1"]:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", mode)
|
||||
var crowd := Node3D.new()
|
||||
scene.add_child(crowd)
|
||||
for count in [1, 8, 16]:
|
||||
while crowd.get_child_count() < count:
|
||||
var index := crowd.get_child_count()
|
||||
var view := PlayerView.new()
|
||||
var build_start := Time.get_ticks_usec()
|
||||
check(view.build(AssetRoot.path(), index % 8), "crowd race %d build" % (index % 8))
|
||||
report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8,
|
||||
"build_ms": (Time.get_ticks_usec() - build_start) / 1000.0,
|
||||
"note": "synchronous build only; OS/shader caches may already be warm"})
|
||||
crowd.add_child(view)
|
||||
view.position = centre + Vector3((index % 4 - 1.5) * 2, 0, (index / 4 + 1) * 2)
|
||||
view.position.y = scene.world.sample_height(view.position.x, view.position.z)
|
||||
view.set_anim_state("run")
|
||||
for i in 12: await process_frame
|
||||
var times: Array[float] = []
|
||||
var switches: Array[float] = []
|
||||
var memory_start := Performance.get_monitor(Performance.MEMORY_STATIC)
|
||||
var previous := Time.get_ticks_usec()
|
||||
for i in sample_frames:
|
||||
if i > 0 and i % 30 == 0:
|
||||
var switch_start := Time.get_ticks_usec()
|
||||
var state: String = ["run", "attack", "wait"][(i / 30) % 3]
|
||||
for view in crowd.get_children(): view.set_anim_state(state)
|
||||
switches.append((Time.get_ticks_usec() - switch_start) / 1000.0)
|
||||
await process_frame
|
||||
var now := Time.get_ticks_usec()
|
||||
times.append((now - previous) / 1000.0)
|
||||
previous = now
|
||||
for view in crowd.get_children():
|
||||
var bounds: AABB = view.model.get_visual_aabb()
|
||||
check(bounds.position.is_finite() and bounds.size.is_finite() and bounds.size.length() > 0,
|
||||
"finite posed bounds after run/attack/wait blend")
|
||||
times.sort()
|
||||
switches.sort()
|
||||
report.samples.append({"gpu_skin": mode == "1", "extra_players": count,
|
||||
"median_ms": times[sample_frames / 2], "p95_ms": times[int((sample_frames - 1) * 0.95)], "max_ms": times[-1],
|
||||
"motion_switches": switches.size(), "motion_switch_max_ms": switches[-1],
|
||||
"memory_static_start": memory_start, "memory_static_end": Performance.get_monitor(Performance.MEMORY_STATIC),
|
||||
"clip_cache": Metin2AnimPlayer.get_clip_cache_stats(),
|
||||
"note": "%d window-frame intervals with repeated motion transitions; not GPU timestamp, RSS or FPS guarantee" % sample_frames})
|
||||
print("SCENARIO_SAMPLE gpu=%s count=%d frames=%d max_ms=%.3f" % [mode, count, sample_frames, times[-1]])
|
||||
crowd.free()
|
||||
await process_frame
|
||||
OS.set_environment("MTGODOT_GPUSKIN", "0")
|
||||
var mobs := Node3D.new()
|
||||
scene.add_child(mobs)
|
||||
for race in [101, 110, 20001]:
|
||||
var view := MobView.new()
|
||||
if not view.build(AssetRoot.path(), null, race):
|
||||
check(false, "NPC/monster build race=%d" % race)
|
||||
view.free()
|
||||
continue
|
||||
mobs.add_child(view)
|
||||
view.position = centre + Vector3(mobs.get_child_count() * 2, 0, 3)
|
||||
view.position.y = scene.world.sample_height(view.position.x, view.position.z)
|
||||
view.set_anim_state("wait")
|
||||
check(not view.model.get_visual_aabb().size.is_zero_approx(), "NPC/monster real geometry")
|
||||
for size in [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440)]:
|
||||
root.size = size
|
||||
for i in 5: await process_frame
|
||||
if scene.quickbar and scene.quickbar._root:
|
||||
var rect: Rect2 = scene.quickbar._root.get_global_rect()
|
||||
check(Rect2(Vector2.ZERO, root.get_visible_rect().size).encloses(rect), "quickbar inside viewport %s" % size)
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await RenderingServer.frame_post_draw
|
||||
check(root.get_texture().get_image().save_png(output.path_join("scene-%dx%d.png" % [size.x, size.y])) == OK, "screenshot saved")
|
||||
# Late server location update exercises the production map-correction path.
|
||||
client.ents[1000].pos_cm = Vector2(959109, 269267)
|
||||
client.ents[1000].pos = Vector3(9591.09, 0, -2692.67)
|
||||
client.world_reset.emit()
|
||||
client.warp.emit(client.ents[1000].pos, false)
|
||||
for i in 60: await process_frame
|
||||
check(scene.map_path == "OutdoorC1/metin2_map_c1" and scene._map_loaded(), "A1 to C1 map correction")
|
||||
report["final_map"] = scene.map_path
|
||||
scene.free()
|
||||
client.free()
|
||||
await process_frame
|
||||
report["failures"] = failures
|
||||
report["duration_seconds"] = (Time.get_ticks_msec() - run_start) / 1000.0
|
||||
FileAccess.open(output.path_join("report.json"), FileAccess.WRITE).store_string(JSON.stringify(report, "\t"))
|
||||
print("rendering_scenario_test: failures=%d report=%s" % [failures.size(), output])
|
||||
quit(1 if not failures.is_empty() else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d36j4r2snd4uv
|
||||
@@ -266,6 +266,8 @@ func _run() -> void:
|
||||
var qb: Node = Quickbar.new()
|
||||
get_root().add_child(qb)
|
||||
qb.setup(fc, st, ui, func() -> Node: return pl)
|
||||
var cast_targets: Array = []
|
||||
qb.skill_cast_started.connect(func(sid: int, vid: int): cast_targets.append([sid, vid]))
|
||||
qb.assign(0, "skill", 1)
|
||||
_ck(qb._slots[0].icon.texture != null, "quickbar skill slot shows skill icon")
|
||||
_ck(String(qb._slots[0].lbl.text) == "", "quickbar hides text when skill icon is available")
|
||||
@@ -274,12 +276,24 @@ func _run() -> void:
|
||||
_ck(fc.skill_uses.size() == 1 and fc.skill_uses[0] == [1, 2000],
|
||||
"quickbar slot 0 -> use_skill(skill 1, selected target)")
|
||||
_ck(fc.casts.size() == 1, "quickbar slot 0 -> cast_skill once")
|
||||
_ck(cast_targets == [[1, 2000]], "successful cast publishes resolved visual target")
|
||||
if fc.casts.size() == 1:
|
||||
_ck(int(fc.casts[0][0]) == st.motion_idx_of(1), "cast used skill 1's motion_idx")
|
||||
# 冷却中不再施放
|
||||
qb.activate(0)
|
||||
_ck(fc.skill_uses.size() == 1, "on cooldown -> no second use_skill")
|
||||
_ck(fc.casts.size() == 1, "on cooldown -> no second cast")
|
||||
qb.assign(1, "skill", 1, false)
|
||||
qb.activate(1)
|
||||
_ck(fc.casts.size() == 1, "same skill in another slot shares cooldown")
|
||||
fc.quickslots = [{"pos": 0, "type": 2, "ref": 1}, {"pos": 1, "type": 2, "ref": 1}]
|
||||
qb.restore_from_server()
|
||||
qb._process(0.0)
|
||||
_ck(qb._slots[0].cd.visible and qb._slots[1].cd.visible, "server slot refresh preserves cooldown overlays")
|
||||
qb.activate(0)
|
||||
_ck(fc.casts.size() == 1, "server refresh cannot reset skill cooldown")
|
||||
fc.quickslots = [] # restore this fixture's original server state for later UI cases
|
||||
qb._clear(1)
|
||||
# GC_SKILL_COOLTIME_END 提前解锁
|
||||
fc.skill_cooldown_end.emit(1)
|
||||
qb.activate(0)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
extends SceneTree
|
||||
const Anchor = preload("res://fx/target_effect_anchor.gd")
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
class Client extends Node:
|
||||
func get_main_vid() -> int: return 1
|
||||
class World extends Node:
|
||||
var target: Node3D
|
||||
func node_for(_vid: int) -> Node3D: return target
|
||||
class Registry extends RefCounted:
|
||||
var seen: Array = []
|
||||
func spawn_target(event: Dictionary, target: Node3D, _world: Node3D): seen.append([event, target])
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var target := Node3D.new()
|
||||
root.add_child(target)
|
||||
target.position = Vector3(8, 2, -5)
|
||||
target.rotation.y = PI * 0.5
|
||||
var effect := Node3D.new()
|
||||
root.add_child(effect)
|
||||
var anchor := Anchor.new()
|
||||
effect.add_child(anchor)
|
||||
check(anchor.configure(target, Vector3(100, 0, 100), true), "following target configured")
|
||||
check(effect.global_position.distance_to(target.to_global(Vector3(1, 1, 0))) < 0.00001, "source Z-up/cm and target rotation")
|
||||
target.position.x += 3
|
||||
anchor._process(0)
|
||||
check(effect.global_position.distance_to(target.to_global(Vector3(1, 1, 0))) < 0.00001, "follows target movement")
|
||||
anchor.configure(target, Vector3(100, 0, 100), false)
|
||||
check(effect.global_position.distance_to(target.global_position + Vector3(1, 1, 0)) < 0.00001, "snapshot offset is world-aligned")
|
||||
var position := effect.global_position
|
||||
target.position.x += 2
|
||||
await process_frame
|
||||
check(effect.global_position == position, "snapshot does not follow")
|
||||
var game := GameScene.new()
|
||||
root.add_child(game)
|
||||
game.client = Client.new()
|
||||
game.add_child(game.client)
|
||||
var world := World.new()
|
||||
game.add_child(world)
|
||||
world.target = target
|
||||
game.net_world = world
|
||||
var registry := Registry.new()
|
||||
game.fx = registry
|
||||
game._on_skill_cast_started(1, 2)
|
||||
world.target = null # selected target later changes, original cast remains.
|
||||
game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO})
|
||||
check(registry.seen.size() == 1 and registry.seen[0][1] == target, "event keeps cast-time target and full path")
|
||||
check(registry.seen[0][0].effect == "full/path.mse", "path not reduced to basename")
|
||||
target.free()
|
||||
game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO})
|
||||
check(registry.seen.size() == 1, "despawned target not replaced by caster")
|
||||
game.free()
|
||||
effect.free()
|
||||
await process_frame
|
||||
print("target_effect_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ccjmylw80npfk
|
||||
@@ -0,0 +1,117 @@
|
||||
class_name PlayableConfig
|
||||
|
||||
## 首个联网内测的配置解析器。
|
||||
##
|
||||
## 这是纯数据校验层:不读取账号密码、不创建网络连接、不修改游戏状态。
|
||||
## 生产测试入口必须先通过 validate(),任何缺少的必测字段都只能得到 BLOCKED,
|
||||
## 不能由测试脚本临时补默认值后继续发包。
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
const REQUIRED_KEYS := [
|
||||
"scenario_id", "protocol", "server", "character_slot", "map_key",
|
||||
"waypoints_cm", "allowed_mob_vnums", "allowed_drop_vnums", "skill_cases",
|
||||
"resolution", "loops", "timeout_seconds",
|
||||
]
|
||||
|
||||
static func load_file(path: String) -> Dictionary:
|
||||
var result := {"ok": false, "config": {}, "errors": []}
|
||||
if path.strip_edges().is_empty():
|
||||
result.errors.append("config path is empty")
|
||||
return result
|
||||
if not FileAccess.file_exists(path):
|
||||
result.errors.append("config file does not exist: %s" % path)
|
||||
return result
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
result.errors.append("config file cannot be opened")
|
||||
return result
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
result.errors.append("config root must be a JSON object")
|
||||
return result
|
||||
var checked := validate(parsed)
|
||||
result.config = parsed
|
||||
result.errors = checked.errors
|
||||
result.ok = bool(checked.ok)
|
||||
return result
|
||||
|
||||
static func validate(config: Dictionary) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
for key in REQUIRED_KEYS:
|
||||
if not config.has(key):
|
||||
errors.append("missing required key: %s" % key)
|
||||
if int(config.get("schema_version", -1)) != SCHEMA_VERSION:
|
||||
errors.append("schema_version must be %d" % SCHEMA_VERSION)
|
||||
if String(config.get("scenario_id", "")).strip_edges().is_empty():
|
||||
errors.append("scenario_id must not be empty")
|
||||
var protocol := String(config.get("protocol", "")).strip_edges().to_lower()
|
||||
if protocol != "classic":
|
||||
errors.append("protocol must be classic")
|
||||
var server: Variant = config.get("server", null)
|
||||
if not (server is Dictionary):
|
||||
errors.append("server must be an object selected from serverinfo")
|
||||
else:
|
||||
for key in ["server_index", "channel_index"]:
|
||||
if not server.has(key) or int(server[key]) < 0:
|
||||
errors.append("server.%s must be a non-negative integer" % key)
|
||||
if not config.has("character_slot") or int(config.get("character_slot", -1)) < 0:
|
||||
errors.append("character_slot must be a non-negative integer")
|
||||
if String(config.get("map_key", "")).strip_edges().is_empty():
|
||||
errors.append("map_key is empty; resolve it from the server/map fixture")
|
||||
var waypoints: Variant = config.get("waypoints_cm", null)
|
||||
if not (waypoints is Array) or waypoints.is_empty():
|
||||
errors.append("waypoints_cm must contain at least one [x,y] centimeter point")
|
||||
else:
|
||||
for i in waypoints.size():
|
||||
var point: Variant = waypoints[i]
|
||||
if not (point is Array) or point.size() != 2 or not _is_number(point[0]) or not _is_number(point[1]):
|
||||
errors.append("waypoints_cm[%d] must be [x,y] numbers" % i)
|
||||
var mobs: Variant = config.get("allowed_mob_vnums", null)
|
||||
if not (mobs is Array) or mobs.is_empty():
|
||||
errors.append("allowed_mob_vnums must contain confirmed server mob vnums")
|
||||
else:
|
||||
_validate_positive_int_array(mobs, "allowed_mob_vnums", errors)
|
||||
var drops: Variant = config.get("allowed_drop_vnums", null)
|
||||
if not (drops is Array) or drops.is_empty():
|
||||
errors.append("allowed_drop_vnums must contain a deterministic pickup fixture")
|
||||
else:
|
||||
_validate_positive_int_array(drops, "allowed_drop_vnums", errors)
|
||||
var skills: Variant = config.get("skill_cases", null)
|
||||
if not (skills is Array) or skills.is_empty():
|
||||
errors.append("skill_cases must contain at least one confirmed skill case")
|
||||
else:
|
||||
for i in skills.size():
|
||||
if not (skills[i] is Dictionary):
|
||||
errors.append("skill_cases[%d] must be an object" % i)
|
||||
continue
|
||||
var item: Dictionary = skills[i]
|
||||
for key in ["case_id", "skill_id", "required_evidence"]:
|
||||
if not item.has(key):
|
||||
errors.append("skill_cases[%d] missing %s" % [i, key])
|
||||
if String(item.get("case_id", "")).strip_edges().is_empty():
|
||||
errors.append("skill_cases[%d].case_id is empty" % i)
|
||||
if int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255:
|
||||
errors.append("skill_cases[%d].skill_id must be 1..254" % i)
|
||||
if not (item.get("required_evidence", null) is Array) or item.required_evidence.is_empty():
|
||||
errors.append("skill_cases[%d].required_evidence must be non-empty" % i)
|
||||
var resolution: Variant = config.get("resolution", null)
|
||||
if not (resolution is Array) or resolution.size() != 2 or int(resolution[0]) <= 0 or int(resolution[1]) <= 0:
|
||||
errors.append("resolution must be [width,height]")
|
||||
if int(config.get("loops", 0)) < 1:
|
||||
errors.append("loops must be at least 1")
|
||||
if int(config.get("timeout_seconds", 0)) < 1:
|
||||
errors.append("timeout_seconds must be positive")
|
||||
# This config is deliberately data-only. Reject the common accidental secret paths.
|
||||
for forbidden in ["account", "username", "password", "token", "secret"]:
|
||||
if config.has(forbidden):
|
||||
errors.append("credentials must not be stored in config: %s" % forbidden)
|
||||
return {"ok": errors.is_empty(), "errors": errors}
|
||||
|
||||
static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void:
|
||||
for i in values.size():
|
||||
if not _is_number(values[i]) or int(values[i]) <= 0 or float(values[i]) != float(int(values[i])):
|
||||
errors.append("%s[%d] must be a positive integer" % [name, i])
|
||||
|
||||
static func _is_number(value: Variant) -> bool:
|
||||
return value is int or value is float
|
||||
@@ -0,0 +1,115 @@
|
||||
class_name PlayableProbe
|
||||
extends Node
|
||||
|
||||
## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入,
|
||||
## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。
|
||||
|
||||
signal observed(kind: String, payload: Dictionary)
|
||||
|
||||
var client: Node
|
||||
var flow: Node
|
||||
var connection_epoch := 0
|
||||
var _connections: Array[Dictionary] = []
|
||||
|
||||
func setup(app_flow: Node, m2client: Node) -> void:
|
||||
flow = app_flow
|
||||
client = m2client
|
||||
if client == null:
|
||||
return
|
||||
_bind("entered_game", Callable(self, "_on_entered_game"))
|
||||
_bind("disconnected", Callable(self, "_on_disconnected"))
|
||||
_bind("char_list", Callable(self, "_on_char_list"))
|
||||
_bind("entity_spawned", Callable(self, "_on_entity_spawned"))
|
||||
_bind("entity_despawned", Callable(self, "_on_entity_despawned"))
|
||||
_bind("entity_moved", Callable(self, "_on_entity_moved"))
|
||||
_bind("entity_dead", Callable(self, "_on_entity_dead"))
|
||||
_bind("damage", Callable(self, "_on_damage"))
|
||||
_bind("vitals_changed", Callable(self, "_on_vitals"))
|
||||
_bind("ground_item_added", Callable(self, "_on_ground_item_added"))
|
||||
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed"))
|
||||
_bind("item_picked_up", Callable(self, "_on_item_picked_up"))
|
||||
_bind("inventory_changed", Callable(self, "_on_inventory_changed"))
|
||||
_bind("effect_cue", Callable(self, "_on_effect_cue"))
|
||||
_bind("fly_cue", Callable(self, "_on_fly_cue"))
|
||||
_bind("fly_targeting", Callable(self, "_on_fly_targeting"))
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game()
|
||||
var main_vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0
|
||||
var entity_count: int = client.get_entities().size() if client != null and client.has_method("get_entities") else 0
|
||||
var ground_count: int = client.get_ground_items().size() if client != null and client.has_method("get_ground_items") else 0
|
||||
var state := int(flow.state()) if flow != null and flow.has_method("state") else -1
|
||||
return {"in_game": in_game, "main_vid": main_vid, "entity_count": entity_count,
|
||||
"ground_item_count": ground_count, "app_state": state, "connection_epoch": connection_epoch}
|
||||
|
||||
func disconnect_all() -> void:
|
||||
for entry in _connections:
|
||||
var signal_name: String = entry.signal
|
||||
var callback: Callable = entry.callback
|
||||
if client != null and client.has_signal(signal_name) and client.is_connected(signal_name, callback):
|
||||
client.disconnect(signal_name, callback)
|
||||
_connections.clear()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
disconnect_all()
|
||||
|
||||
func _bind(signal_name: String, callback: Callable) -> void:
|
||||
if client.has_signal(signal_name) and not client.is_connected(signal_name, callback):
|
||||
client.connect(signal_name, callback)
|
||||
_connections.append({"signal": signal_name, "callback": callback})
|
||||
|
||||
func _emit(kind: String, payload := {}) -> void:
|
||||
var data: Dictionary = payload.duplicate(true)
|
||||
data["connection_epoch"] = connection_epoch
|
||||
data["received_us"] = Time.get_ticks_usec()
|
||||
observed.emit(kind, data)
|
||||
|
||||
func _on_entered_game() -> void:
|
||||
connection_epoch += 1
|
||||
_emit("entered_game")
|
||||
|
||||
func _on_disconnected(reason: String) -> void:
|
||||
_emit("disconnected", {"reason": reason})
|
||||
|
||||
func _on_char_list(chars: Array) -> void:
|
||||
_emit("char_list", {"count": chars.size()})
|
||||
|
||||
func _on_entity_spawned(entity: Dictionary) -> void:
|
||||
_emit("entity_spawned", {"vid": int(entity.get("vid", 0)), "race": int(entity.get("race", 0)), "kind": int(entity.get("kind", -1))})
|
||||
|
||||
func _on_entity_despawned(vid: int) -> void:
|
||||
_emit("entity_despawned", {"vid": vid})
|
||||
|
||||
func _on_entity_moved(vid: int) -> void:
|
||||
_emit("entity_moved", {"vid": vid})
|
||||
|
||||
func _on_entity_dead(vid: int) -> void:
|
||||
_emit("entity_dead", {"vid": vid})
|
||||
|
||||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
_emit("damage", {"vid": vid, "amount": amount, "flag": flag})
|
||||
|
||||
func _on_vitals(vid: int) -> void:
|
||||
_emit("vitals_changed", {"vid": vid})
|
||||
|
||||
func _on_ground_item_added(item: Dictionary) -> void:
|
||||
_emit("ground_item_added", {"vid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), "owner": String(item.get("owner", ""))})
|
||||
|
||||
func _on_ground_item_removed(vid: int) -> void:
|
||||
_emit("ground_item_removed", {"vid": vid})
|
||||
|
||||
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
|
||||
_emit("item_picked_up", {"vnum": vnum, "count": count, "source": source})
|
||||
|
||||
func _on_inventory_changed(window: int, cell: int) -> void:
|
||||
_emit("inventory_changed", {"window": window, "cell": cell})
|
||||
|
||||
func _on_effect_cue(vid: int, name: String, special: int) -> void:
|
||||
_emit("effect_cue", {"vid": vid, "name": name, "special": special})
|
||||
|
||||
func _on_fly_cue(kind: int, start_vid: int, end_vid: int) -> void:
|
||||
_emit("fly_cue", {"type": kind, "start_vid": start_vid, "end_vid": end_vid})
|
||||
|
||||
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void:
|
||||
_emit("fly_targeting", {"shooter_vid": shooter_vid, "target_vid": target_vid,
|
||||
"target_cm": [target_cm.x, target_cm.y], "append": append})
|
||||
@@ -0,0 +1,125 @@
|
||||
class_name PlayableReport
|
||||
|
||||
## 联网测试进程内的报告构建器。
|
||||
## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"]
|
||||
|
||||
var _report: Dictionary = {}
|
||||
var _started_us := 0
|
||||
|
||||
func begin(run_id: String, suite: String, config: Dictionary) -> void:
|
||||
_started_us = Time.get_ticks_usec()
|
||||
_report = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"run_id": run_id,
|
||||
"suite": suite,
|
||||
"status": "BLOCKED",
|
||||
"build": _build_info(),
|
||||
"environment": _environment_info(),
|
||||
"cases": [],
|
||||
"events": "events.jsonl",
|
||||
"failures": [],
|
||||
"blocked": [],
|
||||
"coverage": {"required": 0, "passed": 0},
|
||||
"exit_gate": {"checked": false, "process_code": null, "errors": []},
|
||||
}
|
||||
# Keep configuration useful for reproducing a run without copying any secret.
|
||||
_report["scenario_id"] = String(config.get("scenario_id", ""))
|
||||
|
||||
func add_case(case_id: String, status: String, reason := "", evidence: Array = [], duration_ms := 0.0) -> void:
|
||||
var normalized := status.to_upper()
|
||||
if not VALID_STATUSES.has(normalized):
|
||||
normalized = "FAIL"
|
||||
_fail("invalid case status for %s" % case_id)
|
||||
var item := {"id": case_id, "status": normalized, "duration_ms": duration_ms,
|
||||
"reason": reason, "evidence": _sanitize(evidence)}
|
||||
_report.cases.append(item)
|
||||
_report.coverage.required = int(_report.coverage.required) + 1
|
||||
if normalized == "PASS":
|
||||
_report.coverage.passed = int(_report.coverage.passed) + 1
|
||||
elif normalized == "FAIL":
|
||||
_fail("%s: %s" % [case_id, reason if not reason.is_empty() else "failed"])
|
||||
elif normalized == "BLOCKED":
|
||||
_report.blocked.append(reason if not reason.is_empty() else case_id)
|
||||
|
||||
func add_event(event: Dictionary, events_path: String) -> void:
|
||||
var safe: Variant = _sanitize(event)
|
||||
var file := FileAccess.open(events_path, FileAccess.READ_WRITE)
|
||||
if file == null:
|
||||
file = FileAccess.open(events_path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
_fail("cannot open events file")
|
||||
return
|
||||
file.seek_end()
|
||||
file.store_line(JSON.stringify(safe))
|
||||
file.close()
|
||||
|
||||
func finish() -> Dictionary:
|
||||
if _report.is_empty():
|
||||
return {}
|
||||
var required := int(_report.coverage.required)
|
||||
var passed := int(_report.coverage.passed)
|
||||
var failures: Array = _report.failures
|
||||
if not failures.is_empty():
|
||||
_report.status = "FAIL"
|
||||
elif required == 0 or passed != required or not _report.blocked.is_empty():
|
||||
_report.status = "BLOCKED"
|
||||
else:
|
||||
_report.status = "PASS"
|
||||
_report.duration_seconds = float(Time.get_ticks_usec() - _started_us) / 1000000.0
|
||||
return _report
|
||||
|
||||
func write(path: String) -> bool:
|
||||
var report := finish()
|
||||
if report.is_empty():
|
||||
return false
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return false
|
||||
file.store_string(JSON.stringify(report, " "))
|
||||
file.close()
|
||||
return true
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
_report.failures.append(message)
|
||||
|
||||
func _build_info() -> Dictionary:
|
||||
return {
|
||||
"engine_sha256": OS.get_environment("MT_BUILD_ENGINE_SHA256"),
|
||||
"extension_sha256": OS.get_environment("MT_BUILD_EXTENSION_SHA256"),
|
||||
"pck_sha256": OS.get_environment("MT_BUILD_PCK_SHA256"),
|
||||
"arch": OS.get_environment("MT_BUILD_ARCH"),
|
||||
}
|
||||
|
||||
func _environment_info() -> Dictionary:
|
||||
var size := DisplayServer.window_get_size() if DisplayServer.get_name() != "headless" else Vector2i.ZERO
|
||||
return {
|
||||
"os": OS.get_name(),
|
||||
"renderer": RenderingServer.get_video_adapter_name(),
|
||||
"resolution": [size.x, size.y],
|
||||
}
|
||||
|
||||
func _sanitize(value: Variant) -> Variant:
|
||||
if value is Dictionary:
|
||||
var out := {}
|
||||
for key in value:
|
||||
var name := String(key).to_lower()
|
||||
if name in ["password", "passwd", "token", "secret", "account"]:
|
||||
out[key] = "[redacted]"
|
||||
else:
|
||||
out[key] = _sanitize(value[key])
|
||||
return out
|
||||
if value is Array:
|
||||
var out_array: Array = []
|
||||
for item in value:
|
||||
out_array.append(_sanitize(item))
|
||||
return out_array
|
||||
if value is Vector2:
|
||||
return [value.x, value.y]
|
||||
if value is Vector3:
|
||||
return [value.x, value.y, value.z]
|
||||
if value is Object:
|
||||
return "[object]"
|
||||
return value
|
||||
@@ -238,7 +238,9 @@ func _build_stage() -> void:
|
||||
root.add_child(we)
|
||||
|
||||
_pivot = Node3D.new()
|
||||
_pivot.rotation.y = PI * 0.5
|
||||
# Animation sampling no longer reapplies the exported InitialPlacement;
|
||||
# the intro pose already faces the camera without a quarter-turn correction.
|
||||
_pivot.rotation.y = 0.0
|
||||
root.add_child(_pivot)
|
||||
|
||||
_cam = Camera3D.new()
|
||||
|
||||
@@ -98,6 +98,9 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
|
||||
if pump.is_valid(): pump.call()
|
||||
model = ClassDB.instantiate("Metin2Model")
|
||||
model.name = "Metin2Model"
|
||||
# NPC/monster GR2 uses the same source winding as PCs. Without conversion,
|
||||
# back-face culling exposes interiors and hides the outward-facing surfaces.
|
||||
model.set("flip_winding", true)
|
||||
model.set("texture_dir", _dir)
|
||||
model.set("gr2_path", gr2) # 重:解 gr2 + 建网格
|
||||
if pump.is_valid(): pump.call()
|
||||
@@ -129,6 +132,12 @@ func set_anim_state(s: String) -> void:
|
||||
anim.set("anim_path", msa)
|
||||
_refresh_sound_script(msa)
|
||||
|
||||
# Read-only motion resolver used by the forest acceptance harness. Returning
|
||||
# the selected .msa before playback lets the test distinguish a real action
|
||||
# from a silent "keep the previous animation" fallback.
|
||||
func get_motion_path(state: String) -> String:
|
||||
return _motion_for(state)
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
|
||||
or not bool(anim.get("playing")):
|
||||
|
||||
@@ -32,10 +32,9 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||
if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"):
|
||||
return false
|
||||
var cls: String = CLASS_OF[race & 3]
|
||||
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls
|
||||
# pc contains races 0..3 (including female assassin/shaman); pc2 is 4..7.
|
||||
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race >= 4 else "PC/ymir work/pc/%s" % cls
|
||||
var base := _resolve_dir(assets_root, pc_dir)
|
||||
if base == "":
|
||||
base = _resolve_dir(assets_root, "PC/ymir work/pc/%s" % cls) # 兜底用男模
|
||||
if base == "":
|
||||
return false
|
||||
motion_dir = base.path_join("general")
|
||||
|
||||
+12
-2
@@ -25,6 +25,7 @@ const JOB_DIR := {
|
||||
}
|
||||
|
||||
signal skill_activated(skill_id: int) # game_scene 接它播技能特效
|
||||
signal skill_cast_started(skill_id: int, target_vid: int) # resolved target, not later selection
|
||||
# §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案
|
||||
signal skill_rejected(skill_id: int, code: String)
|
||||
|
||||
@@ -44,6 +45,7 @@ var item_mouse: Node
|
||||
var _assets_root := ""
|
||||
var _slots := [] # 当前页 UI:[{btn, cd, lbl, icon, grade}]
|
||||
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
|
||||
var _skill_cooldowns := {} # skill identity survives slot moves/restores within this UI session
|
||||
var _page := 0
|
||||
var _move_from := -1
|
||||
var _mobile_mode := false
|
||||
@@ -93,7 +95,9 @@ func restore_from_server() -> void:
|
||||
continue
|
||||
match int(qs.get("type", 0)):
|
||||
1: restored[pos] = {"kind": "item", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
|
||||
2: restored[pos] = {"kind": "skill", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
|
||||
2:
|
||||
var skill_id := int(qs.get("ref", 0))
|
||||
restored[pos] = {"kind": "skill", "id": skill_id, "cd_end": float(_skill_cooldowns.get(skill_id, 0.0))}
|
||||
3: restored[pos] = {"kind": "emote", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
|
||||
_state = restored
|
||||
_refresh_page()
|
||||
@@ -108,7 +112,7 @@ func assign(slot: int, kind: String, id: int, persist := true) -> bool:
|
||||
var type := 2 if kind == "skill" else 1 if kind == "item" else 3 if kind == "emote" else 0
|
||||
if type == 0 or not client.quickslot_add(global, type, id):
|
||||
return false
|
||||
_state[global] = {"kind": kind, "id": id, "cd_end": 0.0}
|
||||
_state[global] = {"kind": kind, "id": id, "cd_end": float(_skill_cooldowns.get(id, 0.0)) if kind == "skill" else 0.0}
|
||||
_refresh_slot(slot)
|
||||
return true
|
||||
|
||||
@@ -207,8 +211,13 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
|
||||
if client.has_method("use_skill"):
|
||||
intent_sent = client.use_skill(int(s.id), target_vid)
|
||||
if intent_sent and client.cast_skill(mi, yaw, int(xy.x), int(xy.y)):
|
||||
skill_cast_started.emit(int(s.id), target_vid)
|
||||
s.cd_end = _now() + _skill_cooldown(int(s.id))
|
||||
_state[global] = s
|
||||
_skill_cooldowns[int(s.id)] = s.cd_end
|
||||
for peer in _state:
|
||||
if peer.kind == "skill" and peer.id == s.id:
|
||||
peer.cd_end = s.cd_end
|
||||
# §3.4:扇形 / 圆形技能的附加 fly-targeting(主目标已在 use_skill 里发过;
|
||||
# PythonPlayerSkill.cpp:684 的 `if (dwTargetMaxCount>0 …)` 补目标块)。
|
||||
if target_vid != 0 and net_play and table \
|
||||
@@ -550,6 +559,7 @@ func _refresh_page() -> void:
|
||||
_refresh_slot(slot)
|
||||
|
||||
func _on_cd_end(skill_id: int) -> void:
|
||||
_skill_cooldowns.erase(skill_id)
|
||||
for i in _state.size():
|
||||
var s: Dictionary = _state[i]
|
||||
if s.kind == "skill" and s.id == skill_id:
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
extends RefCounted
|
||||
|
||||
static var _cache := {}
|
||||
static var _dds_helper: Object = null
|
||||
|
||||
# DDS 运行时解码:走 Metin2World.load_dds(C++ 里的 dxt.cpp,DXT1/3/5 + BGRA8)。
|
||||
# Godot 无原生运行时 DDS 解码,UI 里的 select.dds / 职业名图都得走这条路。
|
||||
@@ -17,9 +16,12 @@ static func load_dds_image(path: String) -> Image:
|
||||
return null
|
||||
if not ClassDB.class_exists("Metin2World"):
|
||||
return null
|
||||
if _dds_helper == null or not is_instance_valid(_dds_helper):
|
||||
_dds_helper = ClassDB.instantiate("Metin2World")
|
||||
var img = _dds_helper.call("load_dds", path)
|
||||
# Metin2World is a Node, not RefCounted; a static reference never frees it.
|
||||
# The decoder returns an independently owned Image, so release the helper
|
||||
# after each decode (textures themselves remain cached by load_tex).
|
||||
var helper: Object = ClassDB.instantiate("Metin2World")
|
||||
var img = helper.call("load_dds", path)
|
||||
helper.free()
|
||||
return img if img is Image else null
|
||||
|
||||
static func load_tex(assets_root: String, vpath: String) -> Texture2D:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
extends SceneTree
|
||||
var failures := 0
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
if failures < 6: printerr("FAIL: " + label)
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var world = ClassDB.instantiate("Metin2World")
|
||||
world.auto_load = false
|
||||
world.objects_enabled = false
|
||||
world.env_enabled = false
|
||||
world.assets_root = AssetRoot.path()
|
||||
world.map_path = "OutdoorA1/metin2_map_a1"
|
||||
root.add_child(world)
|
||||
check(world.load_map(), "real A1 loads")
|
||||
var pieces := 0
|
||||
var vertices_checked := 0
|
||||
var near_shore := 0
|
||||
var boundary_vertices := 0
|
||||
for mesh in world.find_children("*", "MeshInstance3D", true, false):
|
||||
if not String(mesh.name).begins_with("Water"):
|
||||
continue
|
||||
pieces += 1
|
||||
var material: ShaderMaterial = mesh.mesh.surface_get_material(0)
|
||||
check(material.shader.code.contains("unshaded") and material.shader.code.contains("ALBEDO = tex;")
|
||||
and material.shader.code.contains("ALPHA = COLOR.a;"), "water selects texture RGB / vertex alpha")
|
||||
var arrays: Array = mesh.mesh.surface_get_arrays(0)
|
||||
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var colors: PackedColorArray = arrays[Mesh.ARRAY_COLOR]
|
||||
for i in vertices.size():
|
||||
var v := vertices[i]
|
||||
# sample_height selects a neighbouring chunk on the outer border;
|
||||
# no such chunk exists there. The mesh still has its padded edge data.
|
||||
if v.x >= 1024.0 or v.z >= 1280.0:
|
||||
boundary_vertices += 1
|
||||
continue
|
||||
var depth := (v.y - float(world.sample_height(v.x, v.z))) * 100.0
|
||||
var expected := clampf(depth / 200.0, 0.0, 0.8) # A1 HeightScale=0.5
|
||||
# ArrayMesh stores vertex colors as RGBA8, like legacy diffuse DWORD.
|
||||
check(absf(colors[i].a - expected) < 1.0 / 255.0 + 0.0001, "real shoreline depth formula pos=%s actual=%s expected=%s" % [v, colors[i].a, expected])
|
||||
if colors[i].a < 0.12: near_shore += 1
|
||||
vertices_checked += 1
|
||||
for i in range(0, vertices.size(), 4):
|
||||
check(absf(vertices[i + 1].x - vertices[i].x - 2.0) < 0.001, "retain every water cell's depth samples")
|
||||
check(pieces > 0 and vertices_checked > 0 and near_shore > 0, "water and shallow shoreline exercised")
|
||||
world.free()
|
||||
await process_frame
|
||||
print("water_reference_test: failures=%d pieces=%d vertices=%d shallow=%d boundary_skipped=%d (geometry/material, not 40250 pixels)" % [failures, pieces, vertices_checked, near_shore, boundary_vertices])
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hpjr6orgucph
|
||||
Reference in New Issue
Block a user