diff --git a/lib/games/src/game_rng.h b/lib/games/src/game_rng.h new file mode 100644 index 0000000..6aec506 --- /dev/null +++ b/lib/games/src/game_rng.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +namespace roro::games { + +// The Games' own dice (issue #101): the same seed gives the same game, which is what lets their +// rules be tested. xorshift32; never 0. +class Rng { + public: + explicit Rng(uint32_t seed) : s_(seed ? seed : 0x9E3779B9u) {} + uint32_t next() { + s_ ^= s_ << 13; + s_ ^= s_ >> 17; + s_ ^= s_ << 5; + return s_; + } + int below(int n) { return n <= 1 ? 0 : static_cast(next() % static_cast(n)); } + float unit() { return (next() >> 8) / 16777216.0f; } // 0 up to 1 + + private: + uint32_t s_; +}; + +} // namespace roro::games diff --git a/lib/games/src/pong.cpp b/lib/games/src/pong.cpp new file mode 100644 index 0000000..c8acc76 --- /dev/null +++ b/lib/games/src/pong.cpp @@ -0,0 +1,103 @@ +#include "pong.h" + +#include +#include + +namespace roro::games { + +namespace { +constexpr float kTick = 0.004f; // the ball moves a pixel at most between two looks +constexpr float kSteepest = 0.87f; // 50 degrees, in radians: off the very end of a paddle +float clampPaddle(float y) { return std::clamp(y, Pong::kPaddleH / 2, Pong::kHeight - Pong::kPaddleH / 2); } +} // namespace + +void Pong::start() { + playerScore_ = deviceScore_ = rally_ = longest_ = 0; + player_ = device_ = kHeight / 2; + serve(rng_.below(2) == 0); +} + +void Pong::serve(bool toPlayer) { + x_ = kWidth / 2; + y_ = kHeight / 2; + speed_ = kServeSpeed; + float angle = (rng_.unit() - 0.5f) * 1.0f; // within 29 degrees of level + vx_ = std::cos(angle) * speed_ * (toPlayer ? -1 : 1); + vy_ = std::sin(angle) * speed_; + rally_ = 0; + wait_ = kServePause; + phase_ = Phase::Serve; +} + +bool Pong::meets(float paddleY) const { return std::fabs(y_ - paddleY) <= kPaddleH / 2 + kBall / 2; } + +// Where on the paddle it hit decides the angle it leaves at, and each return is a little faster. +void Pong::bounceOff(float paddleY, bool toRight) { + float where = std::clamp((y_ - paddleY) / (kPaddleH / 2), -1.0f, 1.0f); + speed_ = std::min(kMaxSpeed, speed_ * kFaster); + vx_ = std::cos(where * kSteepest) * speed_ * (toRight ? 1 : -1); + vy_ = std::sin(where * kSteepest) * speed_; +} + +uint8_t Pong::step(float seconds, int move) { + uint8_t events = 0; + seconds = std::min(seconds, 0.25f); // a held-up loop is not a quarter of the field + while (seconds > 0 && phase_ != Phase::Over) { + float dt = std::min(seconds, kTick); + seconds -= dt; + events |= tick(dt, move); + } + return events; +} + +uint8_t Pong::tick(float dt, int move) { + player_ = clampPaddle(player_ + (move < 0 ? -1 : move > 0 ? 1 : 0) * kPlayerSpeed * dt); + // The device watches the ball once it comes its way, and waits in the middle otherwise. + float target = vx_ > 0 && phase_ == Phase::Play ? y_ : kHeight / 2; + float reach = kDeviceSpeed * dt; + device_ = clampPaddle(device_ + std::clamp(target - device_, -reach, reach)); + + if (phase_ == Phase::Serve) { + wait_ -= dt; + if (wait_ <= 0) phase_ = Phase::Play; + return 0; + } + + uint8_t events = 0; + float before = x_; + x_ += vx_ * dt; + y_ += vy_ * dt; + if (y_ < kBall / 2) { + y_ = kBall - y_; + vy_ = -vy_; + events |= Wall; + } else if (y_ > kHeight - kBall / 2) { + y_ = 2 * (kHeight - kBall / 2) - y_; + vy_ = -vy_; + events |= Wall; + } + + const float playerFace = kMargin + kPaddleW + kBall / 2, deviceFace = kWidth - kMargin - kPaddleW - kBall / 2; + if (vx_ < 0 && before >= playerFace && x_ < playerFace && meets(player_)) { + x_ = playerFace; + bounceOff(player_, true); + rally_++; + longest_ = std::max(longest_, rally_); + events |= Hit; + } else if (vx_ > 0 && before <= deviceFace && x_ > deviceFace && meets(device_)) { + x_ = deviceFace; + bounceOff(device_, false); + events |= Hit; + } else if (x_ < 0 || x_ > kWidth) { + bool playerScored = x_ > kWidth; + (playerScored ? playerScore_ : deviceScore_)++; + events |= Point; + if (playerScore_ >= kWinScore || deviceScore_ >= kWinScore) { + phase_ = Phase::Over; + events |= Over; + } else serve(playerScored); // to whoever lost the point + } + return events; +} + +} // namespace roro::games diff --git a/lib/games/src/pong.h b/lib/games/src/pong.h new file mode 100644 index 0000000..60ded1d --- /dev/null +++ b/lib/games/src/pong.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +#include "game_rng.h" + +namespace roro::games { + +// Pong against the device (issue #101): the player's paddle on the left, the device's on the +// right, first to 7. The ball goes faster with each return, and the device's paddle only moves so +// fast: a steep, fast ball gets past it. Lengths are pixels of the 240 x 122 field, times seconds. +class Pong { + public: + static constexpr float kWidth = 240, kHeight = 122; + static constexpr float kPaddleH = 24, kPaddleW = 3, kMargin = 6, kBall = 4; + static constexpr float kPlayerSpeed = 150, kDeviceSpeed = 88; + static constexpr float kServeSpeed = 110, kMaxSpeed = 270, kFaster = 1.06f; + static constexpr float kServePause = 0.8f; + static constexpr int kWinScore = 7; + + enum Event : uint8_t { Wall = 1, Hit = 2, Point = 4, Over = 8 }; + enum class Phase : uint8_t { Serve, Play, Over }; + + explicit Pong(uint32_t seed) : rng_(seed) { start(); } + void start(); + // `seconds` later, with the player's paddle asked up (-1), down (1) or left alone (0). + // What happened, as Events. + uint8_t step(float seconds, int move); + + Phase phase() const { return phase_; } + float ballX() const { return x_; } + float ballY() const { return y_; } + float speed() const { return speed_; } + float playerY() const { return player_; } // the paddle's middle + float deviceY() const { return device_; } + int playerScore() const { return playerScore_; } + int deviceScore() const { return deviceScore_; } + bool playerWon() const { return playerScore_ >= kWinScore; } + int rally() const { return rally_; } // the player's returns since the serve + int longestRally() const { return longest_; } // in this game + + private: + uint8_t tick(float dt, int move); + void serve(bool toPlayer); + bool meets(float paddleY) const; + void bounceOff(float paddleY, bool toRight); + + Rng rng_; + Phase phase_ = Phase::Serve; + float x_ = 0, y_ = 0, vx_ = 0, vy_ = 0, speed_ = 0; + float player_ = 0, device_ = 0, wait_ = 0; + int playerScore_ = 0, deviceScore_ = 0, rally_ = 0, longest_ = 0; +}; + +} // namespace roro::games diff --git a/lib/games/src/snake.cpp b/lib/games/src/snake.cpp new file mode 100644 index 0000000..2875cff --- /dev/null +++ b/lib/games/src/snake.cpp @@ -0,0 +1,92 @@ +#include "snake.h" + +#include + +namespace roro::games { + +namespace { +bool opposite(Snake::Dir a, Snake::Dir b) { return (static_cast(a) + 2) % 4 == static_cast(b); } +} // namespace + +void Snake::start() { + body_.clear(); + taken_.fill(false); + for (int i = 0; i < 3; i++) { // three cells in the middle, heading right + Cell c{static_cast(kCols / 2 - i), static_cast(kRows / 2)}; + body_.push_back(c); + mark(c, true); + } + dir_ = Dir::Right; + turnCount_ = 0; + score_ = 0; + sinceMs_ = 0; + alive_ = true; + placeFood(); +} + +uint32_t Snake::intervalMs() const { + uint32_t off = static_cast(score_ / kFoodsAStep) * kFasterMs; + return off >= kSlowestMs - kFastestMs ? kFastestMs : kSlowestMs - off; +} + +void Snake::turn(Dir dir) { + Dir last = turnCount_ ? turns_[turnCount_ - 1] : dir_; + if (dir == last || opposite(dir, last) || turnCount_ >= 2) return; + turns_[turnCount_++] = dir; +} + +// On a free cell, each as likely as another. +void Snake::placeFood() { + int free = kCols * kRows - static_cast(body_.size()); + if (free <= 0) return; + int pick = rng_.below(free); + for (int i = 0; i < kCols * kRows; i++) { + if (taken_[static_cast(i)]) continue; + if (pick-- == 0) { + food_ = {static_cast(i % kCols), static_cast(i / kCols)}; + return; + } + } +} + +uint8_t Snake::step(uint32_t ms) { + uint8_t events = 0; + if (!alive_) return 0; + sinceMs_ += std::min(ms, 500); + while (alive_ && sinceMs_ >= intervalMs()) { + sinceMs_ -= intervalMs(); + events |= move(); + } + return events; +} + +uint8_t Snake::move() { + if (turnCount_) { + dir_ = turns_[0]; + turns_[0] = turns_[1]; + turnCount_--; + } + static const int8_t dc[4] = {0, 1, 0, -1}, dr[4] = {-1, 0, 1, 0}; + Cell head{static_cast(body_.front().col + dc[static_cast(dir_)]), + static_cast(body_.front().row + dr[static_cast(dir_)])}; + bool eats = head == food_; + // The tail moves on as the head arrives: the head may go where the tail was. A snake that + // dies stays as it was, whole. + bool ontoTail = !eats && head == body_.back(); + if (head.col < 0 || head.col >= kCols || head.row < 0 || head.row >= kRows || (taken(head) && !ontoTail)) { + alive_ = false; + return Died; + } + if (!eats) { + mark(body_.back(), false); + body_.pop_back(); + } + body_.push_front(head); + mark(head, true); + if (!eats) return Moved; + score_++; + placeFood(); + return Moved | Ate; +} + +} // namespace roro::games diff --git a/lib/games/src/snake.h b/lib/games/src/snake.h new file mode 100644 index 0000000..955b41d --- /dev/null +++ b/lib/games/src/snake.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include + +#include "game_rng.h" + +namespace roro::games { + +// Snake (issue #101): a field of 38 x 19 cells, the walls and its own body kill it, and it goes +// faster as it eats. Time comes in from outside, in milliseconds. +class Snake { + public: + static constexpr int kCols = 38, kRows = 19; + static constexpr uint32_t kSlowestMs = 150, kFastestMs = 60, kFasterMs = 5; + static constexpr int kFoodsAStep = 3; // it gets faster every so many + + enum class Dir : uint8_t { Up, Right, Down, Left }; + enum Event : uint8_t { Moved = 1, Ate = 2, Died = 4 }; + struct Cell { + int8_t col, row; + bool operator==(const Cell& o) const { return col == o.col && row == o.row; } + }; + + explicit Snake(uint32_t seed) : rng_(seed) { start(); } + void start(); + // A turn asked for; it is taken at the next move. Two are remembered, so a quick corner (up, + // then left) works; turning back on itself is not a turn. + void turn(Dir dir); + uint8_t step(uint32_t ms); + + bool alive() const { return alive_; } + int score() const { return score_; } // foods eaten + const std::deque& body() const { return body_; } // the head first + Cell food() const { return food_; } + Dir heading() const { return dir_; } + uint32_t intervalMs() const; // between two moves, now + // For the tests: the food put where one wants it. + void putFood(Cell at) { food_ = at; } + + private: + bool taken(Cell c) const { return taken_[static_cast(c.row * kCols + c.col)]; } + void mark(Cell c, bool on) { taken_[static_cast(c.row * kCols + c.col)] = on; } + void placeFood(); + uint8_t move(); + + Rng rng_; + std::deque body_; + std::array taken_{}; + Cell food_{0, 0}; + Dir dir_ = Dir::Right; + Dir turns_[2] = {Dir::Right, Dir::Right}; + int turnCount_ = 0, score_ = 0; + uint32_t sinceMs_ = 0; + bool alive_ = true; +}; + +} // namespace roro::games diff --git a/lib/games/src/tetris.cpp b/lib/games/src/tetris.cpp new file mode 100644 index 0000000..67cd1ab --- /dev/null +++ b/lib/games/src/tetris.cpp @@ -0,0 +1,202 @@ +#include "tetris.h" + +#include + +namespace roro::games { + +namespace { + +// Each piece in its four turns, as the cells it fills in a 4 x 4 box: bit (row * 4 + col), the +// top left first. They turn as in the Super Rotation System. +constexpr uint16_t bits(const char* rows) { + uint16_t v = 0; + for (int i = 0; i < 16; i++) + if (rows[i] == '#') v |= static_cast(1u << i); + return v; +} +constexpr uint16_t kShapes[7][4] = { + {bits("....####........"), bits("..#...#...#...#."), bits("........####...."), bits(".#...#...#...#..")}, // I + {bits(".##..##........."), bits(".##..##........."), bits(".##..##........."), bits(".##..##.........")}, // O + {bits(".#..###........."), bits(".#...##..#......"), bits("....###..#......"), bits(".#..##...#......")}, // T + {bits(".##.##.........."), bits(".#...##...#....."), bits(".....##.##......"), bits("#...##...#......")}, // S + {bits("##...##........."), bits("..#..##..#......"), bits("....##...##....."), bits(".#..##..#.......")}, // Z + {bits("#...###........."), bits(".##..#...#......"), bits("....###...#....."), bits(".#...#..##......")}, // J + {bits("..#.###........."), bits(".#...#...##....."), bits("....###.#......."), bits("##...#...#......")}, // L +}; +// A turn that does not fit where the piece is tries these shifts, in order: a step from a wall, +// one up off the floor, two for the long piece. +constexpr int8_t kKicks[6][2] = {{0, 0}, {-1, 0}, {1, 0}, {0, -1}, {-2, 0}, {2, 0}}; +constexpr int kLineScore[5] = {0, 100, 300, 500, 800}; + +} // namespace + +std::array Tetris::cells(const Piece& p) { + std::array out{}; + if (p.kind < 1 || p.kind > 7) return out; + uint16_t shape = kShapes[p.kind - 1][p.turn & 3]; + int n = 0; + for (int i = 0; i < 16 && n < 4; i++) + if (shape & (1u << i)) out[static_cast(n++)] = {static_cast(p.col + i % 4), static_cast(p.row + i / 4)}; + return out; +} + +void Tetris::start() { + well_.fill(0); + score_ = lines_ = lastCleared_ = 0; + fallSince_ = heldMs_ = 0; + heldDir_ = 0; + repeating_ = over_ = softSpent_ = false; + bagLeft_ = 0; + next_ = deal(); + spawn(deal()); +} + +// The bag: the seven pieces in a shuffled order, then seven again. +uint8_t Tetris::deal() { + if (bagLeft_ == 0) { + for (int i = 0; i < 7; i++) bag_[i] = static_cast(i + 1); + for (int i = 6; i > 0; i--) std::swap(bag_[i], bag_[rng_.below(i + 1)]); + bagLeft_ = 7; + } + return bag_[--bagLeft_]; +} + +uint32_t Tetris::fallMs() const { + uint32_t ms = kSlowestMs; + for (int i = 0; i < level() && ms > kFastestMs; i++) ms = ms * 85 / 100; + return std::max(ms, kFastestMs); +} + +// Inside the walls and above the floor, on no cell already filled. Above the well is free. +bool Tetris::fits(const Piece& p) const { + for (const Cell& c : cells(p)) { + if (c.col < 0 || c.col >= kCols || c.row >= kRows) return false; + if (c.row >= 0 && cell(c.col, c.row)) return false; + } + return true; +} + +bool Tetris::shift(int dc, int dr) { + Piece moved = piece_; + moved.col = static_cast(moved.col + dc); + moved.row = static_cast(moved.row + dr); + if (!fits(moved)) return false; + piece_ = moved; + return true; +} + +bool Tetris::rotate(int by) { + Piece turned = piece_; + turned.turn = static_cast((turned.turn + 4 + by) & 3); + for (auto& kick : kKicks) { + Piece tried = turned; + tried.col = static_cast(tried.col + kick[0]); + tried.row = static_cast(tried.row + kick[1]); + if (fits(tried)) { + piece_ = tried; + return true; + } + } + return false; +} + +// The piece comes in at the top, in the middle. If there is no room for it, the game is over. +uint8_t Tetris::spawn(uint8_t kind) { + piece_ = Piece{kind, 0, 3, static_cast(kind == 1 ? -1 : 0)}; + fallSince_ = 0; + if (fits(piece_)) return 0; + over_ = true; + return Over; +} + +void Tetris::give(uint8_t kind) { spawn(kind); } + +uint8_t Tetris::lock() { + uint8_t events = Locked; + for (const Cell& c : cells(piece_)) { + if (c.row < 0) { // it came to rest above the well + over_ = true; + return static_cast(events | Over); + } + well_[static_cast(c.row * kCols + c.col)] = piece_.kind; + } + // Full rows go, and what is above comes down. + int cleared = 0, before = level(); + for (int row = kRows - 1; row >= 0;) { + bool full = true; + for (int col = 0; col < kCols && full; col++) full = cell(col, row) != 0; + if (!full) { + row--; + continue; + } + for (int r = row; r > 0; r--) + for (int col = 0; col < kCols; col++) well_[static_cast(r * kCols + col)] = cell(col, r - 1); + for (int col = 0; col < kCols; col++) well_[static_cast(col)] = 0; + cleared++; // the same row again: it now holds the one that was above + } + lastCleared_ = cleared; + if (cleared) { + score_ += kLineScore[cleared] * (before + 1); + lines_ += cleared; + events |= Lines; + if (level() > before) events |= LevelUp; + } + uint8_t coming = next_; + next_ = deal(); + return static_cast(events | spawn(coming)); +} + +uint8_t Tetris::step(uint32_t ms, const TetrisInput& in) { + if (over_) return 0; + uint8_t events = 0; + ms = std::min(ms, 1000); // a held-up loop is not a piece through the floor + + if (in.rotate && rotate(1)) events |= Turned; + if (in.rotateBack && rotate(-1)) events |= Turned; + + // Left and right: once at the press, then again and again while held. + int dir = in.left == in.right ? 0 : in.left ? -1 : 1; + if (dir != heldDir_) { + heldDir_ = dir; + heldMs_ = 0; + repeating_ = false; + if (dir && shift(dir, 0)) events |= Moved; + } else if (dir) { + heldMs_ += ms; + uint32_t wait = repeating_ ? kRepeatEveryMs : kRepeatAfterMs; + while (heldMs_ >= wait) { + heldMs_ -= wait; + repeating_ = true; + wait = kRepeatEveryMs; + if (shift(dir, 0)) events |= Moved; + } + } + + if (in.hardDrop) { + int rows = 0; + while (shift(0, 1)) rows++; + score_ += 2 * rows; + return static_cast(events | lock()); + } + + // Falling faster is for the piece it was asked for: the key still held when that one lands + // does not send the next one down too. It has to be let go and pressed again. + if (!in.soft) softSpent_ = false; + bool soft = in.soft && !softSpent_; + uint32_t every = soft ? std::min(fallMs(), kSoftMs) : fallMs(); + fallSince_ += ms; + while (!over_ && fallSince_ >= every) { + fallSince_ -= every; + if (shift(0, 1)) { + if (soft) score_++; + events |= Moved; + } else { + events |= lock(); + softSpent_ = in.soft; + break; // the next piece starts its own fall + } + } + return events; +} + +} // namespace roro::games diff --git a/lib/games/src/tetris.h b/lib/games/src/tetris.h new file mode 100644 index 0000000..3549822 --- /dev/null +++ b/lib/games/src/tetris.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include + +#include "game_rng.h" + +namespace roro::games { + +// What the player asks of Tetris in one step: the first three for as long as the key is held, +// the others once for each press. +struct TetrisInput { + bool left = false, right = false, soft = false; + bool rotate = false, rotateBack = false, hardDrop = false; +}; + +// Tetris (issue #101): a well of 10 x 20, the seven pieces dealt from a bag (each once in every +// seven), the next one shown, a turn that shifts away from a wall, a soft and a hard drop, and a +// fall that quickens every ten lines. No hold, no ghost. Time comes in milliseconds. +class Tetris { + public: + static constexpr int kCols = 10, kRows = 20; + static constexpr uint32_t kSlowestMs = 800, kFastestMs = 80; + static constexpr uint32_t kSoftMs = 90; // a row, falling faster: quick, and still steered + static constexpr uint32_t kRepeatAfterMs = 170, kRepeatEveryMs = 50; // a held left or right + static constexpr int kLinesALevel = 10; + + enum Event : uint8_t { Moved = 1, Turned = 2, Locked = 4, Lines = 8, LevelUp = 16, Over = 32 }; + // The pieces, 1 to 7, in the order I O T S Z J L; 0 is an empty cell. + struct Piece { + uint8_t kind = 0; + uint8_t turn = 0; // 0 to 3, quarter turns clockwise + int8_t col = 0, row = 0; // of the top left of its 4 x 4 box; the row may be above the well + }; + struct Cell { + int8_t col, row; + }; + + explicit Tetris(uint32_t seed) : rng_(seed) { start(); } + void start(); + uint8_t step(uint32_t ms, const TetrisInput& in); + + bool over() const { return over_; } + uint8_t cell(int col, int row) const { return well_[static_cast(row * kCols + col)]; } + const Piece& piece() const { return piece_; } + uint8_t next() const { return next_; } + int score() const { return score_; } + int lines() const { return lines_; } + int level() const { return lines_ / kLinesALevel; } + uint32_t fallMs() const; // between two rows of fall, at this level + int lastCleared() const { return lastCleared_; } // by the last piece locked + + // The four cells a piece covers. + static std::array cells(const Piece& p); + // For the tests: a cell of the well set, and the piece in play replaced. + void put(int col, int row, uint8_t kind) { well_[static_cast(row * kCols + col)] = kind; } + void give(uint8_t kind); + + private: + bool fits(const Piece& p) const; + bool shift(int dc, int dr); + bool rotate(int by); + uint8_t lock(); + uint8_t spawn(uint8_t kind); + uint8_t deal(); + + Rng rng_; + std::array well_{}; + Piece piece_; + uint8_t next_ = 1; + uint8_t bag_[7] = {}; + int bagLeft_ = 0; + int score_ = 0, lines_ = 0, lastCleared_ = 0; + uint32_t fallSince_ = 0, heldMs_ = 0; + int heldDir_ = 0; + bool repeating_ = false, over_ = false; + bool softSpent_ = false; // the key that brought the last piece down is still held +}; + +} // namespace roro::games diff --git a/lib/games/src/tune.h b/lib/games/src/tune.h new file mode 100644 index 0000000..7a5f46e --- /dev/null +++ b/lib/games/src/tune.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include + +namespace roro::games { + +// A tune as the speaker can play one: a note at a time, each so many eighths long. hz 0 is a rest. +struct Note { + uint16_t hz; + uint8_t eighths; +}; + +// Plays a tune round and round as time passes (issue #101). It makes no sound itself: step() +// says when the next note begins, and the caller sounds it. +class TunePlayer { + public: + // `at(i)` is the tune's i-th note, of `count`. + TunePlayer(Note (*at)(size_t), size_t count, uint32_t eighthMs) : note_(at), count_(count), eighthMs_(eighthMs) {} + + void restart() { + at_ = 0; + leftMs_ = 0; + begun_ = false; + } + // `ms` later: true if a note begins now, with the note and how long it lasts. After a long + // gap the notes that would have gone by are skipped, not played in a burst. + bool step(uint32_t ms, Note& note, uint32_t& lengthMs) { + if (!count_) return false; + if (!begun_) { + begun_ = true; + ms = 0; // the time before the first note is nobody's + } else if (ms < leftMs_) { + leftMs_ -= ms; + return false; + } else { + ms -= leftMs_; + at_ = (at_ + 1) % count_; + while (ms >= note_(at_).eighths * eighthMs_) { // whole notes missed + ms -= note_(at_).eighths * eighthMs_; + at_ = (at_ + 1) % count_; + } + } + note = note_(at_); + lengthMs = note.eighths * eighthMs_; + leftMs_ = lengthMs - ms; + return true; + } + size_t position() const { return at_; } + // A new pace, from the next note on. + void setEighthMs(uint32_t ms) { eighthMs_ = ms; } + uint32_t eighthMs() const { return eighthMs_; } + + private: + Note (*note_)(size_t); + size_t count_; + uint32_t eighthMs_; + size_t at_ = 0; + uint32_t leftMs_ = 0; + bool begun_ = false; +}; + +// Korobeiniki, the Russian folk song (1861) that Tetris is played to: the tune twice, then its +// slow second part. +namespace korobeiniki { +constexpr uint16_t E4 = 330, Gs4 = 415, A4 = 440, B4 = 494, C5 = 523, D5 = 587, E5 = 659, F5 = 698, G5 = 784, Gs5 = 831, A5 = 880; +// clang-format off +constexpr Note kFirst[] = { + {E5, 2}, {B4, 1}, {C5, 1}, {D5, 2}, {C5, 1}, {B4, 1}, + {A4, 2}, {A4, 1}, {C5, 1}, {E5, 2}, {D5, 1}, {C5, 1}, + {B4, 3}, {C5, 1}, {D5, 2}, {E5, 2}, + {C5, 2}, {A4, 2}, {A4, 2}, {0, 2}, + {0, 1}, {D5, 2}, {F5, 1}, {A5, 2}, {G5, 1}, {F5, 1}, + {E5, 3}, {C5, 1}, {E5, 2}, {D5, 1}, {C5, 1}, + {B4, 2}, {B4, 1}, {C5, 1}, {D5, 2}, {E5, 2}, + {C5, 2}, {A4, 2}, {A4, 2}, {0, 2}, +}; +constexpr Note kSecond[] = { + {E5, 4}, {C5, 4}, {D5, 4}, {B4, 4}, {C5, 4}, {A4, 4}, {Gs4, 4}, {B4, 2}, {0, 2}, + {E5, 4}, {C5, 4}, {D5, 4}, {B4, 4}, {C5, 2}, {E5, 2}, {A5, 4}, {Gs5, 4}, {0, 4}, +}; +// clang-format on +constexpr size_t kFirstCount = sizeof(kFirst) / sizeof(kFirst[0]), kSecondCount = sizeof(kSecond) / sizeof(kSecond[0]); +constexpr size_t kCount = 2 * kFirstCount + kSecondCount; +constexpr uint32_t kEighthMs = 200; // 150 quarter notes a minute, at the first level +constexpr uint32_t kFastestEighthMs = 110; // 272: as fast as it is still a tune +constexpr uint32_t kFasterMs = 8; // off each eighth, a level +// The tune hurries as the game does: its pace at a level of Tetris, the first being 0. +constexpr uint32_t eighthMsAt(int level) { + return level <= 0 ? kEighthMs + : static_cast(level) * kFasterMs >= kEighthMs - kFastestEighthMs ? kFastestEighthMs + : kEighthMs - static_cast(level) * kFasterMs; +} + +// The whole of it, in the order it is played. +constexpr Note at(size_t i) { + return i < kFirstCount ? kFirst[i] : i < 2 * kFirstCount ? kFirst[i - kFirstCount] : kSecond[i - 2 * kFirstCount]; +} +} // namespace korobeiniki + +} // namespace roro::games diff --git a/test/test_games/test_games.cpp b/test/test_games/test_games.cpp new file mode 100644 index 0000000..701070b --- /dev/null +++ b/test/test_games/test_games.cpp @@ -0,0 +1,698 @@ +#include + +#include +#include + +#include "pong.h" +#include "snake.h" +#include "tetris.h" +#include "tune.h" + +using namespace roro::games; + +void setUp() {} +void tearDown() {} + +// ---- Pong ---- + +namespace { +// Plays until the phase or an event says so; the player follows the ball if `follow`. +uint8_t playPong(Pong& p, bool follow, uint8_t until, int maxSteps = 200000) { + for (int i = 0; i < maxSteps; i++) { + int move = !follow ? 0 : p.ballY() < p.playerY() - 2 ? -1 : p.ballY() > p.playerY() + 2 ? 1 : 0; + uint8_t e = p.step(0.01f, move); + if (e & until) return e; + } + return 0; +} +} // namespace + +void test_pong_starts_with_a_serve_from_the_middle() { + Pong p(1); + TEST_ASSERT_TRUE(p.phase() == Pong::Phase::Serve); + TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kWidth / 2, p.ballX()); + p.step(Pong::kServePause / 2, 0); + TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kWidth / 2, p.ballX()); // it waits + for (int i = 0; i < 100; i++) p.step(0.01f, 0); + TEST_ASSERT_TRUE(p.phase() == Pong::Phase::Play); + p.step(0.1f, 0); + TEST_ASSERT_TRUE(std::fabs(p.ballX() - Pong::kWidth / 2) > 5); +} + +void test_pong_the_same_seed_is_the_same_game() { + Pong a(42), b(42), c(43); + for (int i = 0; i < 500; i++) { + a.step(0.01f, i % 3 - 1); + b.step(0.01f, i % 3 - 1); + c.step(0.01f, i % 3 - 1); + } + TEST_ASSERT_EQUAL_FLOAT(a.ballX(), b.ballX()); + TEST_ASSERT_EQUAL_FLOAT(a.ballY(), b.ballY()); + TEST_ASSERT_TRUE(a.ballY() != c.ballY() || a.ballX() != c.ballX()); +} + +void test_pong_the_ball_stays_between_the_walls() { + Pong p(7); + bool wall = false; + for (int i = 0; i < 20000; i++) { + uint8_t e = p.step(0.01f, p.ballY() < p.playerY() ? -1 : 1); + wall |= (e & Pong::Wall) != 0; + TEST_ASSERT_TRUE(p.ballY() >= 0 && p.ballY() <= Pong::kHeight); + if (p.phase() == Pong::Phase::Over) p.start(); + } + TEST_ASSERT_TRUE(wall); +} + +void test_pong_a_paddle_stays_on_the_field() { + Pong p(3); + for (int i = 0; i < 300; i++) p.step(0.01f, -1); + TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kPaddleH / 2, p.playerY()); + for (int i = 0; i < 300; i++) p.step(0.01f, 1); + TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kHeight - Pong::kPaddleH / 2, p.playerY()); +} + +void test_pong_a_return_counts_and_speeds_the_ball_up() { + Pong p(5); + float served = Pong::kServeSpeed; + // Follow the ball until the player returns it once. + int before = p.rally(); + for (int i = 0; i < 100000 && p.rally() == before; i++) { + int move = p.ballY() < p.playerY() - 2 ? -1 : p.ballY() > p.playerY() + 2 ? 1 : 0; + p.step(0.01f, move); + } + TEST_ASSERT_EQUAL(1, p.rally()); + TEST_ASSERT_EQUAL(1, p.longestRally()); + TEST_ASSERT_TRUE(p.speed() > served); + TEST_ASSERT_TRUE(p.speed() <= Pong::kMaxSpeed); +} + +void test_pong_a_miss_is_a_point_for_the_other_side() { + Pong p(9); + // The player never moves from the top corner: sooner or later the ball gets past. + for (int i = 0; i < 300; i++) p.step(0.01f, -1); + while (p.deviceScore() == 0 && p.phase() != Pong::Phase::Over) p.step(0.01f, -1); + TEST_ASSERT_EQUAL(1, p.deviceScore()); + TEST_ASSERT_TRUE(p.phase() == Pong::Phase::Serve); // served again, from the middle + TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kWidth / 2, p.ballX()); + TEST_ASSERT_EQUAL(0, p.rally()); +} + +void test_pong_ends_at_seven() { + Pong p(11); + uint8_t e = playPong(p, false, Pong::Over); // a player who does nothing loses + TEST_ASSERT_TRUE(e & Pong::Over); + TEST_ASSERT_TRUE(p.phase() == Pong::Phase::Over); + TEST_ASSERT_EQUAL(Pong::kWinScore, std::max(p.playerScore(), p.deviceScore())); + TEST_ASSERT_EQUAL(0, p.step(1.0f, 1)); // nothing moves once it is over + p.start(); + TEST_ASSERT_EQUAL(0, p.deviceScore()); + TEST_ASSERT_TRUE(p.phase() == Pong::Phase::Serve); +} + +void test_pong_the_device_can_be_beaten() { + // A player who always meets the ball: the device, slower than a steep fast ball, must lose + // points over a few games. + int playerPoints = 0; + for (uint32_t seed = 1; seed <= 5; seed++) { + Pong p(seed); + playPong(p, true, Pong::Over, 2000000); + playerPoints += p.playerScore(); + } + TEST_ASSERT_TRUE(playerPoints > 0); +} + +void test_pong_the_device_paddle_has_a_top_speed() { + Pong p(13); + float before = p.deviceY(); + for (int i = 0; i < 1000; i++) { + p.step(0.01f, 0); + TEST_ASSERT_TRUE(std::fabs(p.deviceY() - before) <= Pong::kDeviceSpeed * 0.01f + 0.01f); + before = p.deviceY(); + if (p.phase() == Pong::Phase::Over) p.start(); + } +} + +// ---- Snake ---- + +namespace { +void moves(Snake& s, int n) { + for (int i = 0; i < n && s.alive(); i++) s.step(s.intervalMs()); +} +} // namespace + +void test_snake_starts_in_the_middle_three_long() { + Snake s(1); + TEST_ASSERT_TRUE(s.alive()); + TEST_ASSERT_EQUAL(3, s.body().size()); + TEST_ASSERT_EQUAL(Snake::kCols / 2, s.body().front().col); + TEST_ASSERT_EQUAL(Snake::kRows / 2, s.body().front().row); + TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Right); + for (auto& c : s.body()) TEST_ASSERT_FALSE(c == s.food()); +} + +void test_snake_moves_at_its_pace() { + Snake s(1); + s.putFood({0, 0}); + TEST_ASSERT_EQUAL(0, s.step(Snake::kSlowestMs - 1)); + TEST_ASSERT_EQUAL(Snake::Moved, s.step(1)); + TEST_ASSERT_EQUAL(Snake::kCols / 2 + 1, s.body().front().col); + TEST_ASSERT_EQUAL(3, s.body().size()); +} + +void test_snake_turns_but_not_back_on_itself() { + Snake s(1); + s.putFood({0, 0}); + s.turn(Snake::Dir::Left); // straight back: not a turn + moves(s, 1); + TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Right); + s.turn(Snake::Dir::Up); + moves(s, 1); + TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Up); + TEST_ASSERT_EQUAL(Snake::kRows / 2 - 1, s.body().front().row); +} + +void test_snake_remembers_a_quick_corner() { + Snake s(1); + s.putFood({0, 0}); + s.turn(Snake::Dir::Up); // both before the next move + s.turn(Snake::Dir::Left); // would be "back" for a snake heading right, but not after the first + s.turn(Snake::Dir::Down); // a third is not remembered + moves(s, 1); + TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Up); + moves(s, 1); + TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Left); + moves(s, 1); + TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Left); + TEST_ASSERT_TRUE(s.alive()); +} + +void test_snake_eats_grows_and_gets_new_food() { + Snake s(1); + Snake::Cell head = s.body().front(); + s.putFood({static_cast(head.col + 1), head.row}); + uint8_t e = s.step(s.intervalMs()); + TEST_ASSERT_TRUE(e & Snake::Ate); + TEST_ASSERT_EQUAL(1, s.score()); + TEST_ASSERT_EQUAL(4, s.body().size()); + for (auto& c : s.body()) TEST_ASSERT_FALSE(c == s.food()); + TEST_ASSERT_TRUE(s.food().col >= 0 && s.food().col < Snake::kCols && s.food().row >= 0 && s.food().row < Snake::kRows); +} + +void test_snake_dies_at_a_wall() { + Snake s(1); + s.putFood({0, 0}); + uint8_t last = 0; + for (int i = 0; i < Snake::kCols && s.alive(); i++) last = s.step(s.intervalMs()); + TEST_ASSERT_FALSE(s.alive()); + TEST_ASSERT_TRUE(last & Snake::Died); + TEST_ASSERT_EQUAL(Snake::kCols - 1, s.body().front().col); // it stopped at the edge + TEST_ASSERT_EQUAL(3, s.body().size()); // whole + TEST_ASSERT_EQUAL(0, s.step(1000)); +} + +void test_snake_dies_on_itself() { + Snake s(1); + // Fed four times in a row, it is long enough to bite itself in a tight turn. + for (int i = 0; i < 4; i++) { + Snake::Cell head = s.body().front(); + s.putFood({static_cast(head.col + 1), head.row}); + s.step(s.intervalMs()); + } + s.putFood({0, 0}); + TEST_ASSERT_EQUAL(7, s.body().size()); + s.turn(Snake::Dir::Down); + moves(s, 1); + s.turn(Snake::Dir::Left); + moves(s, 1); + s.turn(Snake::Dir::Up); + moves(s, 1); + TEST_ASSERT_FALSE(s.alive()); +} + +void test_snake_may_follow_its_own_tail() { + Snake s(1); + Snake::Cell head = s.body().front(); + s.putFood({static_cast(head.col + 1), head.row}); + s.step(s.intervalMs()); // four long: a square of 2 x 2 is its own tail chased + s.putFood({0, 0}); + for (auto d : {Snake::Dir::Down, Snake::Dir::Left, Snake::Dir::Up, Snake::Dir::Right, Snake::Dir::Down}) { + s.turn(d); + moves(s, 1); + } + TEST_ASSERT_TRUE(s.alive()); +} + +void test_snake_gets_faster_as_it_eats() { + Snake s(1); + uint32_t start = s.intervalMs(); + TEST_ASSERT_EQUAL(Snake::kSlowestMs, start); + for (int i = 0; i < Snake::kFoodsAStep; i++) { + Snake::Cell head = s.body().front(); + s.putFood({static_cast(head.col + 1), head.row}); + s.step(s.intervalMs()); + } + TEST_ASSERT_EQUAL(start - Snake::kFasterMs, s.intervalMs()); +} + +void test_snake_the_same_seed_puts_the_food_in_the_same_places() { + Snake a(77), b(77), c(78); + TEST_ASSERT_TRUE(a.food() == b.food()); + bool differs = !(a.food() == c.food()); + for (int i = 0; i < 3; i++) { + for (Snake* s : {&a, &b, &c}) { + Snake::Cell head = s->body().front(); + s->putFood({static_cast(head.col + 1), head.row}); + s->step(s->intervalMs()); + } + TEST_ASSERT_TRUE(a.food() == b.food()); + differs |= !(a.food() == c.food()); + } + TEST_ASSERT_TRUE(differs); +} + +// ---- Tetris ---- + +namespace { +const TetrisInput kNothing; +TetrisInput press(bool TetrisInput::*key) { + TetrisInput in; + in.*key = true; + return in; +} +int filled(const Tetris& t) { + int n = 0; + for (int r = 0; r < Tetris::kRows; r++) + for (int c = 0; c < Tetris::kCols; c++) n += t.cell(c, r) != 0; + return n; +} +// A row filled but for one column. +void fillRow(Tetris& t, int row, int gap) { + for (int c = 0; c < Tetris::kCols; c++) + if (c != gap) t.put(c, row, 7); +} +} // namespace + +void test_tetris_deals_each_piece_once_in_seven() { + Tetris t(5); + for (int bag = 0; bag < 4; bag++) { + std::set seen; + for (int i = 0; i < 7; i++) { + seen.insert(t.piece().kind); + t.step(0, press(&TetrisInput::hardDrop)); + if (t.over()) t.start(), i = -1, seen.clear(); // start over on a full well + } + TEST_ASSERT_EQUAL(7, seen.size()); + t.start(); + } +} + +void test_tetris_shows_the_piece_that_comes_next() { + Tetris t(9); + uint8_t next = t.next(); + t.step(0, press(&TetrisInput::hardDrop)); + TEST_ASSERT_EQUAL(next, t.piece().kind); +} + +void test_tetris_a_piece_falls_at_the_levels_pace() { + Tetris t(1); + t.give(3); // a T + int row = t.piece().row; + TEST_ASSERT_EQUAL(0, t.step(Tetris::kSlowestMs - 1, kNothing)); + TEST_ASSERT_EQUAL(row, t.piece().row); + TEST_ASSERT_EQUAL(Tetris::Moved, t.step(1, kNothing)); + TEST_ASSERT_EQUAL(row + 1, t.piece().row); +} + +void test_tetris_a_soft_drop_is_faster_and_scores() { + Tetris t(1); + t.give(3); + int row = t.piece().row; + t.step(Tetris::kSoftMs * 3, press(&TetrisInput::soft)); + TEST_ASSERT_EQUAL(row + 3, t.piece().row); + TEST_ASSERT_EQUAL(3, t.score()); +} + +void test_tetris_a_held_soft_drop_stops_with_its_piece() { + Tetris t(1); + t.give(2); + TetrisInput soft = press(&TetrisInput::soft); + uint8_t all = 0; + for (int i = 0; i < 40 && !(all & Tetris::Locked); i++) all |= t.step(Tetris::kSoftMs, soft); + TEST_ASSERT_TRUE(all & Tetris::Locked); + // The key is still down: the next piece falls at the level's pace, not the fast one. + int row = t.piece().row, score = t.score(); + t.step(Tetris::kSoftMs * 4, soft); + TEST_ASSERT_EQUAL(row, t.piece().row); + TEST_ASSERT_EQUAL(score, t.score()); + t.step(0, kNothing); // let go, and pressed again: it works + t.step(Tetris::kSoftMs * 2, soft); + TEST_ASSERT_TRUE(t.piece().row > row); +} + +void test_tetris_moves_stop_at_the_walls() { + Tetris t(1); + t.give(2); // an O: two wide + for (int i = 0; i < 20; i++) { + t.step(0, press(&TetrisInput::left)); + t.step(0, kNothing); // released: the next press is a press + } + int leftmost = Tetris::kCols; + for (auto& c : Tetris::cells(t.piece())) leftmost = std::min(leftmost, c.col); + TEST_ASSERT_EQUAL(0, leftmost); + for (int i = 0; i < 20; i++) { + t.step(0, press(&TetrisInput::right)); + t.step(0, kNothing); + } + int rightmost = 0; + for (auto& c : Tetris::cells(t.piece())) rightmost = std::max(rightmost, c.col); + TEST_ASSERT_EQUAL(Tetris::kCols - 1, rightmost); +} + +void test_tetris_a_held_key_repeats() { + Tetris t(1); + t.give(2); + int col = t.piece().col; + TetrisInput left = press(&TetrisInput::left); + t.step(0, left); // the press: one step at once + TEST_ASSERT_EQUAL(col - 1, t.piece().col); + t.step(Tetris::kRepeatAfterMs - 1, left); + TEST_ASSERT_EQUAL(col - 1, t.piece().col); // not yet + t.step(1, left); + TEST_ASSERT_EQUAL(col - 2, t.piece().col); + t.step(Tetris::kRepeatEveryMs, left); + TEST_ASSERT_EQUAL(col - 3, t.piece().col); + TetrisInput both = left; + both.right = true; // both held: neither + t.step(Tetris::kRepeatEveryMs * 3, both); + TEST_ASSERT_EQUAL(col - 3, t.piece().col); +} + +void test_tetris_turns_and_turns_back() { + Tetris t(1); + t.give(3); + auto before = Tetris::cells(t.piece()); + TEST_ASSERT_EQUAL(Tetris::Turned, t.step(0, press(&TetrisInput::rotate))); + TEST_ASSERT_EQUAL(1, t.piece().turn); + t.step(0, press(&TetrisInput::rotateBack)); + TEST_ASSERT_EQUAL(0, t.piece().turn); + auto after = Tetris::cells(t.piece()); + for (int i = 0; i < 4; i++) { + TEST_ASSERT_EQUAL(before[i].col, after[i].col); + TEST_ASSERT_EQUAL(before[i].row, after[i].row); + } + for (int i = 0; i < 4; i++) t.step(0, press(&TetrisInput::rotate)); + TEST_ASSERT_EQUAL(0, t.piece().turn); // four quarter turns: as it was +} + +void test_tetris_a_turn_against_a_wall_shifts_the_piece() { + Tetris t(1); + t.give(1); // the long one + t.step(0, press(&TetrisInput::rotate)); // upright + for (int i = 0; i < 10; i++) { + t.step(0, press(&TetrisInput::right)); + t.step(0, kNothing); + } + for (auto& c : Tetris::cells(t.piece())) TEST_ASSERT_EQUAL(Tetris::kCols - 1, c.col); // against the right wall + TEST_ASSERT_EQUAL(Tetris::Turned, t.step(0, press(&TetrisInput::rotate))); // flat again: it needs four columns + for (auto& c : Tetris::cells(t.piece())) TEST_ASSERT_TRUE(c.col >= Tetris::kCols - 4 && c.col < Tetris::kCols); +} + +void test_tetris_a_turn_with_no_room_does_not_happen() { + Tetris t(1); + t.give(1); + t.step(0, press(&TetrisInput::rotate)); + // Walled in on both sides, a column wide. + int col = Tetris::cells(t.piece())[0].col; + for (int r = 0; r < Tetris::kRows; r++) + for (int c = 0; c < Tetris::kCols; c++) + if (c != col) t.put(c, r, 7); + TEST_ASSERT_EQUAL(0, t.step(0, press(&TetrisInput::rotate))); + TEST_ASSERT_EQUAL(1, t.piece().turn); +} + +void test_tetris_a_hard_drop_lands_locks_and_scores() { + Tetris t(1); + t.give(2); // an O + int from = t.piece().row; + uint8_t e = t.step(0, press(&TetrisInput::hardDrop)); + TEST_ASSERT_TRUE(e & Tetris::Locked); + TEST_ASSERT_EQUAL(4, filled(t)); + TEST_ASSERT_NOT_EQUAL(0, t.cell(4, Tetris::kRows - 1)); + TEST_ASSERT_NOT_EQUAL(0, t.cell(5, Tetris::kRows - 2)); + TEST_ASSERT_EQUAL(2 * (Tetris::kRows - 2 - from), t.score()); // two a row fallen + TEST_ASSERT_EQUAL(0, t.lastCleared()); +} + +void test_tetris_a_piece_locks_when_it_can_fall_no_more() { + Tetris t(1); + t.give(2); + uint8_t all = 0; + for (int i = 0; i < Tetris::kRows + 2 && !(all & Tetris::Locked); i++) all |= t.step(Tetris::kSlowestMs, kNothing); + TEST_ASSERT_TRUE(all & Tetris::Locked); + TEST_ASSERT_EQUAL(4, filled(t)); +} + +void test_tetris_a_full_row_goes_and_the_rest_comes_down() { + Tetris t(1); + fillRow(t, Tetris::kRows - 1, 4); // the gap where the O will not fit: use the long one upright + t.put(0, Tetris::kRows - 2, 5); // something above, to see it come down + t.give(1); + t.step(0, press(&TetrisInput::rotate)); // upright, in column 5 + t.step(0, press(&TetrisInput::left)); // column 4 + uint8_t e = t.step(0, press(&TetrisInput::hardDrop)); + TEST_ASSERT_TRUE(e & Tetris::Lines); + TEST_ASSERT_EQUAL(1, t.lastCleared()); + TEST_ASSERT_EQUAL(1, t.lines()); + TEST_ASSERT_EQUAL(5, t.cell(0, Tetris::kRows - 1)); // what was above + TEST_ASSERT_EQUAL(1, t.cell(4, Tetris::kRows - 1)); // and the rest of the long piece + TEST_ASSERT_EQUAL(0, t.cell(1, Tetris::kRows - 1)); + TEST_ASSERT_EQUAL(1 + 3, filled(t)); +} + +void test_tetris_four_rows_at_once_score_most() { + Tetris t(1); + for (int r = Tetris::kRows - 4; r < Tetris::kRows; r++) fillRow(t, r, 9); + t.give(1); + t.step(0, press(&TetrisInput::rotate)); + for (int i = 0; i < 10; i++) { + t.step(0, press(&TetrisInput::right)); + t.step(0, kNothing); + } + int before = t.score(); + uint8_t e = t.step(0, press(&TetrisInput::hardDrop)); + TEST_ASSERT_TRUE(e & Tetris::Lines); + TEST_ASSERT_EQUAL(4, t.lastCleared()); + TEST_ASSERT_EQUAL(0, filled(t)); + int dropped = (t.score() - before) - 800; + TEST_ASSERT_TRUE(dropped > 0 && dropped <= 2 * Tetris::kRows); // 800 for the four, and the drop +} + +void test_tetris_ten_lines_are_a_level_and_a_faster_fall() { + Tetris t(1); + TEST_ASSERT_EQUAL(0, t.level()); + TEST_ASSERT_EQUAL(Tetris::kSlowestMs, t.fallMs()); + uint8_t all = 0; + for (int i = 0; i < 3; i++) { // 4 + 4 + 4 + for (int r = Tetris::kRows - 4; r < Tetris::kRows; r++) fillRow(t, r, 9); + t.give(1); + t.step(0, press(&TetrisInput::rotate)); + for (int k = 0; k < 10; k++) { + t.step(0, press(&TetrisInput::right)); + t.step(0, kNothing); + } + all |= t.step(0, press(&TetrisInput::hardDrop)); + } + TEST_ASSERT_EQUAL(12, t.lines()); + TEST_ASSERT_EQUAL(1, t.level()); + TEST_ASSERT_TRUE(all & Tetris::LevelUp); + TEST_ASSERT_EQUAL(Tetris::kSlowestMs * 85 / 100, t.fallMs()); +} + +void test_tetris_the_fall_never_gets_faster_than_its_limit() { + Tetris t(1); + for (int i = 0; i < 80; i++) { + for (int r = Tetris::kRows - 4; r < Tetris::kRows; r++) fillRow(t, r, 9); + t.give(1); + t.step(0, press(&TetrisInput::rotate)); + for (int k = 0; k < 10; k++) { + t.step(0, press(&TetrisInput::right)); + t.step(0, kNothing); + } + t.step(0, press(&TetrisInput::hardDrop)); + } + TEST_ASSERT_TRUE(t.level() >= 30); + TEST_ASSERT_EQUAL(Tetris::kFastestMs, t.fallMs()); +} + +void test_tetris_is_over_when_a_piece_has_no_room() { + Tetris t(3); + uint8_t last = 0; + for (int i = 0; i < 40 && !t.over(); i++) last = t.step(0, press(&TetrisInput::hardDrop)); // all in the middle + TEST_ASSERT_TRUE(t.over()); + TEST_ASSERT_TRUE(last & Tetris::Over); + TEST_ASSERT_EQUAL(0, t.step(1000, press(&TetrisInput::hardDrop))); + t.start(); + TEST_ASSERT_FALSE(t.over()); + TEST_ASSERT_EQUAL(0, filled(t)); + TEST_ASSERT_EQUAL(0, t.score()); +} + +void test_tetris_the_same_seed_deals_the_same_pieces() { + Tetris a(21), b(21), c(22); + bool differs = false; + for (int i = 0; i < 14; i++) { + TEST_ASSERT_EQUAL(a.piece().kind, b.piece().kind); + differs |= a.piece().kind != c.piece().kind; + for (Tetris* t : {&a, &b, &c}) { + t->step(0, press(&TetrisInput::hardDrop)); + if (t->over()) t->start(); + } + } + TEST_ASSERT_TRUE(differs); +} + +void test_tetris_every_piece_in_every_turn_has_four_cells_in_its_box() { + for (uint8_t kind = 1; kind <= 7; kind++) + for (uint8_t turn = 0; turn < 4; turn++) { + Tetris::Piece p{kind, turn, 0, 0}; + std::set where; + for (auto& c : Tetris::cells(p)) { + TEST_ASSERT_TRUE(c.col >= 0 && c.col < 4 && c.row >= 0 && c.row < 4); + where.insert(c.row * 4 + c.col); + } + TEST_ASSERT_EQUAL(4, where.size()); + } +} + +// ---- The tune ---- + +void test_a_tune_gives_its_notes_in_time() { + static const Note kThree[] = {{440, 2}, {0, 1}, {880, 1}}; + TunePlayer p([](size_t i) { return kThree[i]; }, 3, 100); + Note n{}; + uint32_t ms = 0; + TEST_ASSERT_TRUE(p.step(0, n, ms)); // the first note at once + TEST_ASSERT_EQUAL(440, n.hz); + TEST_ASSERT_EQUAL(200, ms); + TEST_ASSERT_FALSE(p.step(199, n, ms)); + TEST_ASSERT_TRUE(p.step(1, n, ms)); // a rest is a note too: the caller plays nothing + TEST_ASSERT_EQUAL(0, n.hz); + TEST_ASSERT_EQUAL(100, ms); + TEST_ASSERT_TRUE(p.step(100, n, ms)); + TEST_ASSERT_EQUAL(880, n.hz); + TEST_ASSERT_TRUE(p.step(100, n, ms)); // and round again + TEST_ASSERT_EQUAL(440, n.hz); + TEST_ASSERT_EQUAL(0, p.position()); +} + +void test_a_tune_keeps_time_over_uneven_steps() { + static const Note kTwo[] = {{440, 1}, {880, 1}}; + TunePlayer p([](size_t i) { return kTwo[i]; }, 2, 100); + Note n{}; + uint32_t ms = 0; + p.step(0, n, ms); + int notes = 1; + for (int i = 0; i < 300; i++) notes += p.step(33, n, ms); // 9.9 seconds in steps of 33 ms + TEST_ASSERT_EQUAL(100, notes); // one every 100 ms: the 33s do not add up to a drift +} + +void test_a_tune_skips_what_a_long_gap_missed() { + static const Note kFour[] = {{100, 1}, {200, 1}, {300, 1}, {400, 1}}; + TunePlayer p([](size_t i) { return kFour[i]; }, 4, 100); + Note n{}; + uint32_t ms = 0; + p.step(0, n, ms); + TEST_ASSERT_TRUE(p.step(250, n, ms)); // 200 and half of 300 went by + TEST_ASSERT_EQUAL(300, n.hz); + TEST_ASSERT_FALSE(p.step(49, n, ms)); + TEST_ASSERT_TRUE(p.step(1, n, ms)); + TEST_ASSERT_EQUAL(400, n.hz); + p.restart(); + TEST_ASSERT_TRUE(p.step(0, n, ms)); + TEST_ASSERT_EQUAL(100, n.hz); +} + +void test_korobeiniki_is_whole_bars() { + // Each part is eight bars of eight eighths, and it begins on its famous E. + int first = 0, second = 0; + for (size_t i = 0; i < korobeiniki::kFirstCount; i++) first += korobeiniki::kFirst[i].eighths; + for (size_t i = 0; i < korobeiniki::kSecondCount; i++) second += korobeiniki::kSecond[i].eighths; + TEST_ASSERT_EQUAL(64, first); + TEST_ASSERT_EQUAL(64, second); + TEST_ASSERT_EQUAL(659, korobeiniki::at(0).hz); + TEST_ASSERT_EQUAL(659, korobeiniki::at(korobeiniki::kFirstCount).hz); // the tune again + TEST_ASSERT_EQUAL(659, korobeiniki::at(2 * korobeiniki::kFirstCount).hz); // then the slow part + TEST_ASSERT_EQUAL(4, korobeiniki::at(2 * korobeiniki::kFirstCount).eighths); + TEST_ASSERT_EQUAL(0, korobeiniki::at(korobeiniki::kCount - 1).hz); // and a breath before it starts over +} + +void test_the_tune_hurries_with_the_level() { + TEST_ASSERT_EQUAL(200, korobeiniki::eighthMsAt(0)); + TEST_ASSERT_EQUAL(192, korobeiniki::eighthMsAt(1)); + TEST_ASSERT_EQUAL(160, korobeiniki::eighthMsAt(5)); + TEST_ASSERT_EQUAL(korobeiniki::kFastestEighthMs, korobeiniki::eighthMsAt(12)); + TEST_ASSERT_EQUAL(korobeiniki::kFastestEighthMs, korobeiniki::eighthMsAt(99)); + for (int level = 0; level < 20; level++) TEST_ASSERT_TRUE(korobeiniki::eighthMsAt(level + 1) <= korobeiniki::eighthMsAt(level)); +} + +void test_a_new_pace_starts_at_the_next_note() { + static const Note kTwo[] = {{440, 1}, {880, 1}}; + TunePlayer p([](size_t i) { return kTwo[i]; }, 2, 100); + Note n{}; + uint32_t ms = 0; + p.step(0, n, ms); + p.setEighthMs(50); + TEST_ASSERT_FALSE(p.step(99, n, ms)); // the note that had begun keeps its length + TEST_ASSERT_TRUE(p.step(1, n, ms)); + TEST_ASSERT_EQUAL(50, ms); + TEST_ASSERT_TRUE(p.step(50, n, ms)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_pong_starts_with_a_serve_from_the_middle); + RUN_TEST(test_pong_the_same_seed_is_the_same_game); + RUN_TEST(test_pong_the_ball_stays_between_the_walls); + RUN_TEST(test_pong_a_paddle_stays_on_the_field); + RUN_TEST(test_pong_a_return_counts_and_speeds_the_ball_up); + RUN_TEST(test_pong_a_miss_is_a_point_for_the_other_side); + RUN_TEST(test_pong_ends_at_seven); + RUN_TEST(test_pong_the_device_can_be_beaten); + RUN_TEST(test_pong_the_device_paddle_has_a_top_speed); + RUN_TEST(test_snake_starts_in_the_middle_three_long); + RUN_TEST(test_snake_moves_at_its_pace); + RUN_TEST(test_snake_turns_but_not_back_on_itself); + RUN_TEST(test_snake_remembers_a_quick_corner); + RUN_TEST(test_snake_eats_grows_and_gets_new_food); + RUN_TEST(test_snake_dies_at_a_wall); + RUN_TEST(test_snake_dies_on_itself); + RUN_TEST(test_snake_may_follow_its_own_tail); + RUN_TEST(test_snake_gets_faster_as_it_eats); + RUN_TEST(test_snake_the_same_seed_puts_the_food_in_the_same_places); + RUN_TEST(test_tetris_deals_each_piece_once_in_seven); + RUN_TEST(test_tetris_shows_the_piece_that_comes_next); + RUN_TEST(test_tetris_a_piece_falls_at_the_levels_pace); + RUN_TEST(test_tetris_a_soft_drop_is_faster_and_scores); + RUN_TEST(test_tetris_a_held_soft_drop_stops_with_its_piece); + RUN_TEST(test_tetris_moves_stop_at_the_walls); + RUN_TEST(test_tetris_a_held_key_repeats); + RUN_TEST(test_tetris_turns_and_turns_back); + RUN_TEST(test_tetris_a_turn_against_a_wall_shifts_the_piece); + RUN_TEST(test_tetris_a_turn_with_no_room_does_not_happen); + RUN_TEST(test_tetris_a_hard_drop_lands_locks_and_scores); + RUN_TEST(test_tetris_a_piece_locks_when_it_can_fall_no_more); + RUN_TEST(test_tetris_a_full_row_goes_and_the_rest_comes_down); + RUN_TEST(test_tetris_four_rows_at_once_score_most); + RUN_TEST(test_tetris_ten_lines_are_a_level_and_a_faster_fall); + RUN_TEST(test_tetris_the_fall_never_gets_faster_than_its_limit); + RUN_TEST(test_tetris_is_over_when_a_piece_has_no_room); + RUN_TEST(test_tetris_the_same_seed_deals_the_same_pieces); + RUN_TEST(test_tetris_every_piece_in_every_turn_has_four_cells_in_its_box); + RUN_TEST(test_a_tune_gives_its_notes_in_time); + RUN_TEST(test_a_tune_keeps_time_over_uneven_steps); + RUN_TEST(test_a_tune_skips_what_a_long_gap_missed); + RUN_TEST(test_korobeiniki_is_whole_bars); + RUN_TEST(test_the_tune_hurries_with_the_level); + RUN_TEST(test_a_new_pace_starts_at_the_next_note); + return UNITY_END(); +}