Add new scene files and implement stage impact resolver
- Created three new scene files: `wobble.scene`, `wobble2.scene`, and `wobble3.scene` with various terrain and prop configurations. - Introduced `stage_impact_resolver.gd` script to manage impact detection and response between props and stickmen. - Added unit tests for actor blocking overlap to ensure proper interaction with stationary props.
This commit is contained in:
@@ -71,6 +71,9 @@ const LEG_COLLISION_RADIUS: float = 14.0
|
||||
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
|
||||
const COLLISION_LAYER: int = 1
|
||||
const WALK_BLOCKED_TIMEOUT: float = 0.45
|
||||
## Temporary, throttled diagnostics for actor-blocking prop sweep queries.
|
||||
const ACTOR_PROP_DEBUG_ENABLED: bool = true
|
||||
const ACTOR_PROP_DEBUG_INTERVAL_FRAMES: int = 30
|
||||
const COLLISION_PARTS: Dictionary = {
|
||||
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
|
||||
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
|
||||
@@ -83,6 +86,14 @@ const COLLISION_PARTS: Dictionary = {
|
||||
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
}
|
||||
const ACTOR_BLOCKING_SWEEP_PARTS: Array[String] = [
|
||||
"torso",
|
||||
"head",
|
||||
"left_upper_leg",
|
||||
"left_lower_leg",
|
||||
"right_upper_leg",
|
||||
"right_lower_leg",
|
||||
]
|
||||
const REACTION_BASE_ANGLE: float = 0.35
|
||||
const REACTION_REFERENCE_SPEED: float = 400.0
|
||||
const REACTION_MAX_ANGLE: float = 0.6
|
||||
@@ -101,6 +112,7 @@ const REACTION_TARGETS: Dictionary = {
|
||||
}
|
||||
|
||||
signal arrived(target: Vector2)
|
||||
signal blocking_prop_sweep_hit(prop: PropBlock, normal: Vector2, world_position: Vector2, requested_velocity: Vector2)
|
||||
|
||||
var _rig: StickmanRig = null
|
||||
var foot_contacts: Dictionary = {"Left_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false}}
|
||||
@@ -146,6 +158,11 @@ var _blocked_walk_elapsed: float = 0.0
|
||||
var _walk_failed: bool = false
|
||||
var collision_bodies: Dictionary = {}
|
||||
var collision_lengths: Dictionary = {}
|
||||
var collision_proxy_velocities: Dictionary = {}
|
||||
var _collision_proxy_positions: Dictionary = {}
|
||||
var _last_sweep_hit_tick: Dictionary = {}
|
||||
var _actor_prop_debug_last_sample_frame: int = -1
|
||||
var _actor_prop_debug_last_hit_tick: Dictionary = {}
|
||||
var _collision_visuals: Dictionary = {}
|
||||
var reaction_bones: Dictionary = {}
|
||||
var impact_reactions: Dictionary = {}
|
||||
@@ -176,6 +193,9 @@ func configure(rig: StickmanRig) -> void:
|
||||
func setup_collision_bodies() -> void:
|
||||
collision_bodies.clear()
|
||||
collision_lengths.clear()
|
||||
collision_proxy_velocities.clear()
|
||||
_collision_proxy_positions.clear()
|
||||
_last_sweep_hit_tick.clear()
|
||||
_collision_visuals.clear()
|
||||
reaction_bones.clear()
|
||||
if _rig == null or _rig._body_container == null or _rig._skeleton == null:
|
||||
@@ -226,14 +246,42 @@ func setup_collision_bodies() -> void:
|
||||
proxy.global_transform = visual.global_transform
|
||||
collision_bodies[key] = proxy
|
||||
_collision_visuals[key] = visual
|
||||
_collision_proxy_positions[proxy.get_instance_id()] = proxy.global_position
|
||||
collision_proxy_velocities[proxy.get_instance_id()] = Vector2.ZERO
|
||||
|
||||
|
||||
func sync_collision_bodies() -> void:
|
||||
var physics_delta: float = 1.0 / maxf(1.0, float(Engine.physics_ticks_per_second))
|
||||
for key: String in collision_bodies:
|
||||
var proxy: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D
|
||||
var visual: Node2D = _collision_visuals.get(key) as Node2D
|
||||
if proxy != null and visual != null and is_instance_valid(proxy) and is_instance_valid(visual):
|
||||
var proxy_id: int = proxy.get_instance_id()
|
||||
var target_position: Vector2 = visual.global_position
|
||||
var previous_position: Vector2 = _collision_proxy_positions.get(proxy_id, target_position) as Vector2
|
||||
var velocity: Vector2 = (target_position - previous_position) / physics_delta
|
||||
collision_proxy_velocities[proxy_id] = velocity if velocity.is_finite() else Vector2.ZERO
|
||||
proxy.global_transform = visual.global_transform
|
||||
_collision_proxy_positions[proxy_id] = proxy.global_position
|
||||
|
||||
|
||||
func collision_proxy_velocity(body: PhysicsBody2D) -> Vector2:
|
||||
if body == null or not is_instance_valid(body):
|
||||
return Vector2.ZERO
|
||||
var velocity: Vector2 = collision_proxy_velocities.get(body.get_instance_id(), Vector2.ZERO) as Vector2
|
||||
return velocity if velocity.is_finite() else Vector2.ZERO
|
||||
|
||||
|
||||
func reset_collision_proxy_velocity_baselines() -> void:
|
||||
_collision_proxy_positions.clear()
|
||||
collision_proxy_velocities.clear()
|
||||
for proxy_value: Variant in collision_bodies.values():
|
||||
var proxy: AnimatableBody2D = proxy_value as AnimatableBody2D
|
||||
if proxy == null or not is_instance_valid(proxy):
|
||||
continue
|
||||
var proxy_id: int = proxy.get_instance_id()
|
||||
_collision_proxy_positions[proxy_id] = proxy.global_position
|
||||
collision_proxy_velocities[proxy_id] = Vector2.ZERO
|
||||
|
||||
|
||||
func mounted_visual_bounds_local(part: Node2D) -> Dictionary:
|
||||
@@ -362,6 +410,9 @@ func walk_to(target: Vector2, speed: float = -1.0) -> bool:
|
||||
|
||||
|
||||
func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_override: float = -1.0) -> bool:
|
||||
if ACTOR_PROP_DEBUG_ENABLED:
|
||||
_actor_prop_debug_last_sample_frame = -1
|
||||
_actor_prop_debug_last_hit_tick.clear()
|
||||
if _walk_debug_enabled():
|
||||
print("[WalkDebug] MOVE_TO style=%s target=%s root=%s" % [style, target, _rig.global_position if _rig != null else Vector2.ZERO])
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
|
||||
@@ -557,8 +608,17 @@ func _update_blocked_walk(delta: float) -> void:
|
||||
func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
|
||||
if _rig == null or is_zero_approx(motion.x):
|
||||
return {"fraction": 1.0}
|
||||
var debug_frame: int = Engine.get_physics_frames()
|
||||
var debug_sample: bool = ACTOR_PROP_DEBUG_ENABLED and (
|
||||
_actor_prop_debug_last_sample_frame < 0
|
||||
or debug_frame - _actor_prop_debug_last_sample_frame >= ACTOR_PROP_DEBUG_INTERVAL_FRAMES
|
||||
)
|
||||
if debug_sample:
|
||||
_actor_prop_debug_last_sample_frame = debug_frame
|
||||
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
||||
if space_state == null:
|
||||
if debug_sample:
|
||||
print("[PropCollisionDebug][Sweep] no_space_state rig=%s motion=%s" % [_rig.name, str(motion)])
|
||||
return {"fraction": 1.0}
|
||||
var excluded: Array[RID] = []
|
||||
for own_proxy: AnimatableBody2D in collision_bodies.values():
|
||||
@@ -568,12 +628,24 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
|
||||
if held != null and is_instance_valid(held):
|
||||
excluded.append(held.get_rid())
|
||||
var safe_fraction: float = 1.0
|
||||
for part_name: String in ["torso", "head"]:
|
||||
var best_hit_fraction: float = INF
|
||||
var best_hit: Dictionary = {}
|
||||
var requested_velocity: Vector2 = motion * float(Engine.physics_ticks_per_second)
|
||||
if debug_sample:
|
||||
print(
|
||||
"[PropCollisionDebug][Sweep] rig=%s root=%s motion=%s mask=%d excluded=%d"
|
||||
% [_rig.name, str(_rig.global_position), str(motion), STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER, excluded.size()]
|
||||
)
|
||||
for part_name: String in ACTOR_BLOCKING_SWEEP_PARTS:
|
||||
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
|
||||
if proxy == null or not is_instance_valid(proxy):
|
||||
if debug_sample:
|
||||
print("[PropCollisionDebug][SweepPart] part=%s missing_proxy" % part_name)
|
||||
continue
|
||||
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||
if shape_node == null or shape_node.shape == null:
|
||||
if debug_sample:
|
||||
print("[PropCollisionDebug][SweepPart] part=%s missing_shape" % part_name)
|
||||
continue
|
||||
var query := PhysicsShapeQueryParameters2D.new()
|
||||
query.shape = shape_node.shape
|
||||
@@ -581,11 +653,143 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
|
||||
query.motion = motion
|
||||
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
|
||||
query.exclude = excluded
|
||||
# cast_motion() ignores shapes that already overlap at the start. A
|
||||
# stickman can begin PLAY partly inside a placed prop, so inspect the
|
||||
# starting pose and block only motion that pushes farther into it. Motion
|
||||
# away from the contact normal remains free so the stickman can get out.
|
||||
var start_hit: Dictionary = space_state.get_rest_info(query)
|
||||
var start_prop: PropBlock = _prop_from_rest_info(start_hit)
|
||||
var start_normal: Vector2 = start_hit.get("normal", Vector2.ZERO) as Vector2
|
||||
var start_dot: float = start_normal.dot(motion)
|
||||
if debug_sample:
|
||||
print(
|
||||
"[PropCollisionDebug][SweepPart] part=%s shape=%s origin=%s disabled=%s start_id=%d start_prop=%s start_blocking=%s start_normal=%s dot=%s"
|
||||
% [
|
||||
part_name,
|
||||
shape_node.shape.get_class(),
|
||||
str(query.transform.origin),
|
||||
str(shape_node.disabled),
|
||||
int(start_hit.get("collider_id", 0)),
|
||||
start_prop.name if start_prop != null and is_instance_valid(start_prop) else "<unresolved>",
|
||||
str(start_prop.is_actor_blocking()) if start_prop != null and is_instance_valid(start_prop) else "false",
|
||||
str(start_normal),
|
||||
str(start_dot),
|
||||
]
|
||||
)
|
||||
if not start_hit.is_empty():
|
||||
var start_position: Vector2 = start_hit.get("point", Vector2.ZERO) as Vector2
|
||||
if (
|
||||
start_prop != null
|
||||
and is_instance_valid(start_prop)
|
||||
and start_prop.is_actor_blocking()
|
||||
and start_normal.is_finite()
|
||||
and not start_normal.is_zero_approx()
|
||||
and start_position.is_finite()
|
||||
and start_dot < -0.001
|
||||
):
|
||||
safe_fraction = 0.0
|
||||
if best_hit_fraction > 0.0:
|
||||
best_hit_fraction = 0.0
|
||||
best_hit = {
|
||||
"prop": start_prop,
|
||||
"normal": start_normal.normalized(),
|
||||
"position": start_position,
|
||||
}
|
||||
continue
|
||||
var cast: PackedFloat32Array = space_state.cast_motion(query)
|
||||
if cast.size() >= 1:
|
||||
safe_fraction = minf(safe_fraction, cast[0])
|
||||
if cast.size() < 1:
|
||||
if debug_sample:
|
||||
print("[PropCollisionDebug][SweepPart] part=%s cast returned no fractions" % part_name)
|
||||
continue
|
||||
safe_fraction = minf(safe_fraction, cast[0])
|
||||
if debug_sample:
|
||||
print(
|
||||
"[PropCollisionDebug][SweepPart] part=%s cast_safe=%.4f cast_unsafe=%.4f"
|
||||
% [part_name, cast[0], cast[1] if cast.size() > 1 else cast[0]]
|
||||
)
|
||||
if cast[0] >= 0.999:
|
||||
continue
|
||||
var unsafe_fraction: float = cast[1] if cast.size() > 1 else cast[0]
|
||||
var rest_query: PhysicsShapeQueryParameters2D = PhysicsShapeQueryParameters2D.new()
|
||||
rest_query.shape = shape_node.shape
|
||||
rest_query.transform = shape_node.global_transform
|
||||
rest_query.transform.origin += motion * minf(1.0, unsafe_fraction + 0.001)
|
||||
rest_query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
|
||||
rest_query.exclude = excluded
|
||||
var hit: Dictionary = space_state.get_rest_info(rest_query)
|
||||
var prop: PropBlock = _prop_from_rest_info(hit)
|
||||
if debug_sample:
|
||||
print(
|
||||
"[PropCollisionDebug][SweepHit] part=%s unsafe=%.4f id=%d prop=%s blocking=%s normal=%s point=%s"
|
||||
% [
|
||||
part_name,
|
||||
unsafe_fraction,
|
||||
int(hit.get("collider_id", 0)),
|
||||
prop.name if prop != null and is_instance_valid(prop) else "<unresolved>",
|
||||
str(prop.is_actor_blocking()) if prop != null and is_instance_valid(prop) else "false",
|
||||
str(hit.get("normal", Vector2.ZERO)),
|
||||
str(hit.get("point", Vector2.ZERO)),
|
||||
]
|
||||
)
|
||||
if prop == null or not is_instance_valid(prop) or not prop.is_actor_blocking():
|
||||
continue
|
||||
if unsafe_fraction >= best_hit_fraction:
|
||||
continue
|
||||
var normal: Vector2 = hit.get("normal", Vector2.ZERO) as Vector2
|
||||
var world_position: Vector2 = hit.get("point", Vector2.ZERO) as Vector2
|
||||
if not normal.is_finite() or normal.is_zero_approx() or not world_position.is_finite():
|
||||
continue
|
||||
var relative_velocity: Vector2 = requested_velocity - prop.get_impact_velocity()
|
||||
if normal.dot(relative_velocity) > 0.0:
|
||||
normal = -normal
|
||||
best_hit_fraction = unsafe_fraction
|
||||
best_hit = {"prop": prop, "normal": normal.normalized(), "position": world_position}
|
||||
if debug_sample:
|
||||
var result_prop: PropBlock = best_hit.get("prop") as PropBlock
|
||||
print(
|
||||
"[PropCollisionDebug][SweepResult] rig=%s fraction=%.4f hit=%s"
|
||||
% [_rig.name, safe_fraction, result_prop.name if result_prop != null and is_instance_valid(result_prop) else "<none>"]
|
||||
)
|
||||
if not best_hit.is_empty():
|
||||
var hit_prop: PropBlock = best_hit["prop"] as PropBlock
|
||||
var hit_tick: int = Engine.get_physics_frames()
|
||||
var hit_id: int = hit_prop.get_instance_id()
|
||||
if int(_last_sweep_hit_tick.get(hit_id, -1)) != hit_tick:
|
||||
_last_sweep_hit_tick[hit_id] = hit_tick
|
||||
var last_debug_hit_tick: int = int(_actor_prop_debug_last_hit_tick.get(hit_id, -ACTOR_PROP_DEBUG_INTERVAL_FRAMES))
|
||||
if ACTOR_PROP_DEBUG_ENABLED and hit_tick - last_debug_hit_tick >= ACTOR_PROP_DEBUG_INTERVAL_FRAMES:
|
||||
_actor_prop_debug_last_hit_tick[hit_id] = hit_tick
|
||||
print(
|
||||
"[PropCollisionDebug][Impact] rig=%s prop=%s id=%s normal=%s position=%s requested_velocity=%s"
|
||||
% [
|
||||
_rig.name,
|
||||
hit_prop.name,
|
||||
hit_prop.stage_object_id,
|
||||
str(best_hit["normal"]),
|
||||
str(best_hit["position"]),
|
||||
str(requested_velocity),
|
||||
]
|
||||
)
|
||||
blocking_prop_sweep_hit.emit(
|
||||
hit_prop,
|
||||
best_hit["normal"] as Vector2,
|
||||
best_hit["position"] as Vector2,
|
||||
requested_velocity
|
||||
)
|
||||
return {"fraction": safe_fraction}
|
||||
|
||||
|
||||
func _prop_from_rest_info(rest_info: Dictionary) -> PropBlock:
|
||||
# PhysicsDirectSpaceState2D.get_rest_info() returns collider_id rather than
|
||||
# the `collider` object key used by ray/shape intersection queries.
|
||||
var collider_id: int = int(rest_info.get("collider_id", 0))
|
||||
if collider_id <= 0:
|
||||
return null
|
||||
var collider: PropBlock = instance_from_id(collider_id) as PropBlock
|
||||
if collider == null or not is_instance_valid(collider):
|
||||
return null
|
||||
return collider
|
||||
|
||||
func _settle_walk_markers(delta: float) -> void:
|
||||
if not _settling or _rig == null:
|
||||
return
|
||||
@@ -826,6 +1030,7 @@ func prepare_for_ragdoll() -> void:
|
||||
set_terrain_following_enabled(false)
|
||||
|
||||
func reset_for_ragdoll_handoff() -> void:
|
||||
reset_collision_proxy_velocity_baselines()
|
||||
_cancel_landing_impact(false)
|
||||
air_pose_base.clear()
|
||||
air_pose_weight = 0.0
|
||||
|
||||
@@ -177,6 +177,7 @@ signal state_changed(new_state: int)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
signal arrived(target: Vector2) # walk_to reached its destination
|
||||
signal blocking_prop_sweep_hit(rig: StickmanRig, prop: PropBlock, normal: Vector2, world_position: Vector2, requested_velocity: Vector2)
|
||||
signal action_started(action: Dictionary, index: int)
|
||||
signal action_finished(action: Dictionary, index: int)
|
||||
signal queue_finished # queue ran to completion (not on stop)
|
||||
@@ -230,6 +231,7 @@ var source_stk_path: String = ""
|
||||
## boundaries while this facade preserves the long-standing public API.
|
||||
var _locomotion_component: StickmanLocomotion = null
|
||||
var _ragdoll_component: StickmanRagdoll = null
|
||||
var _ragdoll_velocity_samples: Dictionary = {}
|
||||
var _action_runner_component: StickmanActionRunner = null
|
||||
var _prop_interactor_component: Node = null
|
||||
var _simulation_paused: bool = false
|
||||
@@ -257,6 +259,7 @@ func _ready() -> void:
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.configure(self)
|
||||
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
|
||||
_locomotion_component.blocking_prop_sweep_hit.connect(_on_blocking_prop_sweep_hit)
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.configure(self, _locomotion_component)
|
||||
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
|
||||
@@ -326,6 +329,7 @@ func _ready() -> void:
|
||||
func _physics_process(delta: float) -> void:
|
||||
if _simulation_paused:
|
||||
return
|
||||
_sample_ragdoll_velocity_baselines()
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
|
||||
if _locomotion_component != null:
|
||||
@@ -347,6 +351,32 @@ func get_collision_bodies() -> Dictionary:
|
||||
return _locomotion_component.collision_bodies if _locomotion_component != null else {}
|
||||
|
||||
|
||||
func get_ragdoll_bodies() -> Array[RigidBody2D]:
|
||||
if _ragdoll_component == null:
|
||||
var empty: Array[RigidBody2D] = []
|
||||
return empty
|
||||
return _ragdoll_component.get_body_nodes()
|
||||
|
||||
|
||||
func collision_proxy_velocity(body: PhysicsBody2D) -> Vector2:
|
||||
return _locomotion_component.collision_proxy_velocity(body) if _locomotion_component != null else Vector2.ZERO
|
||||
|
||||
|
||||
func ragdoll_body_velocity(body: RigidBody2D) -> Vector2:
|
||||
if body == null or not is_instance_valid(body):
|
||||
return Vector2.ZERO
|
||||
var velocity: Vector2 = _ragdoll_velocity_samples.get(body.get_instance_id(), body.linear_velocity) as Vector2
|
||||
return velocity if velocity.is_finite() else Vector2.ZERO
|
||||
|
||||
|
||||
func _sample_ragdoll_velocity_baselines() -> void:
|
||||
if _ragdoll_component == null:
|
||||
return
|
||||
for body: RigidBody2D in _ragdoll_component.get_body_nodes():
|
||||
if body != null and is_instance_valid(body):
|
||||
_ragdoll_velocity_samples[body.get_instance_id()] = body.linear_velocity
|
||||
|
||||
|
||||
func get_collision_body_rids() -> Array[RID]:
|
||||
var result: Array[RID] = []
|
||||
for proxy: AnimatableBody2D in get_collision_bodies().values():
|
||||
@@ -492,6 +522,7 @@ func _set_state(next_state: int) -> void:
|
||||
if int(state) == next_state:
|
||||
return
|
||||
state = next_state
|
||||
reset_collision_proxy_velocity_baselines()
|
||||
state_changed.emit(int(state))
|
||||
|
||||
|
||||
@@ -595,6 +626,7 @@ func snap_to_standing() -> void:
|
||||
_action_runner_component.clear_pause_state()
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.clear_pause_snapshot()
|
||||
reset_collision_proxy_velocity_baselines()
|
||||
|
||||
|
||||
## Restores the spawn-marker pose through the ragdoll component. Kept on the
|
||||
@@ -687,6 +719,23 @@ func _resolve_limb_bones() -> void:
|
||||
func set_collision_bodies_enabled(enabled: bool) -> void:
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.set_collision_bodies_enabled(enabled)
|
||||
reset_collision_proxy_velocity_baselines()
|
||||
|
||||
|
||||
func reset_collision_proxy_velocity_baselines() -> void:
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.reset_collision_proxy_velocity_baselines()
|
||||
_ragdoll_velocity_samples.clear()
|
||||
_sample_ragdoll_velocity_baselines()
|
||||
|
||||
|
||||
func _on_blocking_prop_sweep_hit(
|
||||
prop: PropBlock,
|
||||
normal: Vector2,
|
||||
world_position: Vector2,
|
||||
requested_velocity: Vector2
|
||||
) -> void:
|
||||
blocking_prop_sweep_hit.emit(self, prop, normal, world_position, requested_velocity)
|
||||
|
||||
|
||||
func apply_collision_reaction(
|
||||
|
||||
Reference in New Issue
Block a user