Tnua and dolly (#2)
* character controller + camera rig * make tnua work * cash collect
This commit is contained in:
55
Cargo.lock
generated
55
Cargo.lock
generated
@@ -428,6 +428,37 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bevy-tnua"
|
||||||
|
version = "0.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8a5783db12b6f927e7cc9976c15e23fd5b107319a9c1be8b92622fa10b94a62"
|
||||||
|
dependencies = [
|
||||||
|
"bevy",
|
||||||
|
"bevy-tnua-physics-integration-layer",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bevy-tnua-avian3d"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fb3348f84267ced2c4535bd3415b384df84d49b9d04824442aef7f3b6b3af37c"
|
||||||
|
dependencies = [
|
||||||
|
"avian3d",
|
||||||
|
"bevy",
|
||||||
|
"bevy-tnua-physics-integration-layer",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bevy-tnua-physics-integration-layer"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbe99d25b7253cb99a00f69fe9accc875645b4ecf9910bb5f24459835ff177e"
|
||||||
|
dependencies = [
|
||||||
|
"bevy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bevy_a11y"
|
name = "bevy_a11y"
|
||||||
version = "0.15.3"
|
version = "0.15.3"
|
||||||
@@ -643,6 +674,17 @@ dependencies = [
|
|||||||
"sysinfo",
|
"sysinfo",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bevy_dolly"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68783929254625d3ffa54a6fed7ff7216abf90296eea5e3885cde419d5daead0"
|
||||||
|
dependencies = [
|
||||||
|
"bevy",
|
||||||
|
"bevy_math",
|
||||||
|
"bevy_transform",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bevy_ecs"
|
name = "bevy_ecs"
|
||||||
version = "0.15.3"
|
version = "0.15.3"
|
||||||
@@ -723,15 +765,6 @@ dependencies = [
|
|||||||
"encase_derive_impl",
|
"encase_derive_impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bevy_flycam"
|
|
||||||
version = "0.15.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "83039a8e4f7e92c96c97b0b3ac738a54781f77ce5086d3ae39698219586a7f1d"
|
|
||||||
dependencies = [
|
|
||||||
"bevy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bevy_gilrs"
|
name = "bevy_gilrs"
|
||||||
version = "0.15.3"
|
version = "0.15.3"
|
||||||
@@ -2744,7 +2777,9 @@ dependencies = [
|
|||||||
"avian3d",
|
"avian3d",
|
||||||
"bevy",
|
"bevy",
|
||||||
"bevy-inspector-egui",
|
"bevy-inspector-egui",
|
||||||
"bevy_flycam",
|
"bevy-tnua",
|
||||||
|
"bevy-tnua-avian3d",
|
||||||
|
"bevy_dolly",
|
||||||
"bevy_trenchbroom",
|
"bevy_trenchbroom",
|
||||||
"nil",
|
"nil",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ opt-level = 3
|
|||||||
avian3d = "0.2"
|
avian3d = "0.2"
|
||||||
bevy = "0.15.3"
|
bevy = "0.15.3"
|
||||||
bevy_trenchbroom = { version = "0.6.2", features = ["auto_register", "avian"] }
|
bevy_trenchbroom = { version = "0.6.2", features = ["auto_register", "avian"] }
|
||||||
bevy_flycam = "0.15.0"
|
|
||||||
nil = "0.14.0"
|
nil = "0.14.0"
|
||||||
bevy-inspector-egui = "0.29.1"
|
bevy-inspector-egui = "0.29.1"
|
||||||
|
bevy-tnua = "0.21.0"
|
||||||
|
bevy-tnua-avian3d = "0.2.0"
|
||||||
|
bevy_dolly = { version = "0.0.5", default-features = false }
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
bevy_trenchbroom = { git = "https://github.com/Noxmore/bevy_trenchbroom.git", rev = "3b79f1b" }
|
bevy_trenchbroom = { git = "https://github.com/Noxmore/bevy_trenchbroom.git", rev = "3b79f1b" }
|
||||||
|
|||||||
38
src/camera.rs
Normal file
38
src/camera.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy_dolly::prelude::*;
|
||||||
|
|
||||||
|
impl GameCameraRig {
|
||||||
|
pub fn new_with_arm(arm: Vec3) -> Self {
|
||||||
|
Self(
|
||||||
|
CameraRig::builder()
|
||||||
|
.with(Position::new(Vec3::ZERO))
|
||||||
|
.with(Rotation::new(Quat::IDENTITY))
|
||||||
|
.with(Smooth::new_position(1.25).predictive(true))
|
||||||
|
.with(Smooth::new_rotation(10.))
|
||||||
|
.with(Arm::new(arm))
|
||||||
|
.with(
|
||||||
|
LookAt::new(Vec3::ZERO + Vec3::Y)
|
||||||
|
.tracking_smoothness(1.25)
|
||||||
|
.tracking_predictive(true),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_position_target(&mut self, target_position: Vec3, target_rotation: Quat) {
|
||||||
|
self.driver_mut::<Position>().position = target_position;
|
||||||
|
self.driver_mut::<Rotation>().rotation = target_rotation;
|
||||||
|
self.driver_mut::<LookAt>().target = target_position + Vec3::Y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A custom camera rig which combines smoothed movement with a look-at driver.
|
||||||
|
#[derive(Component, Debug, Deref, DerefMut)]
|
||||||
|
pub struct GameCameraRig(CameraRig);
|
||||||
|
|
||||||
|
// Turn the nested rig into a driver, so it can be used in another rig.
|
||||||
|
impl RigDriver for GameCameraRig {
|
||||||
|
fn update(&mut self, params: RigUpdateParams) -> Transform {
|
||||||
|
self.0.update(params.delta_time_seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/main.rs
47
src/main.rs
@@ -1,5 +1,7 @@
|
|||||||
mod alien;
|
mod alien;
|
||||||
|
mod camera;
|
||||||
mod cash;
|
mod cash;
|
||||||
|
mod player;
|
||||||
mod tb_entities;
|
mod tb_entities;
|
||||||
|
|
||||||
use avian3d::PhysicsPlugins;
|
use avian3d::PhysicsPlugins;
|
||||||
@@ -7,8 +9,11 @@ use avian3d::prelude::*;
|
|||||||
use bevy::core_pipeline::tonemapping::Tonemapping;
|
use bevy::core_pipeline::tonemapping::Tonemapping;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::render::view::ColorGrading;
|
use bevy::render::view::ColorGrading;
|
||||||
use bevy_flycam::prelude::*;
|
use bevy_dolly::prelude::*;
|
||||||
|
use bevy_tnua::prelude::TnuaControllerPlugin;
|
||||||
|
use bevy_tnua_avian3d::TnuaAvian3dPlugin;
|
||||||
use bevy_trenchbroom::prelude::*;
|
use bevy_trenchbroom::prelude::*;
|
||||||
|
use camera::GameCameraRig;
|
||||||
|
|
||||||
#[derive(Resource, Reflect, Debug)]
|
#[derive(Resource, Reflect, Debug)]
|
||||||
#[reflect(Resource)]
|
#[reflect(Resource)]
|
||||||
@@ -19,6 +24,9 @@ struct DebugVisuals {
|
|||||||
pub shadows: bool,
|
pub shadows: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct MainCamera;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|
||||||
@@ -35,18 +43,18 @@ fn main() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app.add_plugins(PhysicsPlugins::default());
|
app.add_plugins(PhysicsPlugins::default());
|
||||||
// app.add_plugins(PhysicsDebugPlugin::default());
|
app.add_plugins(PhysicsDebugPlugin::default());
|
||||||
|
app.add_plugins((
|
||||||
|
TnuaControllerPlugin::new(FixedUpdate),
|
||||||
|
TnuaAvian3dPlugin::new(FixedUpdate),
|
||||||
|
));
|
||||||
|
|
||||||
// bevy_flycam setup so we can get a closer look at the scene, mainly for debugging
|
app.add_systems(Update, Dolly::<MainCamera>::update_active);
|
||||||
app.add_plugins(PlayerPlugin);
|
|
||||||
|
|
||||||
app.add_plugins(alien::plugin);
|
app.add_plugins(alien::plugin);
|
||||||
app.add_plugins(cash::plugin);
|
app.add_plugins(cash::plugin);
|
||||||
|
app.add_plugins(player::plugin);
|
||||||
|
|
||||||
app.insert_resource(MovementSettings {
|
|
||||||
sensitivity: 0.00005,
|
|
||||||
speed: 12.,
|
|
||||||
});
|
|
||||||
app.insert_resource(AmbientLight {
|
app.insert_resource(AmbientLight {
|
||||||
color: Color::WHITE,
|
color: Color::WHITE,
|
||||||
brightness: 400.,
|
brightness: 400.,
|
||||||
@@ -55,7 +63,7 @@ fn main() {
|
|||||||
|
|
||||||
app.add_plugins(TrenchBroomPlugin(TrenchBroomConfig::new("hedz")));
|
app.add_plugins(TrenchBroomPlugin(TrenchBroomConfig::new("hedz")));
|
||||||
|
|
||||||
app.add_systems(Startup, (write_trenchbroom_config, music));
|
app.add_systems(Startup, (setup_cam, write_trenchbroom_config, music));
|
||||||
app.add_systems(PostStartup, setup_scene);
|
app.add_systems(PostStartup, setup_scene);
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -65,6 +73,16 @@ fn main() {
|
|||||||
app.run();
|
app.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup_cam(mut commands: Commands) {
|
||||||
|
commands.spawn((
|
||||||
|
Camera3d::default(),
|
||||||
|
MainCamera,
|
||||||
|
Rig::builder()
|
||||||
|
.with(GameCameraRig::new_with_arm(Vec3::new(0., 5., 14.)))
|
||||||
|
.build(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_scene(mut commands: Commands, asset_server: Res<AssetServer>) {
|
fn setup_scene(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||||
commands.spawn(SceneRoot(asset_server.load("maps/map1.map#Scene")));
|
commands.spawn(SceneRoot(asset_server.load("maps/map1.map#Scene")));
|
||||||
|
|
||||||
@@ -80,14 +98,6 @@ fn setup_scene(mut commands: Commands, asset_server: Res<AssetServer>) {
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
commands.spawn((
|
|
||||||
SceneRoot(
|
|
||||||
asset_server
|
|
||||||
.load(GltfAssetLabel::Scene(0).from_asset("models/heads/angry_demonstrator.glb")),
|
|
||||||
),
|
|
||||||
Transform::from_xyz(0.0, 5.0, 0.0),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_box(
|
fn spawn_box(
|
||||||
@@ -110,9 +120,6 @@ fn spawn_box(
|
|||||||
|
|
||||||
fn music(asset_server: Res<AssetServer>, mut commands: Commands) {
|
fn music(asset_server: Res<AssetServer>, mut commands: Commands) {
|
||||||
commands.spawn(AudioPlayer::new(asset_server.load("sfx/music/02.ogg")));
|
commands.spawn(AudioPlayer::new(asset_server.load("sfx/music/02.ogg")));
|
||||||
commands.spawn(AudioPlayer::new(
|
|
||||||
asset_server.load("sfx/heads/angry_demonstrator.ogg"),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_trenchbroom_config(server: Res<TrenchBroomServer>) {
|
fn write_trenchbroom_config(server: Res<TrenchBroomServer>) {
|
||||||
|
|||||||
189
src/player.rs
Normal file
189
src/player.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use crate::{camera::GameCameraRig, cash::Cash, tb_entities::SpawnPoint};
|
||||||
|
use avian3d::prelude::*;
|
||||||
|
use bevy::{
|
||||||
|
input::mouse::MouseMotion,
|
||||||
|
prelude::*,
|
||||||
|
window::{CursorGrabMode, PrimaryWindow},
|
||||||
|
};
|
||||||
|
use bevy_dolly::prelude::Rig;
|
||||||
|
use bevy_tnua::{TnuaUserControlsSystemSet, prelude::*};
|
||||||
|
use bevy_tnua_avian3d::TnuaAvian3dSensorShape;
|
||||||
|
|
||||||
|
#[derive(Component, Default)]
|
||||||
|
pub struct Player;
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PlayerSpawned {
|
||||||
|
spawned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plugin(app: &mut App) {
|
||||||
|
app.init_resource::<PlayerSpawned>();
|
||||||
|
app.add_systems(Startup, initial_grab_cursor);
|
||||||
|
app.add_systems(Update, (spawn, update_camera, cursor_events, collect_cash));
|
||||||
|
app.add_systems(
|
||||||
|
FixedUpdate,
|
||||||
|
apply_controls.in_set(TnuaUserControlsSystemSet),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn(
|
||||||
|
mut commands: Commands,
|
||||||
|
asset_server: Res<AssetServer>,
|
||||||
|
query: Query<&Transform, With<SpawnPoint>>,
|
||||||
|
mut player_spawned: ResMut<PlayerSpawned>,
|
||||||
|
) {
|
||||||
|
if player_spawned.spawned {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(spawn) = query.iter().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let transform = Transform::from_translation(spawn.translation + Vec3::new(0., 3., 0.));
|
||||||
|
|
||||||
|
let mesh = asset_server
|
||||||
|
.load(GltfAssetLabel::Scene(0).from_asset("models/heads/angry demonstrator.glb"));
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
Name::from("player"),
|
||||||
|
Player,
|
||||||
|
transform,
|
||||||
|
Visibility::default(),
|
||||||
|
RigidBody::Dynamic,
|
||||||
|
Collider::capsule(1.2, 1.5),
|
||||||
|
LockedAxes::ROTATION_LOCKED,
|
||||||
|
TnuaController::default(),
|
||||||
|
TnuaAvian3dSensorShape(Collider::cylinder(1.0, 0.0)),
|
||||||
|
))
|
||||||
|
.with_child((
|
||||||
|
Transform::from_translation(Vec3::new(0., 1.0, 0.))
|
||||||
|
.with_rotation(Quat::from_rotation_y(std::f32::consts::PI)),
|
||||||
|
SceneRoot(mesh),
|
||||||
|
));
|
||||||
|
|
||||||
|
commands.spawn(AudioPlayer::new(
|
||||||
|
asset_server.load("sfx/heads/angry demonstrator.ogg"),
|
||||||
|
));
|
||||||
|
|
||||||
|
player_spawned.spawned = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_events(
|
||||||
|
mut mouse: EventReader<MouseMotion>,
|
||||||
|
mut player: Query<&mut Transform, With<Player>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut player) = player.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for ev in mouse.read() {
|
||||||
|
player.rotate_y(ev.delta.x * -0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_grab_cursor(window: &mut Window) {
|
||||||
|
match window.cursor_options.grab_mode {
|
||||||
|
CursorGrabMode::None => {
|
||||||
|
window.cursor_options.grab_mode = CursorGrabMode::Confined;
|
||||||
|
window.cursor_options.visible = false;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
window.cursor_options.grab_mode = CursorGrabMode::None;
|
||||||
|
window.cursor_options.visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_grab_cursor(mut primary_window: Query<&mut Window, With<PrimaryWindow>>) {
|
||||||
|
if let Ok(mut window) = primary_window.get_single_mut() {
|
||||||
|
toggle_grab_cursor(&mut window);
|
||||||
|
} else {
|
||||||
|
warn!("Primary window not found for `initial_grab_cursor`!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_controls(
|
||||||
|
keyboard: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut query: Query<&mut TnuaController>,
|
||||||
|
player: Query<&Transform, With<Player>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut controller) = query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(player) = player.get_single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut direction = Vec3::ZERO;
|
||||||
|
|
||||||
|
if keyboard.pressed(KeyCode::KeyW) {
|
||||||
|
direction = player.forward().as_vec3();
|
||||||
|
}
|
||||||
|
if keyboard.pressed(KeyCode::KeyS) {
|
||||||
|
direction = player.forward().as_vec3() * -1.;
|
||||||
|
}
|
||||||
|
if keyboard.pressed(KeyCode::KeyA) {
|
||||||
|
direction += player.left().as_vec3();
|
||||||
|
}
|
||||||
|
if keyboard.pressed(KeyCode::KeyD) {
|
||||||
|
direction += player.right().as_vec3();
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.basis(TnuaBuiltinWalk {
|
||||||
|
// The `desired_velocity` determines how the character will move.
|
||||||
|
desired_velocity: direction.normalize_or_zero() * 10.0,
|
||||||
|
// The `float_height` must be greater (even if by little) from the distance between the
|
||||||
|
// character's center and the lowest point of its collider.
|
||||||
|
float_height: 3.0,
|
||||||
|
// `TnuaBuiltinWalk` has many other fields for customizing the movement - but they have
|
||||||
|
// sensible defaults. Refer to the `TnuaBuiltinWalk`'s documentation to learn what they do.
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Feed the jump action every frame as long as the player holds the jump button. If the player
|
||||||
|
// stops holding the jump button, simply stop feeding the action.
|
||||||
|
if keyboard.pressed(KeyCode::Space) {
|
||||||
|
controller.action(TnuaBuiltinJump {
|
||||||
|
// The height is the only mandatory field of the jump button.
|
||||||
|
height: 4.0,
|
||||||
|
// `TnuaBuiltinJump` also has customization fields with sensible defaults.
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_camera(player: Query<&Transform, With<Player>>, mut rig: Single<&mut Rig>) {
|
||||||
|
let Some(player) = player.iter().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
rig.driver_mut::<GameCameraRig>()
|
||||||
|
.set_position_target(player.translation, player.rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_cash(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut collision_event_reader: EventReader<CollisionStarted>,
|
||||||
|
query_player: Query<&Player>,
|
||||||
|
query_cash: Query<&Cash>,
|
||||||
|
asset_server: Res<AssetServer>,
|
||||||
|
) {
|
||||||
|
for CollisionStarted(e1, e2) in collision_event_reader.read() {
|
||||||
|
let collect = if query_player.contains(*e1) && query_cash.contains(*e2) {
|
||||||
|
Some(*e2)
|
||||||
|
} else if query_player.contains(*e2) && query_cash.contains(*e1) {
|
||||||
|
Some(*e1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(cash) = collect {
|
||||||
|
commands.spawn(AudioPlayer::new(asset_server.load("sfx/effects/cash.ogg")));
|
||||||
|
commands.entity(cash).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,9 +89,12 @@ impl CashSpawn {
|
|||||||
|
|
||||||
let mesh = asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/cash.glb"));
|
let mesh = asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/cash.glb"));
|
||||||
|
|
||||||
world
|
world.commands().entity(entity).insert((
|
||||||
.commands()
|
Name::new("cash"),
|
||||||
.entity(entity)
|
SceneRoot(mesh),
|
||||||
.insert((Name::new("cash"), SceneRoot(mesh), Cash));
|
Cash,
|
||||||
|
Collider::cuboid(2., 3.0, 2.),
|
||||||
|
Sensor,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user