Troubleshooting a function that returns the smallest angle difference between two planes.
I finished the conversion from floating point coordinates to a pseudo fixed-point form, where every pixel contains 4096×4096 discrete positions. Finally, the prototype levels work again! I also refactored the sprite rendering code, and redid most of the player’s animations. Several small tweaks to movement and blocking actors have made it in, and actors can now be designated as “early”, which shifts them to the first section of a scene’s live actors list.
Here are a couple of recent WIP videos: January and April. I think they might be a bit oversaturated: I’m using a pretty old TN monitor which doesn’t have very good color reproduction.
Coord-Scaling
Alright, I’ve more or less converted the whole project over to scaled coordinates. I gave up on passing the coord-scale value to every actor script via require(), and just started using a preprocessor macro to replace instances of ‘I_SCALE’ with the numeric constant.
The initial engine-side work wasn’t too bad, but switching over all of the actor scripts was an awful, repetitive, demoralizing experience, and I’m not sure if it was worth the trouble. It’s the kind of thing that should really be done near the beginning of a project with a fresh slate. But it’s in, and I can finally move onto more interesting things.
Sprites (the data structure)
I did some more sprite render cleanup, getting rid of various unnecessary safety checks. These were from an earlier time when the engine wasn’t structured as well. Sprites now cache their current animDef and frame table so that they can be looked up with fewer steps at render time. I also added a new sprite mode which allows for arbitrary quads into the texture atlas. This will probably be rarely used, if ever, but would simplify special-case scrolling of textures within smaller rectangular areas.
__index Methods
I took a bunch of sprite manipulation functions out of the animation module and reorganized them into a metatable which is attached to each sprite instance. Before, actors used a set of wrapper methods as the main way of configuring their own graphics. For every function like animation.set(sprite, new_anim_name), there would be a wrapper for actors like self:setAnim(new_anim_name, [sprite_index]). While this more or less worked OK, it was a bit difficult juggling sprite index numbers as the last argument in a wrapper function. Some of these functions (the temp-art ones, at least) take up to ten arguments. It would be nicer if I could just grab a sprite table reference and then call functions attached to it instead, like my_sprite:animSet(new_anim_name).
I converted the animation.set* functions over to sprite methods. This had the nice side effect of obsoleting a lot of “if not sprite then log.err(“This isn’t a sprite!”); return; end” guards at the top of these functions.
I also planned to get rid of the actor wrapper methods entirely, and just use the sprite versions. But the vast majority of actors never allocate more than a single sprite, and wrappers are still nice compared to digging out the relevant sprite table. So I kept versions of them which only operate on the actor’s first sprite.
AnimDef methods
I also made some improvements to how animations are defined. They’re still written out as function calls in source code — still no tool for it — but I reorganized the animDef tables to contain a metatable with callable methods. The most useful of these is def:clone(), which takes an existing animDef and creates a duplicate with all of the frame quads offset by an arbitrary amount. It can also associate the new animDef with a different spritesheet. Use of this has drastically reduced the amount of repetitive declarations in my animDef files, at least as far as the player goes. I probably should have put in something like this earlier, but I feel like I have a better handle on what I need now.
I had written a weird “loop n times and then switch to another animation” feature that was built into animDefs. That’s probably the wrong scope for it, so it’s gone. More useful: I added a ‘loop point’ value so that looping animations can return to a specific frame instead of always resetting to frame #1.
Sprites (the art assets)
Did a bunch of touch-up work on the main player art, and reorganized the player’s spritesheets.
- Doubled the number of animation frames for walking, rolling, swimming, and hanging from horizontal/vertical bars. Sped up the playback speed in most cases.
- Added a brief transition state between standing and ducking, visible for about 0.04 seconds.
- Filled in some missing eye-blinking versions of various idle animations.
- Redrew and reprogrammed jumping animations to better match the player’s environment hitbox
There are several versions of the base animations, so it took some time to fully apply the changes. I also added some code to diminish the walking animation’s playback speed when the player is moving against a wall or holding a full shot charge (a status which slightly reduces the max walking speed.)
Old and new base animations.
Jump Animation
I’ve had this lingering issue where the player’s jump graphics don’t accurately reflect his environment bounding box. His legs pull up a bit during the jump, leaving some empty space between his feet and the bottom of the box. It’s most noticeable in the middle jump frame:
Left: The player wouldn’t be able to land on the pillar because of the hitbox + graphic mismatch. Right: a detailed look at the hitbox and how the middle jump frame doesn’t fully cover the bottom part. This is particularly troublesome because this frame displays during the apex of the player’s jump arc, and the foot sticking out ahead looks like it’s ready to land on solid terrain.
It stinks to see the player character fail to land on a ledge, even though his feet, visually, are in the right spot. I tried implementing a quick-and-dirty workaround to shave off the bottom part of an actor’s hitbox, but this caused other problems. So I redrew the animations, taking care to make it play out quickly, and to end on a set of frames where the player’s foot is touching the lower-front corner of the hitbox. It now feels a lot more reasonable when the player can’t make it onto a ledge, whereas before it felt like the game was cheating you.
I’m not 100% happy with the new jump stance. It looks a little weird, but it’s far more important for the graphics to feel right in conjunction with the collision boxes.
The player won’t make it onto this ledge, but at least there isn’t conspicuous empty space between his foot and the terrain.
Danger Indicators
Added a brief flashing icon to the health bar when the player hits 1 HP. This lasts for about a half-second, and is followed by a mellower, much less urgent pulsing icon. This coincides with a brief “alarm” sound.
Added an ‘exhausted’ animation. This triggers when there is only one hitpoint left and the user hasn’t pressed any buttons for about one second.
Someone’s having a bad day.
Mostly Fruitless Experiments
More Animation
Attack Animation Variants
I guess I lost my mind for a bit, because I decided it would be a good idea to create variations on the main kick attack to go with the various angles and velocities that the user is able to impart on the projectile via directional inputs. Here’s a chart of the main variants for standing. The center-right frame is the original default kick.
So here are some problems I encountered. There isn’t enough relative pixel resolution to clearly render the kicking leg at these angles, and a character design with no neck and no shoulders is always going to require a heaping dose of contorted arm/leg/face positions to get the desired poses. We also can’t deviate too far from a more-or-less upright vertical stance because we need to remain close to the shape of player’s hitbox.
If you notice in the bottom-left frame, the player is kicking a bit into the ground. This would only trigger if the player had kicked while mid-air and then quickly landed on ground, or while standing on a slope facing downward. It looks fine mid-air, but not so much when landing on flat ground, and doubly so when crouching on flat ground, where the leg sticks down even further. It also looks OK on very steep slopes, where the angle of the leg and the angle of the ground are similar, but wrong when the angles are sufficiently different.
Basically, increasing the quantity of kicking poses inadvertently draws attention to the limitations of the attack system. Sticking to one kind of kick is more readable in the heat of the moment, and you are less likely to question why the player can only kick in these specific directions and not in others.
That said… after backing all of this out, I did later make a spinning mid-air variation of the base kick, and I haven’t disliked it enough to remove it yet:
Game Logic Tweaks
I reduced the attack cooldown time from 0.375 seconds to 0.25.
Back in late 2020, I redesigned how the player’s attack works. A thrown shot used to hurt the player following a brief grace period, and it wasn’t possible to catch and re-throw it. When I changed that behavior, I programmed it so that it would only activate once the player’s kicking attack state was fully cooled down. While in the kicking stance, you are mostly immobile.
This month, I experimented with allowing the player to catch the shot while still in the kicking state. This makes it easier to catch recently-thrown shots, but I need to be careful about the window of opportunity provided. If you can immediately catch a shot and kick it again, then the game devolves into a button-mashing contest. Even if I wanted a mashing aspect in the game, it’s too easy for the shot to ricochet unexpectedly due to changing circumstances, so this isn’t really a dependable behavior like, say, the standard one-button attack combos in beat-em’up games.
So we can catch the shot partway into the kick action, but another kick can’t be performed until the previous kick’s cooldown is complete. We don’t want the shot to be held in this state for too long because it looks weird. There’s a lot of what-ifs and this isn’t something that I’m ever going to get perfect.
Right now, the fastest rate of repeated throwing is about three kicks per second. Catching re-enables once the kick state is 50% cooled down.
I made this diagram before reducing the kick cooldown time from 96 to 64 ticks, and am too lazy to edit it.
Ceiling compression
Implemented a brief pause when the player bumps his head against ceilings. This was in Hibernator (though it doesn’t become noticeable until you’ve upgraded your jump power a couple of times), and I’m generally a fan of the idea when a game has many situations where you need to jump over hazards while dealing with low ceilings. However, the more complex the game environment, the harder it is to do without feeling inconsistent, or having it backfire unexpectedly in certain cases. It can look weird if only one object (ie the player) has this property, and moving + sloped ceilings can present challenges as well.
Compression examples.
I got this in, then had second thoughts. Before I backed out the code changes, I tried to think of any practical situations where it would help, and if it’d be possible to enable ceiling compression only for those circumstances. And there is one case where hitting the ceiling and immediately falling downward kind of sucks: when hugging a vertical pole, and jumping while your head is close to a ceiling. Often when this happens, you bonk your head, start falling downward, and don’t reach the desired target.
Enabling ceiling compression just for this case improved the player’s ease of hopping from pole to pole while close to a ceiling. But if the compression time is too long, it draws user scrutiny when the player leaps from the pole, compresses against the ceiling, lands on solid ground, leaps again and doesn’t compress. So I made the compression time pretty short.
My implementation basically goes like this:
- If colliding with ceiling:
- If ceiling_compress == false:
- yvel = max(yvel, 0)
- Else:
- yvel = yvel + ceiling_compress
- If ceiling_compress == false:
So while compressed against a ceiling, the actor’s Y velocity is still negative, leading it to continuously move up and be position-corrected over and over. Besides gravity, the actor is also being pushed down by a secondary ceiling_compress value. The earlier you collide with a ceiling during a jump, the longer you’ll compress against it. Hanging onto a pole enables the compression, while any foot-to-ground contact disables it.
If you were to jump against a ceiling in this state and then quickly get out from underneath it, your character would start moving upwards again. This is a helpful mechanic in some games, but in this context, I think it’s looks and feels confusing. I guess another option would be to zero out Y velocity and gravity for the duration of the compression? Like I said, this gets dicier with ceilings that are angled or which can move.
Shot Shunting
The player’s shot is a sphere, but the internal collision shape is an axis-aligned square. This is a limitation of my collision and platforming code. Things mostly work OK, but there are some cases where a shot teeters on a ledge but doesn’t fall off.
Shots could hang this far over a ledge without falling off.
I’ve been wanting to add a small nudge to push the shot off in cases where the bottom-center coord isn’t touching solid ground, and this month I finally tried it. While it technically works, unfortunately, a square is still a square, and it looks pretty unnatural as it slides off. Like with the ceiling compression stuff, I came very close to pulling the code out, but I decided it would still be OK for dealing with the most extreme cases, such as the one depicted in the above image. I don’t really have a good solution for this short of rewriting the platforming code.
“b_zero”
I’m running out of names for these stupid tweaks. This is another field I stuck into the platformer state table, and so far only the player’s shot uses it. Under normal circumstances, when an actor bounces against a moving block:
- Its Y velocity is inverted to make it look like it’s bouncing away
- Since it’s a moving surface, the moving block’s Y velocity is added to the actor’s Y velocity
- The Y velocity may be reduced a bit depending on the bounce factor (like “reduce velocity by 10%”)
Everything’s fine until we get to the player’s shot, which alternates between bouncing off surfaces and anchoring to them. Under normal circumstances, when the player kicks a shot while ducking, we expect the shot to roll on the ground and come to a stop. This worked on tilemapped ground, it worked on static blocks, and it worked on blocks moving down (to a point.) But to my surprise, it did not work on blocks moving slightly upwards. Instead, it would slide along the surface with practically no slowdown at all.
Digging into the platforming code, I realized that the above steps (plus the sequential nature of how actors are processed in the engine) made it so that the player’s shot in this situation would continuously bounce just a tiny bit above the block surface, not making enough contact to activate the rolling state.
So when b_zero is true: if the actor’s yvel is less than the negated block velocity, then its yvel is set to zero. This makes the actor continuously bump into the block’s top surface, which satisfies the criteria needed for the shot to switch from bouncing mode to rolling mode.
“anchor_cont”
Another platformer tweak for the player’s shot. When false, this disables the code that maintains grounded continuity between different kinds of floors. The continuity code ensures that actors like the player don’t abruptly disconnect from the floor, for example when stepping from flat ground to a downhill slope. I found some conflicts when it comes to the player’s shot though, and disabling those lines fixed it, so now it’s a parameter.
“Early” actors, and using GOTO again
So I noticed a pretty huge oversight in my scene tick loop: if an actor fails to start up successfully, it will still execute its tick code and other related activities. Oops. I’m not sure how this went unnoticed for so long, but I recently I encountered an immediate and reproducible crash where this was the cause. I had the choice of either wrapping every actor function call in the scene runner with “if not marked_for_removal then …”, or using goto as a stand-in for the continue keyword found in other programming languages. I went with the latter because it’s easier to follow.
I had been using goto early on, but removed it because I was worried that it would cause problems if I needed to run any of the code in plain Lua. (Newer versions of Lua do have goto, but not 5.1.) With my frequent use of other LuaJIT features, it’s safe to say that this concern was unfounded.
Merging Platformer and “Cargo Shift” Services
I guess this is an example of the codebase solidifying. Last month, I wrote a bit about the problem of shifting things that are resting on top of moving platforms, specifically in the context of low-res art. (The gist is that cargo which isn’t pixel-aligned with the platform can jitter as it and the platform cross pixel column boundaries at different times.) The shifting service has been in the codebase for a long time, and kept separate from the “platforming state” service so that it could be executed after platformTick() and main actor tick code.
The reason for that was because as the player travels through a world, rooms are set up and torn down, with associated actors being removed and newly added. So even though the player actor is one of the last things spawned during world setup, it slowly works its way to the start of the list as new things are added after it.
Now that actors can be spawned into the “early” part of a scene’s actor list, and virtually all cargo-carrying actors are now configured to use that feature, it’s no longer necessary to keep the platforming tick and cargo-shifting logic separate.
Project Outlook
It’s been a long few months with some big detours, but I think the project is in a better spot now. For May, I’m hoping to get some more stage prototype content done, and I am probably going to revisit state machines for managing actors.
Stats
Codebase Issue Tickets: 45
Total LOC: 108035*
Core LOC: 37452
Estimated Play-through Time: 4:00.22**
*The lower total LOC can be attributed to using a more efficient method of setting up animations.
**Practically the same prototype content as January. Kind of spooked that the time is only off by 0.01 seconds.
Since you made it to the end, here are some goofy unused sprites:
1 Comment