b0in.xyz

Notes - Godot - MovingController2D and simple collision/levels warping

2023-10-01 13:07:00 -0700

Learning godot. Just notes on a simple two-level dungeon. Code is annotated.

The scene graph. Sprites are polygon2ds so i dont have to worry about spriting or animation for now.

MovementController2D.gd:

#
# # Movement Controller
#
# WARNING: not production quality, just learning code.  
#
# This movement controller is used to apply generic movement
# functionality to 2D objects. Just add a basic Node object
# under your Player object of type Node2D (or extended) then
# drag the script onto your MovementController Node object.
#
# * Player (Area2D)
#   * MovementController (Node) [script]
# 
extends Node

#
# ## Moving Actions
#
# Moving actions are simple objects which have a special _process
# method that returns a boolean signifying whether the "action"
# is still running or not.
#
# ### Empty Moving Action
#
# This one always returns "still running" / true and only gets
# replaced when input is triggered.
#
class EmptyMovingAction:
	extends Object

	func _process(_delta):
		return true
		
#
# ### MovingAction
#
# This one extends EmptyMovingAction and provides
# actual behavior, just moves in a direction and duration
# given.
# 
class MovingAction:
	extends EmptyMovingAction
	
	var duration: int
	var direction: Vector2
	var sprite: Node2D
	
	func _init(dir: Vector2, dur: int, spr: Node2D):
		duration = dur
		direction = dir
		sprite = spr
	
	func _process(delta):
		if duration <= 0:
			return false
		duration = duration - delta
		sprite.translate(direction*delta)
		return true
		
#
# ## State
#
# We define our current moving action as Empty, first,
# as it means "no movement" and the _proecss loop just triggers
# nothing.
#
# Empty is a singleton so we do not have to instantiate it
# over and over.

var empty_action = EmptyMovingAction.new()
var moving_action: EmptyMovingAction = empty_action

# We also have our sprite object, but it's just get_parent
var sprite: Node2D

#
# We export two variables, this makes them show up 
# in the godot UI. Distance is how far a single input will
# send the player and duration is how long it should run for.
#
@export var duration: int
@export var distance: int

func _ready():
	sprite = get_parent()

#
# ## Input
#
# Most input is handled below in _process but handling the 
# unpress action here ensures we get smooth stopping when we 
# press 2 arrow buttons at once. Left and Up then releasing
# at the same time might be seen as "left unpressed, up pressed"
# for a brief moment, causing a stray movement in the up direction.
#
# this ensures that doesn't happen by just stopping movement the moment
# you release any button. If you keep holding down 'up' after releasing 'left'
# the next 'up' in _process will spawn a new up, causing smooth motion.
#
func _input(event):
	if not event.is_pressed():
		if event.is_action_type():
			moving_action = EmptyMovingAction.new()


#
# We calculate dx and dy based on input so no one path has 
# priority (aka, checking ui_right before ui_left could
# cause ui_right to always trigger if you press both buttons down).
# 
# This design ensures 8-way movement with multi-button input.
#
func _process(delta):
	var dx = 0
	var dy = 0
	var pressed = false
	
	if Input.is_action_pressed("ui_right"):
		pressed = true
		dx += distance
		
	if Input.is_action_pressed("ui_left"):
		pressed = true
		dx -= distance
	
	if Input.is_action_pressed("ui_up"):
		pressed = true
		dy -= distance
		
	if Input.is_action_pressed("ui_down"):
		pressed = true
		dy += distance
	
	# 
	# this is why MovingAction itself cant be a Node we add or remove,
	# we need to be able to detect when a MovingAction is done and
	# remove it from our loop.
	#
	if not moving_action._process(delta):
		moving_action = empty_action
	if pressed:
		moving_action = MovingAction.new(Vector2(dx, dy), duration, sprite)

dungeon.gd:

#
# ## Dungeon
# 
# This is the node which represents a 2D top-down dungeon containing 2
# levels.
#
# This and the generic MovingController2D script are the only
# scripts in this demo.
#
extends Node2D

#
# ## Starting up
# 
# We have two tilemaps in this, each one representing a "Level" in this
# dungeon. On start, we disable the second tilemap and hide it.
# 
# hiding it disables rendering while setting process_mode removes
# all eventing and processing, including collision detection.
#
func _ready():
	$TileMap2.process_mode = PROCESS_MODE_DISABLED
	$TileMap2.hide()
	$TileMap1.process_mode = PROCESS_MODE_INHERIT
	$TileMap1.show()

# 
## Events
#
# Each level in our dungeon has 1 event each. Both events are bound to
# area_entered signals on the Area2D object of the "Event" receiver (within
# godot UI, not in the code), meaning the "area" variable is the Player 
# or another non-player Area2D, such as another monster or a UI element.
#
# This handler triggers when one Area2D enters the "Downstairs" event,
# which is triggered when you walk into the stairs object. This
# "teleports" the user to the level 2 of the dungeon by disabling
# one tilemap and enabling the other. 
#	
func _on_downstairs_event_area_entered(area: Node2D):
	if area == $Player:
		$TileMap2.show()
		$TileMap1.hide()
		# call deffered is used because this is a "collision/physics" callback
		# and we cant change the physics logic during it. I wouldn't know
		# except godot prints an error
		$TileMap2.call_deferred("set_process_mode", PROCESS_MODE_INHERIT)
		$TileMap1.call_deferred("set_process_mode", PROCESS_MODE_DISABLED)

# 
# This is called when the player comes into contact
# with a monster. We trigger a "push-back"
# animation which would represent taking damage from the monster.
#
func _on_monster_area_entered(area: Node2D):
	if area == $Player:
		# there is no touched_monster signal in the player 
		# object (yet) but this is allows us to trigger stuff like
		# 'lower HP, play damage animation, etc' and make it all
		# encapsulated within the player object.
		area.emit_signal("touched_monster", $TileMap2/Monster)
		# for now, this janky animation...
		var monster_pos: Vector2 = $TileMap2/Monster.global_position
		area.translate (-abs(monster_pos - (area.global_position)))