Files
mtgodot-poc/project/ui/weapon_trace.gd
T
shenandshen c93894313a fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
2026-09-21 16:38:59 -07:00

312 lines
10 KiB
GDScript

# WeaponTrace —— 40250 GameLib/WeaponTrace.cpp 的 Godot 适配层。
#
# 40250 的刀光不是一次性 MSE 特效:AttachWeapon 为可追踪武器创建对象,
# ActorInstance 每帧采样武器实例的刀根/刀尖世界坐标,RenderTrace 再把
# short/long 两条时间轨迹用 cubic spline 拼成 triangle strip。这里保留同一
# 状态边界和参数;渲染坐标在世界空间采样,在当前 PlayerView 局部渲染,
# 因此角色移动时已经生成的尾迹不会被父节点位移二次带走。
extends Node3D
const DEFAULT_LIFETIME := 0.18
const DEFAULT_SAMPLING_TIME := 0.003
const TRACE_COLOR := Color(0.3, 0.8, 1.0, 1.0)
var lifetime := DEFAULT_LIFETIME
var sampling_time := DEFAULT_SAMPLING_TIME
var reach_scale := 1.0 # CActorInstance::__GetReachScale()
var use_texture := false
var texture_path := ""
var _weapon: Node3D
var _playing := false
var _has_geometry := false
var _tip_local := Vector3.ZERO
var _short_points: Array = [] # newest first:刀根
var _long_points: Array = [] # newest first:刀尖
var _vertex_count := 0
var _mesh := ImmediateMesh.new()
var _mesh_instance := MeshInstance3D.new()
var _material := StandardMaterial3D.new()
func _ready() -> void:
_ensure_render_node()
func _ensure_render_node() -> void:
if _mesh_instance.get_parent() == null:
_mesh_instance.name = "WeaponTraceMesh"
_mesh_instance.mesh = _mesh
_mesh_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(_mesh_instance)
_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_material.blend_mode = BaseMaterial3D.BLEND_MODE_MIX
_material.cull_mode = BaseMaterial3D.CULL_DISABLED
_material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
_material.vertex_color_use_as_albedo = true
_mesh_instance.material_override = _material
_mesh_instance.visible = false
func attach_to_weapon(weapon_node: Node3D) -> bool:
if _weapon == weapon_node and is_instance_valid(_weapon):
return _has_geometry
turn_off()
clear()
_weapon = weapon_node
_has_geometry = false
_tip_local = Vector3.ZERO
if not is_instance_valid(_weapon):
return false
_has_geometry = _resolve_tip_local()
return _has_geometry
func detach_weapon() -> void:
turn_off()
clear()
_weapon = null
_has_geometry = false
_tip_local = Vector3.ZERO
func turn_on() -> void:
# 40250 TurnOn only changes the playing flag; the existing tail remains and
# naturally fades, so combo segments do not pop the trace between swings.
_playing = true
func turn_off() -> void:
# 40250 TurnOff deliberately does not Clear(): old samples remain visible
# until lifetime expiry. Equipment replacement uses detach_weapon(), which
# is the DestroyWeaponTrace/Clear path instead.
_playing = false
func set_lifetime(value: float) -> void:
lifetime = maxf(value, 0.001)
_trim_expired()
func set_sampling_time(value: float) -> void:
sampling_time = maxf(value, 0.0001)
func set_reach_scale(value: float) -> void:
# 40250 applies this scale when extending the trace from the bone matrix;
# recompute the cached local endpoint so an actor-scale change takes effect
# before the next sample.
reach_scale = maxf(value, 0.0)
if is_instance_valid(_weapon):
_has_geometry = _resolve_tip_local()
func set_texture(path: String) -> void:
texture_path = path
use_texture = path != ""
_material.albedo_texture = _load_texture(path) if use_texture else null
func use_alpha() -> void:
use_texture = false
_material.albedo_texture = null
func use_texture_mode() -> void:
use_texture = true
_material.albedo_texture = _load_texture(texture_path) if texture_path != "" else null
func is_playing() -> bool:
return _playing
func debug_state() -> Dictionary:
return {
"playing": _playing,
"has_geometry": _has_geometry,
"short_points": _short_points.size(),
"long_points": _long_points.size(),
"vertex_count": _vertex_count,
"lifetime": lifetime,
"sampling_time": sampling_time,
"reach_scale": reach_scale,
"tip_local": _tip_local,
"tip_length": _tip_local.length(),
"use_texture": use_texture,
}
func _process(delta: float) -> void:
_ensure_render_node()
var elapsed := maxf(delta, 0.0)
_age_points(elapsed)
if _playing and _has_geometry and is_instance_valid(_weapon):
_sample_weapon()
_render_trace()
func _sample_weapon() -> void:
var root_world := _weapon.global_transform.origin
var tip_world := _weapon.global_transform * (_tip_local)
_short_points.push_front({"age": 0.0, "pos": root_world})
_long_points.push_front({"age": 0.0, "pos": tip_world})
_trim_expired()
func _age_points(delta: float) -> void:
for sample in _short_points:
sample["age"] = float(sample.get("age", 0.0)) + delta
for sample in _long_points:
sample["age"] = float(sample.get("age", 0.0)) + delta
_trim_expired()
func _trim_expired() -> void:
while not _short_points.is_empty() and float(_short_points.back().get("age", 0.0)) > lifetime:
_short_points.pop_back()
while not _long_points.is_empty() and float(_long_points.back().get("age", 0.0)) > lifetime:
_long_points.pop_back()
func clear() -> void:
_short_points.clear()
_long_points.clear()
_vertex_count = 0
if is_instance_valid(_mesh):
_mesh.clear_surfaces()
if is_instance_valid(_mesh_instance):
_mesh_instance.visible = false
func _resolve_tip_local() -> bool:
# CWeaponTrace::SetWeaponInstance computes m_fLength from the maximum
# distance between the weapon bone origin and GetBoundBox() corners. It does
# not select that corner as the direction of the blade. Update() then moves
# along the composite bone's local Z axis by m_fLength * reach_scale.
var inverse := _weapon.global_transform.affine_inverse()
var farthest := 0.0
var meshes: Array[MeshInstance3D] = []
if _weapon is MeshInstance3D:
meshes.append(_weapon as MeshInstance3D)
for child in _weapon.find_children("*", "MeshInstance3D", true, false):
var mi := child as MeshInstance3D
if mi != null and not meshes.has(mi):
meshes.append(mi)
for mi in meshes:
if mi.mesh == null:
continue
var box := mi.get_aabb()
for corner in 8:
var local_point := box.position + Vector3(
box.size.x if (corner & 1) != 0 else 0.0,
box.size.y if (corner & 2) != 0 else 0.0,
box.size.z if (corner & 4) != 0 else 0.0)
var world_point: Vector3 = mi.global_transform * local_point
var weapon_local: Vector3 = inverse * world_point
var distance := weapon_local.length_squared()
if distance > farthest:
farthest = distance
if farthest <= 0.000001 or is_zero_approx(reach_scale):
return false
_tip_local = Vector3(0.0, 0.0, sqrt(farthest) * reach_scale)
return _tip_local.length_squared() > 0.000001
func _render_trace() -> void:
if _short_points.size() <= 1 or _long_points.size() <= 1:
_vertex_count = 0
_mesh.clear_surfaces()
_mesh_instance.visible = false
return
var vertices := _build_vertices()
_vertex_count = vertices.size()
_mesh.clear_surfaces()
if vertices.size() < 4:
_mesh_instance.visible = false
return
_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLE_STRIP, _material)
for vertex in vertices:
_mesh.surface_set_color(Color(TRACE_COLOR.r, TRACE_COLOR.g, TRACE_COLOR.b,
float(vertex.get("alpha", 0.0))))
_mesh.surface_set_uv(vertex.get("uv", Vector2.ZERO))
_mesh.surface_add_vertex(to_local(vertex.get("pos", Vector3.ZERO)))
_mesh.surface_end()
_mesh_instance.visible = true
func _build_vertices() -> Array:
var length := minf(lifetime, float(_long_points.back().get("age", 0.0)))
var out: Array = []
# Reference BuildVertex renders the long track first (UV y=0), then the
# short track (UV y=1), interleaving the pair into a triangle strip.
var long_vertices := _build_spline(_long_points, true, length)
var short_vertices := _build_spline(_short_points, false, length)
var count := mini(long_vertices.size(), short_vertices.size())
for i in count:
out.append(long_vertices[i])
out.append(short_vertices[i])
return out
func _build_spline(input: Array, is_long: bool, length: float) -> Array:
var out: Array = []
var n := input.size() - 1
if n <= 0:
return out
var h: Array = []
var r: Array = []
h.resize(n + 1)
r.resize(n + 1)
for i in n:
var dt := float(input[i + 1].get("age", 0.0)) - float(input[i].get("age", 0.0))
if dt <= 0.000001:
return out
h[i] = dt
var p0: Vector3 = input[i].get("pos", Vector3.ZERO)
var p1: Vector3 = input[i + 1].get("pos", Vector3.ZERO)
r[i] = (p1 - p0) * (3.0 / dt)
r[n] = Vector3.ZERO
for i in range(n, 0, -1):
r[i] = r[i] + r[i - 1]
var stack: Array = []
var rate := 0.5
r[0] = r[0] * 0.5
stack.append(rate)
for i in range(1, n):
r[i] = r[i] - r[i - 1]
rate = 1.0 / (4.0 - rate)
r[i] = r[i] * rate
stack.append(rate)
r[n] = r[n] - r[n - 1]
rate = 1.0 / (2.0 - rate)
r[n] = r[n] * rate
for i in range(n - 1, -1, -1):
r[i] = r[i] - float(stack.pop_back()) * r[i + 1]
var base := 0
var timebase := 0.0
var timenext := float(h[0])
var delta := maxf(sampling_time, 0.0001)
var a: Vector3 = input[0].get("pos", Vector3.ZERO)
var b: Vector3 = r[0]
var first_delta: Vector3 = input[1].get("pos", Vector3.ZERO) - a
var c: Vector3 = (3.0 * first_delta - r[1] * float(h[0]) - 2.0 * float(h[0]) * r[0]) \
/ (float(h[0]) * float(h[0]))
var d: Vector3 = (-2.0 * first_delta + (r[1] + r[0]) * float(h[0])) \
/ (float(h[0]) * float(h[0]) * float(h[0]))
var t := 0.0
var life := maxf(lifetime, 0.001)
while t <= length + 0.000001:
while t > timenext and base < n:
timebase = timenext
base += 1
if base >= n:
break
var seg_delta: Vector3 = input[base + 1].get("pos", Vector3.ZERO) - input[base].get("pos", Vector3.ZERO)
a = input[base].get("pos", Vector3.ZERO)
b = r[base]
c = (3.0 * seg_delta - r[base + 1] * float(h[base]) -
2.0 * float(h[base]) * r[base]) / (float(h[base]) * float(h[base]))
d = (-2.0 * seg_delta + (r[base + 1] + r[base]) * float(h[base])) / \
(float(h[base]) * float(h[base]) * float(h[base]))
timenext += float(h[base])
if base >= n:
break
var cc := t - timebase
var position: Vector3 = a + cc * (b + cc * (c + cc * d))
var ttt := clampf((t + float(input[0].get("age", 0.0))) / life, 0.0, 1.0)
var alpha := clampf((1.0 - ttt) * (1.0 - ttt) / 2.5 - 0.1, 0.0, 1.0) if is_long else 0.0
out.append({"pos": position, "uv": Vector2(t / life, 0.0 if is_long else 1.0), "alpha": alpha})
t += delta
return out
func _load_texture(path: String) -> Texture2D:
if path == "":
return null
if ResourceLoader.exists(path):
return ResourceLoader.load(path) as Texture2D
if not FileAccess.file_exists(path):
return null
var image := Image.load_from_file(path)
return ImageTexture.create_from_image(image) if image != null and not image.is_empty() else null