Balleg Devlog Jan 2021

Pic from an unfinished fourth beta level. Testing three layers of parallax scrolling in the background (which of course doesn’t come through at all in a still screenshot, oops.) Top-right: player getting hit by a laser projectile, shot by the blue square. Bhzzap!

 

Build Script Changes

I added another step to the build script: all new or updated files are copied from game to game_out so that destructive changes can be made without endangering the source. I mentioned in December that I was interested in adding a preprocessor pass as a way to get constants. I did a small test with sed to replace the string CONSTANT_TEST with 987654321, and then did nothing else with it. Oh well. It may still be helpful when I have to deal with packaging.

conf.lua

LÖVE has a per-project config file which allows you to disable built-in modules. Some are mandatory, but others like love.audio can be turned off as long as you don’t attempt to call any love.audio.* functions at run-time. After some housecleaning, I made the following optional:

  • love.audio
  • love.joystick
  • love.keyboard
  • love.mouse
  • love.sound

If either love.audio or love.sound are disabled, then all project-level audio functionality is dummied out. Interestingly, the love.keypressed callbacks still work even when love.keyboard is disabled.

I also made enough changes to support setting t.window to false, to prevent initial window creation during startup (the window just gets recreated again after the config files are loaded.) Calls to  love.graphics.* before the window is created with setMode() will raise an error.

 

More Indexed Palette Work

In 2020, I wrote a GLSL fragment shader which can apply indexed color palettes to pixel art using the red channel as an 8-bit color index. Even though I mostly got it working, I didn’t really do anything with it (other than get most of the existing art to display correctly) until now.

This was my previous palette, which was thrown together just to get existing art to display properly:

 

This is my new palette layout:

 

 

The top four rows are unlikely to change much, or if they do mid-game, it’s expected that art assets will still look reasonable. All rows after that are left up to the scene and its associated entities to manage.

 

Projectile Toolkit + Service

128 shots spawned at the same time.

I made a pared-down variant of the platforming physics code, intended for simple projectiles which self-destruct on contact with solids. Previously, all projectiles used the platformer service for collision detection, gravity, etc. This code has a lot of stuff for placing actors on surfaces which isn’t applicable to simple projectiles.

Here are a couple of screenshots showing tick processing time when 128 shots were spawned at the same origin point simultaneously. With the platformer service, this was enough to momentarily drop to a single-digit framerate:

 

With the pared-down projectile service, it briefly drops to just below 60 Hz:

 

In the first case, the shots were configured to check collisions against other shots. This would be the most likely culprit for the processing spike, as every shot had to register a collision event against every other shot, even though they didn’t actually interact with each other.

This service’s biggest weakness is that for tilemap collision checks, it only looks at the first room in the actor’s overlap list. So it’s expected that these shots won’t be traveling from room to room, or that they are small enough that transient collision issues won’t be noticeable to the end user.

 

LÖVE Threads + Tone Generator

Started playing around with threads in LÖVE, something I haven’t touched up to this point. Each thread is a separate Lua instance with its own state, and LÖVE provides channel structures to transfer signals and data back and forth. If you had strict.lua active or made changes to LuaJIT config in the main thread, that stuff is not automatically present in a newly-started thread.

I did this because I found an old audio generation snippet from the (no longer online, but archived) LÖVE Bitbucket issue tracker. The poster recommended putting this sort of thing into a separate thread to avoid interference from other in-game activities (which is to say, the audio crackles if the framerate drops.) I tried the snippet a while ago in a test project on the main thread, but wasn’t able to find a good buffer size that didn’t crackle from underruns or have noticeable latency.

The snippet writes samples to a SoundData buffer, and then queues that data in a Queueable Source object, which can seamlessly play back an arbitrary number of sound chunks in order. Even on a thread, I couldn’t find a good config that was sufficiently responsive for sound effects without popping. The best I could get was 2048 mono samples with 2 queues.

I did some latency tests by playing two sounds by different means at the same time:

  • Main thread static source VS threaded synth via queueable source: 0.034 seconds
  • Main thread static source VS threaded static source: 0.023 seconds*

(*With occasional instances of both sounds playing at the same time.)

I guess there are two layers of incremental buffer writing here: the SoundData being queued, and also the audio data buffer that OpenAL is writing to.

I am a little disappointed with the results, but it’s not a total loss, as the queueable source could still be used for ambient sounds which don’t need to be tightly synced with visuals. And I could still render synthesized SFX to SoundData objects during start-up or transition screens, and use them as plain static sources.

 

Custom LÖVE error handler

This followed from the audio thread work. When LÖVE displays the blue error screen and crashes, the other threads continue running. Even though the error handler tells all audio sources to stop, my audio thread was still feeding and playing a queueable source in the background. Closing the application in this state would cause an OpenAL mutex assertion error, leading to a core dump unrelated to the actual problem. So I copied the default handler to my codebase and added a bit that tries to close all threads.

LÖVE’s default error handler is in ‘src/scripts/boot.lua‘ and it needs the utf8 module loaded to work.

 

Variable Bounce Restitution

 

Thought it’d be good to have different amounts of bounce response depending on the terrain. Unfortunately, my collision and platformer code isn’t good at returning details about solid terrain that an actor has bumped into. It’s possible to bump into more than one tile at a time, so which one gets priority? What if they have conflicting response settings? After 20 minutes of working on the problem, I backed out my changes and opted for a lazy, hacky solution: create passable terrain with a “low_restitution” flag, and then in the player’s shot, just check if its center XY is overlapping a tile with that flag present. If yes, lower the shot’s bounce-back parameters. If no, restore them to the normal values.

 

Indexed Palettes with Gamma-Correct Rendering

Back in June 2020, I had difficulty getting an indexed palette shader to cooperate with gamma-correct mode. While the palette canvas itself looked OK, sprites which sampled colors from the palette using their red channel as an index would select the wrong color within the first few indices, even when calling unGammaCorrectColor() on the red value within the shader. Thanks to this recent GitHub issue, I now understand what was happening: there are two sets of gamma-correct functions: fast, and accurate. LÖVE shader code defaults to the fast versions. To get the proper index from the red channel, I needed unGammaCorrectColorPrecise().

(EDIT 29/Jul/2021: I later found that unGammaCorrectColorPrecise() still has precision issues with color values approaching 255. I ended up specifying ‘{linear = true}’ in the texture atlas so that LÖVE doesn’t color-correct it. I’ll post a bit more about this in the July 2021 devlog post.)

 

ImageData:mapPixel()

I tried replacing some setPixel() loops with calls to mapPixel(). The latter is supposed to be faster in high-volume cases because it locks the ImageData object once, whereas the former locks and unlocks for every individual call within the loop.

When using mapPixel, you pass a higher-order function which is then called on every pixel within the specified rectangular area. This function accepts the arguments (x, y, r, g, b, a), which is enough to do things like invert the color, or darken or brighten it. For any additional state, it appears that you need to use upvalues from within the higher-order function. For example, this is a function that forces all pixels with a specific RGB value to be 100% transparent, with an option to make other colors 100% opaque:

-- Table to hold additional state
gfxData.t_forceAlpha = {}

-- The higher-order function
function gfxData.H_forceAlpha(x, y, r, g, b, a)
    local t = gfxData.t_forceAlpha
        if r == t.r and g == t.g and b == t.b then
            return r, g, b, 0.0
        elseif t.opaque_other_pixels then
            return r, g, b, 1.0
        end
    end

-- The calling function
function gfxData.forceAlpha(image_data, r, g, b, opaque_other_pixels)
    opaque_other_pixels = opaque_other_pixels or false
    
    -- Stuff additional state into table which will be
    -- seen as an upvalue from gfxData.H_forceAlpha.
    local t = gfxData.t_forceAlpha
    t.opaque_other_pixels = opaque_other_pixels
    t.r = r
    t.g = g
    t.b = b
    
    image_data:mapPixel(gfxData.H_forceAlpha)
end

Does that look right? Maybe it’s not meant for this kind of thing. Here’s the original for reference:

function gfxData.forceAlpha(image_data, r, g, b, opaque_other_pixels)
    opaque_other_pixels = opaque_other_pixels or false
    
    local image_w, image_h = image_data:getDimensions()

    local this_r, this_g, this_b

    for y = 0, image_h - 1 do
        for x = 0, image_w - 1 do
            this_r, this_g, this_b = image_data:getPixel(x, y)

                if r == this_r and g == this_g and b == this_b then
                    image_data:setPixel(x, y, this_r, this_g, this_b, 0.0)
                elseif opaque_other_pixels then
                    image_data:setPixel(x, y, this_r, this_g, this_b, 1.0)
                end
            end
        end
    end
end

At the very least, I can check mapPixel() off of my LÖVE feature bucket list.

 

Project Outlook

The outlook is about about the same as it was in December to be honest. There are some issues I need to get resolved with respect to how multi-actor entities work (specifically: how they interact with the player’s shot), and I need to clean up the code that generates and configures enemy projectiles. Hopefully I’ll have a better update at the end of February.

 

Stats

I wanted to collect more interesting stats this year, but the ones I considered (like “total estimated game screens” or “total ActorDefs”) are just going to paint an inaccurate picture. So I’ll stick to codebase issue tickets, lines of code (including whitespace and comments), and estimated play-through time (me running through the game as-is with a stopwatch.)

Codebase Issue Tickets: 53
Total LOC: 96734
Core LOC: 35533
Estimated Play-through Time: 4:00.21

 

(e 29/Jul/2021: Note on unGammaCorrectColorPrecise().)

Leave a Comment