mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 23:27:49 +01:00
625 lines
19 KiB
C++
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
|