Devlog 2024-01-20

Way less to talk about this week, since it’s only been seven days since the last post instead of 14 months. My dev time this week continued to be focused on Escape from Evil Island!

Checklist

  • Evil Island: Fixed some bugs, added some polish
  • Evil Island: Finished the second-last level of the game

The Second-Last Level

I call this the “second-last” level, but really it functions as the last level. Like the games it’s based on, and to simplify things, the game requires you to finish a level in one shot – there are no checkpoints or mid-level saving. I did not, however, want to force the player to play a long level and then do a “boss fight” at the end, with the prospect of having to do the whole level over again if you lost to the boss. I thought about adding checkpoints (really hard, honestly, but it really would complicate things a lot) but instead I just decided to make the final boss fight its own level entirely. This means you’ll be able to visit the shops to stock up, or finish up whatever optional things you wanted to do, before tackling the boss itself.

Control Remapping

This is actually something I did a couple weeks ago, but I didn’t fit it into the big info dump in my last devlog. While this game was intended to be much smaller than it actually was, one thing I wanted to include was control remapping. Partly because it’s just good practice, and partly because I hadn’t actually written one before in Godot, and wanted to see if there were any surprises. The way Godot handles input is pretty much exactly how you’d want to build it to make control remapping easy, so I wasn’t too worried.

Turns out… I was right! It’s pretty straightforward. I have a default input map set up in my game code, which is just a dictionary that looks like this.

var default_input_map:Dictionary = {
    "ui_accept": ["k"+str(KEY_X), "j"+str(JOY_BUTTON_A)],
    "ui_cancel": ["k"+str(KEY_Z), "j"+str(JOY_BUTTON_B)],
    "play_jump": ["k"+str(KEY_X), "j"+str(JOY_BUTTON_A)],
    "play_shoot": ["k"+str(KEY_Z), "j"+str(JOY_BUTTON_X)],
    "play_activate": ["k"+str(KEY_C), "j"+str(JOY_BUTTON_B)],
    "play_drop": ["k"+str(KEY_DOWN), "j"+str(JOY_BUTTON_DPAD_DOWN)],
}

As you can see, I’ve got a keyboard (“k” prefix) and a joypad (“j” prefix) for each action. The ui_accept, ui_cancel, etc strings match the actions I have defined in my project’s Input Map. (The Input Map essentially contains a duplicate of this which gets overwritten at play time, which is something I need to clean up, actually.) I already have config loading/saving set up (since I’ve had sound and music volume configuration set up for a while). It’s a straightforward JSON-based config, so it’s easy to add new fields, so during the game load I grab whatever button mapping I have saved (as a dictionary matching the format of the default) in that config, defaulting to that above default_input_map if I’ve never saved a different button mapping dictionary.

Then I loop through the input map (loaded from config or the default) and do this:

for action_name in input_config:
    for button in input_config[action_name]:
        if button.begins_with('k'):
            var new_event = InputEventKey.new()
            new_event.keycode = int(button.substr(1))
            InputMap.action_add_event(action_name, new_event)
        if button.begins_with('j'):
            var new_event = InputEventJoypadButton.new()
            new_event.button_index = int(button.substr(1))
            InputMap.action_add_event(action_name, new_event)

So for each entry in the list for this action, if it starts with a “k” then I create a new InputEventKey and set its keycode, then use InputMap.action_add_event() to set it on that action. If it starts with a “j” I make an InputEventJoypadButton instead. You can look at the InputMap documentation here but the quick version is that InputMap is a big map of action names to a list of input events – if the player (by pushing a button, typing a key, etc etc) generates any of those input events it marks the action as pressed (or whatever makes sense for the input type). Then when you call Input.is_action_pressed() or similar, it will return true.

In the options menu, when you pick something to remap, it simply waits for any input. This part is a bit less obvious, but basically I have an _input function on my options menu, with a boolean flag I can set when I’m waiting for “any key”, and then await a custom next_key_press signal. Inside that _input() I watch for InputEventKey and InputEventJoypadButton events, and if so I get the scancode/button index out and emit next_key_press.

# slightly edited for brevity; the actual code plays sound effects as appropriate,
# and also has a short list of un-remappable buttons it will ignore

func _input(evt):
    if not visible or not _waiting_for_input:
        return
    if evt is InputEventKey and evt.pressed:
        _waiting_for_input = false
        if evt.keycode == KEY_ESCAPE:
            next_key_press.emit(null)   # null means the player cancelled, so leave the current mapping alone
        else:
            next_key_press.emit(evt)
    if evt is InputEventJoypadButton and evt.pressed:
        _waiting_for_input = false
        if evt.button_index == JOY_BUTTON_START:
            next_key_press.emit(null)
        else:
            next_key_press.emit(evt)

Then, the code awaiting next_key_press can simply grab the input map from the config (including loading the default if necessary), replace the appropriate button for the action (i.e. a keypress has to replace an existing keypress, not a joypad button), and then re-calls the function set up my input.

By far the most annoying part was to get the UI looking how I wanted. I’m building the game at a 320x240 resolution, which means there just isn’t much room for what you’d see in a modern control remapping interface. I settled on allowing exactly one keyboard button and one controller button per action. There are also hard-coded buttons for some of these actions, as duplicates, but those shouldn’t cause any problems.

In addition to the clean up I mentioned above, there are a couple things left I’d like to add: I want a “reset” to defaults, and I want to think a bit about how to prevent the player getting themselves stuck with unusable controls (e.g. mapping confirm and cancel to the same button will probably cause issues that might be impossible to fix?) I suspect I can just have a small set of buttons that must not be the same (e.g. Confirm and Cancel), to keep the player from getting stuck, and they can make whatever mess they want aside from that. Similarly, the reset should be trivial, the hardest part will be fitting it on the UI.

Next Week

My big target for next week is to get started on the final boss fight. To be honest, I’m not even sure exactly how it’s going to work; I want it to have a bit of spectacle to it but I also don’t want it to be so different from playing the rest of the game that it acts as an insurmountable difficulty wall either. We’ll see what I come up with!