Lock/unlock movement to arbitrary 2D axis in 3D game

:information_source: Attention Topic was automatically imported from the old Question2Answer platform.
:bust_in_silhouette: Asked By kylekyleton

There is flat 3D floor on which a character can move freely while holding a button – WASD controls: x-axis, z-axis, jumping, whatever. Cool.
Upon releasing the button, I want to lock the player’s movement into a 2D axis defined by the character’s position and the position of some other CharacterBody3D (a bad guy, probably), turning it into a 2.5D interaction between these two characters, with only sideways inputs and jumps being allowed. This 2D axis is very unlikely to line up with the global x- and z- axes, so those handy axis-lock toggles aren’t going to do the trick.

Right now I achieve this by defining a vector based on the position of the bad guy minus the position of the player, and I use the left/right inputs to move along this vector.

Two problems come up:

  1. When the player tries to jump over the bad guy, it doesn’t happen. The vector’s endpoint is the bad guy’s position, so lateral movement beyond it is impossible.
  2. Even if you briefly re-enable the free-movement mode manually to get to the opposite side of the bad guy, the controls do not properly swap – i.e. pressing the button that ought to move you toward the enemy instead moves you away.

What can I do about this? Is there a way to clamp the player’s movement to a Plane or something? Relevant code is below. Thanks.

func _physics_process(delta):
	direction = Vector3.ZERO    # initialize direction to zero. without this, movement is persistent after releasing input
	
	# sets an axis on which to move when not pressing free-run button
	var attack_vector = (target.position - position)
	
	# FIX ME
	# stuck when you reach the target's position
	# controls don't properly reverse when you approach target from opposite side
	direction = Input.get_axis("dir_left","dir_right") * attack_vector
	
	# free movement while modifier button is held
	if Input.is_action_pressed("move_modifier"):
		direction.x = Input.get_axis("dir_left","dir_right")
		direction.z = Input.get_axis("dir_up","dir_down")
		direction = direction.rotated(Vector3.UP, camera.rotation.y).normalized()

	if direction != Vector3.ZERO:
		direction = direction.normalized()
		$Pivot.look_at(position+direction, Vector3.UP)
	
	velocity.x = direction.x * speed
	velocity.z = direction.z * speed
	velocity.y -= fall_acceleration * delta
:bust_in_silhouette: Reply From: Miel

Hello,
here are a few ideas. (I’m not sure I understood your project)

So the player can only move in a 2d plane, but is the enemy constrained too? If so storing the movement axis when entering the 2d mode will fix both of your problems. Something like:

func _physics_process(delta: float) -> void:
    if entered_2d_mode_this_frame:
        movement_axis = (badguy.global_position - global_position).normalized()
    if in_2d_mode:
        move_along(movement_axis)

Is the camera static and well set up? I see you use the camera rotation. If the camera is perpendicular to the movement plane you can move the player along $Camera.global_transform.basis.x which is the “sideways” axis relative to the camera.

Otherwise the problem for 1. is the case where your player and the bad guy are vertically aligned. You could remember the last axis (from the last frame) and reuse it if this happens.
Also, if I understand correctly the attack vector should only be in the XZ plane.

var last_attack_vector: Vector3

func _physics_process(delta: float) -> void:
	var attack_vector := (badguy.global_position - global_position)
	attack_vector.y = 0
	if attack_vector.is_equal_approx(Vector3.ZERO):
		attack_vector = last_attack_vector
	else:
		attack_vector = attack_vector.normalized()
		last_attack_vector = attack_vector
# at this point `attack_vector` contains a valid normalized XZ direction
:bust_in_silhouette: Reply From: Xx_Henry_xX

One way I can think of is setting attack_vector just once per modifier key release and not every process update.

  1. Seperate the attack_vector as a class variable. (remember that you have to be explicit about the data type when making properties)
  2. Make a script that will set attack_vector to (target.position - position) if it’s not a zero vector, and the modifier key is not held down.
  3. Also include a script that will set attack_vector to zero vector when the modifier key is pressed.
  4. While we are at it, when setting the attack_vector, set its y component to 0 (so moving does not cause you to float or move through the floor when you set it midair).

While I’m no expert on GDScript, here’s something I whipped up that does those fixes.

Vector3 attack_vector = Vector3.ZERO

func _physics_process(delta):
    direction = Vector3.ZERO

    if Input.is_action_pressed("move_modifier"):
        direction.x = Input.get_axis("dir_left","dir_right")
        direction.z = Input.get_axis("dir_up","dir_down")
        direction = direction.rotated(Vector3.UP, camera.rotation.y).normalized()
        attack_vector = Vector3.ZERO
    else:
        if attack_vector != Vector3.ZERO:
            attack_vector = (target.position - position)
            attack_vector.y = 0
        direction = Input.get_axis("dir_left","dir_right") * attack_vector

    if direction != Vector3.ZERO:
        direction = direction.normalized()
        $Pivot.look_at(position+direction, Vector3.UP)

    velocity.x = direction.x * speed
    velocity.z = direction.z * speed
    velocity.y -= fall_acceleration * delta

Also a bit of design-wise comment: I’d suggest switching the mode for modifier pressed and unpressed so that pressing the modifier key will lock the plane to the enemy like lock-on systems in some 3D action games.

I couldn’t quite get your script to work as I’d hoped. However, your description of how to handle it was enlightening. Here’s what I ended up doing:

  1. Initialize @onready the attack_vector as a class variable to the appropriate vector: (target.position - position)
  2. Set attack_vector to (target.position - position) when the modifier key is released, using:
if Input.is_action_just_released("move_modifier"):
			attack_vector = (target.position - position)
			attack_vector.y = 0
		direction = Input.get_axis("dir_left","dir_right") * attack_vector.normalized()

Problem solved. Movement works as intended.

kylekyleton | 2022-08-08 14:15

Upon further review, I ended up doing something more like my original code. In case anyone else stumbles into this thread looking for answers, here’s what I did:

Attack vector is updated every frame, except when the character is airborne or its target is airborne – in these cases, it just retains the last-known vector as its attack vector until both characters are on the ground again.

This solves the problem of the character getting stuck either above or below the target when the XZ coordinates of the player-character are equal to those of the target.

The second problem is solved by doing an unproject_position check on the player and the target and seeing which side of the target the player is on, and adjusts the vector accordingly.

It feels kind of kludgey but it works well.

kylekyleton | 2022-08-17 22:23