#include "apps/games_app.h" #include #include #include #include #include #include "app_keys.h" #include "ui/fonts.h" #include "ui/icons.h" #include "ui/theme.h" #include "ui/widgets.h" namespace roro { using namespace games; namespace { constexpr uint32_t kFrameMs = 33; // 30 pictures a second while a Game runs constexpr uint32_t kTapMs = 70; // how long a tap moves Pong's paddle constexpr int kCell = 6; // Snake's and Tetris's cells, in pixels const char* const kNames[3] = {"Pong", "Snake", "Tetris"}; const char* const kBestKeys[3] = {"best_pong", "best_snake", "best_tetris"}; // The seven pieces in the theme's seven accents: they follow the theme chosen. uint16_t pieceColor(uint8_t kind) { switch (kind) { case 1: return theme::kAccent; case 2: return theme::kYellow; case 3: return theme::kViolet; case 4: return theme::kGood; case 5: return theme::kRed; case 6: return theme::kMessage; default: return theme::kWarning; } } } // namespace // The speaker mixes its channels: the tune has one, the beeps another, so a line cleared does // not cut a note short. constexpr int kTuneChannel = 0, kBeepChannel = 1; constexpr uint32_t kBreathMs = 25; // of silence at the end of each note, so two alike are two void GamesApp::beep(int hz, int ms) { if (settings_.getBool(Setting::Sound)) M5Cardputer.Speaker.tone(hz, ms, kBeepChannel, true); } bool GamesApp::musicOn() { if (music_ < 0) { int32_t stored = 1; music_ = store_.getInt("tetris_music", stored) ? (stored != 0) : 1; // on, until switched off } return music_ == 1; } // The tune's loudness, in eight steps. The speaker is set for short beeps, at a quarter of what // it can do: for the tune it is turned up all the way, and each channel's own volume does the // rest, the tune's by the step chosen, the others' a quarter so a beep stays as loud as it was. constexpr int kLoudest = 8, kUsualVolume = 4; // The top is four fifths of all the speaker can do: above that the small speaker distorts. constexpr uint8_t kStepVolume[kLoudest] = {5, 10, 16, 26, 45, 77, 128, 204}; // 64 is the speaker's usual int GamesApp::volume() { if (volume_ < 0) { int32_t stored = kUsualVolume; volume_ = store_.getInt("tetris_volume", stored) && stored >= 1 && stored <= kLoudest ? stored : kUsualVolume; } return volume_; } void GamesApp::setVolume(int level) { level = std::clamp(level, 1, kLoudest); if (level == volume()) return; volume_ = level; store_.putInt("tetris_volume", level); if (masterWas_ >= 0) M5Cardputer.Speaker.setChannelVolume(kTuneChannel, kStepVolume[level - 1]); } void GamesApp::loud(bool on) { auto& speaker = M5Cardputer.Speaker; if (on == (masterWas_ >= 0)) return; if (on) { masterWas_ = speaker.getVolume(); speaker.setAllChannelVolume(static_cast(masterWas_)); // times a master of 255: as before speaker.setChannelVolume(kTuneChannel, kStepVolume[volume() - 1]); speaker.setVolume(255); } else { speaker.setVolume(static_cast(masterWas_)); speaker.setAllChannelVolume(255); masterWas_ = -1; } } void GamesApp::quiet() { M5Cardputer.Speaker.stop(kTuneChannel); loud(false); } int GamesApp::best(Which w) { if (best_[w] < 0) { int32_t stored = 0; best_[w] = store_.getInt(kBestKeys[w], stored) && stored > 0 ? stored : 0; } return best_[w]; } void GamesApp::onEnter() { lastMs_ = millis(); requestRedraw(); } void GamesApp::pause() { if (which_ != Menu && state_[which_] == State::Playing) state_[which_] = State::Paused; quiet(); } void GamesApp::onExit() { pause(); } void GamesApp::begin() { uint32_t seed = esp_random(); if (which_ == Pong) pong_.reset(new games::Pong(seed)); else if (which_ == Snake) snake_.reset(new games::Snake(seed)); else if (which_ == Tetris) tetris_.reset(new games::Tetris(seed)); state_[which_] = State::Playing; tune_.restart(); newBest_ = false; tapped_ = TetrisInput(); tapMove_ = 0; lastMs_ = millis(); } void GamesApp::ended(int score) { state_[which_] = State::Over; quiet(); newBest_ = score > best(which_); if (newBest_) { best_[which_] = score; store_.putInt(kBestKeys[which_], score); } beep(newBest_ ? 1320 : 220, 250); } bool GamesApp::onKey(const KeyEvent& e) { requestRedraw(); if (which_ == Menu) { switch (e.key) { case Key::Left: chosen_ = (chosen_ + 2) % 3; return true; case Key::Right: chosen_ = (chosen_ + 1) % 3; return true; case Key::Select: which_ = static_cast(chosen_); lastMs_ = millis(); return true; default: return false; // Back leaves the App } } State& state = state_[which_]; if (which_ == Tetris && e.key == Key::Char && e.ch == '0') { // the tune, on or off, whatever the game is doing music_ = musicOn() ? 0 : 1; store_.putInt("tetris_music", music_); if (!music_) quiet(); return true; } if (which_ == Tetris && e.key == Key::Char && (e.ch == '8' || e.ch == '9')) { // quieter, louder setVolume(volume() + (e.ch == '9' ? 1 : -1)); return true; } if (e.key == Key::Back) { // to the list; the Game waits, paused pause(); which_ = Menu; return true; } if (state == State::Ready || state == State::Over) { if (e.key == Key::Select) begin(); return true; } if (state == State::Paused) { // any key carries on state = State::Playing; lastMs_ = millis(); return true; } if (e.key == Key::Char && (e.ch == 'p' || e.ch == 'P')) { pause(); return true; } return which_ == Pong ? pongKey(e) : which_ == Snake ? snakeKey(e) : tetrisKey(e); } bool GamesApp::pongKey(const KeyEvent& e) { if (e.key != Key::Up && e.key != Key::Down) return true; tapMove_ = e.key == Key::Up ? -1 : 1; tapUntilMs_ = millis() + kTapMs; return true; } bool GamesApp::snakeKey(const KeyEvent& e) { switch (e.key) { case Key::Up: snake_->turn(Snake::Dir::Up); break; case Key::Down: snake_->turn(Snake::Dir::Down); break; case Key::Left: snake_->turn(Snake::Dir::Left); break; case Key::Right: snake_->turn(Snake::Dir::Right); break; default: break; } return true; } bool GamesApp::tetrisKey(const KeyEvent& e) { switch (e.key) { case Key::Up: tapped_.rotate = true; break; case Key::Left: tapped_.left = true; break; case Key::Right: tapped_.right = true; break; case Key::Down: tapped_.soft = true; break; case Key::Char: if (e.ch == 'z' || e.ch == 'Z') tapped_.rotateBack = true; else if (e.ch == '1') tapped_.hardDrop = true; // far from the arrows: not hit by mistake break; default: break; } return true; } void GamesApp::update(uint32_t nowMs) { uint32_t ms = std::min(nowMs - lastMs_, 100); if (which_ == Menu || state_[which_] != State::Playing) { lastMs_ = nowMs; return; } if (ms == 0) return; lastMs_ = nowMs; if (which_ == Pong) { int move = keys_.held(Key::Up) ? -1 : keys_.held(Key::Down) ? 1 : 0; if (!move && static_cast(tapUntilMs_ - nowMs) > 0) move = tapMove_; uint8_t events = pong_->step(ms / 1000.0f, move); if (events & Pong::Over) ended(pong_->longestRally()); else if (events & Pong::Point) beep(330, 120); else if (events & Pong::Hit) beep(880, 15); else if (events & Pong::Wall) beep(440, 10); } else if (which_ == Snake) { uint8_t events = snake_->step(ms); if (events & Snake::Died) ended(snake_->score()); else if (events & Snake::Ate) beep(990, 25); } else { TetrisInput in = tapped_; tapped_ = TetrisInput(); in.left |= keys_.held(Key::Left); in.right |= keys_.held(Key::Right); in.soft |= keys_.held(Key::Down); uint8_t events = tetris_->step(ms, in); if (events & Tetris::Over) ended(tetris_->score()); else if (events & Tetris::LevelUp) beep(1320, 120); else if (events & Tetris::Lines) beep(tetris_->lastCleared() == 4 ? 1320 : 880, 60); else if (events & Tetris::Locked) beep(220, 12); Note note{}; uint32_t length = 0; tune_.setEighthMs(korobeiniki::eighthMsAt(tetris_->level())); // it hurries as the game does if (state_[Tetris] == State::Playing && musicOn() && settings_.getBool(Setting::Sound) && tune_.step(ms, note, length) && note.hz) { loud(true); M5Cardputer.Speaker.tone(note.hz, length - kBreathMs, kTuneChannel, true); } } if (nowMs - lastDrawMs_ >= kFrameMs) { lastDrawMs_ = nowMs; requestRedraw(); } } void GamesApp::help(std::vector& out) const { switch (which_) { case Pong: return keys::add(out, keys::kGamesPong); case Snake: return keys::add(out, keys::kGamesSnake); case Tetris: return keys::add(out, keys::kGamesTetris); default: return keys::add(out, keys::kGames); } } const char* GamesApp::helpTitle() const { return which_ == Pong ? "Games: Pong" : which_ == Snake ? "Games: Snake" : which_ == Tetris ? "Games: Tetris" : nullptr; } // A box in the middle of the field: where the Game stands when it is not running. void GamesApp::drawBanner(Canvas& c, const std::string& title, const std::string& detail) { const auto& area = theme::kContent; const int w = 150, h = detail.empty() ? 24 : 40, x = (area.w - w) / 2, y = area.y + (area.h - h) / 2; c.fillRect(x, y, w, h, theme::kBackground); c.drawRect(x, y, w, h, theme::kAccent); c.setTextDatum(top_center); c.setFont(&fonts::bold); c.setTextColor(theme::kText); c.drawString(title.c_str(), area.w / 2, y + 5); if (!detail.empty()) { c.setFont(&fonts::body); c.setTextColor(theme::kMuted); c.drawString(detail.c_str(), area.w / 2, y + 21); } c.setTextDatum(top_left); } // The three Games as the Launcher shows Apps: an icon each, the chosen one on a cyan tile, its // name under the row. A Game waiting paused has the Launcher's orange dot. void GamesApp::drawMenu(Canvas& c) { using namespace theme; static const uint8_t* const kIcons[3] = {icons::pong, icons::snake, icons::tetris}; const int tileW = 60, tileH = 36, x0 = (kContent.w - 3 * tileW) / 2, y = kContent.y + 24; for (int i = 0; i < 3; i++) { int x = x0 + i * tileW, ix = x + (tileW - icons::kSize) / 2, iy = y + (tileH - icons::kSize) / 2; bool chosen = i == chosen_; if (chosen) { int tx = ix - 6, tw = icons::kSize + 12; c.fillRect(tx + 4, y, tw - 8, tileH, kAccent); c.fillRect(tx + 2, y + 2, tw - 4, tileH - 4, kAccent); c.fillRect(tx, y + 4, tw, tileH - 8, kAccent); } c.drawBitmap(ix, iy, kIcons[i], icons::kSize, icons::kSize, chosen ? kFaint : kAccent); if (state_[i] == State::Paused) { c.fillRect(ix + icons::kSize - 6, iy - 1, 10, 10, chosen ? kAccent : kBackground); c.fillRect(ix + icons::kSize - 4, iy + 1, 6, 6, kWarning); } } auto w = static_cast(chosen_); c.setTextDatum(top_center); c.setFont(&fonts::bold); c.setTextColor(kText); c.drawString(kNames[chosen_], kContent.w / 2, y + tileH + 8); int b = best(w); std::string under = state_[chosen_] == State::Paused ? "paused" : ""; if (b > 0) under += (under.empty() ? "" : ", ") + std::string(w == Pong ? "longest rally " : "best ") + std::to_string(b); c.setFont(&fonts::body); c.setTextColor(kMuted); c.drawString(under.c_str(), kContent.w / 2, y + tileH + 8 + kLineHeight + 2); c.setTextDatum(top_left); } void GamesApp::drawPong(Canvas& c) { const auto& area = theme::kContent; State state = state_[Pong]; const int top = area.y; for (int y = top + 2; y < top + static_cast(Pong::kHeight); y += 8) c.drawFastVLine(area.w / 2, y, 4, theme::kFaint); if (pong_) { char score[8]; c.setFont(&fonts::large); c.setTextColor(theme::kMuted); c.setTextDatum(top_center); std::snprintf(score, sizeof score, "%d", pong_->playerScore()); c.drawString(score, area.w / 2 - 24, top + 3); std::snprintf(score, sizeof score, "%d", pong_->deviceScore()); c.drawString(score, area.w / 2 + 24, top + 3); c.setTextDatum(top_left); const int ph = static_cast(Pong::kPaddleH), pw = static_cast(Pong::kPaddleW), m = static_cast(Pong::kMargin); c.fillRect(m, top + static_cast(pong_->playerY()) - ph / 2, pw, ph, theme::kAccent); c.fillRect(area.w - m - pw, top + static_cast(pong_->deviceY()) - ph / 2, pw, ph, theme::kWarning); const int b = static_cast(Pong::kBall); c.fillRect(static_cast(pong_->ballX()) - b / 2, top + static_cast(pong_->ballY()) - b / 2, b, b, theme::kText); } int b = best(Pong); std::string bestLine = b > 0 ? "longest rally " + std::to_string(b) : "first to 7"; if (state == State::Ready) drawBanner(c, "Pong", bestLine); else if (state == State::Paused) drawBanner(c, "Paused", ""); else if (state == State::Over) drawBanner(c, pong_->playerWon() ? "You win" : "The device wins", newBest_ ? "a new longest rally: " + std::to_string(b) : "rally " + std::to_string(pong_->longestRally()) + ", best " + std::to_string(b)); } void GamesApp::drawSnake(Canvas& c) { const auto& area = theme::kContent; State state = state_[Snake]; const int w = Snake::kCols * kCell, h = Snake::kRows * kCell, x0 = (area.w - w) / 2, y0 = area.y + area.h - h - 1; c.drawRect(x0 - 1, y0 - 1, w + 2, h + 2, theme::kFaint); c.setFont(&fonts::small); c.setTextColor(theme::kMuted); char line[32]; std::snprintf(line, sizeof line, "%d", snake_ ? snake_->score() : 0); c.drawString(line, x0, area.y); std::snprintf(line, sizeof line, "best %d", best(Snake)); c.setTextDatum(top_right); c.drawString(line, x0 + w, area.y); c.setTextDatum(top_left); if (snake_) { auto f = snake_->food(); c.fillRect(x0 + f.col * kCell + 1, y0 + f.row * kCell + 1, kCell - 2, kCell - 2, theme::kWarning); bool head = true; for (auto& cell : snake_->body()) { c.fillRect(x0 + cell.col * kCell, y0 + cell.row * kCell, kCell - 1, kCell - 1, head ? theme::kText : theme::kGood); head = false; } } if (state == State::Ready) drawBanner(c, "Snake", "the walls bite"); else if (state == State::Paused) drawBanner(c, "Paused", ""); else if (state == State::Over) drawBanner(c, "Game over", newBest_ ? "a new best: " + std::to_string(snake_->score()) : std::to_string(snake_->score()) + ", best " + std::to_string(best(Snake))); } void GamesApp::drawTetris(Canvas& c) { const auto& area = theme::kContent; State state = state_[Tetris]; const int w = Tetris::kCols * kCell, h = Tetris::kRows * kCell, x0 = (area.w - w) / 2, y0 = area.y + 1; c.drawFastVLine(x0 - 1, y0, h + 1, theme::kMuted); c.drawFastVLine(x0 + w, y0, h + 1, theme::kMuted); c.drawFastHLine(x0 - 1, y0 + h, w + 2, theme::kMuted); auto block = [&](int x, int y, uint8_t kind) { c.fillRect(x, y, kCell - 1, kCell - 1, pieceColor(kind)); }; c.setFont(&fonts::body); const int right = x0 + w + 12, left = 6; auto readout = [&](int x, int y, const char* label, int value) { c.setTextColor(theme::kMuted); c.drawString(label, x, y); c.setTextColor(theme::kText); c.drawString(std::to_string(value).c_str(), x, y + theme::kLineHeight); }; readout(left, area.y + 4, "Score", tetris_ ? tetris_->score() : 0); readout(left, area.y + 38, "Best", best(Tetris)); readout(left, area.y + 72, "Lines", tetris_ ? tetris_->lines() : 0); c.setTextColor(theme::kMuted); c.drawString("Next", right, area.y + 4); readout(right, area.y + 72, "Level", tetris_ ? tetris_->level() + 1 : 1); c.setFont(&fonts::small); c.setTextColor(theme::kMuted); char music[20]; std::snprintf(music, sizeof music, "music %d/%d", volume(), kLoudest); c.drawString(!settings_.getBool(Setting::Sound) ? "sound off" : musicOn() ? music : "music off", right, area.y + area.h - 10); c.setFont(&fonts::body); if (tetris_) { for (int row = 0; row < Tetris::kRows; row++) for (int col = 0; col < Tetris::kCols; col++) if (uint8_t kind = tetris_->cell(col, row)) block(x0 + col * kCell, y0 + row * kCell, kind); if (state != State::Over) for (auto& cell : Tetris::cells(tetris_->piece())) if (cell.row >= 0) block(x0 + cell.col * kCell, y0 + cell.row * kCell, tetris_->piece().kind); Tetris::Piece next{tetris_->next(), 0, 0, 0}; for (auto& cell : Tetris::cells(next)) block(right + cell.col * kCell, area.y + 22 + cell.row * kCell, next.kind); } if (state == State::Ready) drawBanner(c, "Tetris", ""); else if (state == State::Paused) drawBanner(c, "Paused", ""); else if (state == State::Over) drawBanner(c, "Game over", newBest_ ? "a new best: " + std::to_string(tetris_->score()) : std::to_string(tetris_->lines()) + " lines"); } void GamesApp::draw(Canvas& c) { c.setTextDatum(top_left); lastDrawMs_ = millis(); switch (which_) { case Pong: drawPong(c); break; case Snake: drawSnake(c); break; case Tetris: drawTetris(c); break; default: drawMenu(c); break; } } } // namespace roro