Files
roro9stack/src/apps/games_app.cpp
T
twislaandClaude Opus 5.5 77260af157
CI / build (pull_request) Successful in 3m2s
Site / build (pull_request) Successful in 12s
Games App: Asteroids, and Pong by tilting
Asteroids is the sixth Game: lean the device to turn, ; for thrust, .
to fire, the arrows too for a device on a table. In Pong, 0 switches
between the keys and tilting and 9 turns the tilt round, both
remembered. Both read the accelerometer through the same smoothing, and
take however the device is held at the start as the middle.

Played on the device: the default way of Pong's tilt was swapped after
trying it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-15 12:06:29 +02:00

799 lines
35 KiB
C++

#include "apps/games_app.h"
#include <Arduino.h>
#include <M5Cardputer.h>
#include <M5Unified.h>
#include <esp_random.h>
#include <algorithm>
#include <cstdio>
#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<uint8_t>(masterWas_)); // times a master of 255: as before
speaker.setChannelVolume(kTuneChannel, kStepVolume[volume() - 1]);
speaker.setVolume(255);
} else {
speaker.setVolume(static_cast<uint8_t>(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<Which>(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<int>(1, static_cast<int>(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<int>(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<uint32_t>(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<int32_t>(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<KeyHelp>& 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<Which>(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<int>(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<int>(Pong::kPaddleH), pw = static_cast<int>(Pong::kPaddleW), m = static_cast<int>(Pong::kMargin);
c.fillRect(m, top + static_cast<int>(pong_->playerY()) - ph / 2, pw, ph, theme::kAccent);
c.fillRect(area.w - m - pw, top + static_cast<int>(pong_->deviceY()) - ph / 2, pw, ph, theme::kWarning);
const int b = static_cast<int>(Pong::kBall);
c.fillRect(static_cast<int>(pong_->ballX()) - b / 2, top + static_cast<int>(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<int>(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<uint32_t>(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<int>(std::lround(x + std::sin(a) * d));
py[k] = top + static_cast<int>(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<int>(b.x), top + static_cast<int>(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<int>(std::lround(asteroids_->shipX() + dx * co - dy * s));
y = top + static_cast<int>(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