Character Controller
Table of Contents
Introduction
In this tutorial, we'll make a character controller for our 2D platformer. Here's what you'll get after finishing the tutorial:
You can find the source code of the tutorial here, you can
test it yourself by cloning the repository and cargo run --package editor --release
in the platformer
directory.
Project
Let's start by making a new project using the special tiny tool - fyrox-template
- it allows you to generate all boilerplate
parts in a single call. Install it using the following command:
cargo install fyrox-template
Navigate to a folder where you want the project to be created and do the following command:
fyrox-template init --name platformer --style 2d
The tool accepts two arguments - project name and a style, we're interested in 2D game so the style is set to 2D. After the project is generated, you should memorize two commands:
cargo run --package editor --release
- launches the editor with your game attached, the editor allows you to run your game inside it and edit game entities. It is intended to be used only for development.cargo run --package executor --release
- creates and runs the production binary of your game that can be shipped (for example - to a store).
Navigate to the platformer
directory and run cargo run --package editor --release
, after some time you should see the editor:
Great! Now we can start making our game. Go to game/src/lib.rs
- it is where your game logic is located, as you can see
the fyrox-template
generate quite some code for you. There are tiny comments about which place is for what. For more info
about each method, please refer to the docs.
Using the Editor
For now, we don't even need to write a single line of code, we can create a scene entirely in the editor. This section will guide you through the process of scene creation, as a final result we'll get something similar to this:
At first, we need some assets, I prepared all required (and some more) in a separate zip archive, so you don't need to search
assets all over the internet. Download assets from here and unpack them in a data
folder in the root folder of
your project.
Let's start filling the scene. Run the editor and remove all content from the generated scene. Since we're making a 2D game, switch the editor's
camera mode to 2D
at the top toolbar of the scene preview window. Now we need to populate the scene with some objects,
we'll start by adding a simple ground block. Right-click on __ROOT__
of the scene in World Viewer
and select
Add Child -> Physics2D -> Rigid Body
. This will create a rigid body for the ground block, select the rigid body, and
set Body Type
to Static
in Inspector
, by doing this we're telling the physics engine that our ground block should not move
and be rock-solid. Every rigid body requires a collider, otherwise, the physics engine will not know how to handle collisions,
right-click on the rigid body in Inspector
and click Add Child -> Physics2D -> Collider
. We've just added a new collider to the rigid
body, by default it has a Cuboid
shape with a 1.0
meter in height and width. Finally, we need to add some graphics to the rigid body,
right-click on the rigid body and click Add Child -> 2D -> Rectangle
. This adds a simple 2D sprite, select it and set a texture
to it by finding the Material
property in the Inspector, clicking Edit
button near it and setting the diffuseTexture
property by simply drag'n'dropping the texture from the asset browser to the property. For my scene, I'm gonna be using
three sprites.
data/tiles/13.png
- left ground blockdata/tiles/14.png
- center ground blockdata/tiles/15.png
- right ground block
You can use any other textures and build your level as you like. After doing all these steps you should get something like this:
Clone the block by selecting its rigid body and pressing Ctrl+C
followed by Ctrl+V
, navigate to sprite in the copy and change its
texture to either the left or right end of the block. Use Move Tool
to move the block somewhere you like (you can also use grid-snapping
by going to File -> Setting
and setting Snap To Grid
for Move Interaction Mode
). Do this one more time for the opposite end and you
should get something like this:
Repeat these steps if you like, to add more platforms. You can also add some background objects, by creating a new sprite
(right click __ROOT__
and click Add Child -> 2D -> Rectangle
) and assigning a texture to it:
As the last step of world editing, let's add some dynamic objects, like boxes. Pick some random ground block, select its rigid body, and
clone it. Switch body type of the copy to Dynamic
. Now change its sprite texture to a box (drag'n'drop data/objects/Crate.png
to
Texture
field) and clone the box a few times, you should get something like this:
Now for the player. As always, let's start by creating a new rigid body, adding a 2D collider to it, and setting its shape to capsule with the following
parameters - Begin = 0.0, 0.0
and End = 0.0, 0.3
. Add a 2D sprite (rectangle) to the rigid body and set its texture to
data/characters/adventurer/adventurer-Sheet.png
. Set its uv rect to (0.0, 0.0, 0.143, 0.091)
to see only one frame.
We also need a camera, otherwise, we won't see anything. Add it as a child to a player's rigid body. By default, our
camera will have no background, there'll be a black "void", this is not great and let's fix that. Select the camera
and set the Skybox
property to Some
. Now go to asset browser and find data/background/BG.png
, drag'n'drop it to
the Front
field of the Skybox
property. Don't forget to adjust the far plane distance to something like 20.0
,
otherwise, you'll see just a portion of the background image. If everything is done correctly, you should get something like this:
Save your scene by goint to File -> Save Scene
. Now we can run the game using the Play/Stop
button at the top of the
scene previewer. You should see pretty much the same as in the scene preview, except
for service graphics, such as rigid body shapes, node bounds, and so on. Now we can start writing scripts.
As the last preparation step, let's import all entities at the beginning, so you don't need to find them manually, add the following code
at the beginning of the game/src/lib.rs
:
#![allow(unused)] fn main() { use crate::bot::Bot; use fyrox::{ core::{ algebra::{Vector2, Vector3}, pool::Handle, reflect::prelude::*, type_traits::prelude::*, visitor::prelude::*, }, event::{ElementState, Event, WindowEvent}, keyboard::{KeyCode, PhysicalKey}, plugin::{Plugin, PluginContext, PluginRegistrationContext}, scene::{ animation::spritesheet::SpriteSheetAnimation, dim2::{rectangle::Rectangle, rigidbody::RigidBody}, node::Node, Scene, }, script::{ScriptContext, ScriptTrait}, }; use std::path::Path; }
Scripts - Player
Our scene has pretty much everything we need to start adding scripts, we'll start from the Player
script and make our character
move. Navigate to game/src/lib.rs
and at the end of the file add the following code snippet:
#![allow(unused)] fn main() { #[derive(Visit, Reflect, Debug, Clone, Default, TypeUuidProvider, ComponentProvider)] #[type_uuid(id = "c5671d19-9f1a-4286-8486-add4ebaadaec")] #[visit(optional)] struct Player; impl ScriptTrait for Player { // Called once at initialization. fn on_init(&mut self, context: &mut ScriptContext) {} // Put start logic - it is called when every other script is already initialized. fn on_start(&mut self, context: &mut ScriptContext) {} // Called whenever there is an event from OS (mouse click, keypress, etc.) fn on_os_event(&mut self, event: &Event<()>, context: &mut ScriptContext) {} // Called every frame at fixed rate of 60 FPS. fn on_update(&mut self, context: &mut ScriptContext) {} } }
This is a typical "skeleton" of any script, for now, its methods are pretty much empty, we'll fill it with actual code very soon.
Let's go over the most important parts. The snippet starts from the Player
structure definition which has #[derive(Visit, Inspect, Debug, Clone, Default)]
attributes:
Visit
- implements serialization/deserialization functionality, it is used by the editor to save your object to a scene file.Inspect
- generates metadata for the fields of your type - in other words, it allows the editor to "see" what's inside your structure and show additional information attached to the fields via proc-macro attributes.Reflect
- implements compile-time reflection that allows the editor to mutate your objects.Debug
- provides debugging functionality, it is mostly for the editor to let it print stuff into the console.Clone
- makes your structure clone-able, why do we need this? We can clone objects, and we also want the script instance to be copied.Default
implementation is very important - the scripting system uses it to create your scripts in the default state. This is necessary to set some data to it and so on. If it's a special case, you can always implement your ownDefault
's implementation if it's necessary for your script.TypeUuidProvider
is used to attach some unique id for your type, every script *must have a unique ID, otherwise, the engine will not be able to save and load your scripts. To generate a new UUID, use Online UUID Generator or any other tool that can generate UUIDs.
Finally, we implement ScriptTrait
for the Player
. It has a bunch of methods, their names speak for themselves. Learn more about
every method in documentation
Before we can use the script in the editor, we must tell the engine that our script exists - we must register it. Remember that
register
method in the PluginConstructor
trait implementation? It is exactly for script registration, replace its implementation with the following
code snippet:
#![allow(unused)] fn main() { impl Plugin for Game { fn register(&self, context: PluginRegistrationContext) { let script_constructors = &context.serialization_context.script_constructors; script_constructors.add::<Player>("Player"); // ... }
Now the engine knows about our script and will be able to use it. It is pretty much useless in the current state, but we can already
assign it to the player. Select the player's rigid body node and find Script
in the Inspector
, select Player
from the respective
drop-down list and that's pretty much it - now the script is assigned:
Let's learn how to edit script properties from the editor. In the next section, we'll be adding keyframe animation for your character,
it is a perfect opportunity to learn how the engine and the editor operate with user-defined properties in scripts. To animate the player
we need to get its sprite first. Let's start by adding the required field in the Player
structure:
#![allow(unused)] fn main() { #[derive(Visit, Reflect, Debug, Clone, TypeUuidProvider, ComponentProvider)] #[type_uuid(id = "c5671d19-9f1a-4286-8486-add4ebaadaec")] #[visit(optional)] struct Player { sprite: Handle<Node>, // ... }
After adding this, the editor will be able to see the field and give you the ability to edit it in the Inspector.
To assign the correct handle of the sprite to the respective field in script properties, hold Alt
and start dragging
the sprite node from the world viewer to the respective field in the player script. Release the mouse button and if
everything is ok, the field should "say" something different than "Unassigned".
Alright, at this point we know how to work with script properties, now we can start adding basic movement for the player.
Go to the Player
structure and add the following fields:
#![allow(unused)] fn main() { move_left: bool, move_right: bool, jump: bool, }
These fields will store the state of keyboard keys responsible for player movement. Now for on_os_event
, add the following code there:
#![allow(unused)] fn main() { // Called everytime when there is an event from OS (mouse click, key press, etc.) fn on_os_event(&mut self, event: &Event<()>, _context: &mut ScriptContext) { if let Event::WindowEvent { event, .. } = event { if let WindowEvent::KeyboardInput { event, .. } = event { if let PhysicalKey::Code(keycode) = event.physical_key { let is_pressed = event.state == ElementState::Pressed; match keycode { KeyCode::KeyA => self.move_left = is_pressed, KeyCode::KeyD => self.move_right = is_pressed, KeyCode::Space => self.jump = is_pressed, _ => (), } } } } } }
The code responds to OS events and modifies internal movement flags accordingly. Now we need to use the flags somehow, it's time for
on_update
. The method is called each frame and allows you to put game logic there:
#![allow(unused)] fn main() { fn on_update(&mut self, context: &mut ScriptContext) { // The script can be assigned to any scene node, but we assert that it will work only with // 2d rigid body nodes. if let Some(rigid_body) = context.scene.graph[context.handle].cast_mut::<RigidBody>() { let x_speed = if self.move_left { 3.0 } else if self.move_right { -3.0 } else { 0.0 }; if self.jump { rigid_body.set_lin_vel(Vector2::new(x_speed, 4.0)) } else { rigid_body.set_lin_vel(Vector2::new(x_speed, rigid_body.lin_vel().y)) }; // ... } } }
Finally, some interesting code. At first, we check if the node to which the script is assigned is a 2d rigid body, next we're checking movement flags and form horizontal speed, and applying velocity to the body. Velocity is applied in two ways: if the jump button was pressed - apply horizontal velocity and some vertical velocity for jumping. If the jump button wasn't pressed - just change horizontal velocity - this will allow the player to free fall.
Run the editor and enter play mode, press [A][D][Space]
buttons to check if everything works correctly - the player should move
horizontally and be able to jump. You can jump to the boxes on the right and push them off the ledge.
The movement is working, but the player does not change orientation, if we'll go to the left - it looks ok (despite the lack of animation),
but if we'll move to the right - it looks like the player moves backward. Let's fix that by changing the horizontal scaling of the player's
sprite. Add the following code at the end of the if let ...
block of the code above:
#![allow(unused)] fn main() { // It is always a good practice to check whether the handles are valid, at this point we don't know // for sure what's the value of the `sprite` field. It can be unassigned and the following code won't // execute. A simple `context.scene.graph[self.sprite]` would just panicked in this case. if let Some(sprite) = context.scene.graph.try_get_mut(self.sprite) { // We want to change player orientation only if he's moving. if x_speed != 0.0 { let local_transform = sprite.local_transform_mut(); let current_scale = **local_transform.scale(); local_transform.set_scale(Vector3::new( // Just change X scaling to mirror player's sprite. current_scale.x.copysign(-x_speed), current_scale.y, current_scale.z, )); } } }
The comments should clarify what's going on here, but in short, we're changing the horizontal scaling of the player's sprite if the player is
moving. The line current_scale.x.copysign(-x_speed)
could be confusing, what it does? It replaces the sign of current horizontal scaling
using the opposite sign of x_speed
.
Now if you run the game, the player will "look" in correct direction depending on the velocity vector.
Animation
Since we're making a 2D game, we'll be using simple animations based on the continuous change of keyframes. In other words, we'll be changing
the texture of the player's body sprite. Luckily for us, the engine has built-in sprite sheet animations. Just add the
following fields to the Player
:
#![allow(unused)] fn main() { animations: Vec<SpriteSheetAnimation>, current_animation: u32, }
Currently, we just pass default values.
#![allow(unused)] fn main() { impl Default for Player { fn default() -> Self { Self { // ... animations: Default::default(), current_animation: 0, } } } }
The Player will use multiple animations in future tutorials, but for now, it will use only two - idle and run.
Now we need to somehow switch animations. Go to on_update
in Player
and add the following lines after
the x_speed
declaration:
#![allow(unused)] fn main() { if x_speed != 0.0 { self.current_animation = 0; } else { self.current_animation = 1; } }
Here we assume that the run animation will be at index 1
and the idle animation at index 0
. We also need to
apply the texture from the current animation to the player's sprite, and add the following lines at the end of on_update
#![allow(unused)] fn main() { if let Some(current_animation) = self.animations.get_mut(self.current_animation as usize) { current_animation.update(context.dt); if let Some(sprite) = context .scene .graph .try_get_mut(self.sprite) .and_then(|n| n.cast_mut::<Rectangle>()) { // Set new frame to the sprite. sprite .material() .data_ref() .set_texture(&"diffuseTexture".into(), current_animation.texture()) .unwrap(); sprite.set_uv_rect( current_animation .current_frame_uv_rect() .unwrap_or_default(), ); } } }
The code is pretty straightforward - we start by trying to get a reference to the current animation by its index, and if we're succeeded, we update it. At the next step, we're getting sprite and assigning a current frame of the current animation.
Now we need to go to the editor again and add the animations to the Player
, select the player's rigid body, and
find the Script
section in the Inspector
. Add two animations there like so:
After filling in the animations and turning them on, you can run the game and your character should play animations correctly.
Conclusion
In this tutorial, we've learned the basics of the new scripting system of the engine. The game we've built it very simple, but it is just the beginning. It is easy to add more scripts for enemies, weapons, collectible items, and so on.