Making a Gulliotine Enemy

Godot Version

Godot 4.4

Question

Hello! I have been trying to develop a simple enemy for my game. The goal for this enemy is to serve as a gulliotine/smashing hazard that tries to crush the player the second they pass underneath them.

I have attempted this by having a CharacterBody3D move toward the player rapidly as soon as the Area3D connected to it feels the player enter. Once it detects the ground, it pauses, before rising again, stopping as soon as it hits the ceiling. Unfortunetly, this method leads to multiple issues that relate to its Falling and Rising functions.

I wanted to know if there is a simple but effective method that can achieve what I want while staying not too complicated, as I am a beginner coder.

The way you described it seems completely legit and I don’t see why it shouldn’t work.
Can you share how you implemented it and what specifically doesn’t work with your solution?

I would maybe change the CharacterBody3D node type to AnimatableBody3D as it seems to be more suited for this purpose.

I recommend using RayCast to check when the player is under the guillotine and control the movement via animations or timers..

1 Like

For some reason, randomly, when the guillotine makes contact with the ground, the guillotine will immediately bounce up and stop, failing to rise as it should. I have tested it many times and each time this error pops up is inconsistent–sometimes it works, sometimes it breaks. It might stem from how I coded it, but I cannot for the life of me pinpoint what is wrong with it.

If you post the actual code here, it’ll be easier for us to help.

You can post it as text, like this:

```gdscript
# comment
func my_thing(…):
[…]
```

My code looks like this. (These are two separate gdscript files that work for my state machine, the issue is probably not the State Machine itself)

extends Node

var fsm: StateMachine
@onready var CHAR := $"../.."
@onready var ABLE_RISE = false

func enter():

	
	pass



func _process(delta: float) -> void:
	
	#This code has the Guillotine move up until it hits the ceiling
	if CHAR.is_on_floor():
		ABLE_RISE = true
	if ABLE_RISE == true:
		await get_tree().create_timer(.8).timeout
		CHAR.velocity.y = move_toward(0, 100, 20)
		CHAR.move_and_slide()
		if CHAR.is_on_ceiling():
			ABLE_RISE = false
			
#This exits into the next State
func exit(next_state):
	fsm.change_to(next_state)




#When the player enters the Trigger Area, the exit() function is called
func _on_trigger_area_area_entered(area: Area3D) -> void:
	if area.is_in_group("PLAYER") and CHAR.velocity.y == 0:
		exit("Gulli_Attack")









########NEXT STATE V V V

extends Node

var fsm: StateMachine
@onready var CHAR := $"../.."

#Upon the State being entered, it starts a timer inside the Guillotine.
func enter():
	await get_tree().create_timer(.2).timeout
	$"../../Timer2".start()
	pass

func _process(delta: float) -> void:
	
	

	pass
	
func exit():
	# Go back to the last state
	
	fsm.back()
	


#The Guillotine rapidly falls downward, and when it hits the ground, it stops, and exits this state to the idle one.
func ATTACK():
	
	if CHAR.velocity.y >= 0:
		CHAR.velocity.y = 0
	$"../../HITBOX".monitorable = true
	CHAR.velocity.y = move_toward(CHAR.position.y, -100, 400)
	CHAR.move_and_slide()
	if CHAR.is_on_floor() or CHAR.velocity.y == 0:
		
		$"../../Timer2".stop()
		$"../../HITBOX".monitorable = false
		await get_tree().create_timer(.5).timeout
		exit()
	pass
	

func _unhandled_key_input(event):
	pass


func _on_timer_timeout() -> void:
	exit()
	pass # Replace with function body.

#The ATTACK() func is called every time the looping timer ends.
func _on_timer_2_timeout() -> void:
	ATTACK()
	pass # Replace with function body.

Personally, I’d probably do this something like:

# Untested!

const PRE_ATTACK_TIME:   float = 0.2
const FLOOR_REST_TIME:   float = 0.5
const CEILING_REST_TIME: float = 0.8

enum State { waiting, pre_attack, attacking, resting, rising, ceiling_reset }

var state:     State = State.waiting
var rest_time: float = 0.0
var target:    bool  = false

func _process(delta: float) -> void:
    match(state):
        State.waiting:
            if target:
                State  = state.pre_attack
                rest_time = PRE_ATTACK_TIME

        State.pre_attack:
            rest_time -= delta
            if rest_time < 0.0:
                state = State.attack
                $"../../HITBOX".monitorable = true
                CHAR.velocity.y = move_toward(CHAR.position.y, -100, 400)

        State.attacking:
            CHAR.move_and_slide()
            if CHAR.is_on_floor() or is_zero_approx(CHAR.velocity.y):
                $"../../HITBOX".monitorable = false
                rest_time = REST_TIME
                state     = State.resting

        State.resting:
            rest_time -= delta
            if rest_time <= 0.0:
                state = State.rising
                CHAR.velocity.y = move_toward(0, 100, 20)
                target = false

        State.rising:
            CHAR.move_and_slide()
            if CHAR.is_on_ceiling():
                rest_time = CEILING_REST_TIME
                state     = State.ceiling_reset

        State.ceiling_reset:
            rest_time -= delta
            if rest_time < 0.0:
                state = State.waiting

func _target_acquired(area: Area3D) -> void:
    if area.is_in_group("PLAYER") && state == State.waiting:
        target = true

That keeps all the logic in one place, more or less. The target bool is so we can detect the player entering the target area even if we’re not quite ready to hit them; this code will trigger an (eventual) attack if we’re waiting on the ceiling, waiting on the floor, or rising.

Edit: Code changes; I missed your pre-attack delay.

1 Like

I was unable to implement this enum system into my code, as I think I am simply too inept right now. However, I took inspiration and took the logic into one area, which allowed for me to FINALLY get it right!! Thank you tons!

The code now looks like this:

extends CharacterBody3D

var ABLE_RISE = false
var ATTACK = false

func _process(delta: float) -> void:
	if not position.z == 0:
		position.z = 0
	
		
		
	if is_on_ceiling():
		ABLE_RISE = false
	if is_on_floor():
		ABLE_RISE = true
		ATTACK = false
	move_and_slide()
	

func _on_trigger_area_area_entered(area: Area3D) -> void:
	if area.is_in_group("PLAYER") and ABLE_RISE == false:
		ATTACK = true
		$Timer2.start()
	pass # Replace with function body.


func _on_timer_2_timeout() -> void:
	if ABLE_RISE == true:
		await get_tree().create_timer(.5).timeout
		velocity.y = move_toward(0, 100, 30)
	
	if ATTACK == true:
		await get_tree().create_timer(.2).timeout
		self.velocity.y = move_toward(0, -100, 100)
	pass # Replace with function body.
2 Likes