Games in the Source engine have some interesting
movement physics which permit tricks like bunnyhopping,
airstrafing, and
surfing. I implemented a player movement controller for
the Godot game engine which can perform the same tricks, and threw that
into (the glTF conversion of) an iconic surf map to test it.
Click for HTML5 Surf Demo (surf_utopia_v3)
The basic concept behind all of the aforementioned movement tricks is that player movement in Source games has a speed limit, but it's enforced only when strafing (so that rocket jumping and the like can fling the player faster than the speed limit) and only in the direction the player is strafing (so that it's possible to strafe sideways a little bit even while flying through the air above the speed cap):
extends KinematicBody export var jumpImpulse = 2.0 export var gravity = -5.0 export var groundAcceleration = 30.0 export var groundSpeedLimit = 3.0 export var airAcceleration = 500.0 export var airSpeedLimit = 0.5 export var groundFriction = 0.9 export var mouseSensitivity = 0.1 var velocity = Vector3.ZERO var restartTransform var restartVelocity func _ready(): restartTransform = self.global_transform restartVelocity = self.velocity pass # Replace with function body. func _physics_process(delta): # Apply gravity, jumping, and ground friction to velocity velocity.y += gravity * delta if is_on_floor(): # By using is_action_pressed() rather than is_action_just_pressed() # we get automatic bunny hopping. if Input.is_action_pressed("move_jump"): velocity.y = jumpImpulse else: velocity *= groundFriction # Compute X/Z axis strafe vector from WASD inputs var basis = $YawAxis/Camera.get_global_transform().basis var strafeDir = Vector3(0, 0, 0) if Input.is_action_pressed("move_forward"): strafeDir -= basis.z if Input.is_action_pressed("move_backward"): strafeDir += basis.z if Input.is_action_pressed("move_left"): strafeDir -= basis.x if Input.is_action_pressed("move_right"): strafeDir += basis.x strafeDir.y = 0 strafeDir = strafeDir.normalized() # Figure out which strafe force and speed limit applies var strafeAccel = groundAcceleration if is_on_floor() else airAcceleration var speedLimit = groundSpeedLimit if is_on_floor() else airSpeedLimit # Project current velocity onto the strafe direction, and compute a capped # acceleration such that *projected* speed will remain within the limit. var currentSpeed = strafeDir.dot(velocity) var accel = strafeAccel * delta accel = max(0, min(accel, speedLimit - currentSpeed)) # Apply strafe acceleration to velocity and then integrate motion velocity += strafeDir * accel velocity = move_and_slide(velocity, Vector3.UP) if Input.is_action_pressed("move_fast"): velocity = Vector3.ZERO if Input.is_action_just_released("move_fast"): velocity = -30 * basis.z if Input.is_action_just_pressed("checkpoint"): print("Saving Checkpoint: %s / %s" % [self.translation, self.velocity]) restartTransform = self.global_transform restartVelocity = self.velocity if Input.is_action_just_pressed("restart"): self.global_transform = restartTransform self.velocity = restartVelocity pass func _input(event): if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: $YawAxis.rotate_x(deg2rad(event.relative.y * mouseSensitivity * -1)) self.rotate_y(deg2rad(event.relative.x * mouseSensitivity * -1)) # Clamp yaw to [-89, 89] degrees so you can't flip over var yaw = $YawAxis.rotation_degrees.x $YawAxis.rotation_degrees.x = clamp(yaw, -89, 89)
Most of the code is your basic FPS character movement sort of logic. The bit where the magic lives is:
# Project current velocity onto the strafe direction, and compute a capped # acceleration such that *projected* speed will remain within the limit. var currentSpeed = strafeDir.dot(velocity) var accel = strafeAccel * delta accel = max(0, min(accel, speedLimit - currentSpeed))
In order to test the player movement code I needed a map. So what I did was
I used the io_import_vmf
Blender plugin to import surf_utopia_v3
,
one of the best beginner-friendly surf maps ever made.
I exported the whole thing from Blender to glTF, loaded that up in Godot, and tried surfing on it. Then I realized that there was no collision data.
Luckily this is easily fixed
by simply renaming any collidable meshes in a glTF scene to end with -col
. Since
the Blender VMF import process had only converted the visible geometry and there aren't any
visible-but-not-collidable elements in surf_utopia_v3
I was able to bulk-rename
the entire thing and re-export.
After that it was just a bit of trial and error to find values for gravity
,
airAcceleration
, and airSpeedLimit
for which it was possible to
beat the map, and then refine them a bit further until everything felt more or less like
it should.
Godot has the ability to export games to a variety of platforms, including HTML5 for desktop or mobile browsers. I decided to give this a shot so that I could embed a playable demo in this blog post.
The export process was quite simple. Unfortunately the result doesn't work as well as I'd like -- the game ran at ~200 FPS as a Windows executable during development, but in a web browser it only gets ~30 FPS, with noticeable variation depending on how much of the map is within the view frustum.
I dealt with this using the time-honored technique of lowering the far clipping distance, addding some fog to hide it, and pretending that it's supposed to be aesthetic. This brought the HTML5 export up to playability, but now the same machine can get ~600 FPS running it natively.
So that's a little bit of a disappointment, but for a weekend hack I'm happy with the results.