Notes - Godot - MovingController2D and simple collision/levels warping

me@b0in.xyz

2023-10-01 13:07 -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)))