Pokemon-style movement in a 3D environment
Asked Answered
S

13

0

Can anybody help me out with this? I've got my player to move on a grid using snapped. He can walk up ramps and gravity is applied with move_and_slide() (although going up ramps is a bit choppy.) What I'm stumped on is trying to get the player to turn smoothly. When I use lerp_angle it doesnt work. If the character is facing left for example, and you hit right, he should turn and then you can begin walking right after the turn is completed. So a tap on the input would face you the direction you want to move without moving you, unless youre already facing that direction.

extends CharacterBody3D

var walk_speed = 4.0
var turn_speed = 4.0
var angular_speed = 5.0
var pos = Vector3(0, position.y, 0)
var new_pos = Vector3(0, position.y, 0)
var is_moving = false
var percent_moved = 0.0
var percent_rotated = 0.0
var gravity = 15.0
var hover_strength = 0.5
var max_fall_speed = -10.0

@export var locomotionStatePlaybackPath: String;
@export var locomotionBlendPath: String;
@export var jumpOneShotPlayback: String;
	
@onready var ray = $RayCast3D
@onready var player_body = $rig
@onready var anim = $AnimationTree
#@onready var animplayer = $AnimationPlayer
@onready var camera = get_node("Camera3D")
	
enum PlayerState {IDLE, TURNING, WALKING, JUMPING, HOVERING, FALLING, DOUBLEJUMPING}
enum FacingDirection {UP, DOWN, LEFT, RIGHT}
	
var player_state = PlayerState.IDLE
var facing_direction = FacingDirection.UP
	
func _ready():
	pos = position
	
func _physics_process(delta):
	print(position.y)
	pos = pos.snapped(Vector3(0.5,position.y,0.5))
	if player_state == PlayerState.TURNING:
		return
	elif is_moving == false:
		process_input(delta)
	elif new_pos != Vector3.ZERO:
		move(delta)
	else:
		anim.travel("Idle")
		is_moving = false
		
	velocity.y -= gravity * delta
	
	var move_velocity = Vector2(velocity.x, velocity.z)
	var normalized_velocity = move_velocity.length() / walk_speed
	
	move_and_slide()
		
	if Input.is_action_just_pressed("ui_cancel"):
		get_tree().quit()
		
func process_input(delta):
	if new_pos.z == 0:
		new_pos.x = int(Input.is_action_pressed("ui_right")) - int(Input.is_action_pressed("ui_left"))
	if new_pos.x == 0:
		new_pos.z = int(Input.is_action_pressed("ui_down")) - int(Input.is_action_pressed("ui_up"))
		
	if new_pos != Vector3.ZERO:
		
		if need_to_turn():
			player_state = PlayerState.TURNING
			turn(delta)
		else:
			pos = position
			is_moving = true
			
func need_to_turn():
	var new_facing_direction
	if new_pos.x < 0:
		new_facing_direction = FacingDirection.LEFT
	if new_pos.x > 0:
		new_facing_direction = FacingDirection.RIGHT
	if new_pos.z < 0:
		new_facing_direction = FacingDirection.UP
	if new_pos.z > 0:
		new_facing_direction = FacingDirection.DOWN
	
	if facing_direction != new_facing_direction:
		facing_direction = new_facing_direction
		return true
	facing_direction = new_facing_direction
	return false
	
func turn(delta):
	percent_rotated += turn_speed * delta
	if percent_moved >= 1.0:
		percent_rotated = 0.0
	else:
		if new_pos.x < 0:
			player_body.rotation.y = 3 * PI / 2
			player_state = PlayerState.IDLE
		elif new_pos.x > 0:
			player_body.rotation.y = PI / 2
			await get_tree().create_timer(0.5).timeout
			player_state = PlayerState.IDLE
		elif new_pos.z < 0:
			player_body.rotation.y = PI
			await get_tree().create_timer(0.5).timeout
			player_state = PlayerState.IDLE
		elif new_pos.z > 0:
			player_body.rotation.y = 0
			await get_tree().create_timer(0.5).timeout
			player_state = PlayerState.IDLE
	
func move(delta):
	percent_moved += walk_speed * delta
	if percent_moved >= 1.0:
		position = pos + new_pos
		percent_moved = 0.0
		is_moving = false
	else:
		position = pos + (new_pos * percent_moved)
Sinistrorse answered 12/11, 2023 at 4:3 Comment(0)
S
0

Sinistrorse People seem to get stuck on turning a lot. This question pops up every so often. I answered it quite a few times. Searching the forums might get you some answers. Here are examples of how to properly turn using either manual quaternion/basis interpolation or tweening.

Skull answered 12/11, 2023 at 4:20 Comment(0)
S
0

Sinistrorse I use quaternions for rotations like this, but the solution should be similar when using Basis.
you need to create a rotation to move towards and then lerp or slerp towards it.

your code is way more complex than it needs to be:

@export var rotation_speed : float = 50

#in physics_process
var input_dir = Input.get_vector("ui_left", "ui_right", "ui_down", "ui_up")
quaternion = quaternion.slerp(global_transform.looking_at(Vector3(input_dir.x, 0, input_dir.y)).basis.get_rotation_quaternion(), rotation_speed * delta)

what this does is take the character quaternion and slerp it towards a new quaternion created from a transform3D.basis created from the direction of the keys.
each frame it's going to turn a bit given rotation_speed. a rotation_speed of 50 is fast, and lower values result in slower rotation, while 0 is no rotation.

Superphosphate answered 12/11, 2023 at 5:39 Comment(0)
O
0

Jesusemora's code has a couple problems with it.

  1. global_transform.looking_at is always going to point to near (0,0,0) in global space, wherever you are.
  2. Applying rotation_speed * delta to a parameter which expects 0 to 1 is incorrect usage. If it's larger than 1, it will get you there instantly (or possibly overshoot, I'm not sure). If it's smaller, it will never get you there.

Safelight This post by XYZ does precisely what you want with just a little modification. Really you just need these lines:

var q = Quaternion(Vector3.UP, atan2(-dir[s].x, -dir[s].z)) 
t.parallel().tween_property(self, "quaternion", q, turn_duration);

The atan2 there is set up to create the correct rotation angle in Godot. Just use your input vector.xy in place of dir[s].xz, and create a Tween t to do the tween. It will rotate you to the target direction in precisely turn_duration time.

From there, just make sure you can't walk while turning, and vice-versa.

Orellana answered 12/11, 2023 at 9:49 Comment(0)
S
0

Orellana
Global_transform looking_at goes from 000 to a vector taken from input, not space. Position is irrelevant.
I've used this code. A value of 1-0 on slerp won't even nudge it.

Superphosphate answered 12/11, 2023 at 10:24 Comment(0)
S
0

Superphosphate slerp expects the parameter to be in 0-1 range. Your code multiplies some unitless speed value by delta and uses that as a parameter so this makes it rather small. Strictly speaking, Karlsruhe is right. (S)lerping like this is a hacky way to do it for two reasons; you don't have precise control over timing and on top of that timing will be physics tick rate dependent.

However in practice this mostly appears to be working and people frequently use it as a patchy good enough solution that's easy to implement. It comes with a caveat though; you need to guarantee that the processing rate will always be the same. Changing the rate will change the timing. Player movement timing is critical for the feel of the game. Every millisecond matters here, so having this in production code is risky.

Knowing that, if we assume that the processing rate is set in stone, you don't really need to multiply the slerp parameter with delta, just make its range a couple of magnitudes smaller. Multiplying by delta is redundant when the tick rate is constant and your multiplied value has no physical interpretation.

But important thing here is to understand that this types of rotation need to be done by gradually interpolating from current orientation to some wanted orientation. Orientation can be represented either by basis or by a quaternion and interpolation can be done either by manual per-frame slerping or by tweens. Which interpolation method to choose really depends on movement/control style in your game.

And as a final remark; trying to do this with euler angle rotations (instead of basis/quaternion) is not a good idea. I'm tempted to say it's impossible to do it right that way πŸ™‚

Skull answered 12/11, 2023 at 13:3 Comment(0)
S
0

Orellana global_transform.looking_at is always going to point to near (0,0,0) in global space, wherever you are.

Yeah but Tennies's snippet just uses orientation part of the lookat transform so it works for this purpose, although I'd prefer using atan for planar movement as it's computationally far less expensive than pulling in the whole 4x4 matrix to figure out something that's basically a 2D direction.

Skull answered 12/11, 2023 at 13:29 Comment(0)
S
0
func turn(delta):
	percent_rotated += turn_speed * delta
	if percent_rotated >= 1.0:
		percent_rotated = 0.0
		player_state = PlayerState.IDLE
	else:
		if new_pos.x < 0:
			player_body.rotation.y = lerp_angle(player_body.rotation.y, 3 * PI / 2, percent_rotated)
		elif new_pos.x > 0:
			player_body.rotation.y = lerp_angle(player_body.rotation.y, PI / 2, percent_rotated)
		elif new_pos.z < 0:
			player_body.rotation.y = lerp_angle(player_body.rotation.y, PI, percent_rotated)
		elif new_pos.z > 0:
			player_body.rotation.y = lerp_angle(player_body.rotation.y, 0, percent_rotated)

So I managed to fix it. I very idiotically forgot to change the if statement in this block of code from percent_moved to percent_rotated. Now I was able to apply lerp angle and rotate the character no problem. After the character finishes rotating, he then goes to IDLE state and can move again. Now I need to figure out how to make him not stop for one frame every time he walks forward one space...

Sinistrorse answered 12/11, 2023 at 19:45 Comment(0)
S
1

Sinistrorse Do it the right way - use quaternions πŸ˜‰

Skull answered 12/11, 2023 at 19:46 Comment(0)
O
0

Skull No, looking_at takes into account the transform's position in comparison to the vector. It assumes they are in the same coordinate space. That code will literally make the character "look at" (-1,0,0), (1,0,0), (0,0,-1) or (0,0,1) from wherever the transform's global position is. If the global position is (0,0,0) then it will provide the desired rotation, otherwise it will not.

To make it work, you would have to do:

var look_pos = global_position + Vector3(input_dir.x, 0, input_dir.y)
quaternion = global_transform.looking_at(look_pos).basis.get_rotation_quaternion()

or

quaternion = Transform3D.IDENTITY.looking_at(Vector3(input_dir.x, 0, input_dir.y)).basis.get_rotation_quaternion()
Orellana answered 12/11, 2023 at 21:18 Comment(0)
O
0

Sinistrorse How about a way to make it work with 4 distinct directions as you seem to like? It's also slightly faster even than with atan2

Create a script called Rotation.gd and add this code:

class_name Rotation

const LEFT =  Quaternion(Vector3.UP, 1.0 * PI / 2.0)
const RIGHT = Quaternion(Vector3.UP, 3.0 * PI / 2.0)
const UP =    Quaternion(Vector3.UP, 0.0 * PI / 2.0)
const DOWN =  Quaternion(Vector3.UP, 2.0 * PI / 2.0)

This precomputes the quaternions, and they can be accessed from anywhere.

Then in your CharacterBody3D script, you would use this code to turn:

	var q:Quaternion
	var should_rotate := false
	if Input.is_action_just_pressed("ui_left"):
		q = Rotation.LEFT
		should_rotate = true
	elif Input.is_action_just_pressed("ui_right"):
		q = Rotation.RIGHT
		should_rotate = true
	elif Input.is_action_just_pressed("ui_up"):
		q = Rotation.UP
		should_rotate = true
	elif Input.is_action_just_pressed("ui_down"):
		q = Rotation.DOWN
		should_rotate = true

	if should_rotate:
		var t := create_tween()
		t.tween_property(self, "quaternion", q, turn_duration);

This way you don't even need to think about quaternions. You're just using them!
If you need to add more directions, just remember that 360Β° is 2.0 * PI radians. So to add UP_LEFT you would add:
const UP_LEFT = Quaternion(Vector3.UP, 0.5 * PI / 2.0)

Orellana answered 12/11, 2023 at 21:31 Comment(0)
S
0

Orellana No, looking_at takes into account the transform's position in comparison to the vector. It assumes they are in the same coordinate space

You're right, of course.

Precomputing quaternions only makes sense for camera that doesn't rotate though. If you want the commands to operate "in screen space" then quaternions need to take camera orientation into consideration. Nothing wrong with rebuilding them when a command is pressed, or each frame for that matter.

In general I think it's easier to explain/understand the whole thing using bases instead of quaternions. They kind of sound less alien πŸ™‚

Skull answered 12/11, 2023 at 22:4 Comment(0)
S
0

honestly the rotation is functioning perfectly as it should right now. I do have a non-rotating camera as well.

On another note, I've been banging my head against the wall with a couple problems I can't seem to solve.
extends CharacterBody3D

var walk_speed = 4.0
var turn_speed = 6.0
var angular_speed = 5.0
var pos = Vector3(0, position.y, 0)
var new_pos = Vector3(0, position.y, 0)
var is_moving = false
var percent_moved = 0.0
var percent_rotated = 0.0
var gravity = 15.0
var hover_strength = 0.5
var max_fall_speed = -10.0
var direction_keys = []
var acceleration = 20.0

@export var locomotionStatePlaybackPath: String;
@export var locomotionBlendPath: String;
@export var jumpOneShotPlayback: String;

@onready var ray = $RayCast3D
@onready var player_body = $rig
@onready var anim = $AnimationTree
#@onready var animplayer = $AnimationPlayer
@onready var camera = get_node("Camera3D")

enum PlayerState {IDLE, TURNING, WALKING, JUMPING, HOVERING, FALLING, DOUBLEJUMPING}
enum FacingDirection {UP, DOWN, LEFT, RIGHT}

var player_state = PlayerState.IDLE
var facing_direction = FacingDirection.UP

var move_velocity

func _ready():
	player_body.rotation.y = PI
	pos = position

func _process(delta: float) -> void:
	direction_storage()
	move_velocity = abs(new_pos.x + new_pos.z)
	if player_state == PlayerState.IDLE:
		Walk(lerp(move_velocity, 0.0, delta))
	if player_state == PlayerState.WALKING:
		Walk(lerp(move_velocity, 1.0, delta * acceleration))

func direction_storage():
	var directions = ["ui_up", "ui_down", "ui_left", "ui_right"]
	
	for dir in directions:
		if Input.is_action_just_pressed(dir):
			direction_keys.push_back(dir)
		elif Input.is_action_just_released(dir):
			direction_keys.erase(dir)
			
	if direction_keys.size() == 0:
		direction_keys.clear()
			
func _physics_process(delta):
	
	pos = pos.snapped(Vector3(0.5,position.y,0.5))
	
	if player_state == PlayerState.TURNING:
		turn(delta)
	elif player_state == PlayerState.IDLE:
		process_input(delta)
	elif new_pos != Vector3.ZERO:
		move(delta)
	elif direction_keys.size() > 0:
		return
	else:
		#anim.travel("Idle")
		player_state = PlayerState.IDLE
		
	velocity.y -= gravity * delta 
	
	print(direction_keys)
	
	move_and_slide()
		
	if Input.is_action_just_pressed("ui_cancel"):
		get_tree().quit()

func process_input(delta):

	var direction_map = {
		"ui_right": Vector3(1, 0, 0),
		"ui_left": Vector3(-1, 0, 0),
		"ui_down": Vector3(0, 0, 1),
		"ui_up": Vector3(0, 0, -1)
	}
	
	if direction_keys.size() > 0:
		var key = direction_keys.back()
		new_pos = direction_map.get(key,Vector3())
	else:
		new_pos = Vector3.ZERO

	if new_pos != Vector3.ZERO:
		
		if need_to_turn():
			player_state = PlayerState.TURNING
		else:
			pos = position
			player_state = PlayerState.WALKING

func need_to_turn():
	var new_facing_direction
	if new_pos.x < 0:
		new_facing_direction = FacingDirection.LEFT
	if new_pos.x > 0:
		new_facing_direction = FacingDirection.RIGHT
	if new_pos.z < 0:
		new_facing_direction = FacingDirection.UP
	if new_pos.z > 0:
		new_facing_direction = FacingDirection.DOWN

	if facing_direction != new_facing_direction:
		facing_direction = new_facing_direction
		return true
	facing_direction = new_facing_direction
	return false

func turn(delta):
	percent_rotated += turn_speed * delta
	if percent_rotated >= 1.0:
		percent_rotated = 0.0
		player_state = PlayerState.IDLE
	else:
		if new_pos.x < 0:
			player_body.rotation.y = lerp_angle(player_body.rotation.y, 3 * PI / 2, percent_rotated)
		elif new_pos.x > 0:
			player_body.rotation.y = lerp_angle(player_body.rotation.y, PI / 2, percent_rotated)
		elif new_pos.z < 0:
			player_body.rotation.y = lerp_angle(player_body.rotation.y, PI, percent_rotated)
		elif new_pos.z > 0:
			player_body.rotation.y = lerp_angle(player_body.rotation.y, 0, percent_rotated)

func move(delta):
	percent_moved += walk_speed * delta
	if percent_moved >= 1.0:
		position = pos + new_pos
		percent_moved = 0.0
		player_state = PlayerState.IDLE
	else:
		position = pos + (new_pos * percent_moved)



func DoubleJumpAnim():
	var playback = anim.get(locomotionStatePlaybackPath) as AnimationNodeStateMachinePlayback;
	playback.travel("double_jump")		

func JumpAnim():
	var playback = anim.get(locomotionStatePlaybackPath) as AnimationNodeStateMachinePlayback;
	playback.start("jump")

func LandAnim():
	var playback = anim.get(locomotionStatePlaybackPath) as AnimationNodeStateMachinePlayback;
	playback.start("land")

func FallAnim():
	var playback = anim.get(locomotionStatePlaybackPath) as AnimationNodeStateMachinePlayback;
	playback.travel("fall")
	pass

func FallBlend():
	if velocity.y <= - hover_strength:
		anim.set("parameters/Blend2/blend_amount", velocity.y / max_fall_speed)
	else:
		anim.set("parameters/Blend2/blend_amount", 0)

func Walk(move_velocity):
	var playback = anim.get(locomotionStatePlaybackPath) as AnimationNodeStateMachinePlayback;
	playback.travel("run")
	anim.set("parameters/StateMachine/run/blend_position", move_velocity)

if you look in physics_process, you'll see that process_input only functions when player is in IDLE state.
The Vector3 called new_pos is given a x or z value based on the input and the player is put in the WALKING state. Now that he's no longer in IDLE and the new_pos is something other than Vector3.ZERO, we can access the move() function. The move function moves the player towards new_pos and moves their state to IDLE after he has moved forward one vector unit. Now we can process input again. However, this results in a type of movement where the player pauses for one frame every time he moves forward a meter. This particuarly messes up trying to animate. While this coding structure does almost achieve the desired movement style, that singular pausing frame is ruining it. I think the whole logic needs to be rethought but I haven't come up with a solution yet.

Sinistrorse answered 12/11, 2023 at 22:42 Comment(0)
S
1

Sinistrorse honestly the rotation is functioning perfectly as it should right now.

It will come and bite you in the butt in the future and then you may remember that some random dude on forums once said: "Never use euler angles for 3D orientation" πŸ˜‰

As for other thing, nothing's preventing you to call move() in the same frame the state was switched.

Skull answered 12/11, 2023 at 23:19 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.