⬆️ ⬇️

Creating a platformer for a virtual console TIC-80



8 Bit Panda, a game for the fictional console TIC-80.



This is a post about how I wrote an 8-bit panda, a simple classic-style platformer for the fictional TIC-80 console .



Play in the finished game here .

')

If you are a fan of retro games and you like programming, then there is a chance that you are already familiar with the latest trend: fictional consoles. If not, then you should look at their most famous representatives: PICO-8 and TIC-80 .



I chose TIC-80 because it is free and actively developed, has a wider screen ratio (240x136) than PICO-8 and can export to many platforms, including HTML, Android and binary files for PC.



In this article I will tell you how I wrote a simple 8 bit Panda platformer for the TIC-80.



The main character



First I needed a player character. I didn’t think much about it: the design process basically consisted in the question: “why not a panda?”, The answer to which was: “of course, why not?” So I started drawing my first sprite in the TIC sprites editor. 80:



image




Let me enjoy the impressive lack of artistic skills, but it’s worth considering that there are only 2,256 combinations of 16-color 8x8 sprites. Only some of them will be pandas. If you do not come to the conclusion that this is the worst combination, then I will feel flattered.



Taking it as a basis, I drew a few more sprites representing the main character in other poses: walking, jumping, attacking, etc.



image




Now, when all the readers who were expecting to find lessons on drawing pandas here, turned away from me, let's turn to the code.



If you find the source useful, you can study my source code . But this is not necessary, because I will try to tell as much of the context as possible on each topic. Of course, we will not study all the source code, I’ll just tell you about interesting points that we encountered while developing.



Creating levels



TIC-80 has a built-in map editor that you can (and should) use to create levels. It is quite simple to use it: this is a common matrix of tiles, each of which can be any of the 256 sprites at the bottom of the sprites table (the upper part, with indices from 256 to 511, can be drawn during the execution of the game, but they cannot be on the map, because it takes 9 bits to display).



Sprite vs. Tile: in TIC-80, “sprite” means one of 512 predefined images in an 8x8 pixel cartridge. Map tiles are simply sprites (each card tile can be one of 256 sprites in the bottom half of the sprite table). Therefore, we will say “sprite” when it comes to a graphic element, and “tile” when we keep in mind the cell of the map, even though technically the cell contains a sprite. To summarize: all this does not matter, tiles and sprites are one and the same.



Using the map editor, I first created a fairly simple “level”:



image




First, it is worth noting that there are two main types of tiles:





Later we will get acquainted with tile-entities, but for now let's not talk about them. In the code you need to somehow tell whether the tile is solid or not. I chose a simple approach and decided to limit myself to the sprite index (80): if the sprite index is <80, then the tile is solid. If it is ≥ 80, then the tile is used for decoration. Therefore, in the table of sprites, I just drew all the solid tiles up to index 80, and all the decorative ones after 80:



image




But wait, the water is not solid! What does she do with hard sprites? I did not tell you about overrides: there is a list of overriding the hardness of sprites, which can replace the default hardness. He tells us that, for example, the water tile is actually not solid, even though it is in the table of sprites in the part with solid tiles. But it is not decorative, because it affects the game.



Player status



If I learned something in my career as a programmer, it’s because global variables are bad, but you can use them if you come up with some interesting name, for example, Singleton. Therefore, I have identified several "singletons" that define the state of the game. I use this term quite arbitrarily, because it is not OOP, and in fact they are rather a high-level struct, rather than real singltons.



Well, okay, no matter how they are called. Let's start with Plr , which sets the player’s state at a specific point in time:



Plr={ lives=3, x=0,y=0, grounded=false, ... } 


It has many other fields, but the most important thing here is to notice that this object keeps the player’s entire state at the current level: where the player is at the level of whether he jumps, stands on solid ground, floats, returns to the surface, dies, flying on an airplane (yes, this is one of those pandas that fly on planes), points, active bonuses, etc.



There is also a game state, separate from the player state. For example,



 Game={ m=M.BOOT, --   lvlNo=0, -- ,     ... } 


It stores such values ​​as the current mode (we will tell about it in more detail), the current level, as well as the data for the whole game, calculated in the process of execution.



It is useful to separate the state of the game and the state of the player , because then it’s enough to start / restart the levels: you just need to reset and delete the player’s state, without touching the state of the game.



Rendering level and player



Rendering the level and player on the TIC-80 is incredibly easy. The only thing you need to do is call map () to draw (part of) the map and spr () to draw sprites in any place you want. Since I was drawing my level from the top left corner of the map, I can just draw it like this:



 COLS=30 ROWS=17 function Render() map(0,0,COLS,ROWS) end 


Then I add a player:



 PLAYER_SPRITE=257 spr(PLAYER_SPRITE, Plr.x, Plr.y) 


And we get the following:



image




And, of course, the panda stays in the corner, doing nothing. So far this is not very similar to the game, but we have not finished yet.



Of course, everything becomes more complicated when we want to implement a side-scroller, in which the camera accompanies the player as it moves forward. I did the following: I created a Game.scr variable that determines how much the screen is scrolled to the right. Then when I draw the screen, I move the map to the left by this number of pixels, and when I draw everything, I always subtract Game.scr to draw in the right place on the screen, like this:



 spr(S.PLR.STAND, Plr.x - Game.scr, Plr.y) 


In addition, for efficiency, I determine which part of the level is visible from any point and draw only this map rectangle on the screen, and not its entirety. Ugly details can be found in the RendMap () function.



Now we need to write logic that moves the panda in response to the player’s actions.



Move the panda



I never thought that I would write an article with such a subtitle, but life is full of surprises. Panda is our main character, and in the game of platformer everything moves and jumps, so you can reasonably say that the “panda movement” is the core of the game.



The “movement” part is pretty simple: we just change Plr.x and Plr.y , after which the panda appears elsewhere. Therefore, the most simple motion implementation can be written like this:



 if btn(2) then Plr.x = Plr.x - 1 elseif btn(3) then Plr.x = Plr.x + 1 end 


Remember that in TIC-80 btn (2) is the left key, and btn (3) is the right key. But this way we can only move horizontally and not be able to collide with the walls. We need something more complex, taking into account gravity and obstacles.



 function UpdatePlr() if not IsOnGround() then --  Plr.y = Plr.y + 1 end if btn(2) then Plr.x = Plr.x - 1 elseif btn(3) then Plr.x = Plr.x + 1 end end 


If we implemented IsOnGround () correctly, it will be a big improvement: the player will be able to move left and right, and fall when not on solid ground. So, we can already walk and fall from the cliffs. Amazing!



But at the same time, obstacles are not taken into account: what happens if we enter (horizontally) a solid tile standing in the way? We should not be able to enter there. That is, in general, we have such a scheme - in motion there are two stages:



  1. We decide where the hero wants to go (taking into account such external factors as gravity).
  2. We decide whether to allow the hero to move there (due to obstacles).


The concept of “desire to go” has a broad definition and sets intentional and unintentional displacement: standing on solid ground, the hero “wants” to move down (due to gravity), but cannot because he will face the ground when moving down.



Therefore, it makes sense for us to write a function that encodes all the logic “can the hero move to a given position x, y”. But we will also need it when realizing the enemies, because we will also have to ask “can this enemy move to the x, y position?”. That is, to summarize, it would be best to write a function that receives at the input x, y and an arbitrary collision rectangle (so we can correctly convey the correct x, y and the collision rectangle to the essence of the hero or the enemy):



 C=8 --    ( TIC-80  8) -- ,       -- cr={x,y,w,h}     x,y function CanMove(x,y,cr) local x1 = x + cr.x local y1 = y + cr.y local x2 = x1 + cr.w -1 local y2 = y1 + cr.h - 1 --   ,    local startC = x1 // C local endC = x2 // C local startR = y1 // C local endR = y2 // C for c = startC, endC do for r = startR, endR do if IsTileSolid(mget(c, r)) then return false end end end end 


The logic is quite simple: we just find the boundaries of the rectangle, iteratively go through all the tiles that the rectangle touches, and check if there are any among them solid ( IsTileSolid () just performs our check "≥ 80", plus overrides). If we do not find on the path of a solid tile, then we return true , meaning "well, you can move here . " If we find such a tile, then we return false , meaning “no, we can't move here” . Two situations are illustrated below.



image




Let's write another life-simplifying function that tries to move a certain offset. if possible:



 PLAYER_CR = {x=2,y=2,w=5,h=5} function TryMoveBy(dx,dy) if CanMoveEx(Plr.x + dx, Plr.y + dy, PLAYER_CR) then Plr.x = Plr.x + dx Plr.y = Plr.y + dy return true end return false end 


Now our implementation of the transfer function becomes much cleaner: first we decide where we want to go, then we check whether it is possible. If possible, we move there.



 function UpdatePlr() --  " "  "    " Plr.grounded = not CanMove(Plr.x, Ply.y + 1) if not Plr.grounded then --    ,  . Plr.y = Plr.y + 1 end local dx = btn(2) and -1 or (btn(3) and 1 or 0) local dy = 0 --     TryMoveBy(dx,dy) end 


Well, now we have a movement in view of the obstacles. Later, when we add hard entities (mobile platforms and others), we will have to complicate the function a little to check for collisions with entities, but the principle will remain the same.



Panda animations



If we always use one sprite (number 257), the game will seem boring, because the panda will always be in the same standing pose. Therefore, we need to panda walked / jumped / attacked, etc. We want the sprite to change based on the player’s state. To simplify reference to the numbers of sprites, we will declare constants:



 -- S -     S={ -- S.PLR -      PLR={ STAND=257, WALK1=258, WALK2=259, JUMP=273, SWING=276, SWING_C=260, HIT=277, HIT_C=278, DIE=274, SWIM1=267, SWIM2=268, } } 


They correspond to several panda sprites in the sprite table:



image




So, in our rendering function, we decide which sprite we will use. This is the RendPlr () function, which contains the following:



 local spid if Plr.grounded then if btn(2) or btn(3) then spid = S.PLR.WALK1 + time()%2 else spid = S.PLR.STAND end else spid = S.PLR.JUMP end ... spr(spid, Plr.x, Plr.y) 


Which means: if a player is on hard ground and goes, then perform a walk animation, alternately drawing sprites S.PLR.WALK1 and S.PLR.WALK2. If the player is on hard ground and does not walk, use S.PLR.STAND. If not on hard ground (falls or jumps), then use S.PLR.JUMP.



There is also additional logic to determine the side to which the hero is looking, to perform animation sequences for attacks and jumps, and to add sprite overlays when creating bonuses.



Jumps



People expect a strange thing: when we jump in real life, we actually can do little to change the trajectory in the jump, but when playing platformers we want (or rather we demand ) that the character can arbitrarily change the trajectory of the jump in the air. Therefore, like the characters of many other games, our panda will have the ability to move freely in the air, contrary to physics.



In fact, this greatly simplifies the implementation of jumps. A jump is, in essence, a sequence of changes in the hero's Y coordinate. The X coordinate is freely changed with the arrow keys, as if the player is on the ground.



We will present the jump as an iteration of the "jump sequence":



 JUMP_DY={-3,-3,-3,-3,-2,-2,-2,-2,1,1,0,0,0,0,0} 


When a player jumps, his position on Y changes in each frame by the value specified in the sequence. The variable that tracks our current place in the jump sequence is called Pl.jmp .



The logic of the start / end of the jump will be something like this:





The resulting trajectory of the jump will not be far like a perfect parabola, but it is quite suitable for our purposes. Fall after a jump is a straight line, because on the way down we do not apply acceleration. I tried to add it, but it looked weird, so I decided to do without acceleration. In addition, the rate of decline of 1 pixel / cycle gives us the opportunity to use rather tricky tricks when recognizing collisions.



image


Jump trajectory.



Entities



Tiles are good, but they are static. The only thing you can enjoy - jumping through the fixed blocks of the earth. To revive our platformer, we need enemies, bonuses, etc. All of these “moving or interactive objects” are called entities.



For starters, I drew some frightening enemies. Mostly they are terrified by the quality of drawing, and not by the fact that they are terrible:



image




I just added them to the sprites table and created animations for everyone. I also installed a new split point: sprites with indices ≥ 128 are entities , not static tiles. So I can just add enemies to the level using the map editor, and I will know that they are enemies thanks to their sprite index:



image




Similarly, many other objects are entities: chests, destructible blocks, temporary platforms, elevators, portals, etc.



When loading a level, I check every tile on the map. If it is ≥ 128, I delete the tile card and create an entity in this place. An entity ID (EID) defines what it is. What do we use as an EID? Yes, just take the sprite number again! That is, if the enemy of the "green slug" has a sprite 180, then the EID of the green slug will be equal to 180. It's simple.



All enemies are stored in the global structure Ents .



Entity Animations



Entities can be animated. Instead of manually encoding the animation for each type of enemy, I simply created a large table of animations indexed by EID, which determines which sprites to cycle through:



 --     EID. ANIM={ [EID.EN.SLIME]={S.EN.SLIME,295}, [EID.EN.DEMON]={S.EN.DEMON,292}, [EID.EN.BAT]={S.EN.BAT,296}, [EID.FIREBALL]={S.FIREBALL,294}, [EID.FOOD.LEAF]={S.FOOD.LEAF,288,289}, [EID.PFIRE]={S.PFIRE,264}, ... } 


Notice that some of them are character constants (for example, S.EN.DEMON), when they also coincide with the sprite of the entity, and some are hard-coded integers (292), because in the second case it is just a secondary frame of the animation, which is nowhere else to refer.



When rendering, we can simply find the desired animation in this table and render the correct sprite for each entity.



Meta Tags: map annotations



Sometimes we need to add annotations to the maps used during the execution of the game. For example, if there is a treasure chest, then we need to somehow identify what is inside and how many of these objects. In these cases, we use map annotation markers, these are special tiles with numbers 0–12 that are never displayed (they are removed from the map at runtime):



image




When the level loader sees the chest, he looks at the tile above the chest to find out its contents, and searches for a special numerical marker indicating the quantity. objects to create. Therefore, when a player hits the chest, all items are created:



image




Meta tags can also determine, for example, where elevators should begin and end movement, where the starting position of the level is, phase information for temporary platforms and so on.



They are also used for level compression. We will talk about this below.



Levels



The game has 17 levels. Where are they stored? Well, if you look at the memory cards, you will see the following picture:



image




TIC-80 has 64 “pages” of maps, each of which is a single “screen” of content (30x17 tiles). Pages numbered from 0 to 63.



In our scheme, we reserved the top 8 for use during the execution of the game. There we will store the level after unpacking (more on this later). Then each level is a sequence of 2 or 3 pages in the memory card. We can also create pages for the study screen, victory screen, world map, and start screen. Here is the annotated version of the map:



image




If you play a game, you may notice that the levels are actually much longer than they could fit on 2 or 3 screens. But in the memory of cartridge cards, they are much smaller. What is going on here?



You guessed it (and I gave a hint): the levels are compressed! In the memory of the cards, each column stores in the top line a meta tag , indicating how many times the column repeats . In other words, we implemented a simple form of RLE compression:



image




Therefore, when this page is unpacked, it actually defines a much longer part of the level (almost twice as much, and on some levels even more).



But for what we use the top 8 pages of cards at runtime: when we are ready to play a level, we unpack it for gameplay on pages 0–7. Here is the logic used to run the level:



  1. We read the packed level from the right place in the memory of the cards.
  2. Unpack it into the top 8 pages in accordance with the number of repetitions in each column of the packed level.
  3. We are looking for entities (sprites ≥ 128) and create their instances.
  4. We look for the initial position of the player (metamarker "A") and put the player there.
  5. We start to play.


Entity behaviors



What makes the enemy behave in a certain way? Take for example the red demon from the game. Where does his craving for fireball come to the player? Why don't they just become friends?



image




Each entity has its own behavior. Demons throw fireballs and jump. Green slugs roam back and forth. Blue monsters periodically jump. Icicles fall when the player is fast enough. Destructible blocks are destroyed. Lifts are rising. Chests remain chests until the player hits them, after which they open and create their contents.



A simple way to create all of this logic will be the following:



 if enemy.eid == EID.DEMON then ThrowFireballAtPlayer() elseif enemy.eid == EID_SLIME then --  -  elseif enemy.eid == EID_ELEVATOR then --  -  -- ...     ... 


At first, everything seems simple. But then the work becomes difficult and monotonous. Take for example the movement: when the enemy is moving, we need to perform a bunch of checks - to make sure that the enemy can pass into a given position, check whether he will fall, etc. But some enemies do not fall, they fly (for example, bats). And some swim (fish). Some enemies want to look at the player, others do not. Some enemies want to chase the player. Some simply go about their business, not knowing where the player is. What about the shells? Fireballs, plasma balls, snowballs. Some are affected by gravity, some are not. Some encounter hard objects, others do not. What happens when each of the shells hits the player? What happens when a player hits them? So many variables and combinations!



Writing if / else blocks for each of these cases turns into a burden after some time. Therefore, instead, we will create a behavior system that is a fairly common and useful pattern in game development. First, we will determine all the possible behaviors that the entity may have, and the necessary parameters. For example, it may:





Having defined all possible behaviors, we simply assign them with the necessary parameters to the right entities, in which we call EBT (Entity-Behavior Table, entity behavior table). Here is an example entry for a red demon:



 --    EBT={ ... [EID.EN.DEMON]={ -- : beh={BE.JUMP,BE.FALL,BE.SHOOT, BE.HURT,BE.FACEPLR,BE.VULN}, --  : data={hp=1,moveDen=5,clr=7, aim=AIM.HORIZ, shootEid=EID.FIREBALL, shootSpr=S.EN.DEMON_THROW, lootp=60, loot={EID.FOOD.C,EID.FOOD.D}}, }, ... } 


It reports that the demons have the following behaviors: jump, fall, shooting, damage, turn to the player, vulnerability. In addition, the parameters indicate that the entity has one point of damage, moves every 5 cycles, has a base color of 7 (red), shoots fireballs (EID.FIREBALL), targets the player in the horizontal plane (AIM.HORIZ), has a chance of falling out rewards of 60%, can throw away food C and D (sushi). See, now we can define the whole behavior of this enemy in just a few lines with a combination of different behaviors!



What about these mountains in the background?



Oh, you noticed the mountains in the background! If you look at the memory cards, then there you will not find them. : , , , .



? . ( ) , , ( 300 ).



, ( ), , . , , ( , Game.scr ).





TIC-80 , , . , « », :



 function SetPal(overrides) for c=0,15 do local clr=PAL[c] if overrides and overrides[c] then clr=overrides[c] end poke(0x3fc0+c*3+0,(clr>>16)&255) poke(0x3fc0+c*3+1,(clr>>8)&255) poke(0x3fc0+c*3+2,clr&255) end end 


0x3fc0 , TIC-80 , .





. , . 17 , 6 :



image




Z. : .



( 62 ) :



image




( 61):



image




, . «1», «2» «3» . , , (1–6) . , , «2» 3 ( ), , «3», , 3–2.



«A» , «B» — .





TIC-80. , , , . , -.



image




, , . , , ( , ).



image




8 ( TIC-80):



  1. A , 1–5.
  2. B , 1–5.
  3. ().
  4. C , 1–5.
  5. .
  6. .
  7. 6 ( 2 )
  8. ( «The End»)


Conclusion



, TIC-80 ( ) . , ( , !), !



TIC-80 .

Source: https://habr.com/ru/post/340502/



All Articles