#include "apps/games_app.h" #include #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[6] = {"Pong", "Snake", "Tetris", "Minesweeper", "2048", "Asteroids"}; const char* const kBestKeys[6] = {"best_pong", "best_snake", "best_tetris", "best_mines", "best_2048", "best_asteroids"}; // Which way a lean turns the ship. The sensor's x runs to the device's right (the Compass's level // found that): leaning right brings it down, and that is a clockwise turn. constexpr float kLeanSign = -1.0f; constexpr float kLeanSeconds = 0.06f; // how fast the reading follows the hand constexpr float kImuEvery = 0.01f; // seconds between two readings: the sensor gives 100 a second constexpr int kMineCell = 12; // Minesweeper's cells, in pixels: a digit in each reads constexpr uint32_t kRunAfterMs = 220, kRunEveryMs = 60; // a held arrow runs the cursor along // 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 && which_ != Twenty48 && 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)); levelTilt(); } else if (which_ == Snake) snake_.reset(new games::Snake(seed)); else if (which_ == Tetris) tetris_.reset(new games::Tetris(seed)); else if (which_ == Mines) { mines_.reset(new games::Mines(seed)); cursorCol_ = games::Mines::kCols / 2; cursorRow_ = games::Mines::kRows / 2; runDir_ = -1; } else if (which_ == Twenty48) twenty48_.reset(new games::Twenty48(seed)); else if (which_ == Asteroids) { asteroids_.reset(new games::Asteroids(seed)); levelTilt(); fired_ = false; } state_[which_] = State::Playing; tune_.restart(); newBest_ = false; tapped_ = TetrisInput(); tapMove_ = 0; lastMs_ = millis(); } // `score`: what is kept as the best if it beats it. For Minesweeper it is the seconds a cleared // field took, where less is better, and 0 says the field was not cleared. void GamesApp::ended(int score) { state_[which_] = State::Over; quiet(); newBest_ = which_ == Mines ? score > 0 && (best(Mines) == 0 || score < best(Mines)) : 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_ + kGames - 1) % kGames; return true; case Key::Right: chosen_ = (chosen_ + 1) % kGames; 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 (which_ == Pong && e.key == Key::Char && e.ch == '0') { // the keys, or tilting: at any moment pongTilt_ = pongTilt() ? 0 : 1; store_.putInt("pong_tilt", pongTilt_); levelTilt(); // however it is held when switched is the middle return true; } if (which_ == Pong && e.key == Key::Char && e.ch == '9') { // tilting the other way up pongInverted_ = pongInverted() ? 0 : 1; store_.putInt("pong_invert", pongInverted_); return true; } if (e.key == Key::Back) { // to the other games; this one waits pause(); which_ = Menu; return true; } if (which_ == Twenty48 && state != State::Ready && state != State::Over) { // no clock: nothing to pause or carry on state = State::Playing; return twenty48Key(e); } 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) : which_ == Tetris ? tetrisKey(e) : which_ == Asteroids ? asteroidsKey(e) : minesKey(e); } void GamesApp::moveCursor(int dc, int dr) { cursorCol_ = std::clamp(cursorCol_ + dc, 0, games::Mines::kCols - 1); cursorRow_ = std::clamp(cursorRow_ + dr, 0, games::Mines::kRows - 1); } bool GamesApp::minesKey(const KeyEvent& e) { uint8_t events = 0; switch (e.key) { case Key::Up: moveCursor(0, -1); break; case Key::Down: moveCursor(0, 1); break; case Key::Left: moveCursor(-1, 0); break; case Key::Right: moveCursor(1, 0); break; case Key::Select: events = mines_->dig(cursorCol_, cursorRow_); break; case Key::Char: if (e.ch == 'f' || e.ch == 'F') events = mines_->flag(cursorCol_, cursorRow_); break; default: break; } if (events & games::Mines::Boom) ended(0); else if (events & games::Mines::Won) ended(std::max(1, static_cast(mines_->seconds()))); else if (events & games::Mines::Flagged) beep(660, 10); else if (events & games::Mines::Opened) beep(880, 8); return true; } bool GamesApp::asteroidsKey(const KeyEvent& e) { if (e.key == Key::Down) fired_ = true; // . fires; ; held is thrust, read in update() return true; } void GamesApp::readTilt() { if (!M5.Imu.update()) return; auto data = M5.Imu.getImuData(); float g = std::sqrt(data.accel.x * data.accel.x + data.accel.y * data.accel.y + data.accel.z * data.accel.z); if (g < 0.5f || g > 1.5f) return; // a shake, not a lean float k = std::min(1.0f, kImuEvery / kLeanSeconds); lean_ += k * (data.accel.x / g - lean_); pitch_ += k * (data.accel.y / g - pitch_); } void GamesApp::levelTilt() { for (int i = 0; i < 20 && !M5.Imu.update(); i++) delay(2); // a fresh reading auto data = M5.Imu.getImuData(); float g = std::sqrt(data.accel.x * data.accel.x + data.accel.y * data.accel.y + data.accel.z * data.accel.z); lean_ = level_ = g > 0.5f ? data.accel.x / g : 0; pitch_ = pitchLevel_ = g > 0.5f ? data.accel.y / g : 0; } bool GamesApp::pongInverted() { if (pongInverted_ < 0) { int32_t stored = 0; pongInverted_ = store_.getInt("pong_invert", stored) && stored == 1 ? 1 : 0; } return pongInverted_ == 1; } bool GamesApp::pongTilt() { if (pongTilt_ < 0) { int32_t stored = 0; pongTilt_ = store_.getInt("pong_tilt", stored) && stored == 1 ? 1 : 0; // the keys, until switched } return pongTilt_ == 1; } bool GamesApp::twenty48Key(const KeyEvent& e) { using Dir = games::Twenty48::Dir; uint8_t events = 0; switch (e.key) { case Key::Up: events = twenty48_->slide(Dir::Up); break; case Key::Down: events = twenty48_->slide(Dir::Down); break; case Key::Left: events = twenty48_->slide(Dir::Left); break; case Key::Right: events = twenty48_->slide(Dir::Right); break; default: break; } if (events & games::Twenty48::Over) ended(static_cast(twenty48_->score())); else if (events & games::Twenty48::Reached) beep(1320, 250); else if (events & games::Twenty48::Merged) beep(880, 10); return true; } 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) { float move = keys_.held(Key::Up) ? -1 : keys_.held(Key::Down) ? 1 : 0; if (!move && static_cast(tapUntilMs_ - nowMs) > 0) move = tapMove_; if (pongTilt()) { // tilted: the paddle rolls to the low side, the keys still win readTilt(); float pitch = pongInverted() ? 2 * pitchLevel_ - pitch_ : pitch_; // the other way up: mirrored round the middle if (!move) move = games::moveToward(pong_->playerY(), games::tiltTarget(pitch, pitchLevel_)); } 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 if (which_ == Mines) { mines_->tick(ms); // An arrow held runs the cursor along, after a moment: the field is thirty cells wide. static const Key kArrows[4] = {Key::Up, Key::Right, Key::Down, Key::Left}; int dir = -1; for (int i = 0; i < 4; i++) if (keys_.held(kArrows[i])) dir = i; if (dir != runDir_) { runDir_ = dir; runMs_ = 0; running_ = false; } else if (dir >= 0) { runMs_ += ms; uint32_t wait = running_ ? kRunEveryMs : kRunAfterMs; if (runMs_ >= wait) { runMs_ -= wait; running_ = true; moveCursor(dir == 1 ? 1 : dir == 3 ? -1 : 0, dir == 0 ? -1 : dir == 2 ? 1 : 0); requestRedraw(); } } if (mines_->seconds() != shownSeconds_) { shownSeconds_ = mines_->seconds(); requestRedraw(); } return; } else if (which_ == Twenty48) { return; // it only moves when a key says so } else if (which_ == Asteroids) { games::AsteroidsInput in; readTilt(); in.turn = kLeanSign * games::tiltTurn(lean_, level_); if (keys_.held(Key::Left)) in.turn = -1; // the arrows too, for a device on a table if (keys_.held(Key::Right)) in.turn = 1; in.thrust = keys_.held(Key::Up); in.fire = fired_; fired_ = false; uint8_t events = asteroids_->step(ms / 1000.0f, in); if (events & games::Asteroids::Over) ended(asteroids_->score()); else if (events & games::Asteroids::Died) beep(150, 300); else if (events & games::Asteroids::Wave) beep(990, 120); else if (events & games::Asteroids::Hit) beep(300, 30); else if (events & games::Asteroids::Shot) beep(1400, 8); } 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); case Mines: return keys::add(out, keys::kGamesMines); case Twenty48: return keys::add(out, keys::kGames2048); case Asteroids: return keys::add(out, keys::kGamesAsteroids); default: return keys::add(out, keys::kGames); } } const char* GamesApp::helpTitle() const { static const char* const kTitles[kGames] = {"Games: Pong", "Games: Snake", "Games: Tetris", "Games: Minesweeper", "Games: 2048", "Games: Asteroids"}; return which_ == Menu ? nullptr : kTitles[which_]; } // 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 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[kGames] = {icons::pong, icons::snake, icons::tetris, icons::mines, icons::twenty48, icons::asteroids}; const int tileW = 40, tileH = 36, x0 = (kContent.w - kGames * tileW) / 2, y = kContent.y + 24; // six fill the width for (int i = 0; i < kGames; 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 tw = std::min(icons::kSize + 12, tileW), tx = ix - (tw - icons::kSize) / 2; 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 || (i == Twenty48 && state_[i] == State::Playing)) { // a game is waiting 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" : w == Twenty48 && state_[chosen_] == State::Playing ? "waiting" : ""; if (b > 0) under += (under.empty() ? "" : ", ") + std::string(w == Pong ? "longest rally " : w == Mines ? "cleared in " : "best ") + std::to_string(b) + (w == Mines ? " s" : ""); 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); } c.setFont(&fonts::small); c.setTextColor(theme::kMuted); c.drawString(!pongTilt() ? "keys" : pongInverted() ? "tilt, inverted" : "tilt", 2, top + 2); 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"); } // Minesweeper: the field under a line that says how it stands. No box is drawn over the field when // the game ends: where the mines were is what one wants to see. void GamesApp::drawMines(Canvas& c) { using games::Mines; const auto& area = theme::kContent; State state = state_[GamesApp::Mines]; const int x0 = 0, y0 = area.y + area.h - Mines::kRows * kMineCell; static const uint16_t* const kDigit[8] = {&theme::kAccent, &theme::kGood, &theme::kRed, &theme::kViolet, &theme::kWarning, &theme::kMessage, &theme::kYellow, &theme::kText}; char line[40]; c.setFont(&fonts::body); c.setTextColor(theme::kMuted); int b = best(GamesApp::Mines); bool lost = mines_ && mines_->phase() == Mines::Phase::Lost, won = mines_ && mines_->phase() == Mines::Phase::Won; if (lost) { c.setTextColor(theme::kWarning); std::snprintf(line, sizeof line, "A mine. %lu s", (unsigned long)mines_->seconds()); } else if (won) { c.setTextColor(theme::kGood); if (newBest_) std::snprintf(line, sizeof line, "Cleared in %lu s: a new best", (unsigned long)mines_->seconds()); else std::snprintf(line, sizeof line, "Cleared in %lu s", (unsigned long)mines_->seconds()); } else std::snprintf(line, sizeof line, "%d mines", mines_ ? mines_->minesLeft() : Mines::kMines); c.drawString(line, 2, area.y + 1); c.setTextColor(theme::kMuted); c.setTextDatum(top_right); if (b > 0) std::snprintf(line, sizeof line, "%lu s best %d", (unsigned long)(mines_ ? mines_->seconds() : 0), b); else std::snprintf(line, sizeof line, "%lu s", (unsigned long)(mines_ ? mines_->seconds() : 0)); c.drawString(line, area.w - 2, area.y + 1); c.setTextDatum(top_left); c.setFont(&fonts::bold); c.setTextDatum(middle_center); for (int row = 0; row < Mines::kRows; row++) for (int col = 0; col < Mines::kCols; col++) { int x = x0 + col * kMineCell, y = y0 + row * kMineCell; bool open = mines_ && mines_->open(col, row), flagged = mines_ && mines_->flagged(col, row); bool mine = mines_ && mines_->mine(col, row); if (lost && mine && !flagged) { // where they were; the one dug is the bright one c.fillRect(x, y, kMineCell - 1, kMineCell - 1, open ? theme::kRed : theme::kFaint); c.fillCircle(x + kMineCell / 2 - 1, y + kMineCell / 2 - 1, 3, open ? theme::kText : theme::kRed); } else if (!open) { c.fillRect(x, y, kMineCell - 1, kMineCell - 1, theme::kFaint); if (flagged) { // a little flag on a pole; a wrong one goes pale when the game is lost uint16_t colour = lost && !mine ? theme::kMuted : theme::kWarning; c.fillTriangle(x + 4, y + 2, x + 4, y + 6, x + 8, y + 4, colour); c.drawFastVLine(x + 4, y + 2, 7, colour); } } else if (int n = mines_->around(col, row)) { c.setTextColor(*kDigit[n - 1]); c.drawString(std::to_string(n).c_str(), x + kMineCell / 2, y + kMineCell / 2); } } c.setTextDatum(top_left); if (state == State::Playing) c.drawRect(x0 + cursorCol_ * kMineCell - 1, y0 + cursorRow_ * kMineCell - 1, kMineCell + 1, kMineCell + 1, theme::kText); if (state == State::Ready) drawBanner(c, "Minesweeper", b > 0 ? "cleared in " + std::to_string(b) + " s" : std::to_string(Mines::kMines) + " mines"); else if (state == State::Paused) drawBanner(c, "Paused", ""); } // 2048: the board in the middle, the score beside it. void GamesApp::drawTwenty48(Canvas& c) { using games::Twenty48; const auto& area = theme::kContent; State state = state_[GamesApp::Twenty48]; const int tile = 27, gap = 3, size = 4 * tile + 5 * gap, x0 = (area.w - size) / 2, y0 = area.y; c.fillRect(x0, y0, size, size, theme::kBar); // A colour a power of two, round the theme's accents; the low ones are quiet. static const uint16_t* const kTile[] = {&theme::kFaint, &theme::kMuted, &theme::kAccent, &theme::kGood, &theme::kYellow, &theme::kWarning, &theme::kRed, &theme::kMessage, &theme::kViolet, &theme::kAccent, &theme::kText}; c.setTextDatum(middle_center); for (int row = 0; row < 4; row++) for (int col = 0; col < 4; col++) { int x = x0 + gap + col * (tile + gap), y = y0 + gap + row * (tile + gap); uint8_t p = twenty48_ ? twenty48_->power(col, row) : 0; if (!p) { c.fillRect(x, y, tile, tile, theme::kBackground); continue; } c.fillRect(x, y, tile, tile, *kTile[std::min(p, 11) - 1]); uint32_t v = twenty48_->value(col, row); c.setFont(v < 100 ? &fonts::bold : v < 10000 ? &fonts::medium : &fonts::small); c.setTextColor(p == 1 ? theme::kText : theme::kBackground); c.drawString(std::to_string(v).c_str(), x + tile / 2 + 1, y + tile / 2 + 1); } c.setTextDatum(top_left); c.setFont(&fonts::body); auto readout = [&](int x, int y, const char* label, uint32_t 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(6, area.y + 4, "Score", twenty48_ ? twenty48_->score() : 0); readout(x0 + size + 8, area.y + 4, "Best", static_cast(best(GamesApp::Twenty48))); c.setFont(&fonts::small); if (state == State::Over) { c.setTextColor(theme::kWarning); c.drawString("No move", 6, area.y + area.h - 20); c.drawString(newBest_ ? "left: a best" : "left", 6, area.y + area.h - 10); } else if (twenty48_ && twenty48_->reached()) { c.setTextColor(theme::kGood); c.drawString("2048!", 6, area.y + area.h - 10); } if (state == State::Ready) drawBanner(c, "2048", best(GamesApp::Twenty48) > 0 ? "best " + std::to_string(best(GamesApp::Twenty48)) : ""); } // Asteroids, in outlines: the rocks, the ship, the bullets, and a line of figures along the top. void GamesApp::drawAsteroids(Canvas& c) { using games::Asteroids; const auto& area = theme::kContent; State state = state_[GamesApp::Asteroids]; const int top = area.y; if (asteroids_) { for (auto& r : asteroids_->rocks()) { float radius = r.radius(); // A rock across an edge is drawn on both sides of it, as the field wraps round. for (float ox : {-Asteroids::kWidth, 0.0f, Asteroids::kWidth}) for (float oy : {-Asteroids::kHeight, 0.0f, Asteroids::kHeight}) { float x = r.x + ox, y = r.y + oy; if (x < -radius || x > Asteroids::kWidth + radius || y < -radius || y > Asteroids::kHeight + radius) continue; int px[8], py[8]; for (int k = 0; k < 8; k++) { float a = k * 0.785398f, d = radius * r.look[k] / 255.0f; px[k] = static_cast(std::lround(x + std::sin(a) * d)); py[k] = top + static_cast(std::lround(y - std::cos(a) * d)); } for (int k = 0; k < 8; k++) c.drawLine(px[k], py[k], px[(k + 1) % 8], py[(k + 1) % 8], theme::kText); } } for (auto& b : asteroids_->bullets()) c.fillRect(static_cast(b.x), top + static_cast(b.y), 2, 2, theme::kYellow); // The ship: a dart pointing where it goes. Shielded, it blinks. if (asteroids_->shipShown() && (!asteroids_->shielded() || (millis() / 120) % 2)) { float a = asteroids_->shipAngle(), s = std::sin(a), co = std::cos(a); auto at = [&](float dx, float dy, int& x, int& y) { x = static_cast(std::lround(asteroids_->shipX() + dx * co - dy * s)); y = top + static_cast(std::lround(asteroids_->shipY() + dx * s + dy * co)); }; int nx, ny, lx, ly, rx, ry, bx, by; at(0, -6, nx, ny); at(-4, 4, lx, ly); at(4, 4, rx, ry); at(0, 2, bx, by); c.drawLine(nx, ny, lx, ly, theme::kAccent); c.drawLine(nx, ny, rx, ry, theme::kAccent); c.drawLine(lx, ly, bx, by, theme::kAccent); c.drawLine(rx, ry, bx, by, theme::kAccent); if (asteroids_->thrusting() && (millis() / 60) % 2) { int fx, fy; at(0, 7, fx, fy); c.drawLine(bx, by, fx, fy, theme::kWarning); } } } c.setFont(&fonts::small); c.setTextColor(theme::kMuted); char line[32]; std::snprintf(line, sizeof line, "%d", asteroids_ ? asteroids_->score() : 0); c.drawString(line, 2, top + 1); c.setTextDatum(top_right); std::snprintf(line, sizeof line, "wave %d ships %d", asteroids_ ? asteroids_->wave() : 1, asteroids_ ? asteroids_->lives() : Asteroids::kLives); c.drawString(line, area.w - 2, top + 1); c.setTextDatum(top_left); int b = best(GamesApp::Asteroids); if (state == State::Ready) drawBanner(c, "Asteroids", b > 0 ? "best " + std::to_string(b) : "lean to turn"); else if (state == State::Paused) drawBanner(c, "Paused", ""); else if (state == State::Over) drawBanner(c, "Game over", newBest_ ? "a new best: " + std::to_string(asteroids_->score()) : std::to_string(asteroids_->score()) + ", best " + std::to_string(b)); } 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; case Mines: drawMines(c); break; case Twenty48: drawTwenty48(c); break; case Asteroids: drawAsteroids(c); break; default: drawMenu(c); break; } } } // namespace roro