├── .gitattributes ├── .gitignore ├── Makefile ├── README.md ├── info.json ├── tetrix.nelua └── tetrix.png /.gitattributes: -------------------------------------------------------------------------------- 1 | *.nelua text eol=lf linguist-language=lua 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sqfs 2 | *.elf 3 | *.c 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME=tetrix 2 | DATA_FILES=info.json 3 | COMP=xz 4 | RIVEMU=rivemu 5 | RIVEMU_RUN=$(RIVEMU) 6 | RIVEMU_EXEC=$(RIVEMU) -quiet -no-window -sdk -workspace -exec 7 | ifneq (,$(wildcard /usr/sbin/riv-run)) 8 | RIVEMU_RUN=riv-run 9 | RIVEMU_EXEC= 10 | endif 11 | CFLAGS=$(shell $(RIVEMU_EXEC) riv-opt-flags -Ospeed) 12 | 13 | build: $(NAME).sqfs 14 | 15 | run: $(NAME).sqfs 16 | $(RIVEMU_RUN) $< 17 | 18 | screenshot: $(NAME).png 19 | 20 | clean: 21 | rm -f *.sqfs *.elf *.c 22 | 23 | $(NAME).sqfs: $(NAME).elf $(DATA_FILES) 24 | $(RIVEMU_EXEC) riv-mksqfs $^ $@ -comp $(COMP) 25 | 26 | $(NAME).elf: $(NAME).nelua *.nelua 27 | $(RIVEMU_EXEC) nelua --verbose --release --binary --cache-dir=. --cflags="$(CFLAGS)" --output=$@ $< 28 | $(RIVEMU_EXEC) riv-strip $@ 29 | 30 | $(NAME).png: $(NAME).sqfs 31 | $(RIVEMU) -save-screenshot=$(NAME).png -stop-frame=0 $(NAME).sqfs 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tetrix 2 | 3 | A Tetris like game written in [Nelua](https://nelua.io/) for [RIV](https://docs.rives.io) fantasy console. 4 | 5 | You can play it in your browser 6 | [here](https://emulator.rives.io/#cartridge=https://raw.githubusercontent.com/edubart/cartridges/main/tetrix.sqfs). 7 | 8 | ![Screenshot](https://raw.githubusercontent.com/edubart/tetrix/master/tetrix.png) 9 | 10 | ## Compiling 11 | 12 | First make sure you have the RIV SDK installed in your environment, then just type `make` to compile. 13 | You can also play it by typing `make run`. 14 | -------------------------------------------------------------------------------- /info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tetrix", 3 | "summary": "A puzzle game where players arrange falling geometric shapes to create horizontal lines.", 4 | "description": "Use arrow keys to move the tiles.", 5 | "tags": ["puzzle", "2d"], 6 | "authors": [{ 7 | "name": "Eduardo Bart", 8 | "link": "https://github.com/edubart" 9 | }], 10 | "links": [ 11 | "https://github.com/edubart/tetrix", 12 | "https://discord.com/channels/1207694461476409394/1283878026115027004" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tetrix.nelua: -------------------------------------------------------------------------------- 1 | ## pragma{nogc = true, noerrorloc = true} 2 | 3 | global CELL_SIZE = 12 4 | global VERT_CELLS = 20 5 | global HORZ_CELLS = 10 6 | global GRID_WIDTH = CELL_SIZE*HORZ_CELLS 7 | global GRID_HEIGHT = CELL_SIZE*VERT_CELLS 8 | global SCREEN_WIDTH = 256 9 | global SCREEN_HEIGHT = 256 10 | global GRID_OFFSET_X = (SCREEN_WIDTH - GRID_WIDTH)*3//4 11 | global GRID_OFFSET_Y = (SCREEN_HEIGHT - GRID_HEIGHT)//2 12 | global CELL_MARGIN = 1 13 | global Color: type = @byte 14 | 15 | require 'riv' 16 | require 'math' 17 | 18 | -------------------------------------------------------------------------------- 19 | -- Piece 20 | 21 | local Piece = @record{ 22 | x: integer, 23 | y: integer, 24 | width: integer, 25 | height: integer, 26 | size: integer, 27 | layout: [4][4]byte, 28 | color: uint8 29 | } 30 | 31 | local PIECES: [7]Piece = { 32 | { size=4, layout={ 33 | {0,0,0,0}, 34 | {1,1,1,1}, 35 | {0,0,0,0}, 36 | {0,0,0,0}, 37 | }, 38 | width=4, height=3, 39 | color=RIV_COLOR_LIGHTBLUE 40 | }, 41 | { size=3, layout={ 42 | {0,1,0,0}, 43 | {1,1,1,0}, 44 | {0,0,0,0}, 45 | {0,0,0,0}, 46 | }, 47 | width=3, height=2, 48 | color=RIV_COLOR_PINK 49 | }, 50 | { size=3, layout={ 51 | {0,1,1,0}, 52 | {1,1,0,0}, 53 | {0,0,0,0}, 54 | {0,0,0,0}, 55 | }, 56 | width=3, height=2, 57 | color=RIV_COLOR_GREEN 58 | }, 59 | { size=3, layout={ 60 | {1,1,0,0}, 61 | {0,1,1,0}, 62 | {0,0,0,0}, 63 | {0,0,0,0}, 64 | }, 65 | width=3, height=2, 66 | color=RIV_COLOR_RED 67 | }, 68 | { size=3, layout={ 69 | {1,0,0,0}, 70 | {1,1,1,0}, 71 | {0,0,0,0}, 72 | {0,0,0,0}, 73 | }, 74 | width=3, height=2, 75 | color=RIV_COLOR_LIGHTTEAL 76 | }, 77 | { size=3, layout={ 78 | {0,0,1,0}, 79 | {1,1,1,0}, 80 | {0,0,0,0}, 81 | {0,0,0,0}, 82 | }, 83 | width=3, height=2, 84 | color=RIV_COLOR_ORANGE 85 | }, 86 | { size=2, layout={ 87 | {1,1,0,0}, 88 | {1,1,0,0}, 89 | {0,0,0,0}, 90 | {0,0,0,0}, 91 | }, 92 | width=2, height=2, 93 | color=RIV_COLOR_YELLOW 94 | } 95 | } 96 | 97 | function Piece.random_piece() 98 | local index = riv_rand_uint(#PIECES-1) 99 | local piece = PIECES[index] 100 | piece.x = (HORZ_CELLS - piece.size) // 2 101 | piece.y = 0 102 | return piece 103 | end 104 | 105 | function Piece:rotate_left() 106 | local layout = self.layout 107 | for i=0,self.size-1 do 108 | for j=0,self.size-1 do 109 | self.layout[i][j] = layout[j][self.size-1-i] 110 | end 111 | end 112 | end 113 | 114 | function Piece:rotate_right() 115 | local layout = self.layout 116 | for i=0,self.size-1 do 117 | for j=0,self.size-1 do 118 | self.layout[j][self.size-1-i] = layout[i][j] 119 | end 120 | end 121 | end 122 | 123 | function Piece:translate(x: integer, y: integer) 124 | self.x = self.x + x 125 | self.y = self.y + y 126 | end 127 | 128 | function Piece.draw_cell(x: integer, y: integer, color: Color, shallow: boolean) 129 | if color == 0 then return end 130 | local x = x + CELL_MARGIN 131 | local y = y + CELL_MARGIN 132 | local width = CELL_SIZE - CELL_MARGIN 133 | local height = CELL_SIZE - CELL_MARGIN 134 | if not shallow then 135 | riv_draw_rect_fill(x, y, width, height, color) 136 | else 137 | riv_draw_rect_fill(x, y, width, height, RIV_COLOR_SLATE) 138 | riv_draw_rect_line(x, y, width, height, color) 139 | end 140 | end 141 | 142 | function Piece:draw(x: integer, y: integer, shallow: boolean) 143 | for iy=0,self.size-1 do 144 | for ix=0,self.size-1 do 145 | if self.layout[iy][ix] ~= 0 then 146 | local x = x + ix*CELL_SIZE 147 | local y = y + iy*CELL_SIZE 148 | Piece.draw_cell(x, y, self.color, shallow) 149 | end 150 | end 151 | end 152 | end 153 | 154 | -------------------------------------------------------------------------------- 155 | -- Board 156 | 157 | local Board = @record{ 158 | cells: [VERT_CELLS][HORZ_CELLS]Color 159 | } 160 | 161 | function Board:piece_collides(piece: *Piece) 162 | for iy=0,piece.size-1 do 163 | for ix=0,piece.size-1 do 164 | local tx, ty = piece.x+ix, piece.y+iy 165 | if piece.layout[iy][ix] ~= 0 then 166 | if tx < 0 or tx >= HORZ_CELLS or ty < 0 or ty >= VERT_CELLS then 167 | return true 168 | end 169 | if self.cells[ty][tx] ~= 0 then 170 | return true 171 | end 172 | end 173 | end 174 | end 175 | return false 176 | end 177 | 178 | function Board:place_piece(piece: *Piece) 179 | for iy=0,piece.size-1 do 180 | for ix=0,piece.size-1 do 181 | local tx, ty = piece.x+ix, piece.y+iy 182 | if piece.layout[iy][ix] ~= 0 then 183 | self.cells[ty][tx] = piece.color 184 | end 185 | end 186 | end 187 | end 188 | 189 | function Board:clear_lines() 190 | local num_clears = 0 191 | for y=0,VERT_CELLS-1 do 192 | -- the if line y is full 193 | local full = true 194 | for x=0,HORZ_CELLS-1 do 195 | if self.cells[y][x] == 0 then 196 | full = false 197 | break 198 | end 199 | end 200 | 201 | -- slide lines down 202 | if full then 203 | num_clears = num_clears + 1 204 | for ny=y,1,-1 do 205 | for x=0,HORZ_CELLS-1 do 206 | self.cells[ny][x] = self.cells[ny-1][x] 207 | end 208 | end 209 | end 210 | end 211 | return num_clears 212 | end 213 | 214 | local function draw_grid() 215 | local GRID_COLOR: Color = RIV_COLOR_DARKSLATE 216 | local GAP_COLOR: Color = RIV_COLOR_SLATE 217 | 218 | riv_draw_rect_fill(GRID_OFFSET_X, GRID_OFFSET_Y, GRID_WIDTH, GRID_HEIGHT, GRID_COLOR) 219 | for iy=0,VERT_CELLS do 220 | local x = GRID_OFFSET_X - 1 221 | local w = GRID_WIDTH + 2 222 | local y = GRID_OFFSET_Y + iy*CELL_SIZE 223 | if iy == VERT_CELLS then y = y - 1 end 224 | riv_draw_rect_fill(x+1, y, w-2, 1, GAP_COLOR) 225 | end 226 | for ix=0,HORZ_CELLS do 227 | local x = GRID_OFFSET_X + ix*CELL_SIZE 228 | local y = GRID_OFFSET_Y - 1 229 | local h = GRID_HEIGHT + 2 230 | if ix == HORZ_CELLS then x = x - 1 end 231 | riv_draw_rect_fill(x, y+1, 1, h-2, GAP_COLOR) 232 | end 233 | -- riv_draw_rect_line(GRID_OFFSET_X-1, GRID_OFFSET_Y-1, GRID_WIDTH+2, GRID_HEIGHT+2, GRID_COLOR) 234 | end 235 | 236 | function Board:draw() 237 | draw_grid() 238 | for iy=0,VERT_CELLS-1 do 239 | for ix=0,HORZ_CELLS-1 do 240 | local x = GRID_OFFSET_X + ix*CELL_SIZE 241 | local y = GRID_OFFSET_Y + iy*CELL_SIZE 242 | Piece.draw_cell(x, y, self.cells[iy][ix], false) 243 | end 244 | end 245 | end 246 | 247 | function Board:draw_piece(piece: *Piece, shallow: boolean) 248 | local x = GRID_OFFSET_X + piece.x*CELL_SIZE 249 | local y = GRID_OFFSET_Y + piece.y*CELL_SIZE 250 | piece:draw(x, y, shallow) 251 | end 252 | 253 | -------------------------------------------------------------------------------- 254 | -- Timer 255 | 256 | local fame_time: float32 257 | local Timer = @record { 258 | start_time: number 259 | } 260 | 261 | function Timer:elapsed(): number 262 | return fame_time - self.start_time 263 | end 264 | 265 | function Timer:restart() 266 | self.start_time = fame_time 267 | end 268 | 269 | function Timer.update_frame() 270 | fame_time = riv.time 271 | end 272 | 273 | -------------------------------------------------------------------------------- 274 | -- Game 275 | 276 | local Game = @record { 277 | board: Board, 278 | cur_piece: Piece, 279 | next_piece: Piece, 280 | preview_piece: Piece, 281 | xmove_timer: Timer, 282 | ymove_timer: Timer, 283 | slide_vert_timer: Timer, 284 | score: integer, 285 | lines: integer, 286 | level: integer, 287 | hit_sound: riv_waveform_desc, 288 | lineclear_sound: riv_waveform_desc, 289 | levelup_sound: riv_waveform_desc 290 | } 291 | 292 | function Game:load_assets() 293 | end 294 | 295 | function Game:destroy_assets() 296 | end 297 | 298 | function Game:slide_piece_waydown(piece: *Piece) 299 | for y=1,VERT_CELLS do 300 | piece.y = piece.y + 1 301 | if self.board:piece_collides(piece) then 302 | break 303 | end 304 | end 305 | piece.y = piece.y - 1 306 | end 307 | 308 | function Game:update_preview_piece() 309 | self.preview_piece = self.cur_piece 310 | self:slide_piece_waydown(self.preview_piece) 311 | end 312 | 313 | function Game:spawn_piece() 314 | local piece = self.next_piece 315 | if self.board:piece_collides(piece) then 316 | return false 317 | end 318 | self.cur_piece = piece 319 | self.next_piece = Piece.random_piece() 320 | self:update_preview_piece() 321 | return true 322 | end 323 | 324 | function Game:new_game() 325 | self.board.cells = {} 326 | self.score = 0 327 | self.lines = 0 328 | self.level = 1 329 | self.slide_vert_timer:restart() 330 | self.xmove_timer:restart() 331 | self.ymove_timer:restart() 332 | self.next_piece = Piece.random_piece() 333 | self.cur_piece = Piece.random_piece() 334 | self:update_preview_piece() 335 | end 336 | 337 | function Game:clear_lines() 338 | local num_lines = self.board:clear_lines() 339 | local points = 0 340 | if num_lines == 4 then 341 | points = 1200 342 | elseif num_lines == 3 then 343 | points = 300 344 | elseif num_lines == 2 then 345 | points = 100 346 | elseif num_lines == 1 then 347 | points = 40 348 | end 349 | if points > 0 then 350 | riv_waveform(riv_waveform_desc{ 351 | type = RIV_WAVEFORM_NOISE, 352 | attack = 0.1, 353 | decay = 0.05, 354 | sustain = 0.1, 355 | release = 0.05, 356 | start_frequency = 220, 357 | end_frequency = 40, 358 | amplitude = 0.25, 359 | sustain_level = 0.8, 360 | }) 361 | self.lines = self.lines + num_lines 362 | local level = 1 + (self.lines // 10) 363 | if self.level ~= level then 364 | riv_waveform(riv_waveform_desc{ 365 | type = RIV_WAVEFORM_PULSE, 366 | attack = 0.015, 367 | decay = 0.05, 368 | sustain = 0.4, 369 | release = 0.8, 370 | start_frequency = 80, 371 | end_frequency = 440, 372 | amplitude = 0.25, 373 | sustain_level = 0.5, 374 | }) 375 | self.level = level 376 | end 377 | self.score = self.score + points 378 | end 379 | end 380 | 381 | function Game:slide_current_piece(xoff: integer, yoff: integer) 382 | local piece = self.cur_piece 383 | piece:translate(xoff, yoff) 384 | if self.board:piece_collides(piece) then 385 | return false 386 | end 387 | self.cur_piece = piece 388 | self:update_preview_piece() 389 | return true 390 | end 391 | 392 | function Game:fit_piece(piece: *Piece): boolean 393 | if not self.board:piece_collides(piece) then 394 | return true 395 | end 396 | local newpiece = $piece 397 | for ix=1,2 do 398 | newpiece.x = piece.x + ix 399 | if not self.board:piece_collides(newpiece) then 400 | piece.x = newpiece.x 401 | return true 402 | end 403 | newpiece.x = piece.x - ix 404 | if not self.board:piece_collides(newpiece) then 405 | piece.x = newpiece.x 406 | return true 407 | end 408 | end 409 | newpiece = $piece 410 | for iy=1,3 do 411 | newpiece.y = piece.y - iy 412 | if not self.board:piece_collides(newpiece) then 413 | piece.y = newpiece.y 414 | return true 415 | end 416 | end 417 | return false 418 | end 419 | 420 | function Game:rotate_current_piece(left: boolean): boolean 421 | local piece = self.cur_piece 422 | if left then 423 | piece:rotate_left() 424 | else 425 | piece:rotate_right() 426 | end 427 | if not self:fit_piece(piece) then 428 | return false 429 | end 430 | self.cur_piece = piece 431 | self:update_preview_piece() 432 | return true 433 | end 434 | 435 | function Game:place_current_piece() 436 | riv_waveform(riv_waveform_desc{ 437 | type = RIV_WAVEFORM_SINE, 438 | attack = 0.01, 439 | decay = 0.04, 440 | sustain = 0.05, 441 | release = 0.05, 442 | start_frequency = 440, 443 | end_frequency = 600, 444 | amplitude = 0.25, 445 | sustain_level = 0.5, 446 | }) 447 | self.board:place_piece(self.cur_piece) 448 | self:clear_lines() 449 | if not self:spawn_piece() then 450 | riv.quit = true 451 | end 452 | end 453 | 454 | function Game:update() 455 | local XMOVE_COOLDOWN = 0.08 456 | local YMOVE_COOLDOWN = 0.05 457 | local INTERVAL_DECAY = 0.03 458 | local slide_interval = 0.5 - (self.level - 1)*INTERVAL_DECAY 459 | 460 | -- check slide to the left/right 461 | local colided = false 462 | if self.xmove_timer:elapsed() >= XMOVE_COOLDOWN then 463 | if riv.keys[RIV_GAMEPAD_LEFT].down then 464 | self.xmove_timer:restart() 465 | self:slide_current_piece(-1, 0) 466 | elseif riv.keys[RIV_GAMEPAD_RIGHT].down then 467 | self.xmove_timer:restart() 468 | self:slide_current_piece(1, 0) 469 | end 470 | end 471 | if self.ymove_timer:elapsed() >= YMOVE_COOLDOWN then 472 | if riv.keys[RIV_GAMEPAD_DOWN].down then 473 | self.ymove_timer:restart() 474 | self.slide_vert_timer:restart() 475 | if not self:slide_current_piece(0, 1) then 476 | self:place_current_piece() 477 | end 478 | end 479 | end 480 | 481 | if riv.keys[RIV_GAMEPAD_UP].press then 482 | self:rotate_current_piece(false) 483 | elseif riv.keys[RIV_GAMEPAD_A1].press then 484 | self:rotate_current_piece(true) 485 | elseif riv.keys[RIV_GAMEPAD_A2].press then 486 | self.slide_vert_timer:restart() 487 | self:slide_piece_waydown(self.cur_piece) 488 | self:place_current_piece() 489 | end 490 | 491 | -- slide down automatically 492 | if self.slide_vert_timer:elapsed() >= slide_interval then 493 | self.slide_vert_timer:restart() 494 | if not self:slide_current_piece(0, 1) then 495 | self:place_current_piece() 496 | end 497 | end 498 | 499 | -- save score 500 | local finished_str: string = riv.quit and 'true' or 'false' 501 | riv.outcard_len = riv_snprintf(&riv.outcard[0], RIV_SIZE_OUTCARD, 502 | [[JSON{"score":%d,"level":%d,"lines":%d,"frames":%u,"finished":%s}]], 503 | self.score, self.level, self.lines, riv.frame, finished_str) 504 | end 505 | 506 | local function draw_centered_text(text: cstring, rect: riv_recti, fontsize: integer, fgcolor: Color) 507 | riv_draw_text(text, RIV_SPRITESHEET_FONT_5X7, RIV_CENTER, rect.x + rect.width//2, rect.y + rect.height//2, fontsize, fgcolor) 508 | end 509 | 510 | local function draw_titled_number(title: string, num: integer, rect: riv_recti) 511 | local text: cstring = riv_tprintf("%ld", num) 512 | riv_draw_rect_fill(rect.x, rect.y, rect.width, rect.height, RIV_COLOR_DARKSLATE) 513 | riv_draw_rect_line(rect.x, rect.y, rect.width, rect.height, RIV_COLOR_SLATE) 514 | draw_centered_text(text, rect, 1, RIV_COLOR_WHITE) 515 | local titlerect: riv_recti = rect 516 | titlerect.y = titlerect.y - 16 517 | draw_centered_text(title, titlerect, 1, RIV_COLOR_GOLD) 518 | end 519 | 520 | function Game:draw_score() 521 | draw_titled_number('Score', self.score, {8, 16, 86, 16}) 522 | draw_titled_number('Level', self.level, {8, 48, 86, 16}) 523 | draw_titled_number('Lines', self.lines, {8, 80, 86, 16}) 524 | end 525 | 526 | function Game:draw_next_piece() 527 | local rect: riv_recti = {8, 112, 86, 48} 528 | local titlerect: riv_recti = rect 529 | titlerect.y = titlerect.y - 32 530 | riv_draw_rect_fill(rect.x, rect.y, rect.width, rect.height, RIV_COLOR_DARKSLATE) 531 | riv_draw_rect_line(rect.x, rect.y, rect.width, rect.height, RIV_COLOR_SLATE) 532 | draw_centered_text('Next', titlerect, 1, RIV_COLOR_GOLD) 533 | local x = rect.x + (rect.width - self.next_piece.width*CELL_SIZE) // 2 534 | local y = rect.y + (rect.height - self.next_piece.height*CELL_SIZE) // 2 535 | self.next_piece:draw(x, y, false) 536 | end 537 | 538 | function Game:draw_piece() 539 | self.board:draw_piece(self.cur_piece, false) 540 | self.board:draw_piece(self.preview_piece, true) 541 | end 542 | 543 | function Game:draw() 544 | riv_clear(RIV_COLOR_DARKSLATE) 545 | self:draw_score() 546 | self.board:draw() 547 | self:draw_next_piece() 548 | self:draw_piece() 549 | end 550 | 551 | -------------------------------------------------------------------------------- 552 | -- Main 553 | 554 | -- Initialize game 555 | local game: Game 556 | game:load_assets() 557 | game:new_game() 558 | 559 | local function frame() 560 | Timer.update_frame() 561 | game:update() 562 | game:draw() 563 | riv_present() 564 | end 565 | 566 | repeat 567 | frame() 568 | until riv.quit 569 | 570 | -- Cleanup 571 | game:destroy_assets() 572 | -------------------------------------------------------------------------------- /tetrix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edubart/tetrix/065726fded42c2c354014c92aa5b7e3aac12f86b/tetrix.png --------------------------------------------------------------------------------