[2024/10] Dev Blog: Let PC Interact with a Static Dungeon


[2024/10] Dev Blog: Let PC Interact with a Static Dungeon

The Life of a Government Clerk is a single player, turn-based, coffee break Roguelike game made with Godot engine. The game title comes from Chekhov’s short story, The Death of a Government Clerk, while the core mechanics are inspired by Kafka’s novel, The Castle, in which K witnessed two clerks delivering document by cart in a hotel.

The game is under early development. Its current version is 0.0.3, which is not ready for play yet. This blog post, unlike the previous one, which is mostly about game design, shares a few Godot-specific programming techniques.

Current Progress & Future Plans

In the previous dev blog, I mentioned that I didn’t know how to design different PC abilities based on cart length. Now I have figured out the solution.

  • cart_length == 3: PC starts game with 3 carts. It costs 1 turn to move into a grid occupied by a Servant.
  • cart_length < 7: It costs 2 turns to move into a grid occupied by a Servant.
  • cart_length >= 7: In addition to existing effects, PC also unlocks the fourth primary product, Encyclopedia. It occupies one cart slot as other products, but when being converted to Document, counts as 1.75 Book.

After version 0.0.2, the development will go through four stages:

  • Create a static dungeon for play testing.
  • Let PC interact with buildings and NPCs.
  • Let PC and the dungeon evolve as time goes by.
  • Create a dynamic dungeon by an RNG seed.

By version 0.0.3, the first two stages have been completed, as shown in Game Demo below.

Game Demo

2. Unload an item.

Note that a Clerk can accept at most two items. However, they must be of different types. Therefore, we cannot unload two Atlas to the same Clerk.

8. Drive away Servants: 1/3.

As mentioned above, it takes more turns to drive away Servants with a long line of carts. Besides, if PC has a Stick (12. Drive away Servants: 3/3.), cart length does not matter when interacting with Servants.

14. Loading Document has the highest priority.

The mechanics behind Clerk is complicated (more details below). If a Clerk has a Document, PC always tries to load the Document before unloading an item.

Describe PC and Carts by Code

In version 0.0.2, PC was already able to drag, add or remove Carts. However, its implementation was irrelevant to the last dev blog’s topic. Instead, it will be explained in detail below.

As a full-stack indie game developer, there is not a fine line between game design and game programming. Before typing the first line of code, I always ask myself the same two questions. Do I really need the new function? If so, can I clearly describe the task?

Let me share a failure experience first. There used to be an optional delivery task in the game. An Officer sends an urgent message to another Officer under certain circumstances. The message disappears if PC does not delivery it right away. A successful delivery grants an extra income. From game design’s view, both the risk and reward seem reasonable. But once I start coding, I quickly notice that there are way too many condition checks. This forces me to step back and review my initial design idea. I’d like to encourage player to travel the whole dungeon rather than stay inside a safe zone. An urgent message is one of the many ways to achieve the goal, rather than the only one. Therefore, I dropped the idea.

Now let’s go back to the topic of this part. Carts are critical to the game and I definitely need them. The first and simplest task can be described as:

  • PC and Carts forms a straight line. PC is in the head.
  • When PC moves, move all the Carts in the same turn.
  • The first Cart moves into PC’s previous position. Other Carts follow along.

The next step is separating game data from game logic. Design a container to store data first. More specifically, we need two containers: one for a Cart, the other for a line of Carts.

library/cart_state.gd defines a custom class to store the data of a Cart. Note the naming convention. I use x_state.gd (or XState for class name) to refer to the data container for game object X. Therefore, library/linked_cart_state.gd is the data class for all the Carts as a whole.

Considering the fact that there should not be more than 20 Carts in the game, and PC could not add or remove Carts frequently (less than once per 10 turns), it might be a good idea to store all CartState objects in an array. However, since I’ve already created a linked list for scheduling system, see Godot 4 Roguelike Tutorial, I choose the same data structure to store Carts in linked_cart_state.gd.

Game logic is described in library/cart.gd. The script only contains constants and static functions, and intentionally excludes any variables. These static functions can be called by any node (or script file). They act solely on input arguments and know nothing about game data that is not provided.

Separate Nodes from Library Scripts

The node tree looks mostly the same as the one used in my Godot 4 Roguelike Tutorial, with one subtle difference: node PcAction/PcFov no longer exists. I turned the node into a library script (library/pc_fov.gd) because I want to distinguish nodes from library scripts.

As mentioned above, library scripts are purely data processing tools. Nodes, on the other hand, have three different roles.

  • Nodes can send or respond to signals.
  • Nodes can save data or data containers (custom objects, see above).
  • Nodes provide public properties, methods and functions.

A node can read/write its internal data directly. It can also manipulate external data indirectly by sending signals or calling another node’s public functions.

Code Example

The final part of this dev blog briefly reviews the code that handles PC’s interaction with NPCs. Node PcAction responds to player’s input and calls _move() if player presses an arrow key.

func _on_PlayerInput_action_pressed(input_tag: StringName) -> void:
    ...
    match input_tag:
        InputTag.MOVE_LEFT:
            _move(_pc, Vector2i.LEFT, _linked_cart_state)
            return
        ...
    ...


func _move(pc: Sprite2D, direction: Vector2i, state: LinkedCartState) -> void:
    ...

    # Condition 1.
    if not DungeonSize.is_in_dungeon(coord):
        return

    # Condition 2.
    elif SpriteState.has_actor_at_coord(coord):
        sprite = SpriteState.get_actor_by_coord(coord)
        sub_tag = SpriteState.get_sub_tag(sprite)
        if sub_tag in VALID_ACTOR_TAGS:
            PcHitActor.handle_input(sprite, self, _ref_ActorAction,
                    _ref_GameProgress)
        ...
        return

    # Condition 3.
    elif SpriteState.has_building_at_coord(coord):
        sprite = SpriteState.get_building_by_coord(coord)
        if not sprite.is_in_group(SubTag.DOOR):
            return

    # Move PC and Carts.
    Cart.pull_cart(pc, coord, state)
    ScheduleHelper.start_next_turn()

First note the naming convention in _move() for library scripts and node references:

  • LibraryScript.static_function()
  • _ref_MyNode.public_function()

By game design, move PC and Carts, and then end PC’s turn if PC’s destination is a grid inside dungeon (Condition 1), is not blocked by an NPC (Condition 2), and is not blocked by a building unless it is a door (Condition 3). PC’s interaction with NPCs is handled by a library script PcHitActor.

library/pc_hit_actor.handle_input() calls private functions based on an NPC’s group name, which I call sub_tag.

static func handle_input(...) -> void:
    var sub_tag: StringName = SpriteState.get_sub_tag(actor)
    ...

    match sub_tag:
        ...
        SubTag.SERVICE:
            ...
        SubTag.STATION:
            ...
        SubTag.CLERK:
            ...
        ...
    ...

Interacting with a STATION NPC is the most straightforward. It only involves one node, PcAction.

SubTag.STATION:
    if not _clean_cart(ref_PcAction):
        return

Using service is slightly more complex. We need to change PC state and NPC state by calling two nodes’ (PcAction and ActorAction) public functions in order.

SubTag.SERVICE:
    service_type = ref_ActorAction.get_service_type(actor)
    # Change PC state.
    if _use_service(ref_PcAction, service_type):
        # Change actor state.
        ref_ActorAction.use_service(actor)
    else:
        return

When PC hits a Clerk, he can unload raw files or load Document. The game needs data from both nodes (PcAction and ActorAction) before actually taking action. The mechanism is complex, but by assigning code to different nodes, library scripts, public and private functions, the code is still manageable.

SubTag.CLERK:
    first_item_tag = _get_first_item_tag(ref_PcAction)
    if _can_load_document(ref_PcAction) and \
            ref_ActorAction.send_document(actor):
        _load_document(ref_PcAction)
    elif _can_unload_raw_file(ref_PcAction) and \
            ref_ActorAction.receive_raw_file(actor, first_item_tag):
        _unload_raw_file(ref_PcAction)
    else:
        return

Leave a comment

Log in with itch.io to leave a comment.