Public Access
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
799 lines
35 KiB
C++
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
|