Files
cardboy/Firmware/sdk/apps/tetris_app.cpp
2025-10-10 16:03:23 +02:00

625 lines
19 KiB
C++

#include "cardboy/apps/tetris_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include "cardboy/sdk/display_spec.hpp"
#include "cardboy/sdk/input_state.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstdio>
#include <random>
#include <string>
#include <string_view>
#include <vector>
namespace apps {
namespace {
using cardboy::sdk::AppButtonEvent;
using cardboy::sdk::AppContext;
using cardboy::sdk::AppEvent;
using cardboy::sdk::AppEventType;
using cardboy::sdk::AppTimerHandle;
using cardboy::sdk::InputState;
constexpr char kTetrisAppName[] = "Tetris";
constexpr int kBoardWidth = 10;
constexpr int kBoardHeight = 20;
constexpr int kCellSize = 10;
constexpr std::array<int, 5> kLineScores = {0, 40, 100, 300, 1200};
struct BlockOffset {
int x = 0;
int y = 0;
};
struct Tetromino {
std::array<std::array<BlockOffset, 4>, 4> rotations{};
};
constexpr std::array<BlockOffset, 4> makeOffsets(std::initializer_list<BlockOffset> blocks) {
std::array<BlockOffset, 4> out{};
std::size_t idx = 0;
for (const auto& b: blocks) {
out[idx++] = b;
}
return out;
}
constexpr std::array<BlockOffset, 4> rotate(const std::array<BlockOffset, 4>& src) {
std::array<BlockOffset, 4> out{};
for (std::size_t i = 0; i < src.size(); ++i) {
out[i].x = -src[i].y;
out[i].y = src[i].x;
}
return out;
}
constexpr Tetromino makeTetromino(std::initializer_list<BlockOffset> baseBlocks) {
Tetromino tet{};
tet.rotations[0] = makeOffsets(baseBlocks);
for (int r = 1; r < 4; ++r)
tet.rotations[r] = rotate(tet.rotations[r - 1]);
return tet;
}
constexpr std::array<Tetromino, 7> kPieces = {{
makeTetromino({{-1, 0}, {0, 0}, {1, 0}, {2, 0}}), // I
makeTetromino({{-1, 0}, {0, 0}, {1, 0}, {1, 1}}), // J
makeTetromino({{-1, 1}, {-1, 0}, {0, 0}, {1, 0}}), // L
makeTetromino({{0, 0}, {1, 0}, {0, 1}, {1, 1}}), // O
makeTetromino({{-1, 0}, {0, 0}, {0, 1}, {1, 1}}), // S
makeTetromino({{-1, 0}, {0, 0}, {1, 0}, {0, 1}}), // T
makeTetromino({{-1, 1}, {0, 1}, {0, 0}, {1, 0}}), // Z
}};
class RandomBag {
public:
RandomBag() { refill(); }
void seed(std::uint32_t value) { rng.seed(value); }
int next() {
if (bag.empty())
refill();
int val = bag.back();
bag.pop_back();
return val;
}
private:
std::vector<int> bag;
std::mt19937 rng{std::random_device{}()};
void refill() {
bag.clear();
bag.reserve(7);
for (int i = 0; i < 7; ++i)
bag.push_back(i);
std::shuffle(bag.begin(), bag.end(), rng);
}
};
struct ActivePiece {
int type = 0;
int rotation = 0;
int x = 0;
int y = 0;
};
struct GameState {
std::array<int, kBoardWidth * kBoardHeight> board{};
ActivePiece current{};
int nextPiece = 0;
int level = 1;
int linesCleared = 0;
int score = 0;
int highScore = 0;
bool paused = false;
bool gameOver = false;
};
[[nodiscard]] std::uint32_t randomSeed(AppContext& ctx) {
if (auto* rnd = ctx.random())
return rnd->nextUint32();
static std::random_device rd;
return rd();
}
class TetrisGame {
public:
explicit TetrisGame(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {
bag.seed(randomSeed(context));
loadHighScore();
reset();
}
void onStart() {
scheduleDropTimer();
dirty = true;
renderIfNeeded();
}
void onStop() { cancelTimers(); }
void handleEvent(const AppEvent& event) {
switch (event.type) {
case AppEventType::Button:
handleButtons(event.button);
break;
case AppEventType::Timer:
handleTimer(event.timer.handle);
break;
}
renderIfNeeded();
}
private:
AppContext& context;
typename AppContext::Framebuffer& framebuffer;
GameState state;
RandomBag bag;
InputState lastInput{};
bool dirty = false;
AppTimerHandle dropTimer = cardboy::sdk::kInvalidAppTimer;
AppTimerHandle softTimer = cardboy::sdk::kInvalidAppTimer;
void reset() {
cancelTimers();
int oldHigh = state.highScore;
state = {};
state.highScore = oldHigh;
state.current.type = bag.next();
state.nextPiece = bag.next();
state.current.x = kBoardWidth / 2;
state.current.y = 0;
state.level = 1;
state.gameOver = false;
state.paused = false;
dirty = true;
scheduleDropTimer();
if (auto* power = context.powerManager())
power->setSlowMode(false);
}
void handleButtons(const AppButtonEvent& evt) {
const auto& cur = evt.current;
const auto& prev = evt.previous;
lastInput = cur;
if (cur.b && !prev.b) {
context.requestAppSwitchByName(kMenuAppName);
return;
}
if (cur.start && !prev.start) {
if (state.gameOver) {
reset();
} else {
state.paused = !state.paused;
if (auto* power = context.powerManager())
power->setSlowMode(state.paused);
}
dirty = true;
}
if (state.paused || state.gameOver)
return;
if (cur.left && !prev.left)
tryMove(-1, 0);
if (cur.right && !prev.right)
tryMove(1, 0);
if (cur.a && !prev.a)
rotate(1);
if (cur.select && !prev.select)
hardDrop();
if (cur.down && !prev.down) {
softDropStep();
scheduleSoftDropTimer();
} else if (!cur.down && prev.down) {
cancelSoftDropTimer();
}
}
void handleTimer(AppTimerHandle handle) {
if (handle == dropTimer) {
if (!state.paused && !state.gameOver)
gravityStep();
} else if (handle == softTimer) {
if (lastInput.down && !state.paused && !state.gameOver)
softDropStep();
else
cancelSoftDropTimer();
}
}
void cancelTimers() {
if (dropTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(dropTimer);
dropTimer = cardboy::sdk::kInvalidAppTimer;
}
cancelSoftDropTimer();
}
void cancelSoftDropTimer() {
if (softTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(softTimer);
softTimer = cardboy::sdk::kInvalidAppTimer;
}
}
void scheduleDropTimer() {
cancelDropTimer();
const std::uint32_t interval = dropIntervalMs();
dropTimer = context.scheduleRepeatingTimer(interval);
}
void cancelDropTimer() {
if (dropTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(dropTimer);
dropTimer = cardboy::sdk::kInvalidAppTimer;
}
}
void scheduleSoftDropTimer() {
cancelSoftDropTimer();
softTimer = context.scheduleRepeatingTimer(60);
}
[[nodiscard]] std::uint32_t dropIntervalMs() const {
const int base = 700;
const int step = 50;
int interval = base - (state.level - 1) * step;
if (interval < 120)
interval = 120;
return static_cast<std::uint32_t>(interval);
}
[[nodiscard]] const Tetromino& currentPiece() const { return kPieces[state.current.type]; }
bool canPlace(int nx, int ny, int rot) const {
const auto& piece = kPieces[state.current.type];
rot = ((rot % 4) + 4) % 4;
for (const auto& block: piece.rotations[rot]) {
int gx = nx + block.x;
int gy = ny + block.y;
if (gx < 0 || gx >= kBoardWidth)
return false;
if (gy >= kBoardHeight)
return false;
if (gy >= 0 && cellAt(gx, gy) != 0)
return false;
}
return true;
}
[[nodiscard]] int cellAt(int x, int y) const { return state.board[y * kBoardWidth + x]; }
void setCell(int x, int y, int value) { state.board[y * kBoardWidth + x] = value; }
void tryMove(int dx, int dy) {
int nx = state.current.x + dx;
int ny = state.current.y + dy;
if (canPlace(nx, ny, state.current.rotation)) {
state.current.x = nx;
state.current.y = ny;
dirty = true;
if (dx != 0) {
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
}
}
}
void rotate(int direction) {
int nextRot = state.current.rotation + (direction >= 0 ? 1 : -1);
nextRot = ((nextRot % 4) + 4) % 4;
if (canPlace(state.current.x, state.current.y, nextRot)) {
state.current.rotation = nextRot;
dirty = true;
if (auto* buzzer = context.buzzer())
buzzer->beepRotate();
}
}
void gravityStep() {
if (!canPlace(state.current.x, state.current.y + 1, state.current.rotation)) {
lockPiece();
} else {
state.current.y++;
dirty = true;
}
}
void softDropStep() {
if (canPlace(state.current.x, state.current.y + 1, state.current.rotation)) {
state.current.y++;
state.score += 1;
updateHighScore();
dirty = true;
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
} else {
lockPiece();
}
}
void hardDrop() {
int distance = 0;
while (canPlace(state.current.x, state.current.y + distance + 1, state.current.rotation))
++distance;
if (distance > 0) {
state.current.y += distance;
state.score += distance * 2;
updateHighScore();
dirty = true;
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
}
lockPiece();
}
void lockPiece() {
for (const auto& block: currentPiece().rotations[state.current.rotation]) {
int gx = state.current.x + block.x;
int gy = state.current.y + block.y;
if (gy >= 0 && gy < kBoardHeight && gx >= 0 && gx < kBoardWidth)
setCell(gx, gy, state.current.type + 1);
if (gy < 0)
state.gameOver = true;
}
handleLineClear();
spawnNext();
dirty = true;
if (state.gameOver) {
cancelSoftDropTimer();
cancelDropTimer();
if (auto* buzzer = context.buzzer())
buzzer->beepGameOver();
if (auto* power = context.powerManager())
power->setSlowMode(true);
} else {
if (auto* buzzer = context.buzzer())
buzzer->beepLock();
}
}
void handleLineClear() {
int cleared = 0;
for (int y = kBoardHeight - 1; y >= 0; --y) {
bool full = true;
for (int x = 0; x < kBoardWidth; ++x) {
if (cellAt(x, y) == 0) {
full = false;
break;
}
}
if (full) {
++cleared;
for (int pull = y; pull > 0; --pull)
for (int x = 0; x < kBoardWidth; ++x)
setCell(x, pull, cellAt(x, pull - 1));
for (int x = 0; x < kBoardWidth; ++x)
setCell(x, 0, 0);
++y; // re-check same row after collapse
}
}
if (cleared > 0) {
state.linesCleared += cleared;
if (cleared < static_cast<int>(kLineScores.size()))
state.score += kLineScores[cleared] * state.level;
else
state.score += kLineScores.back() * state.level;
int newLevel = 1 + state.linesCleared / 10;
if (newLevel != state.level) {
state.level = newLevel;
scheduleDropTimer();
if (auto* buzzer = context.buzzer())
buzzer->beepLevelUp(state.level);
}
updateHighScore();
if (auto* buzzer = context.buzzer())
buzzer->beepLines(cleared);
}
}
void spawnNext() {
state.current.type = state.nextPiece;
state.current.rotation = 0;
state.current.x = kBoardWidth / 2;
state.current.y = 0;
state.nextPiece = bag.next();
if (!canPlace(state.current.x, state.current.y, state.current.rotation))
state.gameOver = true;
}
void updateHighScore() {
if (state.score > state.highScore) {
state.highScore = state.score;
if (auto* storage = context.storage())
storage->writeUint32("tetris", "best", static_cast<std::uint32_t>(state.highScore));
}
}
void loadHighScore() {
if (auto* storage = context.storage()) {
std::uint32_t stored = 0;
if (storage->readUint32("tetris", "best", stored))
state.highScore = static_cast<int>(stored);
}
}
void renderIfNeeded() {
if (!dirty)
return;
dirty = false;
framebuffer.beginFrame();
framebuffer.clear(false);
drawBoard();
drawActivePiece();
drawNextPreview();
drawHUD();
framebuffer.endFrame();
}
void drawBoard() {
const int originX = (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2;
const int originY = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2;
for (int y = 0; y < kBoardHeight; ++y) {
for (int x = 0; x < kBoardWidth; ++x) {
if (int value = cellAt(x, y); value != 0)
drawCell(originX, originY, x, y, value, true);
}
}
drawGuides(originX, originY);
}
void drawActivePiece() {
if (state.gameOver)
return;
const int originX = (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2;
const int originY = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2;
for (const auto& block: currentPiece().rotations[state.current.rotation]) {
int gx = state.current.x + block.x;
int gy = state.current.y + block.y;
if (gy < 0)
continue;
drawCell(originX, originY, gx, gy, state.current.type + 1, false);
}
}
void drawCell(int originX, int originY, int cx, int cy, int value, bool solid) {
const int x0 = originX + cx * kCellSize;
const int y0 = originY + cy * kCellSize;
for (int dy = 0; dy < kCellSize; ++dy) {
for (int dx = 0; dx < kCellSize; ++dx) {
bool on = solid ? true : (dx == 0 || dx == kCellSize - 1 || dy == 0 || dy == kCellSize - 1);
framebuffer.drawPixel(x0 + dx, y0 + dy, on);
}
}
(void) value; // value currently unused (monochrome display)
}
void drawGuides(int originX, int originY) {
for (int y = 0; y <= kBoardHeight; ++y) {
const int py = originY + y * kCellSize;
for (int x = 0; x < kBoardWidth * kCellSize; ++x)
framebuffer.drawPixel(originX + x, py, (y % 5) == 0);
}
for (int x = 0; x <= kBoardWidth; ++x) {
const int px = originX + x * kCellSize;
for (int y = 0; y < kBoardHeight * kCellSize; ++y)
framebuffer.drawPixel(px, originY + y, (x % 5) == 0);
}
}
void drawNextPreview() {
const int blockSize = kCellSize;
const int boxSize = blockSize * 4;
const int originX = (cardboy::sdk::kDisplayWidth + kBoardWidth * kCellSize) / 2 + 24;
const int originY = (cardboy::sdk::kDisplayHeight - boxSize) / 2;
for (int dy = 0; dy < boxSize; ++dy)
for (int dx = 0; dx < boxSize; ++dx)
framebuffer.drawPixel(originX + dx, originY + dy, (dy == 0 || dy == boxSize - 1 || dx == 0 || dx == boxSize - 1));
const auto& piece = kPieces[state.nextPiece];
for (const auto& block: piece.rotations[0]) {
const int px = originX + (block.x + 1) * blockSize;
const int py = originY + (block.y + 1) * blockSize;
for (int dy = 1; dy < blockSize - 1; ++dy)
for (int dx = 1; dx < blockSize - 1; ++dx)
framebuffer.drawPixel(px + dx, py + dy, true);
}
}
void drawLabel(int x, int y, std::string_view text, int scale = 1) {
font16x8::drawText(framebuffer, x, y, text, scale, true, 1);
}
void drawHUD() {
const int margin = 16;
drawLabel(margin, margin, "SCORE", 1);
drawLabel(margin, margin + 16, std::to_string(state.score), 1);
drawLabel(margin, margin + 40, "BEST", 1);
drawLabel(margin, margin + 56, std::to_string(state.highScore), 1);
drawLabel(margin, margin + 80, "LEVEL", 1);
drawLabel(margin, margin + 96, std::to_string(state.level), 1);
if (auto* battery = context.battery(); battery && battery->hasData()) {
char line[32];
std::snprintf(line, sizeof(line), "BAT %.2fV", battery->voltage());
drawLabel(margin, margin + 120, line, 1);
}
drawLabel(margin, cardboy::sdk::kDisplayHeight - 48, "A ROTATE", 1);
drawLabel(margin, cardboy::sdk::kDisplayHeight - 32, "DOWN DROP", 1);
drawLabel(margin, cardboy::sdk::kDisplayHeight - 16, "B MENU", 1);
if (state.paused)
drawCenteredBanner("PAUSED");
else if (state.gameOver)
drawCenteredBanner("GAME OVER");
}
void drawCenteredBanner(std::string_view text) {
const int w = font16x8::measureText(text, 2, 1);
const int h = font16x8::kGlyphHeight * 2;
const int x = (cardboy::sdk::kDisplayWidth - w) / 2;
const int y = (cardboy::sdk::kDisplayHeight - h) / 2;
for (int yy = -4; yy < h + 4; ++yy)
for (int xx = -6; xx < w + 6; ++xx)
framebuffer.drawPixel(x + xx, y + yy, yy == -4 || yy == h + 3 || xx == -6 || xx == w + 5);
font16x8::drawText(framebuffer, x, y, text, 2, true, 1);
}
};
class TetrisApp final : public cardboy::sdk::IApp {
public:
explicit TetrisApp(AppContext& ctx) : game(ctx) {}
void onStart() override { game.onStart(); }
void onStop() override { game.onStop(); }
void handleEvent(const AppEvent& event) override { game.handleEvent(event); }
private:
TetrisGame game;
};
class TetrisFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kTetrisAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(AppContext& context) override {
return std::make_unique<TetrisApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createTetrisAppFactory() {
return std::make_unique<TetrisFactory>();
}
} // namespace apps