diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..6246a591b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +This document attempts to list user-visible changes and any major internal +rearrangements of Notcurse. + +* 1.2.5 (not yet released) +** `ncvisual_render()` now returns the number of cells emitted on success, as + opposed to 0. Failure still sees -1 returned. +** `ncvisual_render()` now interprets length parameters of -1 to mean "to the + end along this axis", and no longer interprets 0 to mean this. 0 now means + "a length of 0", resulting in a zero-area rendering. + +* 1.2.4 2020-03-24 +** Add ncmultiselector +** Add `ncdirect_cursor_enable()` and `ncdirect_cursor_disable()`. diff --git a/README.md b/README.md index 496bbed07..32c7f1110 100644 --- a/README.md +++ b/README.md @@ -2398,12 +2398,13 @@ channels_set_bg_default(uint64_t* channels){ ## Included tools -Five binaries are built as part of notcurses: +Six binaries are installed as part of notcurses: * `notcurses-demo`: some demonstration code * `notcurses-view`: renders visual media (images/videos) * `notcurses-input`: decode and print keypresses * `notcurses-planereels`: play around with ncreels * `notcurses-tester`: unit testing +* `notcurses-tetris`: a tetris clone To run `notcurses-demo` from a checkout, provide the `tests/` directory via the `-p` argument. Demos requiring data files will otherwise abort. The base diff --git a/include/notcurses/notcurses.h b/include/notcurses/notcurses.h index 97d442f34..31c5bb86e 100644 --- a/include/notcurses/notcurses.h +++ b/include/notcurses/notcurses.h @@ -653,6 +653,8 @@ ncplane_putegc(struct ncplane* n, const char* gclust, int* sbytes){ // of the plane will not be changed. API int ncplane_putegc_stainable(struct ncplane* n, const char* gclust, int* sbytes); +// 0x0--0x10ffff can be UTF-8-encoded with only 4 bytes...but we aren't +// yet actively guarding against higher values getting into wcstombs FIXME #define WCHAR_MAX_UTF8BYTES 6 // ncplane_putegc(), but following a conversion from wchar_t to UTF-8 multibyte. diff --git a/src/tetris/background.h b/src/tetris/background.h new file mode 100644 index 000000000..21b96d36e --- /dev/null +++ b/src/tetris/background.h @@ -0,0 +1,36 @@ +// background is drawn to the standard plane, at the bottom. +void DrawBackground(const std::string& s) { + int averr; + try{ + backg_ = std::make_unique(s.c_str(), &averr, 0, 0, ncpp::NCScale::Stretch); + }catch(std::exception& e){ + throw TetrisNotcursesErr("visual(): " + s + ": " + e.what()); + } + if(!backg_->decode(&averr)){ + throw TetrisNotcursesErr("decode(): " + s); + } + if(backg_->render(0, 0, -1, -1) <= 0){ + throw TetrisNotcursesErr("render(): " + s); + } +} + +// draw the background on the standard plane, then create a new plane for the play area. +void DrawBoard() { + DrawBackground(BackgroundFile); + int y, x; + stdplane_->get_dim(&y, &x); + board_top_y_ = y - (BOARD_HEIGHT + 2); + board_ = std::make_unique(BOARD_HEIGHT, BOARD_WIDTH * 2, + board_top_y_, x / 2 - (BOARD_WIDTH + 1)); + uint64_t channels = 0; + channels_set_fg(&channels, 0x00b040); + channels_set_bg_alpha(&channels, CELL_ALPHA_TRANSPARENT); + if(!board_->double_box(0, channels, BOARD_HEIGHT - 1, BOARD_WIDTH * 2 - 1, NCBOXMASK_TOP)){ + throw TetrisNotcursesErr("rounded_box()"); + } + channels_set_fg_alpha(&channels, CELL_ALPHA_TRANSPARENT); + board_->set_base(channels, 0, ""); + if(!nc_.render()){ + throw TetrisNotcursesErr("render()"); + } +} diff --git a/src/tetris/gravity.h b/src/tetris/gravity.h new file mode 100644 index 000000000..b8df7ab92 --- /dev/null +++ b/src/tetris/gravity.h @@ -0,0 +1,17 @@ +// the number of milliseconds before a drop is forced at the given level, +// using the NES fps counter of 50ms +static constexpr int Gravity(int level) { + constexpr int MS_PER_GRAV = 30; // 10MHz*63/88/455/525 (~29.97fps) in NTSC + // The number of frames before a drop is forced, per level + constexpr std::array Gravities = { + 48, 43, 38, 33, 28, 23, 18, 13, 8, 6, 5, 5, 5, + 4, 4, 4, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1 + }; + if(level < 0){ + throw std::out_of_range("Illegal level"); + } + if(static_cast(level) < Gravities.size()){ + return Gravities[level] * MS_PER_GRAV; + } + return MS_PER_GRAV; // all levels 29+ are a single grav +} diff --git a/src/tetris/main.cpp b/src/tetris/main.cpp index eb142e8a0..356f52718 100644 --- a/src/tetris/main.cpp +++ b/src/tetris/main.cpp @@ -10,18 +10,6 @@ const std::string BackgroundFile = "../data/tetris-background.jpeg"; using namespace std::chrono_literals; -// "North-facing" tetrimino forms (the form in which they are released from the -// top) are expressed in terms of two rows having between two and four columns. -// We map each game column to four columns and each game row to two rows. -// Each byte of the texture maps to one 4x4 component block (and wastes 7 bits). -static const struct tetrimino { - unsigned color; - const char* texture; -} tetriminos[] = { // OITLJSZ - { 0xcbc900, "****"}, { 0x009caa, " ****"}, { 0x952d98, " * ***"}, - { 0xcf7900, " ****"}, { 0x0065bd, "* ***"}, { 0x69be28, " **** "}, - { 0xbd2939, "** **"} }; - class TetrisNotcursesErr : public std::runtime_error { public: TetrisNotcursesErr(const std::string& s) throw() @@ -40,154 +28,41 @@ public: Tetris(ncpp::NotCurses& nc, std::atomic_bool& gameover) : nc_(nc), score_(0), - msdelay_(100ms), curpiece_(nullptr), board_(nullptr), backg_(nullptr), stdplane_(nc_.get_stdplane()), - gameover_(gameover) + gameover_(gameover), + level_(0), + msdelay_(Gravity(level_)) { DrawBoard(); curpiece_ = NewPiece(); } - // 0.5 cell aspect: One board height == one row. One board width == two columns. + // 0.5 cell aspect: 1 board height == one row. 1 board width == two columns. static constexpr auto BOARD_WIDTH = 10; static constexpr auto BOARD_HEIGHT = 20; - // FIXME ideally this would be called from constructor :/ - void Ticker() { - std::chrono::milliseconds ms; - mtx_.lock(); - do{ - ms = msdelay_; - // FIXME loop and verify we didn't get a spurious wakeup - mtx_.unlock(); - std::this_thread::sleep_for(ms); - const std::lock_guard lock(mtx_); - if(curpiece_){ - int y, x; - curpiece_->get_yx(&y, &x); - if(PieceStuck()){ - if(y <= board_top_y_ - 2){ - gameover_ = true; - return; - } - curpiece_->mergedown(*board_); - curpiece_ = NewPiece(); - }else{ - ++y; - if(!curpiece_->move(y, x) || !nc_.render()){ - throw TetrisNotcursesErr("move() or render()"); - } - } - } - }while(!gameover_); - } - - void MoveLeft() { - const std::lock_guard lock(mtx_); - int y, x; - if(!PrepForMove(&y, &x)){ - return; - } - // For each line of the current piece, find the leftmost populated column. - // Check the game area to the immediate left. If something's there, we - // can't make this move. - ncpp::Cell c; - for(int ly = 0 ; ly < curpiece_->get_dim_y() ; ++ly){ - int lx = 0; - while(lx < curpiece_->get_dim_x()){ - if(curpiece_->get_at(ly, lx, &c)){ - if(c.get().gcluster && c.get().gcluster != ' '){ - break; - } - } - ++lx; - } - if(lx < curpiece_->get_dim_x()){ // otherwise, nothing on this row - ncpp::Cell b; - int cmpy = ly, cmpx = lx - 1; - curpiece_->translate(*board_, &cmpy, &cmpx); - if(board_->get_at(cmpy, cmpx, &b)){ - if(b.get().gcluster && b.get().gcluster != ' '){ - return; // move is blocked - } - } - } - } - --x; - if(!curpiece_->move(y, x) || !nc_.render()){ // FIXME needs y? - throw TetrisNotcursesErr("move() or render()"); - } - } - - void MoveRight() { - const std::lock_guard lock(mtx_); - int y, x; - if(!PrepForMove(&y, &x)){ - return; - } - // For each line of the current piece, find the rightmost populated column. - // Check the game area to the immediate right. If something's there, we - // can't make this move. - ncpp::Cell c; - for(int ly = 0 ; ly < curpiece_->get_dim_y() ; ++ly){ - int lx = curpiece_->get_dim_x() - 1; - while(lx >= 0){ - if(curpiece_->get_at(ly, lx, &c)){ - if(c.get().gcluster && c.get().gcluster != ' '){ - break; - } - } - --lx; - } - if(lx >= 0){ // otherwise, nothing on this row - ncpp::Cell b; - int cmpy = ly, cmpx = lx + 1; - curpiece_->translate(*board_, &cmpy, &cmpx); - if(board_->get_at(cmpy, cmpx, &b)){ - if(b.get().gcluster && b.get().gcluster != ' '){ - return; // move is blocked - } - } - } - } - ++x; - if(!curpiece_->move(y, x) || !nc_.render()){ // FIXME needs y? - throw TetrisNotcursesErr("move() or render()"); - } - } - - void RotateCcw() { - const std::lock_guard lock(mtx_); - int y, x; - if(!PrepForMove(&y, &x)){ - return; - } - // FIXME rotate that fucker ccw - } - - void RotateCw() { - const std::lock_guard lock(mtx_); - int y, x; - if(!PrepForMove(&y, &x)){ - return; - } - // FIXME rotate that fucker cw - } +#include "gravity.h" +#include "ticker.h" +#include "movedown.h" +#include "moveleft.h" +#include "moveright.h" +#include "rotate.h" private: ncpp::NotCurses& nc_; uint64_t score_; std::mutex mtx_; - std::chrono::milliseconds msdelay_; std::unique_ptr curpiece_; std::unique_ptr board_; std::unique_ptr backg_; ncpp::Plane* stdplane_; std::atomic_bool& gameover_; int board_top_y_; + int level_; + std::chrono::milliseconds msdelay_; // Returns true if there's a current piece which can be moved bool PrepForMove(int* y, int* x) { @@ -198,97 +73,9 @@ private: return true; } - // background is drawn to the standard plane, at the bottom. - void DrawBackground(const std::string& s) { - int averr; - try{ - backg_ = std::make_unique(s.c_str(), &averr, 0, 0, ncpp::NCScale::Stretch); - }catch(std::exception& e){ - throw TetrisNotcursesErr("visual(): " + s + ": " + e.what()); - } - if(!backg_->decode(&averr)){ - throw TetrisNotcursesErr("decode(): " + s); - } - if(!backg_->render(0, 0, -1, -1)){ - throw TetrisNotcursesErr("render(): " + s); - } - } - - // draw the background on the standard plane, then create a new plane for - // the play area. - void DrawBoard() { - DrawBackground(BackgroundFile); - int y, x; - stdplane_->get_dim(&y, &x); - board_top_y_ = y - (BOARD_HEIGHT + 2); - board_ = std::make_unique(BOARD_HEIGHT, BOARD_WIDTH * 2, - board_top_y_, x / 2 - (BOARD_WIDTH + 1)); - uint64_t channels = 0; - channels_set_fg(&channels, 0x00b040); - channels_set_bg_alpha(&channels, CELL_ALPHA_TRANSPARENT); - if(!board_->rounded_box(0, channels, BOARD_HEIGHT - 1, BOARD_WIDTH * 2 - 1, NCBOXMASK_TOP)){ - throw TetrisNotcursesErr("rounded_box()"); - } - channels_set_fg_alpha(&channels, CELL_ALPHA_TRANSPARENT); - board_->set_base(channels, 0, ""); - if(!nc_.render()){ - throw TetrisNotcursesErr("render()"); - } - } - - bool PieceStuck() { - if(!curpiece_){ - return false; - } - // check for impact. iterate over bottom row of piece's plane, checking for - // presence of glyph. if there, check row below. if row below is occupied, - // we're stuck. - int y, x; - curpiece_->get_dim(&y, &x); - --y; - while(x--){ - int cmpy = y + 1, cmpx = x; // need game area coordinates via translation - curpiece_->translate(*board_, &cmpy, &cmpx); - ncpp::Cell c; - if(board_->get_at(cmpy, cmpx, &c) < 0){ - throw TetrisNotcursesErr("get_at()"); - } - if(c.get().gcluster && c.get().gcluster != ' '){ - return true; - } - } - return false; - } - - // tidx is an index into tetriminos. yoff and xoff are relative to the - // terminal's origin. returns colored north-facing tetrimino on a plane. - std::unique_ptr NewPiece() { - const int tidx = random() % 7; - const struct tetrimino* t = &tetriminos[tidx]; - const size_t cols = strlen(t->texture); - int y, x; - stdplane_->get_dim(&y, &x); - const int xoff = x / 2 - BOARD_WIDTH + (random() % BOARD_WIDTH - 1); - std::unique_ptr n = std::make_unique(2, cols, board_top_y_ - 2, xoff, nullptr); - if(n){ - uint64_t channels = 0; - channels_set_bg_alpha(&channels, CELL_ALPHA_TRANSPARENT); - channels_set_fg_alpha(&channels, CELL_ALPHA_TRANSPARENT); - n->set_fg(t->color); - n->set_bg_alpha(CELL_ALPHA_TRANSPARENT); - n->set_base(channels, 0, ""); - y = 0; - for(size_t i = 0 ; i < strlen(t->texture) ; ++i){ - if(t->texture[i] == '*'){ - if(n->putstr(y, x, "██") < 0){ - return NULL; - } - } - y += ((x = ((x + 2) % cols)) == 0); - } - } - return n; - } +#include "background.h" +#include "stuck.h" +#include "newpiece.h" }; @@ -296,6 +83,7 @@ int main(void) { if(setlocale(LC_ALL, "") == nullptr){ return EXIT_FAILURE; } + srand(time(NULL)); std::atomic_bool gameover = false; notcurses_options ncopts{}; ncpp::NotCurses nc(ncopts); @@ -309,8 +97,9 @@ int main(void) { break; } switch(input){ - case NCKEY_LEFT: t.MoveLeft(); break; - case NCKEY_RIGHT: t.MoveRight(); break; + case NCKEY_LEFT: case 'h': t.MoveLeft(); break; + case NCKEY_RIGHT: case 'l': t.MoveRight(); break; + case NCKEY_DOWN: case 'j': t.MoveDown(); break; case 'z': t.RotateCcw(); break; case 'x': t.RotateCw(); break; default: @@ -320,7 +109,7 @@ int main(void) { break; } } - if(gameover || input == 'q'){ + if(gameover || input == 'q'){ // FIXME signal it on 'q' gameover = true; tid.join(); }else{ diff --git a/src/tetris/movedown.h b/src/tetris/movedown.h new file mode 100644 index 000000000..4be1dee03 --- /dev/null +++ b/src/tetris/movedown.h @@ -0,0 +1,20 @@ +// returns true if the game has ended as a result of this move down +bool MoveDown() { + const std::lock_guard lock(mtx_); + int y, x; + if(PrepForMove(&y, &x)){ + if(PieceStuck()){ + if(y <= board_top_y_ - 2){ + return true; + } + curpiece_->mergedown(*board_); + curpiece_ = NewPiece(); + }else{ + ++y; + if(!curpiece_->move(y, x) || !nc_.render()){ + throw TetrisNotcursesErr("move() or render()"); + } + } + } + return false; +} diff --git a/src/tetris/moveleft.h b/src/tetris/moveleft.h new file mode 100644 index 000000000..e0c9d0be1 --- /dev/null +++ b/src/tetris/moveleft.h @@ -0,0 +1,36 @@ +void MoveLeft() { + const std::lock_guard lock(mtx_); + int y, x; + if(!PrepForMove(&y, &x)){ + return; + } + // For each line of the current piece, find the leftmost populated column. + // Check the game area to the immediate left. If something's there, we + // can't make this move. + ncpp::Cell c; + for(int ly = 0 ; ly < curpiece_->get_dim_y() ; ++ly){ + int lx = 0; + while(lx < curpiece_->get_dim_x()){ + if(curpiece_->get_at(ly, lx, &c)){ + if(c.get().gcluster && c.get().gcluster != ' '){ + break; + } + } + ++lx; + } + if(lx < curpiece_->get_dim_x()){ // otherwise, nothing on this row + ncpp::Cell b; + int cmpy = ly, cmpx = lx - 1; + curpiece_->translate(*board_, &cmpy, &cmpx); + if(board_->get_at(cmpy, cmpx, &b)){ + if(b.get().gcluster && b.get().gcluster != ' '){ + return; // move is blocked + } + } + } + } + --x; + if(!curpiece_->move(y, x) || !nc_.render()){ // FIXME needs y? + throw TetrisNotcursesErr("move() or render()"); + } +} diff --git a/src/tetris/moveright.h b/src/tetris/moveright.h new file mode 100644 index 000000000..f8ca95922 --- /dev/null +++ b/src/tetris/moveright.h @@ -0,0 +1,36 @@ +void MoveRight() { + const std::lock_guard lock(mtx_); + int y, x; + if(!PrepForMove(&y, &x)){ + return; + } + // For each line of the current piece, find the rightmost populated column. + // Check the game area to the immediate right. If something's there, we + // can't make this move. + ncpp::Cell c; + for(int ly = 0 ; ly < curpiece_->get_dim_y() ; ++ly){ + int lx = curpiece_->get_dim_x() - 1; + while(lx >= 0){ + if(curpiece_->get_at(ly, lx, &c)){ + if(c.get().gcluster && c.get().gcluster != ' '){ + break; + } + } + --lx; + } + if(lx >= 0){ // otherwise, nothing on this row + ncpp::Cell b; + int cmpy = ly, cmpx = lx + 1; + curpiece_->translate(*board_, &cmpy, &cmpx); + if(board_->get_at(cmpy, cmpx, &b)){ + if(b.get().gcluster && b.get().gcluster != ' '){ + return; // move is blocked + } + } + } + } + ++x; + if(!curpiece_->move(y, x) || !nc_.render()){ // FIXME needs y? + throw TetrisNotcursesErr("move() or render()"); + } +} diff --git a/src/tetris/newpiece.h b/src/tetris/newpiece.h new file mode 100644 index 000000000..609635796 --- /dev/null +++ b/src/tetris/newpiece.h @@ -0,0 +1,42 @@ +// tidx is an index into tetriminos. yoff and xoff are relative to the +// terminal's origin. returns colored north-facing tetrimino on a plane. +std::unique_ptr NewPiece() { + // "North-facing" tetrimino forms (form in which they are released from the + // top) are expressed in terms of two rows having between 2 and 4 columns. + // We map each game column to four columns and each game row to two rows. Each + // byte of the texture maps to one 4x4 component block (and wastes 7 bits). + static const struct tetrimino { + unsigned color; + const char* texture; + } tetriminos[] = { // OITLJSZ + { 0xcbc900, "****"}, { 0x009caa, " ****"}, { 0x952d98, " * ***"}, + { 0xcf7900, " ****"}, { 0x0065bd, "* ***"}, { 0x69be28, " **** "}, + { 0xbd2939, "** **"} }; + + const int tidx = random() % 7; + const struct tetrimino* t = &tetriminos[tidx]; + const size_t cols = strlen(t->texture); + int y, x; + stdplane_->get_dim(&y, &x); + const int xoff = x / 2 - BOARD_WIDTH + (random() % BOARD_WIDTH - 1); + std::unique_ptr n = std::make_unique(2, cols, board_top_y_ - 1, xoff, nullptr); + if(n){ + uint64_t channels = 0; + channels_set_bg_alpha(&channels, CELL_ALPHA_TRANSPARENT); + channels_set_fg_alpha(&channels, CELL_ALPHA_TRANSPARENT); + n->set_fg(t->color); + n->set_bg_alpha(CELL_ALPHA_TRANSPARENT); + n->set_base(channels, 0, ""); + y = 0; + x = 0; + for(size_t i = 0 ; i < strlen(t->texture) ; ++i){ + if(t->texture[i] == '*'){ + if(n->putstr(y, x, "██") < 0){ + return NULL; + } + } + y += ((x = ((x + 2) % cols)) == 0); + } + } + return n; +} diff --git a/src/tetris/rotate.h b/src/tetris/rotate.h new file mode 100644 index 000000000..868bfbb11 --- /dev/null +++ b/src/tetris/rotate.h @@ -0,0 +1,21 @@ +void RotateCcw() { + const std::lock_guard lock(mtx_); + int y, x; + if(!PrepForMove(&y, &x)){ + return; + } + if(!curpiece_->rotate_ccw() || !nc_.render()){ + throw TetrisNotcursesErr("rotate_ccw() or render()"); + } +} + +void RotateCw() { + const std::lock_guard lock(mtx_); + int y, x; + if(!PrepForMove(&y, &x)){ + return; + } + if(!curpiece_->rotate_cw() || !nc_.render()){ + throw TetrisNotcursesErr("rotate_cw() or render()"); + } +} diff --git a/src/tetris/stuck.h b/src/tetris/stuck.h new file mode 100644 index 000000000..f8f35da81 --- /dev/null +++ b/src/tetris/stuck.h @@ -0,0 +1,21 @@ +bool PieceStuck() { + if(curpiece_){ + // check for impact. iterate over bottom row of piece's plane, checking for + // presence of glyph. if there, check row below. if row below is occupied, + // we're stuck. + int y, x; + curpiece_->get_dim(&y, &x); + while(x--){ + int cmpy = y, cmpx = x; // need game area coordinates via translation + curpiece_->translate(*board_, &cmpy, &cmpx); + ncpp::Cell c; + if(board_->get_at(cmpy, cmpx, &c) < 0){ + throw TetrisNotcursesErr("get_at()"); + } + if(c.get().gcluster && c.get().gcluster != ' '){ + return true; + } + } + } + return false; +} diff --git a/src/tetris/ticker.h b/src/tetris/ticker.h new file mode 100644 index 000000000..24877fe20 --- /dev/null +++ b/src/tetris/ticker.h @@ -0,0 +1,13 @@ +void Ticker() { // FIXME ideally this would be called from constructor :/ + std::chrono::milliseconds ms; + mtx_.lock(); + do{ + ms = msdelay_; + mtx_.unlock(); + std::this_thread::sleep_for(ms); + if(MoveDown()){ + gameover_ = true; + return; + } + }while(!gameover_); +}