Created
February 26, 2025 22:54
-
-
Save Lightnet/426b64d32635416fa0f359264322eea5 to your computer and use it in GitHub Desktop.
example switch cameras
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// example_cameras.rs | |
// A 3D first-person multi-camera example using Bevy 0.15.3 and Bevy Rapier3D 0.28. | |
// Features multiple players with switchable first-person cameras, mouse capture, WASD movement, Space key jumping, and a debug UI. | |
// Player with ID 0 is active by default; debug UI shows the current player ID via a dedicated 2D camera. | |
// --- Notes --- | |
// This application creates a 3D world with: | |
// - A static ground box and directional boxes (North, South, East, West) colored by world axes (Z, -Z, X, -X). | |
// - Players identified by `PlayerID`, each with a `Player` component and a `CameraPlayer` child (Camera3d). | |
// - Player 0 starts controllable with its camera active; Tab cycles through players. | |
// - Mouse capture (any input) and release (Escape) for rotation. | |
// - WASD moves the active player in the camera’s forward direction; Space triggers a jump when grounded (first-person perspective). | |
// - Debug UI (toggleable with F1) displays FPS and active player ID (e.g., "Player 0" or "Player 1") via a dedicated Camera2d. | |
// - A directional light ensures visibility with shadows. | |
// --- How It Works --- | |
// - **Players**: Each entity has a `Player` component with a unique `PlayerID` (usize). Queried with `Entity` to access relationships. | |
// - **Cameras**: Each player has a `CameraPlayer` child (Camera3d) at head level (0.0, 0.5, 0.0). A separate Camera2d renders the UI. | |
// - **Control**: `GameConfig.active_player_id` determines the controlled player. Systems use `Parent` to match players and 3D cameras. | |
// - **Movement**: WASD uses the active camera’s forward/right vectors (XZ plane); Space adds vertical velocity for jumping when grounded. | |
// - **Rotation**: Mouse rotates the active player’s Camera3d, with the body inheriting rotation via parenting. | |
// - **Switching**: Tab increments `active_player_id`, cycling through players and updating `is_active` for their Camera3d. | |
// - **Debug UI**: Rendered by Camera2d with high order (1), updates every frame with the current `active_player_id` when `debug_visible` is true. | |
// --- Imports --- | |
use bevy::prelude::*; | |
use bevy::window::CursorGrabMode; | |
use bevy::diagnostic::{FrameTimeDiagnosticsPlugin, DiagnosticsStore}; | |
use bevy_rapier3d::prelude::*; | |
// --- Components --- | |
#[derive(Component)] | |
struct Player { | |
id: usize, // Unique identifier for each player | |
} | |
#[derive(Component)] | |
struct CameraPlayer; // Marks the 3D camera component for each player | |
#[derive(Component)] | |
struct DebugText; // Marks the debug UI text entity | |
// --- Resources --- | |
#[derive(Resource)] | |
struct CaptureState { | |
captured: bool, // True when mouse is locked and hidden | |
} | |
#[derive(Resource)] | |
struct GameConfig { | |
movement_speed: f32, | |
mouse_sensitivity: f32, | |
active_player_id: usize, // ID of the currently controlled player (0-based) | |
debug_visible: bool, | |
jump_velocity: f32, // Initial upward velocity for jumping | |
gravity: f32, // Downward acceleration for falling | |
ground_timer: f32, // Time window (seconds) to allow jumping after grounding | |
} | |
impl Default for GameConfig { | |
fn default() -> Self { | |
GameConfig { | |
movement_speed: 8.0, | |
mouse_sensitivity: 0.2, | |
active_player_id: 0, // Start with Player ID 0 active | |
debug_visible: true, | |
jump_velocity: 5.0, // Matches grok_base01.rs | |
gravity: -9.81, // Standard gravity | |
ground_timer: 0.5, // 0.5s window for jumping after landing | |
} | |
} | |
} | |
// --- Main Application Setup --- | |
fn main() { | |
App::new() | |
.add_plugins(DefaultPlugins.set(bevy::log::LogPlugin { | |
..default() | |
})) | |
.add_plugins(RapierPhysicsPlugin::<NoUserData>::default()) | |
.add_plugins(RapierDebugRenderPlugin::default()) | |
.add_plugins(FrameTimeDiagnosticsPlugin) | |
.insert_resource(CaptureState { captured: false }) | |
.insert_resource(GameConfig::default()) | |
.add_systems(Startup, setup) | |
.add_systems(Update, (player_movement, camera_rotation, update_debug_ui)) | |
.run(); | |
} | |
// --- Systems --- | |
// Sets up the initial game world with entities | |
fn setup( | |
mut commands: Commands, | |
mut meshes: ResMut<Assets<Mesh>>, | |
mut materials: ResMut<Assets<StandardMaterial>>, | |
asset_server: Res<AssetServer>, | |
) { | |
// Ground box: large, static base surface | |
commands.spawn(( | |
Mesh3d(meshes.add(Cuboid::new(10.0, 0.2, 10.0))), | |
MeshMaterial3d(materials.add(Color::srgb(0.5, 0.5, 0.5))), | |
Transform::default(), | |
RigidBody::Fixed, | |
Collider::cuboid(5.0, 0.1, 5.0), | |
)); | |
// North box (Z-axis, blue) | |
commands.spawn(( | |
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), | |
MeshMaterial3d(materials.add(Color::srgb(0.0, 0.0, 1.0))), | |
Transform::from_xyz(0.0, 0.5, 5.0), | |
RigidBody::Fixed, | |
Collider::cuboid(0.5, 0.5, 0.5), | |
)); | |
// South box (-Z-axis, cyan) | |
commands.spawn(( | |
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), | |
MeshMaterial3d(materials.add(Color::srgb(0.0, 1.0, 1.0))), | |
Transform::from_xyz(0.0, 0.5, -5.0), | |
RigidBody::Fixed, | |
Collider::cuboid(0.5, 0.5, 0.5), | |
)); | |
// East box (X-axis, red) | |
commands.spawn(( | |
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), | |
MeshMaterial3d(materials.add(Color::srgb(1.0, 0.0, 0.0))), | |
Transform::from_xyz(5.0, 0.5, 0.0), | |
RigidBody::Fixed, | |
Collider::cuboid(0.5, 0.5, 0.5), | |
)); | |
// West box (-X-axis, pink) | |
commands.spawn(( | |
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), | |
MeshMaterial3d(materials.add(Color::srgb(1.0, 0.0, 1.0))), | |
Transform::from_xyz(-5.0, 0.5, 0.0), | |
RigidBody::Fixed, | |
Collider::cuboid(0.5, 0.5, 0.5), | |
)); | |
// Player 1 (ID 0) with CameraPlayer | |
let player1_entity = commands | |
.spawn(( | |
Mesh3d(meshes.add(Cuboid::new(0.5, 1.0, 0.5))), | |
MeshMaterial3d(materials.add(Color::srgb(0.0, 1.0, 0.0))), // Green | |
Transform::from_xyz(-2.0, 0.5, 0.0), | |
RigidBody::KinematicPositionBased, | |
Collider::cuboid(0.25, 0.5, 0.25), | |
KinematicCharacterController { | |
offset: CharacterLength::Absolute(0.01), | |
..default() | |
}, | |
Player { id: 0 }, | |
)) | |
.id(); | |
commands | |
.spawn(( | |
Camera { | |
is_active: true, | |
order: 0, // Lower order for 3D cameras | |
..default() | |
}, | |
Camera3d::default(), | |
Transform::from_xyz(0.0, 0.5, 0.0).looking_at(Vec3::new(0.0, 0.5, -1.0), Vec3::Y), | |
CameraPlayer, | |
)) | |
.set_parent(player1_entity); | |
// Player 2 (ID 1) with CameraPlayer | |
let player2_entity = commands | |
.spawn(( | |
Mesh3d(meshes.add(Cuboid::new(0.5, 1.0, 0.5))), | |
MeshMaterial3d(materials.add(Color::srgb(1.0, 1.0, 0.0))), // Yellow | |
Transform::from_xyz(2.0, 0.5, 0.0), | |
RigidBody::KinematicPositionBased, | |
Collider::cuboid(0.25, 0.5, 0.25), | |
KinematicCharacterController { | |
offset: CharacterLength::Absolute(0.01), | |
..default() | |
}, | |
Player { id: 1 }, | |
)) | |
.id(); | |
commands | |
.spawn(( | |
Camera { | |
is_active: false, | |
order: 0, // Same order as Player 1’s camera | |
..default() | |
}, | |
Camera3d::default(), | |
Transform::from_xyz(0.0, 0.5, 0.0).looking_at(Vec3::new(0.0, 0.5, -1.0), Vec3::Y), | |
CameraPlayer, | |
)) | |
.set_parent(player2_entity); | |
// Lights | |
commands.spawn(( | |
DirectionalLight { | |
illuminance: 10000.0, | |
shadows_enabled: true, | |
..default() | |
}, | |
Transform::from_xyz(4.0, 8.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y), | |
)); | |
// Debug UI with dedicated Camera2d | |
let font = asset_server.load("fonts/FiraSans-Bold.ttf"); | |
commands.spawn(( | |
Node { | |
position_type: PositionType::Absolute, | |
top: Val::Px(5.0), | |
left: Val::Px(5.0), | |
..default() | |
}, | |
)).with_children(|parent| { | |
parent.spawn(( | |
Text::new(""), | |
TextFont { | |
font, | |
font_size: 20.0, | |
..default() | |
}, | |
TextColor(Color::WHITE), | |
DebugText, | |
)); | |
}); | |
// Add Camera2d for UI rendering, last in order to overlay 3D | |
commands.spawn(( | |
Camera2d { | |
..default() | |
}, | |
Camera { | |
order: 1, // Higher order ensures UI renders on top | |
..default() | |
}, | |
)); | |
} | |
// Handles player movement, including WASD and jumping with Space | |
fn player_movement( | |
time: Res<Time>, | |
keys: Res<ButtonInput<KeyCode>>, | |
config: Res<GameConfig>, | |
mut player_query: Query<( | |
Entity, | |
&mut KinematicCharacterController, | |
Option<&KinematicCharacterControllerOutput>, | |
&Player, | |
)>, | |
camera_query: Query<(&Transform, &Parent), With<CameraPlayer>>, | |
capture: Res<CaptureState>, | |
mut grounded_timer: Local<f32>, // Tracks time since last grounded for jumping | |
) { | |
let delta_time = time.delta_secs(); | |
if !capture.captured { | |
return; | |
} | |
for (player_entity, mut controller, output, player) in player_query.iter_mut() { | |
if player.id == config.active_player_id { | |
if let Some((camera_transform, _)) = camera_query.iter().find(|(_, parent)| parent.get() == player_entity) { | |
let grounded = output.map(|o| o.grounded).unwrap_or(false); | |
let mut movement = Vec3::ZERO; | |
let mut vertical_movement = controller.translation.unwrap_or(Vec3::ZERO).y; | |
// Reset grounded timer when on ground | |
if grounded { | |
*grounded_timer = config.ground_timer; // 0.5s window | |
if vertical_movement < 0.0 { // Reset falling velocity | |
vertical_movement = 0.0; | |
} | |
} | |
// Horizontal movement based on camera direction | |
let forward = Vec3::new(camera_transform.forward().x, 0.0, camera_transform.forward().z).normalize_or_zero(); | |
let right = Vec3::new(camera_transform.right().x, 0.0, camera_transform.right().z).normalize_or_zero(); | |
if keys.pressed(KeyCode::KeyW) { movement += forward; } | |
if keys.pressed(KeyCode::KeyS) { movement -= forward; } | |
if keys.pressed(KeyCode::KeyA) { movement -= right; } | |
if keys.pressed(KeyCode::KeyD) { movement += right; } | |
if movement != Vec3::ZERO { | |
movement = movement.normalize() * config.movement_speed * delta_time; | |
} | |
// Jumping with Space key within grounded timer window | |
if *grounded_timer > 0.0 { | |
*grounded_timer -= delta_time; | |
if keys.just_pressed(KeyCode::Space) { | |
vertical_movement = config.jump_velocity; // Apply jump velocity | |
*grounded_timer = 0.0; // Reset timer to prevent double jump | |
println!("Jump initiated for Player {}: {}", player.id, vertical_movement); | |
} | |
} | |
// Apply gravity when not grounded or during jump | |
if !grounded || vertical_movement > 0.0 { | |
vertical_movement += config.gravity * delta_time; | |
} | |
// Combine horizontal and vertical movement | |
movement.y = vertical_movement * delta_time; | |
controller.translation = Some(movement); | |
} | |
break; | |
} | |
} | |
} | |
// Manages camera rotation, mouse capture, and player switching | |
fn camera_rotation( | |
mut windows: Query<&mut Window>, | |
mouse: Res<ButtonInput<MouseButton>>, | |
keys: Res<ButtonInput<KeyCode>>, | |
mut motion: EventReader<bevy::input::mouse::MouseMotion>, | |
mut capture: ResMut<CaptureState>, | |
mut config: ResMut<GameConfig>, | |
player_query: Query<(Entity, &Player)>, | |
mut camera_query: Query<(&mut Transform, &mut Camera, &Parent, &CameraPlayer)>, | |
) { | |
let mut window = windows.single_mut(); | |
if !capture.captured { | |
if keys.get_just_pressed().next().is_some() || mouse.get_just_pressed().next().is_some() { | |
capture.captured = true; | |
window.cursor_options.visible = false; | |
window.cursor_options.grab_mode = CursorGrabMode::Locked; | |
println!("Mouse captured"); | |
} | |
} | |
if capture.captured && keys.just_pressed(KeyCode::Escape) { | |
capture.captured = false; | |
window.cursor_options.visible = true; | |
window.cursor_options.grab_mode = CursorGrabMode::None; | |
println!("Mouse released"); | |
} | |
let player_count = player_query.iter().count(); | |
if keys.just_pressed(KeyCode::Tab) { | |
config.active_player_id = (config.active_player_id + 1) % player_count; | |
for (_, mut camera, parent, _) in camera_query.iter_mut() { | |
let player_id = player_query.iter().find(|(entity, _)| entity == &parent.get()).unwrap().1.id; | |
camera.is_active = player_id == config.active_player_id; | |
} | |
} | |
if capture.captured { | |
for (mut camera_transform, _, parent, _) in camera_query.iter_mut() { | |
let player_id = player_query.iter().find(|(entity, _)| entity == &parent.get()).unwrap().1.id; | |
if player_id == config.active_player_id { | |
for ev in motion.read() { | |
let delta = ev.delta * config.mouse_sensitivity * 0.01; | |
camera_transform.rotate_y(-delta.x); | |
camera_transform.rotate_local_x(-delta.y); | |
let (yaw, pitch, _) = camera_transform.rotation.to_euler(EulerRot::YXZ); | |
camera_transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch.clamp(-std::f32::consts::PI / 2.0 + 0.01, std::f32::consts::PI / 2.0 - 0.01), 0.0); | |
} | |
break; | |
} | |
} | |
} | |
} | |
// Updates the debug UI with game state information | |
fn update_debug_ui( | |
diagnostics: Res<DiagnosticsStore>, | |
keys: Res<ButtonInput<KeyCode>>, | |
mut text_query: Query<&mut Text, With<DebugText>>, | |
config: Res<GameConfig>, | |
) { | |
let mut text = text_query.single_mut(); | |
if config.debug_visible { | |
let fps = diagnostics | |
.get(&FrameTimeDiagnosticsPlugin::FPS) | |
.and_then(|d| d.smoothed()) | |
.unwrap_or(0.0); | |
let camera_name = format!("Player {}", config.active_player_id); | |
let active_camera = config.active_player_id; | |
text.0 = format!( | |
"FPS: {:.1}\nCamera Name: {}\nActive Camera: {}", | |
fps, | |
camera_name, | |
active_camera | |
); | |
} else { | |
text.0 = String::new(); // Clear text when hidden | |
} | |
// Toggle debug visibility with F1 | |
if keys.just_pressed(KeyCode::F1) { | |
//let mut config = config.into_inner(); | |
//config.debug_visible = !config.debug_visible; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment