Sep 21, 2020

SIDE-TRACK: Gyruss (NES) Study


If you're wondering what's up with all these side-tracks, there are a couple reasons. First and foremost, I'm just genuinely curious how NES games (and some SNES) were coded, which was what got me started on CV3. While I don't understand the ins-and-outs of most things, I can sometimes get the gist of the parts I actually care about. I also have ideas for recreations or spin-offs of some old games. Additionally, I'm looking for algorithms that I may have never considered. Password algorithms fly over my head, but they're fun to research, and in this case, Gyruss movement deviated from what I had thought it would be and I admire it more as a result. Like, how do they come up with this stuff?! Anyway...

At its core, Gyruss is a 2D game which exists on a 2D plane with some 2.5D rendering. Before you roll your eyes, what I mean is if  you removed the depth scaling, sprite rotations, and circular motion, at its core Gyruss is simply a vertical shooter n screens high (where n is the sum of all stages, boss fights, and warp scenes per planet), in which the player moves left or right along the bottom of the screen while enemies fly in from all directions. Thus, as for conceptually planning a Gyruss clone, one can do it all in a normal 2D environment and then tweak variables and apply a transformation matrix for the end result.

Conceptualizing Gyruss as a 2D shooter, an instance's position along any concentric circle is thus its x-coordinate and its depth is its y-coordinate. An instance's screen coordinates can be thought of as post-process rendering, since the y-coordinate on the screen does not affect the y-coordinate used to determine depth. Think of it as rolling up the contents of a vertical shooter into a cylinder and then looking down the inside of that cylinder. That's Gyruss in a nutshell.


I bring up this planar quality because conceptualizing Gyruss in planar terms should make scripting enemy flight patterns a little easier. Rather than trying to visualize and code enemy movement in a strictly 3D sense as it would seem I am implying later in this post, it would be far simpler to plan the flight paths exactly as one would for a standard vertical shooter, albeit bearing in mind what effects visually the transformation into cylindrical 2.5D would have on the path.

The rest of this post is technical ramblings I may revise as I dive deeper into the NES port.

The player's position on the screen is dictated by a path containing 256 points, starting with 0 on the extreme right and positions listed clockwise. In actuality, it's just 64 points of data, roughly equivalent to sin(circle_position)*96. 

.db screen_positions
00 02 04 07 09 0B 0D 10 12 15 17 19 1B 1E 20 22
24 26 28 2B 2D 2F 31 33 35 37 39 3A 3C 3E 40 42
43 45 46 48 49 4B 4C 4E 4F 50 52 53 54 55 56 57
58 59 5A 5A 5B 5C 5C 5D 5D 5E 5E 5E 5F 5F 5F 5F

var n = circle_position;
var swap_axes = 0;
if circle_position > 63 {
    n -= 64;
    if circle_position > 128 {
        n -= 64
        if circle_position > 192
            n -= 64;
    }
    else swap_axes = 1;
}
else swap_axes = 1;
X = -(n - 63) & 255;
Y = -(X - 64) & 255;
if swap_axes {
    n = X;
    X = Y;
    Y = n;
}
Y = screen_positions[Y];
X = screen_positions[X];
if circle_position > 128
    Y = -Y;
if circle_position + 64 & 128
    X = -X;
y = screen_positions[Y] + #78;
x = screen_positions[X] + #7C;

While the player is in motion (denoted by byte $65), every 4th step on the step counter increases a sprite_counter on the player. The sprite to be used is determined by circle_position+8>>4, with the animation frame determined by sprite_counter.

Even though the player's ship does not technically have Z-depth, it is #F0. Z-depth is handled from top-down, so the higher the Z-depth, the closer the object is to the screen. At Z-depth #1C bullets are destroyed, and at Z-depth less than #18 enemies are destroyed. This effectively means enemies are also destroyed at Z-depth #100. Bullets are spawned at Z-depth #E4. If we assume a bullet has negligible dimensions, given the values enemies use when comparing proximity to bullets, we can ascertain the player ship has a 6x4 bounding box, while most enemies have a 5x4 bounding box. (If you want to be silly, you can extrapolate from transformation arrays that the player ship effectively has a 12x8 hit box.)

Motion along the z-axis is not a linear transformation, it's polynomial. In fact, you can visualize enemy and bullet motion by looking at an animation of a black hole.


If you pick any intersection along the outer edge and follow it through the animation, you are essentially following the path of an enemy in Gyruss. You can think of Z-depth as the concentric circles. Just like in this animation, the Z-depth transformation increases the distance between y-coordinates the farther from the vortex the enemy gets. Gyruss does mess things up by simplifying sprites, which are handled linearly -- enemies and bullets have 4 sprites determined by z>>6. I'd post the Z-depth transformation, but it's 128 bytes, which I don't care to beautify. You'll have to make do with a trendline derived by a spreadsheet app:

-1.29 + 0.536x - 1.33E-03x^2 + 1.23E-05x^3


Enemies only need two vectors in order to move across three dimensions -- an orbital translation and a Z-depth translation. Both positions and vectors are 16-bit values (one byte for the fractional part). Collision detection thus is a comparison between orbital positions and Z-depths. As previously stated, different enemies have different collision sizes.

Enemy patterns are all scripted and not fun to read. Typically, paths are adjusted based on a couple timers, the path assigned to the enemy, and some other stuff. Like I said, not fun to read. The path instructions are typically 5 bytes: orbital speed (2 bytes), Z-speed (2 bytes), and vector length (1 byte); e.g., #E000 90FF 08 would set Z-speed to -7/16, and the vector length to 8 [steps]. When the vector length decreases to 0, the next vector in the path is used. 

Example of an actual flight path


Once an enemy moves into the center position, it is assigned to one of four groups. There is only one instance per group, so when an enemy joins an existent group, it is destroyed and the size of the group is increased.


 It is worth mentioning the little sparkles that surround the player ship when spawning or firing a bomb demonstrate an additional bit for the in-motion byte. While the highest bit can essentially be thought of as "active", since no instances without that bit set are typically processed, the next highest bit actually tells the game which motion algorithm to use. The sparkles -- and perhaps rarely some enemies -- do indeed travel along a 2D plane and that particular bit tells the game to use normal 2D motion. Maybe it was for memory management, but this seems like an odd design choice. It's a good thing I noticed it, since I had overlooked that entire section of code previously.

The star field consists of just 18 stars rapidly jumping back and forth (similar to the method used for character swapping in CV3). Unlike enemy movement, star movement is just a simple vector with acceleration. When set to the middle of the star field (either at the start of the level or when the star has moved off-screen), byte $45 ticks down from #1F and the next speed and acceleration for the star is based on that value. This ensures no two stars have the same speed. The flickering which creates the illusion of two stars is caused by drawing the star at #F0-x and #E0-y. The sprite changing is based on a timer, which increases as the star nears the player's ship.

When  you destroy an orbiter and collect the power-up sparkles, enemy bullets get a speed boost, which is reset when the player respawns after dying. This is maxed out in Hard Mode (byte $30). Enemies have a shared shot delay, which only counts down when the player is near the same orbital position as any enemy. Thus, if you want to avoid getting shot at, stay away from all enemies -- which means you can't kill them. The more ships that are allowed to congregate in the middle, the harder it will be to limit enemy fire.


Further Updates (11/5/20):

There were obviously multiple programmers, which for the most part coded very similarly within reason. It's still obvious when reaching contributions from different programmers. The boss in the image at the top of this post has very long AI, possibly more than any other enemy in the game, and that's just for the first half of its fight (which, by the way, is very similar to the battle against Legion/Granfaloon in Symphony Of The Night). Some of the variables are also used peculiarly. Still, the code is much easier to read due to the uniformity of the coders than, say, CV3's boss codes.

One feature of Gyruss code in general is spawning enemies and bullets. The game has a database of all the sprites to be used by each type of enemy. However, almost every time an enemy spawns a bullet or some other hazard, the coder felt the need to manually define the sprite as well. In other words, the code might load #44 and save it to the sprite_index, which is already #44 based on the spawning code, meaning I just tracked a value through 80 bytes of code, just for it to amount to repetitious code. 


Technical Update for Cheaters (11/15/20):

Gyruss on the NES has two variables to handle each stage and it is necessary to manipulate them both in tandem. You can "safely" manipulate these two variables on the map screen (anywhere else might crash the game).

Byte $3D is the current stage, starting from 0 up to $36. This determines which enemy patterns will be used and the number of warps remaining, calculated by $3D&3^3. Any value higher than $36 will inevitably crash the game, although a couple invalid values funnily enough yield playable glitches.

Byte $46 is the current planet, starting from 0 (Neptune) up to 9 (Sun). This determines which planet will be named at the start of each stage, which planet will be depicted after a boss fight, which sprites will be loaded into the PPU, and which boss will be fought.

Additionally, byte $3F tells the game if it's on a bonus stage. Setting this to #01 when the number of warps remaining is greater than 0 will cause every stage to be a bonus stage until the player finally completes an actual bonus stage. This also hides the remaining warps, which is why normally when you reach stage #03 it says 'Challenge Stage' rather than '0 warps remaining to Pluto'. When glitching this byte, powerup orbiters and certain enemies will cause multiple powerups to spawn in clusters of 5 or 6, which can cause significant slowdown. Furthermore, bonus stages played without this variable set will allow enemy ships to crash into the player instead of harmlessly passing through as normal.

No comments:

Post a Comment

©TheouAegis Productions™. Powered by Blogger.