💾 Archived View for gemini.ctrl-c.club › ~de_alchmst › b-logs › forth › 010:tetris-clone.gmi captured on 2024-08-31 at 15:20:49. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2024-08-18)
-=-=-=-=-=-=-
Forth is a unique little family of programming langueges. Similar to scheme, there are a lot of implementations that add their own extra features to the minimal ANSI Forth standard. It's a stack machine, meaning that instead of having everything in variables, you kinda just leave data on the stack for functions (or words, as they're called) to operate over.
Forth has interactive REPL and most implementations can't produce stand-alone executables. This might sound like Forth is a interpreted languege... well, REPL is interpreted, but words are compiled into fast and efficent machine code. Forth is, infact, very low-level languege with no-datatypes (at least in the traditional) and no handholding. Once you get over the culture shock of stack-based syntax, it feels a lot like working in C, but without all the unnecessary abstractions.
As a fan of simple, low-level imperative languages, Forth has cought my interest. I have touched Gforth before, but then I didn't really followed up on it and got lost in other projects. Now hoverer, I decided to take another shot at it.
I wanted to stsrt some smaller, non-serious project, just to get some feeling of the languege. Rosetta code like tasks might be fun exericses, but they don't give you any insight in how languege operates once you need to do a bunch of more complex tasks in a single project.
When I pick up a languege like this, I like to make some simple game. Since I haven't made any tetris clone yet, it seemed like a reasonable choice.
I decided to take inspiration from
as it is my prefered tetris implementation.
I decided to use
because it was quite some time since I made a GUI program (web stuff doesn't count) and I wanted to try using C interop for once.
Forth can use variety of file extansions: .f, .fs, .fth, .4th, .forth
Gforth generally uses .fs, but I don't really like it and it already seems to be taken by F#, so I chose to use .4th, purely for aesthetic reasons.
Gforth is case insensitive. Manual seems to write everything in lover-case, but I like to use different case for different stuff:
I also named all raylib functions like so: rl:function-name, to make it obvious that they are external. I append '?' to words that check for some boolean and '!' to words that write to some variable.
Order of files can be seen in main.4th. I separated files for game logic and for drawing. all of these parts have one general word used to do most of needed stuff. In main, I just check for current game state call the needed words. This has led to some words being in weird places, because it needed access to some other words.
Game field is 10 blocks wide and 20 blocks tall. In memory, it's just a buffer of 200 bytes, each one block. 0 represents empty space, 1..7 different color blocks.
Falling pice is represented in a seperate 4x4 buffer. Alongside this buffer, there is also its Rotation, X-pos, Y-pos, Width, Height and Offset, from the left of the buffer. Next pice is always known. Upon pice landing, current buffer is copied on game field and positions reset.
For each pice type, I have word that sets the active pice to state of given rotation. When setting new pice, I set address of this respective word to variable, and call it from here when needed. Pice is set to the buffer by 4 positions of filled blocks and one number specifying it's color.
: set-pice-I ( n -- ) \ rot dup UP-ROT = swap DOWN-ROT = or if \ 0 0 0 0 \ 1 1 1 1 \ 0 0 0 0 \ 0 0 0 0 0 4 2 pice-size! 4 5 6 7 1 pice-buffer! else \ 0 1 0 0 \ 0 1 0 0 \ 0 1 0 0 \ 0 1 0 0 1 2 4 pice-size! 1 5 9 13 1 pice-buffer! then ;
I have timer that is set to 1/Level seconds, after which I move pice down. Moving sideways, dropping and pausing is always possible.
If there is collision right after block switch, Game-mode is switched to GAME-OVER.
Rows are broken nicely one by one with simple animation. I have buffer of 4 cells to store pointers to the rows that need removing. This buffer is operated by three words:
Lastly there is downshift-field, which takes the pointer and actually removes the line, shifting others down. When pice lands, field is scaned for any full rows. If the buffer is not empty, draw-game activates animation and at its end calls downshift-field. While in animation, timer is stopped.
Due to implementation reasons, front of the buffer is actually it's back. Sure, it's not the resizable list type other languages give you and you have to make it yourself, but it's small and simple and I like that.
Well, drawing is pretty simple. First I do a lot of calculations about positions and text. I also make rectangle for field border used by the rl:draw-rectangle-lines-ex word. It's definition looks like this:
c-function rl:draw-rectangle-lines-ex DrawRectangleLinesEx a{*(Rectangle*)} r a{*(Color*)} -- void ( rect thickness color -- )
The stuff in parenthesis at the end is just a comment. As far as structs go, I just define forth struct with the same memory layout as raylib uses. In this case:
begin-structure rectangle% lfield: rectangle-x lfield: rectangle-y lfield: rectangle-width lfield: rectangle-height end-structure
The 'a' means I will give it a pointer. C however does not want void*, but Rectangle struct given by value. The stuff in curly braces can be thought of as C code written before the value inside of a function call like this:
DrawRectangleLinesEx(*(Rectangle*)ptr1, flt, *(Color*)ptr2);
This will cast the pointer to Rectangle pointer and dereference it. Also 'r' means real, meaning take value from the float stack.
Back to drawing...
First I draw the border, then I go through each pice and give it to one of my favorite parts of this codebase:
: draw-block-offset ( n x y offX offY -- ) \ positions rot TILE-SIZE * + rot TILE-SIZE * rot + \ n Y X swap rot TILE-SIZE TILE-SIZE rot \ X Y W H n \ color case 0 of Fg-color rl:draw-rectangle-lines exit endof 1 of I-color endof 2 of J-color endof 3 of L-color endof 4 of T-color endof 5 of O-color endof 6 of S-color endof 7 of Z-color endof endcase rl:draw-rectangle ; : draw-block-field ( n x y -- ) FIELD-OFFSET-X FIELD-OFFSET-Y draw-block-offset ;
First, note how I wrap drawing from actual screen position into drawing from position relative to the field. I could have put this transformation into draw-field, but in forth, you supposedly do things this way...
Second, I start by doing some complicated stack operations to prepare values for rl:draw-rectangle. (you know it's complicated, since I needed to make comments explaining current stack state) Then I throw case into it, to put coresponding color on the stack and end it by calling rl:draw-rectangle. If the color is 0 (empty space) however, I instead call rl:draw-rectangle-lines to draw grid and exit before getting to the rl:draw-rectangle, while still using the same prepared values on the stack.
In, for example C, you could set the values into variables, then call switch and either store color into another variable and call DrawRectangle, or call DrawRectangleLines with the same variables instead and return, but it, at least to me, feels more natural to do this way in forth, as I kinda just wrote this code without thinking about it, while in C, I feel like it would require me to think about it for a bit.
Sure, it's very minor difference in the end, but I think it works as a example of some advantages of stack-based argument passing.
To draw next pice and pice counters, I have prepared extra buffers in pice-preview.4th with coresponding pices.
Then I just draw it with:
: draw-pice-buf-pos { a x y -- } 16 0 ?do a i + c@ dup if i 4 mod i 4 / x y draw-block-offset else drop then loop ;
Here you can also see how you can transform stack values into local variables. You usually just stick with the stack, but sometimes you might feel like doing it this way.
As far as strings go, while you can use forth strings (pointer and length), I mostly use C strings (pointer to null-terminated array) instead, as I don't want to store lenghts seperately.
Now when i think about it, there might be better ways of going about it, but hey, it's my first bigger forth project and it works, so I proclaim it as acceptable.
: count-display ( n x y -- ) count-text-reset rot s>d <# #s #> \ convert number to string Count-text 7 + swap move \ copy to buffer Count-text -rot SECONDARY-TEXT-SIZE Fg-color rl:draw-text ;
Here you can see some code to draw the score counter. The s>d followed by magic is some sort of transforming number to string. I don't really understand it, but it seems to always use the same memory, so no need for deallocation. Then I use the move word, which is used to copy memory. It takes source address, exit address and length in this order, thus the swap.
Menu is also simple. You have array of pointers to strings and move index of selected one. When Return/Space or H/L/Left/Right are hit, I check current item and I might even do something about it. LEVEL and SHOW NEXT are changed by rewriting the string. With themes I just have another array of pointers and just replace it with current one.
Again, nothing special to see here. I just have variables with pointers (because in forth, every variable is just a void*) to colors and I change them to point to colors of the theme I want. All colors are themable, but with beam color, I instead copy the values, as that one changes it's alpha value.
To add your own theme, add it's string to THEME-TEXTS, increase THEME-LENGTH and add your theme into case in apply-theme. Unless you change all colors from the default light theme, also start by calling default-colors.
I have a save file to save chosen preferences and player scores.
FILE LAYOUT 1 byte : level 1 byte : theme 1 byte : show-next 5 bytes : reserved for futre use ( 10 times: ) 8 bytes : score 24 bytes : player name (null terminated => 23 usable) players are sorted from highest score to the lovest
First, you need to locate your script's directory. You can do so with:
sourcefilename dirname
Note that sourcefilename does not change when calling require. Then you want to get the full save file address. I have done so with this code:
\ get dir sourcefilename dirname \ allocate spcae create SAVE-FILE-NAME dup 8 + allot dup 8 + constant SAVE-FILE-LENGTH \ add last part dup SAVE-FILE-NAME + s" save.dat" rot swap move \ add dir part SAVE-FILE-NAME swap move : save.dat ( -- a u) SAVE-FILE-NAME SAVE-FILE-LENGTH ;
Then upon start, you either load the file, or cteate a new one, if not found. In writing to file, you can use emit-file for single char or write-file for copying a buffer. You also need to write 'throw', to check for errors. (or drop, but that is not recommended)
For reading, you use slurp-file to allocate heap memory with copy of the file. You can deallocate here, but in this case, it wouldn't matter that much if I forgot. I only write to file right before exiting, so try not to crash it...
Well, this is just some displaying of text and numbers with the same shifting used by field on removing lines. Nothing to see here. For writing your name, I use rl:get-char-pressed. Due to implementation reasons, you can only use ASCII symbols and max name length is 23 chars.
Some problems I came across:
Sometimes overlooked something and I started to accumulate bunch of data on the stack. Not hard to find and fix tho.
I forgot to mark c-function as returning a value, so it didn't.
While good forth code comments itself, you still should comment occasionally.
When you get invalid address error, you are probably either giving your arguments in wrong order, of you forgot to perform addition on offset and pointer.
-------------------------------------------------
also yes, I have somehow managed to not change git settings and pushed all the commits from my school account without noticing; mind it not.
----------------------------