Paralyzed by Fear
Experimental extraterrestrial exploration.
The basic idea for this game was that instead of controlling the player character directly you would control their emotions, and then the character would move and act based on those emotions. I also wanted it to be some sort of exploration game with environmental storytelling, and so I decided to make the setting an alien planet with strange and scary vegetation, in order to make the experience feel even more weird and novel. After thinking about what emotions would be relevant in a situation where you find yourself stranded on an alien planet, I settled on fear, curiosity, and aggression (I guess I’m using “emotions” loosely here).
I started by putting these emotions on each corner of a triangle and programming a marker on this triangle the player can control. The marker can be controlled by pressing the arrow keys or by clicking on the triangle. To create some extra feeling for the current emotional state I added a fog overlay image which changes color based on the values of the emotions.
func _process(delta):
var fearPressed = fearButton or Input.is_action_pressed("fear")
var curiosityPressed = curiosityButton or Input.is_action_pressed("curiosity")
var aggressionPressed = aggressionButton or Input.is_action_pressed("aggression")
if fearPressed and curiosityPressed and aggressionPressed:
return
elif fearPressed and curiosityPressed:
if aggression <= 0:
return
fear += emotionSpeed * delta
curiosity += emotionSpeed * delta
aggression -= emotionSpeed * 2 * delta
#...more of the same...
elif fearPressed and fear < 1:
fear += emotionSpeed * 2 * delta
if curiosity <= 0:
aggression -= emotionSpeed * 2 * delta
elif aggression <= 0:
curiosity -= emotionSpeed * 2 * delta
else:
curiosity -= emotionSpeed * delta
aggression -= emotionSpeed * delta
#...more of the same...
updateMarker()
func updateMarker():
var x = 224 - 224 * fear + 224 * aggression
var y = 384 - 384 * curiosity
get_node("../UI/Marker").rect_position = Vector2(x, y)
var fogColor = get_node("../UI/Fog").get_modulate()
fogColor.r = aggression * 0.5
fogColor.g = curiosity * 0.5
fogColor.b = fear * 0.5
get_node("../UI/Fog").set_modulate(fogColor)
After that I built an alien forest for the player to explore. The player has one collision body acting as vision, and one acting as the physical body. When the player moves around and a tree (or other object in the forest) enters their vision, it is added to either the list of scary or interesting objects, provided the player has not already examined an object of the same type. If the player is on their way to examine an object and that object enters their body, they start examining it. Examining machines (ID less than 10) will give the player scrap, and examining scary objects will kill the player.
func _on_Vision_enter(body):
obstacles.append(body)
if body.name == "InterestingBody":
for index in range(examinedObjects.size()):
if body.id == examinedObjects[index]:
return
interestingObjects.append(body)
elif body.name == "ScaryBody":
scaryObjects.append(body)
func _on_Vision_exit(body):
obstacles.erase(body)
if body.name == "InterestingBody":
interestingObjects.erase(body)
if body.name == "ScaryBody":
scaryObjects.erase(body)
func _on_Body_enter(body):
if body.name == "InterestingBody" and body.id == targetID:
examinedObjects.append(body.id)
target = null
var index = 0
while index < interestingObjects.size():
if interestingObjects[index].id == body.id:
interestingObjects.remove(index)
else:
index += 1
$Astronaut/AnimationPlayer.current_animation = "examine"
$Astronaut/AnimationPlayer.playback_speed = 1
$ExamineAudio.play()
examining = true
if body.id < 10:
scrap += 1
elif body.name == "ScaryBody" and body.id == targetID:
$Astronaut/AnimationPlayer.current_animation = "examine"
$Astronaut/AnimationPlayer.playback_speed = 1
$ExamineAudio.play()
examining = true
dead = true
The player’s behaviour is determined by the current emotional state. Curiosity will lead the player to start moving towards interesting objects in the forest. This is an important part of the game, but too high curiosity will cause the player to move towards scary objects and certain death. If there are no interesting or scary objects in sight the player will simply move towards the center of the world. High enough aggression will cause the player to just stop and shoot. What constitutes a high enough aggression depends on if there are scary objects in sight. If the fear is too high the player will simply stand still, paralyzed. This is where the game gets its name from, and it is also the initial emotional state.
When thinking about how to control a character through emotions instead of directly, I realized that the easiest way to conceptualize this would be to think of it like I’m making AI. For this part of the code I think it would have been better if I had leaned into that more and used a behavoiur tree.
var position = Vector2(translation.x, translation.z)
if fear > 0.9:
target = null
targetID = 0
elif reload <= 0.0 and (aggression > 0.8 or (aggression > 0.5 and scaryObjects.size() > 0)):
target = null
targetID = 0
shoot()
elif target == null and curiosity > 0.1:
if curiosity > 0.8 and scaryObjects.size() > 0:
var targetIndex = randi() % scaryObjects.size()
var targetPosition = scaryObjects[targetIndex].to_global(Vector3())
target = Vector2(targetPosition.x, targetPosition.z)
targetID = scaryObjects[targetIndex].id
elif interestingObjects.size() > 0:
var targetIndex = randi() % interestingObjects.size()
var targetPosition = interestingObjects[targetIndex].to_global(Vector3())
target = Vector2(targetPosition.x, targetPosition.z)
targetID = interestingObjects[targetIndex].id
else:
target = Vector2()
if position.x > 5:
target.x = position.x - 5
elif position.x < -5:
target.x = position.x + 5
if position.y > 5:
target.y = position.y - 5
elif position.y < -5:
target.y = position.y + 5
The player’s movement is controlled by this simple steering behaviour. It works by steering towards the current target and away from every object in the player’s vision. Distance and size of objects determine how much to avoid them. Movement speed is determined based on curiosity. This code also rotates the player model correctly and plays the walking animation scaled to the current speed. If there is no target the player will stop and play the idle animation.
The code for avoiding obstacles is far from perfect and the player can sometimes be observed walking through trees. I wonder if I did myself a disservice by making the forest too dense for good obstacle avoidance, or perhaps I should have used a navmesh for movement.
if target and target.distance_squared_to(position) > 1:
var toTarget = (target - position).normalized()
var right = Vector2(-direction.y, direction.x)
var wantedRotation = toTarget.dot(right)
for index in range(obstacles.size()):
var obstaclePosition = obstacles[index].to_global(Vector3())
var toObstacle = (Vector2(obstaclePosition.x, obstaclePosition.z) - position).normalized()
var scale = obstacles[index].get_parent().scale.x
var weight = (scale - translation.distance_to(obstaclePosition)) / scale
if weight < 0 or weight > 0.8:
continue
wantedRotation += (toObstacle.dot(right) - PI) * weight
rotationY += wantedRotation * torque * delta
direction.x = cos(rotationY)
direction.y = sin(rotationY)
translate(Vector3(direction.x, 0, direction.y) * speed * curiosity * delta)
if target.distance_squared_to(Vector2(translation.x, translation.z)) <= 1:
target = null
targetID = 0
$Astronaut.rotation = Vector3(0, PI / 2 - rotationY, 0)
$Astronaut/AnimationPlayer.current_animation = "walk"
$Astronaut/AnimationPlayer.playback_speed = animationSpeed * speed * curiosity
footstepCountdown -= curiosity * delta
if footstepCountdown <= 0.0:
footstepCountdown = footstepInterval
if randi() % 2 == 0:
$FootAudio1.play()
else:
$FootAudio2.play()
else:
target = null
targetID = 0
footstepCountdown = footstepStart
$Astronaut/AnimationPlayer.current_animation = "idle"
$Astronaut/AnimationPlayer.playback_speed = 1